diff options
| author | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
|---|---|---|
| committer | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
| commit | b333b06772c89d96aacb5490d6a219fba7c09cc6 (patch) | |
| tree | 211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar | |
| download | llmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz | |
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar')
5 files changed, 520 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte new file mode 100644 index 0000000..aa0c27f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte @@ -0,0 +1,211 @@ +<script lang="ts"> + import { goto } from '$app/navigation'; + import { page } from '$app/state'; + import { Trash2 } from '@lucide/svelte'; + import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app'; + import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte'; + import * as Sidebar from '$lib/components/ui/sidebar'; + import * as AlertDialog from '$lib/components/ui/alert-dialog'; + import Input from '$lib/components/ui/input/input.svelte'; + import { conversationsStore, conversations } from '$lib/stores/conversations.svelte'; + import { chatStore } from '$lib/stores/chat.svelte'; + import { getPreviewText } from '$lib/utils/text'; + import ChatSidebarActions from './ChatSidebarActions.svelte'; + + const sidebar = Sidebar.useSidebar(); + + let currentChatId = $derived(page.params.id); + let isSearchModeActive = $state(false); + let searchQuery = $state(''); + let showDeleteDialog = $state(false); + let showEditDialog = $state(false); + let selectedConversation = $state<DatabaseConversation | null>(null); + let editedName = $state(''); + let selectedConversationNamePreview = $derived.by(() => + selectedConversation ? getPreviewText(selectedConversation.name) : '' + ); + + let filteredConversations = $derived.by(() => { + if (searchQuery.trim().length > 0) { + return conversations().filter((conversation: { name: string }) => + conversation.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + } + + return conversations(); + }); + + async function handleDeleteConversation(id: string) { + const conversation = conversations().find((conv) => conv.id === id); + if (conversation) { + selectedConversation = conversation; + showDeleteDialog = true; + } + } + + async function handleEditConversation(id: string) { + const conversation = conversations().find((conv) => conv.id === id); + if (conversation) { + selectedConversation = conversation; + editedName = conversation.name; + showEditDialog = true; + } + } + + function handleConfirmDelete() { + if (selectedConversation) { + showDeleteDialog = false; + + setTimeout(() => { + conversationsStore.deleteConversation(selectedConversation.id); + selectedConversation = null; + }, 100); // Wait for animation to finish + } + } + + function handleConfirmEdit() { + if (!editedName.trim() || !selectedConversation) return; + + showEditDialog = false; + + conversationsStore.updateConversationName(selectedConversation.id, editedName); + selectedConversation = null; + } + + export function handleMobileSidebarItemClick() { + if (sidebar.isMobile) { + sidebar.toggle(); + } + } + + export function activateSearchMode() { + isSearchModeActive = true; + } + + export function editActiveConversation() { + if (currentChatId) { + const activeConversation = filteredConversations.find((conv) => conv.id === currentChatId); + + if (activeConversation) { + const event = new CustomEvent('edit-active-conversation', { + detail: { conversationId: currentChatId } + }); + document.dispatchEvent(event); + } + } + } + + async function selectConversation(id: string) { + if (isSearchModeActive) { + isSearchModeActive = false; + searchQuery = ''; + } + + await goto(`#/chat/${id}`); + } + + function handleStopGeneration(id: string) { + chatStore.stopGenerationForChat(id); + } +</script> + +<ScrollArea class="h-[100vh]"> + <Sidebar.Header class=" top-0 z-10 gap-6 bg-sidebar/50 px-4 py-4 pb-2 backdrop-blur-lg md:sticky"> + <a href="#/" onclick={handleMobileSidebarItemClick}> + <h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1> + </a> + + <ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery /> + </Sidebar.Header> + + <Sidebar.Group class="mt-4 space-y-2 p-0 px-4"> + {#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive} + <Sidebar.GroupLabel> + {isSearchModeActive ? 'Search results' : 'Conversations'} + </Sidebar.GroupLabel> + {/if} + + <Sidebar.GroupContent> + <Sidebar.Menu> + {#each filteredConversations as conversation (conversation.id)} + <Sidebar.MenuItem class="mb-1"> + <ChatSidebarConversationItem + conversation={{ + id: conversation.id, + name: conversation.name, + lastModified: conversation.lastModified, + currNode: conversation.currNode + }} + {handleMobileSidebarItemClick} + isActive={currentChatId === conversation.id} + onSelect={selectConversation} + onEdit={handleEditConversation} + onDelete={handleDeleteConversation} + onStop={handleStopGeneration} + /> + </Sidebar.MenuItem> + {/each} + + {#if filteredConversations.length === 0} + <div class="px-2 py-4 text-center"> + <p class="mb-4 p-4 text-sm text-muted-foreground"> + {searchQuery.length > 0 + ? 'No results found' + : isSearchModeActive + ? 'Start typing to see results' + : 'No conversations yet'} + </p> + </div> + {/if} + </Sidebar.Menu> + </Sidebar.GroupContent> + </Sidebar.Group> +</ScrollArea> + +<DialogConfirmation + bind:open={showDeleteDialog} + title="Delete Conversation" + description={selectedConversation + ? `Are you sure you want to delete "${selectedConversationNamePreview}"? This action cannot be undone and will permanently remove all messages in this conversation.` + : ''} + confirmText="Delete" + cancelText="Cancel" + variant="destructive" + icon={Trash2} + onConfirm={handleConfirmDelete} + onCancel={() => { + showDeleteDialog = false; + selectedConversation = null; + }} +/> + +<AlertDialog.Root bind:open={showEditDialog}> + <AlertDialog.Content> + <AlertDialog.Header> + <AlertDialog.Title>Edit Conversation Name</AlertDialog.Title> + <AlertDialog.Description> + <Input + class="mt-4 text-foreground" + onkeydown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleConfirmEdit(); + } + }} + placeholder="Enter a new name" + type="text" + bind:value={editedName} + /> + </AlertDialog.Description> + </AlertDialog.Header> + <AlertDialog.Footer> + <AlertDialog.Cancel + onclick={() => { + showEditDialog = false; + selectedConversation = null; + }}>Cancel</AlertDialog.Cancel + > + <AlertDialog.Action onclick={handleConfirmEdit}>Save</AlertDialog.Action> + </AlertDialog.Footer> + </AlertDialog.Content> +</AlertDialog.Root> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte new file mode 100644 index 0000000..30d1f9d --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte @@ -0,0 +1,81 @@ +<script lang="ts"> + import { Search, SquarePen, X } from '@lucide/svelte'; + import { KeyboardShortcutInfo } from '$lib/components/app'; + import { Button } from '$lib/components/ui/button'; + import { Input } from '$lib/components/ui/input'; + + interface Props { + handleMobileSidebarItemClick: () => void; + isSearchModeActive: boolean; + searchQuery: string; + } + + let { + handleMobileSidebarItemClick, + isSearchModeActive = $bindable(), + searchQuery = $bindable() + }: Props = $props(); + + let searchInput: HTMLInputElement | null = $state(null); + + function handleSearchModeDeactivate() { + isSearchModeActive = false; + searchQuery = ''; + } + + $effect(() => { + if (isSearchModeActive) { + searchInput?.focus(); + } + }); +</script> + +<div class="space-y-0.5"> + {#if isSearchModeActive} + <div class="relative"> + <Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" /> + + <Input + bind:ref={searchInput} + bind:value={searchQuery} + onkeydown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()} + placeholder="Search conversations..." + class="pl-8" + /> + + <X + class="cursor-pointertext-muted-foreground absolute top-2.5 right-2 h-4 w-4" + onclick={handleSearchModeDeactivate} + /> + </div> + {:else} + <Button + class="w-full justify-between hover:[&>kbd]:opacity-100" + href="?new_chat=true#/" + onclick={handleMobileSidebarItemClick} + variant="ghost" + > + <div class="flex items-center gap-2"> + <SquarePen class="h-4 w-4" /> + New chat + </div> + + <KeyboardShortcutInfo keys={['shift', 'cmd', 'o']} /> + </Button> + + <Button + class="w-full justify-between hover:[&>kbd]:opacity-100" + onclick={() => { + isSearchModeActive = true; + }} + variant="ghost" + > + <div class="flex items-center gap-2"> + <Search class="h-4 w-4" /> + Search conversations + </div> + + <KeyboardShortcutInfo keys={['cmd', 'k']} /> + </Button> + {/if} +</div> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte new file mode 100644 index 0000000..bf2fa4f --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte @@ -0,0 +1,200 @@ +<script lang="ts"> + import { Trash2, Pencil, MoreHorizontal, Download, Loader2, Square } from '@lucide/svelte'; + import { ActionDropdown } from '$lib/components/app'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import { getAllLoadingChats } from '$lib/stores/chat.svelte'; + import { conversationsStore } from '$lib/stores/conversations.svelte'; + import { onMount } from 'svelte'; + + interface Props { + isActive?: boolean; + conversation: DatabaseConversation; + handleMobileSidebarItemClick?: () => void; + onDelete?: (id: string) => void; + onEdit?: (id: string) => void; + onSelect?: (id: string) => void; + onStop?: (id: string) => void; + } + + let { + conversation, + handleMobileSidebarItemClick, + onDelete, + onEdit, + onSelect, + onStop, + isActive = false + }: Props = $props(); + + let renderActionsDropdown = $state(false); + let dropdownOpen = $state(false); + + let isLoading = $derived(getAllLoadingChats().includes(conversation.id)); + + function handleEdit(event: Event) { + event.stopPropagation(); + onEdit?.(conversation.id); + } + + function handleDelete(event: Event) { + event.stopPropagation(); + onDelete?.(conversation.id); + } + + function handleStop(event: Event) { + event.stopPropagation(); + onStop?.(conversation.id); + } + + function handleGlobalEditEvent(event: Event) { + const customEvent = event as CustomEvent<{ conversationId: string }>; + + if (customEvent.detail.conversationId === conversation.id && isActive) { + handleEdit(event); + } + } + + function handleMouseLeave() { + if (!dropdownOpen) { + renderActionsDropdown = false; + } + } + + function handleMouseOver() { + renderActionsDropdown = true; + } + + function handleSelect() { + onSelect?.(conversation.id); + } + + $effect(() => { + if (!dropdownOpen) { + renderActionsDropdown = false; + } + }); + + onMount(() => { + document.addEventListener('edit-active-conversation', handleGlobalEditEvent as EventListener); + + return () => { + document.removeEventListener( + 'edit-active-conversation', + handleGlobalEditEvent as EventListener + ); + }; + }); +</script> + +<!-- svelte-ignore a11y_mouse_events_have_key_events --> +<button + class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg px-3 py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive + ? 'bg-foreground/5 text-accent-foreground' + : ''}" + onclick={handleSelect} + onmouseover={handleMouseOver} + onmouseleave={handleMouseLeave} +> + <div class="flex min-w-0 flex-1 items-center gap-2"> + {#if isLoading} + <Tooltip.Root> + <Tooltip.Trigger> + <div + class="stop-button flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground" + onclick={handleStop} + onkeydown={(e) => e.key === 'Enter' && handleStop(e)} + role="button" + tabindex="0" + aria-label="Stop generation" + > + <Loader2 class="loading-icon h-3.5 w-3.5 animate-spin" /> + + <Square class="stop-icon hidden h-3 w-3 fill-current text-destructive" /> + </div> + </Tooltip.Trigger> + + <Tooltip.Content> + <p>Stop generation</p> + </Tooltip.Content> + </Tooltip.Root> + {/if} + + <!-- svelte-ignore a11y_click_events_have_key_events --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <span class="truncate text-sm font-medium" onclick={handleMobileSidebarItemClick}> + {conversation.name} + </span> + </div> + + {#if renderActionsDropdown} + <div class="actions flex items-center"> + <ActionDropdown + triggerIcon={MoreHorizontal} + triggerTooltip="More actions" + bind:open={dropdownOpen} + actions={[ + { + icon: Pencil, + label: 'Edit', + onclick: handleEdit, + shortcut: ['shift', 'cmd', 'e'] + }, + { + icon: Download, + label: 'Export', + onclick: (e) => { + e.stopPropagation(); + conversationsStore.downloadConversation(conversation.id); + }, + shortcut: ['shift', 'cmd', 's'] + }, + { + icon: Trash2, + label: 'Delete', + onclick: handleDelete, + variant: 'destructive', + shortcut: ['shift', 'cmd', 'd'], + separator: true + } + ]} + /> + </div> + {/if} +</button> + +<style> + button { + :global([data-slot='dropdown-menu-trigger']:not([data-state='open'])) { + opacity: 0; + } + + &:is(:hover) :global([data-slot='dropdown-menu-trigger']) { + opacity: 1; + } + @media (max-width: 768px) { + :global([data-slot='dropdown-menu-trigger']) { + opacity: 1 !important; + } + } + + .stop-button { + :global(.stop-icon) { + display: none; + } + + :global(.loading-icon) { + display: block; + } + } + + &:is(:hover) .stop-button { + :global(.stop-icon) { + display: block; + } + + :global(.loading-icon) { + display: none; + } + } + } +</style> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte new file mode 100644 index 0000000..afc9847 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import { SearchInput } from '$lib/components/app'; + + interface Props { + value?: string; + placeholder?: string; + onInput?: (value: string) => void; + class?: string; + } + + let { + value = $bindable(''), + placeholder = 'Search conversations...', + onInput, + class: className + }: Props = $props(); +</script> + +<SearchInput bind:value {placeholder} {onInput} class="mb-4 {className}" /> diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts new file mode 100644 index 0000000..4b9b876 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts @@ -0,0 +1,9 @@ +import { useSidebar } from '$lib/components/ui/sidebar'; + +const sidebar = useSidebar(); + +export function handleMobileSidebarItemClick() { + if (sidebar.isMobile) { + sidebar.toggle(); + } +} |
