Merge branch 'upstream' into concedo_experimental

# Conflicts:
#	.devops/nix/package.nix
#	.github/workflows/server.yml
#	docs/development/HOWTO-add-model.md
#	ggml/src/ggml-hexagon/ggml-hexagon.cpp
#	ggml/src/ggml-hexagon/htp/CMakeLists.txt
#	ggml/src/ggml-hexagon/htp/argsort-ops.c
#	ggml/src/ggml-hexagon/htp/concat-ops.c
#	ggml/src/ggml-hexagon/htp/flash-attn-ops.c
#	ggml/src/ggml-hexagon/htp/gated-delta-net-ops.c
#	ggml/src/ggml-hexagon/htp/hmx-flash-attn-ops.c
#	ggml/src/ggml-hexagon/htp/hmx-matmul-ops.c
#	ggml/src/ggml-hexagon/htp/hmx-ops.h
#	ggml/src/ggml-hexagon/htp/htp-ctx.h
#	ggml/src/ggml-hexagon/htp/hvx-utils.h
#	ggml/src/ggml-hexagon/htp/main.c
#	ggml/src/ggml-hexagon/htp/matmul-ops.c
#	ggml/src/ggml-hexagon/htp/pad-ops.c
#	ggml/src/ggml-hexagon/htp/unary-ops.c
#	ggml/src/ggml-opencl/CMakeLists.txt
#	ggml/src/ggml-opencl/ggml-opencl.cpp
#	ggml/src/ggml-opencl/kernels/cvt.cl
#	ggml/src/ggml-webgpu/wgsl-shaders/cpy.wgsl
#	scripts/sync_vendor.py
#	src/llama-context.cpp
#	tests/test-backend-sampler.cpp
#	tools/server/README.md
#	tools/ui/tests/stories/ChatScreenForm.a11y.stories.svelte
This commit is contained in:
Concedo
2026-06-02 19:28:39 +08:00
78 changed files with 1801 additions and 477 deletions
@@ -111,6 +111,7 @@
let preSelectedResourceUri = $state<string | undefined>(undefined);
let currentConfig = $derived(config());
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
@@ -541,6 +542,7 @@
canSend={canSubmit}
{disabled}
{isLoading}
isReasoning={chatStore.isReasoning}
{isRecording}
{showAddButton}
{showModelSelector}
@@ -1,22 +1,18 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import { Plus, File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { buttonVariants } from '$lib/components/ui/button';
import { cn } from '$lib/components/ui/utils';
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_EXTRA_ITEMS,
ATTACHMENT_MCP_ITEMS,
ATTACHMENT_TOOLTIP_TEXT,
TOOLTIP_DELAY_DURATION
} from '$lib/constants';
import { AttachmentMenuItemId } from '$lib/enums';
import {
ChatFormActionAddToolsSubmenu,
ChatFormActionAddMcpServersSubmenu
} from '$lib/components/app';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
interface Props {
@@ -97,107 +93,87 @@
</Tooltip.Root>
<DropdownMenu.Content align="start" class="w-48">
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
{#if enabled}
<DropdownMenu.Item
class="{item.class ?? ''} flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4" />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<File class="h-4 w-4" />
<span>{item.label}</span>
</DropdownMenu.Item>
{:else if item.disabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger tabindex={-1}>
{#snippet child({ props })}
<div {...props} class="cursor-default">
<DropdownMenu.Item class="{item.class ?? ''} flex items-center gap-2" disabled>
<item.icon class="h-4 w-4" />
<span>Add files</span>
</DropdownMenu.SubTrigger>
<span>{item.label}</span>
</DropdownMenu.Item>
</div>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{item.disabledTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
{#if !attachmentMenu.isItemEnabled('hasVisionModality')}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
{#snippet child({ props })}
<DropdownMenu.SubContent class="w-48">
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
{#if enabled}
<DropdownMenu.Item
{...props}
class="flex cursor-pointer items-center gap-2"
onclick={attachmentMenu.callbacks.onFileUpload}
class="{item.class ?? ''} flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[item.action]()}
>
{@const pdfItem = ATTACHMENT_FILE_ITEMS.find(
(i) => i.id === AttachmentMenuItemId.PDF
)}
{#if pdfItem}
<pdfItem.icon class="h-4 w-4" />
<item.icon class="h-4 w-4" />
<span>{pdfItem.label}</span>
{/if}
<span>{item.label}</span>
</DropdownMenu.Item>
{/snippet}
</Tooltip.Trigger>
{:else if item.disabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger tabindex={-1}>
{#snippet child({ props })}
<div {...props} class="cursor-default">
<DropdownMenu.Item
class="{item.class ?? ''} flex items-center gap-2"
disabled
>
<item.icon class="h-4 w-4" />
<Tooltip.Content side="right">
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<span>{item.label}</span>
</DropdownMenu.Item>
</div>
{/snippet}
</Tooltip.Trigger>
<DropdownMenu.Separator />
<Tooltip.Content side="right">
<p>{item.disabledTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{#each ATTACHMENT_EXTRA_ITEMS as item (item.id)}
{#if item.id === AttachmentMenuItemId.SYSTEM_MESSAGE}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
{#snippet child({ props })}
<DropdownMenu.Item
{...props}
class="flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4" />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={onSystemPromptClick}
>
<MessageSquare class="h-4 w-4" />
<span>{item.label}</span>
</DropdownMenu.Item>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{attachmentMenu.getSystemMessageTooltip()}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
<span>System Message</span>
</DropdownMenu.Item>
<ChatFormActionAddToolsSubmenu />
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
{#each ATTACHMENT_MCP_ITEMS as item (item.id)}
{#if attachmentMenu.isItemVisible(item.visibleWhen)}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4" />
{#if hasMcpPromptsSupport}
<DropdownMenu.Separator />
<span>{item.label}</span>
</DropdownMenu.Item>
{/if}
{/each}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={onMcpPromptClick}
>
<Zap class="h-4 w-4" />
<span>MCP Prompt</span>
</DropdownMenu.Item>
{/if}
{#if hasMcpResourcesSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={onMcpResourcesClick}
>
<FolderOpen class="h-4 w-4" />
<span>MCP Resources</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -2,18 +2,19 @@
import type { Snippet } from 'svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Sheet from '$lib/components/ui/sheet';
import * as Collapsible from '$lib/components/ui/collapsible';
import { File, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import { Switch } from '$lib/components/ui/switch';
import { Checkbox } from '$lib/components/ui/checkbox';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_EXTRA_ITEMS,
ATTACHMENT_MCP_ITEMS
} from '$lib/constants/attachment-menu';
import { McpLogo } from '$lib/components/app';
import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { AttachmentMenuItemId } from '$lib/enums';
import { PencilRuler } from '@lucide/svelte';
import { ROUTES, SETTINGS_SECTION_SLUGS } from '$lib/constants/routes';
import { RouterService } from '$lib/services/router.service';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { McpLogo } from '$lib/components/app';
import { PencilRuler, ChevronDown, ChevronRight } from '@lucide/svelte';
import { HealthCheckStatus } from '$lib/enums';
interface Props {
class?: string;
@@ -46,6 +47,9 @@
}: Props = $props();
let sheetOpen = $state(false);
let filesExpanded = $state(true);
let toolsExpanded = $state(false);
let mcpExpanded = $state(false);
const attachmentMenu = useAttachmentMenu(
() => ({
@@ -61,14 +65,22 @@
}
);
const toolsPanel = useToolsPanel();
const sheetItemClass =
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
const sheetItemRowClass =
'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
function getEnabledMcpServers() {
return mcpStore.getServersSorted().filter((s) => s.enabled);
}
</script>
<div class="flex items-center gap-1 {className}">
<Sheet.Root bind:open={sheetOpen}>
{@render trigger({ disabled, onclick: () => (sheetOpen = true) })}
<!-- <ChatFormActionAddButton {disabled} onclick={() => (sheetOpen = true)} /> -->
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
<Sheet.Header>
@@ -80,110 +92,206 @@
</Sheet.Header>
<div class="flex flex-col gap-1 px-1.5 pb-2">
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
{#if enabled}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4 shrink-0" />
<Collapsible.Root open={filesExpanded} onOpenChange={(open) => (filesExpanded = open)}>
<Collapsible.Trigger class={sheetItemClass}>
{#if filesExpanded}
<ChevronDown class="h-4 w-4 shrink-0" />
{:else}
<ChevronRight class="h-4 w-4 shrink-0" />
{/if}
<span>{item.label}</span>
</button>
{:else if item.disabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<item.icon class="h-4 w-4 shrink-0" />
<File class="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</button>
</Tooltip.Trigger>
<span class="flex-1">Add files</span>
</Collapsible.Trigger>
<Tooltip.Content side="right">
<p>{item.disabledTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
{#if enabled}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</button>
{:else if item.disabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<item.icon class="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{item.disabledTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
<Collapsible.Trigger class={sheetItemClass}>
{#if mcpExpanded}
<ChevronDown class="h-4 w-4 shrink-0" />
{:else}
<ChevronRight class="h-4 w-4 shrink-0" />
{/if}
<McpLogo class="inline h-4 w-4 shrink-0" />
<span class="flex-1">MCP Servers</span>
<span class="text-xs text-muted-foreground">
{getEnabledMcpServers().length} server{getEnabledMcpServers().length !== 1 ? 's' : ''}
</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each getEnabledMcpServers() as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const displayName = mcpStore.getServerLabel(server)}
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
{@const isEnabled = conversationsStore.isMcpServerEnabledForChat(server.id)}
{#if !attachmentMenu.isItemEnabled('hasVisionModality')}
{@const pdfItem = ATTACHMENT_FILE_ITEMS.find((i) => i.id === AttachmentMenuItemId.PDF)}
{#if pdfItem}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[pdfItem.action]()}
class={sheetItemRowClass}
onclick={() => !hasError && conversationsStore.toggleMcpServerForChat(server.id)}
disabled={hasError}
>
<pdfItem.icon class="h-4 w-4 shrink-0" />
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span>{pdfItem.label}</span>
<span class="min-w-0 truncate text-sm">{displayName}</span>
</div>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{:else}
<Switch
checked={isEnabled}
onCheckedChange={() => conversationsStore.toggleMcpServerForChat(server.id)}
/>
{/if}
</button>
</Tooltip.Trigger>
{/each}
<Tooltip.Content side="right">
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if getEnabledMcpServers().length === 0}
<div class="px-3 py-2 text-center text-sm text-muted-foreground">
No MCP servers configured
</div>
{/if}
</div>
</Collapsible.Content>
</Collapsible.Root>
{#if toolsPanel.totalToolCount > 0}
<Collapsible.Root open={toolsExpanded} onOpenChange={(open) => (toolsExpanded = open)}>
<Collapsible.Trigger class={sheetItemClass}>
{#if toolsExpanded}
<ChevronDown class="h-4 w-4 shrink-0" />
{:else}
<ChevronRight class="h-4 w-4 shrink-0" />
{/if}
<PencilRuler class="inline h-4 w-4 shrink-0" />
<span class="flex-1">Tools</span>
<span class="text-xs text-muted-foreground">
{toolsPanel.totalToolCount} tool{toolsPanel.totalToolCount !== 1 ? 's' : ''}
</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each toolsPanel.activeGroups as group (group.label)}
{@const { checked, indeterminate } = toolsPanel.getGroupCheckedState(group)}
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
{@const favicon = toolsPanel.getFavicon(group)}
<button
type="button"
class={sheetItemRowClass}
onclick={() => toolsPanel.toggleGroupByLabel(group.label)}
>
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="min-w-0 flex-1 truncate text-sm font-medium">{group.label}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{enabledCount}/{group.tools.length}
</span>
<Checkbox
{checked}
{indeterminate}
class="h-4 w-4 shrink-0"
onclick={(e) => e.stopPropagation()}
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
/>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}
{#each ATTACHMENT_EXTRA_ITEMS as item (item.id)}
{#if item.id === AttachmentMenuItemId.SYSTEM_MESSAGE}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4 shrink-0" />
<button type="button" class={sheetItemClass} onclick={onSystemPromptClick}>
<MessageSquare class="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</button>
</Tooltip.Trigger>
<span>System Message</span>
</button>
<Tooltip.Content side="right">
<p>{attachmentMenu.getSystemMessageTooltip()}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
{#if hasMcpPromptsSupport}
<button type="button" class={sheetItemClass} onclick={onMcpPromptClick}>
<Zap class="h-4 w-4 shrink-0" />
<div class="my-2 border-t"></div>
<span>MCP Prompt</span>
</button>
{/if}
<a href={ROUTES.MCP_SERVERS} class="flex items-center gap-3 px-3 py-2">
<McpLogo class="inline h-4 w-4" />
{#if hasMcpResourcesSupport}
<button type="button" class={sheetItemClass} onclick={onMcpResourcesClick}>
<FolderOpen class="h-4 w-4 shrink-0" />
<span class="text-sm">MCP Servers</span>
</a>
<a
href={RouterService.settings(SETTINGS_SECTION_SLUGS.TOOLS)}
class="flex items-center gap-3 px-3 py-2"
>
<PencilRuler class="inline h-4 w-4" />
<span class="text-sm">Tools</span>
</a>
{#each ATTACHMENT_MCP_ITEMS as item (item.id)}
{#if attachmentMenu.isItemVisible(item.visibleWhen)}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class="h-4 w-4 shrink-0" />
<span>{item.label}</span>
</button>
{/if}
{/each}
<span>MCP Resources</span>
</button>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
@@ -107,7 +107,7 @@
<Checkbox
{checked}
{indeterminate}
onCheckedChange={() => toolsStore.toggleGroup(group)}
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
class="mr-2 h-4 w-4 shrink-0"
/>
</Tooltip.Trigger>
@@ -1,6 +1,11 @@
<script lang="ts">
import { chatStore } from '$lib/stores/chat.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import {
modelsStore,
modelOptions,
selectedModelId,
selectedModelName
} from '$lib/stores/models.svelte';
import { isRouterMode, serverError } from '$lib/stores/server.svelte';
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { isMobile } from '$lib/stores/viewport.svelte';
@@ -39,7 +44,18 @@
let lastSyncedConversationModel: string | null = null;
let selectorModel = $derived(conversationModel ?? modelsStore.selectedModelName ?? null);
let selectorModel = $derived.by(() => {
const storeModel = selectedModelName();
if (storeModel && storeModel !== conversationModel) {
return storeModel;
}
if (conversationModel) {
return conversationModel;
}
return null;
});
$effect(() => {
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
@@ -50,7 +66,6 @@
modelsStore.selectedModelName = null;
modelsStore.clearSelection();
}
lastSyncedConversationModel = conversationModel;
} else if (
isRouter &&
@@ -60,9 +75,7 @@
!conversationModel
) {
lastSyncedConversationModel = null;
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
if (first) modelsStore.selectModelById(first.id);
}
});
@@ -1,11 +1,13 @@
<script lang="ts">
import { Square } from '@lucide/svelte';
import { Square, SkipForward } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { ChatService } from '$lib/services';
import {
ChatFormActionsAdd,
ChatFormActionModels,
ChatFormActionRecord,
ChatFormActionSubmit
ChatFormActionSubmit,
ChatFormReasoningToggle
} from '$lib/components/app';
import { FileTypeCategory } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
@@ -21,6 +23,7 @@
class?: string;
disabled?: boolean;
isLoading?: boolean;
isReasoning?: boolean;
isRecording?: boolean;
showAddButton?: boolean;
showModelSelector?: boolean;
@@ -39,6 +42,7 @@
class: className = '',
disabled = false,
isLoading = false,
isReasoning = false,
isRecording = false,
showAddButton = true,
showModelSelector = true,
@@ -84,6 +88,11 @@
export function openModelSelector() {
selectorModelRef?.open();
}
// the streaming assistant message carries both the completion id and the model that
// produced it, targeting reasoning control from the same source keeps them consistent
let activeMessage = $derived(
conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1]
);
</script>
<div
@@ -91,7 +100,7 @@
style="container-type: inline-size"
>
{#if showAddButton}
<div class="mr-auto flex items-center gap-2">
<div class="mr-auto flex items-center gap-3">
<ChatFormActionsAdd
{disabled}
{hasAudioModality}
@@ -108,19 +117,38 @@
</div>
{/if}
{#if showModelSelector}
<ChatFormActionModels
{disabled}
bind:this={selectorModelRef}
bind:hasAudioModality
bind:hasVideoModality
bind:hasVisionModality
bind:hasModelSelected
bind:isSelectedModelInCache
bind:submitTooltip
forceForegroundText
useGlobalSelection
/>
<div class="flex items-center gap-2">
<ChatFormReasoningToggle />
{#if showModelSelector}
<ChatFormActionModels
{disabled}
bind:this={selectorModelRef}
bind:hasAudioModality
bind:hasVideoModality
bind:hasVisionModality
bind:hasModelSelected
bind:isSelectedModelInCache
bind:submitTooltip
forceForegroundText
useGlobalSelection
/>
{/if}
</div>
{#if isReasoning}
<Button
type="button"
variant="secondary"
onclick={() =>
ChatService.stopReasoning(activeMessage?.completionId ?? '', activeMessage?.model)}
class="group h-8 w-8 rounded-full p-0"
title="Skip reasoning"
>
<span class="sr-only">Skip reasoning</span>
<SkipForward class="h-4 w-4 stroke-muted-foreground group-hover:stroke-foreground" />
</Button>
{/if}
{#if isLoading && !canSubmit}
@@ -0,0 +1,132 @@
<script lang="ts">
import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ReasoningEffort, MessageRole } from '$lib/enums';
import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
import type { ReasoningEffortLevel } from '$lib/types';
import {
modelsStore,
checkModelSupportsThinking,
supportsThinking,
propsCacheVersion,
loadedModelIds
} from '$lib/stores/models.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import type { DatabaseMessage } from '$lib/types/database';
let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled());
let currentEffort = $derived(conversationsStore.getReasoningEffort());
let isOff = $derived(!thinkingEnabled);
let subOpen = $state(false);
// Get conversation model from message history
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let modelSupportsThinkingFromMessages = $derived.by(() => {
const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
if (!modelId) return false;
const messages = conversationsStore.activeMessages;
return messages.some(
(m: DatabaseMessage) =>
m.role === MessageRole.ASSISTANT && m.model === modelId && !!m.reasoningContent
);
});
let modelSupportsThinking = $derived.by(() => {
loadedModelIds();
propsCacheVersion();
if (isRouterMode()) {
const modelId = modelsStore.selectedModelName || conversationModel;
return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
}
return supportsThinking() || modelSupportsThinkingFromMessages;
});
function isSelected(item: ReasoningEffortLevel): boolean {
if (item.isOff) return isOff;
return thinkingEnabled && currentEffort === item.value;
}
function handleSelection(item: ReasoningEffortLevel) {
if (item.isOff) {
conversationsStore.setThinkingEnabled(false);
} else {
conversationsStore.setThinkingEnabled(true);
conversationsStore.setReasoningEffort(item.value as ReasoningEffort);
}
subOpen = false;
}
</script>
{#if modelSupportsThinking}
<DropdownMenu.Sub bind:open={subOpen}>
<DropdownMenu.SubTrigger
class="flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors outline-none hover:bg-accent focus:bg-accent"
>
{#if thinkingEnabled}
<Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
{:else}
<LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
{/if}
<span class="flex-1">Thinking</span>
{#if thinkingEnabled}
<span class="text-xs text-muted-foreground">{currentEffort}</span>
{:else}
<span class="text-xs text-muted-foreground">off</span>
{/if}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-60 rounded-xl bg-popover p-3 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"
>
{#each REASONING_EFFORT_LEVELS as level (level.value)}
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent"
class:bg-accent={isSelected(level)}
onclick={() => handleSelection(level)}
>
{#if isSelected(level)}
<Check class="h-4 w-4 shrink-0 text-foreground" />
{:else}
<div class="h-4 w-4 shrink-0"></div>
{/if}
<span class="flex-1">{level.label}</span>
{#if !level.isOff}
<span class="text-[11px] text-muted-foreground opacity-60">
{REASONING_EFFORT_TOKENS[level.value] === -1
? 'Unlimited'
: `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
</span>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Maximum thinking effort with extended context usage</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</button>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
@@ -0,0 +1,145 @@
<script lang="ts">
import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ReasoningEffort, MessageRole } from '$lib/enums';
import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
import type { ReasoningEffortLevel } from '$lib/types';
import {
modelsStore,
checkModelSupportsThinking,
supportsThinking,
propsCacheVersion,
loadedModelIds
} from '$lib/stores/models.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import type { DatabaseMessage } from '$lib/types/database';
let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled());
let currentEffort = $derived(conversationsStore.getReasoningEffort());
let isOff = $derived(!thinkingEnabled);
let tooltipText = $derived(thinkingEnabled ? `${currentEffort} Reasoning` : 'Disabled Reasoning');
let subOpen = $state(false);
// Get conversation model from message history
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
// Fallback: if model props aren't available, check if any assistant messages
// for this model in the active conversation have reasoning content.
let modelSupportsThinkingFromMessages = $derived.by(() => {
const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
if (!modelId) return false;
const messages = conversationsStore.activeMessages;
return messages.some(
(m: DatabaseMessage) =>
m.role === MessageRole.ASSISTANT && m.model === modelId && !!m.reasoningContent
);
});
// Check if model supports thinking. Primary: chat template from /props.
// Fallback: message history (reasoning content in assistant messages).
let modelSupportsThinking = $derived.by(() => {
loadedModelIds();
propsCacheVersion();
if (isRouterMode()) {
const modelId = modelsStore.selectedModelName || conversationModel;
return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
}
// In non-router mode, use the built-in supportsThinking
return supportsThinking() || modelSupportsThinkingFromMessages;
});
// Check if current item is selected
function isSelected(item: ReasoningEffortLevel): boolean {
if (item.isOff) {
return isOff;
}
return thinkingEnabled && currentEffort === item.value;
}
function handleSelection(item: ReasoningEffortLevel) {
if (item.isOff) {
conversationsStore.setThinkingEnabled(false);
} else {
conversationsStore.setThinkingEnabled(true);
conversationsStore.setReasoningEffort(item.value as ReasoningEffort);
}
subOpen = false;
}
</script>
{#if modelSupportsThinking}
<DropdownMenu.Root bind:open={subOpen}>
<Tooltip.Root>
<Tooltip.Trigger>
<DropdownMenu.Trigger
class={[
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-full p-0 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
thinkingEnabled ? 'bg-amber-400/10 hover:bg-amber-400/20' : 'bg-muted'
]}
aria-label={`${tooltipText}. Click to configure.`}
>
{#if thinkingEnabled}
<Lightbulb class="h-3 w-3 text-amber-400" />
{:else}
<LightbulbOff class="h-3 w-3 text-muted-foreground" />
{/if}
</DropdownMenu.Trigger>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="capitalize">{tooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenu.Content
align="start"
class="w-60 rounded-xl bg-popover p-3 text-popover-foreground shadow-md outline-none"
>
<div class="mb-2 px-2.5 text-sm font-medium">Reasoning effort</div>
{#each REASONING_EFFORT_LEVELS as level (level.value)}
<button
type="button"
class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent"
class:bg-accent={isSelected(level)}
onclick={() => handleSelection(level)}
>
{#if isSelected(level)}
<Check class="h-4 w-4 shrink-0 text-foreground" />
{:else}
<div class="h-4 w-4 shrink-0"></div>
{/if}
<span class="flex-1">{level.label}</span>
{#if !level.isOff}
<span class="text-[11px] text-muted-foreground opacity-60">
{REASONING_EFFORT_TOKENS[level.value] === -1
? 'Unlimited'
: `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
</span>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Maximum reasoning effort with extended context usage</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</button>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
@@ -240,6 +240,15 @@ export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormAct
*/
export { default as ChatFormActionAddMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte';
/**
* **ChatFormReasoningToggle** - Thinking toggle button with effort dropdown
*
* A toggle button with lightbulb icon that indicates thinking status.
* Shows the reasoning effort dropdown when clicked.
* Only visible when the current model supports thinking.
*/
export { default as ChatFormReasoningToggle } from './ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte';
/**
* Hidden file input element for programmatic file selection.
*/
@@ -8,6 +8,7 @@
hideOrgName?: boolean;
showRaw?: boolean;
hideQuantization?: boolean;
hideTags?: boolean;
aliases?: string[];
tags?: string[];
class?: string;
@@ -17,7 +18,8 @@
modelId,
hideOrgName = false,
showRaw = undefined,
hideQuantization = false,
hideQuantization,
hideTags,
aliases,
tags,
class: className = '',
@@ -31,6 +33,8 @@
let parsed = $derived(ModelsService.parseModelId(modelId));
let resolvedShowRaw = $derived(showRaw ?? (config().showRawModelNames as boolean) ?? false);
let resolvedHideQuantization = $derived(hideQuantization ?? !config().showModelQuantization);
let resolvedHideTags = $derived(hideTags ?? !config().showModelTags);
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
@@ -53,7 +57,7 @@
</span>
{/if}
{#if parsed.quantization && !hideQuantization}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
@@ -69,7 +73,7 @@
{/each}
{/if}
{#if uniqueTags.length > 0}
{#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
@@ -106,9 +106,7 @@
]}
style="max-width: min(calc(100cqw - 10rem), 20rem)"
>
<Package class="h-3.5 w-3.5" />
<ModelId modelId={currentModel} class="min-w-0" hideQuantization />
<Package class="h-3.5 w-3.5 shrink-0" />
</span>
{:else}
<p class="text-xs text-muted-foreground">No models available.</p>
@@ -120,7 +118,7 @@
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
<DropdownMenu.Trigger
class={[
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
@@ -133,7 +131,7 @@
]}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5" />
<Package class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<Tooltip.Root>
@@ -144,6 +142,7 @@
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideQuantization
{...props}
/>
{/snippet}
@@ -158,9 +157,9 @@
{/if}
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 animate-spin" />
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5" />
<ChevronDown class="h-3 w-3.5 shrink-0" />
{/if}
</DropdownMenu.Trigger>
@@ -251,7 +250,7 @@
onclick={() => ms.handleOpenChange(true)}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5" />
<Package class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<Tooltip.Root>
@@ -262,6 +261,7 @@
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideQuantization
{...props}
/>
{/snippet}
@@ -274,7 +274,7 @@
{/if}
{#if ms.updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{/if}
</button>
{/if}
@@ -6,8 +6,7 @@
DialogModelInformation,
ModelId,
ModelsSelectorList,
SearchInput,
TruncatedText
SearchInput
} from '$lib/components/app';
interface Props {
@@ -67,7 +66,7 @@
<button
type="button"
class={[
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
@@ -81,7 +80,7 @@
disabled={disabled || ms.updating}
onclick={() => ms.handleOpenChange(true)}
>
<Package class="h-3.5 w-3.5" />
<Package class="h-3.5 w-3.5 shrink-0" />
{#if !selectedOption}
<span class="min-w-0 font-medium">Select model</span>
@@ -90,14 +89,15 @@
class="text-xs"
modelId={selectedOption?.model || ''}
hideQuantization
hideTags
hideOrgName
/>
{/if}
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 animate-spin" />
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5" />
<ChevronDown class="h-3 w-3.5 shrink-0" />
{/if}
</button>
@@ -168,12 +168,12 @@
onclick={() => ms.handleOpenChange(true)}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5" />
<Package class="h-3.5 w-3.5 shrink-0" />
<TruncatedText text={selectedOption?.model || ''} class="font-medium" />
<ModelId modelId={selectedOption?.model || ''} class="font-medium" hideQuantization />
{#if ms.updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{/if}
</button>
{/if}
@@ -74,8 +74,7 @@ export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
*/
export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
/**
* **ModelBadge** - Model name display badge
/** * **ModelBadge** - Model name display badge
*
* Compact badge showing current model name with package icon.
* Only visible in single model mode. Supports tooltip and copy functionality.
@@ -4,6 +4,17 @@ export const API_MODELS = {
UNLOAD: '/models/unload'
};
// chat completion routes, the control route drives realtime inference (e.g. end reasoning)
export const API_CHAT = {
COMPLETIONS: './v1/chat/completions',
CONTROL: './v1/chat/completions/control'
};
// slot introspection, requires the --slots flag on the server
export const API_SLOTS = {
LIST: './slots'
};
export const API_TOOLS = {
LIST: '/tools',
EXECUTE: '/tools'
@@ -79,7 +79,9 @@ export const ATTACHMENT_FILE_ITEMS: AttachmentMenuItem[] = [
}
];
export const ATTACHMENT_EXTRA_ITEMS: AttachmentMenuItem[] = [
export const ATTACHMENT_EXTRA_ITEMS: AttachmentMenuItem[] = [];
export const ATTACHMENT_PROMPT_ITEMS: AttachmentMenuItem[] = [
{
id: AttachmentMenuItemId.SYSTEM_MESSAGE,
label: 'System Message',
@@ -87,10 +89,7 @@ export const ATTACHMENT_EXTRA_ITEMS: AttachmentMenuItem[] = [
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
hasEnabledTooltip: true,
action: AttachmentAction.SYSTEM_PROMPT_CLICK
}
];
export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
},
{
id: AttachmentMenuItemId.MCP_PROMPT,
label: 'MCP Prompt',
@@ -98,7 +97,10 @@ export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
action: AttachmentAction.MCP_PROMPT_CLICK,
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT
},
}
];
export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
{
id: AttachmentMenuItemId.MCP_RESOURCES,
label: 'MCP Resources',
@@ -0,0 +1,7 @@
// actions accepted by the realtime inference control endpoint (API_CHAT.CONTROL)
// kept separate from the endpoint paths since these are protocol level verbs
export const CONTROL_ACTION = {
END_REASONING: 'reasoning_end'
} as const;
export type ControlAction = (typeof CONTROL_ACTION)[keyof typeof CONTROL_ACTION];
+23
View File
@@ -0,0 +1,23 @@
export const ERROR_MESSAGES = {
NETWORK: {
GENERIC: 'Failed to connect to server',
NXDOMAIN: 'Server not found - check server address',
REFUSED: 'Connection refused - server may be offline',
TIMEOUT: 'Request timed out',
UNREACHABLE: 'Server is not running or unreachable'
},
HTTP: {
GENERIC: 'Request failed',
ACCESS_DENIED: 'Access denied',
INTERNAL_ERROR: 'Server error - check server logs',
NOT_FOUND: 'Not found',
TEMPORARILY_UNAVAILABLE: 'Server temporarily unavailable'
}
};
export const HTTP_CODE_TO_STRING: Record<string, string> = {
401: ERROR_MESSAGES.HTTP.ACCESS_DENIED,
403: ERROR_MESSAGES.HTTP.ACCESS_DENIED,
500: ERROR_MESSAGES.HTTP.INTERNAL_ERROR,
503: ERROR_MESSAGES.HTTP.TEMPORARILY_UNAVAILABLE
};
+3
View File
@@ -5,6 +5,8 @@ export * from './agentic';
export * from './api-endpoints';
export * from './attachment-labels';
export * from './database';
export * from './reasoning-effort';
export * from './reasoning-effort-tokens';
export * from './storage';
export * from './attachment-menu';
export * from './auto-scroll';
@@ -15,6 +17,7 @@ export * from './cli-flags';
export * from './code-blocks';
export * from './code';
export * from './context-keys';
export * from './control-actions';
export * from './css-classes';
export * from './floating-ui-constraints';
export * from './formatters';
@@ -0,0 +1,12 @@
import { ReasoningEffort } from '$lib/enums';
/**
* Reasoning effort to token budget mapping.
* Maps the ReasoningEffort enum values to concrete token counts for the server.
*/
export const REASONING_EFFORT_TOKENS: Record<string, number> = {
[ReasoningEffort.LOW]: 512,
[ReasoningEffort.MEDIUM]: 2048,
[ReasoningEffort.HIGH]: 8192,
[ReasoningEffort.MAX]: -1 // unlimited
};
@@ -0,0 +1,21 @@
import { ReasoningEffort } from '$lib/enums';
import type { ReasoningEffortLevel } from '$lib/types';
/**
* Reasoning effort UI labels.
* Keys match the ReasoningEffort enum values for type-safe lookups.
*/
export const REASONING_EFFORT_LABELS: Record<string, string> = {
[ReasoningEffort.LOW]: 'Low',
[ReasoningEffort.MEDIUM]: 'Medium',
[ReasoningEffort.HIGH]: 'High',
[ReasoningEffort.MAX]: 'Max'
};
export const REASONING_EFFORT_LEVELS: ReasoningEffortLevel[] = [
{ value: 'off', label: 'Off', isOff: true },
{ value: ReasoningEffort.LOW, label: 'Low' },
{ value: ReasoningEffort.MEDIUM, label: 'Medium' },
{ value: ReasoningEffort.HIGH, label: 'High' },
{ value: ReasoningEffort.MAX, label: 'Max', hasInfo: true }
];
@@ -29,6 +29,8 @@ export const SETTINGS_KEYS = {
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
SHOW_MODEL_TAGS: 'showModelTags',
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
// Sampling
TEMPERATURE: 'temperature',
@@ -64,6 +66,7 @@ export const SETTINGS_KEYS = {
// Developer
DISABLE_REASONING_PARSING: 'disableReasoningParsing',
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
ENABLE_THINKING: 'enableThinking',
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
CUSTOM_JSON: 'customJson',
@@ -330,6 +330,30 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.SHOW_MODEL_QUANTIZATION,
label: 'Show model quantization information',
help: 'Display quantization badges (e.g. Q8_0, Q4_K_M) next to model names throughout the interface.',
defaultValue: true,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.SHOW_MODEL_QUANTIZATION,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.SHOW_MODEL_TAGS,
label: 'Show model tags',
help: 'Display model tags (e.g. "vision", "reasoning") next to model names throughout the interface.',
defaultValue: true,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY,
sync: {
serverKey: SETTINGS_KEYS.SHOW_MODEL_TAGS,
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
label: 'Always show agentic turns in conversation',
@@ -646,6 +670,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
paramType: SyncableParameterType.BOOLEAN
}
},
{
key: SETTINGS_KEYS.ENABLE_THINKING,
label: 'Enable thinking',
help: 'Enable model thinking/reasoning for each request. When off, the model will skip the thinking phase and go straight to the response.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DEVELOPER
},
{
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
label: 'Enable raw output toggle',
+2
View File
@@ -19,6 +19,8 @@ export const CONFIG_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.config`;
export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTools`;
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`;
export const THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.thinkingEnabledDefault`;
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
// Deprecated old key names (kept for backward compat while users migrate)
+2
View File
@@ -19,6 +19,8 @@ export {
ReasoningFormat
} from './chat.enums';
export { ReasoningEffort } from './reasoning-effort.enums';
export {
FileTypeCategory,
FileTypeImage,
@@ -0,0 +1,10 @@
/**
* Reasoning effort levels for thinking models.
* These values are sent to the server and mapped to token budgets.
*/
export enum ReasoningEffort {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
MAX = 'max'
}
@@ -17,6 +17,8 @@ export interface UseToolsPanelReturn {
getFavicon(group: { source: ToolSource; label: string }): string | null;
isGroupDisabled(group: ToolGroup): boolean;
toggleGroupExpanded(label: string): void;
/** Toggle all tools in a group by label (avoids stale group object references). */
toggleGroupByLabel(label: string): void;
handleOpen(): void;
}
@@ -91,6 +93,13 @@ export function useToolsPanel(): UseToolsPanelReturn {
}
}
function toggleGroupByLabel(label: string): void {
// Find current group by label to get up-to-date tool references
const group = activeGroups.find((g) => g.label === label);
if (!group) return;
toolsStore.toggleGroup(group);
}
function handleOpen(): void {
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
toolsStore.fetchBuiltinTools();
@@ -117,6 +126,7 @@ export function useToolsPanel(): UseToolsPanelReturn {
getFavicon,
isGroupDisabled,
toggleGroupExpanded,
toggleGroupByLabel,
handleOpen
};
}
+78 -4
View File
@@ -6,7 +6,11 @@ import {
ATTACHMENT_LABEL_MCP_PROMPT,
ATTACHMENT_LABEL_MCP_RESOURCE,
LEGACY_AGENTIC_REGEX,
SETTINGS_KEYS
REASONING_EFFORT_TOKENS,
SETTINGS_KEYS,
API_CHAT,
API_SLOTS,
CONTROL_ACTION
} from '$lib/constants';
import {
AttachmentType,
@@ -126,6 +130,7 @@ export class ChatService {
onReasoningChunk,
onToolCallChunk,
onModel,
onCompletionId,
onTimings,
// Tools for function calling
tools,
@@ -158,6 +163,8 @@ export class ChatService {
// Config options
disableReasoningParsing,
excludeReasoningFromContext,
enableThinking,
reasoningEffort,
continueFinalMessage
} = options;
@@ -239,6 +246,21 @@ export class ChatService {
? ReasoningFormat.NONE
: ReasoningFormat.AUTO;
const reasoningBudgetTokens =
enableThinking && reasoningEffort ? (REASONING_EFFORT_TOKENS[reasoningEffort] ?? -1) : -1;
requestBody.chat_template_kwargs = {
...(requestBody.chat_template_kwargs ?? {}),
enable_thinking: enableThinking
};
if (reasoningBudgetTokens >= 0) {
requestBody.thinking_budget_tokens = reasoningBudgetTokens;
}
// arms the budget sampler so reasoning can be ended at runtime via the control endpoint
requestBody.reasoning_control = true;
if (continueFinalMessage) {
requestBody.continue_final_message = true;
requestBody.add_generation_prompt = false;
@@ -289,7 +311,7 @@ export class ChatService {
}
try {
const response = await fetch(`./v1/chat/completions`, {
const response = await fetch(API_CHAT.COMPLETIONS, {
method: 'POST',
headers: getJsonHeaders(),
body: JSON.stringify(requestBody),
@@ -315,6 +337,7 @@ export class ChatService {
onReasoningChunk,
onToolCallChunk,
onModel,
onCompletionId,
onTimings,
conversationId,
signal
@@ -379,7 +402,7 @@ export class ChatService {
*/
static async areAllSlotsIdle(model?: string | null, signal?: AbortSignal): Promise<boolean> {
try {
const url = model ? `./slots?model=${encodeURIComponent(model)}` : './slots';
const url = model ? `${API_SLOTS.LIST}?model=${encodeURIComponent(model)}` : API_SLOTS.LIST;
const res = await fetch(url, { signal });
if (!res.ok) return true;
@@ -390,6 +413,50 @@ export class ChatService {
}
}
/**
* Ends the current reasoning block of a running completion, targeted by its
* chat completion id (streamed back as `id`). Matching the completion rather
* than a slot index avoids a TOCTOU: a finished completion simply matches
* nothing server side. The model is carried so the router forwards to the
* right child, single model ignores it. Returns true on success.
*/
static async stopReasoning(completionId: string, model?: string | null): Promise<boolean> {
if (!completionId) {
console.error(
'stopReasoning: no completion id for the active message, cannot target the running completion'
);
return false;
}
const body: Record<string, unknown> = {
id: completionId,
action: CONTROL_ACTION.END_REASONING
};
if (model) body.model = model;
try {
const res = await fetch(API_CHAT.CONTROL, {
method: 'POST',
headers: getJsonHeaders(),
body: JSON.stringify(body)
});
const data = await res.json().catch(() => null);
if (!res.ok || data?.success !== true) {
console.error('stopReasoning: control request failed', {
status: res.status,
completionId,
response: data
});
return false;
}
return true;
} catch (error) {
console.error('stopReasoning: control request threw', { completionId, error });
return false;
}
}
/**
* Sends a fire-and-forget request to pre-encode the conversation in the server's KV cache.
* After a response completes, this re-submits the full conversation
@@ -457,7 +524,7 @@ export class ChatService {
}
try {
await fetch(`./v1/chat/completions`, {
await fetch(API_CHAT.COMPLETIONS, {
method: 'POST',
headers: getJsonHeaders(),
body: JSON.stringify(requestBody),
@@ -502,6 +569,7 @@ export class ChatService {
onReasoningChunk?: (chunk: string) => void,
onToolCallChunk?: (chunk: string) => void,
onModel?: (model: string) => void,
onCompletionId?: (id: string) => void,
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void,
conversationId?: string,
abortSignal?: AbortSignal
@@ -519,6 +587,7 @@ export class ChatService {
let lastTimings: ChatMessageTimings | undefined;
let streamFinished = false;
let modelEmitted = false;
let idEmitted = false;
let toolCallIndexOffset = 0;
let hasOpenToolCallBatch = false;
@@ -607,6 +676,11 @@ export class ChatService {
onModel?.(chunkModel);
}
if (parsed.id && !idEmitted) {
idEmitted = true;
onCompletionId?.(parsed.id);
}
if (promptProgress) {
ChatService.notifyTimings(undefined, promptProgress, onTimings);
}
@@ -488,6 +488,7 @@ class AgenticStore {
onToolCallsStreaming,
onAttachments,
onModel,
onCompletionId,
onAssistantTurnComplete,
createToolResultMessage,
createAssistantMessage,
@@ -597,6 +598,7 @@ class AgenticStore {
}
},
onModel,
onCompletionId,
onTimings: (timings?: ChatMessageTimings, progress?: ChatMessagePromptProgress) => {
onTimings?.(timings, progress);
if (timings) {
+40
View File
@@ -63,7 +63,10 @@ class ChatStore {
currentResponse = $state('');
errorDialogState = $state<ErrorDialogState | null>(null);
isLoading = $state(false);
// true while the active conversation streams reasoning content but no visible content yet
isReasoning = $state(false);
chatLoadingStates = new SvelteMap<string, boolean>();
chatReasoningStates = new SvelteMap<string, boolean>();
chatStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
private abortControllers = new SvelteMap<string, AbortController>();
private preEncodeAbortController: AbortController | null = null;
@@ -94,6 +97,17 @@ class ChatStore {
} else {
this.chatLoadingStates.delete(convId);
if (convId === conversationsStore.activeConversation?.id) this.isLoading = false;
this.setChatReasoning(convId, false);
}
}
private setChatReasoning(convId: string, reasoning: boolean): void {
if (reasoning) {
this.chatReasoningStates.set(convId, true);
if (convId === conversationsStore.activeConversation?.id) this.isReasoning = true;
} else {
this.chatReasoningStates.delete(convId);
if (convId === conversationsStore.activeConversation?.id) this.isReasoning = false;
}
}
private setChatStreaming(convId: string, response: string, messageId: string): void {
@@ -110,6 +124,7 @@ class ChatStore {
}
syncLoadingStateForChat(convId: string): void {
this.isLoading = this.chatLoadingStates.get(convId) || false;
this.isReasoning = this.chatReasoningStates.get(convId) || false;
const s = this.chatStreamingStates.get(convId);
this.currentResponse = s?.response || '';
this.isStreamingActive = s !== undefined;
@@ -265,6 +280,10 @@ class ChatStore {
return this.chatLoadingStates.get(convId) || false;
}
isChatReasoningPublic(convId: string): boolean {
return this.chatReasoningStates.get(convId) || false;
}
private isChatLoadingInternal(convId: string): boolean {
return this.chatLoadingStates.has(convId) || this.chatStreamingStates.has(convId);
}
@@ -655,6 +674,17 @@ class ChatStore {
}
};
let completionIdRecorded = false;
const recordCompletionId = (id: string): void => {
if (!id || completionIdRecorded) return;
completionIdRecorded = true;
const idx = conversationsStore.findMessageIndex(currentMessageId);
conversationsStore.updateMessageAtIndex(idx, { completionId: id });
DatabaseService.updateMessage(currentMessageId, { completionId: id }).catch(() => {
completionIdRecorded = false;
});
};
const updateStreamingUI = () => {
this.setChatStreaming(convId, streamedContent, currentMessageId);
const idx = conversationsStore.findMessageIndex(currentMessageId);
@@ -676,6 +706,7 @@ class ChatStore {
onChunk: (chunk: string) => {
streamedContent += chunk;
updateStreamingUI();
this.setChatReasoning(convId, false);
},
onReasoningChunk: (chunk: string) => {
streamedReasoningContent += chunk;
@@ -685,6 +716,7 @@ class ChatStore {
conversationsStore.updateMessageAtIndex(idx, {
reasoningContent: streamedReasoningContent
});
this.setChatReasoning(convId, true);
},
onToolCallsStreaming: (toolCalls) => {
const idx = conversationsStore.findMessageIndex(currentMessageId);
@@ -702,6 +734,7 @@ class ChatStore {
DatabaseService.updateMessage(messageId, { extra: updatedExtras }).catch(console.error);
},
onModel: (modelName: string) => recordModel(modelName),
onCompletionId: (id: string) => recordCompletionId(id),
onTurnComplete: (intermediateTimings: ChatMessageTimings) => {
// Update the first assistant message with cumulative agentic timings
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
@@ -887,6 +920,7 @@ class ChatStore {
onChunk: streamCallbacks.onChunk,
onReasoningChunk: streamCallbacks.onReasoningChunk,
onModel: streamCallbacks.onModel,
onCompletionId: streamCallbacks.onCompletionId,
onTimings: streamCallbacks.onTimings,
onComplete: async (
finalContent?: string,
@@ -1373,6 +1407,7 @@ class ChatStore {
appendedContent += chunk;
hasReceivedContent = true;
updateStreamingContent(originalContent + appendedContent);
this.setChatReasoning(msg.convId, false);
},
onReasoningChunk: (chunk: string) => {
appendedReasoning += chunk;
@@ -1382,6 +1417,7 @@ class ChatStore {
conversationsStore.updateMessageAtIndex(idx, {
reasoningContent: originalReasoning + appendedReasoning
});
this.setChatReasoning(msg.convId, true);
},
onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
const tokensPerSecond =
@@ -1816,6 +1852,9 @@ class ChatStore {
if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true;
apiOptions.enableThinking = conversationsStore.getThinkingEnabled();
apiOptions.reasoningEffort = conversationsStore.getReasoningEffort();
if (hasValue(currentConfig.temperature))
apiOptions.temperature = Number(currentConfig.temperature);
@@ -1924,6 +1963,7 @@ export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(c
export const isChatStreaming = () => chatStore.isStreaming();
export const isEditing = () => chatStore.isEditing();
export const isLoading = () => chatStore.isLoading;
export const isReasoning = () => chatStore.isReasoning;
export const pendingEditMessageId = () => chatStore.pendingEditMessageId;
export const chatHasPendingMessage = (convId: string) => chatStore.hasPendingMessage(convId);
export const chatPendingMessageContent = (convId: string) =>
+136 -3
View File
@@ -26,7 +26,7 @@ import { MigrationService } from '$lib/services/migration.service';
import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
import type { McpServerOverride } from '$lib/types/database';
import { MessageRole, HtmlInputType, FileExtensionText } from '$lib/enums';
import { MessageRole, HtmlInputType, FileExtensionText, ReasoningEffort } from '$lib/enums';
import {
ISO_DATE_TIME_SEPARATOR,
ISO_DATE_TIME_SEPARATOR_REPLACEMENT,
@@ -38,7 +38,9 @@ import {
ISO_TIME_SEPARATOR_REPLACEMENT,
NON_ALPHANUMERIC_REGEX,
MULTIPLE_UNDERSCORE_REGEX,
MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY
MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY,
THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY,
REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY
} from '$lib/constants';
import { ROUTES } from '$lib/constants/routes';
@@ -74,6 +76,12 @@ class ConversationsStore {
/** Pending MCP server overrides for new conversations (before first message) */
pendingMcpServerOverrides = $state<McpServerOverride[]>(ConversationsStore.loadMcpDefaults());
/** Global (non-conversation-specific) thinking toggle default */
pendingThinkingEnabled = $state(ConversationsStore.loadThinkingDefaults());
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault());
/** Load MCP default overrides from localStorage */
private static loadMcpDefaults(): McpServerOverride[] {
if (typeof globalThis.localStorage === 'undefined') return [];
@@ -104,6 +112,45 @@ class ConversationsStore {
}
}
/** Load thinking-enabled default from localStorage */
private static loadThinkingDefaults(): boolean {
if (typeof globalThis.localStorage === 'undefined') return false;
try {
const raw = localStorage.getItem(THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY);
if (!raw) return false;
const parsed = raw === 'true';
return typeof parsed === 'boolean' ? parsed : false;
} catch {
return false;
}
}
/** Persist thinking-enabled default to localStorage */
private saveThinkingDefaults(): void {
if (typeof globalThis.localStorage === 'undefined') return;
localStorage.setItem(
THINKING_ENABLED_DEFAULT_LOCALSTORAGE_KEY,
this.pendingThinkingEnabled ? 'true' : 'false'
);
}
/** Load reasoning effort default from localStorage */
private static loadReasoningEffortDefault(): ReasoningEffort {
if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.MEDIUM;
try {
const raw = localStorage.getItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY);
return (raw as ReasoningEffort) || ReasoningEffort.MEDIUM;
} catch {
return ReasoningEffort.MEDIUM;
}
}
/** Persist reasoning effort default to localStorage */
private saveReasoningEffortDefaults(): void {
if (typeof globalThis.localStorage === 'undefined') return;
localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, this.pendingReasoningEffort);
}
/** Callback for title update confirmation dialog */
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
@@ -253,6 +300,12 @@ class ConversationsStore {
this.pendingMcpServerOverrides = [];
}
// Inherit global thinking default into the new conversation
conversation.thinkingEnabled = this.pendingThinkingEnabled;
await DatabaseService.updateConversation(conversation.id, {
thinkingEnabled: this.pendingThinkingEnabled
});
this.conversations = [conversation, ...this.conversations];
this.activeConversation = conversation;
this.activeMessages = [];
@@ -276,6 +329,7 @@ class ConversationsStore {
}
this.pendingMcpServerOverrides = [];
this.pendingThinkingEnabled = false;
this.activeConversation = conversation;
if (conversation.currNode) {
@@ -304,8 +358,9 @@ class ConversationsStore {
clearActiveConversation(): void {
this.activeConversation = null;
this.activeMessages = [];
// reload MCP defaults so new chats inherit persisted state
// reload defaults so new chats inherit persisted state
this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults();
this.pendingThinkingEnabled = ConversationsStore.loadThinkingDefaults();
}
/**
@@ -703,6 +758,84 @@ class ConversationsStore {
this.saveMcpDefaults();
}
/**
* Gets the effective thinking-enabled state for the active conversation.
* Returns the conversation override if set, otherwise the global default.
*/
getThinkingEnabled(): boolean {
if (this.activeConversation) {
return this.activeConversation.thinkingEnabled ?? this.pendingThinkingEnabled;
}
return this.pendingThinkingEnabled;
}
/**
* Sets the thinking-enabled state for the active conversation.
* If no conversation exists, stores the global default.
* @param enabled - The enabled state
*/
async setThinkingEnabled(enabled: boolean): Promise<void> {
if (!this.activeConversation) {
this.pendingThinkingEnabled = enabled;
this.saveThinkingDefaults();
return;
}
this.activeConversation = {
...this.activeConversation,
thinkingEnabled: enabled
};
await DatabaseService.updateConversation(this.activeConversation.id, {
thinkingEnabled: enabled
});
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
if (convIndex !== -1) {
this.conversations[convIndex].thinkingEnabled = enabled;
this.conversations = [...this.conversations];
}
}
/**
* Gets the effective reasoning effort for the active conversation.
* Returns the conversation override if set, otherwise the global default.
*/
getReasoningEffort(): ReasoningEffort {
if (this.activeConversation) {
return this.activeConversation.reasoningEffort ?? this.pendingReasoningEffort;
}
return this.pendingReasoningEffort;
}
/**
* Sets the reasoning effort for the active conversation.
* If no conversation exists, stores the global default.
* @param effort - The effort level ('low' | 'medium' | 'high' | 'max')
*/
async setReasoningEffort(effort: ReasoningEffort): Promise<void> {
if (!this.activeConversation) {
this.pendingReasoningEffort = effort;
this.saveReasoningEffortDefaults();
return;
}
this.activeConversation = {
...this.activeConversation,
reasoningEffort: effort
};
await DatabaseService.updateConversation(this.activeConversation.id, {
reasoningEffort: effort
});
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
if (convIndex !== -1) {
this.conversations[convIndex].reasoningEffort = effort;
this.conversations = [...this.conversations];
}
}
/**
* Forks a conversation at a specific message, creating a new conversation
* containing messages from root up to the target message, then navigates to it.
+70
View File
@@ -4,6 +4,10 @@ import { ServerModelStatus, ModelModality } from '$lib/enums';
import { ModelsService } from '$lib/services/models.service';
import { PropsService } from '$lib/services/props.service';
import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
import {
detectThinkingSupport,
detectThinkingSupportWithReason
} from '$lib/utils/chat-template-thinking-detector';
import { TTLCache } from '$lib/utils';
import {
MODEL_PROPS_CACHE_TTL_MS,
@@ -215,6 +219,67 @@ class ModelsStore {
return usage !== undefined && usage.size > 0;
}
//
// Thinking Support Detection
//
/**
* Whether the selected model's chat template supports thinking/reasoning.
* Uses heuristic detection on the model's chat_template from /props.
*
* - MODEL mode: uses serverStore.props.chat_template (single loaded model)
* - ROUTER mode: fetches /props?model=<id> for the selected model (cached)
*
* Triggers an async fetch of model props if not yet cached in ROUTER mode.
*/
get supportsThinking(): boolean {
const modelId = this.selectedModelName;
if (!modelId) {
if (!isRouterMode()) {
return detectThinkingSupport(serverStore.props?.chat_template ?? '');
}
return false;
}
if (isRouterMode() && !this.modelPropsCache.get(modelId)) {
this.fetchModelProps(modelId);
}
const props = this.getModelProps(modelId);
return detectThinkingSupport(props?.chat_template ?? '');
}
/**
* Check if a specific model supports thinking.
* Fetches model props if not cached (in router mode).
*/
checkModelSupportsThinking(modelId: string): boolean {
if (!modelId) return false;
if (isRouterMode() && !this.modelPropsCache.get(modelId)) {
this.fetchModelProps(modelId);
}
const props = this.getModelProps(modelId);
return detectThinkingSupport(props?.chat_template ?? '');
}
/**
* Detailed thinking support detection result with reason for debugging/UI.
*/
get thinkingSupportDetails(): { supported: boolean; reason: string } {
const modelId = this.selectedModelName;
if (!modelId) {
if (!isRouterMode()) {
return detectThinkingSupportWithReason(serverStore.props?.chat_template ?? '');
}
return { supported: false, reason: 'No model selected' };
}
if (isRouterMode() && !this.modelPropsCache.get(modelId)) {
this.fetchModelProps(modelId);
}
const props = this.getModelProps(modelId);
return detectThinkingSupportWithReason(props?.chat_template ?? '');
}
/**
*
@@ -362,6 +427,7 @@ class ModelsStore {
try {
const props = await PropsService.fetchForModel(modelId);
this.modelPropsCache.set(modelId, props);
this.propsCacheVersion++;
return props;
} catch (error) {
console.warn(`Failed to fetch props for model ${modelId}:`, error);
@@ -755,3 +821,7 @@ export const propsCacheVersion = () => modelsStore.propsCacheVersion;
export const singleModelName = () => modelsStore.singleModelName;
export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
export const favoriteModelIds = () => modelsStore.favoriteModelIds;
export const supportsThinking = () => modelsStore.supportsThinking;
export const checkModelSupportsThinking = (modelId: string) =>
modelsStore.checkModelSupportsThinking(modelId);
export const thinkingSupportDetails = () => modelsStore.thinkingSupportDetails;
+2 -28
View File
@@ -82,8 +82,8 @@ class ServerStore {
this.props = props;
this.error = null;
this.detectRole(props);
} catch (error) {
this.error = this.getErrorMessage(error);
} catch (error: unknown) {
this.error = error instanceof Error ? error.message : String(error);
console.error('Error fetching server properties:', error);
} finally {
this.loading = false;
@@ -95,32 +95,6 @@ class ServerStore {
await fetchPromise;
}
private getErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message || '';
if (error.name === 'TypeError' && message.includes('fetch')) {
return 'Server is not running or unreachable';
} else if (message.includes('ECONNREFUSED')) {
return 'Connection refused - server may be offline';
} else if (message.includes('ENOTFOUND')) {
return 'Server not found - check server address';
} else if (message.includes('ETIMEDOUT')) {
return 'Request timed out';
} else if (message.includes('503')) {
return 'Server temporarily unavailable';
} else if (message.includes('500')) {
return 'Server error - check server logs';
} else if (message.includes('404')) {
return 'Server endpoint not found';
} else if (message.includes('403') || message.includes('401')) {
return 'Access denied';
}
}
return 'Failed to connect to server';
}
clear(): void {
this.props = null;
this.error = null;
+1
View File
@@ -93,6 +93,7 @@ export interface AgenticFlowCallbacks {
onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void;
/** Model name detected from response */
onModel?: (model: string) => void;
onCompletionId?: (id: string) => void;
/** Current assistant turn's streaming is complete - save to DB */
onAssistantTurnComplete?: (
content: string,
+1
View File
@@ -271,6 +271,7 @@ export interface ApiChatCompletionToolCall extends ApiChatCompletionToolCallDelt
}
export interface ApiChatCompletionStreamChunk {
id?: string;
object?: string;
model?: string;
choices: Array<{
+1
View File
@@ -97,6 +97,7 @@ export interface ChatStreamCallbacks {
onToolCallsStreaming?: (toolCalls: ApiChatCompletionToolCall[]) => void;
onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void;
onModel?: (model: string) => void;
onCompletionId?: (id: string) => void;
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
onAssistantTurnComplete?: (
content: string,
+5 -1
View File
@@ -1,5 +1,5 @@
import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat';
import { AttachmentType } from '$lib/enums';
import { AttachmentType, ReasoningEffort } from '$lib/enums';
export interface McpServerOverride {
serverId: string;
@@ -12,6 +12,8 @@ export interface DatabaseConversation {
lastModified: number;
name: string;
mcpServerOverrides?: McpServerOverride[];
thinkingEnabled?: boolean;
reasoningEffort?: ReasoningEffort;
forkedFromConversationId?: string;
}
@@ -112,6 +114,8 @@ export interface DatabaseMessage {
reasoningContent?: string;
/** Serialized JSON array of tool calls made by assistant messages */
toolCalls?: string;
/** Chat completion id streamed by the server, used to target realtime control (e.g. end reasoning) */
completionId?: string;
/** Tool call ID for tool result messages (role: 'tool') */
toolCallId?: string;
children: string[];
+3
View File
@@ -162,3 +162,6 @@ export type {
// Tools types
export type { ToolEntry, ToolGroup } from './tools';
// Reasoning
export type { ReasoningEffortLevel } from './reasoning';
+6
View File
@@ -0,0 +1,6 @@
export interface ReasoningEffortLevel {
value: string;
label: string;
isOff?: boolean;
hasInfo?: boolean;
}
+11 -1
View File
@@ -2,7 +2,12 @@ import type { SETTING_CONFIG_DEFAULT, SETTINGS_SECTION_TITLES } from '$lib/const
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
import type { OpenAIToolDefinition } from './mcp';
import type { DatabaseMessageExtra } from './database';
import type { ParameterSource, SyncableParameterType, SettingsFieldType } from '$lib/enums';
import type {
ParameterSource,
ReasoningEffort,
SyncableParameterType,
SettingsFieldType
} from '$lib/enums';
import type { Icon } from '@lucide/svelte';
import type { Component } from 'svelte';
@@ -65,6 +70,10 @@ export interface SettingsChatServiceOptions {
disableReasoningParsing?: boolean;
// Strip reasoning content from context before sending
excludeReasoningFromContext?: boolean;
// Enable model thinking/reasoning via chat_template_kwargs
enableThinking?: boolean;
// Reasoning effort level (low/medium/high/max) for thinking models
reasoningEffort?: ReasoningEffort;
tools?: OpenAIToolDefinition[];
// Generation parameters
temperature?: number;
@@ -101,6 +110,7 @@ export interface SettingsChatServiceOptions {
onToolCallChunk?: (chunk: string) => void;
onAttachments?: (extras: DatabaseMessageExtra[]) => void;
onModel?: (model: string) => void;
onCompletionId?: (id: string) => void;
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
onComplete?: (
response: string,
+52 -9
View File
@@ -1,6 +1,7 @@
import { base } from '$app/paths';
import { getJsonHeaders, getAuthHeaders } from './api-headers';
import { UrlProtocol } from '$lib/enums';
import { ERROR_MESSAGES, HTTP_CODE_TO_STRING } from '$lib/constants/error';
/**
* API Fetch Utilities
@@ -54,10 +55,15 @@ export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}):
? path
: `${base}${path}`;
const response = await fetch(url, {
...fetchOptions,
headers
});
let response;
try {
response = await fetch(url, {
...fetchOptions,
headers
});
} catch (e) {
throw new Error(beautifyNetworkError(e));
}
if (!response.ok) {
const errorMessage = await parseErrorMessage(response);
@@ -101,10 +107,15 @@ export async function apiFetchWithParams<T>(
const baseHeaders = authOnly ? getAuthHeaders() : getJsonHeaders();
const headers = { ...baseHeaders, ...customHeaders };
const response = await fetch(url.toString(), {
...fetchOptions,
headers
});
let response;
try {
response = await fetch(url.toString(), {
...fetchOptions,
headers
});
} catch (e) {
throw new Error(beautifyNetworkError(e));
}
if (!response.ok) {
const errorMessage = await parseErrorMessage(response);
@@ -154,5 +165,37 @@ async function parseErrorMessage(response: Response): Promise<string> {
// JSON parsing failed, use status text
}
return `Request failed: ${response.status} ${response.statusText}`;
const httpErrorStr = HTTP_CODE_TO_STRING[response.status];
if (httpErrorStr) {
return httpErrorStr;
}
return `${ERROR_MESSAGES.HTTP.GENERIC}: ${response.status} ${response.statusText}`;
}
/**
* Converts a network issue into a human-readable message.
* @param throwable - The throwable raised during fetch operation
* @returns Error in an human-readable format
*/
function beautifyNetworkError(throwable: unknown): string {
let message;
if (throwable instanceof Error) {
message = throwable.message;
if (throwable.name === 'TypeError' && message.includes('fetch')) {
return ERROR_MESSAGES.NETWORK.UNREACHABLE;
}
} else {
message = String(throwable);
}
if (message.includes('ECONNREFUSED')) {
return ERROR_MESSAGES.NETWORK.REFUSED;
} else if (message.includes('ENOTFOUND')) {
return ERROR_MESSAGES.NETWORK.NXDOMAIN;
} else if (message.includes('ETIMEDOUT')) {
return ERROR_MESSAGES.NETWORK.TIMEOUT;
}
return `${ERROR_MESSAGES.NETWORK.GENERIC} (${message})`;
}
@@ -0,0 +1,86 @@
/**
* Detects whether a model's chat template supports thinking/reasoning control.
*
* The server "/props" endpoint does NOT expose a supports_thinking flag.
* It is computed internally by common_chat_templates_support_enable_thinking
* in common/chat.cpp. A proper server flag would make this unnecessary.
*
* Detection order (most reliable first):
* 1. Thinking-control Jinja2 variables === pass-through via chat_template_kwargs
* 2. Thinking-control Jinja2 conditionals === template-native on/off logic
* 3. Paired thinking-content tag pairs === models that output special tags
*/
const THINKING_KWARG_VARS = ['enable_thinking', 'reasoning_effort', 'thinking_budget'];
/**
* Paired thinking-content tag patterns.
*
* Inspected: llama-cpp-deepseek-r1/v3, nim-nemotron-{3,4}-nano, qwen-qwq-32b,
* qwen-3-32b, google-gemma-4-31b-it, kimikimi-k2-thinking, apertus-8b-instruct,
* mistralai-Mistral-Small-3.2-24B, ByteDance-Seed-OSS.
*
* The self-closing entry is Kimi-K2, Gemma4 fixed-length pair,
* where both tags always appear adjacent with no content between.
*/
const THINKING_TAG_PATTERNS: Array<[string, string | null]> = [
['<think>', '</think>'],
['<|channel>thought', '<|channel|>'],
['<|think|>', '</|think|>'],
['<seed:think|>', '</seed:think|>'],
['<think></think>', null]
];
const JINJA_THINKING_CONDITIONALS: RegExp[] = [
// Matches: {% if enable thinking %}, {% if enable_thinking %}, {% if (enable_thinking is defined) %}
// Handles: underscore-separated (enable_thinking), space-separated (enable thinking),
// and optional parens/brackets before enable (if (enable_thinking )
/\{%-?\s*if\s+\(?\s*\w*enable[\s_]+\w*(thinking|think|reasoning)/i,
/\{%-?\s*if\s+\w*(thinking|reasoning)\s*(is not|==|!=)/i,
/\{%-?\s*if\s+not\s+\w*enable/i,
/\{%-?\s*if\s+ns\.enable_thinking/i
];
/** Guards against false positives:
* - Generic thought keyword (tool descriptions say "chain of thought")
* - Qwen vertical-bar token (used for ALL tool calls, not thinking)
*/
export function detectThinkingSupport(t: string): boolean {
if (!t) return false;
for (const kwarg of THINKING_KWARG_VARS) {
const regex = new RegExp(
`(\\{\\{[^{}]*\\b${kwarg}\\b[^{}]*\\}\\}|\\{%[^{}]*\\b${kwarg}\\b[^{}]*%\\})`,
'i'
);
if (regex.test(t)) return true;
}
for (const p of JINJA_THINKING_CONDITIONALS) {
if (p.test(t)) return true;
}
for (const [s, e] of THINKING_TAG_PATTERNS) {
if (t.includes(s) && (!e || t.includes(e))) return true;
}
return false;
}
export function detectThinkingSupportWithReason(t: string): { supported: boolean; reason: string } {
if (!t) return { supported: false, reason: 'No chat template available' };
for (const kwarg of THINKING_KWARG_VARS) {
const regex = new RegExp(
`(\\{\\{[^{}]*\\b${kwarg}\\b[^{}]*\\}\\}|\\{%[^{}]*\\b${kwarg}\\b[^{}]*%\\})`,
'i'
);
if (regex.test(t)) {
return { supported: true, reason: 'Found: ' + kwarg };
}
}
for (const p of JINJA_THINKING_CONDITIONALS) {
if (p.test(t)) return { supported: true, reason: 'Found: thinking conditional' };
}
for (const [s, e] of THINKING_TAG_PATTERNS) {
if (t.includes(s) && (!e || t.includes(e))) {
return { supported: true, reason: 'Found: ' + s + (e ? ' .. ' + e : ' (self)') };
}
}
return { supported: false, reason: 'No thinking patterns found' };
}