summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar
diff options
context:
space:
mode:
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar')
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte211
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte81
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte200
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte19
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/chat/ChatSidebar/handle-mobile-sidebar-item-click.ts9
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();
+ }
+}