ui: Add Thinking mode toggle with reasoning effort levels + improvements for Chat Form Add Action UI (#23434)
* 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 <aleksander.grygier@gmail.com> * fix: Model tags * refactor: Cleanup * refactor: Remove unneeded components * refactor: Cleanup
This commit is contained in:
@@ -4077,7 +4077,6 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
|
|||||||
params.sampling.top_k = 0;
|
params.sampling.top_k = 0;
|
||||||
params.sampling.min_p = 0.01f;
|
params.sampling.min_p = 0.01f;
|
||||||
params.use_jinja = true;
|
params.use_jinja = true;
|
||||||
//params.default_template_kwargs["reasoning_effort"] = "\"high\"";
|
|
||||||
}
|
}
|
||||||
).set_examples({LLAMA_EXAMPLE_SERVER, LLAMA_EXAMPLE_CLI}));
|
).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.top_k = 0;
|
||||||
params.sampling.min_p = 0.01f;
|
params.sampling.min_p = 0.01f;
|
||||||
params.use_jinja = true;
|
params.use_jinja = true;
|
||||||
//params.default_template_kwargs["reasoning_effort"] = "\"high\"";
|
|
||||||
}
|
}
|
||||||
).set_examples({LLAMA_EXAMPLE_SERVER, LLAMA_EXAMPLE_CLI}));
|
).set_examples({LLAMA_EXAMPLE_SERVER, LLAMA_EXAMPLE_CLI}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
VITE_PUBLIC_APP_NAME='llama-ui'
|
VITE_PUBLIC_APP_NAME='llama-ui'
|
||||||
# VITE_DEBUG='true'
|
VITE_DEBUG='true'
|
||||||
|
VITE_PUBLIC_SERVER_ORIGIN='http://localhost:8033'
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ bun.lockb
|
|||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
/static/
|
/static/
|
||||||
|
dist/
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
/dist/
|
/dist/
|
||||||
|
|||||||
+8
-6
@@ -115,16 +115,18 @@ This starts:
|
|||||||
- **Vite dev server** at `http://localhost:5173` - The main UI frontend app
|
- **Vite dev server** at `http://localhost:5173` - The main UI frontend app
|
||||||
- **Storybook** at `http://localhost:6006` - Component documentation
|
- **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
|
```typescript
|
||||||
// vite.config.ts proxy configuration
|
// vite.config.ts proxy configuration
|
||||||
proxy: {
|
proxy: {
|
||||||
'/v1': 'http://localhost:8080',
|
'/v1': SERVER_ORIGIN,
|
||||||
'/props': 'http://localhost:8080',
|
'/props': SERVER_ORIGIN,
|
||||||
'/slots': 'http://localhost:8080',
|
'/models': SERVER_ORIGIN,
|
||||||
'/models': 'http://localhost:8080'
|
'/tools': SERVER_ORIGIN,
|
||||||
}
|
'/slots': SERVER_ORIGIN,
|
||||||
|
'/cors-proxy': SERVER_ORIGIN
|
||||||
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development Workflow
|
### Development Workflow
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { defineConfig } from '@playwright/test';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
webServer: {
|
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,
|
port: 8181,
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
reuseExistingServer: false
|
reuseExistingServer: false
|
||||||
|
|||||||
@@ -111,6 +111,7 @@
|
|||||||
let preSelectedResourceUri = $state<string | undefined>(undefined);
|
let preSelectedResourceUri = $state<string | undefined>(undefined);
|
||||||
|
|
||||||
let currentConfig = $derived(config());
|
let currentConfig = $derived(config());
|
||||||
|
|
||||||
let pasteLongTextToFileLength = $derived.by(() => {
|
let pasteLongTextToFileLength = $derived.by(() => {
|
||||||
const n = Number(currentConfig.pasteLongTextToFileLen);
|
const n = Number(currentConfig.pasteLongTextToFileLen);
|
||||||
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
|
||||||
|
|||||||
+35
-59
@@ -1,22 +1,18 @@
|
|||||||
<script lang="ts">
|
<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 DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import { buttonVariants } from '$lib/components/ui/button';
|
import { buttonVariants } from '$lib/components/ui/button';
|
||||||
import { cn } from '$lib/components/ui/utils';
|
import { cn } from '$lib/components/ui/utils';
|
||||||
import {
|
import {
|
||||||
ATTACHMENT_FILE_ITEMS,
|
ATTACHMENT_FILE_ITEMS,
|
||||||
ATTACHMENT_EXTRA_ITEMS,
|
|
||||||
ATTACHMENT_MCP_ITEMS,
|
|
||||||
ATTACHMENT_TOOLTIP_TEXT,
|
ATTACHMENT_TOOLTIP_TEXT,
|
||||||
TOOLTIP_DELAY_DURATION
|
TOOLTIP_DELAY_DURATION
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import { AttachmentMenuItemId } from '$lib/enums';
|
|
||||||
import {
|
import {
|
||||||
ChatFormActionAddToolsSubmenu,
|
ChatFormActionAddToolsSubmenu,
|
||||||
ChatFormActionAddMcpServersSubmenu
|
ChatFormActionAddMcpServersSubmenu
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
|
|
||||||
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -97,6 +93,14 @@
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
|
|
||||||
<DropdownMenu.Content align="start" class="w-48">
|
<DropdownMenu.Content align="start" class="w-48">
|
||||||
|
<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)}
|
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
|
||||||
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
|
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
|
||||||
{#if enabled}
|
{#if enabled}
|
||||||
@@ -113,7 +117,10 @@
|
|||||||
<Tooltip.Trigger tabindex={-1}>
|
<Tooltip.Trigger tabindex={-1}>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<div {...props} class="cursor-default">
|
<div {...props} class="cursor-default">
|
||||||
<DropdownMenu.Item class="{item.class ?? ''} flex items-center gap-2" disabled>
|
<DropdownMenu.Item
|
||||||
|
class="{item.class ?? ''} flex items-center gap-2"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
<item.icon class="h-4 w-4" />
|
<item.icon class="h-4 w-4" />
|
||||||
|
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
@@ -128,76 +135,45 @@
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
|
||||||
{#if !attachmentMenu.isItemEnabled('hasVisionModality')}
|
|
||||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
|
||||||
<Tooltip.Trigger>
|
|
||||||
{#snippet child({ props })}
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
{...props}
|
|
||||||
class="flex cursor-pointer items-center gap-2"
|
class="flex cursor-pointer items-center gap-2"
|
||||||
onclick={attachmentMenu.callbacks.onFileUpload}
|
onclick={onSystemPromptClick}
|
||||||
>
|
>
|
||||||
{@const pdfItem = ATTACHMENT_FILE_ITEMS.find(
|
<MessageSquare class="h-4 w-4" />
|
||||||
(i) => i.id === AttachmentMenuItemId.PDF
|
|
||||||
)}
|
|
||||||
{#if pdfItem}
|
|
||||||
<pdfItem.icon class="h-4 w-4" />
|
|
||||||
|
|
||||||
<span>{pdfItem.label}</span>
|
<span>System Message</span>
|
||||||
{/if}
|
|
||||||
</DropdownMenu.Item>
|
</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}
|
|
||||||
|
|
||||||
<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}
|
|
||||||
|
|
||||||
<ChatFormActionAddToolsSubmenu />
|
<ChatFormActionAddToolsSubmenu />
|
||||||
|
|
||||||
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
|
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
|
||||||
|
|
||||||
{#each ATTACHMENT_MCP_ITEMS as item (item.id)}
|
{#if hasMcpPromptsSupport}
|
||||||
{#if attachmentMenu.isItemVisible(item.visibleWhen)}
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
class="flex cursor-pointer items-center gap-2"
|
class="flex cursor-pointer items-center gap-2"
|
||||||
onclick={() => attachmentMenu.callbacks[item.action]()}
|
onclick={onMcpPromptClick}
|
||||||
>
|
>
|
||||||
<item.icon class="h-4 w-4" />
|
<Zap class="h-4 w-4" />
|
||||||
|
|
||||||
<span>{item.label}</span>
|
<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>
|
</DropdownMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+174
-66
@@ -2,18 +2,19 @@
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import * as Sheet from '$lib/components/ui/sheet';
|
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 { TOOLTIP_DELAY_DURATION } from '$lib/constants';
|
||||||
import {
|
import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
|
||||||
ATTACHMENT_FILE_ITEMS,
|
|
||||||
ATTACHMENT_EXTRA_ITEMS,
|
|
||||||
ATTACHMENT_MCP_ITEMS
|
|
||||||
} from '$lib/constants/attachment-menu';
|
|
||||||
import { McpLogo } from '$lib/components/app';
|
|
||||||
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
||||||
import { AttachmentMenuItemId } from '$lib/enums';
|
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
|
||||||
import { PencilRuler } from '@lucide/svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { ROUTES, SETTINGS_SECTION_SLUGS } from '$lib/constants/routes';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { RouterService } from '$lib/services/router.service';
|
import { McpLogo } from '$lib/components/app';
|
||||||
|
import { PencilRuler, ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||||
|
import { HealthCheckStatus } from '$lib/enums';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
@@ -46,6 +47,9 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let sheetOpen = $state(false);
|
let sheetOpen = $state(false);
|
||||||
|
let filesExpanded = $state(true);
|
||||||
|
let toolsExpanded = $state(false);
|
||||||
|
let mcpExpanded = $state(false);
|
||||||
|
|
||||||
const attachmentMenu = useAttachmentMenu(
|
const attachmentMenu = useAttachmentMenu(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -61,14 +65,22 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toolsPanel = useToolsPanel();
|
||||||
|
|
||||||
const sheetItemClass =
|
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';
|
'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>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 {className}">
|
<div class="flex items-center gap-1 {className}">
|
||||||
<Sheet.Root bind:open={sheetOpen}>
|
<Sheet.Root bind:open={sheetOpen}>
|
||||||
{@render trigger({ disabled, onclick: () => (sheetOpen = true) })}
|
{@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.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
|
||||||
<Sheet.Header>
|
<Sheet.Header>
|
||||||
@@ -80,6 +92,21 @@
|
|||||||
</Sheet.Header>
|
</Sheet.Header>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 px-1.5 pb-2">
|
<div class="flex flex-col gap-1 px-1.5 pb-2">
|
||||||
|
<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)}
|
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
|
||||||
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
|
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
|
||||||
{#if enabled}
|
{#if enabled}
|
||||||
@@ -108,82 +135,163 @@
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible.Root>
|
||||||
|
|
||||||
|
<Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
|
||||||
|
<Collapsible.Trigger class={sheetItemClass}>
|
||||||
|
{#if mcpExpanded}
|
||||||
|
<ChevronDown class="h-4 w-4 shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight class="h-4 w-4 shrink-0" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<McpLogo class="inline h-4 w-4 shrink-0" />
|
||||||
|
|
||||||
|
<span class="flex-1">MCP Servers</span>
|
||||||
|
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{getEnabledMcpServers().length} server{getEnabledMcpServers().length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
|
||||||
|
<Collapsible.Content>
|
||||||
|
<div class="flex flex-col gap-0.5 pl-4">
|
||||||
|
{#each getEnabledMcpServers() as server (server.id)}
|
||||||
|
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
||||||
|
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
|
||||||
|
{@const displayName = mcpStore.getServerLabel(server)}
|
||||||
|
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
|
||||||
|
{@const isEnabled = conversationsStore.isMcpServerEnabledForChat(server.id)}
|
||||||
|
|
||||||
{#if !attachmentMenu.isItemEnabled('hasVisionModality')}
|
|
||||||
{@const pdfItem = ATTACHMENT_FILE_ITEMS.find((i) => i.id === AttachmentMenuItemId.PDF)}
|
|
||||||
{#if pdfItem}
|
|
||||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
|
||||||
<Tooltip.Trigger>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={sheetItemClass}
|
class={sheetItemRowClass}
|
||||||
onclick={() => attachmentMenu.callbacks[pdfItem.action]()}
|
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}
|
||||||
<span>{pdfItem.label}</span>
|
<img
|
||||||
</button>
|
src={faviconUrl}
|
||||||
</Tooltip.Trigger>
|
alt=""
|
||||||
|
class="h-4 w-4 shrink-0 rounded-sm"
|
||||||
<Tooltip.Content side="right">
|
onerror={(e) => {
|
||||||
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
</Tooltip.Content>
|
}}
|
||||||
</Tooltip.Root>
|
/>
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each ATTACHMENT_EXTRA_ITEMS as item (item.id)}
|
<span class="min-w-0 truncate text-sm">{displayName}</span>
|
||||||
{#if item.id === AttachmentMenuItemId.SYSTEM_MESSAGE}
|
</div>
|
||||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
|
||||||
<Tooltip.Trigger>
|
{#if hasError}
|
||||||
<button
|
<span
|
||||||
type="button"
|
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
|
||||||
class={sheetItemClass}
|
|
||||||
onclick={() => attachmentMenu.callbacks[item.action]()}
|
|
||||||
>
|
>
|
||||||
<item.icon class="h-4 w-4 shrink-0" />
|
Error
|
||||||
|
</span>
|
||||||
<span>{item.label}</span>
|
{:else}
|
||||||
</button>
|
<Switch
|
||||||
</Tooltip.Trigger>
|
checked={isEnabled}
|
||||||
|
onCheckedChange={() => conversationsStore.toggleMcpServerForChat(server.id)}
|
||||||
<Tooltip.Content side="right">
|
/>
|
||||||
<p>{attachmentMenu.getSystemMessageTooltip()}</p>
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div class="my-2 border-t"></div>
|
{#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>
|
||||||
|
|
||||||
<a href={ROUTES.MCP_SERVERS} class="flex items-center gap-3 px-3 py-2">
|
{#if toolsPanel.totalToolCount > 0}
|
||||||
<McpLogo class="inline h-4 w-4" />
|
<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}
|
||||||
|
|
||||||
<span class="text-sm">MCP Servers</span>
|
<PencilRuler class="inline h-4 w-4 shrink-0" />
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
<span class="flex-1">Tools</span>
|
||||||
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>
|
<span class="text-xs text-muted-foreground">
|
||||||
</a>
|
{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)}
|
||||||
|
|
||||||
{#each ATTACHMENT_MCP_ITEMS as item (item.id)}
|
|
||||||
{#if attachmentMenu.isItemVisible(item.visibleWhen)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={sheetItemClass}
|
class={sheetItemRowClass}
|
||||||
onclick={() => attachmentMenu.callbacks[item.action]()}
|
onclick={() => toolsPanel.toggleGroupByLabel(group.label)}
|
||||||
>
|
>
|
||||||
<item.icon class="h-4 w-4 shrink-0" />
|
{#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>{item.label}</span>
|
<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}
|
||||||
|
|
||||||
|
<button type="button" class={sheetItemClass} onclick={onSystemPromptClick}>
|
||||||
|
<MessageSquare class="h-4 w-4 shrink-0" />
|
||||||
|
|
||||||
|
<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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</Sheet.Content>
|
</Sheet.Content>
|
||||||
</Sheet.Root>
|
</Sheet.Root>
|
||||||
|
|||||||
+1
-1
@@ -107,7 +107,7 @@
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
{checked}
|
{checked}
|
||||||
{indeterminate}
|
{indeterminate}
|
||||||
onCheckedChange={() => toolsStore.toggleGroup(group)}
|
onCheckedChange={() => toolsPanel.toggleGroupByLabel(group.label)}
|
||||||
class="mr-2 h-4 w-4 shrink-0"
|
class="mr-2 h-4 w-4 shrink-0"
|
||||||
/>
|
/>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
|
|||||||
+18
-5
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { chatStore } from '$lib/stores/chat.svelte';
|
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 { isRouterMode, serverError } from '$lib/stores/server.svelte';
|
||||||
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
|
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
|
||||||
import { isMobile } from '$lib/stores/viewport.svelte';
|
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||||
@@ -39,7 +44,18 @@
|
|||||||
|
|
||||||
let lastSyncedConversationModel: string | null = null;
|
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(() => {
|
$effect(() => {
|
||||||
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
|
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
|
||||||
@@ -50,7 +66,6 @@
|
|||||||
modelsStore.selectedModelName = null;
|
modelsStore.selectedModelName = null;
|
||||||
modelsStore.clearSelection();
|
modelsStore.clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSyncedConversationModel = conversationModel;
|
lastSyncedConversationModel = conversationModel;
|
||||||
} else if (
|
} else if (
|
||||||
isRouter &&
|
isRouter &&
|
||||||
@@ -60,9 +75,7 @@
|
|||||||
!conversationModel
|
!conversationModel
|
||||||
) {
|
) {
|
||||||
lastSyncedConversationModel = null;
|
lastSyncedConversationModel = null;
|
||||||
|
|
||||||
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
|
const first = modelOptions().find((m) => modelsStore.loadedModelIds.includes(m.model));
|
||||||
|
|
||||||
if (first) modelsStore.selectModelById(first.id);
|
if (first) modelsStore.selectModelById(first.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-2
@@ -6,7 +6,8 @@
|
|||||||
ChatFormActionsAdd,
|
ChatFormActionsAdd,
|
||||||
ChatFormActionModels,
|
ChatFormActionModels,
|
||||||
ChatFormActionRecord,
|
ChatFormActionRecord,
|
||||||
ChatFormActionSubmit
|
ChatFormActionSubmit,
|
||||||
|
ChatFormReasoningToggle
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import { FileTypeCategory } from '$lib/enums';
|
import { FileTypeCategory } from '$lib/enums';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
@@ -99,7 +100,7 @@
|
|||||||
style="container-type: inline-size"
|
style="container-type: inline-size"
|
||||||
>
|
>
|
||||||
{#if showAddButton}
|
{#if showAddButton}
|
||||||
<div class="mr-auto flex items-center gap-2">
|
<div class="mr-auto flex items-center gap-3">
|
||||||
<ChatFormActionsAdd
|
<ChatFormActionsAdd
|
||||||
{disabled}
|
{disabled}
|
||||||
{hasAudioModality}
|
{hasAudioModality}
|
||||||
@@ -116,6 +117,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ChatFormReasoningToggle />
|
||||||
|
|
||||||
{#if showModelSelector}
|
{#if showModelSelector}
|
||||||
<ChatFormActionModels
|
<ChatFormActionModels
|
||||||
{disabled}
|
{disabled}
|
||||||
@@ -130,6 +134,7 @@
|
|||||||
useGlobalSelection
|
useGlobalSelection
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if isReasoning}
|
{#if isReasoning}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
+132
@@ -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}
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
|
import { ReasoningEffort, MessageRole } from '$lib/enums';
|
||||||
|
import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
|
||||||
|
import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
|
||||||
|
import type { ReasoningEffortLevel } from '$lib/types';
|
||||||
|
import {
|
||||||
|
modelsStore,
|
||||||
|
checkModelSupportsThinking,
|
||||||
|
supportsThinking,
|
||||||
|
propsCacheVersion,
|
||||||
|
loadedModelIds
|
||||||
|
} from '$lib/stores/models.svelte';
|
||||||
|
import { chatStore } from '$lib/stores/chat.svelte';
|
||||||
|
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
|
||||||
|
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||||
|
import type { DatabaseMessage } from '$lib/types/database';
|
||||||
|
|
||||||
|
let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled());
|
||||||
|
let currentEffort = $derived(conversationsStore.getReasoningEffort());
|
||||||
|
let isOff = $derived(!thinkingEnabled);
|
||||||
|
let tooltipText = $derived(thinkingEnabled ? `${currentEffort} Reasoning` : 'Disabled Reasoning');
|
||||||
|
let subOpen = $state(false);
|
||||||
|
|
||||||
|
// Get conversation model from message history
|
||||||
|
let conversationModel = $derived(
|
||||||
|
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback: if model props aren't available, check if any assistant messages
|
||||||
|
// for this model in the active conversation have reasoning content.
|
||||||
|
let modelSupportsThinkingFromMessages = $derived.by(() => {
|
||||||
|
const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
|
||||||
|
if (!modelId) return false;
|
||||||
|
const messages = conversationsStore.activeMessages;
|
||||||
|
return messages.some(
|
||||||
|
(m: DatabaseMessage) =>
|
||||||
|
m.role === MessageRole.ASSISTANT && m.model === modelId && !!m.reasoningContent
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if model supports thinking. Primary: chat template from /props.
|
||||||
|
// Fallback: message history (reasoning content in assistant messages).
|
||||||
|
let modelSupportsThinking = $derived.by(() => {
|
||||||
|
loadedModelIds();
|
||||||
|
propsCacheVersion();
|
||||||
|
|
||||||
|
if (isRouterMode()) {
|
||||||
|
const modelId = modelsStore.selectedModelName || conversationModel;
|
||||||
|
return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In non-router mode, use the built-in supportsThinking
|
||||||
|
return supportsThinking() || modelSupportsThinkingFromMessages;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if current item is selected
|
||||||
|
function isSelected(item: ReasoningEffortLevel): boolean {
|
||||||
|
if (item.isOff) {
|
||||||
|
return isOff;
|
||||||
|
}
|
||||||
|
return thinkingEnabled && currentEffort === item.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelection(item: ReasoningEffortLevel) {
|
||||||
|
if (item.isOff) {
|
||||||
|
conversationsStore.setThinkingEnabled(false);
|
||||||
|
} else {
|
||||||
|
conversationsStore.setThinkingEnabled(true);
|
||||||
|
conversationsStore.setReasoningEffort(item.value as ReasoningEffort);
|
||||||
|
}
|
||||||
|
subOpen = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if modelSupportsThinking}
|
||||||
|
<DropdownMenu.Root bind:open={subOpen}>
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class={[
|
||||||
|
'flex h-6 w-6 cursor-pointer items-center justify-center rounded-full p-0 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
thinkingEnabled ? 'bg-amber-400/10 hover:bg-amber-400/20' : 'bg-muted'
|
||||||
|
]}
|
||||||
|
aria-label={`${tooltipText}. Click to configure.`}
|
||||||
|
>
|
||||||
|
{#if thinkingEnabled}
|
||||||
|
<Lightbulb class="h-3 w-3 text-amber-400" />
|
||||||
|
{:else}
|
||||||
|
<LightbulbOff class="h-3 w-3 text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
|
||||||
|
<Tooltip.Content>
|
||||||
|
<p class="capitalize">{tooltipText}</p>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
|
||||||
|
<DropdownMenu.Content
|
||||||
|
align="start"
|
||||||
|
class="w-60 rounded-xl bg-popover p-3 text-popover-foreground shadow-md outline-none"
|
||||||
|
>
|
||||||
|
<div class="mb-2 px-2.5 text-sm font-medium">Reasoning effort</div>
|
||||||
|
|
||||||
|
{#each REASONING_EFFORT_LEVELS as level (level.value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent"
|
||||||
|
class:bg-accent={isSelected(level)}
|
||||||
|
onclick={() => handleSelection(level)}
|
||||||
|
>
|
||||||
|
{#if isSelected(level)}
|
||||||
|
<Check class="h-4 w-4 shrink-0 text-foreground" />
|
||||||
|
{:else}
|
||||||
|
<div class="h-4 w-4 shrink-0"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="flex-1">{level.label}</span>
|
||||||
|
|
||||||
|
{#if !level.isOff}
|
||||||
|
<span class="text-[11px] text-muted-foreground opacity-60">
|
||||||
|
{REASONING_EFFORT_TOKENS[level.value] === -1
|
||||||
|
? 'Unlimited'
|
||||||
|
: `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if level.hasInfo}
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content side="left">
|
||||||
|
<p>Maximum reasoning effort with extended context usage</p>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
{/if}
|
||||||
@@ -240,6 +240,15 @@ export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormAct
|
|||||||
*/
|
*/
|
||||||
export { default as ChatFormActionAddMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte';
|
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.
|
* Hidden file input element for programmatic file selection.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
hideOrgName?: boolean;
|
hideOrgName?: boolean;
|
||||||
showRaw?: boolean;
|
showRaw?: boolean;
|
||||||
hideQuantization?: boolean;
|
hideQuantization?: boolean;
|
||||||
|
hideTags?: boolean;
|
||||||
aliases?: string[];
|
aliases?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
class?: string;
|
class?: string;
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
modelId,
|
modelId,
|
||||||
hideOrgName = false,
|
hideOrgName = false,
|
||||||
showRaw = undefined,
|
showRaw = undefined,
|
||||||
hideQuantization = false,
|
hideQuantization,
|
||||||
|
hideTags,
|
||||||
aliases,
|
aliases,
|
||||||
tags,
|
tags,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
@@ -31,6 +33,8 @@
|
|||||||
|
|
||||||
let parsed = $derived(ModelsService.parseModelId(modelId));
|
let parsed = $derived(ModelsService.parseModelId(modelId));
|
||||||
let resolvedShowRaw = $derived(showRaw ?? (config().showRawModelNames as boolean) ?? false);
|
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 uniqueAliases = $derived([...new Set(aliases ?? [])]);
|
||||||
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
|
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
|
||||||
@@ -53,7 +57,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if parsed.quantization && !hideQuantization}
|
{#if parsed.quantization && !resolvedHideQuantization}
|
||||||
<span class={badgeClass}>
|
<span class={badgeClass}>
|
||||||
{parsed.quantization}
|
{parsed.quantization}
|
||||||
</span>
|
</span>
|
||||||
@@ -69,7 +73,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if uniqueTags.length > 0}
|
{#if uniqueTags.length > 0 && !resolvedHideTags}
|
||||||
{#each uniqueTags as tag (tag)}
|
{#each uniqueTags as tag (tag)}
|
||||||
<span class={tagBadgeClass}>{tag}</span>
|
<span class={tagBadgeClass}>{tag}</span>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -106,9 +106,7 @@
|
|||||||
]}
|
]}
|
||||||
style="max-width: min(calc(100cqw - 10rem), 20rem)"
|
style="max-width: min(calc(100cqw - 10rem), 20rem)"
|
||||||
>
|
>
|
||||||
<Package class="h-3.5 w-3.5" />
|
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||||
|
|
||||||
<ModelId modelId={currentModel} class="min-w-0" hideQuantization />
|
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||||
@@ -120,7 +118,7 @@
|
|||||||
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
|
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
class={[
|
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
|
!ms.isCurrentModelInCache
|
||||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||||
: forceForegroundText
|
: forceForegroundText
|
||||||
@@ -133,7 +131,7 @@
|
|||||||
]}
|
]}
|
||||||
disabled={disabled || ms.updating}
|
disabled={disabled || ms.updating}
|
||||||
>
|
>
|
||||||
<Package class="h-3.5 w-3.5" />
|
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||||
|
|
||||||
{#if selectedOption}
|
{#if selectedOption}
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
@@ -144,6 +142,7 @@
|
|||||||
modelId={selectedOption.model}
|
modelId={selectedOption.model}
|
||||||
class="min-w-0 overflow-hidden"
|
class="min-w-0 overflow-hidden"
|
||||||
hideOrgName={false}
|
hideOrgName={false}
|
||||||
|
hideQuantization
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -158,9 +157,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if ms.updating || ms.isLoadingModel}
|
{#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}
|
{:else}
|
||||||
<ChevronDown class="h-3 w-3.5" />
|
<ChevronDown class="h-3 w-3.5 shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
@@ -251,7 +250,7 @@
|
|||||||
onclick={() => ms.handleOpenChange(true)}
|
onclick={() => ms.handleOpenChange(true)}
|
||||||
disabled={disabled || ms.updating}
|
disabled={disabled || ms.updating}
|
||||||
>
|
>
|
||||||
<Package class="h-3.5 w-3.5" />
|
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||||
|
|
||||||
{#if selectedOption}
|
{#if selectedOption}
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
@@ -262,6 +261,7 @@
|
|||||||
modelId={selectedOption.model}
|
modelId={selectedOption.model}
|
||||||
class="min-w-0 overflow-hidden"
|
class="min-w-0 overflow-hidden"
|
||||||
hideOrgName={false}
|
hideOrgName={false}
|
||||||
|
hideQuantization
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -274,7 +274,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if ms.updating}
|
{#if ms.updating}
|
||||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
DialogModelInformation,
|
DialogModelInformation,
|
||||||
ModelId,
|
ModelId,
|
||||||
ModelsSelectorList,
|
ModelsSelectorList,
|
||||||
SearchInput,
|
SearchInput
|
||||||
TruncatedText
|
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -67,7 +66,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={[
|
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
|
!ms.isCurrentModelInCache
|
||||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||||
: forceForegroundText
|
: forceForegroundText
|
||||||
@@ -81,7 +80,7 @@
|
|||||||
disabled={disabled || ms.updating}
|
disabled={disabled || ms.updating}
|
||||||
onclick={() => ms.handleOpenChange(true)}
|
onclick={() => ms.handleOpenChange(true)}
|
||||||
>
|
>
|
||||||
<Package class="h-3.5 w-3.5" />
|
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||||
|
|
||||||
{#if !selectedOption}
|
{#if !selectedOption}
|
||||||
<span class="min-w-0 font-medium">Select model</span>
|
<span class="min-w-0 font-medium">Select model</span>
|
||||||
@@ -90,14 +89,15 @@
|
|||||||
class="text-xs"
|
class="text-xs"
|
||||||
modelId={selectedOption?.model || ''}
|
modelId={selectedOption?.model || ''}
|
||||||
hideQuantization
|
hideQuantization
|
||||||
|
hideTags
|
||||||
hideOrgName
|
hideOrgName
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if ms.updating || ms.isLoadingModel}
|
{#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}
|
{:else}
|
||||||
<ChevronDown class="h-3 w-3.5" />
|
<ChevronDown class="h-3 w-3.5 shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -168,12 +168,12 @@
|
|||||||
onclick={() => ms.handleOpenChange(true)}
|
onclick={() => ms.handleOpenChange(true)}
|
||||||
disabled={disabled || ms.updating}
|
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}
|
{#if ms.updating}
|
||||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -74,8 +74,7 @@ export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
|
|||||||
*/
|
*/
|
||||||
export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.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.
|
* Compact badge showing current model name with package icon.
|
||||||
* Only visible in single model mode. Supports tooltip and copy functionality.
|
* Only visible in single model mode. Supports tooltip and copy functionality.
|
||||||
|
|||||||
@@ -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,
|
id: AttachmentMenuItemId.SYSTEM_MESSAGE,
|
||||||
label: 'System Message',
|
label: 'System Message',
|
||||||
@@ -87,10 +89,7 @@ export const ATTACHMENT_EXTRA_ITEMS: AttachmentMenuItem[] = [
|
|||||||
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
|
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
|
||||||
hasEnabledTooltip: true,
|
hasEnabledTooltip: true,
|
||||||
action: AttachmentAction.SYSTEM_PROMPT_CLICK
|
action: AttachmentAction.SYSTEM_PROMPT_CLICK
|
||||||
}
|
},
|
||||||
];
|
|
||||||
|
|
||||||
export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
|
|
||||||
{
|
{
|
||||||
id: AttachmentMenuItemId.MCP_PROMPT,
|
id: AttachmentMenuItemId.MCP_PROMPT,
|
||||||
label: 'MCP Prompt',
|
label: 'MCP Prompt',
|
||||||
@@ -98,7 +97,10 @@ export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
|
|||||||
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
|
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
|
||||||
action: AttachmentAction.MCP_PROMPT_CLICK,
|
action: AttachmentAction.MCP_PROMPT_CLICK,
|
||||||
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT
|
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT
|
||||||
},
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
|
||||||
{
|
{
|
||||||
id: AttachmentMenuItemId.MCP_RESOURCES,
|
id: AttachmentMenuItemId.MCP_RESOURCES,
|
||||||
label: 'MCP Resources',
|
label: 'MCP Resources',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export * from './agentic';
|
|||||||
export * from './api-endpoints';
|
export * from './api-endpoints';
|
||||||
export * from './attachment-labels';
|
export * from './attachment-labels';
|
||||||
export * from './database';
|
export * from './database';
|
||||||
|
export * from './reasoning-effort';
|
||||||
|
export * from './reasoning-effort-tokens';
|
||||||
export * from './storage';
|
export * from './storage';
|
||||||
export * from './attachment-menu';
|
export * from './attachment-menu';
|
||||||
export * from './auto-scroll';
|
export * from './auto-scroll';
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ReasoningEffort } from '$lib/enums';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reasoning effort to token budget mapping.
|
||||||
|
* Maps the ReasoningEffort enum values to concrete token counts for the server.
|
||||||
|
*/
|
||||||
|
export const REASONING_EFFORT_TOKENS: Record<string, number> = {
|
||||||
|
[ReasoningEffort.LOW]: 512,
|
||||||
|
[ReasoningEffort.MEDIUM]: 2048,
|
||||||
|
[ReasoningEffort.HIGH]: 8192,
|
||||||
|
[ReasoningEffort.MAX]: -1 // unlimited
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ReasoningEffort } from '$lib/enums';
|
||||||
|
import type { ReasoningEffortLevel } from '$lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reasoning effort UI labels.
|
||||||
|
* Keys match the ReasoningEffort enum values for type-safe lookups.
|
||||||
|
*/
|
||||||
|
export const REASONING_EFFORT_LABELS: Record<string, string> = {
|
||||||
|
[ReasoningEffort.LOW]: 'Low',
|
||||||
|
[ReasoningEffort.MEDIUM]: 'Medium',
|
||||||
|
[ReasoningEffort.HIGH]: 'High',
|
||||||
|
[ReasoningEffort.MAX]: 'Max'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REASONING_EFFORT_LEVELS: ReasoningEffortLevel[] = [
|
||||||
|
{ value: 'off', label: 'Off', isOff: true },
|
||||||
|
{ value: ReasoningEffort.LOW, label: 'Low' },
|
||||||
|
{ value: ReasoningEffort.MEDIUM, label: 'Medium' },
|
||||||
|
{ value: ReasoningEffort.HIGH, label: 'High' },
|
||||||
|
{ value: ReasoningEffort.MAX, label: 'Max', hasInfo: true }
|
||||||
|
];
|
||||||
@@ -29,6 +29,8 @@ export const SETTINGS_KEYS = {
|
|||||||
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
|
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
|
||||||
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
|
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
|
||||||
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
|
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
|
||||||
|
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
|
||||||
|
SHOW_MODEL_TAGS: 'showModelTags',
|
||||||
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
|
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
|
||||||
// Sampling
|
// Sampling
|
||||||
TEMPERATURE: 'temperature',
|
TEMPERATURE: 'temperature',
|
||||||
@@ -64,6 +66,7 @@ export const SETTINGS_KEYS = {
|
|||||||
// Developer
|
// Developer
|
||||||
DISABLE_REASONING_PARSING: 'disableReasoningParsing',
|
DISABLE_REASONING_PARSING: 'disableReasoningParsing',
|
||||||
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
|
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
|
||||||
|
ENABLE_THINKING: 'enableThinking',
|
||||||
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
|
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
|
||||||
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
|
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
|
||||||
CUSTOM_JSON: 'customJson',
|
CUSTOM_JSON: 'customJson',
|
||||||
|
|||||||
@@ -330,6 +330,30 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
|||||||
paramType: SyncableParameterType.BOOLEAN
|
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,
|
key: SETTINGS_KEYS.ALWAYS_SHOW_AGENTIC_TURNS,
|
||||||
label: 'Always show agentic turns in conversation',
|
label: 'Always show agentic turns in conversation',
|
||||||
@@ -646,6 +670,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
|||||||
paramType: SyncableParameterType.BOOLEAN
|
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,
|
key: SETTINGS_KEYS.SHOW_RAW_OUTPUT_SWITCH,
|
||||||
label: 'Enable raw output toggle',
|
label: 'Enable raw output toggle',
|
||||||
|
|||||||
@@ -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 DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTools`;
|
||||||
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
|
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
|
||||||
export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.mcpDefaultEnabled`;
|
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`;
|
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
|
||||||
|
|
||||||
// Deprecated old key names (kept for backward compat while users migrate)
|
// Deprecated old key names (kept for backward compat while users migrate)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export {
|
|||||||
ReasoningFormat
|
ReasoningFormat
|
||||||
} from './chat.enums';
|
} from './chat.enums';
|
||||||
|
|
||||||
|
export { ReasoningEffort } from './reasoning-effort.enums';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FileTypeCategory,
|
FileTypeCategory,
|
||||||
FileTypeImage,
|
FileTypeImage,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Reasoning effort levels for thinking models.
|
||||||
|
* These values are sent to the server and mapped to token budgets.
|
||||||
|
*/
|
||||||
|
export enum ReasoningEffort {
|
||||||
|
LOW = 'low',
|
||||||
|
MEDIUM = 'medium',
|
||||||
|
HIGH = 'high',
|
||||||
|
MAX = 'max'
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ export interface UseToolsPanelReturn {
|
|||||||
getFavicon(group: { source: ToolSource; label: string }): string | null;
|
getFavicon(group: { source: ToolSource; label: string }): string | null;
|
||||||
isGroupDisabled(group: ToolGroup): boolean;
|
isGroupDisabled(group: ToolGroup): boolean;
|
||||||
toggleGroupExpanded(label: string): void;
|
toggleGroupExpanded(label: string): void;
|
||||||
|
/** Toggle all tools in a group by label (avoids stale group object references). */
|
||||||
|
toggleGroupByLabel(label: string): void;
|
||||||
handleOpen(): 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 {
|
function handleOpen(): void {
|
||||||
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
|
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
|
||||||
toolsStore.fetchBuiltinTools();
|
toolsStore.fetchBuiltinTools();
|
||||||
@@ -117,6 +126,7 @@ export function useToolsPanel(): UseToolsPanelReturn {
|
|||||||
getFavicon,
|
getFavicon,
|
||||||
isGroupDisabled,
|
isGroupDisabled,
|
||||||
toggleGroupExpanded,
|
toggleGroupExpanded,
|
||||||
|
toggleGroupByLabel,
|
||||||
handleOpen
|
handleOpen
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ATTACHMENT_LABEL_MCP_PROMPT,
|
ATTACHMENT_LABEL_MCP_PROMPT,
|
||||||
ATTACHMENT_LABEL_MCP_RESOURCE,
|
ATTACHMENT_LABEL_MCP_RESOURCE,
|
||||||
LEGACY_AGENTIC_REGEX,
|
LEGACY_AGENTIC_REGEX,
|
||||||
|
REASONING_EFFORT_TOKENS,
|
||||||
SETTINGS_KEYS,
|
SETTINGS_KEYS,
|
||||||
API_CHAT,
|
API_CHAT,
|
||||||
API_SLOTS,
|
API_SLOTS,
|
||||||
@@ -162,6 +163,8 @@ export class ChatService {
|
|||||||
// Config options
|
// Config options
|
||||||
disableReasoningParsing,
|
disableReasoningParsing,
|
||||||
excludeReasoningFromContext,
|
excludeReasoningFromContext,
|
||||||
|
enableThinking,
|
||||||
|
reasoningEffort,
|
||||||
continueFinalMessage
|
continueFinalMessage
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@@ -243,6 +246,18 @@ export class ChatService {
|
|||||||
? ReasoningFormat.NONE
|
? ReasoningFormat.NONE
|
||||||
: ReasoningFormat.AUTO;
|
: 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
|
// arms the budget sampler so reasoning can be ended at runtime via the control endpoint
|
||||||
requestBody.reasoning_control = true;
|
requestBody.reasoning_control = true;
|
||||||
|
|
||||||
|
|||||||
@@ -1852,6 +1852,9 @@ class ChatStore {
|
|||||||
|
|
||||||
if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true;
|
if (currentConfig.excludeReasoningFromContext) apiOptions.excludeReasoningFromContext = true;
|
||||||
|
|
||||||
|
apiOptions.enableThinking = conversationsStore.getThinkingEnabled();
|
||||||
|
apiOptions.reasoningEffort = conversationsStore.getReasoningEffort();
|
||||||
|
|
||||||
if (hasValue(currentConfig.temperature))
|
if (hasValue(currentConfig.temperature))
|
||||||
apiOptions.temperature = Number(currentConfig.temperature);
|
apiOptions.temperature = Number(currentConfig.temperature);
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { MigrationService } from '$lib/services/migration.service';
|
|||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
|
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
|
||||||
import type { McpServerOverride } from '$lib/types/database';
|
import type { McpServerOverride } from '$lib/types/database';
|
||||||
import { MessageRole, HtmlInputType, FileExtensionText } from '$lib/enums';
|
import { MessageRole, HtmlInputType, FileExtensionText, ReasoningEffort } from '$lib/enums';
|
||||||
import {
|
import {
|
||||||
ISO_DATE_TIME_SEPARATOR,
|
ISO_DATE_TIME_SEPARATOR,
|
||||||
ISO_DATE_TIME_SEPARATOR_REPLACEMENT,
|
ISO_DATE_TIME_SEPARATOR_REPLACEMENT,
|
||||||
@@ -38,7 +38,9 @@ import {
|
|||||||
ISO_TIME_SEPARATOR_REPLACEMENT,
|
ISO_TIME_SEPARATOR_REPLACEMENT,
|
||||||
NON_ALPHANUMERIC_REGEX,
|
NON_ALPHANUMERIC_REGEX,
|
||||||
MULTIPLE_UNDERSCORE_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';
|
} from '$lib/constants';
|
||||||
|
|
||||||
import { ROUTES } from '$lib/constants/routes';
|
import { ROUTES } from '$lib/constants/routes';
|
||||||
@@ -74,6 +76,12 @@ class ConversationsStore {
|
|||||||
/** Pending MCP server overrides for new conversations (before first message) */
|
/** Pending MCP server overrides for new conversations (before first message) */
|
||||||
pendingMcpServerOverrides = $state<McpServerOverride[]>(ConversationsStore.loadMcpDefaults());
|
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 */
|
/** Load MCP default overrides from localStorage */
|
||||||
private static loadMcpDefaults(): McpServerOverride[] {
|
private static loadMcpDefaults(): McpServerOverride[] {
|
||||||
if (typeof globalThis.localStorage === 'undefined') return [];
|
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 */
|
/** Callback for title update confirmation dialog */
|
||||||
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
|
titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
|
||||||
|
|
||||||
@@ -253,6 +300,12 @@ class ConversationsStore {
|
|||||||
this.pendingMcpServerOverrides = [];
|
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.conversations = [conversation, ...this.conversations];
|
||||||
this.activeConversation = conversation;
|
this.activeConversation = conversation;
|
||||||
this.activeMessages = [];
|
this.activeMessages = [];
|
||||||
@@ -276,6 +329,7 @@ class ConversationsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.pendingMcpServerOverrides = [];
|
this.pendingMcpServerOverrides = [];
|
||||||
|
this.pendingThinkingEnabled = false;
|
||||||
this.activeConversation = conversation;
|
this.activeConversation = conversation;
|
||||||
|
|
||||||
if (conversation.currNode) {
|
if (conversation.currNode) {
|
||||||
@@ -304,8 +358,9 @@ class ConversationsStore {
|
|||||||
clearActiveConversation(): void {
|
clearActiveConversation(): void {
|
||||||
this.activeConversation = null;
|
this.activeConversation = null;
|
||||||
this.activeMessages = [];
|
this.activeMessages = [];
|
||||||
// reload MCP defaults so new chats inherit persisted state
|
// reload defaults so new chats inherit persisted state
|
||||||
this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults();
|
this.pendingMcpServerOverrides = ConversationsStore.loadMcpDefaults();
|
||||||
|
this.pendingThinkingEnabled = ConversationsStore.loadThinkingDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -703,6 +758,84 @@ class ConversationsStore {
|
|||||||
this.saveMcpDefaults();
|
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
|
* Forks a conversation at a specific message, creating a new conversation
|
||||||
* containing messages from root up to the target message, then navigates to it.
|
* containing messages from root up to the target message, then navigates to it.
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { ServerModelStatus, ModelModality } from '$lib/enums';
|
|||||||
import { ModelsService } from '$lib/services/models.service';
|
import { ModelsService } from '$lib/services/models.service';
|
||||||
import { PropsService } from '$lib/services/props.service';
|
import { PropsService } from '$lib/services/props.service';
|
||||||
import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
|
import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
|
||||||
|
import {
|
||||||
|
detectThinkingSupport,
|
||||||
|
detectThinkingSupportWithReason
|
||||||
|
} from '$lib/utils/chat-template-thinking-detector';
|
||||||
import { TTLCache } from '$lib/utils';
|
import { TTLCache } from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
MODEL_PROPS_CACHE_TTL_MS,
|
MODEL_PROPS_CACHE_TTL_MS,
|
||||||
@@ -215,6 +219,67 @@ class ModelsStore {
|
|||||||
|
|
||||||
return usage !== undefined && usage.size > 0;
|
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 {
|
try {
|
||||||
const props = await PropsService.fetchForModel(modelId);
|
const props = await PropsService.fetchForModel(modelId);
|
||||||
this.modelPropsCache.set(modelId, props);
|
this.modelPropsCache.set(modelId, props);
|
||||||
|
this.propsCacheVersion++;
|
||||||
return props;
|
return props;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to fetch props for model ${modelId}:`, 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 singleModelName = () => modelsStore.singleModelName;
|
||||||
export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
|
export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
|
||||||
export const favoriteModelIds = () => modelsStore.favoriteModelIds;
|
export const favoriteModelIds = () => modelsStore.favoriteModelIds;
|
||||||
|
export const supportsThinking = () => modelsStore.supportsThinking;
|
||||||
|
export const checkModelSupportsThinking = (modelId: string) =>
|
||||||
|
modelsStore.checkModelSupportsThinking(modelId);
|
||||||
|
export const thinkingSupportDetails = () => modelsStore.thinkingSupportDetails;
|
||||||
|
|||||||
Vendored
+3
-1
@@ -1,5 +1,5 @@
|
|||||||
import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat';
|
import type { ChatMessageTimings, ChatRole, ChatMessageType } from '$lib/types/chat';
|
||||||
import { AttachmentType } from '$lib/enums';
|
import { AttachmentType, ReasoningEffort } from '$lib/enums';
|
||||||
|
|
||||||
export interface McpServerOverride {
|
export interface McpServerOverride {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -12,6 +12,8 @@ export interface DatabaseConversation {
|
|||||||
lastModified: number;
|
lastModified: number;
|
||||||
name: string;
|
name: string;
|
||||||
mcpServerOverrides?: McpServerOverride[];
|
mcpServerOverrides?: McpServerOverride[];
|
||||||
|
thinkingEnabled?: boolean;
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
forkedFromConversationId?: string;
|
forkedFromConversationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,3 +162,6 @@ export type {
|
|||||||
|
|
||||||
// Tools types
|
// Tools types
|
||||||
export type { ToolEntry, ToolGroup } from './tools';
|
export type { ToolEntry, ToolGroup } from './tools';
|
||||||
|
|
||||||
|
// Reasoning
|
||||||
|
export type { ReasoningEffortLevel } from './reasoning';
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface ReasoningEffortLevel {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
isOff?: boolean;
|
||||||
|
hasInfo?: boolean;
|
||||||
|
}
|
||||||
Vendored
+10
-1
@@ -2,7 +2,12 @@ import type { SETTING_CONFIG_DEFAULT, SETTINGS_SECTION_TITLES } from '$lib/const
|
|||||||
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
|
import type { ChatMessagePromptProgress, ChatMessageTimings } from './chat';
|
||||||
import type { OpenAIToolDefinition } from './mcp';
|
import type { OpenAIToolDefinition } from './mcp';
|
||||||
import type { DatabaseMessageExtra } from './database';
|
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 { Icon } from '@lucide/svelte';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
@@ -65,6 +70,10 @@ export interface SettingsChatServiceOptions {
|
|||||||
disableReasoningParsing?: boolean;
|
disableReasoningParsing?: boolean;
|
||||||
// Strip reasoning content from context before sending
|
// Strip reasoning content from context before sending
|
||||||
excludeReasoningFromContext?: boolean;
|
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[];
|
tools?: OpenAIToolDefinition[];
|
||||||
// Generation parameters
|
// Generation parameters
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
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 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
await screen.findByRole('menu');
|
await screen.findByRole('menu');
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(document.activeElement).toHaveTextContent('Text Files');
|
expect(document.activeElement).toHaveTextContent('Add files');
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { llamaCppBuildPlugin } from './scripts/vite-plugin-llama-cpp-build';
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const SERVER_ORIGIN = import.meta.env?.VITE_PUBLIC_SERVER_ORIGIN || 'http://localhost:8080';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -75,12 +77,12 @@ export default defineConfig({
|
|||||||
|
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/v1': 'http://localhost:8080',
|
'/v1': SERVER_ORIGIN,
|
||||||
'/props': 'http://localhost:8080',
|
'/props': SERVER_ORIGIN,
|
||||||
'/models': 'http://localhost:8080',
|
'/models': SERVER_ORIGIN,
|
||||||
'/tools': 'http://localhost:8080',
|
'/tools': SERVER_ORIGIN,
|
||||||
'/slots': 'http://localhost:8080',
|
'/slots': SERVER_ORIGIN,
|
||||||
'/cors-proxy': 'http://localhost:8080'
|
'/cors-proxy': SERVER_ORIGIN
|
||||||
},
|
},
|
||||||
headers: {
|
headers: {
|
||||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||||
|
|||||||
Reference in New Issue
Block a user