webui: Server tools (#21237)

* wip: server_tools

* feat: Integrate with `/tools` endpoint

* feat: Builtin + MCP + JSON Schema Tools WIP

* refactor

* displayName -> display_name

* snake_case everywhere

* rm redundant field

* feat: Improvements

* chore: update webui build output

* refactor: Updates after server updates

* chore: update webui build output

* change arg to --tools all

* feat: UI improvements

* chore: update webui build output

* add readme mention

* llama-gen-docs

* chore: update webui build output

* chore: update webui build output

* chore: update webui build output

* feat: Reorganize settings sections

* feat: Separate dialogs for MCP Servers Settings and Import/Export

* feat: WIP

* feat: WIP

* feat: WIP

* feat: WIP

* feat: WIP

* feat: WIP

* WIP on allozaur/20677-webui-server-tools

* feat: UI improvements

* chore: Update package lock

* chore: Run `npm audit fix`

* feat: UI WIP

* feat: UI

* refactor: Desktop Icon Strip DRY

* feat: Cleaner rendering and transition for ChatScreen

* feat: UI improvements

* feat: UI improvement

* feat: Remove MCP Server "enable" switch from Tools submenu

* chore: Run `npm audit fix`

* feat: WIP

* feat: Logic improvements

* refactor: Cleanup

* refactor: DRY

* test: Fix Chat Sidebar UI Tests

* chore: Update package lock

* refactor: Cleanup

* feat: Chat Message Action Card with Continue and Permission flow implementations

* feat: Add agentic steering messages, draft messages and improve chat UX

* fix: Search results UI

* test: Fix unit test

* feat: UI/UX improvements

* refactor: Simplify `useToolsPanel` access in components

* feat: Implement Processing Info Context API

* feat: Implement 'Go back to chat' functionality for settings

* feat: Enhance MCP Server management in Chat Form Attachments

* style: Minor UI and branding adjustments

* chore: Update webui static build output

* chore: Formatting, linting & type checks

* feat: Draft messages logic

* feat: UI improvements

* feat: Steering Messages improvements

* refactor: Cleanup

* refactor: Cleanup

* feat: Improve UI

* refactor: Settings navigation hook

* refactor: DRY code

* refactor: DRY ChatMessageUser UI components

* refactor: Desktop Icon Strip DRY

* refactor: Tools & permissions

* fix: Navigation condition

* refactor: Cleanup

* refactor: Cleanup

* refactor: Cleanup

* fix: preserve reasoning_content in agentic flow

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
Aleksander Grygier
2026-04-28 14:35:49 +03:00
committed by GitHub
parent 19821178be
commit f42e29fdf1
138 changed files with 11345 additions and 8326 deletions
File diff suppressed because one or more lines are too long
+5618 -5478
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+392 -500
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -29,7 +29,7 @@
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.987 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
@@ -77,7 +77,7 @@
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.19 0 0);
--sidebar: oklch(0.2 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
@@ -1,18 +1,20 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Button, type ButtonVariant, type ButtonSize } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { Component } from 'svelte';
import { TooltipSide } from '$lib/enums';
interface Props {
icon: Component;
tooltip: string;
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
size?: 'default' | 'sm' | 'lg' | 'icon';
variant?: ButtonVariant;
size?: ButtonSize;
iconSize?: string;
class?: string;
disabled?: boolean;
onclick: (e?: MouseEvent) => void;
'aria-label'?: string;
tooltipSide?: TooltipSide;
}
let {
@@ -23,6 +25,7 @@
class: className = '',
disabled = false,
iconSize = 'h-3 w-3',
tooltipSide = TooltipSide.TOP,
onclick,
'aria-label': ariaLabel
}: Props = $props();
@@ -35,7 +38,7 @@
{size}
{disabled}
{onclick}
class="h-6 w-6 p-0 {className} flex"
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
aria-label={ariaLabel || tooltip}
>
{@const IconComponent = icon}
@@ -44,7 +47,7 @@
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Content side={tooltipSide}>
<p>{tooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -300,7 +300,7 @@
if (sendOnEnter || isModifier) {
event.preventDefault();
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
if (!canSubmit || disabled || hasLoadingAttachments) return;
onSubmit?.();
}
@@ -555,7 +555,7 @@
class="relative {className}"
onsubmit={(e) => {
e.preventDefault();
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
if (!canSubmit || disabled || hasLoadingAttachments) return;
onSubmit?.();
}}
>
@@ -1,333 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { Plus, MessageSquare, Settings, Zap, FolderOpen } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Switch } from '$lib/components/ui/switch';
import { FILE_TYPE_ICONS, TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
}
let {
class: className = '',
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
}: Props = $props();
let isNewChat = $derived(!page.params.id);
let systemMessageTooltip = $derived(
isNewChat
? 'Add custom system message for a new conversation'
: 'Inject custom system message at the beginning of the conversation'
);
let dropdownOpen = $state(false);
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let hasMcpServers = $derived(mcpServers.length > 0);
let mcpSearchQuery = $state('');
let filteredMcpServers = $derived.by(() => {
const query = mcpSearchQuery.toLowerCase().trim();
if (!query) return mcpServers;
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
});
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
function handleMcpSubMenuOpen(open: boolean) {
if (open) {
mcpSearchQuery = '';
mcpStore.runHealthChecksForServers(mcpServers);
}
}
function handleMcpPromptClick() {
dropdownOpen = false;
onMcpPromptClick?.();
}
function handleMcpSettingsClick() {
dropdownOpen = false;
onMcpSettingsClick?.();
}
function handleMcpResourcesClick() {
dropdownOpen = false;
onMcpResourcesClick?.();
}
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
</script>
<div class="flex items-center gap-1 {className}">
<DropdownMenu.Root bind:open={dropdownOpen}>
<DropdownMenu.Trigger name="Attach files" {disabled}>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<Button
class="file-upload-button h-8 w-8 rounded-full p-0"
{disabled}
variant="secondary"
type="button"
>
<span class="sr-only">{fileUploadTooltipText}</span>
<Plus class="h-4 w-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{fileUploadTooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-48">
{#if hasVisionModality}
<DropdownMenu.Item
class="images-button flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.image class="h-4 w-4" />
<span>Images</span>
</DropdownMenu.Item>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="images-button flex cursor-pointer items-center gap-2"
disabled
>
<FILE_TYPE_ICONS.image class="h-4 w-4" />
<span>Images</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>Image processing requires a vision model</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasAudioModality}
<DropdownMenu.Item
class="audio-button flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
<span>Audio Files</span>
</DropdownMenu.Item>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item class="audio-button flex cursor-pointer items-center gap-2" disabled>
<FILE_TYPE_ICONS.audio class="h-4 w-4" />
<span>Audio Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>Audio files processing requires an audio model</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.text class="h-4 w-4" />
<span>Text Files</span>
</DropdownMenu.Item>
{#if hasVisionModality}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
<span>PDF Files</span>
</DropdownMenu.Item>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onFileUpload?.()}
>
<FILE_TYPE_ICONS.pdf class="h-4 w-4" />
<span>PDF Files</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => onSystemPromptClick?.()}
>
<MessageSquare class="h-4 w-4" />
<span>System Message</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{systemMessageTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenu.Separator />
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class="h-4 w-4" />
<span>MCP Servers</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 pt-0">
<DropdownMenuSearchable
placeholder="Search servers..."
bind:searchValue={mcpSearchQuery}
emptyMessage={hasMcpServers ? 'No servers found' : 'No MCP servers configured'}
isEmpty={filteredMcpServers.length === 0}
>
<div class="max-h-64 overflow-y-auto">
{#each filteredMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => !hasError && toggleServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if mcpStore.getServerFavicon(server.id)}
<img
src={mcpStore.getServerFavicon(server.id)}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate text-sm">{getServerLabel(server)}</span>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
</button>
{/each}
</div>
{#snippet footer()}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</DropdownMenuSearchable>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{#if hasMcpPromptsSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpPromptClick}
>
<Zap class="h-4 w-4" />
<span>MCP Prompt</span>
</DropdownMenu.Item>
{/if}
{#if hasMcpResourcesSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpResourcesClick}
>
<FolderOpen class="h-4 w-4" />
<span>MCP Resources</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -1,170 +0,0 @@
<script lang="ts">
import { Plus, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Sheet from '$lib/components/ui/sheet';
import { FILE_TYPE_ICONS } from '$lib/constants';
import { McpLogo } from '$lib/components/app';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
}
let {
class: className = '',
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
}: Props = $props();
let sheetOpen = $state(false);
function handleMcpPromptClick() {
sheetOpen = false;
onMcpPromptClick?.();
}
function handleMcpSettingsClick() {
onMcpSettingsClick?.();
}
function handleMcpResourcesClick() {
sheetOpen = false;
onMcpResourcesClick?.();
}
function handleSheetFileUpload() {
sheetOpen = false;
onFileUpload?.();
}
function handleSheetSystemPromptClick() {
sheetOpen = false;
onSystemPromptClick?.();
}
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
const sheetItemClass =
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
</script>
<div class="flex items-center gap-1 {className}">
<Sheet.Root bind:open={sheetOpen}>
<Button
class="file-upload-button h-8 w-8 rounded-full p-0"
{disabled}
variant="secondary"
type="button"
onclick={() => (sheetOpen = true)}
>
<span class="sr-only">{fileUploadTooltipText}</span>
<Plus class="h-4 w-4" />
</Button>
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0">
<Sheet.Header>
<Sheet.Title>Add to chat</Sheet.Title>
<Sheet.Description class="sr-only">
Add files, system prompt or configure MCP servers
</Sheet.Description>
</Sheet.Header>
<div class="flex flex-col gap-1 overflow-y-auto px-1.5 pb-2">
<!-- Images -->
<button
type="button"
class={sheetItemClass}
disabled={!hasVisionModality}
onclick={handleSheetFileUpload}
>
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
<span>Images</span>
{#if !hasVisionModality}
<span class="ml-auto text-xs text-muted-foreground">Requires vision model</span>
{/if}
</button>
<!-- Audio -->
<button
type="button"
class={sheetItemClass}
disabled={!hasAudioModality}
onclick={handleSheetFileUpload}
>
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
<span>Audio Files</span>
{#if !hasAudioModality}
<span class="ml-auto text-xs text-muted-foreground">Requires audio model</span>
{/if}
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.text class="h-4 w-4 shrink-0" />
<span>Text Files</span>
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
<span>PDF Files</span>
{#if !hasVisionModality}
<span class="ml-auto text-xs text-muted-foreground">Text-only</span>
{/if}
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetSystemPromptClick}>
<MessageSquare class="h-4 w-4 shrink-0" />
<span>System Message</span>
</button>
<button type="button" class={sheetItemClass} onclick={handleMcpSettingsClick}>
<McpLogo class="h-4 w-4 shrink-0" />
<span>MCP Servers</span>
</button>
{#if hasMcpPromptsSupport}
<button type="button" class={sheetItemClass} onclick={handleMcpPromptClick}>
<Zap class="h-4 w-4 shrink-0" />
<span>MCP Prompt</span>
</button>
{/if}
{#if hasMcpResourcesSupport}
<button type="button" class={sheetItemClass} onclick={handleMcpResourcesClick}>
<FolderOpen class="h-4 w-4 shrink-0" />
<span>MCP Resources</span>
</button>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
</div>
@@ -1,62 +1,39 @@
<script lang="ts">
import { Settings } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Settings, Plus } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import { DropdownMenuSearchable, McpActiveServersAvatars } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
import { goto } from '$app/navigation';
interface Props {
class?: string;
disabled?: boolean;
onSettingsClick?: () => void;
onMcpSettingsClick?: () => void;
}
let { class: className = '', disabled = false, onSettingsClick }: Props = $props();
let { onMcpSettingsClick }: Props = $props();
let searchQuery = $state('');
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let mcpSearchQuery = $state('');
let allMcpServers = $derived(mcpStore.getServersSorted());
let mcpServers = $derived(allMcpServers.filter((s) => s.enabled));
let hasMcpServers = $derived(mcpServers.length > 0);
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
);
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
const healthState = mcpStore.getHealthCheckState(s.id);
return healthState.status !== HealthCheckStatus.ERROR;
})
);
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, 3)
.map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
.filter((f) => f.url !== null)
);
// let hasAnyMcpServers = $derived(allMcpServers.length > 0);
let filteredMcpServers = $derived.by(() => {
const query = searchQuery.toLowerCase().trim();
if (query) {
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
}
return mcpServers;
const query = mcpSearchQuery.toLowerCase().trim();
if (!query) return mcpServers;
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
});
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
}
function handleDropdownOpen(open: boolean) {
if (open) {
mcpStore.runHealthChecksForServers(mcpServers);
}
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
}
@@ -64,38 +41,33 @@
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
function handleMcpSubMenuOpen(open: boolean) {
if (open) {
mcpSearchQuery = '';
mcpStore.runHealthChecksForServers(allMcpServers);
}
}
function handleMcpSettingsClick() {
onMcpSettingsClick?.();
goto(`${hasMcpServers ? '' : '?add'}#/settings/mcp`);
}
</script>
{#if hasMcpServers && hasEnabledMcpServers && mcpFavicons.length > 0}
<DropdownMenu.Root
onOpenChange={(open) => {
if (!open) {
searchQuery = '';
}
handleDropdownOpen(open);
}}
>
<DropdownMenu.Trigger
{disabled}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<button
type="button"
class="inline-flex cursor-pointer items-center rounded-sm py-1 disabled:cursor-not-allowed disabled:opacity-60"
{disabled}
aria-label="MCP Servers"
>
<McpActiveServersAvatars class={className} />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class="h-4 w-4" />
<DropdownMenu.Content align="start" class="w-72 pt-0">
<span>MCP Servers</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 pt-0">
{#if hasMcpServers}
<DropdownMenuSearchable
bind:searchValue={searchQuery}
placeholder="Search servers..."
bind:searchValue={mcpSearchQuery}
emptyMessage="No servers found"
isEmpty={filteredMcpServers.length === 0}
>
@@ -107,7 +79,7 @@
<button
type="button"
class="flex w-full items-center justify-between gap-2 px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => !hasError && toggleServerForChat(server.id)}
disabled={hasError}
>
@@ -147,7 +119,7 @@
{#snippet footer()}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={onSettingsClick}
onclick={handleMcpSettingsClick}
>
<Settings class="h-4 w-4" />
@@ -155,6 +127,21 @@
</DropdownMenu.Item>
{/snippet}
</DropdownMenuSearchable>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
{:else}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
No MCP servers configured
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Plus class="h-4 w-4" />
<span>Add MCP Servers</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -7,20 +7,13 @@
interface Props {
canSend?: boolean;
disabled?: boolean;
isLoading?: boolean;
showErrorState?: boolean;
tooltipLabel?: string;
}
let {
canSend = false,
disabled = false,
isLoading = false,
showErrorState = false,
tooltipLabel
}: Props = $props();
let { canSend = false, disabled = false, showErrorState = false, tooltipLabel }: Props = $props();
let isDisabled = $derived(!canSend || disabled || isLoading);
let isDisabled = $derived(!canSend || disabled);
</script>
{#snippet submitButton(props = {})}
@@ -0,0 +1,146 @@
<script lang="ts">
import { PencilRuler, ChevronDown, ChevronRight, Loader2, Info } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { toolsStore } from '$lib/stores/tools.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
const toolsPanel = useToolsPanel();
const hasMcpServersAvailable = $derived(mcpStore.getServersSorted().length > 0);
</script>
<DropdownMenu.Sub onOpenChange={(open) => open && toolsPanel.handleOpen()}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<PencilRuler class="h-4 w-4" />
<span>Tools</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 p-0">
{#if toolsPanel.totalToolCount === 0}
{#if toolsStore.loading}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
<Loader2 class="mx-auto mb-1 h-4 w-4 animate-spin" />
Loading tools...
</div>
{:else if toolsStore.isToolsEndpointUnreachable}
<div class="grid gap-2.5 px-3 py-4 text-sm text-muted-foreground">
<span class="flex gap-2">
<Info class="mt-0.5 h-4 w-4 shrink-0" />
<span
>Run llama-server with <code>--tools</code> flag to enable
<strong>Built-in Tools</strong>.</span
>
</span>
<span class="flex gap-2">
<Info class="mt-0.5 h-4 w-4 shrink-0" />
<span
>{hasMcpServersAvailable ? 'Enable' : 'Add'} MCP Server(s) to access
<strong>MCP Tools</strong>.</span
>
</span>
</div>
{:else if toolsStore.error}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">Failed to load tools</div>
{:else if toolsPanel.noToolsInfoMessage}
<div class="flex gap-2 px-3 py-4 text-sm text-muted-foreground">
<Info class="mt-0.5 h-4 w-4 shrink-0" />
<span>{toolsPanel.noToolsInfoMessage}</span>
</div>
{:else}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">No tools available</div>
{/if}
{:else}
<div class="max-h-80 overflow-y-auto p-2 pr-1">
{#each toolsPanel.activeGroups as group (group.label)}
{@const isExpanded = toolsPanel.expandedGroups.has(group.label)}
{@const { checked, indeterminate } = toolsPanel.getGroupCheckedState(group)}
{@const favicon = toolsPanel.getFavicon(group)}
<Collapsible.Root
open={isExpanded}
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.label)}
>
<div class="flex items-center gap-1">
<Collapsible.Trigger
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
{:else}
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{toolsPanel.getEnabledToolCount(group)}/{group.tools.length}
</span>
</Collapsible.Trigger>
<Tooltip.Root>
<Tooltip.Trigger>
<Checkbox
{checked}
{indeterminate}
onCheckedChange={() => toolsStore.toggleGroup(group)}
class="mr-2 h-4 w-4 shrink-0"
/>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checked ? 'Disable' : 'Enable'}
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each group.tools as tool (tool.function.name)}
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50"
onclick={() => toolsStore.toggleTool(tool.function.name)}
>
<Checkbox
checked={toolsStore.isToolEnabled(tool.function.name)}
onCheckedChange={() => toolsStore.toggleTool(tool.function.name)}
class="h-4 w-4 shrink-0"
/>
<span class="min-w-0 flex-1 truncate font-mono text-[12px]">
{tool.function.name}
</span>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/each}
</div>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -6,21 +6,19 @@
ChatFormActionAttachmentsSheet,
ChatFormActionRecord,
ChatFormActionSubmit,
McpServersSelector,
ModelsSelector,
ModelsSelectorDropdown,
ModelsSelectorSheet
} from '$lib/components/app';
import { SETTINGS_SECTION_TITLES } from '$lib/constants';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { getChatSettingsDialogContext } from '$lib/contexts';
import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils';
import { config } from '$lib/stores/settings.svelte';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode, serverError } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { config } from '$lib/stores/settings.svelte';
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { getFileTypeCategory } from '$lib/utils';
import { goto } from '$app/navigation';
interface Props {
canSend?: boolean;
@@ -165,7 +163,8 @@
return '';
});
let selectorModelRef: ModelsSelector | ModelsSelectorSheet | undefined = $state(undefined);
let selectorModelRef: ModelsSelectorDropdown | ModelsSelectorSheet | undefined =
$state(undefined);
let isMobile = new IsMobile();
@@ -173,8 +172,6 @@
selectorModelRef?.open();
}
const chatSettingsDialog = getChatSettingsDialogContext();
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
@@ -200,8 +197,8 @@
{onFileUpload}
{onSystemPromptClick}
{onMcpPromptClick}
onMcpSettingsClick={() => goto('#/settings/mcp')}
{onMcpResourcesClick}
onMcpSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
/>
{:else}
<ChatFormActionAttachmentsDropdown
@@ -214,17 +211,12 @@
{onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
onMcpSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
onMcpSettingsClick={() => goto('#/settings/mcp')}
/>
{/if}
<McpServersSelector
{disabled}
onSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
/>
</div>
<div class="ml-auto flex items-center gap-1.5">
<div class="ml-auto flex items-center gap-2">
{#if isMobile.current}
<ModelsSelectorSheet
disabled={disabled || isOffline}
@@ -234,7 +226,7 @@
useGlobalSelection
/>
{:else}
<ModelsSelector
<ModelsSelectorDropdown
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={conversationModel}
@@ -244,7 +236,7 @@
{/if}
</div>
{#if isLoading}
{#if isLoading && !hasText}
<Button
type="button"
variant="secondary"
@@ -263,7 +255,6 @@
<ChatFormActionSubmit
canSend={canSend && hasModelSelected && isSelectedModelInCache}
{disabled}
{isLoading}
tooltipLabel={submitTooltip}
showErrorState={hasModelSelected && !isSelectedModelInCache}
/>
@@ -0,0 +1,182 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_EXTRA_ITEMS,
ATTACHMENT_MCP_ITEMS,
ATTACHMENT_TOOLTIP_TEXT,
TOOLTIP_DELAY_DURATION
} from '$lib/constants';
import { AttachmentMenuItemId } from '$lib/enums';
import { ChatFormActionToolsSubmenu, ChatFormActionMcpServersSubmenu } from '$lib/components/app';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
}
let {
class: className = '',
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
}: Props = $props();
let dropdownOpen = $state(false);
function handleMcpSettingsClick() {
dropdownOpen = false;
onMcpSettingsClick?.();
}
const attachmentMenu = useAttachmentMenu(
() => ({ hasVisionModality, hasAudioModality, hasMcpPromptsSupport, hasMcpResourcesSupport }),
() => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }),
() => {
dropdownOpen = false;
}
);
</script>
<div class="flex items-center gap-1 {className}">
<DropdownMenu.Root bind:open={dropdownOpen}>
<DropdownMenu.Trigger name="Attach files" {disabled}>
<Tooltip.Root>
<Tooltip.Trigger class="w-full">
<Button
class="file-upload-button h-8 w-8 rounded-full p-0"
{disabled}
variant="secondary"
type="button"
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
<Plus class="h-4 w-4" />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{ATTACHMENT_TOOLTIP_TEXT}</p>
</Tooltip.Content>
</Tooltip.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-48">
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
{#if enabled}
<DropdownMenu.Item
class="{item.class ?? ''} flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4" />
<span>{item.label}</span>
</DropdownMenu.Item>
{:else if item.disabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="{item.class ?? ''} flex cursor-pointer items-center gap-2"
disabled
>
<item.icon class="h-4 w-4" />
<span>{item.label}</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{item.disabledTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
{#if !attachmentMenu.isItemEnabled('hasVisionModality')}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={attachmentMenu.callbacks.onFileUpload}
>
{@const pdfItem = ATTACHMENT_FILE_ITEMS.find(
(i) => i.id === AttachmentMenuItemId.PDF
)}
{#if pdfItem}
<pdfItem.icon class="h-4 w-4" />
<span>{pdfItem.label}</span>
{/if}
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<DropdownMenu.Separator />
{#each ATTACHMENT_EXTRA_ITEMS as item (item.id)}
{#if item.id === AttachmentMenuItemId.SYSTEM_MESSAGE}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4" />
<span>{item.label}</span>
</DropdownMenu.Item>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{attachmentMenu.getSystemMessageTooltip()}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
<ChatFormActionToolsSubmenu />
<ChatFormActionMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
{#each ATTACHMENT_MCP_ITEMS as item (item.id)}
{#if attachmentMenu.isItemVisible(item.visibleWhen)}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4" />
<span>{item.label}</span>
</DropdownMenu.Item>
{/if}
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -0,0 +1,184 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Sheet from '$lib/components/ui/sheet';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_EXTRA_ITEMS,
ATTACHMENT_MCP_ITEMS,
ATTACHMENT_TOOLTIP_TEXT
} from '$lib/constants/attachment-menu';
import { ChatFormActionToolsSubmenu, ChatFormActionMcpServersSubmenu } from '$lib/components/app';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { AttachmentMenuItemId } from '$lib/enums';
interface Props {
class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
}
let {
class: className = '',
disabled = false,
hasAudioModality = false,
hasVisionModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
}: Props = $props();
let sheetOpen = $state(false);
const attachmentMenu = useAttachmentMenu(
() => ({ hasVisionModality, hasAudioModality, hasMcpPromptsSupport, hasMcpResourcesSupport }),
() => ({ onFileUpload, onSystemPromptClick, onMcpPromptClick, onMcpResourcesClick }),
() => {
sheetOpen = false;
}
);
function handleMcpSettingsClick() {
sheetOpen = false;
onMcpSettingsClick?.();
}
const sheetItemClass =
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
</script>
<div class="flex items-center gap-1 {className}">
<Sheet.Root bind:open={sheetOpen}>
<Button
class="file-upload-button h-8 w-8 rounded-full p-0"
{disabled}
variant="secondary"
type="button"
onclick={() => (sheetOpen = true)}
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
<Plus class="h-4 w-4" />
</Button>
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
<Sheet.Header>
<Sheet.Title>Add to chat</Sheet.Title>
<Sheet.Description class="sr-only">
Add files, system prompt or configure MCP servers
</Sheet.Description>
</Sheet.Header>
<div class="flex flex-col gap-1 px-1.5 pb-2">
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
{#if enabled}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</button>
{:else if item.disabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<item.icon class="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{item.disabledTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
{#if !attachmentMenu.isItemEnabled('hasVisionModality')}
{@const pdfItem = ATTACHMENT_FILE_ITEMS.find((i) => i.id === AttachmentMenuItemId.PDF)}
{#if pdfItem}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[pdfItem.action]()}
>
<pdfItem.icon class="h-4 w-4 shrink-0" />
<span>{pdfItem.label}</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/if}
{#each ATTACHMENT_EXTRA_ITEMS as item (item.id)}
{#if item.id === AttachmentMenuItemId.SYSTEM_MESSAGE}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{attachmentMenu.getSystemMessageTooltip()}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
<div class="my-2 border-t"></div>
<ChatFormActionToolsSubmenu />
<ChatFormActionMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
{#each ATTACHMENT_MCP_ITEMS as item (item.id)}
{#if attachmentMenu.isItemVisible(item.visibleWhen)}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</button>
{/if}
{/each}
</div>
</Sheet.Content>
</Sheet.Root>
</div>
@@ -1,12 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services/database.service';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { MessageRole, AttachmentType } from '$lib/enums';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import {
ChatMessageAssistant,
ChatMessageUser,
@@ -118,7 +118,7 @@
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
if (conversationDeleted) {
goto(`${base}/`);
goto(`#/`);
}
return;
@@ -138,7 +138,7 @@
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
if (conversationDeleted) {
goto(`${base}/`);
goto(`#/`);
}
} else {
chatActions.delete(message);
@@ -200,7 +200,7 @@
const conversationDeleted = await chatStore.removeSystemPromptPlaceholder(message.id);
isEditing = false;
if (conversationDeleted) {
goto(`${base}/`);
goto(`#/`);
}
return;
}
@@ -252,70 +252,72 @@
}
</script>
{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem
bind:textareaElement
class={className}
{deletionInfo}
{message}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else if mcpPromptExtra}
<ChatMessageMcpPrompt
class={className}
{deletionInfo}
{message}
mcpPrompt={mcpPromptExtra}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else if message.role === MessageRole.USER}
<ChatMessageUser
class={className}
{deletionInfo}
{message}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onForkConversation={handleForkConversation}
onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else}
<ChatMessageAssistant
bind:textareaElement
class={className}
{deletionInfo}
{isLastAssistantMessage}
{message}
{toolMessages}
messageContent={message.content}
onConfirmDelete={handleConfirmDelete}
onContinue={handleContinue}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onForkConversation={handleForkConversation}
onNavigateToSibling={handleNavigateToSibling}
onRegenerate={handleRegenerate}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{/if}
<div use:fadeInView>
{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem
bind:textareaElement
class={className}
{deletionInfo}
{message}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else if mcpPromptExtra}
<ChatMessageMcpPrompt
class={className}
{deletionInfo}
{message}
mcpPrompt={mcpPromptExtra}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else if message.role === MessageRole.USER}
<ChatMessageUser
class={className}
{deletionInfo}
{message}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onForkConversation={handleForkConversation}
onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else}
<ChatMessageAssistant
bind:textareaElement
class={className}
{deletionInfo}
{isLastAssistantMessage}
{message}
{toolMessages}
messageContent={message.content}
onConfirmDelete={handleConfirmDelete}
onContinue={handleContinue}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onForkConversation={handleForkConversation}
onNavigateToSibling={handleNavigateToSibling}
onRegenerate={handleRegenerate}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet, Component } from 'svelte';
interface Props {
icon: Component<{ class?: string }>;
message: Snippet;
actions: Snippet;
}
let { icon: Icon, message, actions }: Props = $props();
</script>
<div class="my-2 rounded-lg border border-border bg-card p-3">
<div class="mb-3 flex items-center gap-2 text-sm">
<Icon class="h-4 w-4 shrink-0 text-muted-foreground" />
<span>
{@render message()}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
{@render actions()}
</div>
</div>
@@ -1,38 +1,104 @@
<script lang="ts">
import { Wrench, Loader2, Brain } from '@lucide/svelte';
import {
ChatMessageStatistics,
CollapsibleContentBlock,
MarkdownContent,
SyntaxHighlightedCode
SyntaxHighlightedCode,
ChatMessagePermissionRequest,
ChatMessageContinueRequest
} from '$lib/components/app';
import { config } from '$lib/stores/settings.svelte';
import { Wrench, Loader2, Brain } from '@lucide/svelte';
import { AgenticSectionType, FileTypeText } from '$lib/enums';
import { formatJsonPretty } from '$lib/utils';
import {
AgenticSectionType,
ChatMessageStatsView,
FileTypeText,
ToolPermissionDecision
} from '$lib/enums';
import type {
ChatMessageAgenticTimings,
ChatMessageAgenticTurnStats,
DatabaseMessage
} from '$lib/types';
import {
deriveAgenticSections,
formatJsonPretty,
parseToolResultWithImages,
type AgenticSection,
type ToolResultLine
} from '$lib/utils';
import type { DatabaseMessage } from '$lib/types/database';
import type { ChatMessageAgenticTimings, ChatMessageAgenticTurnStats } from '$lib/types/chat';
import { ChatMessageStatsView } from '$lib/enums';
import {
agenticPendingPermissionRequest,
agenticResolvePermission,
agenticPendingContinueRequest,
agenticResolveContinue
} from '$lib/stores/agentic.svelte';
import { config } from '$lib/stores/settings.svelte';
interface Props {
message: DatabaseMessage;
toolMessages?: DatabaseMessage[];
isStreaming?: boolean;
isLastAssistantMessage?: boolean;
highlightTurns?: boolean;
}
let { message, toolMessages = [], isStreaming = false, highlightTurns = false }: Props = $props();
let {
message,
toolMessages = [],
isStreaming = false,
isLastAssistantMessage = false,
highlightTurns = false
}: Props = $props();
let expandedStates: Record<number, boolean> = $state({});
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
let permissionDismissed = $state(false);
const pendingPermission = $derived(
isStreaming && isLastAssistantMessage ? agenticPendingPermissionRequest(message.convId) : null
);
// Reset dismissed when pendingPermission changes (new request or cleared)
let prevPendingRef: typeof pendingPermission = null;
$effect(() => {
if (pendingPermission !== prevPendingRef) {
prevPendingRef = pendingPermission;
if (pendingPermission) {
permissionDismissed = false;
}
}
});
function handlePermission(decision: ToolPermissionDecision) {
permissionDismissed = true;
agenticResolvePermission(message.convId, decision);
}
let continueDismissed = $state(false);
const pendingContinue = $derived(
isStreaming && isLastAssistantMessage ? agenticPendingContinueRequest(message.convId) : false
);
let prevContinueRef = false;
$effect(() => {
if (pendingContinue !== prevContinueRef) {
prevContinueRef = pendingContinue;
if (pendingContinue) {
continueDismissed = false;
}
}
});
function handleContinue(shouldContinue: boolean) {
continueDismissed = true;
agenticResolveContinue(message.convId, shouldContinue);
}
const sections = $derived(deriveAgenticSections(message, toolMessages, [], isStreaming));
// Parse tool results with images
@@ -201,7 +267,11 @@
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if section.toolResult}
{#if isPending}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Waiting for result...
</div>
{:else if section.toolResult}
<div class="overflow-auto rounded-lg border border-border bg-muted p-4">
{#each section.parsedLines as line, i (i)}
<div class="font-mono text-xs leading-relaxed whitespace-pre-wrap">{line.text}</div>
@@ -215,10 +285,8 @@
{/if}
{/each}
</div>
{:else if isPending}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Waiting for result...
</div>
{:else}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">No output</div>
{/if}
</div>
</CollapsibleContentBlock>
@@ -289,6 +357,18 @@
{@render renderSection(section, index)}
{/each}
{/if}
{#if pendingPermission && !permissionDismissed}
<ChatMessagePermissionRequest
toolName={pendingPermission.toolName}
serverLabel={pendingPermission.serverLabel}
onDecision={handlePermission}
/>
{/if}
{#if pendingContinue && !continueDismissed}
<ChatMessageContinueRequest onDecision={handleContinue} />
{/if}
</div>
<style>
@@ -4,7 +4,7 @@
ChatMessageActions,
ChatMessageStatistics,
ModelBadge,
ModelsSelector
ModelsSelectorDropdown
} from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
@@ -308,6 +308,7 @@
{message}
{toolMessages}
isStreaming={isChatStreaming()}
{isLastAssistantMessage}
highlightTurns={highlightAgenticTurns}
/>
{/if}
@@ -336,10 +337,10 @@
class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground"
>
{#if isRouter}
<ModelsSelector
<ModelsSelectorDropdown
currentModel={displayedModel}
disabled={isLoading()}
onModelChange={async (modelId, modelName) => {
onModelChange={async (modelId: string, modelName: string) => {
const status = modelsStore.getModelStatus(modelId);
if (status !== ServerModelStatus.LOADED) {
@@ -0,0 +1,30 @@
<script lang="ts">
import { RotateCw } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import ChatMessageActionCard from './ChatMessageActionCard.svelte';
interface Props {
onDecision: (shouldContinue: boolean) => void;
}
let { onDecision }: Props = $props();
</script>
<ChatMessageActionCard icon={RotateCw}>
{#snippet message()}
Agentic turn limit reached. Continue?
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={() => onDecision(true)}>Continue</Button>
<Button
variant="destructive"
size="sm"
class="text-destructive hover:text-destructive"
onclick={() => onDecision(false)}
>
Stop
</Button>
{/snippet}
</ChatMessageActionCard>
@@ -0,0 +1,88 @@
<script lang="ts">
import { ChevronDown, ShieldQuestion } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as ButtonGroup from '$lib/components/ui/button-group';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { ToolSource, ToolPermissionDecision } from '$lib/enums';
import { TOOL_SERVER_LABELS } from '$lib/constants';
import { toolsStore } from '$lib/stores/tools.svelte';
import ChatMessageActionCard from './ChatMessageActionCard.svelte';
interface Props {
toolName: string;
serverLabel: string;
onDecision: (decision: ToolPermissionDecision) => void;
}
let { toolName, serverLabel, onDecision }: Props = $props();
</script>
<ChatMessageActionCard icon={ShieldQuestion}>
{#snippet message()}
Allow use of
<span class="font-semibold">{toolName}</span>
{#if serverLabel}
from <span class="font-semibold">{serverLabel}</span>
{/if}
?
{/snippet}
{#snippet actions()}
<DropdownMenu.Root>
<ButtonGroup.Root
class="overflow-hidden rounded-md bg-foreground text-white shadow-sm dark:bg-secondary dark:text-foreground"
>
<Button
class="rounded-none! shadow-none!"
size="sm"
onclick={() => onDecision(ToolPermissionDecision.ONCE)}
>
Allow once
</Button>
<ButtonGroup.Separator />
<DropdownMenu.Trigger>
<Button size="sm" class="rounded-none! !ps-2 shadow-none!">
<ChevronDown class="h-3.5 w-3.5" />
</Button>
</DropdownMenu.Trigger>
</ButtonGroup.Root>
<DropdownMenu.Content align="start" class="min-w-[8rem]">
<DropdownMenu.Item onclick={() => onDecision(ToolPermissionDecision.ALWAYS)}>
Always allow <pre>{toolName}</pre>
tool
</DropdownMenu.Item>
{#if serverLabel}
<DropdownMenu.Item onclick={() => onDecision(ToolPermissionDecision.ALWAYS_SERVER)}>
Always allow all tools from {serverLabel}
</DropdownMenu.Item>
{:else}
{@const source = toolsStore.getToolSource(toolName)}
{@const providerName =
source === ToolSource.BUILTIN
? TOOL_SERVER_LABELS[ToolSource.BUILTIN]
: source === ToolSource.CUSTOM
? TOOL_SERVER_LABELS[ToolSource.CUSTOM]
: 'MCP Tools'}
<DropdownMenu.Item onclick={() => onDecision(ToolPermissionDecision.ALWAYS_SERVER)}>
Approve all tools from {providerName}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button
variant="destructive"
size="sm"
class="text-destructive hover:text-destructive"
onclick={() => onDecision(ToolPermissionDecision.DENY)}
>
Deny
</Button>
{/snippet}
</ChatMessageActionCard>
@@ -1,11 +1,9 @@
<script lang="ts">
import { Card } from '$lib/components/ui/card';
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts';
import { config } from '$lib/stores/settings.svelte';
import ChatMessageActions from './ChatMessageActions.svelte';
import ChatMessageEditForm from './ChatMessageEditForm.svelte';
import { MessageRole } from '$lib/enums';
import ChatMessageUserBubble from './ChatMessageUserBubble.svelte';
interface Props {
class?: string;
@@ -44,34 +42,6 @@
// Get contexts
const editCtx = getMessageEditContext();
let isMultiline = $state(false);
let messageElement: HTMLElement | undefined = $state();
const currentConfig = config();
$effect(() => {
if (!messageElement || !message.content.trim()) return;
if (message.content.includes('\n')) {
isMultiline = true;
return;
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const element = entry.target as HTMLElement;
const estimatedSingleLineHeight = 24; // Typical line height for text-md
isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
}
});
resizeObserver.observe(messageElement);
return () => {
resizeObserver.disconnect();
};
});
</script>
<div
@@ -82,29 +52,11 @@
{#if editCtx.isEditing}
<ChatMessageEditForm />
{:else}
{#if message.extra && message.extra.length > 0}
<div class="mb-2 max-w-[80%]">
<ChatAttachmentsList attachments={message.extra} readonly imageHeight="h-80" />
</div>
{/if}
{#if message.content.trim()}
<Card
class="max-w-[80%] overflow-y-auto rounded-[1.125rem] border-none bg-primary/5 px-3.75 py-1.5 text-foreground backdrop-blur-md data-[multiline]:py-2.5 dark:bg-primary/15"
data-multiline={isMultiline ? '' : undefined}
style="max-height: var(--max-message-height); overflow-wrap: anywhere; word-break: break-word;"
>
{#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement}>
<MarkdownContent class="markdown-user-content -my-4" content={message.content} />
</div>
{:else}
<span bind:this={messageElement} class="text-md whitespace-pre-wrap">
{message.content}
</span>
{/if}
</Card>
{/if}
<ChatMessageUserBubble
content={message.content}
attachments={message.extra}
renderMarkdown={true}
/>
{#if message.timestamp}
<div class="max-w-[80%]">
@@ -0,0 +1,76 @@
<script lang="ts">
import { Card } from '$lib/components/ui/card';
import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
interface Props {
content: string;
attachments?: DatabaseMessageExtra[];
renderMarkdown?: boolean;
textColorClass?: string;
cardBgClass?: string;
maxHeightStyle?: string;
}
let {
content,
attachments = [],
renderMarkdown = false,
textColorClass = 'text-foreground',
cardBgClass = 'dark:bg-primary/15',
maxHeightStyle = 'max-height: var(--max-message-height);'
}: Props = $props();
let isMultiline = $state(false);
let messageElement: HTMLElement | undefined = $state();
const currentConfig = config();
$effect(() => {
if (!messageElement || !content.trim()) return;
if (content.includes('\n')) {
isMultiline = true;
return;
}
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const element = entry.target as HTMLElement;
const estimatedSingleLineHeight = 24; // Typical line height for text-md
isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
}
});
resizeObserver.observe(messageElement);
return () => {
resizeObserver.disconnect();
};
});
</script>
{#if attachments && attachments.length > 0}
<div class="mb-2 max-w-[80%]">
<ChatAttachmentsList {attachments} readonly imageHeight="h-40" />
</div>
{/if}
{#if content.trim()}
<Card
class="max-w-[80%] overflow-y-auto rounded-[1.125rem] border-none bg-primary/5 px-3.75 py-1.5 {textColorClass} backdrop-blur-md data-[multiline]:py-2.5 {cardBgClass}"
data-multiline={isMultiline ? '' : undefined}
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
{#if renderMarkdown && currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement}>
<MarkdownContent class="markdown-user-content -my-4" {content} />
</div>
{:else}
<span bind:this={messageElement} class="text-md whitespace-pre-wrap">
{content}
</span>
{/if}
</Card>
{/if}
@@ -0,0 +1,71 @@
<script lang="ts">
import { ActionIcon } from '$lib/components/app';
import ChatMessageEditForm from './ChatMessageEditForm.svelte';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import { ArrowUp, Edit, Trash2 } from '@lucide/svelte';
import { getProcessingInfoContext } from '$lib/contexts';
import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte';
import ChatMessageUserBubble from './ChatMessageUserBubble.svelte';
interface Props {
class?: string;
content: string;
extras?: DatabaseMessageExtra[];
onSendImmediately: () => void;
onEdit: (newContent: string, extras?: DatabaseMessageExtra[]) => void;
onDelete: () => void;
}
let {
class: className = '',
content,
extras = [],
onSendImmediately,
onEdit,
onDelete
}: Props = $props();
const processingInfoCtx = getProcessingInfoContext();
let showProcessingInfo = $derived(processingInfoCtx.showProcessingInfo);
const editCtx = useMessageEditContext({
getContent: () => content,
getExtras: () => extras,
onSave: (content, extras) => onEdit(content, extras)
});
</script>
<div
use:fadeInView
aria-label="Pending user message"
class="group flex flex-col items-end gap-3 transition-opacity hover:opacity-80 md:gap-2 {className} sticky {showProcessingInfo
? 'bottom-44'
: 'bottom-32'}"
role="group"
>
{#if editCtx.isEditing}
<ChatMessageEditForm />
{:else}
<ChatMessageUserBubble
{content}
attachments={extras}
textColorClass="text-muted-foreground"
cardBgClass="dark:bg-primary/8"
maxHeightStyle="overflow-wrap: anywhere; word-break: break-word;"
/>
<div class="max-w-[80%]">
<div class="relative flex h-6 items-center justify-between">
<div class="right-0 flex items-center gap-2 opacity-100 transition-opacity">
<div
class="pointer-events-auto inset-0 flex items-center gap-1 opacity-0 transition-all duration-150 group-hover:opacity-100"
>
<ActionIcon icon={Edit} tooltip="Edit" onclick={editCtx.handleEdit} />
<ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} />
<ActionIcon icon={ArrowUp} tooltip="Send immediately" onclick={onSendImmediately} />
</div>
</div>
</div>
</div>
{/if}
</div>
@@ -1,11 +1,23 @@
<script lang="ts">
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import { ChatMessage } from '$lib/components/app';
import ChatMessageUserPending from './ChatMessageUserPending.svelte';
import { setChatActionsContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte';
import {
chatPendingMessageContent,
chatPendingMessageExtras,
chatClearPendingMessage,
chatInjectPendingMessage
} from '$lib/stores/chat.svelte';
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
import {
agenticPendingSteeringMessageContent,
agenticPendingSteeringMessageExtras,
agenticClearSteeringMessage,
agenticInjectSteeringMessage
} from '$lib/stores/agentic.svelte';
import {
copyToClipboard,
formatMessageForClipboard,
@@ -14,12 +26,11 @@
} from '$lib/utils';
interface Props {
class?: string;
messages?: DatabaseMessage[];
onUserAction?: () => void;
}
let { class: className, messages = [], onUserAction }: Props = $props();
let { messages = [], onUserAction }: Props = $props();
let allConversationMessages = $state<DatabaseMessage[]>([]);
const currentConfig = config();
@@ -196,19 +207,42 @@
});
</script>
<div
class="flex h-full flex-col space-y-10 pt-24 {className}"
style="height: auto; min-height: calc(100dvh - 14rem);"
>
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)}
<div use:fadeInView>
<ChatMessage
class="mx-auto w-full max-w-[48rem]"
{message}
{toolMessages}
{isLastAssistantMessage}
{siblingInfo}
/>
</div>
{/each}
</div>
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)}
<ChatMessage
class="mx-auto mt-12 w-full max-w-[48rem]"
{message}
{toolMessages}
{isLastAssistantMessage}
{siblingInfo}
/>
{/each}
{#if activeConversation() && agenticPendingSteeringMessageContent(activeConversation()!.id)}
{@const convId = activeConversation()!.id}
{@const pendingContent = agenticPendingSteeringMessageContent(convId)}
{#if pendingContent}
<ChatMessageUserPending
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
extras={agenticPendingSteeringMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
onEdit={(newContent, extras) => agenticInjectSteeringMessage(convId, newContent, extras)}
onDelete={() => agenticClearSteeringMessage(convId)}
/>
{/if}
{:else if activeConversation() && chatPendingMessageContent(activeConversation()!.id)}
{@const convId = activeConversation()!.id}
{@const pendingContent = chatPendingMessageContent(convId)}
{#if pendingContent}
<ChatMessageUserPending
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
extras={chatPendingMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
onEdit={(newContent, extras) => chatInjectPendingMessage(convId, newContent, extras)}
onDelete={() => chatClearPendingMessage(convId)}
/>
{/if}
{/if}
@@ -2,7 +2,6 @@
import { afterNavigate } from '$app/navigation';
import {
ChatScreenForm,
ChatScreenHeader,
ChatMessages,
ChatScreenProcessingInfo,
DialogEmptyFileAlert,
@@ -12,15 +11,16 @@
} from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { KeyboardKey } from '$lib/enums';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import {
chatStore,
errorDialog,
isLoading,
isChatStreaming,
isEditing,
getAddFilesHandler
getAddFilesHandler,
activeProcessingState
} from '$lib/stores/chat.svelte';
import {
conversationsStore,
@@ -34,9 +34,11 @@
import { parseFilesToMessageExtras, processFilesToChatUploaded } from '$lib/utils/browser-only';
import { ErrorDialogType } from '$lib/enums';
import { onMount } from 'svelte';
import { fade, fly, slide } from 'svelte/transition';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import { Trash2, AlertTriangle, RefreshCw } from '@lucide/svelte';
import ChatScreenDragOverlay from './ChatScreenDragOverlay.svelte';
import { page } from '$app/state';
import { setProcessingInfoContext } from '$lib/contexts';
let { showCenteredEmpty = false } = $props();
@@ -79,6 +81,18 @@
let isCurrentConversationLoading = $derived(isLoading() || isChatStreaming());
let showProcessingInfo = $derived(
isCurrentConversationLoading ||
(config().keepStatsVisible && !!page.params.id) ||
activeProcessingState() !== null
);
setProcessingInfoContext({
get showProcessingInfo() {
return showProcessingInfo;
}
});
let isRouter = $derived(isRouterMode());
let conversationModel = $derived(
@@ -208,20 +222,13 @@
processFiles(files);
}
function handleKeydown(event: KeyboardEvent) {
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
if (
isCtrlOrCmd &&
event.shiftKey &&
(event.key === KeyboardKey.D_LOWER || event.key === KeyboardKey.D_UPPER)
) {
event.preventDefault();
const { handleKeydown } = useKeyboardShortcuts({
deleteActiveConversation: () => {
if (activeConversation()) {
showDeleteDialog = true;
}
}
}
});
async function handleSystemPromptAdd(draft: { message: string; files: ChatUploadedFile[] }) {
if (draft.message || draft.files.length > 0) {
@@ -342,9 +349,9 @@
<svelte:window onkeydown={handleKeydown} />
<ChatScreenHeader />
{#if !isEmpty}
{#if isServerLoading}
<ServerLoadingSplash />
{:else}
<div
bind:this={chatScrollContainer}
aria-label="Chat interface with file drop zone"
@@ -356,26 +363,42 @@
onscroll={handleScroll}
role="main"
>
<div class="flex flex-col">
<ChatMessages
class="mb-16 md:mb-24"
messages={activeMessages()}
onUserAction={() => {
autoScroll.enable();
autoScroll.scrollToBottom();
}}
/>
<div class="flex grow flex-col pt-14">
{#if !isEmpty}
<ChatMessages
messages={activeMessages()}
onUserAction={() => {
autoScroll.enable();
autoScroll.scrollToBottom();
}}
/>
{/if}
<div
class="pointer-events-none sticky right-0 bottom-4 left-0 mt-auto"
in:slide={{ duration: 150, axis: 'y' }}
class="pointer-events-none {isEmpty
? 'absolute bottom-[calc(50dvh-7rem)]'
: 'sticky bottom-4'} right-4 left-4 mt-auto pt-16 transition-all duration-200"
>
<ChatScreenProcessingInfo />
{#if isEmpty}
<div class="mb-8 px-4 text-center" use:fadeInView={{ duration: 300 }}>
<h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">Hello there</h1>
<p class="text-muted-foreground md:text-lg">
{serverStore.props?.modalities?.audio
? 'Record audio, type a message '
: 'Type a message'} or upload files to get started
</p>
</div>
{/if}
{#if page.params.id}
<ChatScreenProcessingInfo />
{/if}
{#if hasPropsError}
<div
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
in:fly={{ y: 10, duration: 250 }}
use:fadeInView={{ y: 10, duration: 250 }}
>
<Alert.Root variant="destructive">
<AlertTriangle class="h-4 w-4" />
@@ -412,69 +435,6 @@
</div>
</div>
</div>
{:else if isServerLoading}
<!-- Server Loading State -->
<ServerLoadingSplash />
{:else}
<div
aria-label="Welcome screen with file drop zone"
class="flex h-full items-center justify-center"
ondragenter={handleDragEnter}
ondragleave={handleDragLeave}
ondragover={handleDragOver}
ondrop={handleDrop}
role="main"
>
<div class="w-full max-w-[48rem] px-4">
<div class="mb-10 text-center" in:fade={{ duration: 300 }}>
<h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">llama.cpp</h1>
<p class="text-muted-foreground md:text-lg">
{serverStore.props?.modalities?.audio
? 'Record audio, type a message '
: 'Type a message'} or upload files to get started
</p>
</div>
{#if hasPropsError}
<div class="mb-4" in:fly={{ y: 10, duration: 250 }}>
<Alert.Root variant="destructive">
<AlertTriangle class="h-4 w-4" />
<Alert.Title class="flex items-center justify-between">
<span>Server unavailable</span>
<button
onclick={() => serverStore.fetch()}
disabled={isServerLoading}
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
>
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
{isServerLoading ? 'Retrying...' : 'Retry'}
</button>
</Alert.Title>
<Alert.Description>{serverError()}</Alert.Description>
</Alert.Root>
</div>
{/if}
<div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
<ChatScreenForm
disabled={hasPropsError}
{initialMessage}
isLoading={isCurrentConversationLoading}
onFileRemove={handleFileRemove}
onFileUpload={handleFileUpload}
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={handleSystemPromptAdd}
showHelperText
bind:uploadedFiles
/>
</div>
</div>
</div>
{/if}
<!-- File Upload Error Alert Dialog -->
@@ -575,21 +535,3 @@
open={Boolean(activeErrorDialog)}
type={activeErrorDialog?.type ?? ErrorDialogType.SERVER}
/>
<style>
.conversation-chat-form {
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
z-index: -1;
left: 0;
right: 0;
width: 100%;
height: 2.375rem;
background-color: var(--background);
}
}
</style>
@@ -1,7 +1,9 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { page } from '$app/state';
import { ChatFormHelperText, ChatForm } from '$lib/components/app';
import { onMount } from 'svelte';
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
interface Props {
class?: string;
@@ -32,11 +34,20 @@
}: Props = $props();
let chatFormRef: ChatForm | undefined = $state(undefined);
let chatId = $derived(page.params.id as string | undefined);
let message = $derived(initialMessage);
let previousIsLoading = $derived(isLoading);
let previousInitialMessage = $derived(initialMessage);
// Sync message when initialMessage prop changes (e.g., after draft restoration)
const { clearDraft } = useDraftMessages({
getChatId: () => chatId,
getMessage: () => message,
getFiles: () => uploadedFiles,
setMessage: (m) => (message = m),
setFiles: (f) => (uploadedFiles = f),
getInitialMessage: () => initialMessage
});
$effect(() => {
if (initialMessage !== previousInitialMessage) {
message = initialMessage;
@@ -51,12 +62,7 @@
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
async function handleSubmit() {
if (
(!message.trim() && uploadedFiles.length === 0) ||
disabled ||
isLoading ||
hasLoadingAttachments
)
if ((!message.trim() && uploadedFiles.length === 0) || disabled || hasLoadingAttachments)
return;
if (!chatFormRef?.checkModelSelected()) return;
@@ -66,6 +72,7 @@
message = '';
uploadedFiles = [];
clearDraft();
chatFormRef?.resetTextareaHeight();
@@ -89,8 +96,10 @@
setTimeout(() => chatFormRef?.focus(), 10);
});
afterNavigate(() => {
setTimeout(() => chatFormRef?.focus(), 10);
afterNavigate((navigation) => {
if (navigation?.from != null) {
setTimeout(() => chatFormRef?.focus(), 10);
}
});
$effect(() => {
@@ -1,26 +0,0 @@
<script lang="ts">
import { Settings } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { useSidebar } from '$lib/components/ui/sidebar';
import { getChatSettingsDialogContext } from '$lib/contexts';
const sidebar = useSidebar();
const chatSettingsDialog = getChatSettingsDialogContext();
</script>
<header
class="pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end p-2 duration-200 ease-linear md:p-4 {sidebar.open
? 'md:left-[var(--sidebar-width)]'
: ''}"
>
<div class="pointer-events-auto flex items-center space-x-2">
<Button
variant="ghost"
size="icon-lg"
onclick={() => chatSettingsDialog.open()}
class="rounded-full backdrop-blur-lg"
>
<Settings class="h-4 w-4" />
</Button>
</div>
</header>
@@ -5,18 +5,17 @@
import { chatStore, isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { activeMessages, activeConversation } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
import { getProcessingInfoContext } from '$lib/contexts';
const processingState = useProcessingState();
const processingInfoCtx = getProcessingInfoContext();
let showProcessingInfo = $derived(processingInfoCtx.showProcessingInfo);
let isCurrentConversationLoading = $derived(isLoading());
let isStreaming = $derived(isChatStreaming());
let hasProcessingData = $derived(processingState.processingState !== null);
let processingDetails = $derived(processingState.getTechnicalDetails());
let showProcessingInfo = $derived(
isCurrentConversationLoading || isStreaming || config().keepStatsVisible || hasProcessingData
);
$effect(() => {
const conversation = activeConversation();
@@ -7,16 +7,10 @@
Monitor,
ChevronLeft,
ChevronRight,
Database
ListRestart,
Sliders
} from '@lucide/svelte';
import {
ChatSettingsFooter,
ChatSettingsImportExportTab,
ChatSettingsFields,
McpLogo,
McpServersSettings
} from '$lib/components/app';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import {
SETTINGS_SECTION_TITLES,
@@ -29,14 +23,16 @@
import { setMode } from 'mode-watcher';
import { ColorMode } from '$lib/enums/ui';
import { SettingsFieldType } from '$lib/enums/settings';
import { fade } from 'svelte/transition';
import type { Component } from 'svelte';
interface Props {
class?: string;
onSave?: () => void;
initialSection?: SettingsSectionTitle;
}
let { onSave, initialSection }: Props = $props();
let { class: className, onSave, initialSection }: Props = $props();
const settingSections: Array<{
fields: SettingsFieldConfig[];
@@ -45,7 +41,7 @@
}> = [
{
title: SETTINGS_SECTION_TITLES.GENERAL,
icon: Settings,
icon: Sliders,
fields: [
{
key: SETTINGS_KEYS.THEME,
@@ -111,6 +107,11 @@
label: 'Show thought in progress',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
label: 'Show tool call in progress',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
label: 'Keep stats visible after generation',
@@ -143,13 +144,13 @@
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.AUTO_SHOW_SIDEBAR_ON_NEW_CHAT,
label: 'Auto-show sidebar on new chat',
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
label: 'Show raw model names',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
label: 'Show raw model names',
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
label: 'Always show agentic turns in conversation',
type: SettingsFieldType.CHECKBOX
}
]
@@ -267,33 +268,18 @@
]
},
{
title: SETTINGS_SECTION_TITLES.IMPORT_EXPORT,
icon: Database,
fields: []
},
{
title: SETTINGS_SECTION_TITLES.MCP,
icon: McpLogo,
title: SETTINGS_SECTION_TITLES.AGENTIC,
icon: ListRestart,
fields: [
{
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
label: 'Agentic loop max turns',
label: 'Agentic turns',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
label: 'Always show agentic turns in conversation',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
label: 'Max lines per tool preview',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
label: 'Show tool call in progress',
type: SettingsFieldType.CHECKBOX
}
]
},
@@ -457,119 +443,116 @@
});
</script>
<div class="flex h-full flex-col overflow-hidden md:flex-row">
<!-- Desktop Sidebar -->
<div class="hidden w-64 border-r border-border/30 p-6 md:block">
<nav class="space-y-1 py-2">
{#each settingSections as section (section.title)}
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
section.title
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
onclick={() => (activeSection = section.title)}
>
<section.icon class="h-4 w-4" />
<span class="ml-2">{section.title}</span>
</button>
{/each}
</nav>
</div>
<!-- Mobile Header with Horizontal Scrollable Menu -->
<div class="flex flex-col pt-6 md:hidden">
<div class="border-b border-border/30 pt-4 md:py-4">
<!-- Horizontal Scrollable Category Menu with Navigation -->
<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 {canScrollLeft
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
onclick={scrollLeft}
aria-label="Scroll left"
>
<ChevronLeft class="h-4 w-4" />
</button>
<div
class="scrollbar-hide overflow-x-auto py-2"
bind:this={scrollContainer}
onscroll={updateScrollButtons}
>
<div class="flex min-w-max gap-2">
{#each settingSections as section (section.title)}
<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 {activeSection ===
section.title
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
onclick={(e: MouseEvent) => {
activeSection = section.title;
scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="h-4 w-4 flex-shrink-0" />
<span>{section.title}</span>
</button>
{/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 {canScrollRight
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
onclick={scrollRight}
aria-label="Scroll right"
>
<ChevronRight class="h-4 w-4" />
</button>
<div class="flex h-full flex-col overflow-y-auto {className} w-full" in:fade={{ duration: 150 }}>
<div class="flex flex-1 flex-col gap-4 md:flex-row">
<!-- Desktop Sidebar -->
<div class="sticky top-0 hidden w-64 flex-col self-start bg-background pt-8 pb-4 md:flex">
<div class="flex items-center gap-2 pb-8">
<Settings class="h-6 w-6" />
<h1 class="text-2xl font-semibold">Settings</h1>
</div>
<nav class="space-y-1">
{#each settingSections as section (section.title)}
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
section.title
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
onclick={() => (activeSection = section.title)}
>
<section.icon class="h-4 w-4" />
<span class="ml-2">{section.title}</span>
</button>
{/each}
</nav>
</div>
</div>
<ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
<div class="space-y-6 p-4 md:p-6">
<div class="grid">
<div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
<currentSection.icon class="h-5 w-5" />
<!-- Mobile Header with Horizontal Scrollable Menu -->
<div class="sticky top-0 z-10 flex flex-col bg-background md:hidden">
<div class="flex items-center gap-2 px-4 pt-4 pb-2 md:pt-6">
<Settings class="h-5 w-5 md:h-6 md:w-6" />
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
</div>
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
</div>
{#if currentSection.title === SETTINGS_SECTION_TITLES.IMPORT_EXPORT}
<ChatSettingsImportExportTab />
{:else if currentSection.title === SETTINGS_SECTION_TITLES.MCP}
<div class="space-y-6">
<ChatSettingsFields
fields={currentSection.fields}
{localConfig}
onConfigChange={handleConfigChange}
onThemeChange={handleThemeChange}
/>
<div class="border-b border-border/30 py-2">
<!-- Horizontal Scrollable Category Menu with Navigation -->
<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 {canScrollLeft
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
onclick={scrollLeft}
aria-label="Scroll left"
>
<ChevronLeft class="h-4 w-4" />
</button>
<div class="border-t border-border/30 pt-6">
<McpServersSettings />
<div
class="scrollbar-hide overflow-x-auto py-2"
bind:this={scrollContainer}
onscroll={updateScrollButtons}
>
<div class="flex min-w-max gap-2">
{#each settingSections as section (section.title)}
<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 {activeSection ===
section.title
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
onclick={(e: MouseEvent) => {
activeSection = section.title;
scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="h-4 w-4 flex-shrink-0" />
<span>{section.title}</span>
</button>
{/each}
</div>
</div>
{:else}
<div class="space-y-6">
<ChatSettingsFields
fields={currentSection.fields}
{localConfig}
onConfigChange={handleConfigChange}
onThemeChange={handleThemeChange}
/>
</div>
{/if}
</div>
<div class="mt-8 border-t pt-6">
<p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
<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 {canScrollRight
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
onclick={scrollRight}
aria-label="Scroll right"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
</div>
</ScrollArea>
</div>
<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
<div class="mx-auto max-w-3xl flex-1">
<div class="space-y-6 p-4 md:p-6 md:pt-28">
<div class="grid">
<div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
<currentSection.icon class="h-5 w-5" />
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
</div>
{#if currentSection.fields}
<div class="space-y-6">
<ChatSettingsFields
fields={currentSection.fields}
{localConfig}
onConfigChange={handleConfigChange}
onThemeChange={handleThemeChange}
/>
</div>
{/if}
</div>
<div class="mt-8 border-t border-border/30 pt-6">
<p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
</div>
</div>
<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
</div>
</div>
</div>
@@ -70,7 +70,7 @@
{/if}
</div>
<div class="relative w-full md:max-w-md">
<div class="relative w-full">
<Input
id={field.key}
value={currentValue}
@@ -117,7 +117,7 @@
value={String(localConfig[field.key] ?? '')}
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
placeholder=""
class="min-h-[10rem] w-full md:max-w-2xl"
class="min-h-[10rem] w-full md:max-w-3xl"
/>
{#if field.help || SETTING_CONFIG_INFO[field.key]}
@@ -176,7 +176,7 @@
}
}}
>
<div class="relative w-full md:w-auto md:max-w-md">
<div class="relative w-full md:w-auto">
<Select.Trigger class="w-full">
<div class="flex items-center gap-2">
{#if selectedOption?.icon}
@@ -29,7 +29,7 @@
}
</script>
<div class="flex justify-between border-t border-border/30 p-6">
<div class="sticky bottom-0 mx-auto mt-4 flex w-full justify-between p-6">
<div class="flex gap-2">
<Button variant="outline" onclick={handleResetClick}>
<RotateCcw class="h-3 w-3" />
@@ -0,0 +1,122 @@
<script lang="ts">
import { ChevronDown, ChevronRight } from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import { TruncatedText } from '$lib/components/app';
import { toolsStore } from '$lib/stores/tools.svelte';
import { permissionsStore } from '$lib/stores/permissions.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { ToolSource } from '$lib/enums';
import { SvelteSet } from 'svelte/reactivity';
let expandedGroups = new SvelteSet<string>();
let groups = $derived(toolsStore.toolGroups);
function getFavicon(group: { source: ToolSource; label: string }): string | null {
if (group.source !== ToolSource.MCP) return null;
for (const server of mcpStore.getServersSorted()) {
if (mcpStore.getServerLabel(server) === group.label) {
return mcpStore.getServerFavicon(server.id);
}
}
return null;
}
function toggleExpanded(label: string) {
if (expandedGroups.has(label)) {
expandedGroups.delete(label);
} else {
expandedGroups.add(label);
}
}
</script>
{#if groups.length === 0}
<div class="py-8 text-center text-sm text-muted-foreground">No tools available</div>
{:else}
<div class="space-y-2">
{#each groups as group (group.label)}
{@const isExpanded = expandedGroups.has(group.label)}
{@const favicon = getFavicon(group)}
<Collapsible.Root open={isExpanded} onOpenChange={() => toggleExpanded(group.label)}>
<Collapsible.Trigger
class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
{:else}
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="ml-4 border-l border-border/50 pl-2">
<!-- Header row -->
<div class="flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground">
<span class="min-w-0 flex-1">Tool</span>
<span class="w-16 shrink-0 text-center">Enabled</span>
<span class="w-20 shrink-0 text-center">Always allow</span>
</div>
{#each group.tools as tool (tool.function.name)}
{@const toolName = tool.function.name}
{@const isEnabled = toolsStore.isToolEnabled(toolName)}
{@const permissionKey = toolsStore.getPermissionKey(toolName)}
{@const isAlwaysAllowed = permissionKey
? permissionsStore.hasTool(permissionKey)
: false}
<div class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50">
<TruncatedText text={toolName} class="min-w-0 flex-1 truncate" showTooltip={true} />
<div class="flex w-16 shrink-0 justify-center">
<Checkbox
checked={isEnabled}
onCheckedChange={() => toolsStore.toggleTool(toolName)}
class="h-4 w-4"
/>
</div>
<div class="flex w-20 shrink-0 justify-center">
<Checkbox
checked={isAlwaysAllowed}
onCheckedChange={() => {
if (isAlwaysAllowed) {
permissionsStore.revokeTool(permissionKey!);
} else {
permissionsStore.allowTool(permissionKey!);
}
}}
class="h-4 w-4"
/>
</div>
</div>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/each}
</div>
{/if}
@@ -1,7 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { Trash2, Pencil } from '@lucide/svelte';
import { Trash2, Pencil, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app';
import { Checkbox } from '$lib/components/ui/checkbox';
import Label from '$lib/components/ui/label/label.svelte';
@@ -16,6 +17,7 @@
import { chatStore } from '$lib/stores/chat.svelte';
import { getPreviewText } from '$lib/utils';
import ChatSidebarActions from './ChatSidebarActions.svelte';
import { APP_NAME } from '$lib/constants';
const sidebar = Sidebar.useSidebar();
@@ -32,10 +34,14 @@
);
let filteredConversations = $derived.by(() => {
if (searchQuery.trim().length > 0) {
return conversations().filter((conversation: { name: string }) =>
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
);
if (isSearchModeActive) {
if (searchQuery.trim().length > 0) {
return conversations().filter((conversation: { name: string }) =>
conversation.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
return [];
}
return conversations();
@@ -107,10 +113,31 @@
}
}
let chatSidebarActions: { activateSearch?: () => void } | undefined = $state();
let openedForSearch = $state(false);
export function activateSearchMode() {
isSearchModeActive = true;
if (!sidebar.open) {
openedForSearch = true;
}
chatSidebarActions?.activateSearch?.();
}
function handleSearchDeactivated() {
if (openedForSearch) {
openedForSearch = false;
sidebar.toggle();
}
}
$effect(() => {
if (!sidebar.open) {
isSearchModeActive = false;
searchQuery = '';
openedForSearch = false;
}
});
export function editActiveConversation() {
if (currentChatId) {
const activeConversation = filteredConversations.find((conv) => conv.id === currentChatId);
@@ -130,6 +157,7 @@
searchQuery = '';
}
handleMobileSidebarItemClick();
await goto(`#/chat/${id}`);
}
@@ -138,60 +166,79 @@
}
</script>
<ScrollArea class="h-[100vh]">
<Sidebar.Header class=" top-0 z-10 gap-4 bg-sidebar/50 p-4 pb-2 backdrop-blur-lg md:sticky">
<a href="#/" onclick={handleMobileSidebarItemClick}>
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
</a>
<div class="flex h-full flex-col">
<ScrollArea class="h-full flex-1">
<Sidebar.Header class="gap-4 bg-sidebar/50 p-3 backdrop-blur-lg md:pt-4 md:pb-2">
<div class="flex items-center justify-between">
<a href="#/" onclick={handleMobileSidebarItemClick}>
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">{APP_NAME}</h1>
</a>
<ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
</Sidebar.Header>
<Button
class="rounded-full md:hidden"
variant="ghost"
size="icon"
onclick={() => sidebar.toggle()}
>
<X class="h-4 w-4" />
<span class="sr-only">Close sidebar</span>
</Button>
</div>
<Sidebar.Group class="mt-2 space-y-2 p-0 px-4">
{#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
<Sidebar.GroupLabel>
{isSearchModeActive ? 'Search results' : 'Conversations'}
</Sidebar.GroupLabel>
{/if}
<ChatSidebarActions
bind:this={chatSidebarActions}
{handleMobileSidebarItemClick}
bind:isSearchModeActive
bind:searchQuery
onSearchDeactivated={handleSearchDeactivated}
/>
</Sidebar.Header>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each conversationTree as { conversation, depth } (conversation.id)}
<Sidebar.MenuItem class="mb-1 p-0">
<ChatSidebarConversationItem
conversation={{
id: conversation.id,
name: conversation.name,
lastModified: conversation.lastModified,
currNode: conversation.currNode,
forkedFromConversationId: conversation.forkedFromConversationId
}}
{depth}
{handleMobileSidebarItemClick}
isActive={currentChatId === conversation.id}
onSelect={selectConversation}
onEdit={handleEditConversation}
onDelete={handleDeleteConversation}
onStop={handleStopGeneration}
/>
</Sidebar.MenuItem>
{/each}
<Sidebar.Group class="mt-2 h-[calc(100vh-21rem)] space-y-2 p-0 px-3">
{#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
<Sidebar.GroupLabel>
{isSearchModeActive ? 'Search results' : 'Recent conversations'}
</Sidebar.GroupLabel>
{/if}
{#if conversationTree.length === 0}
<div class="px-2 py-4 text-center">
<p class="mb-4 p-4 text-sm text-muted-foreground">
{searchQuery.length > 0
? 'No results found'
: isSearchModeActive
? 'Start typing to see results'
: 'No conversations yet'}
</p>
</div>
{/if}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</ScrollArea>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each conversationTree as { conversation, depth } (conversation.id)}
<Sidebar.MenuItem class="mb-1 p-0">
<ChatSidebarConversationItem
conversation={{
id: conversation.id,
name: conversation.name,
lastModified: conversation.lastModified,
currNode: conversation.currNode,
forkedFromConversationId: conversation.forkedFromConversationId
}}
{depth}
isActive={currentChatId === conversation.id}
onSelect={selectConversation}
onEdit={handleEditConversation}
onDelete={handleDeleteConversation}
onStop={handleStopGeneration}
/>
</Sidebar.MenuItem>
{/each}
{#if conversationTree.length === 0}
<div class="px-2 py-4 text-center">
<p class="mb-4 p-4 text-sm text-muted-foreground">
{searchQuery.length > 0
? 'No results found'
: isSearchModeActive
? 'Start typing to see results'
: 'No conversations yet'}
</p>
</div>
{/if}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</ScrollArea>
</div>
<DialogConfirmation
bind:open={showDeleteDialog}
@@ -1,102 +1,96 @@
<script lang="ts">
import { Search, SquarePen, X } from '@lucide/svelte';
import { KeyboardShortcutInfo } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { McpLogo } from '$lib/components/app';
import { SETTINGS_SECTION_TITLES } from '$lib/constants';
import { getChatSettingsDialogContext } from '$lib/contexts';
import type { Component } from 'svelte';
import { SearchInput } from '$lib/components/app';
import { page } from '$app/state';
import { SIDEBAR_ACTIONS_ITEMS } from '$lib/constants/ui';
interface Props {
handleMobileSidebarItemClick: () => void;
isSearchModeActive: boolean;
searchQuery: string;
isCancelAlwaysVisible?: boolean;
onSearchDeactivated?: () => void;
}
let {
handleMobileSidebarItemClick,
isSearchModeActive = $bindable(),
searchQuery = $bindable()
searchQuery = $bindable(),
isCancelAlwaysVisible = false,
onSearchDeactivated
}: Props = $props();
let searchInput: HTMLInputElement | null = $state(null);
const chatSettingsDialog = getChatSettingsDialogContext();
let searchInputRef = $state<HTMLInputElement | null>(null);
function handleSearchModeDeactivate() {
isSearchModeActive = false;
searchQuery = '';
onSearchDeactivated?.();
}
$effect(() => {
if (isSearchModeActive) {
searchInput?.focus();
}
});
export function activateSearch() {
isSearchModeActive = true;
// Focus after Svelte renders the input
queueMicrotask(() => searchInputRef?.focus());
}
</script>
{#snippet itemIcon(Icon: Component)}
<Icon class="h-4 w-4" />
{/snippet}
<div class="my-1 space-y-1">
{#if isSearchModeActive}
<div class="relative">
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
<Input
bind:ref={searchInput}
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()}
placeholder="Search conversations..."
class="pl-8"
/>
<X
class="cursor-pointertext-muted-foreground absolute top-2.5 right-2 h-4 w-4"
onclick={handleSearchModeDeactivate}
/>
</div>
<SearchInput
bind:value={searchQuery}
bind:ref={searchInputRef}
onClose={handleSearchModeDeactivate}
onKeyDown={(e) => e.key === 'Escape' && handleSearchModeDeactivate()}
placeholder="Search conversations..."
{isCancelAlwaysVisible}
/>
{:else}
<Button
class="w-full justify-between backdrop-blur-none! hover:[&>kbd]:opacity-100"
href="?new_chat=true#/"
onclick={handleMobileSidebarItemClick}
variant="ghost"
>
<div class="flex items-center gap-2">
<SquarePen class="h-4 w-4" />
{#each SIDEBAR_ACTIONS_ITEMS as item (item.route)}
{#if !item.route}
<Button
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100"
onclick={activateSearch}
variant="ghost"
>
<div class="flex items-center gap-2">
{@render itemIcon(item.icon)}
New chat
</div>
{item.tooltip}
</div>
<KeyboardShortcutInfo keys={['shift', 'cmd', 'o']} />
</Button>
{#if item.keys}
<KeyboardShortcutInfo keys={item.keys} />
{/if}
</Button>
{:else}
<Button
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {(item.activeRouteId &&
page.route.id === item.activeRouteId) ||
(item.activeRoutePrefix && page.route.id?.startsWith(item.activeRoutePrefix))
? 'bg-accent text-accent-foreground'
: ''}"
href={item.route}
onclick={handleMobileSidebarItemClick}
variant="ghost"
>
<div class="flex items-center gap-2">
{@render itemIcon(item.icon)}
<Button
class="w-full justify-between backdrop-blur-none! hover:[&>kbd]:opacity-100"
onclick={() => {
isSearchModeActive = true;
}}
variant="ghost"
>
<div class="flex items-center gap-2">
<Search class="h-4 w-4" />
{item.tooltip}
</div>
Search
</div>
<KeyboardShortcutInfo keys={['cmd', 'k']} />
</Button>
<Button
class="w-full justify-between backdrop-blur-none! hover:[&>kbd]:opacity-100"
onclick={() => {
chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP);
}}
variant="ghost"
>
<div class="flex items-center gap-2">
<McpLogo class="h-4 w-4" />
MCP Servers
</div>
</Button>
{#if item.keys}
<KeyboardShortcutInfo keys={item.keys} />
{/if}
</Button>
{/if}
{/each}
{/if}
</div>
@@ -19,7 +19,6 @@
isActive?: boolean;
depth?: number;
conversation: DatabaseConversation;
handleMobileSidebarItemClick?: () => void;
onDelete?: (id: string) => void;
onEdit?: (id: string) => void;
onSelect?: (id: string) => void;
@@ -28,7 +27,6 @@
let {
conversation,
handleMobileSidebarItemClick,
onDelete,
onEdit,
onSelect,
@@ -150,9 +148,7 @@
</Tooltip.Root>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span class="truncate text-sm font-medium" onclick={handleMobileSidebarItemClick}>
<span class="truncate text-sm font-medium">
{conversation.name}
</span>
</div>
@@ -124,7 +124,7 @@ export { default as ChatAttachmentsViewAll } from './ChatAttachments/ChatAttachm
* **Architecture:**
* - Composes ChatFormTextarea, ChatFormActions, and ChatFormPromptPicker
* - Manages file upload state via `uploadedFiles` bindable prop
* - Integrates with ModelsSelector for model selection in router mode
* - Integrates with ModelsSelectorDropdown for model selection in router mode
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
*
* **Input Handling:**
@@ -168,14 +168,14 @@ export { default as ChatForm } from './ChatForm/ChatForm.svelte';
* Images, Text Files, and PDF Files. Each option filters the file picker to
* appropriate types. Images option is disabled when model lacks vision modality.
*/
export { default as ChatFormActionAttachmentsDropdown } from './ChatForm/ChatFormActions/ChatFormActionAttachmentsDropdown.svelte';
export { default as ChatFormActionAttachmentsDropdown } from './ChatForm/ChatFormActions/ChatFormActionsAttachments/ChatFormActionAttachmentsDropdown.svelte';
/**
* Mobile sheet variant of the file attachment selector. Renders a bottom sheet
* with the same options as ChatFormActionAttachmentsDropdown, optimized for
* touch interaction on mobile devices.
*/
export { default as ChatFormActionAttachmentsSheet } from './ChatForm/ChatFormActions/ChatFormActionAttachmentsSheet.svelte';
export { default as ChatFormActionAttachmentsSheet } from './ChatForm/ChatFormActions/ChatFormActionsAttachments/ChatFormActionAttachmentsSheet.svelte';
/**
* Audio recording button with real-time recording indicator. Records audio
@@ -198,6 +198,49 @@ export { default as ChatFormActions } from './ChatForm/ChatFormActions/ChatFormA
*/
export { default as ChatFormActionSubmit } from './ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
/**
* Dropdown submenu for managing tool permissions in the chat form.
*
* Displays a collapsible list of available tools organized by group (Built-in / JSON Schema).
* Each group can be expanded to show individual tools with checkboxes for enabling/disabling.
* Provides bulk enable/disable controls per group and shows enabled/total tool counts.
* Opens the tools panel on the server when the menu opens.
*
* Features:
* - Grouped tools with collapsible sections
* - Group favicon display (MCP server icons)
* - Per-group and per-tool toggle checkboxes
* - Loading/error states for tool discovery
* - Integration with toolsPanel for state management
*
* @example
* ```svelte
* <ChatFormActionToolsSubmenu />
* ```
*/
export { default as ChatFormActionToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionToolsSubmenu.svelte';
/**
* Dropdown submenu for managing MCP servers in the chat form.
*
* Displays a searchable list of enabled MCP servers with toggle switches
* to enable/disable each server for chat. Shows server favicon, health status,
* and a "Manage MCP Servers" settings link.
*
* Features:
* - Search/filter servers by name or URL
* - Per-server toggle to enable/disable for chat
* - Health check indicator (shows "Error" badge for failed servers)
* - Server favicon display
* - Settings link to manage MCP server configuration
*
* @example
* ```svelte
* <ChatFormActionMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
* ```
*/
export { default as ChatFormActionMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionMcpServersSubmenu.svelte';
/**
* Hidden file input element for programmatic file selection.
*/
@@ -456,6 +499,8 @@ export { default as ChatMessage } from './ChatMessages/ChatMessage.svelte';
* ```
*/
export { default as ChatMessageAgenticContent } from './ChatMessages/ChatMessageAgenticContent.svelte';
export { default as ChatMessagePermissionRequest } from './ChatMessages/ChatMessagePermissionRequest.svelte';
export { default as ChatMessageContinueRequest } from './ChatMessages/ChatMessageContinueRequest.svelte';
/**
* Action buttons toolbar for messages. Displays copy, edit, delete, and regenerate
@@ -547,7 +592,7 @@ export { default as ChatMessageEditForm } from './ChatMessages/ChatMessageEditFo
* and server state. Used as the main content area in chat routes.
*
* **Architecture:**
* - Composes ChatMessages, ChatScreenForm, ChatScreenHeader, and dialogs
* - Composes ChatMessages, ChatScreenForm, and dialogs
* - Manages auto-scroll via `createAutoScrollController()` hook
* - Handles file upload pipeline (validation processing state update)
* - Integrates with serverStore for loading/error/warning states
@@ -602,13 +647,6 @@ export { default as ChatScreenDragOverlay } from './ChatScreen/ChatScreenDragOve
*/
export { default as ChatScreenForm } from './ChatScreen/ChatScreenForm.svelte';
/**
* Header bar for chat screen. Displays conversation title (or "New Chat"),
* model selector (in router mode), and action buttons (delete conversation).
* Sticky positioned at the top of the chat area.
*/
export { default as ChatScreenHeader } from './ChatScreen/ChatScreenHeader.svelte';
/**
* Processing info display during generation. Shows real-time statistics:
* tokens per second, prompt/completion token counts, and elapsed time.
@@ -636,55 +674,6 @@ export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProc
*
*/
/**
* **ChatSettings** - Application settings panel
*
* Comprehensive settings interface with categorized sections. Manages all
* user preferences and sampling parameters. Integrates with config store
* for persistence and ParameterSyncService for server synchronization.
*
* **Architecture:**
* - Uses tabbed navigation with category sections
* - Maintains local form state, commits on save
* - Tracks user overrides vs server defaults for sampling params
* - Exposes reset() method for dialog close without save
*
* **Categories:**
* - **General**: API key, system message, show system messages toggle
* - **Display**: Theme selection, message actions visibility, model info badge
* - **Sampling**: Temperature, top_p, top_k, min_p, repeat_penalty, etc.
* - **Penalties**: Frequency penalty, presence penalty, repeat last N
* - **Import/Export**: Conversation backup and restore
* - **MCP**: MCP server management (opens DialogChatSettings with MCP tab)
* - **Developer**: Debug options, disable auto-scroll
*
* **Parameter Sync:**
* - Fetches defaults from server `/props` endpoint
* - Shows source indicator badge (Custom/Server Props/Default)
* - Real-time badge updates as user types
* - Tracks which parameters user has explicitly overridden
*
* **Features:**
* - Mobile-responsive layout with horizontal scrolling tabs
* - Form validation with error messages
* - Secure API key storage (masked input)
* - Import/export conversations as JSON
* - Reset to defaults option per parameter
*
* **Exported API:**
* - `reset()` - Reset form fields to currently saved values (for cancel action)
*
* @example
* ```svelte
* <ChatSettings
* bind:this={settingsRef}
* onSave={() => dialogOpen = false}
* onCancel={() => { settingsRef.reset(); dialogOpen = false; }}
* />
* ```
*/
export { default as ChatSettings } from './ChatSettings/ChatSettings.svelte';
/**
* Footer with save/cancel buttons for settings panel. Positioned at bottom
* of settings dialog. Save button commits form state to config store,
@@ -699,13 +688,6 @@ export { default as ChatSettingsFooter } from './ChatSettings/ChatSettingsFooter
*/
export { default as ChatSettingsFields } from './ChatSettings/ChatSettingsFields.svelte';
/**
* Import/export tab content for conversation data management. Provides buttons
* to export all conversations as JSON file and import from JSON file.
* Handles file download/upload and data validation.
*/
export { default as ChatSettingsImportExportTab } from './ChatSettings/ChatSettingsImportExportTab.svelte';
/**
* Badge indicating parameter source for sampling settings. Shows one of:
* - **Custom**: User has explicitly set this value (orange badge)
@@ -715,6 +697,15 @@ export { default as ChatSettingsImportExportTab } from './ChatSettings/ChatSetti
*/
export { default as ChatSettingsParameterSourceIndicator } from './ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
/**
* **ChatSettingsToolsTab** - Tools configuration tab for chat settings
*
* Displays available tools grouped by source (built-in, MCP, custom) with
* toggles to enable/disable individual tools and tool groups. Shows MCP
* server favicons and permission management controls.
*/
export { default as ChatSettingsToolsTab } from './ChatSettings/ChatSettingsToolsTab.svelte';
/**
*
* SIDEBAR
@@ -1,38 +0,0 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { ChatSettings } from '$lib/components/app';
import type { SettingsSectionTitle } from '$lib/constants';
interface Props {
onOpenChange?: (open: boolean) => void;
open?: boolean;
initialSection?: SettingsSectionTitle;
}
let { onOpenChange, open = false, initialSection }: Props = $props();
let chatSettingsRef: ChatSettings | undefined = $state();
function handleClose() {
onOpenChange?.(false);
}
function handleSave() {
onOpenChange?.(false);
}
$effect(() => {
if (open && chatSettingsRef) {
chatSettingsRef.reset();
}
});
</script>
<Dialog.Root {open} onOpenChange={handleClose}>
<Dialog.Content
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] max-w-4xl! flex-col gap-0 rounded-none
p-0 md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
>
<ChatSettings bind:this={chatSettingsRef} onSave={handleSave} {initialSection} />
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,88 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { McpServerForm } from '$lib/components/app/mcp';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { uuid } from '$lib/utils';
import { MCP_SERVER_ID_PREFIX } from '$lib/constants';
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
}
let { open = $bindable(), onOpenChange }: Props = $props();
let newServerUrl = $state('');
let newServerHeaders = $state('');
let newServerUrlError = $derived.by(() => {
if (!newServerUrl.trim()) return 'URL is required';
try {
new URL(newServerUrl);
return null;
} catch {
return 'Invalid URL format';
}
});
function handleOpenChange(value: boolean) {
if (!value) {
newServerUrl = '';
newServerHeaders = '';
}
open = value;
onOpenChange?.(value);
}
function saveNewServer() {
if (newServerUrlError) return;
const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
mcpStore.addServer({
id: newServerId,
enabled: true,
url: newServerUrl.trim(),
headers: newServerHeaders.trim() || undefined
});
conversationsStore.setMcpServerOverride(newServerId, true);
handleOpenChange(false);
}
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Add New Server</Dialog.Title>
</Dialog.Header>
<div class="space-y-4 py-4">
<McpServerForm
url={newServerUrl}
headers={newServerHeaders}
onUrlChange={(v) => (newServerUrl = v)}
onHeadersChange={(v) => (newServerHeaders = v)}
urlError={newServerUrl ? newServerUrlError : null}
id="new-server"
/>
</div>
<Dialog.Footer>
<Button variant="secondary" size="sm" onclick={() => handleOpenChange(false)}>Cancel</Button>
<Button
variant="default"
size="sm"
onclick={saveNewServer}
disabled={!!newServerUrlError}
aria-label="Save"
>
Add
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,39 +0,0 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { McpLogo, McpServersSettings } from '$lib/components/app';
interface Props {
onOpenChange?: (open: boolean) => void;
open?: boolean;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
function handleClose() {
onOpenChange?.(false);
}
</script>
<Dialog.Root {open} onOpenChange={handleClose}>
<Dialog.Content
class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
md:h-[80dvh] md:h-auto md:max-h-[80dvh] md:min-h-0 md:rounded-lg"
style="max-width: 56rem;"
>
<div class="grid gap-2 border-b border-border/30 p-4 md:p-6">
<Dialog.Title class="inline-flex items-center text-lg font-semibold">
<McpLogo class="mr-2 inline h-4 w-4" />
MCP Servers
</Dialog.Title>
<Dialog.Description class="text-sm text-muted-foreground">
Add and configure MCP servers to enable agentic tool execution capabilities.
</Dialog.Description>
</div>
<div class="flex-1 overflow-y-auto px-4 py-6">
<McpServersSettings />
</div>
</Dialog.Content>
</Dialog.Root>
@@ -11,30 +11,12 @@
*/
/**
* **DialogMcpServerAddNew** - Add new MCP server dialog
*
* SETTINGS DIALOGS
*
* Dialogs for application and server configuration.
*
* Modal dialog for adding a new MCP server with URL and optional headers.
* Validates URL format and integrates with mcpStore and conversationsStore.
*/
/**
* **DialogChatSettings** - Settings dialog wrapper
*
* Modal dialog containing ChatSettings component with proper
* open/close state management and automatic form reset on open.
*
* **Architecture:**
* - Wraps ChatSettings component in ShadCN Dialog
* - Manages open/close state via bindable `open` prop
* - Resets form state when dialog opens to discard unsaved changes
*
* @example
* ```svelte
* <DialogChatSettings bind:open={showSettings} />
* ```
*/
export { default as DialogChatSettings } from './DialogChatSettings.svelte';
export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte';
/**
*
@@ -11,6 +11,7 @@
class?: string;
id?: string;
ref?: HTMLInputElement | null;
isCancelAlwaysVisible?: boolean;
}
let {
@@ -21,10 +22,11 @@
onKeyDown,
class: className,
id,
ref = $bindable(null)
ref = $bindable(null),
isCancelAlwaysVisible = false
}: Props = $props();
let showClearButton = $derived(!!value || !!onClose);
let showClearButton = $derived(isCancelAlwaysVisible || !!value || !!onClose);
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
@@ -63,7 +65,7 @@
{#if showClearButton}
<button
type="button"
class="absolute top-1/2 right-3 -translate-y-1/2 transform text-muted-foreground transition-colors hover:text-foreground"
class="absolute top-1/2 right-3 -translate-y-1/2 transform cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
onclick={handleClear}
aria-label={value ? 'Clear search' : 'Close'}
>
@@ -6,6 +6,7 @@ export * from './dialogs';
export * from './forms';
export * from './mcp';
export * from './misc';
export * from './settings';
export * from './models';
export * from './navigation';
export * from './server';
@@ -1,15 +1,18 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import * as Tooltip from '$lib/components/ui/tooltip';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
import { MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
import McpLogo from './McpLogo.svelte';
interface Props {
class?: string;
onClick?: () => void;
}
let { class: className = '' }: Props = $props();
let { class: className = '', onClick }: Props = $props();
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let enabledMcpServersForChat = $derived(
@@ -28,30 +31,60 @@
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, MAX_DISPLAYED_MCP_AVATARS)
.map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
.map((s) => ({
id: s.id,
name: mcpStore.getServerDisplayName(s.id),
url: mcpStore.getServerFavicon(s.id)
}))
.filter((f) => f.url !== null)
);
</script>
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
<div class={cn('inline-flex items-center gap-1.5', className)}>
{#if !hasEnabledMcpServers}
<button
class={cn(
'inline-flex cursor-pointer items-center gap-0.75 opacity-70 transition-opacity hover:opacity-100',
className,
'opacity-50 hover:opacity-100'
)}
onclick={onClick}
>
<Tooltip.Root>
<Tooltip.Trigger>
<McpLogo class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>MCP Servers</p>
</Tooltip.Content>
</Tooltip.Root>
</button>
{:else if mcpFavicons.length > 0}
<button class={cn('inline-flex items-center gap-0.75', className)} onclick={onClick}>
<div class="flex -space-x-1">
{#each mcpFavicons as favicon (favicon.id)}
<div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
<img
src={favicon.url}
alt=""
class="h-4 w-4"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
<Tooltip.Root>
<Tooltip.Trigger>
<div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
<img
src={favicon.url}
alt=""
class="h-4 w-4"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{favicon.name}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
{#if extraServersCount > 0}
<span class="text-xs text-muted-foreground">+{extraServersCount}</span>
{/if}
</div>
</button>
{/if}
@@ -71,7 +71,7 @@
</div>
{#if capabilities || transportType}
<div class="flex flex-wrap items-center gap-1">
<div class="flex flex-wrap items-center gap-1.5">
{#if transportType}
{@const TransportIcon = MCP_TRANSPORT_ICONS[transportType]}
<Badge variant="outline" class="h-5 gap-1 px-1.5 text-[10px]">
@@ -1,150 +0,0 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { uuid } from '$lib/utils';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { McpServerCard, McpServerCardSkeleton, McpServerForm } from '$lib/components/app/mcp';
import { MCP_SERVER_ID_PREFIX } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
let servers = $derived(mcpStore.getServersSorted());
let initialLoadComplete = $state(false);
$effect(() => {
if (initialLoadComplete) return;
const allChecked =
servers.length > 0 &&
servers.every((server) => {
const state = mcpStore.getHealthCheckState(server.id);
return (
state.status === HealthCheckStatus.SUCCESS || state.status === HealthCheckStatus.ERROR
);
});
if (allChecked) {
initialLoadComplete = true;
}
});
let isAddingServer = $state(false);
let newServerUrl = $state('');
let newServerHeaders = $state('');
let newServerUrlError = $derived.by(() => {
if (!newServerUrl.trim()) return 'URL is required';
try {
new URL(newServerUrl);
return null;
} catch {
return 'Invalid URL format';
}
});
function showAddServerForm() {
isAddingServer = true;
newServerUrl = '';
newServerHeaders = '';
}
function cancelAddServer() {
isAddingServer = false;
newServerUrl = '';
newServerHeaders = '';
}
function saveNewServer() {
if (newServerUrlError) return;
const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
mcpStore.addServer({
id: newServerId,
enabled: true,
url: newServerUrl.trim(),
headers: newServerHeaders.trim() || undefined
});
conversationsStore.setMcpServerOverride(newServerId, true);
isAddingServer = false;
newServerUrl = '';
newServerHeaders = '';
}
</script>
<div class="space-y-5 md:space-y-4">
<div class="flex items-start justify-between gap-4">
<div>
<h4 class="text-base font-semibold">Manage Servers</h4>
</div>
{#if !isAddingServer}
<Button variant="outline" size="sm" class="shrink-0" onclick={showAddServerForm}>
<Plus class="h-4 w-4" />
Add New Server
</Button>
{/if}
</div>
{#if isAddingServer}
<Card.Root class="bg-muted/30 p-4">
<div class="space-y-4">
<p class="font-medium">Add New Server</p>
<McpServerForm
url={newServerUrl}
headers={newServerHeaders}
onUrlChange={(v) => (newServerUrl = v)}
onHeadersChange={(v) => (newServerHeaders = v)}
urlError={newServerUrl ? newServerUrlError : null}
id="new-server"
/>
<div class="flex items-center justify-end gap-2">
<Button variant="secondary" size="sm" onclick={cancelAddServer}>Cancel</Button>
<Button
variant="default"
size="sm"
onclick={saveNewServer}
disabled={!!newServerUrlError}
aria-label="Save"
>
Add
</Button>
</div>
</div>
</Card.Root>
{/if}
{#if servers.length === 0 && !isAddingServer}
<div class="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
No MCP Servers configured yet. Add one to enable agentic features.
</div>
{/if}
{#if servers.length > 0}
<div class="space-y-3">
{#each servers as server (server.id)}
{#if !initialLoadComplete}
<McpServerCardSkeleton />
{:else}
<McpServerCard
{server}
faviconUrl={mcpStore.getServerFavicon(server.id)}
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
onToggle={async () => await conversationsStore.toggleMcpServerForChat(server.id)}
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
onDelete={() => mcpStore.removeServer(server.id)}
/>
{/if}
{/each}
</div>
{/if}
</div>
@@ -39,7 +39,7 @@
* <McpServersSettings />
* ```
*/
export { default as McpServersSettings } from './McpServersSettings.svelte';
export { default as McpServersSettings } from '../settings/SettingsMcpServers.svelte';
/**
* **McpActiveServersAvatars** - Active MCP servers indicator
@@ -69,33 +69,6 @@ export { default as McpServersSettings } from './McpServersSettings.svelte';
*/
export { default as McpActiveServersAvatars } from './McpActiveServersAvatars.svelte';
/**
* **McpServersSelector** - Quick MCP server toggle dropdown
*
* Compact dropdown for quickly enabling/disabling MCP servers for the current chat.
* Uses McpActiveServersAvatars as trigger and shows searchable server list with switches.
*
* **Architecture:**
* - Uses DropdownMenuSearchable for searchable dropdown UI
* - McpActiveServersAvatars as the trigger element
* - Integrates with conversationsStore for per-chat toggle
* - Runs health checks on dropdown open
*
* **Features:**
* - Searchable server list by name/URL
* - Switch toggles matching McpServersSettings behavior
* - Error state display for unhealthy servers
* - Footer link to full MCP settings dialog
*
* @example
* ```svelte
* <McpServersSelector
* onSettingsClick={() => showMcpSettings = true}
* />
* ```
*/
export { default as McpServersSelector } from './McpServersSelector.svelte';
/**
* **McpCapabilitiesBadges** - Server capabilities display
*
@@ -1,8 +1,7 @@
<script lang="ts">
import { Search, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Checkbox } from '$lib/components/ui/checkbox';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import { SvelteSet } from 'svelte/reactivity';
@@ -111,21 +110,7 @@
</script>
<div class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
{#if searchQuery}
<button
class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onclick={() => (searchQuery = '')}
type="button"
>
<X class="h-4 w-4" />
</button>
{/if}
</div>
<SearchInput bind:value={searchQuery} placeholder="Search conversations..." />
<div class="flex items-center justify-between text-sm text-muted-foreground">
<span>
@@ -5,8 +5,9 @@
interface Props {
modelId: string;
showOrgName?: boolean;
hideOrgName?: boolean;
showRaw?: boolean;
hideQuantization?: boolean;
aliases?: string[];
tags?: string[];
class?: string;
@@ -14,8 +15,9 @@
let {
modelId,
showOrgName = false,
hideOrgName = false,
showRaw = undefined,
hideQuantization = false,
aliases,
tags,
class: className = '',
@@ -41,7 +43,7 @@
{:else}
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}" {...rest}>
<span class="min-w-0 truncate font-medium">
{#if showOrgName && parsed.orgName && !(aliases && aliases.length > 0)}{parsed.orgName}/{/if}{displayName}
{#if !hideOrgName && parsed.orgName && !(aliases && aliases.length > 0)}{parsed.orgName}/{/if}{displayName}
</span>
{#if parsed.params}
@@ -50,7 +52,7 @@
</span>
{/if}
{#if parsed.quantization}
{#if parsed.quantization && !hideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
@@ -1,19 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { cn } from '$lib/components/ui/utils';
import {
modelsStore,
modelOptions,
modelsLoading,
modelsUpdating,
selectedModelId,
singleModelName
} from '$lib/stores/models.svelte';
import { KeyboardKey } from '$lib/enums';
import { isRouterMode } from '$lib/stores/server.svelte';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import {
DialogModelInformation,
DropdownMenuSearchable,
@@ -21,8 +12,7 @@
ModelsSelectorList,
ModelsSelectorOption
} from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
import { filterModelOptions, groupModelOptions, type ModelItem } from './utils';
import type { ModelItem } from './utils';
interface Props {
class?: string;
@@ -42,90 +32,26 @@
useGlobalSelection = false
}: Props = $props();
let options = $derived(
modelOptions().filter((option) => {
const modelProps = modelsStore.getModelProps(option.model);
return modelProps?.webui !== false;
})
);
let loading = $derived(modelsLoading());
let updating = $derived(modelsUpdating());
let activeId = $derived(selectedModelId());
let isRouter = $derived(isRouterMode());
let serverModel = $derived(singleModelName());
let isHighlightedCurrentModelActive = $derived.by(() => {
if (!isRouter || !currentModel) return false;
const currentOption = options.find((option) => option.model === currentModel);
return currentOption ? currentOption.id === activeId : false;
});
let isCurrentModelInCache = $derived.by(() => {
if (!isRouter || !currentModel) return true;
return options.some((option) => option.model === currentModel);
});
let isLoadingModel = $state(false);
let searchTerm = $state('');
let isOpen = $state(false);
let highlightedIndex = $state<number>(-1);
let filteredOptions = $derived(filterModelOptions(options, searchTerm));
let groupedFilteredOptions = $derived(
groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
modelsStore.isModelLoaded(m)
)
);
const ms = useModelsSelector({
currentModel: () => currentModel,
useGlobalSelection: () => useGlobalSelection,
onModelChange: () => onModelChange,
onOpenChange: (open) => {
isOpen = open;
highlightedIndex = -1;
}
});
$effect(() => {
void searchTerm;
void ms.searchTerm;
highlightedIndex = -1;
});
let isOpen = $state(false);
let showModelDialog = $state(false);
let infoModelId = $state<string | null>(null);
function handleInfoClick(modelName: string) {
infoModelId = modelName;
showModelDialog = true;
}
onMount(() => {
modelsStore.fetch().catch((error) => {
console.error('Unable to load models:', error);
});
});
function handleOpenChange(open: boolean) {
if (loading || updating) return;
if (isRouter) {
if (open) {
isOpen = true;
searchTerm = '';
highlightedIndex = -1;
modelsStore.fetchRouterModels().then(() => {
modelsStore.fetchModalitiesForLoadedModels();
});
} else {
isOpen = false;
searchTerm = '';
highlightedIndex = -1;
}
} else {
showModelDialog = open;
}
}
export function open() {
handleOpenChange(true);
ms.handleOpenChange(true);
}
function handleSearchKeyDown(event: KeyboardEvent) {
@@ -134,9 +60,9 @@
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (filteredOptions.length === 0) return;
if (ms.filteredOptions.length === 0) return;
if (highlightedIndex === -1 || highlightedIndex === filteredOptions.length - 1) {
if (highlightedIndex === -1 || highlightedIndex === ms.filteredOptions.length - 1) {
highlightedIndex = 0;
} else {
highlightedIndex += 1;
@@ -144,146 +70,69 @@
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (filteredOptions.length === 0) return;
if (ms.filteredOptions.length === 0) return;
if (highlightedIndex === -1 || highlightedIndex === 0) {
highlightedIndex = filteredOptions.length - 1;
highlightedIndex = ms.filteredOptions.length - 1;
} else {
highlightedIndex -= 1;
}
} else if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
const option = filteredOptions[highlightedIndex];
if (highlightedIndex >= 0 && highlightedIndex < ms.filteredOptions.length) {
const option = ms.filteredOptions[highlightedIndex];
handleSelect(option.id);
} else if (filteredOptions.length > 0) {
ms.handleSelect(option.id);
} else if (ms.filteredOptions.length > 0) {
highlightedIndex = 0;
}
}
}
async function handleSelect(modelId: string) {
const option = options.find((opt) => opt.id === modelId);
if (!option) return;
let shouldCloseMenu = true;
if (onModelChange) {
const result = await onModelChange(option.id, option.model);
if (result === false) {
shouldCloseMenu = false;
}
} else {
await modelsStore.selectModelById(option.id);
}
if (shouldCloseMenu) {
handleOpenChange(false);
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'[data-slot="chat-form"] textarea'
);
textarea?.focus();
});
}
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore
.loadModel(option.model)
.catch((error) => console.error('Failed to load model:', error))
.finally(() => (isLoadingModel = false));
}
}
function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
const displayModel = serverModel || currentModel;
if (displayModel) {
return {
id: serverModel ? 'current' : 'offline-current',
model: displayModel,
name: displayModel.split('/').pop() || displayModel,
capabilities: []
};
}
return undefined;
}
if (useGlobalSelection && activeId) {
const selected = options.find((option) => option.id === activeId);
if (selected) return selected;
}
if (currentModel) {
if (!isCurrentModelInCache) {
return {
id: 'not-in-cache',
model: currentModel,
name: currentModel.split('/').pop() || currentModel,
capabilities: []
};
}
return options.find((option) => option.model === currentModel);
}
if (activeId) {
return options.find((option) => option.id === activeId);
}
return undefined;
}
</script>
<div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
{#if loading && options.length === 0 && isRouter}
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading models…
</div>
{:else if options.length === 0 && isRouter}
{:else if ms.options.length === 0 && ms.isRouter}
{#if currentModel}
<span
class={cn(
'inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground',
className
)}
style="max-width: min(calc(100cqw - 9rem), 20rem)"
style="max-width: min(calc(100cqw - 10rem), 20rem)"
>
<Package class="h-3.5 w-3.5" />
<ModelId modelId={currentModel} class="min-w-0" showOrgName />
<ModelId modelId={currentModel} class="min-w-0" hideQuantization />
</span>
{:else}
<p class="text-xs text-muted-foreground">No models available.</p>
{/if}
{:else}
{@const selectedOption = getDisplayOption()}
{@const selectedOption = ms.getDisplayOption()}
{#if isRouter}
<DropdownMenu.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
{#if ms.isRouter}
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
<DropdownMenu.Trigger
class={cn(
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: isHighlightedCurrentModelActive
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-muted-foreground',
isOpen ? 'text-foreground' : ''
isOpen ? 'text-foreground' : '',
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
)}
style="max-width: min(calc(100cqw - 9rem), 20rem)"
disabled={disabled || updating}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5" />
@@ -295,7 +144,7 @@
<ModelId
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
showOrgName
hideOrgName={false}
{...props}
/>
{/snippet}
@@ -309,7 +158,7 @@
<span class="min-w-0 font-medium">Select model</span>
{/if}
{#if updating || isLoadingModel}
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5" />
@@ -321,14 +170,15 @@
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
>
<DropdownMenuSearchable
bind:searchValue={searchTerm}
searchValue={ms.searchTerm}
onSearchChange={(v) => ms.setSearchTerm(v)}
placeholder="Search models..."
onSearchKeyDown={handleSearchKeyDown}
emptyMessage="No models found."
isEmpty={filteredOptions.length === 0 && isCurrentModelInCache}
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
>
<div class="models-list">
{#if !isCurrentModelInCache && currentModel}
{#if !ms.isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
type="button"
@@ -338,47 +188,47 @@
aria-disabled="true"
disabled
>
<ModelId modelId={currentModel} class="flex-1" showOrgName />
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
{/if}
{#if filteredOptions.length === 0}
{#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, showOrgName: boolean)}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{@const { option, flatIndex } = item}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = flatIndex === highlightedIndex}
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{option}
{isSelected}
{isHighlighted}
{isFav}
{showOrgName}
onSelect={handleSelect}
onInfoClick={handleInfoClick}
{hideOrgName}
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
onMouseEnter={() => (highlightedIndex = flatIndex)}
onKeyDown={(e) => {
if (e.key === KeyboardKey.ENTER || e.key === KeyboardKey.SPACE) {
e.preventDefault();
handleSelect(option.id);
ms.handleSelect(option.id);
}
}}
/>
{/snippet}
<ModelsSelectorList
groups={groupedFilteredOptions}
groups={ms.groupedFilteredOptions}
{currentModel}
{activeId}
activeId={ms.activeId}
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
onSelect={handleSelect}
onInfoClick={handleInfoClick}
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
renderOption={modelOption}
/>
</div>
@@ -389,18 +239,18 @@
<button
class={cn(
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: isHighlightedCurrentModelActive
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-muted-foreground',
isOpen ? 'text-foreground' : ''
)}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
onclick={() => handleOpenChange(true)}
disabled={disabled || updating}
onclick={() => ms.handleOpenChange(true)}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5" />
@@ -412,7 +262,7 @@
<ModelId
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
showOrgName
hideOrgName={false}
{...props}
/>
{/snippet}
@@ -424,7 +274,7 @@
</Tooltip.Root>
{/if}
{#if updating}
{#if ms.updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
{/if}
</button>
@@ -432,6 +282,10 @@
{/if}
</div>
{#if showModelDialog}
<DialogModelInformation bind:open={showModelDialog} modelId={infoModelId} />
{#if ms.showModelDialog}
<DialogModelInformation
open={ms.showModelDialog}
onOpenChange={(v) => ms.setShowModelDialog(v)}
modelId={ms.infoModelId}
/>
{/if}
@@ -27,7 +27,7 @@
let render = $derived(renderOption ?? defaultOption);
</script>
{#snippet defaultOption(item: ModelItem, showOrgName: boolean)}
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
@@ -37,7 +37,7 @@
{isSelected}
isHighlighted={false}
{isFav}
{showOrgName}
{hideOrgName}
{onSelect}
{onInfoClick}
onMouseEnter={() => {}}
@@ -48,7 +48,7 @@
{#if groups.loaded.length > 0}
<p class={sectionHeaderClass}>Loaded models</p>
{#each groups.loaded as item (`loaded-${item.option.id}`)}
{@render render(item, true)}
{@render render(item, false)}
{/each}
{/if}
@@ -66,7 +66,7 @@
<p class={orgHeaderClass}>{group.orgName}</p>
{/if}
{#each group.items as item (item.option.id)}
{@render render(item, false)}
{@render render(item, true)}
{/each}
{/each}
{/if}
@@ -20,7 +20,7 @@
isSelected: boolean;
isHighlighted: boolean;
isFav: boolean;
showOrgName?: boolean;
hideOrgName?: boolean;
onSelect: (modelId: string) => void;
onMouseEnter: () => void;
onKeyDown: (e: KeyboardEvent) => void;
@@ -32,7 +32,7 @@
isSelected,
isHighlighted,
isFav,
showOrgName = false,
hideOrgName = false,
onSelect,
onMouseEnter,
onKeyDown,
@@ -71,7 +71,7 @@
>
<ModelId
modelId={option.model}
{showOrgName}
{hideOrgName}
aliases={option.aliases}
tags={option.tags}
class="flex-1"
@@ -1,25 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import * as Sheet from '$lib/components/ui/sheet';
import { cn } from '$lib/components/ui/utils';
import {
modelsStore,
modelOptions,
modelsLoading,
modelsUpdating,
selectedModelId,
singleModelName
} from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import {
DialogModelInformation,
ModelId,
ModelsSelectorList,
SearchInput,
TruncatedText
} from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
import { filterModelOptions, groupModelOptions } from './utils';
interface Props {
class?: string;
@@ -41,201 +31,71 @@
useGlobalSelection = false
}: Props = $props();
let options = $derived(
modelOptions().filter((option) => {
const modelProps = modelsStore.getModelProps(option.model);
return modelProps?.webui !== false;
})
);
let loading = $derived(modelsLoading());
let updating = $derived(modelsUpdating());
let activeId = $derived(selectedModelId());
let isRouter = $derived(isRouterMode());
let serverModel = $derived(singleModelName());
let isLoadingModel = $state(false);
let isHighlightedCurrentModelActive = $derived(
!isRouter || !currentModel
? false
: (() => {
const currentOption = options.find((option) => option.model === currentModel);
return currentOption ? currentOption.id === activeId : false;
})()
);
let isCurrentModelInCache = $derived.by(() => {
if (!isRouter || !currentModel) return true;
return options.some((option) => option.model === currentModel);
});
let searchTerm = $state('');
let filteredOptions = $derived(filterModelOptions(options, searchTerm));
let groupedFilteredOptions = $derived(
groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
modelsStore.isModelLoaded(m)
)
);
let sheetOpen = $state(false);
let showModelDialog = $state(false);
let infoModelId = $state<string | null>(null);
function handleInfoClick(modelName: string) {
infoModelId = modelName;
showModelDialog = true;
}
onMount(() => {
modelsStore.fetch().catch((error) => {
console.error('Unable to load models:', error);
});
});
function handleOpenChange(open: boolean) {
if (loading || updating) return;
if (isRouter) {
if (open) {
sheetOpen = true;
searchTerm = '';
modelsStore.fetchRouterModels().then(() => {
modelsStore.fetchModalitiesForLoadedModels();
});
} else {
sheetOpen = false;
searchTerm = '';
}
} else {
showModelDialog = open;
const ms = useModelsSelector({
currentModel: () => currentModel,
useGlobalSelection: () => useGlobalSelection,
onModelChange: () => onModelChange,
onOpenChange: (open) => {
sheetOpen = open;
}
}
});
export function open() {
handleOpenChange(true);
ms.handleOpenChange(true);
}
function handleSheetOpenChange(open: boolean) {
if (!open) {
handleOpenChange(false);
ms.handleOpenChange(false);
}
}
async function handleSelect(modelId: string) {
const option = options.find((opt) => opt.id === modelId);
if (!option) return;
let shouldCloseMenu = true;
if (onModelChange) {
const result = await onModelChange(option.id, option.model);
if (result === false) {
shouldCloseMenu = false;
}
} else {
await modelsStore.selectModelById(option.id);
}
if (shouldCloseMenu) {
handleOpenChange(false);
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'[data-slot="chat-form"] textarea'
);
textarea?.focus();
});
}
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore
.loadModel(option.model)
.catch((error) => console.error('Failed to load model:', error))
.finally(() => (isLoadingModel = false));
}
}
function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
if (serverModel) {
return {
id: 'current',
model: serverModel,
name: serverModel.split('/').pop() || serverModel,
capabilities: []
};
}
return undefined;
}
if (useGlobalSelection && activeId) {
const selected = options.find((option) => option.id === activeId);
if (selected) return selected;
}
if (currentModel) {
if (!isCurrentModelInCache) {
return {
id: 'not-in-cache',
model: currentModel,
name: currentModel.split('/').pop() || currentModel,
capabilities: []
};
}
return options.find((option) => option.model === currentModel);
}
if (activeId) {
return options.find((option) => option.id === activeId);
}
return undefined;
}
</script>
<div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
{#if loading && options.length === 0 && isRouter}
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading models…
</div>
{:else if options.length === 0 && isRouter}
{:else if ms.options.length === 0 && ms.isRouter}
<p class="text-xs text-muted-foreground">No models available.</p>
{:else}
{@const selectedOption = getDisplayOption()}
{@const selectedOption = ms.getDisplayOption()}
{#if isRouter}
{#if ms.isRouter}
<button
type="button"
class={cn(
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: isHighlightedCurrentModelActive
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-muted-foreground',
sheetOpen ? 'text-foreground' : ''
)}
style="max-width: min(calc(100cqw - 9rem), 20rem)"
disabled={disabled || updating}
onclick={() => handleOpenChange(true)}
disabled={disabled || ms.updating}
onclick={() => ms.handleOpenChange(true)}
>
<Package class="h-3.5 w-3.5" />
<TruncatedText text={selectedOption?.model || 'Select model'} class="min-w-0 font-medium" />
{#if !selectedOption}
<span class="min-w-0 font-medium">Select model</span>
{:else}
<ModelId
class="text-xs"
modelId={selectedOption?.model || ''}
hideQuantization
hideOrgName
/>
{/if}
{#if updating || isLoadingModel}
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5" />
@@ -254,11 +114,15 @@
<div class="flex flex-col gap-1 pb-4">
<div class="mb-3 px-4">
<SearchInput placeholder="Search models..." bind:value={searchTerm} />
<SearchInput
placeholder="Search models..."
value={ms.searchTerm}
onInput={(v) => ms.setSearchTerm(v)}
/>
</div>
<div class="max-h-[60vh] overflow-y-auto px-2">
{#if !isCurrentModelInCache && currentModel}
{#if !ms.isCurrentModelInCache && currentModel}
<button
type="button"
class="flex w-full cursor-not-allowed items-center rounded-md bg-red-400/10 px-3 py-2.5 text-left text-sm text-red-400"
@@ -272,18 +136,18 @@
<div class="my-1 h-px bg-border"></div>
{/if}
{#if filteredOptions.length === 0}
{#if ms.filteredOptions.length === 0}
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
{/if}
<ModelsSelectorList
groups={groupedFilteredOptions}
groups={ms.groupedFilteredOptions}
{currentModel}
{activeId}
activeId={ms.activeId}
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
onSelect={handleSelect}
onInfoClick={handleInfoClick}
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
/>
</div>
</div>
@@ -293,23 +157,23 @@
<button
class={cn(
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: isHighlightedCurrentModelActive
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-muted-foreground'
)}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
onclick={() => handleOpenChange(true)}
disabled={disabled || updating}
onclick={() => ms.handleOpenChange(true)}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5" />
<TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" />
{#if updating}
{#if ms.updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
{/if}
</button>
@@ -317,6 +181,10 @@
{/if}
</div>
{#if showModelDialog}
<DialogModelInformation bind:open={showModelDialog} modelId={infoModelId} />
{#if ms.showModelDialog}
<DialogModelInformation
open={ms.showModelDialog}
onOpenChange={(v) => ms.setShowModelDialog(v)}
modelId={ms.infoModelId}
/>
{/if}
@@ -11,7 +11,7 @@
*/
/**
* **ModelsSelector** - Model selection dropdown
* **ModelsSelectorDropdown** - Model selection dropdown (desktop)
*
* Dropdown for selecting AI models with status indicators,
* search, and model information display. Adapts UI based on server mode.
@@ -35,20 +35,20 @@
*
* @example
* ```svelte
* <ModelsSelector
* <ModelsSelectorDropdown
* currentModel={conversation.modelId}
* onModelChange={(id, name) => updateModel(id)}
* useGlobalSelection
* />
* ```
*/
export { default as ModelsSelector } from './ModelsSelector.svelte';
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
/**
* **ModelsSelectorList** - Grouped model options list
*
* Renders grouped model options (loaded, favorites, available) with section
* headers and org subgroups. Shared between ModelsSelector and ModelsSelectorSheet
* headers and org subgroups. Shared between ModelsSelectorDropdown and ModelsSelectorSheet
* to avoid template duplication.
*
* Accepts an optional `renderOption` snippet to customize how each option is
@@ -68,8 +68,8 @@ export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
/**
* **ModelsSelectorSheet** - Mobile model selection sheet
*
* Bottom sheet variant of ModelsSelector optimized for touch interaction
* on mobile devices. Same functionality as ModelsSelector but uses Sheet UI
* Bottom sheet variant of ModelsSelectorDropdown optimized for touch interaction
* on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
* instead of DropdownMenu.
*/
export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
@@ -0,0 +1,84 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ActionIcon } from '$lib/components/app/actions';
import {
ICON_STRIP_TRANSITION_DURATION,
ICON_STRIP_TRANSITION_DELAY_MULTIPLIER,
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
import { TooltipSide } from '$lib/enums';
import { fade } from 'svelte/transition';
import { circIn } from 'svelte/easing';
import { onMount } from 'svelte';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
interface Props {
sidebarOpen: boolean;
onSearchClick: () => void;
}
let { sidebarOpen = false, onSearchClick }: Props = $props();
const { handleKeydown } = useKeyboardShortcuts({ activateSearchMode: () => onSearchClick() });
let initialized = $state(false);
let showIcons = $derived(!sidebarOpen);
showIcons = false;
onMount(() => {
showIcons = !sidebarOpen;
setTimeout(() => {
initialized = true;
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * SIDEBAR_ACTIONS_ITEMS.length);
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="hidden shrink-0 transition-[width] duration-200 ease-linear md:block {sidebarOpen
? 'w-0'
: 'w-[calc(var(--sidebar-width-icon)+1.5rem)]'}"
></div>
<aside
class="fixed top-0 bottom-0 left-0 z-10 hidden w-[calc(var(--sidebar-width-icon)+1.5rem)] flex-col items-center justify-between py-3 transition-opacity duration-200 ease-linear md:flex {sidebarOpen
? 'pointer-events-none opacity-0'
: 'opacity-100'}"
>
<div class="mt-12 flex flex-col items-center gap-1">
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{@const onclick = item.route ? () => goto(item.route!) : onSearchClick}
{@const isActive = item.activeRouteId
? page.route.id === item.activeRouteId
: item.activeRoutePrefix
? !!page.route.id?.startsWith(item.activeRoutePrefix)
: false}
{#if showIcons}
<div
in:fade={{
duration: ICON_STRIP_TRANSITION_DURATION,
delay: !initialized
? ICON_STRIP_TRANSITION_DELAY_MULTIPLIER + i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER
: 0,
easing: circIn
}}
>
<ActionIcon
icon={item.icon}
tooltip={item.tooltip}
tooltipSide={TooltipSide.RIGHT}
size="lg"
iconSize="h-4 w-4"
class="h-9 w-9 rounded-full hover:bg-accent! {isActive
? 'bg-accent text-accent-foreground'
: ''}"
{onclick}
/>
</div>
{/if}
{/each}
</div>
</aside>
@@ -63,3 +63,11 @@ export { default as DropdownMenuSearchable } from './DropdownMenuSearchable.svel
* ```
*/
export { default as DropdownMenuActions } from './DropdownMenuActions.svelte';
/**
* **DesktopIconStrip** - Fixed icon strip for desktop sidebar
*
* Vertical icon strip shown on desktop when the sidebar is collapsed.
* Contains navigation shortcuts for new chat, search, MCP, import/export, and settings.
*/
export { default as DesktopIconStrip } from './DesktopIconStrip.svelte';
@@ -0,0 +1,154 @@
<script lang="ts">
import {
ChatSettingsFooter,
ChatSettingsFields,
ChatSettingsToolsTab
} from '$lib/components/app';
import {
SettingsChatDesktopSidebar,
SettingsChatMobileHeader
} from '$lib/components/app/settings';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import {
NUMERIC_FIELDS,
POSITIVE_INTEGER_FIELDS,
SETTINGS_CHAT_SECTIONS,
SETTINGS_SECTION_TITLES,
type SettingsSection
} from '$lib/constants';
import { setMode } from 'mode-watcher';
import { ColorMode } from '$lib/enums/ui';
import { fade } from 'svelte/transition';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { setChatSettingsConfigContext } from '$lib/contexts';
import { settingsReferrer } from '$lib/stores/settings-referrer.svelte';
interface Props {
initialSection?: string;
getSectionHref?: (section: SettingsSection) => string;
}
let { initialSection, getSectionHref }: Props = $props();
let activeSlug = $derived(
initialSection ?? (page.params as Record<string, string | undefined>).section ?? 'general'
);
let currentSection = $derived(
SETTINGS_CHAT_SECTIONS.find((section) => section.slug === activeSlug) ||
SETTINGS_CHAT_SECTIONS[0]
);
let localConfig: SettingsConfigType = $state({ ...config() });
let mobileHeader: { updateCarousel: () => void } | undefined;
function handleThemeChange(newTheme: string) {
localConfig.theme = newTheme;
setMode(newTheme as ColorMode);
}
function handleConfigChange(key: string, value: string | boolean) {
localConfig[key] = value;
}
function handleReset() {
localConfig = { ...config() };
setMode(localConfig.theme as ColorMode);
mobileHeader?.updateCarousel();
}
function handleSave() {
if (localConfig.custom && typeof localConfig.custom === 'string' && localConfig.custom.trim()) {
try {
JSON.parse(localConfig.custom);
} catch (error) {
alert('Invalid JSON in custom parameters. Please check the format and try again.');
console.error(error);
return;
}
}
const processedConfig = { ...localConfig };
for (const field of NUMERIC_FIELDS) {
if (processedConfig[field] !== undefined && processedConfig[field] !== '') {
const numValue = Number(processedConfig[field]);
if (!isNaN(numValue)) {
if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
processedConfig[field] = Math.max(1, Math.round(numValue));
} else {
processedConfig[field] = numValue;
}
} else {
alert(`Invalid numeric value for ${field}. Please enter a valid number.`);
return;
}
}
}
settingsStore.updateMultipleConfig(processedConfig);
goto(settingsReferrer.url);
}
export function reset() {
localConfig = { ...config() };
}
setChatSettingsConfigContext({
get localConfig() {
return localConfig;
},
handleConfigChange,
handleThemeChange
});
</script>
<div
class="mx-auto flex h-full max-h-[100dvh] w-full flex-col overflow-y-auto md:pl-8"
in:fade={{ duration: 150 }}
>
<div class="flex flex-1 flex-col gap-4 md:flex-row">
<SettingsChatDesktopSidebar
sections={SETTINGS_CHAT_SECTIONS}
isActive={(section: SettingsSection) => section.slug === activeSlug}
getHref={getSectionHref ?? ((section: SettingsSection) => `#/settings/chat/${section.slug}`)}
/>
<SettingsChatMobileHeader
sections={SETTINGS_CHAT_SECTIONS}
isActive={(section: SettingsSection) => section.slug === activeSlug}
getHref={getSectionHref ?? ((section: SettingsSection) => `#/settings/chat/${section.slug}`)}
bind:this={mobileHeader}
/>
<div class="mx-auto max-w-3xl flex-1">
<div class="space-y-6 p-4 md:p-6 md:pt-28">
<div class="grid">
<div class="mb-6 flex items-center gap-2 border-b border-border/30 pb-6 md:flex">
<currentSection.icon class="h-5 w-5" />
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
</div>
{#if currentSection.title === SETTINGS_SECTION_TITLES.TOOLS}
<ChatSettingsToolsTab />
{:else if currentSection.fields}
<div class="space-y-6">
<ChatSettingsFields
fields={currentSection.fields}
{localConfig}
onConfigChange={handleConfigChange}
onThemeChange={handleThemeChange}
/>
</div>
{/if}
</div>
<div class="mt-8 border-t border-border/30 pt-6">
<p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
</div>
</div>
<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
</div>
</div>
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
import { Settings } from '@lucide/svelte';
import type { SettingsSection, SettingsSectionTitle } from '$lib/constants';
interface Props {
sections: SettingsSection[];
isActive: (section: SettingsSection) => boolean;
getHref?: (section: SettingsSection) => string;
onSectionChange?: (section: SettingsSectionTitle) => void;
}
let { sections, isActive, getHref, onSectionChange }: Props = $props();
</script>
<div class="sticky top-0 hidden w-64 flex-col self-start bg-background pt-10 pb-4 md:flex">
<div class="flex items-center gap-2 pb-10">
<Settings class="h-6 w-6" />
<h1 class="text-2xl font-semibold">Settings</h1>
</div>
<nav class="space-y-1">
{#each sections as section (section.title)}
{#if getHref}
<a
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm no-underline transition-colors hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
href={getHref(section)}
>
<section.icon class="h-4 w-4" />
<span class="ml-2">{section.title}</span>
</a>
{:else}
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
onclick={() => onSectionChange?.(section.title)}
>
<section.icon class="h-4 w-4" />
<span class="ml-2">{section.title}</span>
</button>
{/if}
{/each}
</nav>
</div>
@@ -0,0 +1,107 @@
<script lang="ts">
import { Settings, ChevronLeft, ChevronRight } from '@lucide/svelte';
import { onMount, tick } from 'svelte';
import type { SettingsSection, SettingsSectionTitle } from '$lib/constants';
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
interface Props {
sections: SettingsSection[];
isActive: (section: SettingsSection) => boolean;
getHref?: (section: SettingsSection) => string;
onSectionChange?: (section: SettingsSectionTitle) => void;
}
let { sections, isActive, getHref, onSectionChange }: Props = $props();
const carousel = useScrollCarousel();
onMount(async () => {
await tick();
if (carousel.scrollContainer) {
const activeTab = carousel.scrollContainer.querySelector('[data-active="true"]');
if (activeTab instanceof HTMLElement) {
carousel.scrollToCenter(activeTab);
}
}
});
export function updateCarousel() {
setTimeout(carousel.updateScrollButtons, 100);
}
</script>
<div class="sticky top-0 z-10 flex flex-col bg-background md:hidden">
<div class="flex items-center gap-2 px-4 pt-4 pb-2 md:pt-6">
<Settings class="h-5 w-5 md:h-6 md:w-6" />
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
</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="h-4 w-4" />
</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'}"
data-active={isActive(section)}
href={getHref(section)}
onclick={(e: MouseEvent) => {
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="h-4 w-4 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'}"
data-active={isActive(section)}
onclick={(e: MouseEvent) => {
onSectionChange?.(section.title);
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="h-4 w-4 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="h-4 w-4" />
</button>
</div>
</div>
</div>
@@ -1,11 +1,21 @@
<script lang="ts">
import { Download, Upload, Trash2 } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import type { Component } from 'svelte';
import { Download, Upload, Trash2, Database } from '@lucide/svelte';
import { Button, type ButtonVariant } from '$lib/components/ui/button';
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
import { createMessageCountMap } from '$lib/utils';
import { ISO_DATE_TIME_SEPARATOR } from '$lib/constants';
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
import { toast } from 'svelte-sonner';
import { fade } from 'svelte/transition';
import { ConversationSelectionMode, HtmlInputType, FileExtensionText } from '$lib/enums';
interface SectionOpts {
wrapperClass?: string;
titleClass?: string;
buttonVariant?: ButtonVariant;
buttonClass?: string;
summary?: { show: boolean; verb: string; items: DatabaseConversation[] };
}
let exportedConversations = $state<DatabaseConversation[]>([]);
let importedConversations = $state<DatabaseConversation[]>([]);
@@ -56,10 +66,7 @@
})
);
conversationsStore.downloadConversationFile(
allData,
`${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations.json`
);
conversationsStore.downloadConversationFile(allData);
exportedConversations = selectedConversations;
showExportSummary = true;
@@ -75,8 +82,8 @@
try {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.type = HtmlInputType.FILE;
input.accept = FileExtensionText.JSON;
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement)?.files?.[0];
@@ -174,114 +181,104 @@
}
</script>
<div class="space-y-6">
<div class="space-y-4">
<div class="grid">
<h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
{#snippet summaryList(show: boolean, verb: string, items: DatabaseConversation[])}
{#if show && items.length > 0}
<div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
<h5 class="mb-2 text-sm font-medium">
{verb}
{items.length} conversation{items.length === 1 ? '' : 's'}
</h5>
<p class="mb-4 text-sm text-muted-foreground">
Download all your conversations as a JSON file. This includes all messages, attachments, and
conversation history.
</p>
<ul class="space-y-1 text-sm text-muted-foreground">
{#each items.slice(0, 10) as conv (conv.id)}
<li class="truncate">{conv.name || 'Untitled conversation'}</li>
{/each}
<Button
class="w-full justify-start justify-self-start md:w-auto"
onclick={handleExportClick}
variant="outline"
>
<Download class="mr-2 h-4 w-4" />
Export conversations
</Button>
{#if showExportSummary && exportedConversations.length > 0}
<div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
<h5 class="mb-2 text-sm font-medium">
Exported {exportedConversations.length} conversation{exportedConversations.length === 1
? ''
: 's'}
</h5>
<ul class="space-y-1 text-sm text-muted-foreground">
{#each exportedConversations.slice(0, 10) as conv (conv.id)}
<li class="truncate">{conv.name || 'Untitled conversation'}</li>
{/each}
{#if exportedConversations.length > 10}
<li class="italic">
... and {exportedConversations.length - 10} more
</li>
{/if}
</ul>
</div>
{/if}
{#if items.length > 10}
<li class="italic">... and {items.length - 10} more</li>
{/if}
</ul>
</div>
{/if}
{/snippet}
<div class="grid border-t border-border/30 pt-4">
<h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
{#snippet section(
title: string,
description: string,
Icon: Component,
buttonText: string,
onclick: () => void,
opts: SectionOpts
)}
{@const buttonClass = opts?.buttonClass ?? 'justify-start justify-self-start md:w-auto'}
{@const buttonVariant = opts?.buttonVariant ?? 'outline'}
<div class="grid gap-1 {opts?.wrapperClass ?? ''}">
<h4 class="mt-0 mb-2 text-sm font-medium {opts?.titleClass ?? ''}">{title}</h4>
<p class="mb-4 text-sm text-muted-foreground">
Import one or more conversations from a previously exported JSON file. This will merge with
your existing conversations.
</p>
<p class="mb-4 text-sm text-muted-foreground">{description}</p>
<Button
class="w-full justify-start justify-self-start md:w-auto"
onclick={handleImportClick}
variant="outline"
>
<Upload class="mr-2 h-4 w-4" />
Import conversations
</Button>
<Button class={buttonClass} {onclick} variant={buttonVariant}>
<Icon class="mr-2 h-4 w-4" />
{#if showImportSummary && importedConversations.length > 0}
<div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
<h5 class="mb-2 text-sm font-medium">
Imported {importedConversations.length} conversation{importedConversations.length === 1
? ''
: 's'}
</h5>
{buttonText}
</Button>
<ul class="space-y-1 text-sm text-muted-foreground">
{#each importedConversations.slice(0, 10) as conv (conv.id)}
<li class="truncate">{conv.name || 'Untitled conversation'}</li>
{/each}
{#if opts?.summary}
{@render summaryList(opts.summary.show, opts.summary.verb, opts.summary.items)}
{/if}
</div>
{/snippet}
{#if importedConversations.length > 10}
<li class="italic">
... and {importedConversations.length - 10} more
</li>
{/if}
</ul>
</div>
{/if}
</div>
<div class="space-y-6" in:fade={{ duration: 150 }}>
<div class="flex items-center gap-2 pb-4">
<Database class="h-5 w-5 md:h-6 md:w-6" />
<div class="grid border-t border-border/30 pt-4">
<h4 class="mb-2 text-sm font-medium text-destructive">Delete All Conversations</h4>
<h1 class="text-xl font-semibold md:text-2xl">Import / Export</h1>
</div>
<p class="mb-4 text-sm text-muted-foreground">
Permanently delete all conversations and their messages. This action cannot be undone.
Consider exporting your conversations first if you want to keep a backup.
</p>
<div class="space-y-6">
{@render section(
'Export Conversations',
'Download all your conversations as a JSON file. This includes all messages, attachments, and conversation history.',
Download,
'Export conversations',
handleExportClick,
{ summary: { show: showExportSummary, verb: 'Exported', items: exportedConversations } }
)}
<Button
class="text-destructive-foreground w-full justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto"
onclick={handleDeleteAllClick}
variant="destructive"
>
<Trash2 class="mr-2 h-4 w-4" />
{@render section(
'Import Conversations',
'Import one or more conversations from a previously exported JSON file. This will merge with your existing conversations.',
Upload,
'Import conversations',
handleImportClick,
{
wrapperClass: 'border-t border-border/30 pt-6',
summary: { show: showImportSummary, verb: 'Imported', items: importedConversations }
}
)}
Delete all conversations
</Button>
</div>
{@render section(
'Delete All Conversations',
'Permanently delete all conversations and their messages. This action cannot be undone. Consider exporting your conversations first if you want to keep a backup.',
Trash2,
'Delete all conversations',
handleDeleteAllClick,
{
wrapperClass: 'border-t border-border/30 pt-4',
titleClass: 'text-destructive',
buttonVariant: 'destructive',
buttonClass:
'text-destructive-foreground justify-start justify-self-start bg-destructive hover:bg-destructive/80 md:w-auto'
}
)}
</div>
</div>
<DialogConversationSelection
conversations={availableConversations}
{messageCountMap}
mode="export"
mode={ConversationSelectionMode.EXPORT}
bind:open={showExportDialog}
onCancel={() => (showExportDialog = false)}
onConfirm={handleExportConfirm}
@@ -290,7 +287,7 @@
<DialogConversationSelection
conversations={availableConversations}
{messageCountMap}
mode="import"
mode={ConversationSelectionMode.IMPORT}
bind:open={showImportDialog}
onCancel={() => (showImportDialog = false)}
onConfirm={handleImportConfirm}
@@ -0,0 +1,109 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { McpServerCard, McpServerCardSkeleton } from '$lib/components/app/mcp';
import { DialogMcpServerAddNew } from '$lib/components/app/dialogs';
import { HealthCheckStatus } from '$lib/enums';
import { fade } from 'svelte/transition';
import { onMount } from 'svelte';
import McpLogo from '../mcp/McpLogo.svelte';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
interface Props {
class?: string;
}
let { class: className }: Props = $props();
let servers = $derived(mcpStore.getServersSorted());
let initialLoadComplete = $state(false);
let isAddingServer = $state(false);
onMount(() => {
if (page.url.searchParams.has('add')) {
isAddingServer = true;
const newUrl = new URL(page.url);
newUrl.searchParams.delete('add');
replaceState(newUrl, {});
}
});
$effect(() => {
if (initialLoadComplete) return;
const allChecked =
servers.length > 0 &&
servers.every((server) => {
const state = mcpStore.getHealthCheckState(server.id);
return (
state.status === HealthCheckStatus.SUCCESS || state.status === HealthCheckStatus.ERROR
);
});
if (allChecked) {
initialLoadComplete = true;
}
});
</script>
<div in:fade={{ duration: 150 }} class="max-h-full overflow-auto">
<div class="flex items-center gap-2 p-4 md:absolute md:top-8 md:left-8 md:px-0 md:py-2">
<McpLogo class="h-5 w-5 md:h-6 md:w-6" />
<h1 class="text-xl font-semibold md:text-2xl">MCP Servers</h1>
</div>
<div class="sticky top-0 z-10 mt-4 flex items-start justify-end gap-4 px-8 py-4">
<Button variant="outline" size="sm" class="shrink-0" onclick={() => (isAddingServer = true)}>
<Plus class="h-4 w-4" />
Add New Server
</Button>
</div>
<DialogMcpServerAddNew bind:open={isAddingServer} />
<div class="grid gap-5 md:space-y-4 {className}">
{#if servers.length === 0 && !isAddingServer}
<div class="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
No MCP Servers configured yet. Add one to enable agentic features.
</div>
{/if}
{#if servers.length > 0}
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
>
{#each servers as server (server.id)}
{#if !initialLoadComplete}
<McpServerCardSkeleton />
{:else}
<McpServerCard
{server}
faviconUrl={mcpStore.getServerFavicon(server.id)}
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
onToggle={async () => {
const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
await conversationsStore.toggleMcpServerForChat(server.id);
if (!wasEnabled) {
toolsStore.enableAllToolsForServer(server.id);
}
}}
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
onDelete={() => mcpStore.removeServer(server.id)}
/>
{/if}
{/each}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,32 @@
/**
* Full chat settings page layout with sidebar, mobile header, and content area.
* Manages local configuration state, section navigation, and context setup.
* Accepts an optional `initialSection` prop to override the URL-based section resolution.
*/
export { default as SettingsChat } from './SettingsChat.svelte';
/**
* Desktop sidebar navigation for chat settings.
* Displays a list of settings sections with icons and titles.
* Supports both hash-link navigation (via `getHref`) and in-app section switching (via `onSectionChange`).
*/
export { default as SettingsChatDesktopSidebar } from './SettingsChatDesktopSidebar.svelte';
/**
* Mobile header with a horizontally scrollable section picker for chat settings.
* Shows chevron buttons for scroll navigation and highlights the active section.
* Supports both hash-link navigation (via `getHref`) and in-app section switching (via `onSectionChange`).
*/
export { default as SettingsChatMobileHeader } from './SettingsChatMobileHeader.svelte';
/**
* Settings Import/Export panel.
* Provides UI for importing and exporting chat settings configurations.
*/
export { default as SettingsImportExport } from './SettingsImportExport.svelte';
/**
* MCP Servers configuration panel.
* Provides UI for managing Model Context Protocol (MCP) server connections.
*/
export { default as SettingsMcpServers } from './SettingsMcpServers.svelte';
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface Props extends HTMLAttributes<HTMLDivElement> {
children: Snippet;
}
let { class: className, children, ...restProps }: Props = $props();
</script>
<div
class={cn(
'flex items-center [&>*:first-child]:rounded-r-none [&>*:last-child]:rounded-l-none [&>*:not(:first-child):not(:last-child)]:rounded-none',
className
)}
{...restProps}
>
{@render children()}
</div>
@@ -0,0 +1,8 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import type { HTMLAttributes } from 'svelte/elements';
let { ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div class={cn('shrink-0 self-stretch bg-border', 'w-px')} {...restProps}></div>
@@ -0,0 +1,2 @@
export { default as Root } from './button-group-root.svelte';
export { default as Separator } from './button-group-separator.svelte';
@@ -9,9 +9,9 @@
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white!',
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
'shadow-xs hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm dark:border-input border',
secondary:
'dark:bg-secondary dark:text-secondary-foreground bg-background shadow-sm text-foreground hover:bg-muted-foreground/20',
ghost: 'hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm',
@@ -13,7 +13,7 @@
bind:ref
data-slot="dropdown-menu-sub-content"
class={cn(
'z-50 min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
className
)}
{...restProps}
@@ -1,6 +1,7 @@
export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = '18rem';
export const SIDEBAR_MIN_WIDTH = '18rem';
export const SIDEBAR_MAX_WIDTH = '32rem';
export const SIDEBAR_WIDTH_MOBILE = '18rem';
export const SIDEBAR_WIDTH_ICON = '3rem';
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
@@ -1,6 +1,6 @@
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import { getContext, setContext } from 'svelte';
import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants.js';
import { SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_MIN_WIDTH } from './constants.js';
type Getter<T> = () => T;
@@ -24,6 +24,8 @@ class SidebarState {
readonly props: SidebarStateProps;
open = $derived.by(() => this.props.open());
openMobile = $state(false);
sidebarWidth = $state(SIDEBAR_MIN_WIDTH);
isResizing = $state(false);
setOpen: SidebarStateProps['setOpen'];
#isMobile: IsMobile;
state = $derived.by(() => (this.open ? 'expanded' : 'collapsed'));
@@ -53,7 +55,7 @@ class SidebarState {
};
toggle = () => {
return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open);
this.setOpen(!this.open);
};
}
@@ -14,7 +14,7 @@
bind:this={ref}
data-slot="sidebar-footer"
data-sidebar="footer"
class={cn('flex flex-col gap-2 p-2', className)}
class={cn('flex flex-col gap-2 p-3', className)}
{...restProps}
>
{@render children?.()}
@@ -2,7 +2,7 @@
import { tv, type VariantProps } from 'tailwind-variants';
export const sidebarMenuButtonVariants = tv({
base: 'peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
base: 'peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md py-2 px-1 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
@@ -4,7 +4,8 @@
import {
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_WIDTH,
SIDEBAR_MIN_WIDTH,
SIDEBAR_MAX_WIDTH,
SIDEBAR_WIDTH_ICON
} from './constants.js';
import { setSidebar } from './context.svelte.js';
@@ -38,7 +39,7 @@
<div
data-slot="sidebar-wrapper"
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
style="--sidebar-width: {sidebar.sidebarWidth}; --sidebar-min-width: {SIDEBAR_MIN_WIDTH}; --sidebar-max-width: {SIDEBAR_MAX_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
className
@@ -3,6 +3,7 @@
import PanelLeftIcon from '@lucide/svelte/icons/panel-left';
import type { ComponentProps } from 'svelte';
import { useSidebar } from './context.svelte.js';
import { PanelLeftClose } from '@lucide/svelte';
let {
ref = $bindable(null),
@@ -21,9 +22,11 @@
data-slot="sidebar-trigger"
variant="ghost"
size="icon-lg"
class="rounded-full backdrop-blur-lg {className} md:left-{sidebar.open
? 'unset'
: '2'} -top-2 -left-2 md:top-0"
class="rounded-full backdrop-blur-lg {className} {sidebar.open
? 'top-1.5'
: 'top-0'} md:left-[calc(var(--sidebar-width)-3.25rem)] {sidebar.isResizing
? '!duration-0'
: ''}"
type="button"
onclick={(e) => {
onclick?.(e);
@@ -31,6 +34,10 @@
}}
{...restProps}
>
<PanelLeftIcon />
{#if sidebar.open}
<PanelLeftClose />
{:else}
<PanelLeftIcon />
{/if}
<span class="sr-only">Toggle Sidebar</span>
</Button>
@@ -1,9 +1,9 @@
<script lang="ts">
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { SIDEBAR_WIDTH_MOBILE } from './constants.js';
import { SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH } from './constants.js';
import { useSidebar } from './context.svelte.js';
import { remToPx } from '$lib/utils';
let {
ref = $bindable(null),
@@ -20,6 +20,34 @@
} = $props();
const sidebar = useSidebar();
function handleResizePointerDown(e: PointerEvent) {
if (sidebar.isMobile) return;
e.preventDefault();
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
const minPx = remToPx(SIDEBAR_MIN_WIDTH);
const maxPx = remToPx(SIDEBAR_MAX_WIDTH);
sidebar.isResizing = true;
function onPointerMove(ev: PointerEvent) {
const newWidth = side === 'left' ? ev.clientX : window.innerWidth - ev.clientX;
const clamped = Math.min(maxPx, Math.max(minPx, newWidth));
sidebar.sidebarWidth = `${clamped}px`;
}
function onPointerUp() {
sidebar.isResizing = false;
target.removeEventListener('pointermove', onPointerMove);
target.removeEventListener('pointerup', onPointerUp);
}
target.addEventListener('pointermove', onPointerMove);
target.addEventListener('pointerup', onPointerUp);
}
</script>
{#if collapsible === 'none'}
@@ -33,29 +61,10 @@
>
{@render children?.()}
</div>
{:else if sidebar.isMobile}
<Sheet.Root bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}>
<Sheet.Content
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
class="z-99999 w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground sm:z-99 [&>button]:hidden"
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
{side}
>
<Sheet.Header class="sr-only">
<Sheet.Title>Sidebar</Sheet.Title>
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
</Sheet.Header>
<div class="flex h-full w-full flex-col">
{@render children?.()}
</div>
</Sheet.Content>
</Sheet.Root>
{:else}
<div
bind:this={ref}
class="group peer hidden text-sidebar-foreground md:block"
class="group peer block text-sidebar-foreground"
data-state={sidebar.state}
data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''}
data-variant={variant}
@@ -66,36 +75,76 @@
<div
data-slot="sidebar-gap"
class={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'relative bg-transparent transition-[width] duration-200 ease-linear',
sidebar.isResizing && '!duration-0',
'w-0',
variant === 'floating'
? 'md:w-[calc(var(--sidebar-width)+0.75rem)]'
: 'md:w-(--sidebar-width)',
'md:group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)}
></div>
<div
data-slot="sidebar-container"
class={cn(
'fixed inset-y-0 z-999 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:z-0 md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
'fixed inset-y-0 z-[900] flex w-[calc(100dvw-1.5rem)] duration-200 ease-linear md:z-0 md:w-(--sidebar-width)',
'group-data-[collapsible=offcanvas]:pointer-events-none md:group-data-[collapsible=offcanvas]:pointer-events-auto',
sidebar.isResizing && '!duration-0',
variant === 'floating'
? [
'transition-[left,right,width,opacity]',
side === 'left'
? 'left-3 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-0.775)] group-data-[collapsible=offcanvas]:opacity-0'
: 'right-3 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-0.775)] group-data-[collapsible=offcanvas]:opacity-0',
'my-3 overflow-hidden rounded-3xl border border-sidebar-border shadow-md'
]
: [
'h-svh transition-[left,right,width]',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]'
],
// Adjust the padding for inset variant.
variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
: variant === 'floating'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
className
)}
style={variant === 'floating' ? 'height: calc(100dvh - 1.5rem);' : undefined}
{...restProps}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
class="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
class="flex h-full w-full flex-col bg-sidebar"
>
{@render children?.()}
</div>
<!-- Resize handle -->
{#if side === 'left'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-slot="sidebar-resize-handle"
class="absolute inset-y-0 right-0 z-50 hidden w-1.5 cursor-ew-resize touch-none select-none hover:bg-sidebar-border/50 active:bg-sidebar-border md:block"
class:bg-sidebar-border={sidebar.isResizing}
onpointerdown={handleResizePointerDown}
></div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-slot="sidebar-resize-handle"
class="absolute inset-y-0 left-0 z-50 hidden w-1.5 cursor-ew-resize touch-none select-none hover:bg-sidebar-border/50 active:bg-sidebar-border md:block"
class:bg-sidebar-border={sidebar.isResizing}
onpointerdown={handleResizePointerDown}
></div>
{/if}
</div>
</div>
{/if}
@@ -4,4 +4,9 @@
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
</script>
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
<TooltipPrimitive.Trigger
bind:ref
data-slot="tooltip-trigger"
class="cursor-pointer"
{...restProps}
/>
@@ -4,8 +4,6 @@ export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/;
export const NEWLINE_SEPARATOR = '\n';
export const TURN_LIMIT_MESSAGE = '\n\n```\nTurn limit reached\n```\n';
export const LLM_ERROR_BLOCK_START = '\n\n```\nUpstream LLM error:\n';
export const LLM_ERROR_BLOCK_END = '\n```\n';
@@ -4,5 +4,10 @@ export const API_MODELS = {
UNLOAD: '/models/unload'
};
export const API_TOOLS = {
LIST: '/tools',
EXECUTE: '/tools'
};
/** CORS proxy endpoint path */
export const CORS_PROXY_ENDPOINT = '/cors-proxy';
@@ -0,0 +1,103 @@
import type { Component } from 'svelte';
import { MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import { FILE_TYPE_ICONS } from '$lib/constants/icons';
import {
AttachmentAction,
AttachmentItemEnabledWhen,
AttachmentItemVisibleWhen,
AttachmentMenuItemId
} from '$lib/enums';
export interface AttachmentMenuItem {
/** Unique identifier for the item */
id: AttachmentMenuItemId;
/** Display label */
label: string;
/** Lucide icon component */
icon: Component;
/** Extra CSS class applied to the item (e.g. for test selectors) */
class?: string;
/** Whether the item requires a specific modality to be enabled */
enabledWhen?: AttachmentItemEnabledWhen;
/** Tooltip shown when the item is disabled */
disabledTooltip?: string;
/** Callback key on the Props interface to invoke when clicked */
action: AttachmentAction;
/** Whether the item is only shown when a specific capability is present */
visibleWhen?: AttachmentItemVisibleWhen;
/** Whether this item has a tooltip even when enabled (uses dynamic text) */
hasEnabledTooltip?: boolean;
}
/**
* File attachment menu items shown in both the desktop dropdown and mobile sheet.
* The "Tools" submenu is handled separately by each component.
*/
export const ATTACHMENT_FILE_ITEMS: AttachmentMenuItem[] = [
{
id: AttachmentMenuItemId.IMAGES,
label: 'Images',
icon: FILE_TYPE_ICONS.image,
class: 'images-button',
enabledWhen: AttachmentItemEnabledWhen.HAS_VISION_MODALITY,
disabledTooltip: 'Image processing requires a vision model',
action: AttachmentAction.FILE_UPLOAD
},
{
id: AttachmentMenuItemId.AUDIO,
label: 'Audio Files',
icon: FILE_TYPE_ICONS.audio,
class: 'audio-button',
enabledWhen: AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY,
disabledTooltip: 'Audio files processing requires an audio model',
action: AttachmentAction.FILE_UPLOAD
},
{
id: AttachmentMenuItemId.TEXT,
label: 'Text Files',
icon: FILE_TYPE_ICONS.text,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
action: AttachmentAction.FILE_UPLOAD
},
{
id: AttachmentMenuItemId.PDF,
label: 'PDF Files',
icon: FILE_TYPE_ICONS.pdf,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
disabledTooltip: 'PDFs will be converted to text. Image-based PDFs may not work properly.',
hasEnabledTooltip: true,
action: AttachmentAction.FILE_UPLOAD
}
];
export const ATTACHMENT_EXTRA_ITEMS: AttachmentMenuItem[] = [
{
id: AttachmentMenuItemId.SYSTEM_MESSAGE,
label: 'System Message',
icon: MessageSquare,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
hasEnabledTooltip: true,
action: AttachmentAction.SYSTEM_PROMPT_CLICK
}
];
export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
{
id: AttachmentMenuItemId.MCP_PROMPT,
label: 'MCP Prompt',
icon: Zap,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
action: AttachmentAction.MCP_PROMPT_CLICK,
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT
},
{
id: AttachmentMenuItemId.MCP_RESOURCES,
label: 'MCP Resources',
icon: FolderOpen,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
action: AttachmentAction.MCP_RESOURCES_CLICK,
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_RESOURCES_SUPPORT
}
];
export const ATTACHMENT_TOOLTIP_TEXT = 'Add files, system prompt or MCP Servers';
@@ -3,3 +3,4 @@ export const PROMPT_CONTENT_SEPARATOR = '\n\n';
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
export const PROMPT_TRIGGER_PREFIX = '/';
export const RESOURCE_TRIGGER_PREFIX = '@';
export const NEW_CHAT_DRAFT_KEY = '__new_chat__';
@@ -1,3 +1,4 @@
export const CONTEXT_KEY_MESSAGE_EDIT = 'chat-message-edit';
export const CONTEXT_KEY_CHAT_ACTIONS = 'chat-actions';
export const CONTEXT_KEY_CHAT_SETTINGS_DIALOG = 'chat-settings-dialog';
export const CONTEXT_KEY_CHAT_SETTINGS_CONFIG = 'chat-settings-config';
export const CONTEXT_KEY_PROCESSING_INFO = 'processing-info';
@@ -4,6 +4,7 @@
export * from './agentic';
export * from './api-endpoints';
export * from './attachment-labels';
export * from './attachment-menu';
export * from './auto-scroll';
export * from './binary-detection';
export * from './cache';
@@ -35,6 +36,7 @@ export * from './settings-keys';
export * from './settings-sections';
export * from './supported-file-types';
export * from './table-html-restorer';
export * from './tools';
export * from './tooltip-config';
export * from './ui';
export * from './uri-template';
@@ -1,4 +1,6 @@
export const ALWAYS_ALLOWED_TOOLS_LOCALSTORAGE_KEY = 'LlamaCppWebui.alwaysAllowedTools';
export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config';
export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
export const DISABLED_TOOLS_LOCALSTORAGE_KEY = 'LlamaCppWebui.disabledTools';
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = 'LlamaCppWebui.favoriteModels';
export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = 'LlamaCppWebui.mcpDefaultEnabled';
export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
+1 -1
View File
@@ -40,7 +40,7 @@ export const MCP_RECONNECT_MAX_DELAY = 30000;
export const MCP_RECONNECT_ATTEMPT_TIMEOUT_MS = 15_000;
/** Maximum number of MCP server avatars to display in the chat form */
export const MAX_DISPLAYED_MCP_AVATARS = 3;
export const MAX_DISPLAYED_MCP_AVATARS = 4;
/** Expected count when two theme-less icons represent a light/dark pair */
export const EXPECTED_THEMED_ICON_PAIR_COUNT = 2;
@@ -8,7 +8,7 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean |
systemMessage: '',
showSystemMessage: true,
theme: ColorMode.SYSTEM,
showThoughtInProgress: false,
showThoughtInProgress: true,
disableReasoningParsing: false,
excludeReasoningFromContext: false,
showRawOutputSwitch: false,
@@ -24,7 +24,6 @@ export const SETTINGS_KEYS = {
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
AUTO_SHOW_SIDEBAR_ON_NEW_CHAT: 'autoShowSidebarOnNewChat',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
// Sampling
@@ -10,6 +10,8 @@ export const SETTINGS_SECTION_TITLES = {
SAMPLING: 'Sampling',
PENALTIES: 'Penalties',
IMPORT_EXPORT: 'Import/Export',
AGENTIC: 'Agentic',
TOOLS: 'Tools',
MCP: 'MCP',
DEVELOPER: 'Developer'
} as const;
@@ -17,3 +19,298 @@ export const SETTINGS_SECTION_TITLES = {
/** Type for settings section titles */
export type SettingsSectionTitle =
(typeof SETTINGS_SECTION_TITLES)[keyof typeof SETTINGS_SECTION_TITLES];
import {
Funnel,
AlertTriangle,
Code,
Monitor,
ListRestart,
Sliders,
PencilRuler
} from '@lucide/svelte';
import { SettingsFieldType } from '$lib/enums/settings';
import { SETTINGS_COLOR_MODES_CONFIG } from '$lib/constants/settings-config';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import type { Component } from 'svelte';
export interface SettingsSection {
fields?: SettingsFieldConfig[];
icon: Component;
slug: string;
title: SettingsSectionTitle;
}
export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
{
title: SETTINGS_SECTION_TITLES.GENERAL,
slug: 'general',
icon: Sliders,
fields: [
{
key: SETTINGS_KEYS.THEME,
label: 'Theme',
type: SettingsFieldType.SELECT,
options: SETTINGS_COLOR_MODES_CONFIG
},
{ key: SETTINGS_KEYS.API_KEY, label: 'API Key', type: SettingsFieldType.INPUT },
{
key: SETTINGS_KEYS.SYSTEM_MESSAGE,
label: 'System Message',
type: SettingsFieldType.TEXTAREA
},
{
key: SETTINGS_KEYS.PASTE_LONG_TEXT_TO_FILE_LEN,
label: 'Paste long text to file length',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT,
label: 'Copy text attachments as plain text',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ENABLE_CONTINUE_GENERATION,
label: 'Enable "Continue" button',
type: SettingsFieldType.CHECKBOX,
isExperimental: true
},
{
key: SETTINGS_KEYS.PDF_AS_IMAGE,
label: 'Parse PDF as image',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ASK_FOR_TITLE_CONFIRMATION,
label: 'Ask for confirmation before changing conversation title',
type: SettingsFieldType.CHECKBOX
}
]
},
{
title: SETTINGS_SECTION_TITLES.DISPLAY,
slug: 'display',
icon: Monitor,
fields: [
{
key: SETTINGS_KEYS.SHOW_MESSAGE_STATS,
label: 'Show message generation statistics',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS,
label: 'Show thought in progress',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
label: 'Show tool call in progress',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.KEEP_STATS_VISIBLE,
label: 'Keep stats visible after generation',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
label: 'Show microphone on empty input',
type: SettingsFieldType.CHECKBOX,
isExperimental: true
},
{
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
label: 'Render user content as Markdown',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.DISABLE_AUTO_SCROLL,
label: 'Disable automatic scroll',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ALWAYS_SHOW_SIDEBAR_ON_DESKTOP,
label: 'Always show sidebar on desktop',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_RAW_MODEL_NAMES,
label: 'Show raw model names',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
label: 'Always show agentic turns in conversation',
type: SettingsFieldType.CHECKBOX
}
]
},
{
title: SETTINGS_SECTION_TITLES.SAMPLING,
slug: 'sampling',
icon: Funnel,
fields: [
{
key: SETTINGS_KEYS.TEMPERATURE,
label: 'Temperature',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DYNATEMP_RANGE,
label: 'Dynamic temperature range',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DYNATEMP_EXPONENT,
label: 'Dynamic temperature exponent',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.TOP_K,
label: 'Top K',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.TOP_P,
label: 'Top P',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.MIN_P,
label: 'Min P',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.XTC_PROBABILITY,
label: 'XTC probability',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.XTC_THRESHOLD,
label: 'XTC threshold',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.TYP_P,
label: 'Typical P',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.MAX_TOKENS,
label: 'Max tokens',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.SAMPLERS,
label: 'Samplers',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.BACKEND_SAMPLING,
label: 'Backend sampling',
type: SettingsFieldType.CHECKBOX
}
]
},
{
title: SETTINGS_SECTION_TITLES.PENALTIES,
slug: 'penalties',
icon: AlertTriangle,
fields: [
{
key: SETTINGS_KEYS.REPEAT_LAST_N,
label: 'Repeat last N',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.REPEAT_PENALTY,
label: 'Repeat penalty',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.PRESENCE_PENALTY,
label: 'Presence penalty',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.FREQUENCY_PENALTY,
label: 'Frequency penalty',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_MULTIPLIER,
label: 'DRY multiplier',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_BASE,
label: 'DRY base',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_ALLOWED_LENGTH,
label: 'DRY allowed length',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.DRY_PENALTY_LAST_N,
label: 'DRY penalty last N',
type: SettingsFieldType.INPUT
}
]
},
{
title: SETTINGS_SECTION_TITLES.AGENTIC,
slug: 'agentic',
icon: ListRestart,
fields: [
{
key: SETTINGS_KEYS.AGENTIC_MAX_TURNS,
label: 'Agentic turns',
type: SettingsFieldType.INPUT
},
{
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
label: 'Max lines per tool preview',
type: SettingsFieldType.INPUT
}
]
},
{
title: SETTINGS_SECTION_TITLES.TOOLS,
slug: 'tools',
icon: PencilRuler
},
{
title: SETTINGS_SECTION_TITLES.DEVELOPER,
slug: 'developer',
icon: Code,
fields: [
{
key: SETTINGS_KEYS.DISABLE_REASONING_PARSING,
label: 'Disable reasoning content parsing',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.EXCLUDE_REASONING_FROM_CONTEXT,
label: 'Exclude reasoning from context',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
label: 'Enable raw output toggle',
type: SettingsFieldType.CHECKBOX
},
{
key: SETTINGS_KEYS.CUSTOM,
label: 'Custom JSON',
type: SettingsFieldType.TEXTAREA
}
]
}
];
@@ -0,0 +1,11 @@
import { ToolSource } from '$lib/enums/tools';
export const TOOL_GROUP_LABELS = {
[ToolSource.BUILTIN]: 'Built-in',
[ToolSource.CUSTOM]: 'JSON Schema'
} as const;
export const TOOL_SERVER_LABELS = {
[ToolSource.BUILTIN]: 'Built-in Tools',
[ToolSource.CUSTOM]: 'Custom Tools'
} as const;
@@ -1,2 +1,42 @@
import { Database, Settings, Search, SquarePen } from '@lucide/svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import type { Component } from 'svelte';
export const FORK_TREE_DEPTH_PADDING = 8;
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
export const APP_NAME = import.meta.env.VITE_PUBLIC_APP_NAME || 'llama.cpp';
export const ICON_STRIP_TRANSITION_DURATION = 150;
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
export interface DesktopIconStripItem {
icon: Component;
tooltip: string;
route?: string;
activeRouteId?: string;
activeRoutePrefix?: string;
keys?: string[];
}
export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
{ icon: SquarePen, tooltip: 'New chat', route: '?new_chat=true#/', keys: ['shift', 'cmd', 'o'] },
{ icon: Search, tooltip: 'Search', keys: ['cmd', 'k'] },
{
icon: McpLogo,
tooltip: 'MCP Servers',
route: '#/settings/mcp',
activeRouteId: '/settings/mcp'
},
{
icon: Database,
tooltip: 'Import / Export',
route: '#/settings/import-export',
activeRouteId: '/settings/import-export'
},
{
icon: Settings,
tooltip: 'Settings',
route: '#/settings/chat/general',
activeRoutePrefix: '/settings/chat'
}
];
@@ -0,0 +1,20 @@
import { getContext, setContext } from 'svelte';
import { CONTEXT_KEY_CHAT_SETTINGS_CONFIG } from '$lib/constants';
export interface ChatSettingsConfigContext {
readonly localConfig: SettingsConfigType;
handleConfigChange: (key: string, value: string | boolean) => void;
handleThemeChange: (theme: string) => void;
}
const CHAT_SETTINGS_CONFIG_KEY = Symbol.for(CONTEXT_KEY_CHAT_SETTINGS_CONFIG);
export function setChatSettingsConfigContext(
ctx: ChatSettingsConfigContext
): ChatSettingsConfigContext {
return setContext(CHAT_SETTINGS_CONFIG_KEY, ctx);
}
export function getChatSettingsConfigContext(): ChatSettingsConfigContext {
return getContext(CHAT_SETTINGS_CONFIG_KEY);
}
@@ -1,19 +0,0 @@
import { getContext, setContext } from 'svelte';
import type { SettingsSectionTitle } from '$lib/constants';
import { CONTEXT_KEY_CHAT_SETTINGS_DIALOG } from '$lib/constants';
export interface ChatSettingsDialogContext {
open: (initialSection?: SettingsSectionTitle) => void;
}
const CHAT_SETTINGS_DIALOG_KEY = Symbol.for(CONTEXT_KEY_CHAT_SETTINGS_DIALOG);
export function setChatSettingsDialogContext(
ctx: ChatSettingsDialogContext
): ChatSettingsDialogContext {
return setContext(CHAT_SETTINGS_DIALOG_KEY, ctx);
}
export function getChatSettingsDialogContext(): ChatSettingsDialogContext {
return getContext(CHAT_SETTINGS_DIALOG_KEY);
}
+10 -4
View File
@@ -13,7 +13,13 @@ export {
} from './chat-actions.context';
export {
getChatSettingsDialogContext,
setChatSettingsDialogContext,
type ChatSettingsDialogContext
} from './chat-settings-dialog.context';
getChatSettingsConfigContext,
setChatSettingsConfigContext,
type ChatSettingsConfigContext
} from './chat-settings-config.context';
export {
getProcessingInfoContext,
setProcessingInfoContext,
type ProcessingInfoContext
} from './processing-info.context';
@@ -0,0 +1,16 @@
import { getContext, setContext } from 'svelte';
import { CONTEXT_KEY_PROCESSING_INFO } from '$lib/constants';
export interface ProcessingInfoContext {
readonly showProcessingInfo: boolean;
}
const PROCESSING_INFO_KEY = Symbol.for(CONTEXT_KEY_PROCESSING_INFO);
export function setProcessingInfoContext(ctx: ProcessingInfoContext): ProcessingInfoContext {
return setContext(PROCESSING_INFO_KEY, ctx);
}
export function getProcessingInfoContext(): ProcessingInfoContext {
return getContext(PROCESSING_INFO_KEY);
}
@@ -10,3 +10,44 @@ export enum AttachmentType {
TEXT = 'TEXT',
LEGACY_CONTEXT = 'context' // Legacy attachment type for backward compatibility
}
/**
* Unique identifiers for attachment menu items in the chat form action dropdowns.
* Used to select which file upload or attachment action is triggered.
*/
export enum AttachmentMenuItemId {
IMAGES = 'images',
AUDIO = 'audio',
TEXT = 'text',
PDF = 'pdf',
SYSTEM_MESSAGE = 'system-message',
MCP_PROMPT = 'mcp-prompt',
MCP_RESOURCES = 'mcp-resources'
}
/**
* Defines when an attachment menu item should be enabled.
*/
export enum AttachmentItemEnabledWhen {
ALWAYS = 'always',
HAS_VISION_MODALITY = 'hasVisionModality',
HAS_AUDIO_MODALITY = 'hasAudioModality'
}
/**
* Defines the callback action triggered when an attachment menu item is clicked.
*/
export enum AttachmentAction {
FILE_UPLOAD = 'onFileUpload',
SYSTEM_PROMPT_CLICK = 'onSystemPromptClick',
MCP_PROMPT_CLICK = 'onMcpPromptClick',
MCP_RESOURCES_CLICK = 'onMcpResourcesClick'
}
/**
* Visibility conditions for attachment menu items.
*/
export enum AttachmentItemVisibleWhen {
HAS_MCP_PROMPTS_SUPPORT = 'hasMcpPromptsSupport',
HAS_MCP_RESOURCES_SUPPORT = 'hasMcpResourcesSupport'
}
+5
View File
@@ -49,3 +49,8 @@ export enum ErrorDialogType {
TIMEOUT = 'timeout',
SERVER = 'server'
}
export enum ConversationSelectionMode {
EXPORT = 'export',
IMPORT = 'import'
}
+11 -2
View File
@@ -1,10 +1,17 @@
export { AttachmentType } from './attachment';
export {
AttachmentType,
AttachmentMenuItemId,
AttachmentItemEnabledWhen,
AttachmentAction,
AttachmentItemVisibleWhen
} from './attachment';
export { AgenticSectionType, ToolCallType } from './agentic';
export {
ChatMessageStatsView,
ContentPartType,
ConversationSelectionMode,
ErrorDialogType,
MessageRole,
MessageType,
@@ -47,6 +54,8 @@ export { ServerRole, ServerModelStatus } from './server';
export { ParameterSource, SyncableParameterType, SettingsFieldType } from './settings';
export { ColorMode, McpPromptVariant, UrlProtocol } from './ui';
export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol } from './ui';
export { KeyboardKey } from './keyboard';
export { ToolSource, ToolPermissionDecision, ToolResponseField } from './tools';
+17
View File
@@ -0,0 +1,17 @@
export enum ToolSource {
BUILTIN = 'builtin',
MCP = 'mcp',
CUSTOM = 'custom'
}
export enum ToolPermissionDecision {
ALWAYS = 'always',
ALWAYS_SERVER = 'always_server',
ONCE = 'once',
DENY = 'deny'
}
export enum ToolResponseField {
PLAIN_TEXT = 'plain_text_response',
ERROR = 'error'
}
+11
View File
@@ -4,6 +4,13 @@ export enum ColorMode {
SYSTEM = 'system'
}
export enum TooltipSide {
TOP = 'top',
RIGHT = 'right',
BOTTOM = 'bottom',
LEFT = 'left'
}
/**
* MCP prompt display variant
*/
@@ -22,3 +29,7 @@ export enum UrlProtocol {
WEBSOCKET = 'ws://',
WEBSOCKET_SECURE = 'wss://'
}
export enum HtmlInputType {
FILE = 'file'
}
@@ -0,0 +1,81 @@
import { page } from '$app/state';
import { AttachmentAction } from '$lib/enums';
export interface AttachmentModalityFlags {
hasVisionModality: boolean;
hasAudioModality: boolean;
hasMcpPromptsSupport: boolean;
hasMcpResourcesSupport: boolean;
}
export interface AttachmentActionCallbacks {
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
}
export interface UseAttachmentMenuReturn {
readonly callbacks: Record<string, () => void>;
isItemEnabled(enabledWhen: string | undefined): boolean;
isItemVisible(visibleWhen: string | undefined): boolean;
getSystemMessageTooltip(): string;
}
/**
* useAttachmentMenu - Shared logic for attachment menu components.
*
* Encapsulates the modality-flag checks and callback wrapping that is
* identical across the desktop dropdown (`ChatFormActionAttachmentsDropdown`)
* and the mobile sheet (`ChatFormActionAttachmentsSheet`).
*
* @param getFlags - Getter returning the current modality capability flags.
* @param getCallbacks - Getter returning the raw action callbacks from props.
* @param close - Function that dismisses the hosting UI element (dropdown / sheet).
*/
export function useAttachmentMenu(
getFlags: () => AttachmentModalityFlags,
getCallbacks: () => AttachmentActionCallbacks,
close: () => void
): UseAttachmentMenuReturn {
const modalityFlags = $derived(getFlags());
const callbacks = $derived.by(() => {
const cbs = getCallbacks();
const wrap = (fn?: () => void) => () => {
close();
fn?.();
};
return {
[AttachmentAction.FILE_UPLOAD]: wrap(cbs.onFileUpload),
[AttachmentAction.SYSTEM_PROMPT_CLICK]: wrap(cbs.onSystemPromptClick),
[AttachmentAction.MCP_PROMPT_CLICK]: wrap(cbs.onMcpPromptClick),
[AttachmentAction.MCP_RESOURCES_CLICK]: wrap(cbs.onMcpResourcesClick)
};
});
function isItemEnabled(enabledWhen: string | undefined): boolean {
if (!enabledWhen || enabledWhen === 'always') return true;
return !!modalityFlags[enabledWhen as keyof AttachmentModalityFlags];
}
function isItemVisible(visibleWhen: string | undefined): boolean {
if (!visibleWhen) return true;
return !!modalityFlags[visibleWhen as keyof AttachmentModalityFlags];
}
function getSystemMessageTooltip(): string {
return !page.params.id
? 'Add custom system message for a new conversation'
: 'Inject custom system message at the beginning of the conversation';
}
return {
get callbacks() {
return callbacks;
},
isItemEnabled,
isItemVisible,
getSystemMessageTooltip
};
}
@@ -0,0 +1,50 @@
import { onMount } from 'svelte';
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { draftMessagesStore } from '$lib/stores/draft-messages.svelte';
interface UseDraftMessagesOptions {
getChatId: () => string | undefined;
getMessage: () => string;
getFiles: () => ChatUploadedFile[];
setMessage: (message: string) => void;
setFiles: (files: ChatUploadedFile[]) => void;
getInitialMessage: () => string;
}
export function useDraftMessages(options: UseDraftMessagesOptions) {
onMount(() => {
const chatId = options.getChatId();
if (!chatId) return;
const draft = draftMessagesStore.getDraftMessage(chatId);
if ((draft.message || draft.files.length > 0) && !options.getInitialMessage()) {
options.setMessage(draft.message);
options.setFiles(draft.files);
}
});
beforeNavigate(() => {
const chatId = options.getChatId();
if (!chatId) return;
draftMessagesStore.saveDraftMessage(chatId, options.getMessage(), options.getFiles());
});
afterNavigate((navigation) => {
if (navigation?.from != null) {
const chatId = options.getChatId();
if (!chatId) return;
const draft = draftMessagesStore.getDraftMessage(chatId);
options.setMessage(draft.message);
options.setFiles(draft.files);
}
});
function clearDraft() {
const chatId = options.getChatId();
if (!chatId) return;
draftMessagesStore.clearDraftMessage(chatId);
}
return { clearDraft };
}
@@ -0,0 +1,42 @@
import { goto } from '$app/navigation';
import { KeyboardKey } from '$lib/enums';
interface KeyboardShortcutsCallbacks {
activateSearchMode?: () => void;
editActiveConversation?: () => void;
onSearchActivated?: () => void;
deleteActiveConversation?: () => void;
}
export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
function handleKeydown(event: KeyboardEvent) {
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
if (isCtrlOrCmd && event.key === KeyboardKey.K_LOWER) {
event.preventDefault();
callbacks.activateSearchMode?.();
callbacks.onSearchActivated?.();
}
if (isCtrlOrCmd && event.shiftKey && event.key === KeyboardKey.O_UPPER) {
event.preventDefault();
goto('?new_chat=true#/');
}
if (event.shiftKey && isCtrlOrCmd && event.key === KeyboardKey.E_UPPER) {
event.preventDefault();
callbacks.editActiveConversation?.();
}
if (
isCtrlOrCmd &&
event.shiftKey &&
(event.key === KeyboardKey.D_LOWER || event.key === KeyboardKey.D_UPPER)
) {
event.preventDefault();
callbacks.deleteActiveConversation?.();
}
}
return { handleKeydown };
}

Some files were not shown because too many files have changed in this diff Show More