enh: folders

This commit is contained in:
Timothy Jaeryang Baek 2025-07-19 14:29:08 +04:00
parent 3251f8b14d
commit 37c2fb0aa8
11 changed files with 361 additions and 50 deletions

View file

@ -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
)

View file

@ -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}

View file

@ -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>
/>
{: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,6 +243,15 @@
</div>
</div>
</div>
{#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>
{:else}
<div class="mx-auto max-w-2xl font-primary mt-2" in:fade={{ duration: 200, delay: 200 }}>
<div class="mx-5">
<Suggestions
@ -261,4 +264,5 @@
/>
</div>
</div>
{/if}
</div>

View file

@ -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}

View file

@ -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>

View file

@ -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}

View file

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

View file

@ -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

View file

@ -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">
{#if edit}
{$i18n.t('Edit Folder')}
{:else}
{$i18n.t('Create Folder')}
{/if}
</div>
<button
class="self-center"

View file

@ -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;