]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: Add Thinking mode toggle with reasoning effort levels + improvements for Chat...
authorAleksander Grygier <redacted>
Tue, 2 Jun 2026 08:23:19 +0000 (10:23 +0200)
committerGitHub <redacted>
Tue, 2 Jun 2026 08:23:19 +0000 (10:23 +0200)
* 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

40 files changed:
common/arg.cpp
tools/ui/.env.example
tools/ui/.prettierignore
tools/ui/README.md
tools/ui/playwright.config.ts
tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddSheet.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionModels.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/index.ts
tools/ui/src/lib/components/app/models/ModelId.svelte
tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte
tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte
tools/ui/src/lib/components/app/models/index.ts
tools/ui/src/lib/constants/attachment-menu.ts
tools/ui/src/lib/constants/index.ts
tools/ui/src/lib/constants/reasoning-effort-tokens.ts [new file with mode: 0644]
tools/ui/src/lib/constants/reasoning-effort.ts [new file with mode: 0644]
tools/ui/src/lib/constants/settings-keys.ts
tools/ui/src/lib/constants/settings-registry.ts
tools/ui/src/lib/constants/storage.ts
tools/ui/src/lib/enums/index.ts
tools/ui/src/lib/enums/reasoning-effort.enums.ts [new file with mode: 0644]
tools/ui/src/lib/hooks/use-tools-panel.svelte.ts
tools/ui/src/lib/services/chat.service.ts
tools/ui/src/lib/stores/chat.svelte.ts
tools/ui/src/lib/stores/conversations.svelte.ts
tools/ui/src/lib/stores/models.svelte.ts
tools/ui/src/lib/types/database.d.ts
tools/ui/src/lib/types/index.ts
tools/ui/src/lib/types/reasoning.ts [new file with mode: 0644]
tools/ui/src/lib/types/settings.d.ts
tools/ui/src/lib/utils/chat-template-thinking-detector.ts [new file with mode: 0644]
tools/ui/tests/e2e/demo.test.ts
tools/ui/tests/stories/ChatScreenForm.a11y.stories.svelte
tools/ui/vite.config.ts

index 2df446d9d6c620cb0a4ddaa309cead383069f194..feffb4c12ef8d72061522cfcddc9588ccaa3f8b9 100644 (file)
@@ -4077,7 +4077,6 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
             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}));
 
@@ -4096,7 +4095,6 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
             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}));
 
index 9a995b746f6567efa1088c26e7755704960e216c..a1e6c54915baddc49f2cf704491cac65d9b13664 100644 (file)
@@ -1,2 +1,3 @@
 VITE_PUBLIC_APP_NAME='llama-ui'
-# VITE_DEBUG='true'
+VITE_DEBUG='true'
+VITE_PUBLIC_SERVER_ORIGIN='http://localhost:8033'
index 14b01e961bd27f2e72de66a5b9f8c6cf4a42d5ac..7bbdcf6a0963f9568d05ad8e75a65794fff47d33 100644 (file)
@@ -7,6 +7,9 @@ bun.lockb
 
 # Miscellaneous
 /static/
+dist/
+.svelte-kit/
+build/
 
 # Build output
 /dist/
index 4334f968162d97223d906ed0457dd2ee55c2fee2..1b99ebbfe80631d8374f8003c4e4ef11dd804403 100644 (file)
@@ -115,16 +115,18 @@ This starts:
 - **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
index 178fd7ba856b950b525045b50c7355e06f7554bb..4603792385914bb0a4815c83961c88991968867a 100644 (file)
@@ -2,7 +2,7 @@ import { defineConfig } from '@playwright/test';
 
 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
index 99faecb6b4128e4c8ed38d6e85b203bea9d23bcd..ed26f9ea58e431e61e16580a84e1d2c01c4b26f5 100644 (file)
        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;
