enh: manage shared chats

This commit is contained in:
Timothy Jaeryang Baek
2026-01-29 18:51:02 +04:00
parent 26a5d8f75d
commit a10ac774ab
7 changed files with 362 additions and 18 deletions

View File

@@ -168,6 +168,14 @@ class ChatTitleIdResponse(BaseModel):
created_at: int
class SharedChatResponse(BaseModel):
id: str
title: str
share_id: Optional[str] = None
updated_at: int
created_at: int
class ChatListResponse(BaseModel):
items: list[ChatModel]
total: int
@@ -675,6 +683,49 @@ class ChatTable:
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_shared_chat_list_by_user_id(
self,
user_id: str,
filter: Optional[dict] = None,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id).filter(
Chat.share_id.isnot(None)
)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(Chat.title.ilike(f"%{query_key}%"))
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by and direction:
if not getattr(Chat, order_by, None):
raise ValueError("Invalid order_by field")
if direction.lower() == "asc":
query = query.order_by(getattr(Chat, order_by).asc())
elif direction.lower() == "desc":
query = query.order_by(getattr(Chat, order_by).desc())
else:
raise ValueError("Invalid direction for ordering")
else:
query = query.order_by(Chat.updated_at.desc())
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chat_list_by_user_id(
self,
user_id: str,

View File

@@ -16,6 +16,7 @@ from open_webui.models.chats import (
ChatResponse,
Chats,
ChatTitleIdResponse,
SharedChatResponse,
ChatStatsExport,
AggregateChatStats,
ChatBody,
@@ -858,6 +859,48 @@ async def unarchive_all_chats(
return Chats.unarchive_all_chats_by_user_id(user.id, db=db)
############################
# GetSharedChats
############################
@router.get("/shared", response_model=list[SharedChatResponse])
async def get_shared_session_user_chat_list(
page: Optional[int] = None,
query: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
if page is None:
page = 1
limit = 60
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
chat_list = [
SharedChatResponse(**chat.model_dump())
for chat in Chats.get_shared_chat_list_by_user_id(
user.id,
filter=filter,
skip=skip,
limit=limit,
db=db,
)
]
return chat_list
############################
# GetSharedChatById
############################

View File

@@ -255,6 +255,55 @@ export const getArchivedChatList = async (
}));
};
export const getSharedChatList = async (
token: string = '',
page: number = 1,
filter?: object
) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('page', `${page}`);
if (filter) {
Object.entries(filter).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
}
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/shared?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res.map((chat) => ({
...chat,
time_range: getTimeRange(chat.updated_at)
}));
};
export const getAllChats = async (token: string) => {
let error = null;

View File

@@ -24,6 +24,7 @@
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import ArchivedChatsModal from '$lib/components/layout/ArchivedChatsModal.svelte';
import SharedChatsModal from '$lib/components/layout/SharedChatsModal.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
const i18n = getContext('i18n');
@@ -36,6 +37,7 @@
let showArchiveConfirmDialog = false;
let showDeleteConfirmDialog = false;
let showArchivedChatsModal = false;
let showSharedChatsModal = false;
let chatImportInputElement: HTMLInputElement;
@@ -136,6 +138,7 @@
</script>
<ArchivedChatsModal bind:show={showArchivedChatsModal} onUpdate={handleArchivedChatsChange} />
<SharedChatsModal bind:show={showSharedChatsModal} />
<ConfirmDialog
title={$i18n.t('Archive All Chats')}
@@ -169,7 +172,7 @@
/>
<div>
<div class="mb-1 text-sm font-medium">{$i18n.t('Chat Management')}</div>
<div class="mb-1 text-sm font-medium">{$i18n.t('Chats')}</div>
<div>
<div class="py-0.5 flex w-full justify-between">
@@ -218,6 +221,21 @@
</div>
</div>
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Shared Chats')}</div>
<button
class="p-1 px-3 text-xs flex rounded-sm transition"
on:click={() => {
showSharedChatsModal = true;
}}
type="button"
>
<span class="self-center">{$i18n.t('Manage')}</span>
</button>
</div>
</div>
<div>
<div class="py-0.5 flex w-full justify-between">
<div class="self-center text-xs">{$i18n.t('Archive All Chats')}</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
><path
d="M7.14286 16.9953C6.75006 16.9953 6.36756 16.9525 6 16.8715C3.70973 16.3665 2 14.3761 2 11.9977C2 9.284 4.22573 7.07548 7 7.00195"
stroke-linecap="round"
stroke-linejoin="round"
></path><path
d="M13.3184 9.63429C12.7858 8.73635 11.9737 7.96977 11 7.4989"
stroke-linecap="round"
stroke-linejoin="round"
></path><path
d="M16.8571 6.99999C17.2499 6.99999 17.6324 7.04278 18 7.12383C20.2903 7.62884 22 9.6192 22 11.9976C22 14.7577 19.6975 16.9952 16.8571 16.9952C16.581 16.9952 15.4776 16.9952 15.1429 16.9952C12.317 16.9952 10 14.4893 10 11.9976C10 11.9976 10 11 10.5 10.5"
stroke-linecap="round"
stroke-linejoin="round"
></path><path d="M3 3L21 21" stroke-linecap="round" stroke-linejoin="round"></path></svg
>

View File

@@ -20,6 +20,9 @@
import XMark from '../icons/XMark.svelte';
import ChevronUp from '../icons/ChevronUp.svelte';
import ChevronDown from '../icons/ChevronDown.svelte';
import Link from '../icons/Link.svelte';
import LinkSlash from '../icons/LinkSlash.svelte';
import Clipboard from '../icons/Clipboard.svelte';
const i18n = getContext('i18n');
@@ -46,6 +49,7 @@
export let loadHandler: null | Function = null;
export let unarchiveHandler: null | Function = null;
export let unshareHandler: null | Function = null;
const setSortKey = (key) => {
if (orderBy === key) {
@@ -286,30 +290,57 @@
</Tooltip>
{/if}
<Tooltip content={$i18n.t('Delete Chat')}>
{#if unshareHandler && chat.share_id}
<Tooltip content={$i18n.t('Copy Share Link')}>
<button
class="self-center w-fit px-1 text-sm rounded-xl"
on:click={async (e) => {
e.stopImmediatePropagation();
e.stopPropagation();
const shareUrl = `${window.location.origin}/s/${chat.share_id}`;
await navigator.clipboard.writeText(shareUrl);
toast.success($i18n.t('Share link copied to clipboard.'));
}}
>
<Clipboard class="size-4" strokeWidth="1.5" />
</button>
</Tooltip>
{/if}
<Tooltip
content={unshareHandler ? $i18n.t('Unshare Chat') : $i18n.t('Delete Chat')}
>
<button
class="self-center w-fit px-1 text-sm rounded-xl"
on:click={async (e) => {
e.stopImmediatePropagation();
e.stopPropagation();
selectedChatId = chat.id;
showDeleteConfirmDialog = true;
if (unshareHandler) {
unshareHandler(chat.id);
} else {
selectedChatId = chat.id;
showDeleteConfirmDialog = true;
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
{#if unshareHandler}
<LinkSlash />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
{/if}
</button>
</Tooltip>
</div>

View File

@@ -0,0 +1,126 @@
<script lang="ts">
import type { Writable } from 'svelte/store';
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
import { deleteSharedChatById, getSharedChatList } from '$lib/apis/chats';
import ChatsModal from './ChatsModal.svelte';
const i18n: Writable<any> = getContext('i18n');
export let show = false;
export let onUpdate = () => {};
let chatList: any[] | null = null;
let page = 1;
let query = '';
let orderBy = 'updated_at';
let direction = 'desc';
let allChatsLoaded = false;
let chatListLoading = false;
let searchDebounceTimeout: any;
let filter: any = {};
$: filter = {
...(query ? { query } : {}),
...(orderBy ? { order_by: orderBy } : {}),
...(direction ? { direction } : {})
};
$: if (filter !== null) {
searchHandler();
}
const searchHandler = async () => {
if (!show) {
return;
}
if (searchDebounceTimeout) {
clearTimeout(searchDebounceTimeout);
}
page = 1;
chatList = null;
if (query === '') {
chatList = await getSharedChatList(localStorage.token, page, filter);
} else {
searchDebounceTimeout = setTimeout(async () => {
chatList = await getSharedChatList(localStorage.token, page, filter);
}, 500);
}
if ((chatList ?? []).length === 0) {
allChatsLoaded = true;
} else {
allChatsLoaded = false;
}
};
const loadMoreChats = async () => {
chatListLoading = true;
page += 1;
let newChatList = [];
if (query) {
newChatList = await getSharedChatList(localStorage.token, page, filter);
} else {
newChatList = await getSharedChatList(localStorage.token, page, filter);
}
// once the bottom of the list has been reached (no results) there is no need to continue querying
allChatsLoaded = newChatList.length === 0;
if (newChatList.length > 0) {
chatList = [...(chatList || []), ...newChatList];
}
chatListLoading = false;
};
const unshareHandler = async (chatId: string) => {
const res = await deleteSharedChatById(localStorage.token, chatId).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res === true) {
toast.success($i18n.t('Chat unshared successfully.'));
onUpdate();
init();
} else if (res === false) {
toast.error($i18n.t('Failed to unshare chat.'));
}
};
const init = async () => {
chatList = await getSharedChatList(localStorage.token);
};
$: if (show) {
init();
}
</script>
<ChatsModal
bind:show
bind:query
bind:orderBy
bind:direction
title={$i18n.t('Shared Chats')}
emptyPlaceholder={$i18n.t('You have no shared conversations.')}
shareUrl={false}
{chatList}
{allChatsLoaded}
{chatListLoading}
onUpdate={() => {
onUpdate();
init();
}}
loadHandler={loadMoreChats}
{unshareHandler}
/>