This commit is contained in:
Timothy Jaeryang Baek
2026-06-15 23:42:29 +02:00
parent c783fd30f2
commit 45fcf272ef
11 changed files with 474 additions and 66 deletions
+86
View File
@@ -234,3 +234,89 @@ export const deleteFolderById = async (token: string, id: string, deleteContents
return res;
};
export const updateFolderAccessById = async (
token: string,
id: string,
accessGrants: any[]
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${id}/access/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ access_grants: accessGrants })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
export const getSharedFolders = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/shared`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return [];
});
if (error) {
throw error;
}
return res;
};
export const getSharedFolderChats = async (token: string, folderId: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
return null;
});
if (error) {
throw error;
}
return res;
};
+15 -1
View File
@@ -169,6 +169,9 @@
let chat = null;
let tags = [];
// Read-only when viewing someone else's chat (e.g. via shared folder access)
$: readOnly = chat != null && chat.user_id !== $user?.id;
let chatTasks = [];
let history = {
@@ -3103,6 +3106,7 @@
<Messages
bind:this={messagesRef}
chatId={$chatId}
{readOnly}
bind:history
bind:autoScroll
bind:prompt
@@ -3126,7 +3130,16 @@
</div>
</div>
<div class=" pb-2 {dragged ? 'z-0' : 'z-10'}">
{#if readOnly}
<div class="pb-6 z-10">
<div
class="text-xs text-gray-400 dark:text-gray-500 text-center"
>
{$i18n.t('Read only')}
</div>
</div>
{:else}
<div class=" pb-2 {dragged ? 'z-0' : 'z-10'}">
<MessageInput
bind:this={messageInput}
{history}
@@ -3208,6 +3221,7 @@
<!-- {$i18n.t('LLMs can make mistakes. Verify important information.')} -->
</div>
</div>
{/if}
{:else}
<div class="flex items-center h-full">
<Placeholder
@@ -4,12 +4,15 @@
const i18n: Writable<any> = getContext('i18n');
import { user } from '$lib/stores';
import { fade } from 'svelte/transition';
import ChatList from './ChatList.svelte';
import FolderKnowledge from './FolderKnowledge.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { getChatListByFolderId } from '$lib/apis/chats';
import { getSharedFolderChats } from '$lib/apis/folders';
export let folder: any = null;
@@ -22,24 +25,8 @@
let allChatsLoaded = false;
const loadChats = async () => {
chatListLoading = true;
page += 1;
let newChatList: any[] = [];
newChatList = await getChatListByFolderId(localStorage.token, folder.id, page).catch(
(error) => {
console.error(error);
return [];
}
);
// once the bottom of the list has been reached (no results) there is no need to continue querying
allChatsLoaded = newChatList.length === 0;
chats = [...(chats || []), ...(newChatList || [])];
chatListLoading = false;
// getSharedFolderChats returns all users' chats in one shot; no pagination
allChatsLoaded = true;
};
const setChatList = async () => {
@@ -49,12 +36,21 @@
chatListLoading = false;
if (folder && folder.id) {
const res = await getChatListByFolderId(localStorage.token, folder.id, page);
if (res) {
chats = res;
// Always use the shared folder endpoint so owners also see
// chats created by users who have write access to this folder.
const res = await getSharedFolderChats(localStorage.token, folder.id).catch(
(error) => {
console.error(error);
return null;
}
);
if (res && res.chats) {
chats = res.chats;
allChatsLoaded = true;
} else {
chats = [];
// Fallback to regular API (e.g. if user has no shared access)
const fallback = await getChatListByFolderId(localStorage.token, folder.id, page).catch(() => []);
chats = fallback || [];
}
} else {
chats = [];
@@ -20,6 +20,7 @@
import { getChatsByFolderId } from '$lib/apis/chats';
import FolderModal from '$lib/components/layout/Sidebar/Folders/FolderModal.svelte';
import FolderShareModal from '$lib/components/layout/Sidebar/Folders/FolderShareModal.svelte';
import Folder from '$lib/components/icons/Folder.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
@@ -36,6 +37,7 @@
let showFolderModal = false;
let showCreateSubFolderModal = false;
let showShareModal = false;
let showDeleteConfirm = false;
let deleteFolderContents = true;
@@ -173,6 +175,8 @@
onSubmit={createSubFolderHandler}
/>
<FolderShareModal bind:show={showShareModal} {folder} />
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete folder?')}
@@ -232,6 +236,9 @@
onEdit={() => {
showFolderModal = true;
}}
onShare={() => {
showShareModal = true;
}}
onDelete={() => {
showDeleteConfirm = true;
}}
+31 -1
View File
@@ -46,7 +46,7 @@
deleteAllChats,
getChatListBySearchText
} from '$lib/apis/chats';
import { createNewFolder, getFolders, updateFolderParentIdById } from '$lib/apis/folders';
import { createNewFolder, getFolders, getSharedFolders, updateFolderParentIdById } from '$lib/apis/folders';
import { createNewNote, getPinnedNoteList, toggleNotePinnedStatusById } from '$lib/apis/notes';
import { updateUserSettings } from '$lib/apis/users';
import { checkActiveChats } from '$lib/apis/tasks';
@@ -61,6 +61,7 @@
import Folder from '../common/Folder.svelte';
import Tooltip from '../common/Tooltip.svelte';
import Folders from './Sidebar/Folders.svelte';
import SharedFolderItem from './Sidebar/SharedFolderItem.svelte';
import { getChannels, createNewChannel } from '$lib/apis/channels';
import ChannelModal from './Sidebar/ChannelModal.svelte';
import ChannelItem from './Sidebar/ChannelItem.svelte';
@@ -98,12 +99,15 @@
let showPinnedNotes = false;
let showChannels = false;
let showFolders = false;
let showSharedFolders = false;
let folders = {};
let folderRegistry = {};
let newFolderId = null;
let sharedFolders: any[] = [];
$: pinnedItems = $settings?.pinnedMenuItems ?? DEFAULT_PINNED_ITEMS;
const isMenuItemVisible = (id) => {
@@ -215,6 +219,31 @@
});
}
}
// Merge shared folders into the same structure
try {
sharedFolders = await getSharedFolders(localStorage.token);
} catch (e) {
sharedFolders = [];
}
for (const sf of sharedFolders) {
if (folders[sf.id]) continue; // Already owned by user
folders[sf.id] = { ...sf, shared: true };
}
// Build parent-child relationships for shared folders
for (const sf of sharedFolders) {
if (folders[sf.id]?.shared && sf.parent_id && folders[sf.parent_id]) {
folders[sf.parent_id].childrenIds = folders[sf.parent_id].childrenIds
? [...new Set([...folders[sf.parent_id].childrenIds, sf.id])]
: [sf.id];
}
}
};
const initSharedFolders = async () => {
await initFolders();
};
const createFolder = async ({ name, data, parent_id }) => {
@@ -291,6 +320,7 @@
scrollPaginationEnabled.set(false);
initFolders();
initSharedFolders();
await Promise.all([
await (async () => {
console.log('Init tags');
@@ -63,6 +63,7 @@
export let selected = false;
export let shiftKey = false;
export let readonly = false;
export let onDragEnd = () => {};
@@ -445,7 +446,7 @@
id="sidebar-chat-group"
bind:this={itemElement}
class=" w-full {className} relative group"
draggable={!confirmEdit}
draggable={!confirmEdit && !readonly}
>
{#if confirmEdit}
<div
@@ -509,6 +510,7 @@
lastReadAt = Date.now() / 1000;
}}
on:dblclick={async (e) => {
if (readonly) return;
e.preventDefault();
e.stopPropagation();
@@ -557,6 +559,7 @@
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if !readonly}
<div
id="sidebar-chat-item-menu"
class="
@@ -691,4 +694,5 @@
</div>
{/if}
</div>
{/if}
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { getContext, createEventDispatcher } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import RecursiveFolder from './RecursiveFolder.svelte';
@@ -12,16 +13,29 @@
export let onDelete = (folderId) => {};
let folderList = [];
// Get the list of folders that have no parent, sorted by name alphabetically
$: folderList = Object.keys(folders)
.filter((key) => folders[key].parent_id === null)
.sort((a, b) =>
folders[a].name.localeCompare(folders[b].name, undefined, {
numeric: true,
sensitivity: 'base'
let ownedList = [];
let sharedList = [];
$: {
const rootKeys = Object.keys(folders)
.filter((key) => {
const f = folders[key];
if (!f.name) return false;
// Root folder: no parent, or shared folder whose parent isn't in our folders
if (f.shared) {
return !f.parent_id || !folders[f.parent_id];
}
return f.parent_id === null;
})
);
.sort((a, b) =>
(folders[a].name ?? '').localeCompare(folders[b].name ?? '', undefined, {
numeric: true,
sensitivity: 'base'
})
);
ownedList = rootKeys.filter((key) => !folders[key].shared);
sharedList = rootKeys.filter((key) => folders[key].shared);
}
const onItemMove = (e) => {
if (e.originFolderId) {
@@ -40,7 +54,7 @@
}
</script>
{#each folderList as folderId (folderId)}
{#each ownedList as folderId (folderId)}
<RecursiveFolder
className=""
bind:folderRegistry
@@ -60,3 +74,29 @@
}}
/>
{/each}
{#if sharedList.length > 0}
<div class="w-full pl-2.5 text-[11px] text-gray-400 dark:text-gray-600 pt-2 pb-0.5">
{$i18n.t('Shared')}
</div>
{#each sharedList as folderId (folderId)}
<RecursiveFolder
className=""
bind:folderRegistry
{folders}
{folderId}
{shiftKey}
{onDelete}
{onItemMove}
on:import={(e) => {
dispatch('import', e.detail);
}}
on:update={(e) => {
dispatch('update', e.detail);
}}
on:change={(e) => {
dispatch('change', e.detail);
}}
/>
{/each}
{/if}
@@ -14,6 +14,7 @@
export let align: 'start' | 'end' = 'start';
export let onEdit = () => {};
export let onExport = () => {};
export let onShare = () => {};
export let onDelete = () => {};
export let onCreateSubFolder = () => {};
@@ -76,6 +77,20 @@
<div class="flex items-center">{$i18n.t('Export')}</div>
</button>
<button
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
on:click={() => {
onShare();
}}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.935-2.186 2.25 2.25 0 0 0-3.935 2.186Z" />
</svg>
<div class="flex items-center">{$i18n.t('Share')}</div>
</button>
<hr class="border-gray-50/30 dark:border-gray-800/30 my-1" />
<button
class="flex gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
on:click={() => {
@@ -0,0 +1,67 @@
<script lang="ts">
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Modal from '$lib/components/common/Modal.svelte';
import AccessControl from '$lib/components/workspace/common/AccessControl.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { updateFolderAccessById } from '$lib/apis/folders';
import { user } from '$lib/stores';
type AccessGrant = {
id?: string;
principal_type: 'user' | 'group';
principal_id: string;
permission: 'read' | 'write';
};
export let show = false;
export let folder: any = null;
let accessGrants: AccessGrant[] = [];
$: if (folder) {
accessGrants = folder.access_grants ?? [];
}
const handleAccessChange = async () => {
if (!folder) return;
try {
const res = await updateFolderAccessById(localStorage.token, folder.id, accessGrants);
if (res) {
accessGrants = res.access_grants ?? accessGrants;
}
} catch (e) {
console.error('Failed to update folder access', e);
}
};
</script>
<Modal size="sm" bind:show>
<div>
<div class=" flex justify-between dark:text-gray-100 px-5 pt-3 pb-1">
<div class=" text-lg font-medium self-center font-primary">
{$i18n.t('Share')}: {folder?.name ?? ''}
</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="w-full px-5 pb-4 dark:text-white">
<AccessControl
bind:accessGrants
onChange={handleAccessChange}
accessRoles={['read', 'write']}
share={$user?.role === 'admin' || $user?.permissions?.sharing?.folders}
sharePublic={false}
shareUsers={$user?.role === 'admin' || $user?.permissions?.access_grants?.allow_users}
/>
</div>
</div>
</Modal>
@@ -19,7 +19,8 @@
updateFolderById,
updateFolderParentIdById,
getFolderById,
createNewFolder
createNewFolder,
getSharedFolderChats
} from '$lib/apis/folders';
import {
getChatById,
@@ -39,6 +40,7 @@
import ChatItem from './ChatItem.svelte';
import FolderMenu from './Folders/FolderMenu.svelte';
import FolderShareModal from './Folders/FolderShareModal.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import FolderModal from './Folders/FolderModal.svelte';
import Emoji from '$lib/components/common/Emoji.svelte';
@@ -63,6 +65,7 @@
let folderElement;
let showFolderModal = false;
let showShareModal = false;
let edit = false;
let showCreateSubFolderModal = false;
@@ -78,7 +81,7 @@
const onDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
if (dragged || parentDragged) {
if (dragged || parentDragged || folders[folderId]?.shared) {
return;
}
draggedOver = true;
@@ -374,10 +377,18 @@
export const setFolderItems = async () => {
await tick();
if (open) {
chats = await getChatListByFolderId(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
return [];
});
// Always use getSharedFolderChats so owners also see chats
// created by users who have write access to this folder.
try {
const res = await getSharedFolderChats(localStorage.token, folderId);
chats = res?.chats ?? [];
} catch (error) {
// Fallback to regular API
chats = await getChatListByFolderId(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
return [];
});
}
} else {
chats = null;
}
@@ -478,6 +489,8 @@
onSubmit={createSubFolderHandler}
/>
<FolderShareModal bind:show={showShareModal} folder={folders[folderId]} />
{#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">
@@ -491,7 +504,7 @@
</DragGhost>
{/if}
<div bind:this={folderElement} class="relative {className}" draggable="true">
<div bind:this={folderElement} class="relative {className}" draggable="{!folders[folderId]?.shared}">
{#if draggedOver}
<div
class="absolute top-0 left-0 w-full h-full rounded-xs bg-gray-100/50 dark:bg-gray-700/20 bg-opacity-50 dark:bg-opacity-10 z-50 pointer-events-none touch-none"
@@ -515,6 +528,7 @@
? 'bg-gray-100 dark:bg-gray-900 selected'
: ''}"
on:dblclick={(e) => {
if (folders[folderId]?.shared) return;
if (clickTimer) {
clearTimeout(clickTimer); // cancel the single-click action
clickTimer = null;
@@ -614,29 +628,34 @@
{/if}
</div>
<button
class="absolute z-10 right-2 invisible group-hover:visible self-center flex items-center dark:text-gray-300"
>
<FolderMenu
onEdit={() => {
showFolderModal = true;
}}
onDelete={() => {
showDeleteConfirm = true;
}}
onExport={() => {
exportHandler();
}}
onCreateSubFolder={() => {
createSubFolderParentId = folderId;
showCreateSubFolderModal = true;
}}
{#if !folders[folderId]?.shared}
<button
class="absolute z-10 right-2 invisible group-hover:visible self-center flex items-center dark:text-gray-300"
>
<div class="p-1 dark:hover:bg-gray-850 rounded-lg touch-auto">
<EllipsisHorizontal className="size-4" strokeWidth="2.5" />
</div>
</FolderMenu>
</button>
<FolderMenu
onEdit={() => {
showFolderModal = true;
}}
onShare={() => {
showShareModal = true;
}}
onDelete={() => {
showDeleteConfirm = true;
}}
onExport={() => {
exportHandler();
}}
onCreateSubFolder={() => {
createSubFolderParentId = folderId;
showCreateSubFolderModal = true;
}}
>
<div class="p-1 dark:hover:bg-gray-850 rounded-lg touch-auto">
<EllipsisHorizontal className="size-4" strokeWidth="2.5" />
</div>
</FolderMenu>
</button>
{/if}
</div>
</div>
@@ -0,0 +1,130 @@
<script lang="ts">
import { getContext, createEventDispatcher } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import { goto } from '$app/navigation';
import { chatId, mobile, showSidebar } from '$lib/stores';
import { getSharedFolderChats } from '$lib/apis/folders';
import ChatItem from './ChatItem.svelte';
import Collapsible from '../../common/Collapsible.svelte';
import ChevronDown from '../../icons/ChevronDown.svelte';
import ChevronRight from '../../icons/ChevronRight.svelte';
import FolderOpen from '$lib/components/icons/FolderOpen.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let folder: any;
export let allSharedFolders: any[] = [];
export let className = '';
let expanded = false;
let chats: any[] = [];
let loading = false;
let loaded = false;
$: children = allSharedFolders.filter((f) => f.parent_id === folder.id);
$: permission = folder.permission || 'read';
$: isWritable = permission === 'write';
const toggleExpand = async () => {
expanded = !expanded;
if (expanded && !loaded) {
await loadChats();
}
};
const loadChats = async () => {
loading = true;
try {
const res = await getSharedFolderChats(localStorage.token, folder.id);
if (res) {
chats = res.chats || [];
}
} catch (e) {
console.error('Failed to load shared folder chats', e);
chats = [];
} finally {
loading = false;
loaded = true;
}
};
</script>
<div class={className}>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="w-full group"
>
<button
class="w-full py-1 flex items-center gap-1 text-xs text-left font-medium
text-gray-600 dark:text-gray-400
hover:text-gray-900 dark:hover:text-gray-200
transition-colors duration-100 cursor-pointer"
on:click={toggleExpand}
>
<div class="shrink-0">
{#if expanded}
<ChevronDown className="size-3" strokeWidth="2.5" />
{:else}
<ChevronRight className="size-3" strokeWidth="2.5" />
{/if}
</div>
<FolderOpen className="size-3.5 shrink-0" strokeWidth="2" />
<div class="flex-1 truncate">
{folder.name}
</div>
{#if folder.owner_name}
<div class="shrink-0 text-[10px] text-gray-400 dark:text-gray-600 pr-1">
{folder.owner_name}
</div>
{/if}
{#if !isWritable}
<div class="shrink-0 text-[10px] text-gray-400 dark:text-gray-600" title="Read only">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-3">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
{/if}
</button>
</div>
{#if expanded}
<div class="pl-3">
{#if loading}
<div class="flex items-center gap-1.5 py-1 px-2 text-xs text-gray-400">
<Spinner className="size-3" />
<span>{$i18n.t('Loading...')}</span>
</div>
{:else}
{#each chats as chat (chat.id)}
<ChatItem
id={chat.id}
title={chat.title}
createdAt={chat.created_at}
updatedAt={chat.updated_at}
readonly={chat.readonly ?? !isWritable}
/>
{/each}
{#each children as child (child.id)}
<svelte:self
folder={child}
{allSharedFolders}
/>
{/each}
{#if chats.length === 0 && children.length === 0}
<div class="text-[11px] text-gray-400 dark:text-gray-600 py-1 px-2">
{$i18n.t('Empty')}
</div>
{/if}
{/if}
</div>
{/if}
</div>