index 0a8260a2952b182804fe266cbe5a79aed040bf6b..47954032117a3cb87b7c093d91c9f5f98b378937 100644 (file)
@@ -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 {
                </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>
index 5ca5973668cc4d6909a4c1f4f3172f3da17ce4f5..9adb9eb89d87f14fe894c28c7687f1bc889f4948 100644 (file)
@@ -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(
                () => ({
                }
        );
 
+       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>
index b11467da823671f38efcde8e97271538fc8c3b98..813227fbce08dac82dcabb41f327ce520363eb12 100644 (file)
                                                                        <Checkbox
                                                                                {checked}
                                                                                {indeterminate}
-                                                                               onCheckedChange={() => toolsStore.toggleGroup(group)}
+                                                                               onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
                                                                                class="mr-2 h-4 w-4 shrink-0"
                                                                        />
                                                                </Tooltip.Trigger>
index fd866b243eac694f076d1bfc7c8bd2af3dfdc2b9..712326cba60868aa49e54207f14c246795af328a 100644 (file)
@@ -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';
 
        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);
                }
        });
index 627107ef5023644d16f8b5a6be3cfcc6db0a1903..a80f00bc6787207e6607d6e213ce168d51d4727c 100644 (file)
@@ -6,7 +6,8 @@
                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
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningEffortSubmenu.svelte
new file mode 100644 (file)
index 0000000..936e037
--- /dev/null
@@ -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}
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormReasoningToggle.svelte
new file mode 100644 (file)
index 0000000..f6bcbcb
--- /dev/null
@@ -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}
index be5535960247a08643b907beb77f9fee69949c09..8ed3cc65ec80743586ad36d89e83a70ea57501e8 100644 (file)
@@ -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.
  */
index 2fe952feea066dffc85122c90981ff451def048d..f566b55ee88b6db7941501fba336adefbfee1679 100644 (file)
@@ -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}
index 998a6a0faaad6316bec6264124fabc4f82330172..0f1fba880979328fa00c6561a224c694e66b47e1 100644 (file)
                                ]}
                                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}
index d38ed8c0752f1a1b475e173ab80d3a44586c178c..2ddbf24055b8b01fc4d11687a869bc4d498e76dc 100644 (file)
@@ -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>
                                                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}
index a6ba6817fc082a2e33e822223cdf59a6bed65eac..3ac6ecb678b257af12e49c2951704a23169219f8 100644 (file)
@@ -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.
index 966f46f41d08f67216b98ae8fa3a8f58113a1461..3d7381812e5d04bce2c799949350f0f8aca068c3 100644 (file)
@@ -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',
index eb85910370fbb57d78bed4a8634dad7c45beeed6..54f39feee7b4403755889093affd318d90a366d5 100644 (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';
diff --git a/tools/ui/src/lib/constants/reasoning-effort-tokens.ts b/tools/ui/src/lib/constants/reasoning-effort-tokens.ts
new file mode 100644 (file)
index 0000000..059af71
--- /dev/null
@@ -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
+};
diff --git a/tools/ui/src/lib/constants/reasoning-effort.ts b/tools/ui/src/lib/constants/reasoning-effort.ts
new file mode 100644 (file)
index 0000000..d854e91
--- /dev/null
@@ -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 }
+];
index 7cdd2db7c4d78df547fef3f61fe6ad4ac2ad1233..5fff9f94c2ca005519af5487816382d7c3504357 100644 (file)
@@ -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',
index 20ac33c85f803c1f2f652c1db0cd25eb2cbc14f7..9246b970335aa50115c7334760a9463f103efc83 100644 (file)
@@ -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',
index d03254b9ccb1deec595b10e067bd4f681cdd90fb..5d33e82f30d05ef8570009e3d88659444b1154d3 100644 (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)
index b80b5b61ed85f95c1024a090ccddfd255fd3fe7a..7438a76429f2b359702263abcc190c805adb68ea 100644 (file)
@@ -19,6 +19,8 @@ export {
        ReasoningFormat
 } from './chat.enums';
 
