mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-07 16:37:57 +02:00
Compare commits
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Eye, Mic, Video } from '@lucide/svelte';
|
||||
import { Image, Mic, Video } from '@lucide/svelte';
|
||||
import { ModelModality } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
@@ -19,7 +19,7 @@
|
||||
]}
|
||||
>
|
||||
{#if modality === ModelModality.VISION}
|
||||
<Eye class="h-3 w-3" />
|
||||
<Image class="h-3 w-3" />
|
||||
|
||||
Vision (Image)
|
||||
{:else if modality === ModelModality.VIDEO}
|
||||
|
||||
+5
-5
@@ -2,9 +2,9 @@
|
||||
import {
|
||||
ChatAttachmentsListItem,
|
||||
DialogChatAttachmentsPreview,
|
||||
DialogMcpResourcePreview,
|
||||
HorizontalScrollCarousel
|
||||
DialogMcpResourcePreview
|
||||
} from '$lib/components/app';
|
||||
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
|
||||
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
|
||||
import { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from '$lib/utils';
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
uploadedFiles = $bindable([])
|
||||
}: Props = $props();
|
||||
|
||||
let carouselRef: HorizontalScrollCarousel | undefined = $state();
|
||||
let carouselRef: ScrollCarousel | undefined = $state();
|
||||
let mcpResourcePreviewOpen = $state(false);
|
||||
let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
|
||||
let previewFocusIndex = $state(0);
|
||||
@@ -91,11 +91,11 @@
|
||||
{#if displayItems.length > 0}
|
||||
<div class={className} {style}>
|
||||
{#if limitToSingleRow}
|
||||
<HorizontalScrollCarousel bind:this={carouselRef}>
|
||||
<ScrollCarousel bind:this={carouselRef} variant="center">
|
||||
{#each displayItems as item (item.id)}
|
||||
{@render attachmentitem(item)}
|
||||
{/each}
|
||||
</HorizontalScrollCarousel>
|
||||
</ScrollCarousel>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-start justify-end gap-3">
|
||||
{#each displayItems as item (item.id)}
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { FileText, Music, Video } from '@lucide/svelte';
|
||||
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
|
||||
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
|
||||
import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
|
||||
|
||||
interface PreviewItem {
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
{#if items.length > 1}
|
||||
<div class="sticky bottom-0 z-10 mt-4 flex-shrink-0">
|
||||
<HorizontalScrollCarousel class="max-w-full">
|
||||
<ScrollCarousel class="max-w-full" variant="center">
|
||||
{#each items as item, index (item.id)}
|
||||
<button
|
||||
{...{ [UI_DATA_ATTRS.THUMBNAIL_INDEX]: index }}
|
||||
@@ -64,6 +64,6 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</HorizontalScrollCarousel>
|
||||
</ScrollCarousel>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+2
-7
@@ -2,7 +2,6 @@
|
||||
import { File, FolderOpen, MessageSquare, Plus, Zap } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddMcpServersSubmenu,
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
ChatFormActionAddToolsSubmenu
|
||||
} from '$lib/components/app';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
@@ -93,10 +92,6 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<File class={ICON_CLASS_DEFAULT} />
|
||||
@@ -156,11 +151,11 @@
|
||||
|
||||
<ChatFormActionAddToolsSubmenu />
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
|
||||
|
||||
{#if chatFormActions.hasMcpPromptsSupport}
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpPromptClick}
|
||||
|
||||
+59
-61
@@ -8,69 +8,67 @@
|
||||
const reasoning = useReasoningMenu();
|
||||
</script>
|
||||
|
||||
{#if reasoning.modelSupportsThinking}
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
{#if reasoning.thinkingEnabled}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="text-sm inline-flex gap-2 {!reasoning.thinkingEnabled
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
>
|
||||
Reasoning
|
||||
|
||||
<span class="capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent
|
||||
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
|
||||
<span
|
||||
class="text-sm inline-flex gap-2 {!reasoning.isReasoningActive
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
>
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
|
||||
level
|
||||
)
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
onclick={() => reasoning.select(level)}
|
||||
>
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
Reasoning
|
||||
|
||||
<span class="flex-1">{level.label}</span>
|
||||
<span class="capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="text-[11px] text-muted-foreground opacity-60">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
<DropdownMenu.SubContent
|
||||
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
|
||||
>
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
|
||||
level
|
||||
)
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
onclick={() => reasoning.select(level)}
|
||||
>
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
{#if level.hasInfo}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="left">
|
||||
<p>Maximum reasoning effort with extended context usage</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
{/if}
|
||||
<span class="flex-1">{level.label}</span>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="text-[11px] text-muted-foreground opacity-60">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if level.hasInfo}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="left">
|
||||
<p>Maximum reasoning effort with extended context usage</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
+3
-1
@@ -100,7 +100,7 @@
|
||||
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if reasoning.thinkingEnabled}
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
@@ -194,6 +194,8 @@
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
||||
<div class="h-px bg-border"></div>
|
||||
|
||||
<Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if mcpExpanded}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ChatAttachmentsListItemMcpResource,
|
||||
HorizontalScrollCarousel
|
||||
} from '$lib/components/app';
|
||||
import { ChatAttachmentsListItemMcpResource } from '$lib/components/app';
|
||||
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
|
||||
import { mcpStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
@@ -26,7 +24,7 @@
|
||||
|
||||
{#if hasAttachments}
|
||||
<div class={className}>
|
||||
<HorizontalScrollCarousel gapSize="2">
|
||||
<ScrollCarousel gapSize="2" variant="center">
|
||||
{#each attachments as attachment, i (attachment.id)}
|
||||
<ChatAttachmentsListItemMcpResource
|
||||
class={i === 0 ? 'ml-3' : ''}
|
||||
@@ -35,6 +33,6 @@
|
||||
onclick={() => handleResourceClick(attachment.resource.uri)}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalScrollCarousel>
|
||||
</ScrollCarousel>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
+2
-2
@@ -196,7 +196,7 @@
|
||||
--assistant-min-height-offset: calc(
|
||||
var(--last-user-message-height, 19rem) + var(--chat-form-height, 6rem) +
|
||||
var(--chat-form-bottom-position, 0.5rem) + var(--chat-form-padding-top, 6rem) +
|
||||
var(--assistant-margin-top, 3rem)
|
||||
var(--assistant-margin-top, 3rem) + var(--chat-tabs-offset, 0px)
|
||||
);
|
||||
min-height: calc(100dvh - var(--assistant-min-height-offset));
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
--assistant-min-height-offset: calc(
|
||||
var(--last-user-message-height, 18rem) + var(--chat-form-height, 6rem) +
|
||||
var(--chat-form-bottom-position, 1rem) + var(--chat-form-padding-top, 6rem) +
|
||||
var(--assistant-margin-top, 3rem)
|
||||
var(--assistant-margin-top, 3rem) + var(--chat-tabs-offset, 0px)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,11 +40,10 @@
|
||||
let initialMessage = $state('');
|
||||
let showDeleteDialog = $state(false);
|
||||
let showEmptyFileDialog = $state(false);
|
||||
// a new-chat tab is an unsaved (temporary) conversation, so the empty
|
||||
// greeting shows whenever the route is a new-chat tab with no messages
|
||||
let isEmpty = $derived(
|
||||
showCenteredEmpty &&
|
||||
!conversationsStore.activeConversation &&
|
||||
conversationsStore.activeMessages.length === 0 &&
|
||||
!chatStore.isLoading
|
||||
showCenteredEmpty && conversationsStore.activeMessages.length === 0 && !chatStore.isLoading
|
||||
);
|
||||
let activeErrorDialog = $derived(chatStore.errorDialogState);
|
||||
let isServerLoading = $derived(serverStore.loading);
|
||||
@@ -76,7 +75,9 @@
|
||||
});
|
||||
const { handleKeydown } = useKeyboardShortcuts({
|
||||
deleteActiveConversation: () => {
|
||||
if (conversationsStore.activeConversation) {
|
||||
const conversation = conversationsStore.activeConversation;
|
||||
|
||||
if (conversation) {
|
||||
showDeleteDialog = true;
|
||||
}
|
||||
}
|
||||
@@ -297,7 +298,7 @@
|
||||
<ServerLoadingSplash />
|
||||
{:else}
|
||||
<div
|
||||
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-full px-4 md:py-0 pt-12 pb-48 md:pb-4"
|
||||
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-1rem-var(--chat-tabs-offset,0px))] px-4 md:py-0 pt-12 pb-48 md:pb-4"
|
||||
style:--chat-form-bottom-position={chatFormBottomPosition}
|
||||
ondragenter={dragAndDrop.dragHandlers.dragenter}
|
||||
ondragleave={dragAndDrop.dragHandlers.dragleave}
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
tooltip="Scroll to bottom"
|
||||
size="lg"
|
||||
iconSize={ICON_CLASS_DEFAULT}
|
||||
class="h-9 w-9 rounded-full bg-accent text-accent-foreground absolute bottom-4 shadow-md"
|
||||
class="h-9 w-9 rounded-full bg-muted/60 border border-border/50 shadow-sm text-accent-foreground absolute bottom-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import ChatTabsItem from './ChatTabsItem.svelte';
|
||||
import { Plus } from '@lucide/svelte';
|
||||
import { page } from '$app/state';
|
||||
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
|
||||
import { chatStore, conversationsStore, NEW_CHAT_TAB_ID, tabsStore, uiStore } from '$lib/stores';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
const carousel = useScrollCarousel();
|
||||
|
||||
let activeId = $derived(page.params.id ?? NEW_CHAT_TAB_ID);
|
||||
|
||||
let tabs = $derived(
|
||||
tabsStore.openTabs.map((id) => ({
|
||||
id,
|
||||
isNewChat: id === NEW_CHAT_TAB_ID,
|
||||
name:
|
||||
id === NEW_CHAT_TAB_ID
|
||||
? 'New chat'
|
||||
: (conversationsStore.conversations.find((c) => c.id === id)?.name ?? 'Chat')
|
||||
}))
|
||||
);
|
||||
|
||||
// hide the New chat button while a new-chat tab is already open
|
||||
let showNewChatButton = $derived(!tabsStore.openTabs.includes(NEW_CHAT_TAB_ID));
|
||||
|
||||
let loadingIds = $derived(new Set(chatStore.getAllLoadingChats()));
|
||||
|
||||
function handleClose(id: string) {
|
||||
void tabsStore.close(id, activeId ?? null);
|
||||
}
|
||||
|
||||
function handleStop(id: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
void chatStore.stopGenerationForChat(id);
|
||||
}
|
||||
|
||||
function handleAuxClick(id: string, event: MouseEvent) {
|
||||
// middle-click closes, like browser tabs
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
handleClose(id);
|
||||
}
|
||||
}
|
||||
|
||||
let previousTabIds = new Set<string>();
|
||||
let previousActiveId: string | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const currentIds = new Set(tabs.map((t) => t.id));
|
||||
const addedIds = tabs.filter((t) => !previousTabIds.has(t.id)).map((t) => t.id);
|
||||
|
||||
previousTabIds = currentIds;
|
||||
|
||||
const activeChanged = activeId !== previousActiveId;
|
||||
|
||||
previousActiveId = activeId;
|
||||
|
||||
// scroll when the active tab changes (a click) or when a new tab is added
|
||||
if (addedIds.length === 0 && !activeChanged) return;
|
||||
|
||||
// wait for the new tab to be laid out before scrolling to it
|
||||
void tick().then(() => {
|
||||
const el = carousel.scrollContainer?.querySelector<HTMLElement>('[data-active-tab]');
|
||||
|
||||
if (el && activeId) {
|
||||
carousel.scrollToCenter(el);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="group sticky pl-1 top-0 z-10 hidden md:block chat-tabs-fade transition-[padding] duration-200 ease-in-out pt-3.25 {uiStore.isSidebarExpanded
|
||||
? 'max-w-[calc(100vw-20rem)]'
|
||||
: 'max-w-[calc(100vw-5rem)]'}"
|
||||
aria-label="Open conversations"
|
||||
>
|
||||
<div class="relative">
|
||||
<ScrollCarousel
|
||||
class="h-10"
|
||||
containerClass="flex h-10 min-w-0 items-center"
|
||||
innerClass="items-center gap-1.25"
|
||||
{carousel}
|
||||
>
|
||||
{#each tabs as tab (tab.id)}
|
||||
<ChatTabsItem
|
||||
{tab}
|
||||
isActive={tab.id === activeId}
|
||||
isLoading={loadingIds.has(tab.id)}
|
||||
onActivate={(id) => tabsStore.activate(id)}
|
||||
onClose={handleClose}
|
||||
onStop={handleStop}
|
||||
onAuxClick={handleAuxClick}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if showNewChatButton}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="backdrop-blur-lg flex h-8 w-8 mr-4 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-foreground/5"
|
||||
onclick={() => conversationsStore.openNewChat()}
|
||||
aria-label="New chat"
|
||||
>
|
||||
<Plus class="h-4 w-4 opacity-40 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>New chat</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</ScrollCarousel>
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-[5] w-8 bg-gradient-to-r from-background to-transparent transition-opacity {carousel.canScrollLeft
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'}"
|
||||
></div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-[5] w-8 bg-gradient-to-l from-background to-transparent transition-opacity {carousel.canScrollRight
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'}"
|
||||
></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.chat-tabs-fade {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--background) 100%, transparent) 25%,
|
||||
color-mix(in srgb, var(--background) 80%, transparent) 50%,
|
||||
color-mix(in srgb, var(--background) 40%, transparent) 75%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { Loader2, Square, SquarePen, X } from '@lucide/svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
isNewChat: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tab: Tab;
|
||||
isActive?: boolean;
|
||||
isLoading?: boolean;
|
||||
onActivate?: (id: string) => void;
|
||||
onClose?: (id: string) => void;
|
||||
onStop?: (id: string, event: MouseEvent) => void;
|
||||
onAuxClick?: (id: string, event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isActive = false,
|
||||
isLoading = false,
|
||||
onActivate,
|
||||
onAuxClick,
|
||||
onClose,
|
||||
onStop,
|
||||
tab
|
||||
}: Props = $props();
|
||||
|
||||
let contentOpacity = $derived(isActive ? '' : 'opacity-45 group-hover:opacity-75');
|
||||
|
||||
let href = $derived(tab.isNewChat ? ROUTES.START : RouterService.chat(tab.id));
|
||||
|
||||
function handleActivate(event: MouseEvent) {
|
||||
// keep routing through tabsStore.activate() so the new-chat sentinel
|
||||
// and history behave exactly like the programmatic navigation
|
||||
event.preventDefault();
|
||||
onActivate?.(tab.id);
|
||||
}
|
||||
|
||||
// stop/close sit inside the tab link; swallow the click so the tab does
|
||||
// not also navigate
|
||||
function handleActionClick(event: MouseEvent, action: () => void) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
action();
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
data-active-tab={isActive ? 'true' : undefined}
|
||||
{href}
|
||||
class={cn(
|
||||
'flex h-8 max-w-52 min-w-0 shrink-0 cursor-pointer items-center gap-1 rounded-lg pr-1 text-sm whitespace-nowrap no-underline transition-[background-color,border-color,box-shadow] hover:bg-foreground/10 border backdrop-blur-xl first:ml-2',
|
||||
isLoading ? 'pl-1' : 'pl-3',
|
||||
isActive
|
||||
? 'bg-muted/60 border-border/10 shadow-sm text-accent-foreground hover:bg-primary/15'
|
||||
: 'border-transparent hover:bg-primary/10 hover:border-border/10 hover:shadow-sm'
|
||||
)}
|
||||
onclick={handleActivate}
|
||||
onauxclick={(e) => onAuxClick?.(tab.id, e)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="stop-button flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
onclick={(e) => handleActionClick(e, () => onStop?.(tab.id, e))}
|
||||
aria-label="Stop generation"
|
||||
>
|
||||
<Loader2
|
||||
class="loading-icon h-3.5 w-3.5 animate-spin transition-opacity duration-300 {contentOpacity}"
|
||||
/>
|
||||
|
||||
<Square
|
||||
class="stop-icon hidden h-3 w-3 fill-current text-destructive transition-opacity {contentOpacity}"
|
||||
/>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Stop generation</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if tab.isNewChat}
|
||||
<SquarePen class="h-3.5 w-3.5 shrink-0 transition-opacity {contentOpacity}" />
|
||||
{/if}
|
||||
|
||||
<span class="truncate transition-opacity {contentOpacity}">{tab.name}</span>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class={cn(
|
||||
'flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:bg-foreground/10 hover:text-foreground',
|
||||
contentOpacity
|
||||
)}
|
||||
onclick={(e) => handleActionClick(e, () => onClose?.(tab.id))}
|
||||
aria-label="Close tab"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Close tab</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.stop-button {
|
||||
:global(.stop-icon) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.loading-icon) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:is(:hover) {
|
||||
:global(.stop-icon) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:global(.loading-icon) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -686,6 +686,18 @@ export { default as ChatMessageSystem } from './ChatMessages/ChatMessage/ChatMes
|
||||
*/
|
||||
export { default as ChatScreen } from './ChatScreen/ChatScreen.svelte';
|
||||
|
||||
/**
|
||||
* **ChatTabs** - Browser-style tab bar for open conversations
|
||||
*
|
||||
* Horizontal strip of tabs rendered above ChatScreen in the chat layout,
|
||||
* one per conversation tracked by tabsStore. The active tab follows the
|
||||
* route's conversation id; clicking a tab navigates to it, middle-click or
|
||||
* the close button closes it (switching to the left neighbor when closing
|
||||
* the active tab), and a trailing "+" button starts a new chat. Shows a
|
||||
* spinner on tabs with a running generation. Desktop-only.
|
||||
*/
|
||||
export { default as ChatTabs } from './ChatTabs/ChatTabs.svelte';
|
||||
|
||||
/**
|
||||
* Visual overlay displayed when user drags files over the chat screen.
|
||||
* Shows drop zone indicator to guide users where to release files.
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
gapSize?: string;
|
||||
onScrollableChange?: (isScrollable: boolean) => void;
|
||||
}
|
||||
|
||||
let { children, class: className = '', gapSize = '3', onScrollableChange }: Props = $props();
|
||||
|
||||
let canScrollLeft = $state(false);
|
||||
let canScrollRight = $state(false);
|
||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||
|
||||
function scrollLeft(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
scrollContainer.scrollBy({ behavior: 'smooth', left: scrollContainer.clientWidth * -0.67 });
|
||||
}
|
||||
|
||||
function scrollRight(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
scrollContainer.scrollBy({ behavior: 'smooth', left: scrollContainer.clientWidth * 0.67 });
|
||||
}
|
||||
|
||||
function updateScrollButtons() {
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const { clientWidth, scrollLeft, scrollWidth } = scrollContainer;
|
||||
|
||||
canScrollLeft = scrollLeft > 0;
|
||||
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
|
||||
|
||||
const isScrollable = scrollWidth > clientWidth;
|
||||
|
||||
onScrollableChange?.(isScrollable);
|
||||
}
|
||||
|
||||
export function resetScroll() {
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollLeft = 0;
|
||||
setTimeout(() => {
|
||||
updateScrollButtons();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const observer = new ResizeObserver(() => updateScrollButtons());
|
||||
|
||||
observer.observe(scrollContainer);
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative {className}">
|
||||
<button
|
||||
class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-background/25 shadow-md backdrop-blur-xs transition-opacity hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0"
|
||||
onclick={scrollLeft}
|
||||
disabled={!canScrollLeft}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft class={ICON_CLASS_DEFAULT} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="scrollbar-hide flex items-start gap-{gapSize} overflow-x-auto"
|
||||
bind:this={scrollContainer}
|
||||
onscroll={updateScrollButtons}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-background/25 shadow-md backdrop-blur-xs transition-opacity hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0"
|
||||
onclick={scrollRight}
|
||||
disabled={!canScrollRight}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight class={ICON_CLASS_DEFAULT} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -21,13 +21,6 @@
|
||||
*/
|
||||
export { default as ConversationSelection } from './ConversationSelection.svelte';
|
||||
|
||||
/**
|
||||
* Horizontal scrollable carousel with navigation arrows.
|
||||
* Used for displaying items in a horizontally scrollable container
|
||||
* with left/right navigation buttons that appear on hover.
|
||||
*/
|
||||
export { default as HorizontalScrollCarousel } from './HorizontalScrollCarousel.svelte';
|
||||
|
||||
/**
|
||||
* **TruncatedText** - Text with ellipsis and tooltip
|
||||
*
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Image, Lightbulb, Mic, Video } from '@lucide/svelte';
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { ModelModalities } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
hideOrgName?: boolean;
|
||||
showRaw?: boolean;
|
||||
showRawTooltip?: boolean;
|
||||
hideQuantization?: boolean;
|
||||
hideTags?: boolean;
|
||||
aliases?: string[];
|
||||
tags?: string[];
|
||||
modalities?: ModelModalities;
|
||||
supportsThinking?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -20,8 +26,11 @@
|
||||
hideOrgName = false,
|
||||
hideQuantization,
|
||||
hideTags,
|
||||
modalities,
|
||||
modelId,
|
||||
showRaw = undefined,
|
||||
showRawTooltip = false,
|
||||
supportsThinking = false,
|
||||
tags,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
@@ -42,6 +51,9 @@
|
||||
|
||||
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
|
||||
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
|
||||
let hasModalityIcons = $derived(
|
||||
supportsThinking || modalities?.vision || modalities?.video || modalities?.audio
|
||||
);
|
||||
|
||||
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
|
||||
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
|
||||
@@ -50,37 +62,103 @@
|
||||
{#if resolvedShowRaw}
|
||||
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
|
||||
{:else}
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}" {...rest}>
|
||||
{#snippet nameAndBadges()}
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
|
||||
</span>
|
||||
|
||||
{#if parsed.params}
|
||||
<span class={badgeClass}>
|
||||
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if parsed.quantization && !resolvedHideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if primaryAlias}
|
||||
{#if primaryAlias !== parsed.modelName}
|
||||
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{#if parsed.params}
|
||||
<span class={badgeClass}>
|
||||
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
{:else if uniqueAliases.length > 1}
|
||||
{#each uniqueAliases as alias (alias)}
|
||||
<span class={badgeClass}>{alias}</span>
|
||||
{/each}
|
||||
|
||||
{#if parsed.quantization && !resolvedHideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if primaryAlias}
|
||||
{#if primaryAlias !== parsed.modelName}
|
||||
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
|
||||
{/if}
|
||||
{:else if uniqueAliases.length > 1}
|
||||
{#each uniqueAliases as alias (alias)}
|
||||
<span class={badgeClass}>{alias}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if uniqueTags.length > 0 && !resolvedHideTags}
|
||||
{#each uniqueTags as tag (tag)}
|
||||
<span class={tagBadgeClass}>{tag}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
|
||||
{#if showRawTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
|
||||
{@render nameAndBadges()}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{modelId}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
{@render nameAndBadges()}
|
||||
{/if}
|
||||
|
||||
{#if uniqueTags.length > 0 && !resolvedHideTags}
|
||||
{#each uniqueTags as tag (tag)}
|
||||
<span class={tagBadgeClass}>{tag}</span>
|
||||
{/each}
|
||||
{#if hasModalityIcons}
|
||||
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
|
||||
{#if supportsThinking}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Lightbulb class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Reasoning</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.vision}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Image class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Vision</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.video}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Video class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Video</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.audio}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Mic class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>Audio</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import ModelsSelectorSettingsFields from './ModelsSelectorSettingsFields.svelte';
|
||||
import { Funnel } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import type { SettingsSection } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
section: SettingsSection;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { disabled = false, section }: Props = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2" {disabled}>
|
||||
<Funnel class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span class="text-sm text-muted-foreground">Sampling & Penalties</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent
|
||||
class="max-h-96 w-72 overflow-y-auto bg-popover p-3 pt-2 text-popover-foreground shadow-md outline-none"
|
||||
>
|
||||
<ModelsSelectorSettingsFields {section} />
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
@@ -1,20 +1,28 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import type { ModelItem } from './utils';
|
||||
import { ChevronDown, Loader2 } from '@lucide/svelte';
|
||||
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
DialogModelInformation,
|
||||
DropdownMenuSearchable,
|
||||
ModelId,
|
||||
ModelsSelectorAdvancedSubmenu,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorOption
|
||||
} from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { MODEL_SELECTOR_ICON } from '$lib/constants';
|
||||
import {
|
||||
MODEL_SELECTOR_ICON,
|
||||
SETTINGS_CHAT_SECTIONS,
|
||||
SETTINGS_KEYS,
|
||||
SETTINGS_SECTION_SLUGS
|
||||
} from '$lib/constants';
|
||||
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
import { modelLoadFraction } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
@@ -37,6 +45,9 @@
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
// The model submenu opens together with the menu so the list and its search
|
||||
// box are immediately available, as before the submenu was introduced
|
||||
let modelSubOpen = $state(false);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
@@ -44,24 +55,49 @@
|
||||
onOpenChange: (open) => {
|
||||
isOpen = open;
|
||||
highlightedId = null;
|
||||
|
||||
if (open) {
|
||||
// Defer submenu open so the Sub component is mounted first;
|
||||
// setting bind:open synchronously can be lost if the Sub hasn't
|
||||
// rendered yet.
|
||||
queueMicrotask(() => {
|
||||
if (isOpen) modelSubOpen = true;
|
||||
});
|
||||
} else {
|
||||
modelSubOpen = false;
|
||||
}
|
||||
},
|
||||
useGlobalSelection: () => useGlobalSelection
|
||||
});
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
|
||||
// Sampling/penalties need a loaded model to show server defaults, so
|
||||
// disable the submenu until one is loaded.
|
||||
const hasModelLoaded = $derived(modelsStore.loadedModelIds.length > 0);
|
||||
|
||||
const samplingPenaltiesSection = $derived(
|
||||
SETTINGS_CHAT_SECTIONS.find((s) => s.slug === SETTINGS_SECTION_SLUGS.SAMPLING_PENALTIES)
|
||||
);
|
||||
|
||||
const showOrgNameInTrigger = $derived(
|
||||
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
void ms.searchTerm;
|
||||
highlightedId = null;
|
||||
});
|
||||
|
||||
// Focus the dropdown's search box without scrolling the page. bits-ui
|
||||
// Focus the model submenu's search box without scrolling the page. bits-ui
|
||||
// auto-focuses the opened content by default, which can yank the page
|
||||
// scroll; we prevent that on the Content and refocus the search here.
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen || !modelSubOpen) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const search = document.querySelector<HTMLElement>(
|
||||
'[data-slot="dropdown-menu-content"] input'
|
||||
'[data-slot="dropdown-menu-sub-content"] input'
|
||||
);
|
||||
|
||||
search?.focus({ preventScroll: true });
|
||||
@@ -188,7 +224,7 @@
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class={[
|
||||
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
@@ -203,16 +239,22 @@
|
||||
>
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={false}
|
||||
hideQuantization
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{/if}
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideQuantization
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
@@ -236,73 +278,101 @@
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
|
||||
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuSearchable
|
||||
searchValue={ms.searchTerm}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
placeholder="Search models..."
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
>
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
role="option"
|
||||
aria-selected="true"
|
||||
aria-disabled="true"
|
||||
disabled
|
||||
>
|
||||
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
|
||||
<DropdownMenu.Sub bind:open={modelSubOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<MODEL_SELECTOR_ICON class="h-4 w-4" />
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{option}
|
||||
{isSelected}
|
||||
{isHighlighted}
|
||||
{isFav}
|
||||
{hideOrgName}
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
/>
|
||||
{/snippet}
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
|
||||
{/if}
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<ModelsSelectorList
|
||||
groups={ms.groupedFilteredOptions}
|
||||
{currentModel}
|
||||
activeId={ms.activeId}
|
||||
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
renderOption={modelOption}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
|
||||
<DropdownMenuSearchable
|
||||
searchValue={ms.searchTerm}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
placeholder="Search models..."
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
>
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
role="option"
|
||||
aria-selected="true"
|
||||
aria-disabled="true"
|
||||
disabled
|
||||
>
|
||||
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{option}
|
||||
{isSelected}
|
||||
{isHighlighted}
|
||||
{isFav}
|
||||
{hideOrgName}
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ModelsSelectorList
|
||||
groups={ms.groupedFilteredOptions}
|
||||
{currentModel}
|
||||
activeId={ms.activeId}
|
||||
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
renderOption={modelOption}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
|
||||
{#if samplingPenaltiesSection}
|
||||
<ModelsSelectorAdvancedSubmenu
|
||||
section={samplingPenaltiesSection}
|
||||
disabled={!hasModelLoaded}
|
||||
/>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
@@ -333,11 +403,15 @@
|
||||
<ModelId
|
||||
modelId={selectedOption.model}
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={false}
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{/if}
|
||||
|
||||
@@ -58,15 +58,19 @@
|
||||
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
|
||||
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
|
||||
let loadTitle = $derived(modelLoadProgressText(loadProgress));
|
||||
let modalities = $derived(option.modalities);
|
||||
let supportsThinking = $derived(modelsStore.props.checkModelSupportsThinking(option.model));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
|
||||
'cursor-pointer',
|
||||
isSelected && 'bg-accent/50 text-accent-foreground',
|
||||
isSelected && !isHighlighted && 'bg-accent/50',
|
||||
isHighlighted && 'bg-accent',
|
||||
!isSelected && !isHighlighted && 'hover:bg-muted',
|
||||
(isSelected || isHighlighted) && 'text-accent-foreground',
|
||||
'hover:bg-accent',
|
||||
'focus:bg-accent',
|
||||
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||
]}
|
||||
role="option"
|
||||
@@ -82,6 +86,9 @@
|
||||
{hideOrgName}
|
||||
aliases={option.aliases}
|
||||
tags={option.tags}
|
||||
{modalities}
|
||||
{supportsThinking}
|
||||
showRawTooltip
|
||||
class="flex-1"
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { SettingsFieldType } from '$lib/enums/settings.enums';
|
||||
import { modelsStore, serverStore, settingsStore } from '$lib/stores';
|
||||
import type { SettingsSection } from '$lib/types';
|
||||
import { normalizeFloatingPoint } from '$lib/utils/precision';
|
||||
|
||||
interface Props {
|
||||
section: SettingsSection;
|
||||
}
|
||||
|
||||
let { section }: Props = $props();
|
||||
|
||||
let currentModelParams = $derived.by(() => {
|
||||
void modelsStore.props.cacheVersion;
|
||||
|
||||
if (serverStore.isRouterMode) {
|
||||
const currentModelName = modelsStore.selectedModelName;
|
||||
|
||||
if (currentModelName) {
|
||||
const currentModelProps = modelsStore.props.getModelProps(currentModelName);
|
||||
|
||||
return (currentModelProps?.default_generation_settings?.params ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
return (serverStore.defaultParams ?? {}) as Record<string, unknown>;
|
||||
});
|
||||
|
||||
function currentValue(key: string): string {
|
||||
const value = settingsStore.config[key];
|
||||
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function handleInput(key: string, value: string) {
|
||||
settingsStore.updateConfig(key, value);
|
||||
}
|
||||
|
||||
function placeholder(key: string): string {
|
||||
const serverDefault = currentModelParams[key];
|
||||
|
||||
return serverDefault != null ? `Default: ${normalizeFloatingPoint(serverDefault)}` : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid gap-1">
|
||||
{#each section.fields ?? [] as field (field.key)}
|
||||
{#if field.type === SettingsFieldType.INPUT}
|
||||
<label class="mb-2 flex flex-col gap-1.5">
|
||||
<span class="text-xs font-medium text-muted-foreground">{field.label}</span>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
value={currentValue(field.key)}
|
||||
placeholder={placeholder(field.key)}
|
||||
oninput={(event) => handleInput(field.key, event.currentTarget.value)}
|
||||
class="h-8"
|
||||
/>
|
||||
</label>
|
||||
{:else if field.type === SettingsFieldType.CHECKBOX}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-1 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
checked={Boolean(settingsStore.config[field.key])}
|
||||
onCheckedChange={(checked) => settingsStore.updateConfig(field.key, Boolean(checked))}
|
||||
/>
|
||||
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import ModelsSelectorSettingsFields from './ModelsSelectorSettingsFields.svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import type { SettingsSection } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
section: SettingsSection;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { disabled = false, section }: Props = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2" {disabled}>
|
||||
<section.icon class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<span class="text-sm text-muted-foreground">{section.title}</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent
|
||||
class="max-h-96 w-72 overflow-y-auto bg-popover p-2 text-popover-foreground shadow-md outline-none"
|
||||
>
|
||||
<ModelsSelectorSettingsFields {section} />
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
@@ -65,6 +65,10 @@ export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
|
||||
*/
|
||||
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
|
||||
|
||||
export { default as ModelsSelectorAdvancedSubmenu } from './ModelsSelectorAdvancedSubmenu.svelte';
|
||||
|
||||
export { default as ModelsSelectorSettingsSubmenu } from './ModelsSelectorSettingsSubmenu.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorSheet** - Mobile model selection sheet
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ModelModality } from '$lib/enums';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
@@ -17,6 +18,23 @@ export interface GroupedModelOptions {
|
||||
available: OrgGroup[];
|
||||
}
|
||||
|
||||
function matchesModality(option: ModelOption, term: string): boolean {
|
||||
const modalities = option.modalities;
|
||||
|
||||
if (!modalities) return false;
|
||||
|
||||
switch (term) {
|
||||
case ModelModality.VISION.toLowerCase():
|
||||
return modalities.vision;
|
||||
case ModelModality.AUDIO.toLowerCase():
|
||||
return modalities.audio;
|
||||
case ModelModality.VIDEO.toLowerCase():
|
||||
return modalities.video;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
|
||||
@@ -27,7 +45,8 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
|
||||
option.model.toLowerCase().includes(term) ||
|
||||
option.name?.toLowerCase().includes(term) ||
|
||||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
|
||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
|
||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
|
||||
matchesModality(option, term)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+26
-25
@@ -14,7 +14,7 @@
|
||||
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
|
||||
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
import { chatStore, conversationsStore, deviceStore, settingsStore } from '$lib/stores';
|
||||
import { chatStore, conversationsStore, deviceStore, settingsStore, uiStore } from '$lib/stores';
|
||||
import { buildConversationTree } from '$lib/utils';
|
||||
import { circIn } from 'svelte/easing';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
@@ -31,30 +31,29 @@
|
||||
toggleSidebar: () => toggleExpandedMode()
|
||||
});
|
||||
|
||||
let isExpandedMode = $state(false);
|
||||
let hoveredTooltip = $state<string | null>(null);
|
||||
let logoHovered = $state(false);
|
||||
|
||||
const isStripExpanded = $derived(isExpandedMode || hoveredTooltip !== null);
|
||||
const isStripExpanded = $derived(uiStore.isSidebarExpanded || hoveredTooltip !== null);
|
||||
const isOnMobile = $derived(deviceStore.isMobile);
|
||||
const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean);
|
||||
|
||||
$effect(() => {
|
||||
if (alwaysShowOnDesktop && !isOnMobile) {
|
||||
isExpandedMode = true;
|
||||
uiStore.isSidebarExpanded = true;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleExpandedMode() {
|
||||
isExpandedMode = !isExpandedMode;
|
||||
uiStore.isSidebarExpanded = !uiStore.isSidebarExpanded;
|
||||
|
||||
if (!isExpandedMode) {
|
||||
if (!uiStore.isSidebarExpanded) {
|
||||
hoveredTooltip = null;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!isExpandedMode) {
|
||||
if (!uiStore.isSidebarExpanded) {
|
||||
isSearchModeActive = false;
|
||||
searchQuery = '';
|
||||
|
||||
@@ -66,7 +65,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (deviceStore.isMobile && page.url.hash.includes(ROUTES.SEARCH)) {
|
||||
isExpandedMode = false;
|
||||
uiStore.isSidebarExpanded = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -294,7 +293,7 @@
|
||||
}
|
||||
|
||||
pendingCollapse = setTimeout(() => {
|
||||
isExpandedMode = false;
|
||||
uiStore.isSidebarExpanded = false;
|
||||
pendingCollapse = null;
|
||||
}, 100);
|
||||
}
|
||||
@@ -314,7 +313,7 @@
|
||||
class={[
|
||||
'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 w-[calc(100dvw-1rem)]',
|
||||
'md:h-[calc(100dvh-1.125rem)]',
|
||||
isExpandedMode &&
|
||||
uiStore.isSidebarExpanded &&
|
||||
(deviceStore.isStandalone
|
||||
? 'h-[calc(100dvh-2rem)]'
|
||||
: deviceStore.isIOSDevice
|
||||
@@ -323,9 +322,9 @@
|
||||
'rounded-3xl md:rounded-2xl',
|
||||
'flex flex-col justify-between',
|
||||
'md:transition-[width,padding] duration-200 ease-out',
|
||||
isStripExpanded && 'md:w-72 md:bg-muted/60 md:backdrop-blur-xl border-border shadow-md',
|
||||
isStripExpanded && 'md:w-72 md:bg-muted/60 md:backdrop-blur-xl shadow-md',
|
||||
!isStripExpanded && 'md:w-12',
|
||||
isExpandedMode && 'is-expanded'
|
||||
uiStore.isSidebarExpanded && 'is-expanded'
|
||||
]}
|
||||
>
|
||||
<div class="px-2 flex items-center justify-between">
|
||||
@@ -337,24 +336,26 @@
|
||||
onmouseleave={() => (logoHovered = false)}
|
||||
>
|
||||
<ActionIcon
|
||||
icon={!isExpandedMode && logoHovered && innerWidth > 768 ? PanelLeftOpen : Logo}
|
||||
icon={!uiStore.isSidebarExpanded && logoHovered && innerWidth > 768
|
||||
? PanelLeftOpen
|
||||
: Logo}
|
||||
size="lg"
|
||||
iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
|
||||
class="{isExpandedMode
|
||||
class="{uiStore.isSidebarExpanded
|
||||
? 'bg-muted! md:bg-foreground/5!'
|
||||
: 'bg-transparent!'} md:h-9 md:w-9 h-10 w-10 rounded-full md:hover:bg-foreground/10! pointer-events-auto"
|
||||
href={isExpandedMode ? ROUTES.START : undefined}
|
||||
onclick={isExpandedMode ? undefined : toggleExpandedMode}
|
||||
tooltip={isExpandedMode ? undefined : 'Open Sidebar'}
|
||||
: 'bg-transparent'} md:h-9 md:w-9 h-10 w-10 rounded-full md:hover:bg-foreground/10! pointer-events-auto"
|
||||
href={uiStore.isSidebarExpanded ? ROUTES.START : undefined}
|
||||
onclick={uiStore.isSidebarExpanded ? undefined : toggleExpandedMode}
|
||||
tooltip={uiStore.isSidebarExpanded ? undefined : 'Open Sidebar'}
|
||||
tooltipSide={TooltipSide.RIGHT}
|
||||
ariaLabel={isExpandedMode ? 'Go to start' : 'Expand navigation'}
|
||||
ariaLabel={uiStore.isSidebarExpanded ? 'Go to start' : 'Expand navigation'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isOnMobile || (isExpandedMode && !alwaysShowOnDesktop)}
|
||||
{#if isOnMobile || (uiStore.isSidebarExpanded && !alwaysShowOnDesktop)}
|
||||
<div
|
||||
class="flex items-center transition-all duration-150 ease-out {deviceStore.isMobile &&
|
||||
!isExpandedMode
|
||||
!uiStore.isSidebarExpanded
|
||||
? 'opacity-0 h-0!'
|
||||
: ''}"
|
||||
in:fade={{ delay: 50, duration: 150, easing: circIn }}
|
||||
@@ -377,12 +378,12 @@
|
||||
<div
|
||||
class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {deviceStore.isMobile
|
||||
? 'transition-[opacity,height] duration-200 ease-out'
|
||||
: ''} {deviceStore.isMobile && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
|
||||
: ''} {deviceStore.isMobile && !uiStore.isSidebarExpanded ? 'opacity-0 !h-0' : ''}"
|
||||
in:fade={{ duration: 200 }}
|
||||
out:fade={{ duration: 200 }}
|
||||
>
|
||||
<SidebarNavigationActions
|
||||
isExpandedMode={innerWidth > 768 ? isExpandedMode : true}
|
||||
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
|
||||
class="px-2"
|
||||
bind:isSearchModeActive
|
||||
bind:searchQuery
|
||||
@@ -391,7 +392,7 @@
|
||||
searchQuery = '';
|
||||
}}
|
||||
onSearchClick={() => {
|
||||
isExpandedMode = true;
|
||||
uiStore.isSidebarExpanded = true;
|
||||
isSearchModeActive = true;
|
||||
}}
|
||||
onNewChat={() => {
|
||||
@@ -401,7 +402,7 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if isExpandedMode || isOnMobile}
|
||||
{#if uiStore.isSidebarExpanded || isOnMobile}
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<SidebarNavigationConversationList
|
||||
class="px-2"
|
||||
|
||||
+29
-17
@@ -12,7 +12,7 @@
|
||||
SIDEBAR_ACTIONS_ITEMS
|
||||
} from '$lib/constants';
|
||||
import { TooltipSide } from '$lib/enums';
|
||||
import { deviceStore } from '$lib/stores';
|
||||
import { conversationsStore, deviceStore } from '$lib/stores';
|
||||
import type { Component } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { circIn } from 'svelte/easing';
|
||||
@@ -109,14 +109,20 @@
|
||||
{@const isActive = isItemActive(item)}
|
||||
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
|
||||
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
|
||||
{@const itemOnClick = item.route
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
goto(item.route!);
|
||||
}
|
||||
: isSearchOnMobile
|
||||
? undefined
|
||||
: onSearchClick}
|
||||
{@const itemOnClick =
|
||||
item.action === 'new-chat'
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
conversationsStore.openNewChat();
|
||||
}
|
||||
: item.route
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
goto(item.route!);
|
||||
}
|
||||
: isSearchOnMobile
|
||||
? undefined
|
||||
: onSearchClick}
|
||||
{@const itemTransition = {
|
||||
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
|
||||
duration: ICON_STRIP_TRANSITION_DURATION,
|
||||
@@ -157,14 +163,20 @@
|
||||
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
|
||||
{@const isActive = isItemActive(item)}
|
||||
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
|
||||
{@const itemOnClick = item.route
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
goto(item.route!);
|
||||
}
|
||||
: isSearchOnMobile
|
||||
? undefined
|
||||
: onSearchClick}
|
||||
{@const itemOnClick =
|
||||
item.action === 'new-chat'
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
conversationsStore.openNewChat();
|
||||
}
|
||||
: item.route
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
goto(item.route!);
|
||||
}
|
||||
: isSearchOnMobile
|
||||
? undefined
|
||||
: onSearchClick}
|
||||
{@const itemTransition = {
|
||||
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
|
||||
duration: ICON_STRIP_TRANSITION_DURATION,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, ChevronRight, Settings } from '@lucide/svelte';
|
||||
import { Settings } from '@lucide/svelte';
|
||||
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
|
||||
import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
|
||||
import { BooleanString } from '$lib/enums';
|
||||
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
|
||||
@@ -44,70 +45,42 @@
|
||||
</div>
|
||||
|
||||
<div class="border-b border-border/30 py-2">
|
||||
<div class="relative flex items-center" style="scroll-padding: 1rem;">
|
||||
<button
|
||||
class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {carousel.canScrollLeft
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={carousel.scrollLeft}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft class={ICON_CLASS_DEFAULT} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="scrollbar-hide overflow-x-auto py-2"
|
||||
bind:this={carousel.scrollContainer}
|
||||
onscroll={carousel.updateScrollButtons}
|
||||
>
|
||||
<div class="flex min-w-max gap-2">
|
||||
{#each sections as section (section.title)}
|
||||
{#if getHref}
|
||||
<a
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap no-underline transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
|
||||
href={getHref(section)}
|
||||
onclick={(e: MouseEvent) => {
|
||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
|
||||
onclick={(e: MouseEvent) => {
|
||||
onSectionChange?.(section.title);
|
||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {carousel.canScrollRight
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0'}"
|
||||
onclick={carousel.scrollRight}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight class={ICON_CLASS_DEFAULT} />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollCarousel {carousel} innerClass="gap-2">
|
||||
{#each sections as section (section.title)}
|
||||
{#if getHref}
|
||||
<a
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap no-underline transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
|
||||
href={getHref(section)}
|
||||
onclick={(e: MouseEvent) => {
|
||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
|
||||
onclick={(e: MouseEvent) => {
|
||||
onSectionChange?.(section.title);
|
||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
|
||||
<span>{section.title}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</ScrollCarousel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import ScrollCarousel from './scroll-carousel.svelte';
|
||||
|
||||
export { ScrollCarousel };
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
/** External carousel hook for callers that need to drive it (e.g. scrollToCenter). */
|
||||
carousel?: ReturnType<typeof useScrollCarousel>;
|
||||
/** Classes for the outer relative wrapper. */
|
||||
class?: string;
|
||||
/** Classes for the scrollable overflow container. */
|
||||
containerClass?: string;
|
||||
/** Classes for the min-w-max content wrapper. */
|
||||
innerClass?: string;
|
||||
/** Tailwind gap class applied to the content wrapper. */
|
||||
gapSize?: string;
|
||||
/** Arrow placement and styling. */
|
||||
variant?: 'top' | 'center';
|
||||
/** Called with whether the content overflows the container. */
|
||||
onScrollableChange?: (isScrollable: boolean) => void;
|
||||
/** Pixels scrolled per arrow click; defaults to ~2/3 of the container width. */
|
||||
scrollBy?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
carousel: externalCarousel,
|
||||
children,
|
||||
class: className = '',
|
||||
containerClass = '',
|
||||
gapSize = '3',
|
||||
innerClass = '',
|
||||
onScrollableChange,
|
||||
scrollBy,
|
||||
variant = 'top'
|
||||
}: Props = $props();
|
||||
|
||||
const internalCarousel = $derived(useScrollCarousel(onScrollableChange));
|
||||
const carousel = $derived(externalCarousel ?? internalCarousel);
|
||||
|
||||
const isCenter = $derived(variant === 'center');
|
||||
|
||||
function scrollLeft(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
|
||||
const container = carousel.scrollContainer;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
container.scrollBy({ behavior: 'smooth', left: -(scrollBy ?? container.clientWidth * 0.67) });
|
||||
}
|
||||
|
||||
function scrollRight(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
|
||||
const container = carousel.scrollContainer;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
container.scrollBy({ behavior: 'smooth', left: scrollBy ?? container.clientWidth * 0.67 });
|
||||
}
|
||||
|
||||
export function resetScroll() {
|
||||
const container = carousel.scrollContainer;
|
||||
|
||||
if (!container) return;
|
||||
|
||||
container.scrollLeft = 0;
|
||||
setTimeout(() => carousel.updateScrollButtons(), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('group relative', !isCenter && 'flex items-center', className)}
|
||||
style={!isCenter ? 'scroll-padding: 1rem;' : undefined}
|
||||
>
|
||||
<button
|
||||
class={cn(
|
||||
'absolute z-10 flex h-6 w-6 items-center justify-center rounded-full shadow-md transition-opacity',
|
||||
isCenter
|
||||
? 'top-1/2 left-4 -translate-y-1/2 bg-background/25 backdrop-blur-xs hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0'
|
||||
: 'left-2 bg-muted backdrop-blur-sm hover:bg-accent',
|
||||
!isCenter &&
|
||||
(carousel.canScrollLeft
|
||||
? 'opacity-0 group-hover:opacity-100'
|
||||
: 'pointer-events-none opacity-0')
|
||||
)}
|
||||
{...isCenter ? { disabled: !carousel.canScrollLeft } : {}}
|
||||
onclick={scrollLeft}
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft class={ICON_CLASS_DEFAULT} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
class={cn('scrollbar-hide overflow-x-auto', containerClass)}
|
||||
bind:this={carousel.scrollContainer}
|
||||
onscroll={carousel.updateScrollButtons}
|
||||
>
|
||||
<div
|
||||
class={cn('flex min-w-max', `gap-${gapSize}`, innerClass)}
|
||||
bind:this={carousel.contentContainer}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class={cn(
|
||||
'absolute z-10 flex h-6 w-6 items-center justify-center rounded-full shadow-md transition-opacity',
|
||||
isCenter
|
||||
? 'top-1/2 right-4 -translate-y-1/2 bg-background/25 backdrop-blur-xs hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0'
|
||||
: 'right-2 bg-muted backdrop-blur-sm hover:bg-accent',
|
||||
!isCenter &&
|
||||
(carousel.canScrollRight
|
||||
? 'opacity-0 group-hover:opacity-100'
|
||||
: 'pointer-events-none opacity-0')
|
||||
)}
|
||||
{...isCenter ? { disabled: !carousel.canScrollRight } : {}}
|
||||
onclick={scrollRight}
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight class={ICON_CLASS_DEFAULT} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -4,8 +4,6 @@ export const URL_PARAMS = {
|
||||
LOAD: 'load',
|
||||
/** Model to select. */
|
||||
MODEL: 'model',
|
||||
/** Start a new chat. */
|
||||
NEW_CHAT: 'new_chat',
|
||||
/** Prompt to send on arrival. */
|
||||
QUERY: 'q'
|
||||
} as const;
|
||||
@@ -15,8 +13,6 @@ export const ROUTES = {
|
||||
CHAT: '#/chat',
|
||||
/** MCP servers. */
|
||||
MCP_SERVERS: '#/mcp-servers',
|
||||
/** New chat — root with new chat query param. */
|
||||
NEW_CHAT: `?${URL_PARAMS.NEW_CHAT}=true#/`,
|
||||
/** Search — mobile-only full-page conversation search. */
|
||||
SEARCH: '#/search',
|
||||
/** Settings base — for dynamic settings URLs use RouterService. */
|
||||
|
||||
@@ -11,6 +11,7 @@ export const SETTINGS_KEYS = {
|
||||
API_KEY: 'apiKey',
|
||||
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
|
||||
BACKEND_SAMPLING: 'backend_sampling',
|
||||
CONVERSATION_TABS: 'conversationTabs',
|
||||
COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText',
|
||||
CUSTOM_CSS: 'customCss',
|
||||
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
|
||||
@@ -53,6 +54,7 @@ export const SETTINGS_KEYS = {
|
||||
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
|
||||
// Display
|
||||
SHOW_MESSAGE_STATS: 'showMessageStats',
|
||||
SHOW_MODEL_ORG_NAME_IN_TRIGGER: 'showModelOrgNameInTrigger',
|
||||
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
|
||||
SHOW_MODEL_TAGS: 'showModelTags',
|
||||
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
|
||||
|
||||
@@ -111,9 +111,8 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
defaultValue: true,
|
||||
help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
|
||||
isExperimental: true,
|
||||
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
|
||||
label: 'Show microphone on empty input',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
@@ -255,6 +254,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
|
||||
label: 'Always show sidebar on desktop',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: true,
|
||||
help: 'Show open chats as browser-style tabs above the conversation, one per open chat. When disabled, only one chat is shown at a time.',
|
||||
key: SETTINGS_KEYS.CONVERSATION_TABS,
|
||||
label: 'Conversation tabs',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Display full raw model identifiers (e.g. "ggml-org/GLM-4.7-Flash-GGUF:Q8_0") instead of parsed names with badges.',
|
||||
@@ -276,6 +282,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
|
||||
label: 'Show model tags',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Display the organization name in the model selector trigger button.',
|
||||
key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER,
|
||||
label: 'Show organization name in model selector trigger',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Display the current build version in the bottom-right corner of the interface.',
|
||||
|
||||
@@ -22,6 +22,7 @@ export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTool
|
||||
export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`;
|
||||
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
|
||||
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
|
||||
export const CHAT_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.chatTabs`;
|
||||
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
|
||||
export const DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.dismissedRecommendedMcpServers`;
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
|
||||
export const MAX_HEIGHT_CODE_BLOCK = '22rem';
|
||||
|
||||
export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
|
||||
{ icon: SquarePen, keys: ['shift', 'cmd', 'o'], route: ROUTES.NEW_CHAT, tooltip: 'New chat' },
|
||||
{ action: 'new-chat', icon: SquarePen, keys: ['shift', 'cmd', 'o'], tooltip: 'New chat' },
|
||||
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
|
||||
{
|
||||
activeRouteId: '/mcp-servers',
|
||||
|
||||
@@ -16,5 +16,7 @@ export enum KeyboardKey {
|
||||
O_LOWER = 'o',
|
||||
O_UPPER = 'O',
|
||||
SPACE = ' ',
|
||||
TAB = 'Tab'
|
||||
TAB = 'Tab',
|
||||
X_LOWER = 'x',
|
||||
X_UPPER = 'X'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
import { page } from '$app/state';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import { conversationsStore, NEW_CHAT_TAB_ID, settingsStore, tabsStore } from '$lib/stores';
|
||||
|
||||
interface KeyboardShortcutsCallbacks {
|
||||
activateSearchMode?: () => void;
|
||||
@@ -9,6 +9,8 @@ interface KeyboardShortcutsCallbacks {
|
||||
deleteActiveConversation?: () => void;
|
||||
navigateToPrevConversation?: () => void;
|
||||
navigateToNextConversation?: () => void;
|
||||
navigateToPrevTab?: () => void;
|
||||
navigateToNextTab?: () => void;
|
||||
toggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
@@ -34,7 +36,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
|
||||
) {
|
||||
event.preventDefault();
|
||||
|
||||
goto(ROUTES.NEW_CHAT);
|
||||
conversationsStore.openNewChat();
|
||||
}
|
||||
|
||||
if (event.shiftKey && isCmdOrCtrl && event.key === KeyboardKey.E_UPPER) {
|
||||
@@ -42,6 +44,28 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
|
||||
callbacks.editActiveConversation?.();
|
||||
}
|
||||
|
||||
if (
|
||||
event.shiftKey &&
|
||||
isCmdOrCtrl &&
|
||||
(event.key === KeyboardKey.X_LOWER || event.key === KeyboardKey.X_UPPER)
|
||||
) {
|
||||
// several components register this shortcut; only let the first handler
|
||||
// act so the synchronous navigation does not cascade-close every tab
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// close-tab only makes sense with conversation tabs enabled
|
||||
if (!settingsStore.config.conversationTabs) return;
|
||||
|
||||
const activeId =
|
||||
page.params.id ?? (page.route.id === '/(chat)' ? NEW_CHAT_TAB_ID : undefined);
|
||||
|
||||
if (activeId) {
|
||||
void tabsStore.close(activeId, activeId);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isCmdOrCtrl &&
|
||||
event.shiftKey &&
|
||||
@@ -60,6 +84,16 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
|
||||
event.preventDefault();
|
||||
callbacks.navigateToNextConversation?.();
|
||||
}
|
||||
|
||||
if (isCmdOrCtrl && event.shiftKey && event.key === KeyboardKey.ARROW_LEFT) {
|
||||
event.preventDefault();
|
||||
callbacks.navigateToPrevTab?.();
|
||||
}
|
||||
|
||||
if (isCmdOrCtrl && event.shiftKey && event.key === KeyboardKey.ARROW_RIGHT) {
|
||||
event.preventDefault();
|
||||
callbacks.navigateToNextTab?.();
|
||||
}
|
||||
}
|
||||
|
||||
return { handleKeydown };
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getConversationModel } from '$lib/utils';
|
||||
export interface UseReasoningMenuReturn {
|
||||
readonly modelSupportsThinking: boolean;
|
||||
readonly thinkingEnabled: boolean;
|
||||
readonly isReasoningActive: boolean;
|
||||
readonly isOff: boolean;
|
||||
readonly currentEffort: ReasoningEffort;
|
||||
readonly levels: ReasoningEffortLevel[];
|
||||
@@ -59,6 +60,12 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
|
||||
const thinkingEnabled = $derived(
|
||||
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
|
||||
);
|
||||
// Thinking is effectively on (lightbulb lit) either when an explicit effort
|
||||
// is selected, or when the effort is left at "Default" and the model
|
||||
// supports thinking.
|
||||
const isReasoningActive = $derived(
|
||||
thinkingEnabled || (currentEffort === ReasoningEffort.DEFAULT && modelSupportsThinking)
|
||||
);
|
||||
|
||||
return {
|
||||
get currentEffort() {
|
||||
@@ -67,6 +74,9 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
|
||||
get isOff() {
|
||||
return currentEffort === ReasoningEffort.OFF;
|
||||
},
|
||||
get isReasoningActive() {
|
||||
return isReasoningActive;
|
||||
},
|
||||
isSelected(level: ReasoningEffortLevel): boolean {
|
||||
return currentEffort === level.value;
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export function useScrollCarousel() {
|
||||
export function useScrollCarousel(onScrollableChange?: (isScrollable: boolean) => void) {
|
||||
let canScrollLeft = $state(false);
|
||||
let canScrollRight = $state(false);
|
||||
let scrollContainer = $state<HTMLDivElement | undefined>();
|
||||
let contentContainer = $state<HTMLDivElement | undefined>();
|
||||
|
||||
function scrollToCenter(element: HTMLElement) {
|
||||
if (!scrollContainer) return;
|
||||
@@ -34,12 +35,25 @@ export function useScrollCarousel() {
|
||||
|
||||
canScrollLeft = sl > 0;
|
||||
canScrollRight = sl < scrollWidth - clientWidth - 1;
|
||||
|
||||
onScrollableChange?.(scrollWidth > clientWidth);
|
||||
}
|
||||
|
||||
// Re-evaluate arrow visibility whenever the container or its content resizes,
|
||||
// otherwise the arrows may not appear when overflowing items are added (e.g. new
|
||||
// tabs/attachments) and the user has not scrolled yet.
|
||||
$effect(() => {
|
||||
if (scrollContainer) {
|
||||
updateScrollButtons();
|
||||
}
|
||||
if (!scrollContainer) return;
|
||||
|
||||
updateScrollButtons();
|
||||
|
||||
const observer = new ResizeObserver(() => updateScrollButtons());
|
||||
|
||||
observer.observe(scrollContainer);
|
||||
|
||||
if (contentContainer) observer.observe(contentContainer);
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -49,6 +63,12 @@ export function useScrollCarousel() {
|
||||
get canScrollRight() {
|
||||
return canScrollRight;
|
||||
},
|
||||
get contentContainer() {
|
||||
return contentContainer;
|
||||
},
|
||||
set contentContainer(el: HTMLDivElement | undefined) {
|
||||
contentContainer = el;
|
||||
},
|
||||
get scrollContainer() {
|
||||
return scrollContainer;
|
||||
},
|
||||
|
||||
@@ -146,6 +146,23 @@ export class DatabaseService {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a conversation with a caller-supplied id. Used to save an
|
||||
* unsaved new-chat tab (which already carries a temporary id) on its
|
||||
* first message.
|
||||
*/
|
||||
static async createConversationWithId(conversation: DatabaseConversation): Promise<void> {
|
||||
await db[IDXDB_TABLES.conversations].add(conversation);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Messages
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a new message branch by adding a message and updating parent/child relationships.
|
||||
* Also updates the conversation's currNode to point to the new message.
|
||||
|
||||
@@ -187,8 +187,17 @@ export class ModelsService {
|
||||
|
||||
// 6. Model name = segments before params; tags = remaining segments after params
|
||||
const pivotIdx = paramsIdx !== MODEL_ID.NOT_FOUND ? paramsIdx : segments.length;
|
||||
const modelSegments = segments.slice(0, pivotIdx);
|
||||
|
||||
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID.SEGMENT_SEPARATOR) || null;
|
||||
// strip trailing container-format segments (e.g. GGUF) from the model name
|
||||
while (
|
||||
modelSegments.length > 0 &&
|
||||
MODEL_ID.IGNORED_SEGMENTS.has(modelSegments[modelSegments.length - 1].toUpperCase())
|
||||
) {
|
||||
modelSegments.pop();
|
||||
}
|
||||
|
||||
result.modelName = modelSegments.join(MODEL_ID.SEGMENT_SEPARATOR) || null;
|
||||
|
||||
if (paramsIdx !== MODEL_ID.NOT_FOUND) {
|
||||
result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type ConversationsPreferencesHost
|
||||
} from '$lib/stores/conversations/preferences.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { NEW_CHAT_TAB_ID, tabsStore } from '$lib/stores/tabs.svelte';
|
||||
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -138,8 +139,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
this.notifyConversationsDeleted([...idsToRemove]);
|
||||
|
||||
if (activeWasDeleted) {
|
||||
const activeId = this.activeConversation!.id;
|
||||
|
||||
tabsStore.removeTabs([...idsToRemove].filter((id) => id !== activeId));
|
||||
this.clearActiveConversation();
|
||||
await goto(ROUTES.NEW_CHAT);
|
||||
await tabsStore.close(activeId, activeId);
|
||||
} else {
|
||||
tabsStore.removeTabs([...idsToRemove]);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
@@ -276,11 +282,12 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
|
||||
this.clearActiveConversation();
|
||||
this.conversations = [];
|
||||
tabsStore.clear();
|
||||
this.notifyConversationsDeleted(allIds);
|
||||
|
||||
toast.success('All conversations deleted');
|
||||
|
||||
await goto(ROUTES.NEW_CHAT);
|
||||
await goto(ROUTES.START);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete all conversations:', error);
|
||||
toast.error('Failed to delete conversations');
|
||||
@@ -313,8 +320,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id));
|
||||
|
||||
if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) {
|
||||
const activeId = this.activeConversation.id;
|
||||
|
||||
tabsStore.removeTabs([...idsToRemove].filter((id) => id !== activeId));
|
||||
this.clearActiveConversation();
|
||||
await goto(ROUTES.NEW_CHAT);
|
||||
await tabsStore.close(activeId, activeId);
|
||||
} else {
|
||||
tabsStore.removeTabs([...idsToRemove]);
|
||||
}
|
||||
|
||||
this.notifyConversationsDeleted([...idsToRemove]);
|
||||
@@ -333,7 +345,9 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
|
||||
if (this.activeConversation?.id === convId) {
|
||||
this.clearActiveConversation();
|
||||
await goto(ROUTES.NEW_CHAT);
|
||||
await tabsStore.close(convId, convId);
|
||||
} else {
|
||||
tabsStore.removeTabs([convId]);
|
||||
}
|
||||
|
||||
this.notifyConversationsDeleted([convId]);
|
||||
@@ -571,6 +585,17 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
return () => this.conversationDeletionListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a fresh chat by navigating to the bare `#/` new-chat screen. The
|
||||
* chat layout opens a new-chat tab for it when Conversation tabs are on.
|
||||
*/
|
||||
async openNewChat(): Promise<string> {
|
||||
this.clearActiveConversation();
|
||||
await goto(ROUTES.START);
|
||||
|
||||
return NEW_CHAT_TAB_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes active messages based on currNode after branch navigation.
|
||||
*/
|
||||
@@ -676,14 +701,6 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Import & Export
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Updates the current node of the active conversation
|
||||
* @param nodeId - The new current node ID
|
||||
@@ -717,6 +734,14 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Import & Export
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
private notifyConversationsDeleted(convIds: string[]): void {
|
||||
if (convIds.length === 0) return;
|
||||
|
||||
|
||||
@@ -152,11 +152,13 @@ export class ConversationPreferences {
|
||||
return;
|
||||
}
|
||||
|
||||
this.host.applyConversationUpdate(this.host.activeConversation.id, {
|
||||
const id = this.host.activeConversation.id;
|
||||
|
||||
this.host.applyConversationUpdate(id, {
|
||||
cwd: trimmed
|
||||
});
|
||||
|
||||
await DatabaseService.updateConversation(this.host.activeConversation.id, {
|
||||
await DatabaseService.updateConversation(id, {
|
||||
cwd: trimmed
|
||||
});
|
||||
|
||||
@@ -202,12 +204,15 @@ export class ConversationPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
await DatabaseService.updateConversation(this.host.activeConversation.id, {
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
const overrides = newOverrides.length > 0 ? newOverrides : undefined;
|
||||
const id = this.host.activeConversation.id;
|
||||
|
||||
this.host.applyConversationUpdate(id, {
|
||||
mcpServerOverrides: overrides
|
||||
});
|
||||
|
||||
this.host.applyConversationUpdate(this.host.activeConversation.id, {
|
||||
mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined
|
||||
await DatabaseService.updateConversation(id, {
|
||||
mcpServerOverrides: overrides
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,11 +229,13 @@ export class ConversationPreferences {
|
||||
return;
|
||||
}
|
||||
|
||||
this.host.applyConversationUpdate(this.host.activeConversation.id, {
|
||||
const id = this.host.activeConversation.id;
|
||||
|
||||
this.host.applyConversationUpdate(id, {
|
||||
reasoningEffort: effort
|
||||
});
|
||||
|
||||
await DatabaseService.updateConversation(this.host.activeConversation.id, {
|
||||
await DatabaseService.updateConversation(id, {
|
||||
reasoningEffort: effort
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export { chatStore } from './chat/index.svelte';
|
||||
|
||||
export { draftMessagesStore } from './chat/drafts.svelte';
|
||||
|
||||
// CONVERSATION TABS
|
||||
export { NEW_CHAT_TAB_ID, tabsStore } from './tabs.svelte';
|
||||
|
||||
// CONTEXT STATS (active conversation context window usage)
|
||||
export { contextStatsStore } from './chat/context-stats.svelte';
|
||||
|
||||
@@ -40,6 +43,9 @@ export { modelsStore } from './models/index.svelte';
|
||||
// SERVER
|
||||
export { serverStore } from './server.svelte';
|
||||
|
||||
// UI / LAYOUT
|
||||
export { uiStore } from './ui.svelte';
|
||||
|
||||
// SETTINGS / UI PREFERENCES
|
||||
export { settingsStore } from './settings/index.svelte';
|
||||
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
/**
|
||||
* Explicit store initialization, run once and shared by every caller.
|
||||
*
|
||||
* Order matters: migrations run first because they rename and rewrite
|
||||
* localStorage keys, so every store that reads localStorage initializes
|
||||
* only after they complete. Constructors and module-level side effects
|
||||
* stay empty so import order can no longer change startup behavior.
|
||||
*
|
||||
* The returned promise resolves once the persisted state is in memory, which
|
||||
* route loads await before reading settings: they run ahead of the root layout
|
||||
* script. The conversation list loads in the background, awaited by the chat
|
||||
* page that renders it.
|
||||
*/
|
||||
|
||||
// direct imports, not via the barrel, to avoid circular deps
|
||||
import { conversationsStore } from './conversations/index.svelte';
|
||||
import { permissionsStore } from './permissions.svelte';
|
||||
import { settingsStore } from './settings/index.svelte';
|
||||
import { tabsStore } from './tabs.svelte';
|
||||
import { toolsStore } from './tools.svelte';
|
||||
import { versionStore } from './version.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
@@ -33,7 +20,10 @@ export function initStores(): Promise<void> {
|
||||
permissionsStore.initialize();
|
||||
toolsStore.initialize();
|
||||
void versionStore.initialize();
|
||||
void conversationsStore.initialize();
|
||||
await conversationsStore.initialize();
|
||||
|
||||
// prune persisted tabs against the loaded conversation list
|
||||
tabsStore.init(conversationsStore.conversations.map((c) => c.id));
|
||||
})();
|
||||
|
||||
return startup;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* tabsStore - Reactive State Store for Browser-Style Conversation Tabs
|
||||
*
|
||||
* Tracks which conversations and the new-chat screen are open as tabs in
|
||||
* the chat layout, in order. Real conversation tabs are `#/chat/<id>`
|
||||
* routes; the new-chat tab is the bare `#/` route, represented here by the
|
||||
* `NEW_CHAT_TAB_ID` sentinel (see {@link NEW_CHAT_TAB_ID}).
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **conversationsStore**: owns conversation data; calls `removeTabs()` /
|
||||
* `close()` when conversations are deleted. This store never imports it,
|
||||
* so there is no circular dependency - tab names are resolved by the
|
||||
* ChatTabs component from conversationsStore.
|
||||
* - Tab order persists to localStorage and is pruned against the loaded
|
||||
* conversation list on init. The new-chat tab is dropped on reload (it is
|
||||
* not a conversation), which matches browser behavior.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { CHAT_TABS_LOCALSTORAGE_KEY, ROUTES } from '$lib/constants';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
/** Sentinel tab id for the bare `#/` new-chat screen */
|
||||
export const NEW_CHAT_TAB_ID = 'new-chat';
|
||||
|
||||
class TabsStore {
|
||||
/** Ordered tab ids: conversation ids and the `NEW_CHAT_TAB_ID` sentinel */
|
||||
openTabs = $state<string[]>([]);
|
||||
|
||||
/** False until init() has read the persisted tabs; save() is a no-op before that */
|
||||
private initialized = false;
|
||||
|
||||
/** Navigate to a tab (the new-chat sentinel maps to the bare `#/` route) */
|
||||
async activate(id: string): Promise<void> {
|
||||
await goto(id === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(id));
|
||||
}
|
||||
|
||||
/** Remove all tabs (e.g. after deleting all conversations) */
|
||||
clear(): void {
|
||||
this.openTabs = [];
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a tab. When it belongs to the active route, navigate to the left
|
||||
* neighbor (or the right one when the closed tab was leftmost), falling
|
||||
* back to the new-chat screen when no tabs remain.
|
||||
* @param id - Tab id to close
|
||||
* @param activeTabId - Tab id of the current route, if any
|
||||
*/
|
||||
async close(id: string, activeTabId: string | null): Promise<void> {
|
||||
const idx = this.openTabs.indexOf(id);
|
||||
|
||||
if (idx === -1) return;
|
||||
|
||||
this.openTabs = this.openTabs.filter((tabId) => tabId !== id);
|
||||
this.save();
|
||||
|
||||
if (id !== activeTabId) return;
|
||||
|
||||
const target = (idx > 0 ? this.openTabs[idx - 1] : this.openTabs[0]) ?? null;
|
||||
|
||||
if (target) {
|
||||
await goto(target === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(target));
|
||||
} else {
|
||||
await goto(ROUTES.START);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted tabs, dropping conversation ids that no longer exist.
|
||||
* Called once from initStores() after conversations are loaded.
|
||||
* Merges with (rather than replaces) current openTabs: the chat layout
|
||||
* syncs the route's tab before this async init completes, and replacing
|
||||
* here would drop it.
|
||||
* @param validIds - Ids of conversations present in the database
|
||||
*/
|
||||
init(validIds: string[]): void {
|
||||
if (!browser) return;
|
||||
|
||||
const valid = new Set(validIds);
|
||||
const persisted = this.load().filter((id) => valid.has(id));
|
||||
const extras = this.openTabs.filter((id) => valid.has(id) && !persisted.includes(id));
|
||||
|
||||
this.openTabs = [...persisted, ...extras];
|
||||
this.initialized = true;
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tabs without navigating. Used when conversations are deleted
|
||||
* while some other conversation stays open.
|
||||
* @param ids - Tab ids to drop
|
||||
*/
|
||||
removeTabs(ids: string[]): void {
|
||||
const removed = new Set(ids);
|
||||
const next = this.openTabs.filter((id) => !removed.has(id));
|
||||
|
||||
if (next.length !== this.openTabs.length) {
|
||||
this.openTabs = next;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the tab strip with the route. Called from the chat layout on every
|
||||
* navigation, so any way of reaching a conversation or new-chat tab opens
|
||||
* a tab for it.
|
||||
* @param id - The conversation (or temporary new-chat) id of the route
|
||||
*/
|
||||
syncWithRoute(id: string): void {
|
||||
// untrack: callers invoke this from an effect keyed on the route, and
|
||||
// reading openTabs here would subscribe that effect to openTabs too -
|
||||
// closing the active tab would then re-run the effect and re-add the tab
|
||||
untrack(() => {
|
||||
if (!this.openTabs.includes(id)) {
|
||||
this.openTabs = [...this.openTabs, id];
|
||||
this.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private load(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CHAT_TABS_LOCALSTORAGE_KEY);
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
|
||||
return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
// never write before init has read the persisted tabs, or an early
|
||||
// route sync (layout effect runs before async init) would clobber them
|
||||
if (!browser || !this.initialized) return;
|
||||
|
||||
localStorage.setItem(CHAT_TABS_LOCALSTORAGE_KEY, JSON.stringify(this.openTabs));
|
||||
}
|
||||
}
|
||||
|
||||
export const tabsStore = new TabsStore();
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* uiStore - Shared UI/layout state
|
||||
*
|
||||
* Holds cross-component UI state that does not belong to a single component
|
||||
* (e.g. the desktop sidebar's expanded/collapsed state, which the sidebar
|
||||
* controls and the chat tab bar reacts to).
|
||||
*/
|
||||
|
||||
class UiStore {
|
||||
/** Whether the desktop sidebar is expanded (open). */
|
||||
isSidebarExpanded = $state(false);
|
||||
}
|
||||
|
||||
export const uiStore = new UiStore();
|
||||
+2
@@ -7,6 +7,8 @@ export interface DesktopIconStripItem {
|
||||
icon: Component;
|
||||
tooltip: string;
|
||||
route?: string;
|
||||
/** Custom action handled by the sidebar, e.g. opening a new-chat tab */
|
||||
action?: 'new-chat';
|
||||
activeRouteId?: string;
|
||||
activeRoutePrefix?: string;
|
||||
activeUrlIncludes?: string;
|
||||
|
||||
@@ -1,12 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { ChatScreen } from '$lib/components/app';
|
||||
import { ChatScreen, ChatTabs } from '$lib/components/app';
|
||||
import { NEW_CHAT_TAB_ID, settingsStore, tabsStore } from '$lib/stores';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// the new-chat screen is the bare `#/` route (no conversation id)
|
||||
let showCenteredEmpty = $derived(!page.params.id);
|
||||
|
||||
// tabs show whenever the Conversation tabs setting is enabled, except on the
|
||||
// bare `#/` new-chat view when no real conversation tab is open yet
|
||||
let showTabs = $derived(
|
||||
Boolean(settingsStore.config.conversationTabs) &&
|
||||
(page.params.id || tabsStore.openTabs.some((id) => id !== NEW_CHAT_TAB_ID))
|
||||
);
|
||||
|
||||
// any navigation to a conversation or the new-chat screen opens a tab for it
|
||||
$effect(() => {
|
||||
const id = page.params.id ?? (page.route.id === '/(chat)' ? NEW_CHAT_TAB_ID : undefined);
|
||||
|
||||
if (id && settingsStore.config.conversationTabs) {
|
||||
tabsStore.syncWithRoute(id);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<ChatScreen {showCenteredEmpty} />
|
||||
<div class={showTabs ? 'md:[--chat-tabs-offset:1.25rem]' : ''}>
|
||||
{#if showTabs}
|
||||
<ChatTabs />
|
||||
{/if}
|
||||
|
||||
<ChatScreen {showCenteredEmpty} />
|
||||
</div>
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,79 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { DialogModelNotAvailable } from '$lib/components/app';
|
||||
import { APP_NAME, URL_PARAMS } from '$lib/constants';
|
||||
import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { chatStore, conversationsStore, modelsStore } from '$lib/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
|
||||
let modelParam = $derived(page.url.searchParams.get(URL_PARAMS.MODEL));
|
||||
let newChatParam = $derived(page.url.searchParams.get(URL_PARAMS.NEW_CHAT));
|
||||
let loadParam = $derived(page.url.searchParams.get(URL_PARAMS.LOAD));
|
||||
|
||||
// Dialog state for model not available error
|
||||
let showModelNotAvailable = $state(false);
|
||||
let requestedModelName = $state('');
|
||||
let availableModelNames = $derived(modelsStore.models.map((m) => m.model));
|
||||
|
||||
/**
|
||||
* Clear URL params after message is sent to prevent re-sending on refresh
|
||||
*/
|
||||
function clearUrlParams() {
|
||||
const url = new URL(page.url);
|
||||
|
||||
url.searchParams.delete(URL_PARAMS.QUERY);
|
||||
url.searchParams.delete(URL_PARAMS.MODEL);
|
||||
url.searchParams.delete(URL_PARAMS.LOAD);
|
||||
url.searchParams.delete(URL_PARAMS.NEW_CHAT);
|
||||
|
||||
replaceState(url.toString(), {});
|
||||
}
|
||||
|
||||
async function handleUrlParams() {
|
||||
await modelsStore.fetch();
|
||||
|
||||
if (modelParam) {
|
||||
const model = modelsStore.findModelByName(modelParam);
|
||||
|
||||
if (model) {
|
||||
try {
|
||||
await modelsStore.selectModelById(model.id);
|
||||
|
||||
// with ?load=true, start loading right away so the model is ready sooner;
|
||||
// not awaited, so the UI stays usable during the load
|
||||
if (
|
||||
loadParam === 'true' &&
|
||||
serverStore.isRouterMode &&
|
||||
!modelsStore.isModelLoaded(model.id)
|
||||
) {
|
||||
modelsStore.status
|
||||
.load(model.id)
|
||||
.catch((error) => console.error('Failed to load model:', error));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select model:', error);
|
||||
requestedModelName = modelParam;
|
||||
showModelNotAvailable = true;
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
requestedModelName = modelParam;
|
||||
showModelNotAvailable = true;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ?q= parameter - create new conversation and send message
|
||||
if (qParam !== null) {
|
||||
await conversationsStore.createConversation();
|
||||
clearUrlParams();
|
||||
} else if (modelParam || newChatParam === 'true') {
|
||||
clearUrlParams();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!conversationsStore.isInitialized) {
|
||||
@@ -85,8 +17,10 @@
|
||||
|
||||
await modelsStore.fetch();
|
||||
|
||||
if (qParam !== null || modelParam !== null || newChatParam === 'true') {
|
||||
await handleUrlParams();
|
||||
// a prompt/model deep-link opens a new chat (tab or plain view, per the
|
||||
// Conversation tabs setting); otherwise `#/` is a plain new-chat view
|
||||
if (qParam !== null || modelParam !== null) {
|
||||
await conversationsStore.openNewChat();
|
||||
}
|
||||
|
||||
await modelsStore.ensureFirstModelSelected();
|
||||
@@ -96,9 +30,3 @@
|
||||
<svelte:head>
|
||||
<title>{APP_NAME}</title>
|
||||
</svelte:head>
|
||||
|
||||
<DialogModelNotAvailable
|
||||
bind:open={showModelNotAvailable}
|
||||
modelName={requestedModelName}
|
||||
availableModels={availableModelNames}
|
||||
/>
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
deviceStore,
|
||||
mcpStore,
|
||||
modelsStore,
|
||||
NEW_CHAT_TAB_ID,
|
||||
serverStore,
|
||||
settingsStore,
|
||||
tabsStore,
|
||||
versionStore
|
||||
} from '$lib/stores';
|
||||
import { initStores } from '$lib/stores/init';
|
||||
@@ -74,6 +76,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToTab(direction: -1 | 1) {
|
||||
// only makes sense with conversation tabs enabled
|
||||
if (!settingsStore.config.conversationTabs) return;
|
||||
|
||||
const openTabs = tabsStore.openTabs;
|
||||
|
||||
if (openTabs.length === 0) return;
|
||||
|
||||
const activeId = page.params.id ?? NEW_CHAT_TAB_ID;
|
||||
const idx = openTabs.indexOf(activeId);
|
||||
// active tab not in list (e.g. a non-chat route): start from an edge
|
||||
const targetIdx =
|
||||
idx === -1
|
||||
? direction === 1
|
||||
? 0
|
||||
: openTabs.length - 1
|
||||
: (idx + direction + openTabs.length) % openTabs.length;
|
||||
|
||||
void tabsStore.activate(openTabs[targetIdx]);
|
||||
}
|
||||
|
||||
function navigateToConversation(direction: -1 | 1) {
|
||||
const allConvs = conversationsStore.conversations;
|
||||
|
||||
@@ -96,15 +119,31 @@
|
||||
if (targetIdx >= 0 && targetIdx < allConvs.length) {
|
||||
goto(RouterService.chat(allConvs[targetIdx].id));
|
||||
} else {
|
||||
goto(ROUTES.NEW_CHAT);
|
||||
conversationsStore.openNewChat();
|
||||
}
|
||||
}
|
||||
|
||||
// navigating away from the new-chat screen drops its tab, so it does not
|
||||
// linger once the user moves to a real conversation or another route
|
||||
let previousChatId = $state<string | undefined>(undefined);
|
||||
|
||||
$effect(() => {
|
||||
const id = page.params.id ?? (page.route.id === '/(chat)' ? NEW_CHAT_TAB_ID : undefined);
|
||||
const prev = previousChatId;
|
||||
|
||||
previousChatId = id;
|
||||
|
||||
if (id !== prev && prev && settingsStore.config.conversationTabs && prev === NEW_CHAT_TAB_ID) {
|
||||
untrack(() => tabsStore.removeTabs([NEW_CHAT_TAB_ID]));
|
||||
}
|
||||
});
|
||||
// Global keyboard shortcuts
|
||||
const { handleKeydown } = useKeyboardShortcuts({
|
||||
editActiveConversation: () => chatSidebar?.editActiveConversation?.(),
|
||||
navigateToNextConversation: () => navigateToConversation(1),
|
||||
navigateToPrevConversation: () => navigateToConversation(-1)
|
||||
navigateToNextTab: () => navigateToTab(1),
|
||||
navigateToPrevConversation: () => navigateToConversation(-1),
|
||||
navigateToPrevTab: () => navigateToTab(-1)
|
||||
});
|
||||
|
||||
function checkApiKey() {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
|
||||
|
||||
@@ -21,10 +20,10 @@
|
||||
});
|
||||
|
||||
// Search page is intended for mobile; on desktop the sidebar already exposes
|
||||
// in-place search, so bounce back to a chat.
|
||||
// in-place search, so bounce back to a new-chat tab.
|
||||
$effect(() => {
|
||||
if (browser && !deviceStore.isMobile) {
|
||||
goto(ROUTES.NEW_CHAT, { replaceState: true });
|
||||
conversationsStore.openNewChat();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,7 +65,7 @@
|
||||
if (history.length > 1) {
|
||||
history.back();
|
||||
} else {
|
||||
goto(ROUTES.NEW_CHAT);
|
||||
conversationsStore.openNewChat();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
+7
-7
@@ -1,15 +1,15 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import HorizontalScrollCarousel from '$lib/components/app/misc/HorizontalScrollCarousel.svelte';
|
||||
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
|
||||
import { expect, waitFor } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
component: HorizontalScrollCarousel,
|
||||
component: ScrollCarousel,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev'],
|
||||
title: 'Components/HorizontalScrollCarousel/Accessibility'
|
||||
title: 'Components/ScrollCarousel/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
>
|
||||
<div>
|
||||
<button type="button">before</button>
|
||||
<HorizontalScrollCarousel class="w-96">
|
||||
<ScrollCarousel class="w-96" variant="center">
|
||||
<div class="h-12 w-12 shrink-0 bg-muted"></div>
|
||||
<div class="h-12 w-12 shrink-0 bg-muted"></div>
|
||||
</HorizontalScrollCarousel>
|
||||
</ScrollCarousel>
|
||||
<button type="button">after</button>
|
||||
</div>
|
||||
</Story>
|
||||
@@ -60,10 +60,10 @@
|
||||
>
|
||||
<div>
|
||||
<button type="button">before</button>
|
||||
<HorizontalScrollCarousel class="w-48">
|
||||
<ScrollCarousel class="w-48" variant="center">
|
||||
{#each [...Array(20).keys()] as i (i)}
|
||||
<div class="h-12 w-24 shrink-0 bg-muted">{i}</div>
|
||||
{/each}
|
||||
</HorizontalScrollCarousel>
|
||||
</ScrollCarousel>
|
||||
</div>
|
||||
</Story>
|
||||
@@ -97,6 +97,38 @@ describe('parseModelId', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('strips trailing container format segments from model names', () => {
|
||||
expect(parseModelId('unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'DeepSeek-V4-Flash-0731',
|
||||
orgName: 'unsloth',
|
||||
params: null,
|
||||
quantization: 'Q2_K_XL',
|
||||
raw: 'unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('unsloth/Laguna-S-2.1-GGUF:Q4_K_XL')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Laguna-S-2.1',
|
||||
orgName: 'unsloth',
|
||||
params: null,
|
||||
quantization: 'Q4_K_XL',
|
||||
raw: 'unsloth/Laguna-S-2.1-GGUF:Q4_K_XL',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('org/Model-Name-GGUF')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Model-Name',
|
||||
orgName: 'org',
|
||||
params: null,
|
||||
quantization: null,
|
||||
raw: 'org/Model-Name-GGUF',
|
||||
tags: []
|
||||
});
|
||||
});
|
||||
|
||||
it('handles real-world examples correctly', () => {
|
||||
expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
|
||||
Reference in New Issue
Block a user