This commit is contained in:
Aleksander Grygier 2026-04-03 15:08:48 +02:00 committed by GitHub
commit 8f8cb1609d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
87 changed files with 3667 additions and 1365 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -18,7 +18,7 @@
<div style="display: contents">
<script>
{
__sveltekit_1trm5n9 = {
__sveltekit_e2p3su = {
base: new URL('.', location).pathname.slice(0, -1)
};

File diff suppressed because it is too large Load Diff

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);

View File

@ -13,6 +13,7 @@
disabled?: boolean;
onclick: (e?: MouseEvent) => void;
'aria-label'?: string;
tooltipSide?: 'top' | 'right' | 'bottom' | 'left';
}
let {
@ -23,6 +24,7 @@
class: className = '',
disabled = false,
iconSize = 'h-3 w-3',
tooltipSide = 'top',
onclick,
'aria-label': ariaLabel
}: Props = $props();
@ -35,7 +37,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 +46,7 @@
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Content side={tooltipSide}>
<p>{tooltip}</p>
</Tooltip.Content>
</Tooltip.Root>

View File

@ -1,17 +1,28 @@
<script lang="ts">
import { page } from '$app/state';
import { Plus, MessageSquare, Settings, Zap, FolderOpen } from '@lucide/svelte';
import {
Plus,
MessageSquare,
Zap,
FolderOpen,
PencilRuler,
ChevronDown,
ChevronRight,
Loader2
} from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
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 { 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 { toolsStore, type ToolGroup } from '$lib/stores/tools.svelte';
import { ToolSource } from '$lib/enums';
import { HealthCheckStatus } from '$lib/enums';
import type { MCPServerSettingsEntry } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
class?: string;
@ -23,7 +34,6 @@
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
}
@ -37,7 +47,6 @@
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
}: Props = $props();
@ -51,54 +60,77 @@
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);
});
});
let expandedGroups = new SvelteSet<string>();
let groups = $derived(toolsStore.toolGroups);
let activeGroups = $derived(
groups.filter(
(g) =>
g.source !== ToolSource.MCP ||
!g.serverId ||
conversationsStore.isMcpServerEnabledForChat(g.serverId)
)
);
let totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
function isGroupDisabled(group: ToolGroup): boolean {
return (
group.source === ToolSource.MCP &&
!!group.serverId &&
!conversationsStore.isMcpServerEnabledForChat(group.serverId)
);
}
let hoveredGroup = $state<string | null>(null);
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
function getGroupCheckedState(group: (typeof groups)[number]): {
checked: boolean;
indeterminate: boolean;
} {
return {
checked: toolsStore.isGroupFullyEnabled(group),
indeterminate: toolsStore.isGroupPartiallyEnabled(group)
};
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
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 getEnabledToolCount(group: ToolGroup): number {
if (isGroupDisabled(group)) return 0;
return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.function.name)).length;
}
function handleToolsSubMenuOpen(open: boolean) {
if (open) {
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
toolsStore.fetchBuiltinTools();
}
mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled));
}
}
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}">
@ -215,6 +247,8 @@
</Tooltip.Root>
{/if}
<DropdownMenu.Separator />
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger class="w-full">
<DropdownMenu.Item
@ -232,78 +266,159 @@
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenu.Separator />
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
<DropdownMenu.Sub onOpenChange={handleToolsSubMenuOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class="h-4 w-4" />
<PencilRuler class="h-4 w-4" />
<span>MCP Servers</span>
<span>Tools</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)}
<DropdownMenu.SubContent class="w-72 p-0">
{#if totalToolCount === 0 && groups.length === 0}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
{#if toolsStore.loading}
<Loader2 class="mx-auto mb-1 h-4 w-4 animate-spin" />
Loading tools...
{:else if toolsStore.error}
Failed to load tools
{:else}
No tools available
{/if}
</div>
{:else}
<div class="max-h-80 overflow-y-auto p-2 pr-1">
{#each groups as group (group.label)}
{@const groupDisabled = isGroupDisabled(group)}
{@const isExpanded = expandedGroups.has(group.label)}
{@const { checked, indeterminate } = groupDisabled
? { checked: false, indeterminate: false }
: getGroupCheckedState(group)}
{@const favicon = getFavicon(group)}
<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}
<Collapsible.Root
open={isExpanded}
onOpenChange={() => {
if (expandedGroups.has(group.label)) {
expandedGroups.delete(group.label);
} else {
expandedGroups.add(group.label);
}
}}
>
<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}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-1"
onmouseenter={() => {
if (groupDisabled) hoveredGroup = group.label;
}}
onmouseleave={() => {
if (hoveredGroup === group.label) hoveredGroup = null;
}}
>
<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 {groupDisabled
? 'opacity-40'
: ''}"
>
{#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="truncate text-sm">{getServerLabel(server)}</span>
<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}
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{getEnabledToolCount(group)}/{group.tools.length}
</span>
</Collapsible.Trigger>
{#if groupDisabled && hoveredGroup === group.label && group.serverId}
<Tooltip.Root>
<Tooltip.Trigger>
<Switch
checked={false}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() =>
group.serverId && toggleServerForChat(group.serverId)}
class="mr-2 shrink-0"
/>
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Enable {group.label}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
<Checkbox
{checked}
{indeterminate}
disabled={groupDisabled}
onCheckedChange={() => toolsStore.toggleGroup(group)}
class="mr-2 h-4 w-4 shrink-0 {groupDisabled ? 'opacity-40' : ''}"
/>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checked ? 'Disable' : 'Enable'}
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
</button>
<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 {groupDisabled
? 'pointer-events-none opacity-40'
: 'hover:bg-muted/50'}"
onclick={() =>
!groupDisabled && toolsStore.toggleTool(tool.function.name)}
>
<Checkbox
checked={groupDisabled
? false
: toolsStore.isToolEnabled(tool.function.name)}
disabled={groupDisabled}
onCheckedChange={() =>
!groupDisabled && 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>
{#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>
<!-- <div class="px-3 py-2 text-xs font-medium text-muted-foreground">
{enabledToolCount}/{totalToolCount} tools enabled
</div> -->
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>

View File

@ -1,9 +1,29 @@
<script lang="ts">
import { Plus, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import { page } from '$app/state';
import {
Plus,
MessageSquare,
Zap,
FolderOpen,
PencilRuler,
ChevronDown,
ChevronRight,
Loader2
} from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Switch } from '$lib/components/ui/switch';
import * as Sheet from '$lib/components/ui/sheet';
import { FILE_TYPE_ICONS } from '$lib/constants';
import { McpLogo } from '$lib/components/app';
import { FILE_TYPE_ICONS, TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { toolsStore, type ToolGroup } from '$lib/stores/tools.svelte';
import { ToolSource } from '$lib/enums';
import { SvelteSet } from 'svelte/reactivity';
import { TruncatedText } from '$lib/components/app';
interface Props {
class?: string;
@ -15,7 +35,6 @@
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
}
@ -29,21 +48,71 @@
onFileUpload,
onSystemPromptClick,
onMcpPromptClick,
onMcpSettingsClick,
onMcpResourcesClick
}: Props = $props();
let sheetOpen = $state(false);
let expandedGroups = new SvelteSet<string>();
let groups = $derived(toolsStore.toolGroups);
let activeGroups = $derived(
groups.filter(
(g) =>
g.source !== ToolSource.MCP ||
!g.serverId ||
conversationsStore.isMcpServerEnabledForChat(g.serverId)
)
);
let totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
function isGroupDisabled(group: ToolGroup): boolean {
return (
group.source === ToolSource.MCP &&
!!group.serverId &&
!conversationsStore.isMcpServerEnabledForChat(group.serverId)
);
}
let hoveredGroup = $state<string | null>(null);
function getGroupCheckedState(group: (typeof groups)[number]): {
checked: boolean;
indeterminate: boolean;
} {
return {
checked: toolsStore.isGroupFullyEnabled(group),
indeterminate: toolsStore.isGroupPartiallyEnabled(group)
};
}
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 handleToolsSubMenuOpen(open: boolean) {
if (open) {
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
toolsStore.fetchBuiltinTools();
}
mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled));
}
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
function handleMcpPromptClick() {
sheetOpen = false;
onMcpPromptClick?.();
}
function handleMcpSettingsClick() {
onMcpSettingsClick?.();
}
function handleMcpResourcesClick() {
sheetOpen = false;
onMcpResourcesClick?.();
@ -79,7 +148,7 @@
<Plus class="h-4 w-4" />
</Button>
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0">
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
<Sheet.Header>
<Sheet.Title>Add to chat</Sheet.Title>
@ -88,38 +157,50 @@
</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" />
<div class="flex flex-col gap-1 px-1.5 pb-2">
{#if hasVisionModality}
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
<span>Images</span>
<span>Images</span>
</button>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
{#if !hasVisionModality}
<span class="ml-auto text-xs text-muted-foreground">Requires vision model</span>
{/if}
</button>
<span>Images</span>
</button>
</Tooltip.Trigger>
<!-- Audio -->
<button
type="button"
class={sheetItemClass}
disabled={!hasAudioModality}
onclick={handleSheetFileUpload}
>
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
<Tooltip.Content side="right">
<p>Image processing requires a vision model</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<span>Audio Files</span>
{#if hasAudioModality}
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
{#if !hasAudioModality}
<span class="ml-auto text-xs text-muted-foreground">Requires audio model</span>
{/if}
</button>
<span>Audio Files</span>
</button>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
<span>Audio Files</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>Audio files processing requires an audio model</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.text class="h-4 w-4 shrink-0" />
@ -127,27 +208,197 @@
<span>Text Files</span>
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
{#if hasVisionModality}
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
<span>PDF Files</span>
<span>PDF Files</span>
</button>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
{#if !hasVisionModality}
<span class="ml-auto text-xs text-muted-foreground">Text-only</span>
{/if}
<span>PDF Files</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}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} onclick={handleSheetSystemPromptClick}>
<MessageSquare class="h-4 w-4 shrink-0" />
<span>System Message</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{#if !page.params.id}
Add custom system message for a new conversation
{:else}
Inject custom system message at the beginning of the conversation
{/if}
</p>
</Tooltip.Content>
</Tooltip.Root>
<div class="my-2 border-t"></div>
<button type="button" class={sheetItemClass} onclick={() => handleToolsSubMenuOpen(true)}>
<PencilRuler class="h-4 w-4 shrink-0" />
<span>Tools</span>
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetSystemPromptClick}>
<MessageSquare class="h-4 w-4 shrink-0" />
{#if totalToolCount === 0 && groups.length === 0}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
{#if toolsStore.loading}
<Loader2 class="mx-auto mb-1 h-4 w-4 animate-spin" />
Loading tools...
{:else if toolsStore.error}
Failed to load tools
{:else}
No tools available
{/if}
</div>
{:else}
<div class="max-h-80 overflow-y-auto p-2 pr-1">
{#each groups as group (group.label)}
{@const groupDisabled = isGroupDisabled(group)}
{@const isExpanded = expandedGroups.has(group.label)}
{@const { checked, indeterminate } = groupDisabled
? { checked: false, indeterminate: false }
: getGroupCheckedState(group)}
{@const favicon = getFavicon(group)}
<span>System Message</span>
</button>
<Collapsible.Root
open={isExpanded}
onOpenChange={() => {
if (expandedGroups.has(group.label)) {
expandedGroups.delete(group.label);
} else {
expandedGroups.add(group.label);
}
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-1"
onmouseenter={() => {
if (groupDisabled) hoveredGroup = group.label;
}}
onmouseleave={() => {
if (hoveredGroup === group.label) hoveredGroup = null;
}}
>
<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 {groupDisabled
? 'opacity-40'
: ''}"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
{:else}
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<button type="button" class={sheetItemClass} onclick={handleMcpSettingsClick}>
<McpLogo class="h-4 w-4 shrink-0" />
<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>MCP Servers</span>
</button>
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{group.tools.length}
</span>
</Collapsible.Trigger>
{#if groupDisabled && hoveredGroup === group.label && group.serverId}
<Tooltip.Root>
<Tooltip.Trigger>
<Switch
checked={false}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() =>
group.serverId && toggleServerForChat(group.serverId)}
class="mr-2 shrink-0"
/>
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Enable {group.label}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
<Checkbox
{checked}
{indeterminate}
disabled={groupDisabled}
onCheckedChange={() => toolsStore.toggleGroup(group)}
class="mr-2 h-4 w-4 shrink-0 {groupDisabled ? 'opacity-40' : ''}"
/>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checked ? 'Disable' : 'Enable'}
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</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 text-left text-sm transition-colors {groupDisabled
? 'pointer-events-none opacity-40'
: 'hover:bg-muted/50'}"
onclick={() => !groupDisabled && toolsStore.toggleTool(tool.function.name)}
>
<Checkbox
checked={groupDisabled
? false
: toolsStore.isToolEnabled(tool.function.name)}
disabled={groupDisabled}
onCheckedChange={() =>
!groupDisabled && toolsStore.toggleTool(tool.function.name)}
class="h-4 w-4 shrink-0"
/>
<TruncatedText
text={tool.function.name}
class="min-w-0 flex-1 truncate"
showTooltip={true}
/>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/each}
</div>
{/if}
{#if hasMcpPromptsSupport}
<button type="button" class={sheetItemClass} onclick={handleMcpPromptClick}>

View File

@ -6,21 +6,20 @@
ChatFormActionAttachmentsSheet,
ChatFormActionRecord,
ChatFormActionSubmit,
McpServersSelector,
McpActiveServersAvatars,
ModelsSelector,
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;
@ -169,8 +168,6 @@
selectorModelRef?.open();
}
const chatSettingsDialog = getChatSettingsDialogContext();
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
@ -197,7 +194,6 @@
{onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
onMcpSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
/>
{:else}
<ChatFormActionAttachmentsDropdown
@ -210,17 +206,13 @@
{onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
onMcpSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.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">
<McpActiveServersAvatars onClick={() => goto('/#/settings/mcp')} />
{#if isMobile.current}
<ModelsSelectorSheet
disabled={disabled || isOffline}

View File

@ -2,7 +2,6 @@
import { afterNavigate } from '$app/navigation';
import {
ChatScreenForm,
ChatScreenHeader,
ChatMessages,
ChatScreenProcessingInfo,
DialogEmptyFileAlert,
@ -34,7 +33,7 @@
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';
@ -342,9 +341,10 @@
<svelte:window onkeydown={handleKeydown} />
<ChatScreenHeader />
{#if !isEmpty}
{#if isServerLoading}
<!-- Server Loading State -->
<ServerLoadingSplash />
{:else}
<div
bind:this={chatScrollContainer}
aria-label="Chat interface with file drop zone"
@ -357,25 +357,40 @@
role="main"
>
<div class="flex flex-col">
<ChatMessages
class="mb-16 md:mb-24"
messages={activeMessages()}
onUserAction={() => {
autoScroll.enable();
autoScroll.scrollToBottom();
}}
/>
{#if !isEmpty}
<ChatMessages
class="mb-16 md:mb-24"
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-0 left-0 my-auto transition-all duration-200"
>
{#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}
<ChatScreenProcessingInfo />
{#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,10 +427,7 @@
</div>
</div>
</div>
{:else if isServerLoading}
<!-- Server Loading State -->
<ServerLoadingSplash />
{:else}
<!-- {:else}
<div
aria-label="Welcome screen with file drop zone"
class="flex h-full items-center justify-center"
@ -425,56 +437,58 @@
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>
{#key introKey}
<div class="w-full max-w-[48rem]">
<div class="mb-10 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 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>
<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}
<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
/>
{#if hasPropsError}
<div class="mb-4" use:fadeInView={{ duration: 250, y: 10 }}>
<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>
<ChatScreenForm
disabled={hasPropsError}
{initialMessage}
isLoading={isCurrentConversationLoading}
onFileRemove={handleFileRemove}
onFileUpload={handleFileUpload}
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={handleSystemPromptAdd}
showHelperText
bind:uploadedFiles
/>
</div>
</div>
</div>
</div>
{/key}
</div> -->
{/if}
<!-- File Upload Error Alert Dialog -->
@ -575,21 +589,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>

View File

@ -11,7 +11,7 @@
<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)]'
: ''}"
: ''} {sidebar.isResizing ? '!duration-0' : ''}"
>
<div class="pointer-events-auto flex items-center space-x-2">
<Button

View File

@ -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,
@ -101,6 +97,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',
@ -133,13 +134,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
}
]
@ -257,33 +258,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
}
]
},
@ -442,119 +428,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-12">
<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>

View File

@ -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}

View File

@ -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" />

View File

@ -1,11 +1,12 @@
<script lang="ts">
import { Download, Upload, Trash2 } from '@lucide/svelte';
import { Download, Upload, Trash2, Database } from '@lucide/svelte';
import { Button } 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';
let exportedConversations = $state<DatabaseConversation[]>([]);
let importedConversations = $state<DatabaseConversation[]>([]);
@ -174,10 +175,16 @@
}
</script>
<div class="space-y-6">
<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" />
<h1 class="text-xl font-semibold md:text-2xl">Import / Export</h1>
</div>
<div class="space-y-4">
<div class="grid">
<h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
<h4 class="mt-0 mb-2 text-sm font-medium">Export Conversations</h4>
<p class="mb-4 text-sm text-muted-foreground">
Download all your conversations as a JSON file. This includes all messages, attachments, and
@ -218,7 +225,7 @@
</div>
<div class="grid border-t border-border/30 pt-4">
<h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
<h4 class="mt-0 mb-2 text-sm font-medium">Import Conversations</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
@ -258,7 +265,7 @@
</div>
<div class="grid border-t border-border/30 pt-4">
<h4 class="mb-2 text-sm font-medium text-destructive">Delete All Conversations</h4>
<h4 class="mt-0 mb-2 text-sm font-medium text-destructive">Delete All Conversations</h4>
<p class="mb-4 text-sm text-muted-foreground">
Permanently delete all conversations and their messages. This action cannot be undone.

View File

@ -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,8 @@
import { chatStore } from '$lib/stores/chat.svelte';
import { getPreviewText } from '$lib/utils';
import ChatSidebarActions from './ChatSidebarActions.svelte';
import ChatSidebarFooter from './ChatSidebarFooter.svelte';
import { APP_NAME } from '$lib/constants';
const sidebar = Sidebar.useSidebar();
@ -107,10 +110,19 @@
}
}
let chatSidebarActions: { activateSearch?: () => void } | undefined = $state();
export function activateSearchMode() {
isSearchModeActive = true;
chatSidebarActions?.activateSearch?.();
}
$effect(() => {
if (!sidebar.open) {
isSearchModeActive = false;
searchQuery = '';
}
});
export function editActiveConversation() {
if (currentChatId) {
const activeConversation = filteredConversations.find((conv) => conv.id === currentChatId);
@ -130,6 +142,7 @@
searchQuery = '';
}
handleMobileSidebarItemClick();
await goto(`#/chat/${id}`);
}
@ -138,60 +151,85 @@
}
</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=" sticky top-0 z-10 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
/>
</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' : '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}
{handleMobileSidebarItemClick}
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>
<Sidebar.Footer class="sticky bottom-0 z-10 mt-2 bg-sidebar/50 p-3 backdrop-blur-lg">
<ChatSidebarFooter />
</Sidebar.Footer>
</ScrollArea>
</div>
<DialogConfirmation
bind:open={showDeleteDialog}

View File

@ -1,61 +1,50 @@
<script lang="ts">
import { Search, SquarePen, X } from '@lucide/svelte';
import { Search, SquarePen } 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 { SearchInput } from '$lib/components/app';
interface Props {
handleMobileSidebarItemClick: () => void;
isSearchModeActive: boolean;
searchQuery: string;
isCancelAlwaysVisible?: boolean;
}
let {
handleMobileSidebarItemClick,
isSearchModeActive = $bindable(),
searchQuery = $bindable()
searchQuery = $bindable(),
isCancelAlwaysVisible = false
}: Props = $props();
let searchInput: HTMLInputElement | null = $state(null);
const chatSettingsDialog = getChatSettingsDialogContext();
let searchInputRef = $state<HTMLInputElement | null>(null);
function handleSearchModeDeactivate() {
isSearchModeActive = false;
searchQuery = '';
}
$effect(() => {
if (isSearchModeActive) {
searchInput?.focus();
}
});
export function activateSearch() {
isSearchModeActive = true;
// Focus after Svelte renders the input
queueMicrotask(() => searchInputRef?.focus());
}
</script>
<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"
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100"
href="?new_chat=true#/"
onclick={handleMobileSidebarItemClick}
variant="ghost"
@ -70,10 +59,8 @@
</Button>
<Button
class="w-full justify-between backdrop-blur-none! hover:[&>kbd]:opacity-100"
onclick={() => {
isSearchModeActive = true;
}}
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">
@ -84,19 +71,5 @@
<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}
</div>

View File

@ -0,0 +1,70 @@
<script lang="ts">
import { Database, Settings } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { McpLogo } from '$lib/components/app';
// import { getChatSettingsDialogContext, getMcpServersDialogContext } from '$lib/contexts';
import { page } from '$app/state';
import * as Sidebar from '$lib/components/ui/sidebar';
// const chatSettingsDialog = getChatSettingsDialogContext();
// const mcpServersDialog = getMcpServersDialogContext();
const sidebar = Sidebar.useSidebar();
let isMcpActive = $derived(page.route.id === '/settings/mcp');
let isSettingsActive = $derived(page.route.id === '/settings/chat');
let isImportExportActive = $derived(page.route.id === '/settings/import-export');
function handleMobileSidebarItemClick() {
if (sidebar.isMobile) {
sidebar.toggle();
}
}
</script>
<div class="space-y-1 pt-0">
<Button
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {isMcpActive
? 'bg-accent text-accent-foreground'
: ''}"
href="#/settings/mcp"
onclick={handleMobileSidebarItemClick}
variant="ghost"
>
<div class="flex items-center gap-2">
<McpLogo class="h-4 w-4" />
MCP Servers
</div>
</Button>
<Button
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {isImportExportActive
? 'bg-accent text-accent-foreground'
: ''}"
href="#/settings/import-export"
onclick={handleMobileSidebarItemClick}
variant="ghost"
>
<div class="flex items-center gap-2">
<Database class="h-4 w-4" />
Import / Export
</div>
</Button>
<Button
class="w-full justify-between px-2 backdrop-blur-none! hover:[&>kbd]:opacity-100 {isSettingsActive
? 'bg-accent text-accent-foreground'
: ''}"
href="#/settings/chat"
onclick={handleMobileSidebarItemClick}
variant="ghost"
>
<div class="flex items-center gap-2">
<Settings class="h-4 w-4" />
Settings
</div>
</Button>
</div>

View File

@ -683,7 +683,7 @@ export { default as ChatScreenProcessingInfo } from './ChatScreen/ChatScreenProc
* />
* ```
*/
export { default as ChatSettings } from './ChatSettings/ChatSettings.svelte';
export { default as ChatSettings } from '../settings/SettingsChat.svelte';
/**
* Footer with save/cancel buttons for settings panel. Positioned at bottom
@ -704,7 +704,7 @@ export { default as ChatSettingsFields } from './ChatSettings/ChatSettingsFields
* 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';
export { default as ChatSettingsImportExportTab } from '../settings/SettingsImportExport.svelte';
/**
* Badge indicating parameter source for sampling settings. Shows one of:

View File

@ -4,12 +4,13 @@
import type { SettingsSectionTitle } from '$lib/constants';
interface Props {
class?: string;
onOpenChange?: (open: boolean) => void;
open?: boolean;
initialSection?: SettingsSectionTitle;
}
let { onOpenChange, open = false, initialSection }: Props = $props();
let { class: className = '', onOpenChange, open = false, initialSection }: Props = $props();
let chatSettingsRef: ChatSettings | undefined = $state();
@ -31,7 +32,7 @@
<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"
p-0 md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg {className}"
>
<ChatSettings bind:this={chatSettingsRef} onSave={handleSave} {initialSection} />
</Dialog.Content>

View File

@ -0,0 +1,33 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { ChatSettingsImportExportTab } from '$lib/components/app/chat';
interface Props {
onOpenChange?: (open: boolean) => void;
open?: boolean;
}
let { onOpenChange, open = 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] max-w-4xl! flex-col gap-0 rounded-none
p-0 md:h-[80vh] md:max-h-[80vh] md:min-h-0 md:rounded-lg"
>
<div class="grid gap-2 border-b border-border/30 p-4 md:p-6">
<Dialog.Title class="text-lg font-semibold">Import/Export Conversations</Dialog.Title>
<Dialog.Description class="text-sm text-muted-foreground">
Export your conversations to a JSON file or import previously exported conversations.
</Dialog.Description>
</div>
<div class="flex-1 overflow-y-auto px-4 py-6">
<ChatSettingsImportExportTab />
</div>
</Dialog.Content>
</Dialog.Root>

View File

@ -7,7 +7,7 @@
open?: boolean;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
let { onOpenChange, open = false }: Props = $props();
function handleClose() {
onOpenChange?.(false);

View File

@ -36,6 +36,50 @@
*/
export { default as DialogChatSettings } from './DialogChatSettings.svelte';
/**
* **DialogMcpServersSettings** - MCP servers management dialog
*
* Modal dialog for managing MCP servers with dedicated context.
*
* **Architecture:**
* - Uses context-based state management
* - Provides `open()` method via context
* - Contains McpServersSettings component
*
* @example
* ```svelte
* <!-- In parent component -->
* <DialogMcpServersSettings />
*
* <!-- Trigger via context -->
* {#const mcpDialog = getMcpServersDialogContext()}
* <Button onclick={() => mcpDialog.open()}>Manage MCP Servers</Button>
* ```
*/
export { default as DialogMcpServersSettings } from './DialogMcpServersSettings.svelte';
/**
* **DialogChatSettingsImportExport** - Import/Export conversations dialog
*
* Modal dialog for importing and exporting conversations with dedicated context.
*
* **Architecture:**
* - Uses context-based state management
* - Provides `open()` method via context
* - Contains ChatSettingsImportExportTab component
*
* @example
* ```svelte
* <!-- In parent component -->
* <DialogChatSettingsImportExport />
*
* <!-- Trigger via context -->
* {#const importExportDialog = getImportExportDialogContext()}
* <Button onclick={() => importExportDialog.open()}>Import/Export</Button>
* ```
*/
export { default as DialogChatSettingsImportExport } from './DialogChatSettingsImportExport.svelte';
/**
*
* CONFIRMATION DIALOGS

View File

@ -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'}
>

View File

@ -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';

View File

@ -1,5 +1,6 @@
<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';
@ -7,9 +8,10 @@
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 +30,41 @@
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)}>
<button class={cn('inline-flex items-center gap-1.5', 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}

View File

@ -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]">

View File

@ -2,6 +2,7 @@
import { Settings } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Switch } from '$lib/components/ui/switch';
import * as Tooltip from '$lib/components/ui/tooltip';
import { DropdownMenuSearchable, McpActiveServersAvatars } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
@ -66,7 +67,7 @@
}
</script>
{#if hasMcpServers && hasEnabledMcpServers && mcpFavicons.length > 0}
{#if hasMcpServers}
<DropdownMenu.Root
onOpenChange={(open) => {
if (!open) {
@ -84,11 +85,13 @@
>
<button
type="button"
class="inline-flex cursor-pointer items-center rounded-sm py-1 disabled:cursor-not-allowed disabled:opacity-60"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm py-1 disabled:cursor-not-allowed disabled:opacity-60"
{disabled}
aria-label="MCP Servers"
>
<McpActiveServersAvatars class={className} />
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
<McpActiveServersAvatars class={className} />
{/if}
</button>
</DropdownMenu.Trigger>
@ -134,12 +137,19 @@
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
<Tooltip.Root>
<Tooltip.Trigger>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{isEnabledForChat ? 'Disable' : 'Enable'} {getServerLabel(server)}</p>
</Tooltip.Content>
</Tooltip.Root>
</button>
{/each}
</div>

View File

@ -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>

View File

@ -0,0 +1,179 @@
<script lang="ts">
import { Settings } from '@lucide/svelte';
import * as Sheet from '$lib/components/ui/sheet';
import { Switch } from '$lib/components/ui/switch';
import { McpActiveServersAvatars, SearchInput } 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;
onSettingsClick?: () => void;
}
let { class: className = '', disabled = false, onSettingsClick }: Props = $props();
let searchQuery = $state('');
let mcpServers = $derived(mcpStore.getServersSorted().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 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;
});
let sheetOpen = $state(false);
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
}
function handleOpenChange(open: boolean) {
if (open) {
sheetOpen = true;
searchQuery = '';
mcpStore.runHealthChecksForServers(mcpServers);
} else {
sheetOpen = false;
searchQuery = '';
}
}
function handleSheetOpenChange(open: boolean) {
if (!open) {
handleOpenChange(false);
}
}
export function open() {
handleOpenChange(true);
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
</script>
{#if hasMcpServers}
<button
type="button"
class="inline-flex cursor-pointer items-center gap-1.5 rounded-sm py-1 disabled:cursor-not-allowed disabled:opacity-60"
{disabled}
aria-label="MCP Servers"
onclick={() => handleOpenChange(true)}
>
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
<McpActiveServersAvatars class={className} />
{/if}
</button>
<Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}>
<Sheet.Content side="bottom" class="max-h-[85vh] gap-1">
<Sheet.Header>
<Sheet.Title>MCP Servers</Sheet.Title>
<Sheet.Description class="sr-only">
Toggle MCP servers for the current conversation
</Sheet.Description>
</Sheet.Header>
<div class="flex flex-col gap-1 pb-4">
<div class="mb-3 px-4">
<SearchInput placeholder="Search servers..." bind:value={searchQuery} />
</div>
<div class="max-h-[60vh] overflow-y-auto px-2">
{#if filteredMcpServers.length === 0}
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No servers found.</p>
{/if}
{#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-md px-3 py-2.5 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>
<div class="mt-2 border-t px-2 pt-2">
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-2.5 text-sm transition-colors hover:bg-accent"
onclick={() => {
handleOpenChange(false);
onSettingsClick?.();
}}
>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</button>
</div>
</div>
</Sheet.Content>
</Sheet.Root>
{/if}

View File

@ -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
@ -96,6 +96,21 @@ export { default as McpActiveServersAvatars } from './McpActiveServersAvatars.sv
*/
export { default as McpServersSelector } from './McpServersSelector.svelte';
/**
* **McpServersSheet** - Mobile MCP server toggle sheet
*
* Bottom sheet variant of McpServersSelector for mobile devices.
* Uses Sheet UI instead of dropdown for better touch interaction.
*
* @example
* ```svelte
* <McpServersSheet
* onSettingsClick={() => showMcpSettings = true}
* />
* ```
*/
export { default as McpServersSheet } from './McpServersSheet.svelte';
/**
* **McpCapabilitiesBadges** - Server capabilities display
*

View File

@ -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>

View File

@ -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>

View File

@ -256,11 +256,11 @@
'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>
@ -282,7 +282,7 @@
: 'text-muted-foreground',
isOpen ? 'text-foreground' : ''
)}
style="max-width: min(calc(100cqw - 9rem), 20rem)"
style="max-width: min(calc(100cqw - 9rem), 25rem)"
disabled={disabled || updating}
>
<Package class="h-3.5 w-3.5" />
@ -295,7 +295,7 @@
<ModelId
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
showOrgName
hideOrgName={false}
{...props}
/>
{/snippet}
@ -338,7 +338,7 @@
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>
@ -348,7 +348,7 @@
<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 isHighlighted = flatIndex === highlightedIndex}
@ -359,7 +359,7 @@
{isSelected}
{isHighlighted}
{isFav}
{showOrgName}
{hideOrgName}
onSelect={handleSelect}
onInfoClick={handleInfoClick}
onMouseEnter={() => (highlightedIndex = flatIndex)}
@ -412,7 +412,7 @@
<ModelId
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
showOrgName
hideOrgName={false}
{...props}
/>
{/snippet}

View File

@ -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}

View File

@ -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"

View File

@ -14,6 +14,7 @@
import { isRouterMode } from '$lib/stores/server.svelte';
import {
DialogModelInformation,
ModelId,
ModelsSelectorList,
SearchInput,
TruncatedText
@ -233,7 +234,16 @@
>
<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}
<Loader2 class="h-3 w-3.5 animate-spin" />

View File

@ -0,0 +1,136 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ActionIcon } from '$lib/components/app/actions';
import { McpLogo } from '$lib/components/app/mcp';
import { Database, Settings, Search, SquarePen } from '@lucide/svelte';
import { fade } from 'svelte/transition';
import { circIn } from 'svelte/easing';
import { onMount, type Component } from 'svelte';
interface Props {
sidebarOpen: boolean;
onSearchClick: () => void;
}
let { sidebarOpen, onSearchClick }: Props = $props();
const TRANSITION_DURATION = 250;
const TRANSITION_DELAY_MULTIPLIER = 150;
const TRANSITION_EASING = circIn;
let isMcpActive = $derived(page.route.id === '/settings/mcp');
let isImportExportActive = $derived(page.route.id === '/settings/import-export');
let isSettingsActive = $derived(page.route.id === '/settings/chat');
interface IconItem {
icon: Component;
tooltip: string;
onclick: () => void;
activeClass?: string;
group: 'top' | 'bottom';
}
let icons = $derived<IconItem[]>([
{
icon: SquarePen,
tooltip: 'New Chat',
onclick: () => goto('?new_chat=true#/'),
group: 'top'
},
{
icon: Search,
tooltip: 'Search',
onclick: onSearchClick,
group: 'top'
},
{
icon: McpLogo,
tooltip: 'MCP Servers',
onclick: () => goto('#/settings/mcp'),
activeClass: isMcpActive ? 'bg-accent text-accent-foreground' : '',
group: 'bottom'
},
{
icon: Database,
tooltip: 'Import / Export',
onclick: () => goto('#/settings/import-export'),
activeClass: isImportExportActive ? 'bg-accent text-accent-foreground' : '',
group: 'bottom'
},
{
icon: Settings,
tooltip: 'Settings',
onclick: () => goto('#/settings/chat'),
activeClass: isSettingsActive ? 'bg-accent text-accent-foreground' : '',
group: 'bottom'
}
]);
let topIcons = $derived(icons.filter((i) => i.group === 'top'));
let bottomIcons = $derived(icons.filter((i) => i.group === 'bottom'));
let mounted = $state(false);
onMount(() => (mounted = true));
let showIcons = $derived(mounted && !sidebarOpen);
</script>
<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 topIcons as item, i (item.tooltip)}
{#if showIcons}
<div
in:fade={{
duration: TRANSITION_DURATION,
delay: TRANSITION_DELAY_MULTIPLIER + i * TRANSITION_DELAY_MULTIPLIER,
easing: TRANSITION_EASING
}}
>
<ActionIcon
icon={item.icon}
tooltip={item.tooltip}
tooltipSide="right"
size="lg"
iconSize="h-4 w-4"
class="h-9 w-9 rounded-full hover:bg-accent! {item.activeClass ?? ''}"
onclick={item.onclick}
/>
</div>
{/if}
{/each}
</div>
<div class="flex flex-col items-center gap-1">
{#each bottomIcons as item, i (item.tooltip)}
{#if showIcons}
<div
in:fade={{
duration: TRANSITION_DURATION,
delay:
TRANSITION_DELAY_MULTIPLIER + (topIcons.length + i) * TRANSITION_DELAY_MULTIPLIER,
easing: TRANSITION_EASING
}}
>
<ActionIcon
icon={item.icon}
tooltip={item.tooltip}
size="lg"
iconSize="h-4 w-4"
class="h-9 w-9 rounded-full hover:bg-accent! {item.activeClass ?? ''}"
onclick={item.onclick}
/>
</div>
{/if}
{/each}
</div>
</aside>

View File

@ -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';

View File

@ -0,0 +1,543 @@
<script lang="ts">
import {
Settings,
Funnel,
AlertTriangle,
Code,
Monitor,
ChevronLeft,
ChevronRight,
ListRestart,
Sliders
} from '@lucide/svelte';
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import {
SETTINGS_SECTION_TITLES,
type SettingsSectionTitle,
NUMERIC_FIELDS,
POSITIVE_INTEGER_FIELDS,
SETTINGS_COLOR_MODES_CONFIG,
SETTINGS_KEYS
} from '$lib/constants';
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 { class: className, onSave, initialSection }: Props = $props();
const settingSections: Array<{
fields: SettingsFieldConfig[];
icon: Component;
title: SettingsSectionTitle;
}> = [
{
title: SETTINGS_SECTION_TITLES.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,
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,
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,
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,
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.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
}
]
}
// TODO: Experimental features section will be implemented after initial release
// This includes Python interpreter (Pyodide integration) and other experimental features
// {
// title: 'Experimental',
// icon: Beaker,
// fields: [
// {
// key: 'pyInterpreterEnabled',
// label: 'Enable Python interpreter',
// type: 'checkbox'
// }
// ]
// }
];
let activeSection = $derived<SettingsSectionTitle>(
initialSection ?? SETTINGS_SECTION_TITLES.GENERAL
);
let currentSection = $derived(
settingSections.find((section) => section.title === activeSection) || settingSections[0]
);
let localConfig: SettingsConfigType = $state({ ...config() });
let canScrollLeft = $state(false);
let canScrollRight = $state(false);
let scrollContainer: HTMLDivElement | undefined = $state();
$effect(() => {
if (initialSection) {
activeSection = initialSection;
}
});
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);
}
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;
}
}
// Convert numeric strings to numbers for numeric fields
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);
onSave?.();
}
function scrollToCenter(element: HTMLElement) {
if (!scrollContainer) return;
const containerRect = scrollContainer.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const elementCenter = elementRect.left + elementRect.width / 2;
const containerCenter = containerRect.left + containerRect.width / 2;
const scrollOffset = elementCenter - containerCenter;
scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
}
function scrollLeft() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
}
function scrollRight() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
}
function updateScrollButtons() {
if (!scrollContainer) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
canScrollLeft = scrollLeft > 0;
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
}
export function reset() {
localConfig = { ...config() };
setTimeout(updateScrollButtons, 100);
}
$effect(() => {
if (scrollContainer) {
updateScrollButtons();
}
});
</script>
<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-12">
<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>
<!-- 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" />
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
</div>
<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="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>
</div>
</div>
<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>

View File

@ -0,0 +1,309 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Download, Upload, Trash2, Database } from '@lucide/svelte';
import { Button } 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';
type SectionOpts = {
wrapperClass?: string;
titleClass?: string;
buttonVariant?: 'outline' | 'destructive';
buttonClass?: string;
summary?: { show: boolean; verb: string; items: DatabaseConversation[] };
};
let exportedConversations = $state<DatabaseConversation[]>([]);
let importedConversations = $state<DatabaseConversation[]>([]);
let showExportSummary = $state(false);
let showImportSummary = $state(false);
let showExportDialog = $state(false);
let showImportDialog = $state(false);
let availableConversations = $state<DatabaseConversation[]>([]);
let messageCountMap = $state<Map<string, number>>(new Map());
let fullImportData = $state<Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>>(
[]
);
// Delete functionality state
let showDeleteDialog = $state(false);
async function handleExportClick() {
try {
const allConversations = conversations();
if (allConversations.length === 0) {
toast.info('No conversations to export');
return;
}
const conversationsWithMessages = await Promise.all(
allConversations.map(async (conv: DatabaseConversation) => {
const messages = await conversationsStore.getConversationMessages(conv.id);
return { conv, messages };
})
);
messageCountMap = createMessageCountMap(conversationsWithMessages);
availableConversations = allConversations;
showExportDialog = true;
} catch (err) {
console.error('Failed to load conversations:', err);
alert('Failed to load conversations');
}
}
async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
try {
const allData: ExportedConversations = await Promise.all(
selectedConversations.map(async (conv) => {
const messages = await conversationsStore.getConversationMessages(conv.id);
return { conv: $state.snapshot(conv), messages: $state.snapshot(messages) };
})
);
conversationsStore.downloadConversationFile(
allData,
`${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations.json`
);
exportedConversations = selectedConversations;
showExportSummary = true;
showImportSummary = false;
showExportDialog = false;
} catch (err) {
console.error('Export failed:', err);
alert('Failed to export conversations');
}
}
async function handleImportClick() {
try {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement)?.files?.[0];
if (!file) return;
try {
const text = await file.text();
const parsedData = JSON.parse(text);
let importedData: ExportedConversations;
if (Array.isArray(parsedData)) {
importedData = parsedData;
} else if (
parsedData &&
typeof parsedData === 'object' &&
'conv' in parsedData &&
'messages' in parsedData
) {
// Single conversation object
importedData = [parsedData];
} else {
throw new Error(
'Invalid file format: expected array of conversations or single conversation object'
);
}
fullImportData = importedData;
availableConversations = importedData.map(
(item: { conv: DatabaseConversation; messages: DatabaseMessage[] }) => item.conv
);
messageCountMap = createMessageCountMap(importedData);
showImportDialog = true;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.error('Failed to parse file:', err);
alert(`Failed to parse file: ${message}`);
}
};
input.click();
} catch (err) {
console.error('Import failed:', err);
alert('Failed to import conversations');
}
}
async function handleImportConfirm(selectedConversations: DatabaseConversation[]) {
try {
const selectedIds = new Set(selectedConversations.map((c) => c.id));
const selectedData = $state
.snapshot(fullImportData)
.filter((item) => selectedIds.has(item.conv.id));
await conversationsStore.importConversationsData(selectedData);
importedConversations = selectedConversations;
showImportSummary = true;
showExportSummary = false;
showImportDialog = false;
} catch (err) {
console.error('Import failed:', err);
alert('Failed to import conversations. Please check the file format.');
}
}
async function handleDeleteAllClick() {
try {
const allConversations = conversations();
if (allConversations.length === 0) {
toast.info('No conversations to delete');
return;
}
showDeleteDialog = true;
} catch (err) {
console.error('Failed to load conversations for deletion:', err);
toast.error('Failed to load conversations');
}
}
async function handleDeleteAllConfirm() {
try {
await conversationsStore.deleteAll();
showDeleteDialog = false;
} catch (err) {
console.error('Failed to delete conversations:', err);
}
}
function handleDeleteAllCancel() {
showDeleteDialog = false;
}
</script>
{#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>
<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}
{#if items.length > 10}
<li class="italic">... and {items.length - 10} more</li>
{/if}
</ul>
</div>
{/if}
{/snippet}
{#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">{description}</p>
<Button class={buttonClass} {onclick} variant={buttonVariant}>
<Icon class="mr-2 h-4 w-4" />
{buttonText}
</Button>
{#if opts?.summary}
{@render summaryList(opts.summary.show, opts.summary.verb, opts.summary.items)}
{/if}
</div>
{/snippet}
<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" />
<h1 class="text-xl font-semibold md:text-2xl">Import / Export</h1>
</div>
<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 } }
)}
{@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 }
}
)}
{@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"
bind:open={showExportDialog}
onCancel={() => (showExportDialog = false)}
onConfirm={handleExportConfirm}
/>
<DialogConversationSelection
conversations={availableConversations}
{messageCountMap}
mode="import"
bind:open={showImportDialog}
onCancel={() => (showImportDialog = false)}
onConfirm={handleImportConfirm}
/>
<DialogConfirmation
bind:open={showDeleteDialog}
title="Delete all conversations"
description="Are you sure you want to delete all conversations? This action cannot be undone and will permanently remove all your conversations and messages."
confirmText="Delete All"
cancelText="Cancel"
variant="destructive"
icon={Trash2}
onConfirm={handleDeleteAllConfirm}
onCancel={handleDeleteAllCancel}
/>

View File

@ -0,0 +1,165 @@
<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';
import { fade } from 'svelte/transition';
import McpLogo from '../mcp/McpLogo.svelte';
interface Props {
class?: string;
}
let { class: className }: Props = $props();
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 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:p-0">
<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">
{#if !isAddingServer}
<Button variant="outline" size="sm" class="shrink-0" onclick={showAddServerForm}>
<Plus class="h-4 w-4" />
Add New Server
</Button>
{/if}
</div>
<div class="grid gap-5 md:space-y-4 {className}">
{#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="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 () => await conversationsStore.toggleMcpServerForChat(server.id)}
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
onDelete={() => mcpStore.removeServer(server.id)}
/>
{/if}
{/each}
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,3 @@
export { default as SettingsChat } from './SettingsChat.svelte';
export { default as SettingsImportExport } from './SettingsImportExport.svelte';
export { default as SettingsMcpServers } from './SettingsMcpServers.svelte';

View File

@ -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',

View File

@ -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}

View File

@ -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';

View File

@ -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);
};
}

View File

@ -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?.()}

View File

@ -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',

View File

@ -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

View File

@ -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>

View File

@ -1,8 +1,7 @@
<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';
let {
@ -20,6 +19,40 @@
} = $props();
const sidebar = useSidebar();
function remToPx(rem: string): number {
const val = parseFloat(rem);
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
return val * fontSize;
}
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 +66,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 +80,75 @@
<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)',
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}

View File

@ -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}
/>

View File

@ -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';

View File

@ -1,3 +1,5 @@
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_MCP_SERVERS_DIALOG = 'mcp-servers-dialog';
export const CONTEXT_KEY_IMPORT_EXPORT_DIALOG = 'import-export-dialog';

View File

@ -2,3 +2,4 @@ export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config';
export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = 'LlamaCppWebui.favoriteModels';
export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = 'LlamaCppWebui.mcpDefaultEnabled';
export const DISABLED_TOOLS_LOCALSTORAGE_KEY = 'LlamaCppWebui.disabledTools';

View File

@ -21,7 +21,6 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean |
disableAutoScroll: false,
renderUserContentAsMarkdown: false,
alwaysShowSidebarOnDesktop: false,
autoShowSidebarOnNewChat: true,
autoMicOnEmpty: false,
fullHeightCodeBlocks: false,
showRawModelNames: false,

View File

@ -22,7 +22,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

View File

@ -10,6 +10,7 @@ export const SETTINGS_SECTION_TITLES = {
SAMPLING: 'Sampling',
PENALTIES: 'Penalties',
IMPORT_EXPORT: 'Import/Export',
AGENTIC: 'Agentic',
MCP: 'MCP',
DEVELOPER: 'Developer'
} as const;

View File

@ -1,2 +1,3 @@
export const FORK_TREE_DEPTH_PADDING = 8;
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
export const APP_NAME = 'llama.cpp';

View File

@ -4,6 +4,7 @@ import { CONTEXT_KEY_CHAT_SETTINGS_DIALOG } from '$lib/constants';
export interface ChatSettingsDialogContext {
open: (initialSection?: SettingsSectionTitle) => void;
isActive: () => boolean;
}
const CHAT_SETTINGS_DIALOG_KEY = Symbol.for(CONTEXT_KEY_CHAT_SETTINGS_DIALOG);

View File

@ -0,0 +1,18 @@
import { getContext, setContext } from 'svelte';
import { CONTEXT_KEY_IMPORT_EXPORT_DIALOG } from '$lib/constants';
export interface ImportExportDialogContext {
open: () => void;
}
const IMPORT_EXPORT_DIALOG_KEY = Symbol.for(CONTEXT_KEY_IMPORT_EXPORT_DIALOG);
export function setImportExportDialogContext(
ctx: ImportExportDialogContext
): ImportExportDialogContext {
return setContext(IMPORT_EXPORT_DIALOG_KEY, ctx);
}
export function getImportExportDialogContext(): ImportExportDialogContext {
return getContext(IMPORT_EXPORT_DIALOG_KEY);
}

View File

@ -17,3 +17,15 @@ export {
setChatSettingsDialogContext,
type ChatSettingsDialogContext
} from './chat-settings-dialog.context';
export {
getMcpServersDialogContext,
setMcpServersDialogContext,
type McpServersDialogContext
} from './mcp-servers-dialog.context';
export {
getImportExportDialogContext,
setImportExportDialogContext,
type ImportExportDialogContext
} from './import-export-dialog.context';

View File

@ -0,0 +1,17 @@
import { getContext, setContext } from 'svelte';
import { CONTEXT_KEY_MCP_SERVERS_DIALOG } from '$lib/constants';
export interface McpServersDialogContext {
open: () => void;
isActive: () => boolean;
}
const MCP_SERVERS_DIALOG_KEY = Symbol.for(CONTEXT_KEY_MCP_SERVERS_DIALOG);
export function setMcpServersDialogContext(ctx: McpServersDialogContext): McpServersDialogContext {
return setContext(MCP_SERVERS_DIALOG_KEY, ctx);
}
export function getMcpServersDialogContext(): McpServersDialogContext {
return getContext(MCP_SERVERS_DIALOG_KEY);
}

View File

@ -50,3 +50,5 @@ export { ParameterSource, SyncableParameterType, SettingsFieldType } from './set
export { ColorMode, McpPromptVariant, UrlProtocol } from './ui';
export { KeyboardKey } from './keyboard';
export { ToolSource, ToolResponseField } from './tools';

View File

@ -0,0 +1,10 @@
export enum ToolSource {
BUILTIN = 'builtin',
MCP = 'mcp',
CUSTOM = 'custom'
}
export enum ToolResponseField {
PLAIN_TEXT = 'plain_text_response',
ERROR = 'error'
}

View File

@ -399,9 +399,10 @@ export class ChatService {
try {
const parsed: ApiChatCompletionStreamChunk = JSON.parse(data);
const content = parsed.choices[0]?.delta?.content;
const reasoningContent = parsed.choices[0]?.delta?.reasoning_content;
const toolCalls = parsed.choices[0]?.delta?.tool_calls;
const choice = parsed.choices?.[0];
const content = choice?.delta?.content;
const reasoningContent = choice?.delta?.reasoning_content;
const toolCalls = choice?.delta?.tool_calls;
const timings = parsed.timings;
const promptProgress = parsed.prompt_progress;

View File

@ -191,12 +191,6 @@ export const SYNCABLE_PARAMETERS: SyncableParameter[] = [
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'autoShowSidebarOnNewChat',
serverKey: 'autoShowSidebarOnNewChat',
type: SyncableParameterType.BOOLEAN,
canSync: true
},
{
key: 'showRawModelNames',
serverKey: 'showRawModelNames',

View File

@ -0,0 +1,40 @@
import { apiFetch } from '$lib/utils';
import { API_TOOLS } from '$lib/constants';
import { ToolResponseField } from '$lib/enums';
import type { ToolExecutionResult, ServerBuiltinToolInfo } from '$lib/types';
export class ToolsService {
/**
* Fetch the list of built-in tools from the server.
*
* @returns Array of tool definitions in OpenAI-compatible format
*/
static async list(): Promise<ServerBuiltinToolInfo[]> {
return apiFetch<ServerBuiltinToolInfo[]>(API_TOOLS.LIST);
}
/**
* Execute a built-in tool on the server.
*/
static async executeTool(
toolName: string,
params: Record<string, unknown>,
signal?: AbortSignal
): Promise<ToolExecutionResult> {
const result = await apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
method: 'POST',
body: JSON.stringify({ tool: toolName, params }),
signal
});
if (ToolResponseField.ERROR in result) {
return { content: String(result[ToolResponseField.ERROR]), isError: true };
}
if (ToolResponseField.PLAIN_TEXT in result) {
return { content: String(result[ToolResponseField.PLAIN_TEXT]), isError: false };
}
return { content: JSON.stringify(result), isError: false };
}
}

View File

@ -24,6 +24,9 @@ import { ChatService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { ToolSource } from '$lib/enums';
import { ToolsService } from '$lib/services/tools.service';
import { isAbortError } from '$lib/utils';
import {
DEFAULT_AGENTIC_CONFIG,
@ -186,28 +189,48 @@ class AgenticStore {
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
const maxToolPreviewLines =
Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines;
const hasTools =
mcpStore.hasEnabledServers(perChatOverrides) ||
toolsStore.builtinTools.length > 0 ||
toolsStore.customTools.length > 0;
return {
enabled: mcpStore.hasEnabledServers(perChatOverrides) && DEFAULT_AGENTIC_CONFIG.enabled,
enabled: hasTools && DEFAULT_AGENTIC_CONFIG.enabled,
maxTurns,
maxToolPreviewLines
};
}
private parseToolArguments(args: string | Record<string, unknown>): Record<string, unknown> {
if (typeof args === 'object') return args;
const trimmed = args.trim();
if (trimmed === '') return {};
return JSON.parse(trimmed) as Record<string, unknown>;
}
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
const agenticConfig = this.getConfig(config(), perChatOverrides);
if (!agenticConfig.enabled) return { handled: false };
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
console.log('[AgenticStore] MCP not initialized, falling back to standard chat');
return { handled: false };
const hasMcpServers = mcpStore.hasEnabledServers(perChatOverrides);
if (hasMcpServers) {
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) {
console.log('[AgenticStore] MCP not initialized');
}
}
const tools = mcpStore.getToolDefinitionsForLLM();
// Ensure built-in tools are fetched
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
await toolsStore.fetchBuiltinTools();
}
const tools = toolsStore.getEnabledToolsForLLM();
if (tools.length === 0) {
console.log('[AgenticStore] No tools available, falling back to standard chat');
return { handled: false };
}
@ -235,7 +258,8 @@ class AgenticStore {
totalToolCalls: 0,
lastError: null
});
mcpStore.acquireConnection();
if (hasMcpServers) mcpStore.acquireConnection();
try {
await this.executeAgenticLoop({
@ -255,11 +279,14 @@ class AgenticStore {
return { handled: true, error: normalizedError };
} finally {
this.updateSession(conversationId, { isRunning: false });
await mcpStore
.releaseConnection()
.catch((err: unknown) =>
console.warn('[AgenticStore] Failed to release MCP connection:', err)
);
if (hasMcpServers) {
await mcpStore
.releaseConnection()
.catch((err: unknown) =>
console.warn('[AgenticStore] Failed to release MCP connection:', err)
);
}
}
}
@ -485,17 +512,29 @@ class AgenticStore {
}
const toolStartTime = performance.now();
const mcpCall: MCPToolCall = {
id: toolCall.id,
function: { name: toolCall.function.name, arguments: toolCall.function.arguments }
};
const toolName = toolCall.function.name;
const toolSource = toolsStore.getToolSource(toolName);
let result: string;
let toolSuccess = true;
try {
const executionResult = await mcpStore.executeTool(mcpCall, signal);
result = executionResult.content;
if (toolSource === ToolSource.BUILTIN) {
const args = this.parseToolArguments(toolCall.function.arguments);
const executionResult = await ToolsService.executeTool(toolName, args, signal);
result = executionResult.content;
if (executionResult.isError) toolSuccess = false;
} else {
const mcpCall: MCPToolCall = {
id: toolCall.id,
function: { name: toolName, arguments: toolCall.function.arguments }
};
const executionResult = await mcpStore.executeTool(mcpCall, signal);
result = executionResult.content;
}
} catch (error) {
if (isAbortError(error)) {
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));

View File

@ -765,7 +765,6 @@ class ChatStore {
if (agenticResult.handled) return;
}
// Non-agentic path: direct streaming into the single assistant message
await ChatService.sendMessage(
allMessages,
{

View File

@ -319,6 +319,9 @@ class SettingsStore {
const propsDefaults = this.getServerDefaults();
if (Object.keys(propsDefaults).length === 0) return;
const webuiSettings = serverStore.webuiSettings;
const webuiSettingsKeys = new Set(webuiSettings ? Object.keys(webuiSettings) : []);
for (const [key, propsValue] of Object.entries(propsDefaults)) {
const currentValue = getConfigValue(this.config, key);
@ -328,12 +331,18 @@ class SettingsStore {
// if user value matches server, it's not a real override
if (normalizedCurrent === normalizedDefault) {
this.userOverrides.delete(key);
if (
!webuiSettingsKeys.has(key) &&
getConfigValue(SETTING_CONFIG_DEFAULT, key) === undefined
) {
setConfigValue(this.config, key, undefined);
}
}
}
// webui settings need actual values in config (no placeholder mechanism),
// so write them for non-overridden keys
const webuiSettings = serverStore.webuiSettings;
if (webuiSettings) {
for (const [key, value] of Object.entries(webuiSettings)) {
if (!this.userOverrides.has(key) && value !== undefined) {

View File

@ -0,0 +1,364 @@
import type { OpenAIToolDefinition } from '$lib/types';
import { ToolsService } from '$lib/services/tools.service';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus, ToolSource } from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import { DISABLED_TOOLS_LOCALSTORAGE_KEY } from '$lib/constants';
import { SvelteSet } from 'svelte/reactivity';
export interface ToolEntry {
source: ToolSource;
/** For MCP tools, the server ID; otherwise undefined */
serverName?: string;
definition: OpenAIToolDefinition;
}
export interface ToolGroup {
source: ToolSource;
label: string;
/** For MCP groups, the server ID */
serverId?: string;
tools: OpenAIToolDefinition[];
}
class ToolsStore {
private _builtinTools = $state<OpenAIToolDefinition[]>([]);
private _loading = $state(false);
private _error = $state<string | null>(null);
private _disabledTools = $state(new SvelteSet<string>());
constructor() {
try {
const stored = localStorage.getItem(DISABLED_TOOLS_LOCALSTORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
for (const name of parsed) {
if (typeof name === 'string') this._disabledTools.add(name);
}
}
}
} catch {
throw new Error('Failed to load disabled tools from localStorage');
}
}
private persistDisabledTools(): void {
try {
localStorage.setItem(
DISABLED_TOOLS_LOCALSTORAGE_KEY,
JSON.stringify([...this._disabledTools])
);
} catch {
// ignore storage errors
}
}
get builtinTools(): OpenAIToolDefinition[] {
return this._builtinTools;
}
get mcpTools(): OpenAIToolDefinition[] {
return mcpStore.getToolDefinitionsForLLM();
}
get customTools(): OpenAIToolDefinition[] {
const raw = config().custom;
if (!raw || typeof raw !== 'string') return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(t: unknown): t is OpenAIToolDefinition =>
typeof t === 'object' &&
t !== null &&
'type' in t &&
(t as OpenAIToolDefinition).type === 'function' &&
'function' in t &&
typeof (t as OpenAIToolDefinition).function?.name === 'string'
);
} catch {
return [];
}
}
/** Flat list of all tool entries with source metadata */
get allTools(): ToolEntry[] {
const entries: ToolEntry[] = [];
for (const def of this._builtinTools) {
entries.push({ source: ToolSource.BUILTIN, definition: def });
}
// Use live connections when available (full schema), fall back to health check data
const connections = mcpStore.getConnections();
if (connections.size > 0) {
for (const [serverId, connection] of connections) {
const serverName = mcpStore.getServerDisplayName(serverId);
for (const tool of connection.tools) {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
type: 'object',
properties: {},
required: []
};
entries.push({
source: ToolSource.MCP,
serverName,
definition: {
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: rawSchema
}
}
});
}
}
} else {
for (const { serverName, tools } of this.getMcpToolsFromHealthChecks()) {
for (const tool of tools) {
entries.push({
source: ToolSource.MCP,
serverName,
definition: {
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: { type: 'object', properties: {}, required: [] }
}
}
});
}
}
}
for (const def of this.customTools) {
entries.push({ source: ToolSource.CUSTOM, definition: def });
}
return entries;
}
/** Tools grouped by category for tree display */
get toolGroups(): ToolGroup[] {
const groups: ToolGroup[] = [];
if (this._builtinTools.length > 0) {
groups.push({
source: ToolSource.BUILTIN,
label: 'Built-in',
tools: this._builtinTools
});
}
// Use live connections when available, fall back to health check data
const connections = mcpStore.getConnections();
if (connections.size > 0) {
for (const [serverId, connection] of connections) {
if (connection.tools.length === 0) continue;
const label = mcpStore.getServerDisplayName(serverId);
const tools: OpenAIToolDefinition[] = connection.tools.map((tool) => {
const rawSchema = (tool.inputSchema as Record<string, unknown>) ?? {
type: 'object',
properties: {},
required: []
};
return {
type: 'function' as const,
function: {
name: tool.name,
description: tool.description,
parameters: rawSchema
}
};
});
groups.push({ source: ToolSource.MCP, label, serverId, tools });
}
} else {
for (const { serverId, serverName, tools } of this.getMcpToolsFromHealthChecks()) {
if (tools.length === 0) continue;
const defs: OpenAIToolDefinition[] = tools.map((tool) => ({
type: 'function' as const,
function: {
name: tool.name,
description: tool.description,
parameters: { type: 'object', properties: {}, required: [] }
}
}));
groups.push({ source: ToolSource.MCP, label: serverName, serverId, tools: defs });
}
}
const custom = this.customTools;
if (custom.length > 0) {
groups.push({
source: ToolSource.CUSTOM,
label: 'JSON Schema',
tools: custom
});
}
return groups;
}
/** Only enabled tool definitions (for sending to the API) */
get enabledToolDefinitions(): OpenAIToolDefinition[] {
return this.allTools
.filter((t) => !this._disabledTools.has(t.definition.function.name))
.map((t) => t.definition);
}
/**
* Returns enabled tool definitions for sending to the LLM.
* MCP tools use properly normalized schemas from mcpStore.
* Filters out tools disabled via the UI checkboxes.
*/
getEnabledToolsForLLM(): OpenAIToolDefinition[] {
const disabled = this._disabledTools;
const result: OpenAIToolDefinition[] = [];
for (const tool of this._builtinTools) {
if (!disabled.has(tool.function.name)) {
result.push(tool);
}
}
// MCP tools with properly normalized schemas
for (const tool of mcpStore.getToolDefinitionsForLLM()) {
if (!disabled.has(tool.function.name)) {
result.push(tool);
}
}
for (const tool of this.customTools) {
if (!disabled.has(tool.function.name)) {
result.push(tool);
}
}
return result;
}
get allToolDefinitions(): OpenAIToolDefinition[] {
return this.allTools.map((t) => t.definition);
}
get loading(): boolean {
return this._loading;
}
get error(): string | null {
return this._error;
}
get disabledTools(): SvelteSet<string> {
return this._disabledTools;
}
isToolEnabled(toolName: string): boolean {
return !this._disabledTools.has(toolName);
}
toggleTool(toolName: string): void {
if (this._disabledTools.has(toolName)) {
this._disabledTools.delete(toolName);
} else {
this._disabledTools.add(toolName);
}
this.persistDisabledTools();
}
setToolEnabled(toolName: string, enabled: boolean): void {
if (enabled) {
this._disabledTools.delete(toolName);
} else {
this._disabledTools.add(toolName);
}
}
toggleGroup(group: ToolGroup): void {
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.function.name));
for (const tool of group.tools) {
this.setToolEnabled(tool.function.name, !allEnabled);
}
this.persistDisabledTools();
}
isGroupFullyEnabled(group: ToolGroup): boolean {
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.function.name));
}
isGroupPartiallyEnabled(group: ToolGroup): boolean {
const enabledCount = group.tools.filter((t) => this.isToolEnabled(t.function.name)).length;
return enabledCount > 0 && enabledCount < group.tools.length;
}
/**
* Get MCP tools from health check data (reactive).
* Used when live connections aren't established yet.
*/
private getMcpToolsFromHealthChecks(): {
serverId: string;
serverName: string;
tools: { name: string; description?: string }[];
}[] {
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
for (const server of mcpStore.getServersSorted().filter((s) => s.enabled)) {
const health = mcpStore.getHealthCheckState(server.id);
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
result.push({
serverId: server.id,
serverName: mcpStore.getServerLabel(server),
tools: health.tools
});
}
}
return result;
}
/** Determine the source of a tool by its name. */
getToolSource(toolName: string): ToolSource | null {
if (this._builtinTools.some((t) => t.function.name === toolName)) {
return ToolSource.BUILTIN;
}
for (const entry of this.allTools) {
if (entry.definition.function.name === toolName) {
return entry.source;
}
}
return null;
}
/** Check if there are any enabled tools available (builtin, MCP, or custom). */
get hasEnabledTools(): boolean {
return this.getEnabledToolsForLLM().length > 0;
}
async fetchBuiltinTools(): Promise<void> {
if (this._loading) return;
this._loading = true;
this._error = null;
try {
const toolInfos = await ToolsService.list();
this._builtinTools = toolInfos.map((info) => info.definition);
} catch (err) {
this._error = err instanceof Error ? err.message : String(err);
console.error('[ToolsStore] Failed to fetch built-in tools:', err);
} finally {
this._loading = false;
}
}
}
export const toolsStore = new ToolsStore();
export const allTools = () => toolsStore.allTools;
export const allToolDefinitions = () => toolsStore.allToolDefinitions;
export const enabledToolDefinitions = () => toolsStore.enabledToolDefinitions;
export const toolGroups = () => toolsStore.toolGroups;

View File

@ -116,6 +116,7 @@ export type {
ServerStatus,
ToolCallParams,
ToolExecutionResult,
ServerBuiltinToolInfo,
Tool,
Prompt,
GetPromptResult,

View File

@ -1,4 +1,5 @@
import type { MCPConnectionPhase, MCPLogLevel, HealthCheckStatus } from '$lib/enums/mcp';
import type { ToolSource } from '$lib/enums/tools';
import type {
Client,
ClientCapabilities as SDKClientCapabilities,
@ -256,6 +257,16 @@ export interface ToolExecutionResult {
isError: boolean;
}
export interface ServerBuiltinToolInfo {
display_name: string;
tool: string;
type: ToolSource.BUILTIN;
permissions: {
write: boolean;
};
definition: OpenAIToolDefinition;
}
/**
* Progress tracking state for a specific operation
*/

View File

@ -0,0 +1,12 @@
<script lang="ts">
import { page } from '$app/state';
import { ChatScreen } from '$lib/components/app';
let { children } = $props();
let showCenteredEmpty = $derived(!page.params.id);
</script>
<ChatScreen {showCenteredEmpty} />
{@render children?.()}

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app';
import { DialogModelNotAvailable } from '$lib/components/app';
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
@ -7,6 +7,7 @@
import { onMount } from 'svelte';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { APP_NAME } from '$lib/constants';
let qParam = $derived(page.url.searchParams.get('q'));
let modelParam = $derived(page.url.searchParams.get('model'));
@ -87,11 +88,9 @@
</script>
<svelte:head>
<title>llama.cpp - AI Chat Interface</title>
<title>{APP_NAME}</title>
</svelte:head>
<ChatScreen showCenteredEmpty />
<DialogModelNotAvailable
bind:open={showModelNotAvailable}
modelName={requestedModelName}

View File

@ -2,7 +2,7 @@
import { goto, replaceState } from '$app/navigation';
import { page } from '$app/state';
import { afterNavigate } from '$app/navigation';
import { ChatScreen, DialogModelNotAvailable } from '$lib/components/app';
import { DialogModelNotAvailable } from '$lib/components/app';
import { chatStore, isLoading } from '$lib/stores/chat.svelte';
import {
conversationsStore,
@ -169,8 +169,6 @@
<title>{activeConversation()?.name || 'Chat'} - llama.cpp</title>
</svelte:head>
<ChatScreen />
<DialogModelNotAvailable
bind:open={showModelNotAvailable}
modelName={requestedModelName}

View File

@ -4,13 +4,16 @@
import { browser } from '$app/environment';
import { page } from '$app/state';
import { untrack } from 'svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import {
ChatSidebar,
ChatSettings,
DesktopIconStrip,
DialogConversationTitleUpdate,
DialogChatSettings
DialogChatSettingsImportExport
} from '$lib/components/app';
import { isLoading } from '$lib/stores/chat.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Tooltip from '$lib/components/ui/tooltip';
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
@ -21,22 +24,17 @@
import { modelsStore } from '$lib/stores/models.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import type { SettingsSectionTitle } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import { setChatSettingsDialogContext } from '$lib/contexts';
import { setImportExportDialogContext } from '$lib/contexts';
let { children } = $props();
let isChatRoute = $derived(page.route.id === '/chat/[id]');
let isHomeRoute = $derived(page.route.id === '/');
let isNewChatMode = $derived(page.url.searchParams.get('new_chat') === 'true');
let showSidebarByDefault = $derived(activeMessages().length > 0 || isLoading());
let alwaysShowSidebarOnDesktop = $derived(config().alwaysShowSidebarOnDesktop);
let autoShowSidebarOnNewChat = $derived(config().autoShowSidebarOnNewChat);
let isMobile = new IsMobile();
let isDesktop = $derived(!isMobile.current);
let sidebarOpen = $state(false);
let mounted = $state(false);
let innerHeight = $state<number | undefined>();
let chatSidebar:
| { activateSearchMode?: () => void; editActiveConversation?: () => void }
@ -48,16 +46,29 @@
let titleUpdateNewTitle = $state('');
let titleUpdateResolve: ((value: boolean) => void) | null = null;
let chatSettingsDialogOpen = $state(false);
let chatSettingsDialogInitialSection = $state<SettingsSectionTitle | undefined>(undefined);
let activePanel = $state<'chat' | 'settings' | 'mcp'>('chat');
let isSettingsRoute = $derived(!!page.route.id?.startsWith('/settings'));
let chatSettingsRef: ChatSettings | undefined = $state();
let importExportDialogOpen = $state(false);
setChatSettingsDialogContext({
open: (initialSection?: SettingsSectionTitle) => {
chatSettingsDialogInitialSection = initialSection;
chatSettingsDialogOpen = true;
setImportExportDialogContext({
open: () => {
importExportDialogOpen = true;
}
});
$effect(() => {
if (activePanel === 'settings' && chatSettingsRef) {
chatSettingsRef.reset();
}
});
// Return to chat when navigating to a new route
$effect(() => {
void page.url;
activePanel = 'chat';
});
// Global keyboard shortcuts
function handleKeydown(event: KeyboardEvent) {
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
@ -100,28 +111,16 @@
}
}
onMount(() => {
mounted = true;
});
$effect(() => {
if (alwaysShowSidebarOnDesktop && isDesktop) {
sidebarOpen = true;
return;
}
if (isHomeRoute && !isNewChatMode) {
// Auto-collapse sidebar when navigating to home route (but not in new chat mode)
sidebarOpen = false;
} else if (isHomeRoute && isNewChatMode) {
// Keep sidebar open in new chat mode
sidebarOpen = true;
} else if (isChatRoute) {
// On chat routes, only auto-show sidebar if setting is enabled
if (autoShowSidebarOnNewChat) {
sidebarOpen = true;
}
// If setting is disabled, don't change sidebar state - let user control it manually
} else {
// Other routes follow default behavior
sidebarOpen = showSidebarByDefault;
}
// Don't auto-open or auto-close sidebar during navigation - user controls it manually
});
// Initialize server properties on app load (run once)
@ -185,7 +184,7 @@
const apiKey = config().apiKey;
if (
(page.route.id === '/' || page.route.id === '/chat/[id]') &&
(page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
page.status !== 401 &&
page.status !== 403
) {
@ -229,10 +228,9 @@
<Toaster richColors />
<DialogChatSettings
open={chatSettingsDialogOpen}
onOpenChange={(open) => (chatSettingsDialogOpen = open)}
initialSection={chatSettingsDialogInitialSection}
<DialogChatSettingsImportExport
open={importExportDialogOpen}
onOpenChange={(open) => (importExportDialogOpen = open)}
/>
<DialogConversationTitleUpdate
@ -245,20 +243,36 @@
<Sidebar.Provider bind:open={sidebarOpen}>
<div class="flex h-screen w-full" style:height="{innerHeight}px">
<Sidebar.Root class="h-full">
<Sidebar.Root variant="floating" class="h-full">
<ChatSidebar bind:this={chatSidebar} />
</Sidebar.Root>
{#if !(alwaysShowSidebarOnDesktop && isDesktop)}
<Sidebar.Trigger
class="transition-left absolute left-0 z-[900] duration-200 ease-linear {sidebarOpen
? 'md:left-[var(--sidebar-width)]'
: 'md:left-0!'}"
style="translate: 1rem 1rem;"
{#if !(alwaysShowSidebarOnDesktop && isDesktop) && !(isSettingsRoute && !isDesktop)}
{#if mounted}
<div in:fade={{ duration: 200 }}>
<Sidebar.Trigger
class="transition-left absolute left-0 z-[900] duration-200 ease-linear {sidebarOpen
? 'left-[calc(var(--sidebar-width)+0.75rem)] max-md:hidden'
: 'left-0!'}"
style="translate: 1rem 1rem;"
/>
</div>
{/if}
{/if}
{#if isDesktop && !alwaysShowSidebarOnDesktop}
<DesktopIconStrip
{sidebarOpen}
onSearchClick={() => {
if (chatSidebar?.activateSearchMode) {
chatSidebar.activateSearchMode();
}
sidebarOpen = true;
}}
/>
{/if}
<Sidebar.Inset class="flex flex-1 flex-col overflow-hidden">
<Sidebar.Inset class="flex flex-1 flex-col overflow-auto">
{@render children?.()}
</Sidebar.Inset>
</div>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { page } from '$app/state';
import { ActionIcon } from '$lib/components/app';
let { children } = $props();
let previousRouteId = $state<string | null>(null);
$effect(() => {
const currentId = page.route.id;
return () => {
previousRouteId = currentId;
};
});
function handleClose() {
const prevIsSettings = previousRouteId?.startsWith('/settings');
if (browser && window.history.length > 1 && !prevIsSettings) {
history.back();
} else {
goto('#/');
}
}
</script>
<div class="relative h-full">
<div class="fixed top-4.5 right-4 z-50 md:hidden">
<ActionIcon icon={X} tooltip="Close" onclick={handleClose} />
</div>
<div class="min-h-full">
{@render children?.()}
</div>
</div>

View File

@ -0,0 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { SettingsChat } from '$lib/components/app/settings';
import type { SettingsSectionTitle } from '$lib/constants';
let sectionParam = $derived(page.url.searchParams.get('section') as SettingsSectionTitle | null);
</script>
<SettingsChat
onSave={() => goto('#/')}
initialSection={sectionParam ?? undefined}
class="mx-auto max-h-[100dvh] md:pl-8"
/>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { SettingsImportExport } from '$lib/components/app/settings';
</script>
<div class="mx-auto w-full p-4 md:p-8">
<SettingsImportExport />
</div>

View File

@ -0,0 +1,5 @@
<script lang="ts">
import { SettingsMcpServers } from '$lib/components/app/settings';
</script>
<SettingsMcpServers class="mx-auto w-full p-4 md:p-8" />

View File

@ -1,7 +1,7 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import Page from '../../../src/routes/+page.svelte';
import Page from '../../../src/routes/(chat)/+page.svelte';
let sidebarOpen = $state(false);
</script>

View File

@ -166,6 +166,7 @@ export default defineConfig({
'/v1': 'http://localhost:8080',
'/props': 'http://localhost:8080',
'/models': 'http://localhost:8080',
'/tools': 'http://localhost:8080',
'/cors-proxy': 'http://localhost:8080'
},
headers: {