1
0
Timothy Jaeryang Baek 2 сар өмнө
parent
commit
37c2fb0aa8

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

@@ -49,7 +49,7 @@ async def get_folders(user=Depends(get_verified_user)):
             **folder.model_dump(),
             "items": {
                 "chats": [
-                    {"title": chat.title, "id": chat.id}
+                    {"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
                     for chat in Chats.get_chats_by_folder_id_and_user_id(
                         folder.id, user.id
                     )

+ 3 - 3
src/lib/components/chat/Chat.svelte

@@ -2113,8 +2113,8 @@
 						showBanners={!showCommands}
 					/>
 
-					<div class="flex flex-col flex-auto z-10 w-full @container">
-						{#if $settings?.landingPageMode === 'chat' || createMessagesList(history, history.currentId).length > 0}
+					<div class="flex flex-col flex-auto z-10 w-full @container overflow-auto">
+						{#if ($settings?.landingPageMode === 'chat' && !$selectedFolder) || createMessagesList(history, history.currentId).length > 0}
 							<div
 								class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full z-10 scrollbar-hidden"
 								id="messages-container"
@@ -2212,7 +2212,7 @@
 								</div>
 							</div>
 						{:else}
-							<div class="overflow-auto w-full h-full flex items-center">
+							<div class="flex items-center h-full">
 								<Placeholder
 									{history}
 									{selectedModels}

+ 40 - 36
src/lib/components/chat/Placeholder.svelte

@@ -12,7 +12,9 @@
 		user,
 		models as _models,
 		temporaryChatEnabled,
-		selectedFolder
+		selectedFolder,
+		chats,
+		currentChatPage
 	} from '$lib/stores';
 	import { sanitizeResponseContent, extractCurlyBraceWords } from '$lib/utils';
 	import { WEBUI_BASE_URL } from '$lib/constants';
@@ -21,9 +23,9 @@
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
 	import MessageInput from './MessageInput.svelte';
-	import FolderOpen from '../icons/FolderOpen.svelte';
-	import XMark from '../icons/XMark.svelte';
-	import Folder from '../icons/Folder.svelte';
+	import FolderPlaceholder from './Placeholder/FolderPlaceholder.svelte';
+	import FolderTitle from './Placeholder/FolderTitle.svelte';
+	import { getChatList } from '$lib/apis/chats';
 
 	const i18n = getContext('i18n');
 
@@ -87,29 +89,21 @@
 	>
 		<div class="w-full flex flex-col justify-center items-center">
 			{#if $selectedFolder}
-				<div class="mb-3 px-4 justify-center w-fit flex relative group">
-					<div class="text-center flex gap-3.5 items-center">
-						<div class=" rounded-full bg-gray-50 dark:bg-gray-800 p-3 w-fit">
-							<Folder className="size-4.5" strokeWidth="2" />
-						</div>
+				<FolderTitle
+					folder={$selectedFolder}
+					onUpdate={async (folder) => {
+						selectedFolder.set(folder);
 
-						<div class="text-3xl">
-							{$selectedFolder?.name}
-						</div>
-					</div>
+						await chats.set(await getChatList(localStorage.token, $currentChatPage));
+						currentChatPage.set(1);
+					}}
+					onDelete={async () => {
+						await chats.set(await getChatList(localStorage.token, $currentChatPage));
+						currentChatPage.set(1);
 
-					<div class="absolute -right-3">
-						<button
-							class="group-hover:visible invisible rounded-md"
-							type="button"
-							on:click={() => {
-								selectedFolder.set(null);
-							}}
-						>
-							<XMark className="size-4" />
-						</button>
-					</div>
-				</div>
+						selectedFolder.set(null);
+					}}
+				/>
 			{:else}
 				<div class="flex flex-row justify-center gap-3 @sm:gap-3.5 w-fit px-5 max-w-xl">
 					<div class="flex shrink-0 justify-center">
@@ -249,16 +243,26 @@
 			</div>
 		</div>
 	</div>
-	<div class="mx-auto max-w-2xl font-primary mt-2" in:fade={{ duration: 200, delay: 200 }}>
-		<div class="mx-5">
-			<Suggestions
-				suggestionPrompts={atSelectedModel?.info?.meta?.suggestion_prompts ??
-					models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
-					$config?.default_prompt_suggestions ??
-					[]}
-				inputValue={prompt}
-				{onSelect}
-			/>
+
+	{#if $selectedFolder}
+		<div
+			class="mx-auto px-4 md:max-w-3xl md:px-6 font-primary min-h-62"
+			in:fade={{ duration: 200, delay: 200 }}
+		>
+			<FolderPlaceholder folder={$selectedFolder} />
 		</div>
-	</div>
+	{:else}
+		<div class="mx-auto max-w-2xl font-primary mt-2" in:fade={{ duration: 200, delay: 200 }}>
+			<div class="mx-5">
+				<Suggestions
+					suggestionPrompts={atSelectedModel?.info?.meta?.suggestion_prompts ??
+						models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
+						$config?.default_prompt_suggestions ??
+						[]}
+					inputValue={prompt}
+					{onSelect}
+				/>
+			</div>
+		</div>
+	{/if}
 </div>

+ 103 - 0
src/lib/components/chat/Placeholder/ChatList.svelte

@@ -0,0 +1,103 @@
+<script lang="ts">
+	import { getContext, onMount } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import dayjs from 'dayjs';
+	import localizedFormat from 'dayjs/plugin/localizedFormat';
+	import { getTimeRange } from '$lib/utils';
+
+	dayjs.extend(localizedFormat);
+
+	export let chats = [];
+
+	let chatList = null;
+
+	const init = async () => {
+		if (chats.length === 0) {
+			chatList = [];
+		} else {
+			chatList = chats.map((chat) => ({
+				...chat,
+				time_range: getTimeRange(chat.updated_at)
+			}));
+		}
+	};
+
+	$: if (chats) {
+		init();
+	}
+</script>
+
+{#if chatList}
+	<div class="text-left text-sm w-full mb-3">
+		{#if chatList.length === 0}
+			<div
+				class="text-xs text-gray-500 dark:text-gray-400 text-center px-5 min-h-20 w-full h-full flex justify-center items-center"
+			>
+				{$i18n.t('No chats found')}
+			</div>
+		{/if}
+
+		{#each chatList as chat, idx (chat.id)}
+			{#if (idx === 0 || (idx > 0 && chat.time_range !== chatList[idx - 1].time_range)) && chat?.time_range}
+				<div
+					class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium {idx === 0
+						? ''
+						: 'pt-5'} pb-2 px-2"
+				>
+					{$i18n.t(chat.time_range)}
+					<!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
+							{$i18n.t('Today')}
+							{$i18n.t('Yesterday')}
+							{$i18n.t('Previous 7 days')}
+							{$i18n.t('Previous 30 days')}
+							{$i18n.t('January')}
+							{$i18n.t('February')}
+							{$i18n.t('March')}
+							{$i18n.t('April')}
+							{$i18n.t('May')}
+							{$i18n.t('June')}
+							{$i18n.t('July')}
+							{$i18n.t('August')}
+							{$i18n.t('September')}
+							{$i18n.t('October')}
+							{$i18n.t('November')}
+							{$i18n.t('December')}
+							-->
+				</div>
+			{/if}
+
+			<a
+				class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850"
+				draggable="false"
+				href={`/c/${chat.id}`}
+				on:click={() => (show = false)}
+			>
+				<div class="text-ellipsis line-clamp-1 w-full sm:basis-3/5">
+					{chat?.title}
+				</div>
+
+				<div class="hidden sm:flex sm:basis-2/5 items-center justify-end">
+					<div class=" text-gray-500 dark:text-gray-400 text-xs">
+						{dayjs(chat?.updated_at * 1000).calendar()}
+					</div>
+				</div>
+			</a>
+		{/each}
+
+		<!-- {#if !allChatsLoaded && loadHandler}
+		<Loader
+			on:visible={(e) => {
+				if (!chatListLoading) {
+					loadHandler();
+				}
+			}}
+		>
+			<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
+				<Spinner className=" size-4" />
+				<div class=" ">Loading...</div>
+			</div>
+		</Loader>
+	{/if} -->
+	</div>
+{/if}

+ 0 - 0
src/lib/components/chat/Placeholder/FolderKnowledge.svelte


+ 51 - 0
src/lib/components/chat/Placeholder/FolderPlaceholder.svelte

@@ -0,0 +1,51 @@
+<script>
+	import { getContext } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import { fade } from 'svelte/transition';
+
+	import ChatList from './ChatList.svelte';
+	import FolderKnowledge from './FolderKnowledge.svelte';
+
+	export let folder = null;
+
+	let selectedTab = 'chats';
+</script>
+
+<div>
+	<!-- <div class="mb-1">
+		<div
+			class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent py-1 touch-auto pointer-events-auto"
+		>
+			<button
+				class="min-w-fit p-1.5 {selectedTab === 'knowledge'
+					? ''
+					: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
+				type="button"
+				on:click={() => {
+					selectedTab = 'knowledge';
+				}}>{$i18n.t('Knowledge')}</button
+			>
+
+			<button
+				class="min-w-fit p-1.5 {selectedTab === 'chats'
+					? ''
+					: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
+				type="button"
+				on:click={() => {
+					selectedTab = 'chats';
+				}}
+			>
+				{$i18n.t('Chats')}
+			</button>
+		</div>
+	</div> -->
+
+	<div class="">
+		{#if selectedTab === 'knowledge'}
+			<FolderKnowledge />
+		{:else if selectedTab === 'chats'}
+			<ChatList chats={folder?.items?.chats ?? []} />
+		{/if}
+	</div>
+</div>

+ 147 - 0
src/lib/components/chat/Placeholder/FolderTitle.svelte

@@ -0,0 +1,147 @@
+<script lang="ts">
+	import { getContext } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import DOMPurify from 'dompurify';
+
+	import fileSaver from 'file-saver';
+	const { saveAs } = fileSaver;
+
+	import { toast } from 'svelte-sonner';
+
+	import { selectedFolder } from '$lib/stores';
+
+	import { deleteFolderById, updateFolderById } from '$lib/apis/folders';
+	import { getChatsByFolderId } from '$lib/apis/chats';
+
+	import FolderModal from '$lib/components/layout/Sidebar/Folders/FolderModal.svelte';
+
+	import Folder from '$lib/components/icons/Folder.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
+	import FolderMenu from '$lib/components/layout/Sidebar/Folders/FolderMenu.svelte';
+	import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
+	import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+
+	export let folder = null;
+
+	export let onUpdate: Function = (folderId) => {};
+	export let onDelete: Function = (folderId) => {};
+
+	let showFolderModal = false;
+	let showDeleteConfirm = false;
+
+	const updateHandler = async ({ name, data }) => {
+		if (name === '') {
+			toast.error($i18n.t('Folder name cannot be empty.'));
+			return;
+		}
+
+		const currentName = folder.name;
+
+		name = name.trim();
+		folder.name = name;
+
+		const res = await updateFolderById(localStorage.token, folder.id, {
+			name,
+			...(data ? { data } : {})
+		}).catch((error) => {
+			toast.error(`${error}`);
+
+			folder.name = currentName;
+			return null;
+		});
+
+		if (res) {
+			folder.name = name;
+			if (data) {
+				folder.data = data;
+			}
+
+			toast.success($i18n.t('Folder updated successfully'));
+			selectedFolder.set(folder);
+			onUpdate(folder);
+		}
+	};
+
+	const deleteHandler = async () => {
+		const res = await deleteFolderById(localStorage.token, folder.id).catch((error) => {
+			toast.error(`${error}`);
+			return null;
+		});
+
+		if (res) {
+			toast.success($i18n.t('Folder deleted successfully'));
+			onDelete(folder);
+		}
+	};
+
+	const exportHandler = async () => {
+		const chats = await getChatsByFolderId(localStorage.token, folder.id).catch((error) => {
+			toast.error(`${error}`);
+			return null;
+		});
+		if (!chats) {
+			return;
+		}
+
+		const blob = new Blob([JSON.stringify(chats)], {
+			type: 'application/json'
+		});
+
+		saveAs(blob, `folder-${folder.name}-export-${Date.now()}.json`);
+	};
+</script>
+
+{#if folder}
+	<FolderModal bind:show={showFolderModal} edit={true} {folder} onSubmit={updateHandler} />
+
+	<DeleteConfirmDialog
+		bind:show={showDeleteConfirm}
+		title={$i18n.t('Delete folder?')}
+		on:confirm={() => {
+			deleteHandler();
+		}}
+	>
+		<div class=" text-sm text-gray-700 dark:text-gray-300 flex-1 line-clamp-3">
+			{@html DOMPurify.sanitize(
+				$i18n.t(
+					'This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.',
+					{
+						NAME: folder.name
+					}
+				)
+			)}
+		</div>
+	</DeleteConfirmDialog>
+
+	<div class="mb-3 px-6 @md:max-w-3xl justify-between w-full flex relative group items-center">
+		<div class="text-center flex gap-3.5 items-center">
+			<div class=" rounded-full bg-gray-50 dark:bg-gray-800 p-3 w-fit">
+				<Folder className="size-4.5" strokeWidth="2" />
+			</div>
+
+			<div class="text-3xl">
+				{folder.name}
+			</div>
+		</div>
+
+		<div class="flex items-center">
+			<FolderMenu
+				align="end"
+				onEdit={() => {
+					showFolderModal = true;
+				}}
+				onDelete={() => {
+					showDeleteConfirm = true;
+				}}
+				onExport={() => {
+					exportHandler();
+				}}
+			>
+				<button class="p-1.5 dark:hover:bg-gray-850 rounded-full touch-auto" on:click={(e) => {}}>
+					<EllipsisHorizontal className="size-4" strokeWidth="2.5" />
+				</button>
+			</FolderMenu>
+		</div>
+	</div>
+{/if}

+ 1 - 3
src/lib/components/layout/Sidebar.svelte

@@ -363,9 +363,7 @@
 		});
 
 		chats.subscribe((value) => {
-			if ($selectedFolder) {
-				initFolders();
-			}
+			initFolders();
 		});
 
 		await initChannels();

+ 2 - 1
src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte

@@ -12,6 +12,7 @@
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import Download from '$lib/components/icons/Download.svelte';
 
+	export let align: 'start' | 'end' = 'start';
 	export let onEdit = () => {};
 	export let onExport = () => {};
 	export let onDelete = () => {};
@@ -36,7 +37,7 @@
 			class="w-full max-w-[170px] rounded-lg px-1 py-1.5  z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
 			sideOffset={-2}
 			side="bottom"
-			align="start"
+			{align}
 			transition={flyAndScale}
 		>
 			<DropdownMenu.Item

+ 7 - 1
src/lib/components/layout/Sidebar/Folders/EditFolderModal.svelte → src/lib/components/layout/Sidebar/Folders/FolderModal.svelte

@@ -16,6 +16,8 @@
 	export let show = false;
 	export let onSubmit: Function = (e) => {};
 
+	export let edit = false;
+
 	export let folder = null;
 
 	let name = '';
@@ -53,7 +55,11 @@
 	<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')}
+				{#if edit}
+					{$i18n.t('Edit Folder')}
+				{:else}
+					{$i18n.t('Create Folder')}
+				{/if}
 			</div>
 			<button
 				class="self-center"

+ 6 - 5
src/lib/components/layout/Sidebar/RecursiveFolder.svelte

@@ -36,7 +36,7 @@
 	import ChatItem from './ChatItem.svelte';
 	import FolderMenu from './Folders/FolderMenu.svelte';
 	import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
-	import EditFolderModal from './Folders/EditFolderModal.svelte';
+	import FolderModal from './Folders/FolderModal.svelte';
 	import { goto } from '$app/navigation';
 
 	export let open = false;
@@ -53,7 +53,7 @@
 
 	let folderElement;
 
-	let showEditFolderModal = false;
+	let showFolderModal = false;
 	let edit = false;
 
 	let draggedOver = false;
@@ -378,8 +378,9 @@
 	</div>
 </DeleteConfirmDialog>
 
-<EditFolderModal
-	bind:show={showEditFolderModal}
+<FolderModal
+	bind:show={showFolderModal}
+	edit={true}
 	folder={folders[folderId]}
 	onSubmit={updateHandler}
 />
@@ -482,7 +483,7 @@
 				>
 					<FolderMenu
 						onEdit={() => {
-							showEditFolderModal = true;
+							showFolderModal = true;
 						}}
 						onDelete={() => {
 							showDeleteConfirm = true;