+export { ReasoningEffort } from './reasoning-effort.enums';
+
 export {
        FileTypeCategory,
        FileTypeImage,
diff --git a/tools/ui/src/lib/enums/reasoning-effort.enums.ts b/tools/ui/src/lib/enums/reasoning-effort.enums.ts
new file mode 100644 (file)
index 0000000..dadb0c7
--- /dev/null
@@ -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'
+}
index 911af322a7f65b719e9b560e4559270bfc2e9dfa..9a8acec0fa7450c92d68326ce28089358fccbeaa 100644 (file)
@@ -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
        };
 }
index 09d616bd76aa83d8dca53af271883564c237d47f..468009a54c8694b5673467bfbcce3aa016777dba 100644 (file)
@@ -6,6 +6,7 @@ import {
        ATTACHMENT_LABEL_MCP_PROMPT,
        ATTACHMENT_LABEL_MCP_RESOURCE,
        LEGACY_AGENTIC_REGEX,
+       REASONING_EFFORT_TOKENS,
        SETTINGS_KEYS,
        API_CHAT,
        API_SLOTS,
@@ -162,6 +163,8 @@ export class ChatService {
                        // Config options
                        disableReasoningParsing,
                        excludeReasoningFromContext,
+                       enableThinking,
+                       reasoningEffort,
                        continueFinalMessage
                } = options;
 
@@ -243,6 +246,18 @@ 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;
 
index 667fb417c31eb6337f39e1ac6f956d551105437d..b899130e50a20a4dcc34866ef215e48b8b404555 100644 (file)
@@ -1852,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);
 
index d6589232f0f4e3718d634f7dbddee90e662c0299..cab6f59faebdffe06ca52afebf97dbad0906e8c4 100644 (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.
index bc99d7412cd37805be71584337ad947c88cba952..1990ba6049de69f43e7b1afa597b50f6ff3b2bf3 100644 (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;
index aecc250e811a0948434f66e046dc3d36934266b9..add628476b74e0e022a85b7df6b866443edce018 100644 (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;
 }
 
index 0eb1e670133ca848b48aaebc64ffd8d93eae779d..b88de5d66d96a7677faf344971a7482a79cc0123 100644 (file)
@@ -162,3 +162,6 @@ export type {
 
 // Tools types
 export type { ToolEntry, ToolGroup } from './tools';
+
+// Reasoning
+export type { ReasoningEffortLevel } from './reasoning';
diff --git a/tools/ui/src/lib/types/reasoning.ts b/tools/ui/src/lib/types/reasoning.ts
new file mode 100644 (file)
index 0000000..b72a3b7
--- /dev/null
@@ -0,0 +1,6 @@
+export interface ReasoningEffortLevel {
+       value: string;
+       label: string;
+       isOff?: boolean;
+       hasInfo?: boolean;
+}
index 44e83f8f33c925eef75e914889b9c8a087efd01f..d1cdca957cffa72f86dc5add3e5bf996dd16e7eb 100644 (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;
diff --git a/tools/ui/src/lib/utils/chat-template-thinking-detector.ts b/tools/ui/src/lib/utils/chat-template-thinking-detector.ts
new file mode 100644 (file)
index 0000000..da6382f
--- /dev/null
@@ -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' };
+}
index b7b4bac335170371a11bac6309497dd3674391b5..99a3e86da3d4ebfa63246d0599ed57372e45ea14 100644 (file)
@@ -1,6 +1,7 @@
 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();
 });
index 5d28f420e573043cb431882cbb236a96f05afc3c..46f852c06641cbb7f63c1079f5c3ecbb2257767c 100644 (file)
@@ -44,7 +44,7 @@
                await screen.findByRole('menu');
 
                await waitFor(() => {
-                       expect(document.activeElement).toHaveTextContent('Text Files');
+                       expect(document.activeElement).toHaveTextContent('Add files');
                });
        }}
 />
index f89a689d5e6d755e4af3824e8ff17e3bef2c7cdb..5b57eae3ad549de5b04fbb8696bcf94e1c68e5ce 100644 (file)
@@ -10,6 +10,8 @@ import { llamaCppBuildPlugin } from './scripts/vite-plugin-llama-cpp-build';
 
 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: {
@@ -75,12 +77,12 @@ export default defineConfig({
 
        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',