Files
open-webui/src/lib/components/layout/Sidebar/ChatItem.svelte
T
2026-06-15 23:42:29 +02:00

699 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script context="module" lang="ts">
/** Shared 1×1 transparent drag preview; avoids one Image per sidebar row */
const invisibleDragImage = new Image();
invisibleDragImage.src =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
</script>
<script lang="ts">
import { toast } from 'svelte-sonner';
import { goto, invalidate, invalidateAll } from '$app/navigation';
import { onMount, getContext, createEventDispatcher, tick } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
import {
archiveChatById,
cloneChatById,
deleteChatById,
getAllTags,
getChatById,
getChatList,
getChatListByTagName,
getPinnedChatList,
updateChatById,
updateChatFolderIdById
} from '$lib/apis/chats';
import {
chatId,
chatTitle as _chatTitle,
chats,
mobile,
pinnedChats,
showSidebar,
currentChatPage,
tags,
selectedFolder,
activeChatIds
} from '$lib/stores';
import ChatMenu from './ChatMenu.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ShareChatModal from '$lib/components/chat/ShareChatModal.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
import DragGhost from '$lib/components/common/DragGhost.svelte';
import Check from '$lib/components/icons/Check.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Document from '$lib/components/icons/Document.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import { generateTitle } from '$lib/apis';
import { createMessagesList } from '$lib/utils';
export let className = '';
export let id;
export let title;
export let createdAt: number | null = null;
export let updatedAt: number | null = null;
export let lastReadAt: number | null = null;
export let selected = false;
export let shiftKey = false;
export let readonly = false;
export let onDragEnd = () => {};
function formatTimeAgo(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp * 1000; // timestamp is in seconds
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const years = Math.floor(days / 365);
if (years > 0) return $i18n.t('{{COUNT}}y', { COUNT: years, context: 'time_ago' });
if (weeks > 0) return $i18n.t('{{COUNT}}w', { COUNT: weeks, context: 'time_ago' });
if (days > 0) return $i18n.t('{{COUNT}}d', { COUNT: days, context: 'time_ago' });
if (hours > 0) return $i18n.t('{{COUNT}}h', { COUNT: hours, context: 'time_ago' });
if (minutes > 0) return $i18n.t('{{COUNT}}m', { COUNT: minutes, context: 'time_ago' });
return $i18n.t('1m', { context: 'time_ago' });
}
let chat = null;
let mouseOver = false;
// Local state: tracks the last updatedAt seen while the user was viewing
// this chat. Survives prop refreshes from sidebar data re-fetches that
// would overwrite the `lastReadAt` prop with a stale server value.
let viewedAt: number | null = null;
$: if (id === $chatId) {
viewedAt = updatedAt ?? Date.now() / 1000;
}
$: effectiveReadAt = Math.max(lastReadAt ?? 0, viewedAt ?? 0) || null;
$: unread =
id !== $chatId &&
!$activeChatIds.has(id) &&
(effectiveReadAt === null || (updatedAt !== null && updatedAt > effectiveReadAt));
const loadChat = async () => {
if (!chat) {
draggable = false;
chat = await getChatById(localStorage.token, id);
draggable = true;
}
};
let showShareChatModal = false;
let confirmEdit = false;
let chatTitle = title;
const editChatTitle = async (id, title) => {
if (title === '') {
toast.error($i18n.t('Title cannot be an empty string.'));
} else {
await updateChatById(localStorage.token, id, {
title: title
});
if (id === $chatId) {
_chatTitle.set(title);
}
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await pinnedChats.set(await getPinnedChatList(localStorage.token));
dispatch('change');
}
};
const cloneChatHandler = async (id) => {
const res = await cloneChatById(
localStorage.token,
id,
$i18n.t('Clone of {{TITLE}}', {
TITLE: title
})
).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
goto(`/c/${res.id}`);
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await pinnedChats.set(await getPinnedChatList(localStorage.token));
}
};
let deleting = false;
const deleteChatHandler = async (id) => {
if (deleting) return;
deleting = true;
const res = await deleteChatById(localStorage.token, id).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
tags.set(await getAllTags(localStorage.token));
if ($chatId === id) {
await goto('/');
await chatId.set('');
await tick();
}
dispatch('change');
}
deleting = false;
};
let archiving = false;
const archiveChatHandler = async (id) => {
if (archiving) return;
archiving = true;
try {
await archiveChatById(localStorage.token, id);
if ($chatId === id) {
await goto('/');
chatId.set('');
}
dispatch('change');
toast.success($i18n.t('Chat archived.'));
} catch (error) {
console.error('Error archiving chat:', error);
toast.error($i18n.t('Failed to archive chat.'));
} finally {
archiving = false;
}
};
const moveChatHandler = async (chatId, folderId) => {
if (chatId && folderId) {
const res = await updateChatFolderIdById(localStorage.token, chatId, folderId).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (res) {
currentChatPage.set(1);
await chats.set(await getChatList(localStorage.token, $currentChatPage));
await pinnedChats.set(await getPinnedChatList(localStorage.token));
dispatch('change');
toast.success($i18n.t('Chat moved successfully'));
}
} else {
toast.error($i18n.t('Failed to move chat'));
}
};
let itemElement;
let generating = false;
let ignoreBlur = false;
let doubleClicked = false;
let dragged = false;
let x = 0;
let y = 0;
const onDragStart = (event) => {
event.stopPropagation();
event.dataTransfer.setDragImage(invisibleDragImage, 0, 0);
// Set the data to be transferred
event.dataTransfer.setData(
'text/plain',
JSON.stringify({
type: 'chat',
id: id
})
);
dragged = true;
itemElement.style.opacity = '0.5'; // Optional: Visual cue to show it's being dragged
};
const onDrag = (event) => {
event.stopPropagation();
x = event.clientX;
y = event.clientY;
};
const onDragEndHandler = (event) => {
event.stopPropagation();
itemElement.style.opacity = '1'; // Reset visual cue after drag
dragged = false;
onDragEnd(event);
};
const onClickOutside = (event) => {
if (!itemElement.contains(event.target)) {
if (confirmEdit) {
if (chatTitle !== title) {
editChatTitle(id, chatTitle);
}
confirmEdit = false;
chatTitle = '';
}
}
};
onMount(() => {
const el = itemElement;
if (!el) return;
document.addEventListener('click', onClickOutside, true);
el.addEventListener('dragstart', onDragStart);
el.addEventListener('drag', onDrag);
el.addEventListener('dragend', onDragEndHandler);
return () => {
document.removeEventListener('click', onClickOutside, true);
el.removeEventListener('dragstart', onDragStart);
el.removeEventListener('drag', onDrag);
el.removeEventListener('dragend', onDragEndHandler);
};
});
let showDeleteConfirm = false;
const chatTitleInputKeydownHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
setTimeout(() => {
const input = document.getElementById(`chat-title-input-${id}`);
if (input) input.blur();
}, 0);
} else if (e.key === 'Escape') {
e.preventDefault();
confirmEdit = false;
chatTitle = '';
}
};
const renameHandler = async () => {
chatTitle = title;
confirmEdit = true;
await tick();
setTimeout(() => {
const input = document.getElementById(`chat-title-input-${id}`);
if (input) {
input.focus();
input.select();
}
}, 0);
};
const generateTitleHandler = async () => {
generating = true;
chat = await getChatById(localStorage.token, id);
const chatContent = chat.chat;
// Build the active branch message list from the history tree.
// Fallback to the legacy flat messages array for older chats
// that haven't been migrated to the tree structure yet.
const history = chatContent?.history;
let messages = [];
if (history?.messages && history?.currentId) {
messages = createMessagesList(history, history.currentId).map((message) => ({
role: message.role,
content: message.content
}));
} else {
messages = (chatContent?.messages ?? []).map((message) => ({
role: message.role,
content: message.content
}));
}
// Resolve the model from the most recent assistant message in the
// active branch. This avoids using the stale top-level `models`
// array which may reference a model from an older edit.
let model = '';
// For the active chat, prefer the live dropdown selection.
if (id === $chatId) {
try {
model = JSON.parse(sessionStorage.selectedModels || '[]').find((m) => m) ?? '';
} catch {}
}
if (!model && history?.messages && history?.currentId) {
let currentId = history.currentId;
while (currentId) {
const msg = history.messages[currentId];
if (!msg) break;
if (msg.role === 'assistant' && msg.model) {
model = msg.model;
break;
}
currentId = msg.parentId;
}
}
// Fallback to top-level models if no model was found in the history
if (!model) {
model = chatContent?.models?.at(0) ?? '';
}
chatTitle = '';
const generatedTitle = await generateTitle(localStorage.token, model, messages).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (generatedTitle) {
if (generatedTitle !== title) {
editChatTitle(id, generatedTitle);
}
confirmEdit = false;
} else {
chatTitle = title;
}
generating = false;
};
</script>
<ShareChatModal bind:show={showShareChatModal} chatId={id} />
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete chat?')}
on:confirm={() => {
deleteChatHandler(id);
}}
>
<div class=" text-sm text-gray-500 flex-1 line-clamp-3">
{$i18n.t('This will delete')} <span class=" font-semibold">{title}</span>.
</div>
</DeleteConfirmDialog>
{#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">
<div class="flex items-center gap-1">
<Document className=" size-[18px]" strokeWidth="2" />
<div class=" text-xs text-white line-clamp-1">
{title}
</div>
</div>
</div>
</DragGhost>
{/if}
<div
id="sidebar-chat-group"
bind:this={itemElement}
class=" w-full {className} relative group"
draggable={!confirmEdit && !readonly}
>
{#if confirmEdit}
<div
id="sidebar-chat-item"
class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId ||
confirmEdit
? 'bg-gray-100 dark:bg-gray-900 selected'
: selected
? 'bg-gray-100 dark:bg-gray-950 selected'
: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis relative {generating
? 'cursor-not-allowed'
: ''}"
>
<input
id="chat-title-input-{id}"
bind:value={chatTitle}
class=" bg-transparent w-full outline-hidden mr-10"
placeholder={generating ? $i18n.t('Generating...') : ''}
disabled={generating}
on:keydown={chatTitleInputKeydownHandler}
on:blur={async (e) => {
if (doubleClicked) {
e.preventDefault();
e.stopPropagation();
await tick();
setTimeout(() => {
const input = document.getElementById(`chat-title-input-${id}`);
if (input) input.focus();
}, 0);
doubleClicked = false;
return;
}
}}
/>
</div>
{:else}
<a
id="sidebar-chat-item"
class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId ||
confirmEdit
? 'bg-gray-100 dark:bg-gray-900 selected'
: selected
? 'bg-gray-100 dark:bg-gray-950 selected'
: ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'} whitespace-nowrap text-ellipsis"
href="/c/{id}"
on:click={() => {
dispatch('select');
if ($selectedFolder) {
selectedFolder.set(null);
}
if ($mobile) {
showSidebar.set(false);
}
// Optimistically mark as read in UI when clicked
unread = false;
lastReadAt = Date.now() / 1000;
}}
on:dblclick={async (e) => {
if (readonly) return;
e.preventDefault();
e.stopPropagation();
doubleClicked = true;
renameHandler();
}}
on:mouseenter={(e) => {
mouseOver = true;
}}
on:mouseleave={(e) => {
mouseOver = false;
}}
on:focus={(e) => {}}
draggable="false"
>
<!-- Loading spinner for active chat (left side) -->
{#if $activeChatIds.has(id)}
<div class="shrink-0 self-center pr-2">
<Spinner className="size-3" />
</div>
{/if}
<div class="flex self-center flex-1 w-full min-w-0">
{#if unread}
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
<div class="size-1.5 bg-sky-500 rounded-full" />
</div>
{/if}
<div
dir="auto"
class="text-left self-center overflow-hidden w-full h-[20px] truncate {unread
? 'font-medium text-gray-900 dark:text-gray-100'
: ''}"
>
{title}
</div>
</div>
<!-- Time ago indicator -->
{#if createdAt && !mouseOver}
<div class="shrink-0 self-center text-[10px] text-gray-400 dark:text-gray-500 pl-2">
{formatTimeAgo(createdAt)}
</div>
{/if}
</a>
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if !readonly}
<div
id="sidebar-chat-item-menu"
class="
{id === $chatId || confirmEdit
? 'from-gray-100 dark:from-gray-900 selected'
: selected
? 'from-gray-100 dark:from-gray-950 selected'
: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
absolute {className === 'pr-2'
? 'right-[8px]'
: 'right-1'} top-[4px] py-1 pr-0.5 mr-1.5 pl-5 bg-linear-to-l from-80%
to-transparent"
on:mouseenter={(e) => {
mouseOver = true;
}}
on:mouseleave={(e) => {
mouseOver = false;
}}
>
{#if confirmEdit}
<div
class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
>
<Tooltip content={$i18n.t('Generate')}>
<button
class=" self-center dark:hover:text-white transition disabled:cursor-not-allowed"
id="generate-title-button"
disabled={generating}
on:click={() => {
generateTitleHandler();
}}
>
<Sparkles strokeWidth="2" />
</button>
</Tooltip>
</div>
{:else if shiftKey && mouseOver}
<div class=" flex items-center self-center space-x-1.5">
<Tooltip content={$i18n.t('Archive')} className="flex items-center">
<button
class=" self-center dark:hover:text-white transition disabled:cursor-not-allowed"
disabled={archiving}
on:click={() => {
archiveChatHandler(id);
}}
type="button"
>
<ArchiveBox className="size-4 translate-y-[0.5px]" strokeWidth="2" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class=" self-center dark:hover:text-white transition disabled:cursor-not-allowed"
disabled={deleting}
on:click={() => {
deleteChatHandler(id);
}}
type="button"
>
<GarbageBin strokeWidth="2" />
</button>
</Tooltip>
</div>
{:else}
<div class="flex self-center z-10 items-end">
<ChatMenu
chatId={id}
cloneChatHandler={() => {
cloneChatHandler(id);
}}
shareHandler={() => {
showShareChatModal = true;
}}
{moveChatHandler}
archiveChatHandler={() => {
archiveChatHandler(id);
}}
{renameHandler}
deleteHandler={() => {
showDeleteConfirm = true;
}}
onClose={() => {
dispatch('unselect');
}}
onPinChange={async () => {
dispatch('change');
}}
>
<button
aria-label="Chat Menu"
class=" self-center dark:hover:text-white transition m-0"
on:click={() => {
dispatch('select');
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M2 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM6.5 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM12.5 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
/>
</svg>
</button>
</ChatMenu>
{#if id === $chatId}
<!-- Shortcut support using "delete-chat-button" id -->
<button
id="delete-chat-button"
class="hidden"
on:click={() => {
showDeleteConfirm = true;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M2 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM6.5 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM12.5 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
/>
</svg>
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>