mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 04:15:25 +00:00
enh: folders
This commit is contained in:
parent
3251f8b14d
commit
37c2fb0aa8
11 changed files with 361 additions and 50 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
103
src/lib/components/chat/Placeholder/ChatList.svelte
Normal file
103
src/lib/components/chat/Placeholder/ChatList.svelte
Normal 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}
|
||||
51
src/lib/components/chat/Placeholder/FolderPlaceholder.svelte
Normal file
51
src/lib/components/chat/Placeholder/FolderPlaceholder.svelte
Normal 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>
|
||||
147
src/lib/components/chat/Placeholder/FolderTitle.svelte
Normal file
147
src/lib/components/chat/Placeholder/FolderTitle.svelte
Normal 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}
|
||||
|
|
@ -363,9 +363,7 @@
|
|||
});
|
||||
|
||||
chats.subscribe((value) => {
|
||||
if ($selectedFolder) {
|
||||
initFolders();
|
||||
}
|
||||
});
|
||||
|
||||
await initChannels();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue