This commit is contained in:
Timothy Jaeryang Baek committed 2026-10-14 14:59:41 +04:00
1 parent d637a39ac9
commit 35c927ba99
8 files changed
+217 -42

No files matched your search

+72
View File
@@ -2401,6 +2401,78 @@ class ChatTable:
for chat in all_chats
]
async def get_participant_ids_by_chat_ids(
self, chat_ids: list[str], db: AsyncSession | None = None
) -> dict[str, list[str]]:
participants: dict[str, list[str]] = {chat_id: [] for chat_id in chat_ids}
if not chat_ids:
return participants
async with get_async_db_context(db) as session:
dialect = session.bind.dialect.name
# An empty history object is authoritative, including after message deletion.
if dialect == 'sqlite':
messages = """
SELECT c.id AS chat_id, c.user_id AS owner_id,
CASE WHEN m.type = 'object' THEN m.value ELSE '{}' END AS message
FROM chat AS c, json_each(
CASE
WHEN json_type(c.chat, '$.history.messages') = 'object'
THEN json_extract(c.chat, '$.history.messages')
WHEN json_type(c.chat, '$.messages') = 'array'
THEN json_extract(c.chat, '$.messages')
ELSE '[]'
END
) AS m
WHERE c.id IN :chat_ids
"""
role = "json_extract(message, '$.role')"
def author(path):
return f"""CASE WHEN json_type(message, '{path}') = 'text'
AND trim(json_extract(message, '{path}')) <> ''
THEN json_extract(message, '{path}') END"""
author_id = f"COALESCE({author('$.user_id')}, {author('$.user.id')}, NULLIF(trim(owner_id), ''))"
elif dialect == 'postgresql':
messages = """
SELECT c.id AS chat_id, c.user_id AS owner_id, m.message
FROM chat AS c CROSS JOIN LATERAL (
SELECT value AS message FROM json_each(
CASE WHEN json_typeof(c.chat#>'{history,messages}') = 'object'
THEN c.chat#>'{history,messages}' ELSE '{}'::json END
)
UNION ALL
SELECT value AS message FROM json_array_elements(
CASE WHEN json_typeof(c.chat#>'{history,messages}') IS DISTINCT FROM 'object'
AND json_typeof(c.chat->'messages') = 'array'
THEN c.chat->'messages' ELSE '[]'::json END
)
) AS m
WHERE c.id IN :chat_ids
"""
role = "message->>'role'"
def author(path):
return f"""CASE WHEN json_typeof(message#>'{path}') = 'string'
AND trim(message#>>'{path}') <> '' THEN message#>>'{path}' END"""
author_id = f"COALESCE({author('{user_id}')}, {author('{user,id}')}, NULLIF(trim(owner_id), ''))"
else:
raise NotImplementedError(f'Unsupported dialect: {dialect}')
query = text(f"""
WITH messages AS ({messages}), authors AS (
SELECT chat_id, {author_id} AS author_id
FROM messages WHERE {role} = 'user'
)
SELECT DISTINCT chat_id, author_id FROM authors
WHERE author_id IS NOT NULL ORDER BY chat_id, author_id
""").bindparams(bindparam('chat_ids', expanding=True))
for chat_id, author_id in (await session.execute(query, {'chat_ids': chat_ids})).all():
participants[chat_id].append(author_id)
return participants
async def count_all_chats_by_folder_id(
self,
folder_id: str,
+15 -6
View File
@@ -629,14 +629,23 @@ async def get_shared_folder_chats(
)
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] = {}
participant_ids = await Chats.get_participant_ids_by_chat_ids([chat['id'] for chat in chats], db=db)
user_ids = {chat['user_id'] for chat in chats}
user_ids.update(uid for ids in participant_ids.values() for uid in ids)
users = await Users.get_users_by_user_ids(list(user_ids), db=db) if user_ids else []
names = {u.id: u.name for u in users}
for chat in chats:
uid = chat['user_id']
if uid not in owner_cache:
u = await Users.get_user_by_id(uid, db=db)
owner_cache[uid] = u.name if u else 'Unknown'
chat['owner_name'] = owner_cache[uid]
chat['owner_name'] = names.get(uid, 'Unknown')
chat['participants'] = sorted(
[{'id': author_id, 'name': names.get(author_id)} for author_id in participant_ids[chat['id']]],
key=lambda author: (
author['id'] != uid,
not bool(author['name']),
(author['name'] or '').casefold(),
author['id'],
),
)
chat['active'] = False
if chat['user_id'] != user.id:
chat['last_read_at'] = chat['updated_at']
+5
View File
@@ -323,6 +323,11 @@ export const getSharedFolders = async (token: string) => {
return res;
};
export type ChatParticipant = {
id: string;
name: string | null;
};
export const getSharedFolderChats = async (
token: string,
folderId: string,
@@ -12,12 +12,12 @@
import dayjs from 'dayjs';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import { getTimeRange } from '$lib/utils';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import type { ChatParticipant } from '$lib/apis/folders';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte';
import ChatParticipants from '$lib/components/common/ChatParticipants.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { chatId, socket } from '$lib/stores';
@@ -33,6 +33,7 @@
time_range?: string;
user_id?: string;
owner_name?: string;
participants?: ChatParticipant[];
[key: string]: unknown;
};
@@ -211,20 +212,12 @@
<div
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"
>
{#if showOwnerInfo && chat.user_id && chat.owner_name}
{#if (showOwnerInfo && chat.user_id) || (chat.participants?.length ?? 0) > 1}
<div class="flex shrink-0 me-2">
<ProfilePreview user={{ id: chat.user_id, name: chat.owner_name }}>
<img
src="{WEBUI_API_BASE_URL}/users/{chat.user_id}/profile/image"
alt=""
class="size-4 rounded-full shrink-0 object-cover"
on:error={(e) => {
if (!e.currentTarget.src.endsWith('/static/favicon.png')) {
e.currentTarget.src = `${WEBUI_BASE_URL}/static/favicon.png`;
}
}}
/>
</ProfilePreview>
<ChatParticipants
participants={chat.participants ?? []}
owner={chat.user_id ? { id: chat.user_id, name: chat.owner_name ?? null } : null}
/>
</div>
{/if}
@@ -0,0 +1,97 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Popover } from 'bits-ui';
import type { ChatParticipant } from '$lib/apis/folders';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte';
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
export let participants: ChatParticipant[] = [];
export let owner: ChatParticipant | null = null;
export let avatarClass = 'size-4';
export let activeProfileId: string | null = null;
export let overflowOpen = false;
$: people = participants.length ? participants : owner?.id ? [owner] : [];
$: if (activeProfileId && !people.some((person) => person.id === activeProfileId)) {
activeProfileId = null;
}
$: if (people.length <= 3) overflowOpen = false;
const setProfileOpen = (id: string, open: boolean) => {
if (open) activeProfileId = id;
else if (activeProfileId === id) activeProfileId = null;
};
</script>
{#snippet avatar(person: ChatParticipant, className: string)}
<img
src="{WEBUI_API_BASE_URL}/users/{person.id}/profile/image"
alt=""
draggable="false"
class="{className} rounded-full shrink-0 object-cover"
onerror={(event) => {
if (!event.currentTarget.src.endsWith('/static/favicon.png')) {
event.currentTarget.src = `${WEBUI_BASE_URL}/static/favicon.png`;
}
}}
/>
{/snippet}
<div class="flex shrink-0 items-center gap-1" data-chat-participants draggable="false">
<div class="flex items-center -space-x-1 rtl:space-x-reverse">
{#each people.slice(0, 3) as person (person.id)}
<div class="relative rounded-full hover:z-10 focus-within:z-10">
<ProfilePreview
user={{ id: person.id, name: person.name || $i18n.t('Unknown User') }}
bind:openPreview={
() => !overflowOpen && activeProfileId === person.id,
(open) => setProfileOpen(person.id, open)
}
>
{@render avatar(person, `${avatarClass} bg-white ring-1 ring-white dark:bg-gray-900 dark:ring-gray-900`)}
</ProfilePreview>
</div>
{/each}
</div>
{#if people.length > 3}
<Popover.Root bind:open={overflowOpen} onOpenChange={() => (activeProfileId = null)}>
<Popover.Trigger
class="shrink-0 rounded px-0.5 text-[0.625rem] text-gray-500 hover:bg-gray-100 focus-visible:outline dark:text-gray-400 dark:hover:bg-gray-800"
aria-label={$i18n.t('Show all {{count}} contributors', { count: people.length })}
>
+{people.length - 3}
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side="bottom"
align="start"
sideOffset={6}
class="z-[9999] w-60 max-w-[calc(100vw-1.5rem)] max-h-80 overflow-y-auto rounded-xl bg-white p-2 text-gray-900 shadow-lg ring-1 ring-black/5 dark:bg-gray-850 dark:text-gray-100 dark:ring-white/10"
aria-label={$i18n.t('Contributors')}
>
<ul class="space-y-1">
{#each people as person (person.id)}
<li class="rounded-lg p-1 hover:bg-gray-50 dark:hover:bg-gray-800">
<ProfilePreview
user={{ id: person.id, name: person.name || $i18n.t('Unknown User') }}
bind:openPreview={
() => overflowOpen && activeProfileId === person.id,
(open) => setProfileOpen(person.id, open)
}
>
{@render avatar(person, 'size-6')}
<span class="ms-2 min-w-0 truncate text-sm">
{person.name || $i18n.t('Unknown User')}
</span>
</ProfilePreview>
</li>
{/each}
</ul>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
{/if}
</div>
@@ -16,7 +16,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import type { ChatParticipant } from '$lib/apis/folders';
import { goto, invalidate, invalidateAll } from '$app/navigation';
import { onMount, getContext, createEventDispatcher, tick } from 'svelte';
import { LinkPreview } from 'bits-ui';
@@ -50,7 +50,7 @@
import DragGhost from '$lib/components/common/DragGhost.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ChatHoverPreview from './ChatHoverPreview.svelte';
import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte';
import ChatParticipants from '$lib/components/common/ChatParticipants.svelte';
import ChatIcon from './icons/Chat.svelte';
import MoreHorizontalIcon from './icons/MoreHorizontal.svelte';
import SparklesIcon from './icons/Sparkles.svelte';
@@ -79,6 +79,7 @@
export let ownerName: string | null = null;
export let ownerUserId: string | null = null;
export let participants: ChatParticipant[] = [];
export let onReadStateChange: (data: Record<string, unknown>) => void = () => {};
export let onDragEnd = () => {};
@@ -108,9 +109,10 @@
let focusWithin = false;
let menuOpen = false;
let openPreview = false;
let openProfilePreview = false;
let activeProfileId: string | null = null;
let overflowOpen = false;
$: if (openProfilePreview) openPreview = false;
$: if (activeProfileId || overflowOpen) openPreview = false;
const closeHoverPreview = () => {
if (openPreview) {
@@ -636,23 +638,15 @@
</div>
{:else}
<div id="sidebar-chat-item" class={chatItemClass}>
{#if ownerUserId}
{#if ownerUserId || participants.length > 1}
<div class="flex items-center self-center shrink-0 mr-2">
<ProfilePreview
user={{ id: ownerUserId, name: ownerName }}
bind:openPreview={openProfilePreview}
>
<img
src="{WEBUI_API_BASE_URL}/users/{ownerUserId}/profile/image"
alt=""
class="size-3.5 rounded-full shrink-0 object-cover"
on:error={(e) => {
if (!e.currentTarget.src.endsWith('/static/favicon.png')) {
e.currentTarget.src = `${WEBUI_BASE_URL}/static/favicon.png`;
}
}}
/>
</ProfilePreview>
<ChatParticipants
{participants}
owner={ownerUserId ? { id: ownerUserId, name: ownerName } : null}
avatarClass="size-3.5"
bind:activeProfileId
bind:overflowOpen
/>
</div>
{/if}
{#if $mobile}
@@ -672,7 +666,10 @@
disabled={confirmEdit || dragged || !($settings?.chatHoverPreview ?? true)}
bind:open={openPreview}
onOpenChange={(open) => {
if (open) openProfilePreview = false;
if (open) {
activeProfileId = null;
overflowOpen = false;
}
}}
>
<LinkPreview.Trigger
@@ -996,6 +996,7 @@
lastReadAt={chat.last_read_at}
active={chat.active ?? false}
ownerName={chat.owner_name ?? null}
participants={chat.participants ?? []}
ownerUserId={folders[folderId]?.showOwnerInfo ||
folders[folderId]?.shared ||
chat.user_id !== $user?.id
@@ -130,6 +130,7 @@
lastReadAt={chat.last_read_at}
active={chat.active ?? false}
ownerName={chat.owner_name}
participants={chat.participants ?? []}
ownerUserId={chat.user_id}
readonly={chat.readonly ?? !isWritable}
/>