mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-28 05:27:35 +00:00
feat(search): add chat context menu to search modal (#25490)
This commit is contained in:
parent
958fdbdc88
commit
4712544d5e
2 changed files with 412 additions and 28 deletions
|
|
@ -5,7 +5,18 @@
|
|||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import SearchInput from './Sidebar/SearchInput.svelte';
|
||||
import { getChatById, getChatList, getChatListBySearchText } from '$lib/apis/chats';
|
||||
import {
|
||||
getChatById,
|
||||
getChatList,
|
||||
getChatListBySearchText,
|
||||
cloneChatById,
|
||||
deleteChatById,
|
||||
archiveChatById,
|
||||
updateChatById,
|
||||
updateChatFolderIdById,
|
||||
getPinnedChatList,
|
||||
getAllTags
|
||||
} from '$lib/apis/chats';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
|
||||
import dayjs from '$lib/dayjs';
|
||||
|
|
@ -13,17 +24,234 @@
|
|||
import calendar from 'dayjs/plugin/calendar';
|
||||
import Loader from '../common/Loader.svelte';
|
||||
import { createMessagesList } from '$lib/utils';
|
||||
import { config, user } from '$lib/stores';
|
||||
import {
|
||||
config,
|
||||
user,
|
||||
chats,
|
||||
chatId as currentChatId,
|
||||
pinnedChats,
|
||||
currentChatPage,
|
||||
tags
|
||||
} from '$lib/stores';
|
||||
import Messages from '../chat/Messages.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import PencilSquare from '../icons/PencilSquare.svelte';
|
||||
import PageEdit from '../icons/PageEdit.svelte';
|
||||
|
||||
import ChatMenu from './Sidebar/ChatMenu.svelte';
|
||||
import ShareChatModal from '../chat/ShareChatModal.svelte';
|
||||
import DeleteConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import Sparkles from '../icons/Sparkles.svelte';
|
||||
import ArchiveBox from '../icons/ArchiveBox.svelte';
|
||||
import GarbageBin from '../icons/GarbageBin.svelte';
|
||||
import { generateTitle } from '$lib/apis';
|
||||
dayjs.extend(calendar);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
export let show = false;
|
||||
export let onClose = () => {};
|
||||
|
||||
let showShareChatModal = false;
|
||||
let showDeleteConfirm = false;
|
||||
let menuChatId = '';
|
||||
let menuChatTitle = '';
|
||||
|
||||
let editingChatId = null;
|
||||
let editingChatTitle = '';
|
||||
|
||||
let shiftKey = false;
|
||||
|
||||
const onShiftKeyDown = (e) => {
|
||||
if (e.key === 'Shift') shiftKey = true;
|
||||
};
|
||||
|
||||
const onShiftKeyUp = (e) => {
|
||||
if (e.key === 'Shift') shiftKey = false;
|
||||
};
|
||||
let generating = false;
|
||||
|
||||
const refreshSidebar = async () => {
|
||||
currentChatPage.set(1);
|
||||
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
||||
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
||||
};
|
||||
|
||||
const cloneChatHandler = async (id) => {
|
||||
const chat = chatList?.find((c) => c.id === id);
|
||||
const res = await cloneChatById(
|
||||
localStorage.token,
|
||||
id,
|
||||
$i18n.t('Clone of {{TITLE}}', {
|
||||
TITLE: chat?.title ?? 'Chat'
|
||||
})
|
||||
).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
await refreshSidebar();
|
||||
await searchHandler();
|
||||
}
|
||||
};
|
||||
|
||||
const archiveChatHandler = async (id) => {
|
||||
try {
|
||||
await archiveChatById(localStorage.token, id);
|
||||
|
||||
chatList = chatList?.filter((c) => c.id !== id) ?? null;
|
||||
|
||||
if ($currentChatId === id) {
|
||||
await goto('/');
|
||||
currentChatId.set('');
|
||||
}
|
||||
|
||||
await refreshSidebar();
|
||||
toast.success($i18n.t('Chat archived.'));
|
||||
} catch (error) {
|
||||
toast.error($i18n.t('Failed to archive chat.'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteChatHandler = async (id) => {
|
||||
const res = await deleteChatById(localStorage.token, id).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res) {
|
||||
chatList = chatList?.filter((c) => c.id !== id) ?? null;
|
||||
tags.set(await getAllTags(localStorage.token));
|
||||
|
||||
if ($currentChatId === id) {
|
||||
await goto('/');
|
||||
currentChatId.set('');
|
||||
}
|
||||
|
||||
await refreshSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
chatList = chatList?.filter((c) => c.id !== chatId) ?? null;
|
||||
await refreshSidebar();
|
||||
toast.success($i18n.t('Chat moved successfully'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renameHandler = async (id) => {
|
||||
editingChatId = id;
|
||||
editingChatTitle = chatList?.find((c) => c.id === id)?.title ?? '';
|
||||
|
||||
await tick();
|
||||
const input = document.getElementById(`search-chat-title-input-${id}`);
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRename = async () => {
|
||||
if (!editingChatId) return;
|
||||
|
||||
const trimmed = editingChatTitle.trim();
|
||||
if (trimmed === '') {
|
||||
toast.error($i18n.t('Title cannot be an empty string.'));
|
||||
return;
|
||||
}
|
||||
|
||||
await updateChatById(localStorage.token, editingChatId, { title: trimmed });
|
||||
|
||||
if (chatList) {
|
||||
chatList = chatList.map((c) => (c.id === editingChatId ? { ...c, title: trimmed } : c));
|
||||
}
|
||||
|
||||
editingChatId = null;
|
||||
editingChatTitle = '';
|
||||
await refreshSidebar();
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
editingChatId = null;
|
||||
editingChatTitle = '';
|
||||
};
|
||||
|
||||
const generateTitleHandler = async () => {
|
||||
if (!editingChatId || generating) return;
|
||||
|
||||
generating = true;
|
||||
const chat = await getChatById(localStorage.token, editingChatId).catch(() => null);
|
||||
|
||||
if (!chat) {
|
||||
toast.error($i18n.t('Failed to load chat'));
|
||||
generating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const chatContent = chat.chat;
|
||||
const history = chatContent?.history;
|
||||
let msgList = [];
|
||||
|
||||
if (history?.messages && history?.currentId) {
|
||||
msgList = createMessagesList(history, history.currentId).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}));
|
||||
} else {
|
||||
msgList = (chatContent?.messages ?? []).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}));
|
||||
}
|
||||
|
||||
let model = '';
|
||||
if (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;
|
||||
}
|
||||
}
|
||||
if (!model) {
|
||||
model = chatContent?.models?.at(0) ?? '';
|
||||
}
|
||||
|
||||
editingChatTitle = '';
|
||||
|
||||
const generatedTitle = await generateTitle(localStorage.token, model, msgList).catch(
|
||||
(error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
if (generatedTitle) {
|
||||
editingChatTitle = generatedTitle;
|
||||
}
|
||||
|
||||
generating = false;
|
||||
|
||||
if (generatedTitle) {
|
||||
await confirmRename();
|
||||
}
|
||||
};
|
||||
|
||||
let actions = [
|
||||
{
|
||||
label: $i18n.t('Start a new conversation'),
|
||||
|
|
@ -173,6 +401,10 @@
|
|||
|
||||
$: if (show) {
|
||||
searchHandler();
|
||||
} else {
|
||||
editingChatId = null;
|
||||
editingChatTitle = '';
|
||||
generating = false;
|
||||
}
|
||||
|
||||
const onKeyDown = (e) => {
|
||||
|
|
@ -187,6 +419,11 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Don't handle navigation/activation keys while editing a chat title
|
||||
if (editingChatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.code === 'Escape') {
|
||||
show = false;
|
||||
onClose();
|
||||
|
|
@ -252,6 +489,8 @@
|
|||
];
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('keydown', onShiftKeyDown);
|
||||
document.addEventListener('keyup', onShiftKeyUp);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -259,9 +498,25 @@
|
|||
clearTimeout(searchDebounceTimeout);
|
||||
}
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('keydown', onShiftKeyDown);
|
||||
document.removeEventListener('keyup', onShiftKeyUp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<ShareChatModal bind:show={showShareChatModal} chatId={menuChatId} />
|
||||
|
||||
<DeleteConfirmDialog
|
||||
bind:show={showDeleteConfirm}
|
||||
title={$i18n.t('Delete chat?')}
|
||||
on:confirm={() => {
|
||||
deleteChatHandler(menuChatId);
|
||||
}}
|
||||
>
|
||||
<div class="text-sm text-gray-500 flex-1 line-clamp-3">
|
||||
{$i18n.t('This will delete')} <span class="font-semibold">{menuChatTitle}</span>.
|
||||
</div>
|
||||
</DeleteConfirmDialog>
|
||||
|
||||
<Modal size="xl" bind:show>
|
||||
<div class="py-3 dark:text-gray-300 text-gray-700">
|
||||
<div class="px-4 pb-1.5">
|
||||
|
|
@ -373,42 +628,169 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
class=" w-full flex justify-between items-center rounded-xl text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx ===
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="w-full flex justify-between items-center rounded-xl text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 group/item relative {selectedIdx ===
|
||||
idx + actions.length
|
||||
? 'bg-gray-50 dark:bg-gray-850'
|
||||
: ''}"
|
||||
href="/c/{chat.id}"
|
||||
draggable="false"
|
||||
data-arrow-selected={selectedIdx === idx + actions.length ? 'true' : undefined}
|
||||
on:mouseenter={() => {
|
||||
selectedIdx = idx + actions.length;
|
||||
}}
|
||||
on:click={async () => {
|
||||
await goto(`/c/${chat.id}`);
|
||||
show = false;
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div class=" flex-1">
|
||||
<div class="text-ellipsis line-clamp-1 w-full">
|
||||
{chat?.title}
|
||||
{#if editingChatId === chat.id}
|
||||
<div class="flex-1 min-w-0">
|
||||
<input
|
||||
id="search-chat-title-input-{chat.id}"
|
||||
bind:value={editingChatTitle}
|
||||
class="bg-transparent w-full outline-none"
|
||||
placeholder={generating ? $i18n.t('Generating...') : ''}
|
||||
disabled={generating}
|
||||
on:keydown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
confirmRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
on:blur={() => {
|
||||
if (!generating) {
|
||||
confirmRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center shrink-0 pl-1">
|
||||
<Tooltip content={$i18n.t('Generate')}>
|
||||
<button
|
||||
class="self-center dark:hover:text-white transition disabled:cursor-not-allowed"
|
||||
disabled={generating}
|
||||
on:mousedown|preventDefault={() => {}}
|
||||
on:click|preventDefault|stopPropagation={() => {
|
||||
generateTitleHandler();
|
||||
}}
|
||||
>
|
||||
{#if generating}
|
||||
<Spinner className="size-4" />
|
||||
{:else}
|
||||
<Sparkles strokeWidth="2" />
|
||||
{/if}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
class="flex-1 min-w-0"
|
||||
href="/c/{chat.id}"
|
||||
draggable="false"
|
||||
on:click={async () => {
|
||||
await goto(`/c/${chat.id}`);
|
||||
show = false;
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div class="text-ellipsis line-clamp-1 w-full">
|
||||
{chat?.title}
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-1 pl-2 shrink-0">
|
||||
{#if editingChatId !== chat.id}
|
||||
{#if shiftKey}
|
||||
<div class="invisible group-hover/item:visible flex items-center space-x-1.5">
|
||||
<Tooltip content={$i18n.t('Archive')} className="flex items-center">
|
||||
<button
|
||||
class="self-center dark:hover:text-white transition"
|
||||
on:click|stopPropagation={() => {
|
||||
archiveChatHandler(chat.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"
|
||||
on:click|stopPropagation={() => {
|
||||
deleteChatHandler(chat.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<GarbageBin strokeWidth="2" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="invisible group-hover/item:visible">
|
||||
<ChatMenu
|
||||
chatId={chat.id}
|
||||
shareHandler={() => {
|
||||
menuChatId = chat.id;
|
||||
showShareChatModal = true;
|
||||
}}
|
||||
{moveChatHandler}
|
||||
cloneChatHandler={() => {
|
||||
cloneChatHandler(chat.id);
|
||||
}}
|
||||
archiveChatHandler={() => {
|
||||
archiveChatHandler(chat.id);
|
||||
}}
|
||||
renameHandler={() => {
|
||||
renameHandler(chat.id);
|
||||
}}
|
||||
deleteHandler={() => {
|
||||
menuChatId = chat.id;
|
||||
menuChatTitle = chat.title;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
onClose={() => {}}
|
||||
onPinChange={async () => {
|
||||
await refreshSidebar();
|
||||
await searchHandler();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
aria-label="Chat Menu"
|
||||
class="self-center dark:hover:text-white transition"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{$i18n.t(
|
||||
dayjs(chat?.updated_at * 1000).calendar(null, {
|
||||
sameDay: '[Today]',
|
||||
nextDay: '[Tomorrow]',
|
||||
nextWeek: 'dddd',
|
||||
lastDay: '[Yesterday]',
|
||||
lastWeek: '[Last] dddd',
|
||||
sameElse: 'L'
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" pl-3 shrink-0 text-gray-500 dark:text-gray-400 text-xs">
|
||||
{$i18n.t(
|
||||
dayjs(chat?.updated_at * 1000).calendar(null, {
|
||||
sameDay: '[Today]',
|
||||
nextDay: '[Tomorrow]',
|
||||
nextWeek: 'dddd',
|
||||
lastDay: '[Yesterday]',
|
||||
lastWeek: '[Last] dddd',
|
||||
sameElse: 'L' // use localized format, otherwise dayjs.calendar() defaults to DD/MM/YYYY
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if !allChatsLoaded}
|
||||
|
|
|
|||
|
|
@ -362,6 +362,7 @@
|
|||
draggable="false"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
renameHandler();
|
||||
}}
|
||||
>
|
||||
|
|
@ -375,6 +376,7 @@
|
|||
draggable="false"
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
pinHandler();
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue