* feat: Add "Thinking" toggle and status icon + redesign Chat Form Actions Add panel
* test: Update test reference
* fix: Icon
* fix: E2E test command
* fix: wait for greeting h1 to be visible in e2e test
* fix: remove duplicate PDF option in attachment dropdown
* fix: use label-based group toggle to avoid stale references
* refactor: inline MCP server and tool toggles in mobile sheet
* fix: serve correct build directory in e2e playwright config
* feat: add reasoning effort levels selector in model dropdown
* feat: Reasoning effort
* refactor: Make server origin configurable via environment variable
* feat: Add chat template thinking detector utility
* feat: Add thinking support detection to models store
* refactor: Update model selector components with thinking detection and message-specific indicators
* feat: Update chat form components for model selection and thinking support
* feat: Improve Reasoning controls UI
* refactor: Apply suggestions from code review
Co-authored-by: Aleksander Grygier <redacted>
* fix: Model tags
* refactor: Cleanup
* refactor: Remove unneeded components
* refactor: Cleanup
params.sampling.top_k = 0;
params.sampling.min_p = 0.01f;
params.use_jinja = true;
- //params.default_template_kwargs["reasoning_effort"] = "\"high\"";
}
).set_examples({LLAMA_EXAMPLE_SERVER, LLAMA_EXAMPLE_CLI}));
params.sampling.top_k = 0;
params.sampling.min_p = 0.01f;
params.use_jinja = true;
- //params.default_template_kwargs["reasoning_effort"] = "\"high\"";
}
).set_examples({LLAMA_EXAMPLE_SERVER, LLAMA_EXAMPLE_CLI}));
VITE_PUBLIC_APP_NAME='llama-ui'
-# VITE_DEBUG='true'
+VITE_DEBUG='true'
+VITE_PUBLIC_SERVER_ORIGIN='http://localhost:8033'
# Miscellaneous
/static/
+dist/
+.svelte-kit/
+build/
# Build output
/dist/
- **Vite dev server** at `http://localhost:5173` - The main UI frontend app
- **Storybook** at `http://localhost:6006` - Component documentation
-The Vite dev server proxies API requests to `http://localhost:8080` (default llama-server port):
+The Vite dev server proxies API requests to `SERVER_ORIGIN` (with fallback to default llama-server `8080` port):
```typescript
// vite.config.ts proxy configuration
proxy: {
- '/v1': 'http://localhost:8080',
- '/props': 'http://localhost:8080',
- '/slots': 'http://localhost:8080',
- '/models': 'http://localhost:8080'
-}
+ '/v1': SERVER_ORIGIN,
+ '/props': SERVER_ORIGIN,
+ '/models': SERVER_ORIGIN,
+ '/tools': SERVER_ORIGIN,
+ '/slots': SERVER_ORIGIN,
+ '/cors-proxy': SERVER_ORIGIN
+},
```
### Development Workflow
export default defineConfig({
webServer: {
- command: 'npm run build && http-server ../../build/tools/ui/dist -p 8181',
+ command: 'npm run build && npx http-server ./dist -p 8181',
port: 8181,
timeout: 120000,
reuseExistingServer: false
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;
<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 {
</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" />
-
- <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>{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.Sub>
+ <DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
+ <File class="h-4 w-4" />
+
+ <span>Add files</span>
+ </DropdownMenu.SubTrigger>
+
+ <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" />
-
- <span>{pdfItem.label}</span>
- {/if}
- </DropdownMenu.Item>
- {/snippet}
- </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}
+ <item.icon class="h-4 w-4" />
- <DropdownMenu.Separator />
-
- {#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" />
-
- <span>{item.label}</span>
- </DropdownMenu.Item>
- {/snippet}
- </Tooltip.Trigger>
-
- <Tooltip.Content side="right">
- <p>{attachmentMenu.getSystemMessageTooltip()}</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
- {/each}
+ <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>{item.label}</span>
+ </DropdownMenu.Item>
+ </div>
+ {/snippet}
+ </Tooltip.Trigger>
+
+ <Tooltip.Content side="right">
+ <p>{item.disabledTooltip}</p>
+ </Tooltip.Content>
+ </Tooltip.Root>
+ {/if}
+ {/each}
+ </DropdownMenu.SubContent>
+ </DropdownMenu.Sub>
+
+ <DropdownMenu.Item
+ class="flex cursor-pointer items-center gap-2"
+ onclick={onSystemPromptClick}
+ >
+ <MessageSquare class="h-4 w-4" />
+
+ <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>
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;
}: Props = $props();
let sheetOpen = $state(false);
+ let filesExpanded = $state(true);
+ let toolsExpanded = $state(false);
+ let mcpExpanded = $state(false);
const attachmentMenu = useAttachmentMenu(
() => ({
}
);
+ 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>
</Sheet.Header>
<div class="flex flex-col gap-1 px-1.5 pb-2">
- {#each ATTACHMENT_FILE_ITEMS as item (item.id)}
- {@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
- {#if enabled}
- <button
- type="button"
- class={sheetItemClass}
- onclick={() => attachmentMenu.callbacks[item.action]()}
- >
- <item.icon class="h-4 w-4 shrink-0" />
-
- <span>{item.label}</span>
- </button>
- {:else if item.disabledTooltip}
- <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
- <Tooltip.Trigger>
- <button type="button" class={sheetItemClass} disabled>
- <item.icon class="h-4 w-4 shrink-0" />
-
- <span>{item.label}</span>
- </button>
- </Tooltip.Trigger>
-
- <Tooltip.Content side="right">
- <p>{item.disabledTooltip}</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
- {/each}
-
- {#if !attachmentMenu.isItemEnabled('hasVisionModality')}
- {@const pdfItem = ATTACHMENT_FILE_ITEMS.find((i) => i.id === AttachmentMenuItemId.PDF)}
- {#if pdfItem}
- <Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
- <Tooltip.Trigger>
+ <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}
+
+ <File class="h-4 w-4 shrink-0" />
+
+ <span class="flex-1">Add files</span>
+ </Collapsible.Trigger>
+
+ <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)}
+
<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 class="min-w-0 truncate text-sm">{displayName}</span>
+ </div>
- <span>{pdfItem.label}</span>
+ {#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>
-
- <Tooltip.Content side="right">
- <p>{attachmentMenu.getSystemMessageTooltip()}</p>
- </Tooltip.Content>
- </Tooltip.Root>
- {/if}
- {/each}
-
- <div class="my-2 border-t"></div>
-
- <a href={ROUTES.MCP_SERVERS} class="flex items-center gap-3 px-3 py-2">
- <McpLogo class="inline h-4 w-4" />
-
- <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>System Message</span>
+ </button>
+
+ {#if hasMcpPromptsSupport}
+ <button type="button" class={sheetItemClass} onclick={onMcpPromptClick}>
+ <Zap class="h-4 w-4 shrink-0" />
+
+ <span>MCP Prompt</span>
+ </button>
+ {/if}
+
+ {#if hasMcpResourcesSupport}
+ <button type="button" class={sheetItemClass} onclick={onMcpResourcesClick}>
+ <FolderOpen class="h-4 w-4 shrink-0" />
+
+ <span>MCP Resources</span>
+ </button>
+ {/if}
</div>
</Sheet.Content>
</Sheet.Root>
<Checkbox
{checked}
{indeterminate}
- onCheckedChange={() => toolsStore.toggleGroup(group)}
+ onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
class="mr-2 h-4 w-4 shrink-0"
/>
</Tooltip.Trigger>
<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';
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) {
modelsStore.selectedModelName = null;
modelsStore.clearSelection();
}
-
lastSyncedConversationModel = conversationModel;
} else if (
isRouter &&
!conversationModel
) {
lastSyncedConversationModel = null;
-
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
-
if (first) modelsStore.selectModelById(first.id);
}
});
ChatFormActionsAdd,
ChatFormActionModels,
ChatFormActionRecord,
- ChatFormActionSubmit
+ ChatFormActionSubmit,
+ ChatFormReasoningToggle
} from '$lib/components/app';
import { FileTypeCategory } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
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}
</div>
{/if}
- {#if showModelSelector}
- <ChatFormActionModels
- {disabled}
- bind:this={selectorModelRef}
- bind:hasAudioModality
- bind:hasVideoModality
- bind:hasVisionModality
- bind:hasModelSelected
- bind:isSelectedModelInCache
- bind:submitTooltip
- forceForegroundText
- useGlobalSelection
- />
- {/if}
+ <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
--- /dev/null
+<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}
--- /dev/null
+<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}
*/
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.
*/
hideOrgName?: boolean;
showRaw?: boolean;
hideQuantization?: boolean;
+ hideTags?: boolean;
aliases?: string[];
tags?: string[];
class?: string;
modelId,
hideOrgName = false,
showRaw = undefined,
- hideQuantization = false,
+ hideQuantization,
+ hideTags,
aliases,
tags,
class: className = '',
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 ?? [])])]);
</span>
{/if}
- {#if parsed.quantization && !hideQuantization}
+ {#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/each}
{/if}
- {#if uniqueTags.length > 0}
+ {#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
]}
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>
<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
]}
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>
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
hideOrgName={false}
+ hideQuantization
{...props}
/>
{/snippet}
{/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>
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>
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
hideOrgName={false}
+ hideQuantization
{...props}
/>
{/snippet}
{/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}
DialogModelInformation,
ModelId,
ModelsSelectorList,
- SearchInput,
- TruncatedText
+ SearchInput
} from '$lib/components/app';
interface Props {
<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
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>
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>
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}
*/
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.
}
];
-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',
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
hasEnabledTooltip: true,
action: AttachmentAction.SYSTEM_PROMPT_CLICK
- }
-];
-
-export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
+ },
{
id: AttachmentMenuItemId.MCP_PROMPT,
label: 'MCP Prompt',
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',
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';
--- /dev/null
+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
+};
--- /dev/null
+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 }
+];
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',
// 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',
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',
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',
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)
ReasoningFormat
} from './chat.enums';
+export { ReasoningEffort } from './reasoning-effort.enums';
+
export {
FileTypeCategory,
FileTypeImage,
--- /dev/null
+/**
+ * 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'
+}
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;
}
}
}
+ 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();
getFavicon,
isGroupDisabled,
toggleGroupExpanded,
+ toggleGroupByLabel,
handleOpen
};
}
ATTACHMENT_LABEL_MCP_PROMPT,
ATTACHMENT_LABEL_MCP_RESOURCE,
LEGACY_AGENTIC_REGEX,
+ REASONING_EFFORT_TOKENS,
SETTINGS_KEYS,
API_CHAT,
API_SLOTS,
// Config options
disableReasoningParsing,
excludeReasoningFromContext,
+ enableThinking,
+ reasoningEffort,
continueFinalMessage
} = options;
? 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 (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true;
+ apiOptions.enableThinking = conversationsStore.getThinkingEnabled();
+ apiOptions.reasoningEffort = conversationsStore.getReasoningEffort();
+
if (hasValue(currentConfig.temperature))
apiOptions.temperature = Number(currentConfig.temperature);
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,
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';
/** 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 [];
}
}
+ /** 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>;
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 = [];
}
this.pendingMcpServerOverrides = [];
+ this.pendingThinkingEnabled = false;
this.activeConversation = conversation;
if (conversation.currNode) {
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();
}
/**
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.
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,
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 ?? '');
+ }
/**
*
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);
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;
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;
lastModified: number;
name: string;
mcpServerOverrides?: McpServerOverride[];
+ thinkingEnabled?: boolean;
+ reasoningEffort?: ReasoningEffort;
forkedFromConversationId?: string;
}
// Tools types
export type { ToolEntry, ToolGroup } from './tools';
+
+// Reasoning
+export type { ReasoningEffortLevel } from './reasoning';
--- /dev/null
+export interface ReasoningEffortLevel {
+ value: string;
+ label: string;
+ isOff?: boolean;
+ hasInfo?: boolean;
+}
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';
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;
--- /dev/null
+/**
+ * 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' };
+}
import { expect, test } from '@playwright/test';
-test('home page has expected h1', async ({ page }) => {
+test('home page loads correctly', async ({ page }) => {
await page.goto('/');
- await expect(page.locator('h1').first()).toBeVisible();
+ // Wait for the greeting to become visible (stores need time to initialize)
+ await expect(page.locator('h1', { hasText: /Hello there/ })).toBeVisible();
});
await screen.findByRole('menu');
await waitFor(() => {
- expect(document.activeElement).toHaveTextContent('Text Files');
+ expect(document.activeElement).toHaveTextContent('Add files');
});
}}
/>
const __dirname = dirname(fileURLToPath(import.meta.url));
+const SERVER_ORIGIN = import.meta.env?.VITE_PUBLIC_SERVER_ORIGIN || 'http://localhost:8080';
+
export default defineConfig({
resolve: {
alias: {
server: {
proxy: {
- '/v1': 'http://localhost:8080',
- '/props': 'http://localhost:8080',
- '/models': 'http://localhost:8080',
- '/tools': 'http://localhost:8080',
- '/slots': 'http://localhost:8080',
- '/cors-proxy': 'http://localhost:8080'
+ '/v1': SERVER_ORIGIN,
+ '/props': SERVER_ORIGIN,
+ '/models': SERVER_ORIGIN,
+ '/tools': SERVER_ORIGIN,
+ '/slots': SERVER_ORIGIN,
+ '/cors-proxy': SERVER_ORIGIN
},
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',