This commit is contained in:
Timothy Jaeryang Baek
2026-07-21 13:53:30 -04:00
parent 86efecd9ad
commit 409fb39717
9 changed files with 374 additions and 98 deletions
+21 -1
View File
@@ -1722,17 +1722,21 @@ class ChatTable:
folder_id: str,
skip: int = 0,
limit: int = 60,
sort_by: str = 'updated_at',
sort_dir: str = 'desc',
db: AsyncSession | None = None,
) -> list[dict]:
"""Get chats in a folder across ALL users. Returns dicts with user_id."""
async with get_async_db_context(db) as session:
sort_column = Chat.title if sort_by == 'title' else Chat.updated_at
order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc()
stmt = (
select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at)
.filter_by(folder_id=folder_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(Chat.updated_at.desc(), Chat.id)
.order_by(order_clause, Chat.id)
)
if skip:
@@ -1754,6 +1758,22 @@ class ChatTable:
for chat in all_chats
]
async def count_all_chats_by_folder_id(
self,
folder_id: str,
db: AsyncSession | None = None,
) -> int:
async with get_async_db_context(db) as session:
stmt = (
select(func.count(Chat.id))
.filter_by(folder_id=folder_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
)
result = await session.execute(stmt)
return result.scalar_one()
async def get_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
) -> list[ChatModel]:
+19 -3
View File
@@ -6,7 +6,7 @@ import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from open_webui.config import UPLOAD_DIR
from open_webui.constants import ERROR_MESSAGES
@@ -482,6 +482,9 @@ async def update_folder_access_by_id(
async def get_shared_folder_chats(
request: Request,
id: str,
page: int | None = Query(None, ge=1),
sort_by: str = Query('updated_at'),
sort_dir: str = Query('desc'),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@@ -505,7 +508,17 @@ async def get_shared_folder_chats(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
chats = await Chats.get_all_chats_by_folder_id(id, db=db)
limit = 10
skip = (page - 1) * limit if page is not None else 0
chats = await Chats.get_all_chats_by_folder_id(
id,
skip=skip,
limit=limit if page is not None else 60,
sort_by=sort_by,
sort_dir=sort_dir,
db=db,
)
total = await Chats.count_all_chats_by_folder_id(id, db=db) if page is not None else len(chats)
# Resolve owner names for display (avatar URLs are constructed client-side)
owner_cache: dict[str, str] = {}
@@ -516,10 +529,13 @@ async def get_shared_folder_chats(
owner_cache[uid] = u.name if u else 'Unknown'
chat['owner_name'] = owner_cache[uid]
return {
response = {
'chats': [{**chat, 'readonly': chat['user_id'] != user.id} for chat in chats],
'folder_permission': 'write' if has_write else 'read',
}
if page is not None:
response.update({'total': total, 'has_more': skip + limit < total})
return response
############################
+31 -8
View File
@@ -290,17 +290,40 @@ export const getSharedFolders = async (token: string) => {
return res;
};
export const getSharedFolderChats = async (token: string, folderId: string) => {
export const getSharedFolderChats = async (
token: string,
folderId: string,
params: {
page?: number | null;
sortBy?: 'title' | 'updated_at';
sortDir?: 'asc' | 'desc';
} = {}
) => {
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}`
const searchParams = new URLSearchParams();
if (params.page !== undefined && params.page !== null) {
searchParams.append('page', `${params.page}`);
}
if (params.sortBy) {
searchParams.append('sort_by', params.sortBy);
}
if (params.sortDir) {
searchParams.append('sort_dir', params.sortDir);
}
const query = searchParams.toString();
const res = await fetch(
`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats${query ? `?${query}` : ''}`,
{
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();
+1 -1
View File
@@ -1177,7 +1177,7 @@
}
const pageSubscribe = page.subscribe(async (p) => {
if (p.url.pathname === '/') {
if (p.url.pathname === '/' || p.url.pathname.startsWith('/folders/')) {
await tick();
initNewChat();
}
@@ -1,27 +1,35 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
const i18n: Writable<any> = getContext('i18n');
import dayjs from 'dayjs';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import { getTimeRange } from '$lib/utils';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import Loader from '$lib/components/common/Loader.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
dayjs.extend(localizedFormat);
export let chats = [];
export let chats: any[] = [];
export let chatListLoading = false;
export let allChatsLoaded = false;
export let loadHandler: Function = null;
export let showOwnerInfo = false;
export let page = 1;
export let total = 0;
export let perPage = 10;
export let orderBy: 'title' | 'updated_at' = 'updated_at';
export let direction: 'asc' | 'desc' = 'desc';
export let onPageChange: Function = () => {};
export let onSort: Function = () => {};
let chatList = null;
let chatList: any[] | null = null;
let totalPages = 1;
let pages: (number | 'ellipsis')[] = [];
const init = async () => {
if (chats.length === 0) {
@@ -31,34 +39,43 @@
...chat,
time_range: getTimeRange(chat.updated_at)
}));
chatList.sort((a, b) => {
if (direction === 'asc') {
return a[orderBy] > b[orderBy] ? 1 : -1;
} else {
return a[orderBy] < b[orderBy] ? 1 : -1;
}
});
}
};
const setSortKey = (key) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
direction = 'asc';
}
init();
const setSortKey = (key: 'title' | 'updated_at') => {
onSort(key);
};
let orderBy = 'updated_at';
let direction = 'desc'; // 'asc' or 'desc'
const buildPages = (currentPage: number, pageCount: number): (number | 'ellipsis')[] => {
if (pageCount <= 7) {
return Array.from({ length: pageCount }, (_, i) => i + 1);
}
const items: (number | 'ellipsis')[] = [1];
if (currentPage > 3) {
items.push('ellipsis');
}
const start = Math.max(2, currentPage - 1);
const end = Math.min(pageCount - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
items.push(i);
}
if (currentPage < pageCount - 2) {
items.push('ellipsis');
}
items.push(pageCount);
return items;
};
$: if (chats) {
init();
}
$: totalPages = Math.max(1, Math.ceil(total / perPage));
$: pages = buildPages(page, totalPages);
</script>
{#if chatList}
@@ -153,7 +170,6 @@
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}
@@ -177,19 +193,48 @@
</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 totalPages > 1}
<div class="flex items-center justify-center gap-1.5 pt-2">
<button
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-25 dark:text-gray-500 dark:hover:bg-gray-850 dark:hover:text-gray-300"
disabled={chatListLoading || page <= 1}
aria-label={$i18n.t('Previous page')}
on:click={() => onPageChange(page - 1)}
>
<ChevronLeft className="size-3.5" strokeWidth="2" />
</button>
{#each pages as item, index (item === 'ellipsis' ? `ellipsis-${index}` : item)}
{#if item === 'ellipsis'}
<span
class="inline-flex size-7 items-center justify-center text-xs text-gray-400 select-none dark:text-gray-600"
>
...
</span>
{:else}
<button
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium transition hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-850 dark:hover:text-gray-300 {item ===
page
? 'bg-gray-50 text-gray-800 dark:bg-gray-850 dark:text-gray-100'
: 'text-gray-500 dark:text-gray-500'}"
aria-label={`Page ${item}`}
aria-current={item === page ? 'page' : undefined}
on:click={() => onPageChange(item)}
>
{item}
</button>
{/if}
{/each}
<button
class="inline-flex size-7 items-center justify-center rounded-lg text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-25 dark:text-gray-500 dark:hover:bg-gray-850 dark:hover:text-gray-300"
disabled={chatListLoading || page >= totalPages}
aria-label={$i18n.t('Next page')}
on:click={() => onPageChange(page + 1)}
>
<ChevronRight className="size-3.5" strokeWidth="2" />
</button>
</div>
{/if}
</div>
{/if}
@@ -11,18 +11,21 @@
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;
let selectedTab = 'chats';
const CHATS_PAGE_SIZE = 10;
let page = 1;
let totalChats = 0;
let orderBy: 'title' | 'updated_at' = 'updated_at';
let direction: 'asc' | 'desc' = 'desc';
let currentFolderId: string | null = null;
let chats: any[] | null = null;
let chatListLoading = false;
let allChatsLoaded = false;
$: showOwnerInfo = Boolean(
folder?.shared ||
@@ -30,42 +33,68 @@
(folder?.access_grants?.length ?? 0) > 0
);
const loadChats = async () => {
// getSharedFolderChats returns all users' chats in one shot; no pagination
allChatsLoaded = true;
const setSortKey = (key: 'title' | 'updated_at') => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
} else {
orderBy = key;
direction = key === 'title' ? 'asc' : 'desc';
}
page = 1;
setChatList();
};
const setPage = (nextPage: number) => {
if (nextPage === page || chatListLoading) {
return;
}
page = nextPage;
setChatList();
};
const setChatList = async () => {
const folderId = folder?.id;
chats = null;
page = 1;
allChatsLoaded = false;
chatListLoading = false;
if (folder && folder.id) {
if (folderId) {
// 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) => {
chatListLoading = true;
const res = await getSharedFolderChats(localStorage.token, folderId, {
page,
sortBy: orderBy,
sortDir: direction
}).catch((error) => {
console.error(error);
return null;
});
chatListLoading = false;
if (res && res.chats) {
chats = res.chats;
allChatsLoaded = true;
totalChats = res.total ?? res.chats.length;
} else {
// Fallback to regular API (e.g. if user has no shared access)
const fallback = await getChatListByFolderId(localStorage.token, folder.id, page).catch(
() => []
);
chats = fallback || [];
chats = [];
totalChats = 0;
}
} else {
chats = [];
totalChats = 0;
}
};
$: if (folder) {
$: if (folder?.id && folder.id !== currentFolderId) {
currentFolderId = folder.id;
page = 1;
setChatList();
}
$: if (!folder?.id && currentFolderId !== null) {
currentFolderId = null;
chats = [];
totalChats = 0;
}
</script>
<div>
@@ -105,9 +134,14 @@
<ChatList
{chats}
{chatListLoading}
{allChatsLoaded}
loadHandler={loadChats}
{showOwnerInfo}
{page}
total={totalChats}
perPage={CHATS_PAGE_SIZE}
{orderBy}
{direction}
onPageChange={setPage}
onSort={setSortKey}
/>
{:else}
<div class="py-10">
@@ -44,7 +44,6 @@
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import FolderModal from './Folders/FolderModal.svelte';
import Emoji from '$lib/components/common/Emoji.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let folderRegistry = {};
export let open = false;
@@ -385,24 +384,46 @@
}, 500);
};
const SIDEBAR_CHATS_PAGE_SIZE = 10;
/** @type {any[] | null} */
let chats = null;
export const setFolderItems = async () => {
let chatsPage = 1;
let hasMoreChats = false;
let chatsLoading = false;
export const setFolderItems = async (append = false) => {
await tick();
if (open) {
if (open && !chatsLoading) {
// Always use getSharedFolderChats so owners also see chats
// created by users who have write access to this folder.
const nextPage = append ? chatsPage + 1 : 1;
chatsLoading = true;
try {
const res = await getSharedFolderChats(localStorage.token, folderId);
chats = res?.chats ?? [];
const res = await getSharedFolderChats(localStorage.token, folderId, {
page: nextPage
});
const nextChats = res?.chats ?? [];
chats = append ? [...(chats ?? []), ...nextChats] : nextChats;
chatsPage = nextPage;
hasMoreChats = res?.has_more ?? nextChats.length === SIDEBAR_CHATS_PAGE_SIZE;
} catch (error) {
// Fallback to regular API
chats = await getChatListByFolderId(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
return [];
});
const fallback = await getChatListByFolderId(localStorage.token, folderId, nextPage).catch(
(error) => {
toast.error(`${error}`);
return [];
}
);
chats = append ? [...(chats ?? []), ...(fallback ?? [])] : (fallback ?? []);
chatsPage = nextPage;
hasMoreChats = (fallback?.length ?? 0) === SIDEBAR_CHATS_PAGE_SIZE;
} finally {
chatsLoading = false;
}
} else {
chats = null;
chatsPage = 1;
hasMoreChats = false;
}
};
@@ -564,7 +585,7 @@
await selectedFolder.set({ ...folders[folderId], ...folder });
}
await goto('/');
await goto(`/folders/${folderId}`);
if ($mobile) {
showSidebar.set(!$showSidebar);
@@ -678,7 +699,7 @@
</div>
<div slot="content" class="w-full">
{#if (folders[folderId]?.childrenIds ?? []).length > 0 || (chats ?? []).length > 0}
{#if (folders[folderId]?.childrenIds ?? []).length > 0 || (chats ?? []).length > 0 || hasMoreChats}
<div
class="ml-3 pl-1 mt-[1px] flex flex-col overflow-y-auto scrollbar-hidden border-s border-gray-100 dark:border-gray-900"
>
@@ -731,12 +752,41 @@
}}
/>
{/each}
{#if hasMoreChats}
<button
class="w-full px-2 py-0.5 text-left text-[11px] text-gray-400 transition hover:text-gray-700 disabled:cursor-not-allowed dark:text-gray-600 dark:hover:text-gray-300"
disabled={chatsLoading}
on:click={() => setFolderItems(true)}
>
{#if chatsLoading}
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"
></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
></span>
</div>
{:else}
{$i18n.t('Show more')}
{/if}
</button>
{/if}
</div>
{/if}
{#if chats === null}
<div class="flex justify-center items-center p-2">
<Spinner className="size-4 text-gray-500" />
{#if chats === null && chatsLoading}
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
></span>
</div>
{/if}
</div>
@@ -1,6 +1,8 @@
<script lang="ts">
import { getContext, createEventDispatcher } from 'svelte';
const i18n = getContext('i18n');
import type { Writable } from 'svelte/store';
const i18n: Writable<any> = getContext('i18n');
const dispatch = createEventDispatcher();
import { goto } from '$app/navigation';
@@ -13,16 +15,18 @@
import ChevronRight from './icons/ChevronRight.svelte';
import Eye from './icons/Eye.svelte';
import FolderIcon from './icons/Folder.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let folder: any;
export let allSharedFolders: any[] = [];
export let className = '';
const SIDEBAR_CHATS_PAGE_SIZE = 10;
let expanded = false;
let chats: any[] = [];
let loading = false;
let loaded = false;
let page = 1;
let hasMoreChats = false;
$: children = allSharedFolders.filter((f) => f.parent_id === folder.id);
$: permission = folder.permission || 'read';
@@ -35,16 +39,27 @@
}
};
const loadChats = async () => {
const loadChats = async (append = false) => {
if (loading) {
return;
}
loading = true;
try {
const res = await getSharedFolderChats(localStorage.token, folder.id);
const nextPage = append ? page + 1 : 1;
const res = await getSharedFolderChats(localStorage.token, folder.id, {
page: nextPage
});
if (res) {
chats = res.chats || [];
const nextChats = res.chats || [];
chats = append ? [...chats, ...nextChats] : nextChats;
page = nextPage;
hasMoreChats = res.has_more ?? nextChats.length === SIDEBAR_CHATS_PAGE_SIZE;
}
} catch (e) {
console.error('Failed to load shared folder chats', e);
chats = [];
hasMoreChats = false;
} finally {
loading = false;
loaded = true;
@@ -92,10 +107,15 @@
{#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>
{#if loading && !loaded}
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
></span>
</div>
{:else}
{#each chats as chat (chat.id)}
@@ -115,7 +135,29 @@
<svelte:self folder={child} {allSharedFolders} />
{/each}
{#if chats.length === 0 && children.length === 0}
{#if hasMoreChats}
<button
class="w-full px-2 py-0.5 text-left text-[11px] text-gray-400 transition hover:text-gray-700 disabled:cursor-not-allowed dark:text-gray-600 dark:hover:text-gray-300"
disabled={loading}
on:click={() => loadChats(true)}
>
{#if loading}
<div class="flex gap-1 px-2 py-1.5" aria-label="Loading">
<span class="size-1 rounded-full bg-gray-400 animate-pulse dark:bg-gray-600"></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:150ms] dark:bg-gray-600"
></span>
<span
class="size-1 rounded-full bg-gray-400 animate-pulse [animation-delay:300ms] dark:bg-gray-600"
></span>
</div>
{:else}
{$i18n.t('Show more')}
{/if}
</button>
{/if}
{#if chats.length === 0 && children.length === 0 && !hasMoreChats}
<div class="text-[11px] text-gray-400 dark:text-gray-600 py-1 px-2">
{$i18n.t('Empty')}
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onDestroy, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import Chat from '$lib/components/chat/Chat.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { getFolderById } from '$lib/apis/folders';
import { selectedFolder } from '$lib/stores';
let ready = false;
onMount(async () => {
const folderId = $page.params.folderId;
if (!folderId) {
await goto('/');
return;
}
const folder = await getFolderById(localStorage.token, folderId).catch((error) => {
toast.error(`${error}`);
return null;
});
if (!folder) {
await goto('/');
return;
}
await selectedFolder.set(folder);
ready = true;
});
onDestroy(() => {
selectedFolder.set(null);
});
</script>
{#if ready}
<Chat />
{:else}
<div class="w-full h-screen flex items-center justify-center">
<Spinner />
</div>
{/if}