Merge branch 'upstream' into concedo_experimental

# Conflicts:
#	.devops/openvino.Dockerfile
#	.github/workflows/build-cache.yml
#	.github/workflows/build-openvino.yml
#	.github/workflows/build-self-hosted.yml
#	.github/workflows/release.yml
#	ci/run.sh
#	docs/backend/OPENVINO.md
#	docs/speculative.md
#	ggml/src/ggml-hexagon/ggml-hexagon.cpp
#	ggml/src/ggml-hexagon/htp/htp-ops.h
#	ggml/src/ggml-hexagon/htp/hvx-arith.h
#	ggml/src/ggml-hexagon/htp/hvx-log.h
#	ggml/src/ggml-hexagon/htp/main.c
#	ggml/src/ggml-hexagon/htp/unary-ops.c
#	ggml/src/ggml-hexagon/htp/unary-ops.h
#	ggml/src/ggml-opencl/ggml-opencl.cpp
#	ggml/src/ggml-openvino/CMakeLists.txt
#	ggml/src/ggml-openvino/ggml-decoder.cpp
#	ggml/src/ggml-openvino/ggml-decoder.h
#	ggml/src/ggml-openvino/ggml-openvino-extra.cpp
#	ggml/src/ggml-openvino/ggml-openvino.cpp
#	ggml/src/ggml-openvino/openvino/op/cpy.cpp
#	ggml/src/ggml-openvino/openvino/op/flash_attn_ext.cpp
#	ggml/src/ggml-openvino/openvino/op/gated_delta_net.cpp
#	ggml/src/ggml-openvino/openvino/op/view.cpp
#	ggml/src/ggml-openvino/openvino/op_table.cpp
#	ggml/src/ggml-openvino/openvino/op_table.h
#	ggml/src/ggml-openvino/openvino/translate_session.cpp
#	ggml/src/ggml-openvino/openvino/utils.cpp
#	ggml/src/ggml-openvino/utils.cpp
#	ggml/src/ggml-openvino/utils.h
#	ggml/src/ggml-sycl/fattn-onednn.cpp
#	ggml/src/ggml-sycl/fattn.cpp
#	scripts/pr2wt.sh
#	src/CMakeLists.txt
#	src/llama-mmap.cpp
#	src/llama-quant.cpp
#	tests/CMakeLists.txt
#	tests/test-arg-parser.cpp
#	tests/test-backend-ops.cpp
#	tests/test-llama-archs.cpp
#	tests/test-save-load-state.cpp
#	tools/cli/README.md
#	tools/completion/README.md
#	tools/server/README.md
This commit is contained in:
Concedo
2026-08-28 22:29:03 +08:00
104 changed files with 6236 additions and 1464 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { Eye, Mic, Video } from '@lucide/svelte';
import { MODALITY_ICONS, MODALITY_LABELS } from '$lib/constants';
import { ModelModality } from '$lib/enums';
interface Props {
@@ -8,29 +8,22 @@
}
let { class: className = '', modalities }: Props = $props();
const shownModalities = [ModelModality.VISION, ModelModality.AUDIO, ModelModality.VIDEO] as const;
let visible = $derived(shownModalities.filter((modality) => modalities.includes(modality)));
</script>
{#each modalities as modality (modality)}
{#if modality === ModelModality.VISION || modality === ModelModality.AUDIO || modality === ModelModality.VIDEO}
<span
class={[
'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
className
]}
>
{#if modality === ModelModality.VISION}
<Eye class="h-3 w-3" />
{#each visible as modality (modality)}
{@const ModalityIcon = MODALITY_ICONS[modality]}
<span
class={[
'inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium',
className
]}
>
<ModalityIcon class="h-3 w-3" />
Vision (Image)
{:else if modality === ModelModality.VIDEO}
<Video class="h-3 w-3" />
Vision (Video)
{:else}
<Mic class="h-3 w-3" />
Audio
{/if}
</span>
{/if}
{MODALITY_LABELS[modality]}
</span>
{/each}
@@ -23,7 +23,8 @@
FileExtensionText,
KeyboardKey,
MimeTypeText,
SpecialFileType
SpecialFileType,
ToolSource
} from '$lib/enums';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import {
@@ -73,7 +74,6 @@
disabled?: boolean;
isLoading?: boolean;
placeholder?: string;
showMcpPromptButton?: boolean;
showAddButton?: boolean;
showModelSelector?: boolean;
@@ -103,7 +103,6 @@
onValueChange,
placeholder = 'Type a message...',
showAddButton = true,
showMcpPromptButton = false,
showModelSelector = true,
uploadedFiles = $bindable([]),
value = $bindable('')
@@ -152,9 +151,18 @@
getServerHome: () => toolsStore.serverHome ?? null,
getShowModelSelector: () => showModelSelector,
getValue: () => value,
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
hasPrompts: () =>
mcpStore.hasPromptsCapability(conversationsStore.preferences.getAllMcpServerOverrides()),
hasCwdTools: () => conversationsStore.preferences.hasEnabledCwdTools(),
// policy-aware, same rule as the agentic flow: MCP category on and at
// least one globally-enabled server whose group key is not disabled
hasPrompts: () => {
const prefs = conversationsStore.preferences;
if (!prefs.isCategoryEnabled(ToolSource.MCP)) return false;
return mcpStore
.getServers()
.some((s) => s.enabled && prefs.isServerToolsEnabled(s.id) && s.url.trim());
},
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => {
@@ -620,8 +628,6 @@
isReasoning={chatStore.isReasoning}
{isRecording}
onFileUpload={handleFileUpload}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
onMcpSettingsClick={() => (isMcpServersDialogOpen = true)}
onMicClick={handleMicClick}
{onStop}
@@ -635,7 +641,7 @@
<ContextGaugePopup />
{#if toolsStore.hasEnabledCwdTools}
{#if conversationsStore.preferences.hasEnabledCwdTools()}
<ChatFormCurrentWorkingDirectory
bind:query={pickers.workingDirectoryQuery}
customAnchor={mentionAnchor}
@@ -1,5 +1,5 @@
<script lang="ts">
import { File, MessageSquare, Plus } from '@lucide/svelte';
import { File, Image, MessageSquare, Mic, Plus, Video } from '@lucide/svelte';
import { ChatFormActionAddToolsSubmenu, McpLogo } from '$lib/components/app';
import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@@ -8,10 +8,10 @@
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_TOOLTIP_TEXT,
ICON_CLASS_DEFAULT,
TOOLTIP_DELAY_DURATION
ICON_CLASS_DEFAULT
} from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
interface Props {
@@ -30,21 +30,29 @@
const attachmentMenu = useAttachmentMenu(
() => ({
hasAudioModality: chatFormActions.hasAudioModality,
hasMcpPromptsSupport: chatFormActions.hasMcpPromptsSupport,
hasMcpResourcesSupport: chatFormActions.hasMcpResourcesSupport,
hasVideoModality: chatFormActions.hasVideoModality,
hasVisionModality: chatFormActions.hasVisionModality
}),
() => ({
onFileUpload: chatFormActions.onFileUpload,
onMcpPromptClick: chatFormActions.onMcpPromptClick,
onMcpResourcesClick: chatFormActions.onMcpResourcesClick,
onSystemPromptClick: chatFormActions.onSystemPromptClick
}),
() => {
dropdownOpen = false;
}
);
const FILE_MODALITY_ICONS: Record<string, { icon: typeof Image; label: string }> = {
[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: { icon: Mic, label: 'Audio' },
[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: { icon: Video, label: 'Video' },
[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: { icon: Image, label: 'Vision' }
};
const supportedModalities = $derived.by(() =>
ATTACHMENT_FILE_ITEMS.filter((item) => attachmentMenu.isItemEnabled(item.enabledWhen))
.map((item) => FILE_MODALITY_ICONS[item.enabledWhen ?? ''])
.filter((modality) => modality !== undefined)
);
</script>
<div class="flex items-center gap-1 {className}">
@@ -84,50 +92,32 @@
}
}}
>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<File class={ICON_CLASS_DEFAULT} />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()}
>
<File class={ICON_CLASS_DEFAULT} />
<span class="flex min-w-0 items-center gap-2">
<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
class="{item.class ?? ''} flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class={ICON_CLASS_DEFAULT} />
{#if supportedModalities.length > 0}
<span class="flex items-center gap-0.75 text-muted-foreground">
{#each supportedModalities as modality (modality.label)}
<Tooltip.Root>
<Tooltip.Trigger>
<modality.icon class="size-2.75" />
</Tooltip.Trigger>
<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={ICON_CLASS_DEFAULT} />
<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>
<Tooltip.Content>
<p>{modality.label}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</span>
{/if}
</span>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
@@ -1,51 +0,0 @@
<script lang="ts">
import { FolderOpen, Server, Zap } from '@lucide/svelte';
import { McpLogo } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
const chatFormActions = getChatFormActionsContext();
function handleServersClick() {
chatFormActions.onMcpSettingsClick?.();
}
</script>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class={ICON_CLASS_DEFAULT} />
<span>MCP</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-48">
<DropdownMenu.Item class="flex cursor-pointer items-center gap-2" onclick={handleServersClick}>
<Server class={ICON_CLASS_DEFAULT} />
<span>Servers</span>
</DropdownMenu.Item>
{#if chatFormActions.hasMcpPromptsSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpPromptClick}
>
<Zap class={ICON_CLASS_DEFAULT} />
<span>Prompts</span>
</DropdownMenu.Item>
{/if}
{#if chatFormActions.hasMcpResourcesSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpResourcesClick}
>
<FolderOpen class={ICON_CLASS_DEFAULT} />
<span>Resources</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -8,70 +8,68 @@
const reasoning = useReasoningMenu();
</script>
{#if reasoning.modelSupportsThinking}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
{#if reasoning.thinkingEnabled}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
{:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{:else}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
{#if reasoning.isReasoningActive}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
{:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{:else}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
<span
class="text-sm inline-flex gap-2 {!reasoning.thinkingEnabled
? 'text-muted-foreground'
: ''}"
>
Reasoning
<span class="capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
<span
class="text-sm inline-flex gap-2 {!reasoning.isReasoningActive
? 'text-muted-foreground'
: ''}"
>
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
level
)
? 'bg-accent'
: ''}"
onclick={() => reasoning.select(level)}
>
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
{:else}
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
{/if}
Reasoning
<span class="flex-1">{level.label}</span>
<span class="capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</span>
</DropdownMenu.SubTrigger>
{#if tokenLabel}
<span class="text-[11px] text-muted-foreground opacity-60">
{tokenLabel}
</span>
{/if}
<DropdownMenu.SubContent
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
>
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
level
)
? 'bg-accent'
: ''}"
onclick={() => reasoning.select(level)}
>
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
{:else}
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<span class="flex-1">{level.label}</span>
<Tooltip.Content side="left">
<p>Maximum reasoning effort with extended context usage</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
{#if tokenLabel}
<span class="text-[11px] text-muted-foreground opacity-60">
{tokenLabel}
</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}
</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -1,18 +1,18 @@
<script lang="ts">
import { File, FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import {
Check,
ChevronDown,
ChevronRight,
File,
Lightbulb,
LightbulbOff,
MessageSquare,
PencilRuler
} from '@lucide/svelte';
import { McpLogo } from '$lib/components/app';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as Sheet from '$lib/components/ui/sheet';
import { Switch } from '$lib/components/ui/switch';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
ATTACHMENT_FILE_ITEMS,
@@ -20,12 +20,11 @@
TOOLTIP_DELAY_DURATION
} from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
import { HealthCheckStatus } from '$lib/enums';
import { AttachmentAction } from '$lib/enums/attachment.enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { conversationsStore, mcpStore } from '$lib/stores';
import type { ToolGroup } from '$lib/types';
import type { Snippet } from 'svelte';
interface Props {
@@ -38,23 +37,18 @@
const chatFormActions = getChatFormActionsContext();
let sheetOpen = $state(false);
let reasoningExpanded = $state(false);
let filesExpanded = $state(true);
let reasoningExpanded = $state(false);
let toolsExpanded = $state(false);
let mcpExpanded = $state(false);
const attachmentMenu = useAttachmentMenu(
() => ({
hasAudioModality: chatFormActions.hasAudioModality,
hasMcpPromptsSupport: chatFormActions.hasMcpPromptsSupport,
hasMcpResourcesSupport: chatFormActions.hasMcpResourcesSupport,
hasVideoModality: chatFormActions.hasVideoModality,
hasVisionModality: chatFormActions.hasVisionModality
}),
() => ({
onFileUpload: chatFormActions.onFileUpload,
onMcpPromptClick: chatFormActions.onMcpPromptClick,
onMcpResourcesClick: chatFormActions.onMcpResourcesClick,
onSystemPromptClick: chatFormActions.onSystemPromptClick
}),
() => {
@@ -70,8 +64,6 @@
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';
let mcpServers = $derived(mcpStore.getServers());
</script>
<div class="flex items-center gap-1 {className}">
@@ -194,80 +186,15 @@
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Root onOpenChange={(open) => (mcpExpanded = open)} open={mcpExpanded}>
<Collapsible.Trigger class={sheetItemClass}>
{#if mcpExpanded}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
{:else}
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
{/if}
<button
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
type="button"
>
<MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
<McpLogo class="inline {ICON_CLASS_DEFAULT} shrink-0" />
<span class="flex-1">MCP Servers</span>
<span class="text-xs text-muted-foreground">
{mcpServers.length} server{mcpServers.length !== 1 ? 's' : ''}
</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each mcpServers 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.preferences.isMcpServerEnabledForChat(
server.id
)}
<button
class={sheetItemRowClass}
disabled={hasError}
onclick={() =>
!hasError && conversationsStore.preferences.toggleMcpServerForChat(server.id)}
type="button"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if faviconUrl}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={faviconUrl}
/>
{/if}
<span class="min-w-0 truncate text-sm">{displayName}</span>
</div>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{:else}
<Switch
checked={isEnabled}
onCheckedChange={() =>
conversationsStore.preferences.toggleMcpServerForChat(server.id)}
/>
{/if}
</button>
{/each}
{#if mcpServers.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>
<span>System Message</span>
</button>
{#if toolsPanel.totalToolCount > 0}
<Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
@@ -289,40 +216,12 @@
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each toolsPanel.activeGroups as group (group.key)}
{@const checked = toolsPanel.isGroupChecked(group)}
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
{@const favicon = toolsPanel.getFavicon(group)}
{#each toolsPanel.categoryGroups as group (group.key)}
{@render sheetGroupRow(group)}
{/each}
<button
class={sheetItemRowClass}
onclick={() => toolsPanel.toggleGroupByKey(group.key)}
type="button"
>
{#if favicon}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/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}
class="{ICON_CLASS_DEFAULT} shrink-0"
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
onclick={(e) => e.stopPropagation()}
/>
</button>
{#each toolsPanel.mcpGroups as group (group.key)}
{@render sheetGroupRow(group)}
{/each}
</div>
</Collapsible.Content>
@@ -331,38 +230,55 @@
<button
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
onclick={() => {
sheetOpen = false;
chatFormActions.onMcpSettingsClick?.();
}}
type="button"
>
<MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
<McpLogo class="inline {ICON_CLASS_DEFAULT} shrink-0" />
<span>System Message</span>
<span>MCP Servers</span>
</button>
{#if chatFormActions.hasMcpPromptsSupport}
<button
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_PROMPT_CLICK]()}
type="button"
>
<Zap class="{ICON_CLASS_DEFAULT} shrink-0" />
<span>MCP Prompt</span>
</button>
{/if}
{#if chatFormActions.hasMcpResourcesSupport}
<button
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_RESOURCES_CLICK]()}
type="button"
>
<FolderOpen class="{ICON_CLASS_DEFAULT} shrink-0" />
<span>MCP Resources</span>
</button>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
</div>
{#snippet sheetGroupRow(group: ToolGroup)}
{@const checkState = toolsPanel.getGroupCheckState(group)}
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
{@const favicon = toolsPanel.getFavicon(group)}
{@const groupDisabled = toolsPanel.isGroupDisabled(group)}
<button
class="{sheetItemRowClass} {groupDisabled ? 'pointer-events-none opacity-50' : ''}"
onclick={() => toolsPanel.toggleGroupByKey(group.key)}
type="button"
>
{#if favicon}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/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={checkState.checked}
class="{ICON_CLASS_DEFAULT} shrink-0"
indeterminate={checkState.indeterminate}
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
onclick={(e) => e.stopPropagation()}
/>
</button>
{/snippet}
@@ -7,6 +7,7 @@
import { CLI_FLAGS, ICON_CLASS_DEFAULT } from '$lib/constants';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { mcpStore, toolsStore } from '$lib/stores';
import type { ToolGroup } from '$lib/types';
const toolsPanel = useToolsPanel();
const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0);
@@ -62,95 +63,108 @@
{/if}
{:else}
<div class="max-h-80 overflow-y-auto p-2 pr-1">
{#each toolsPanel.activeGroups as group (group.key)}
{@const isExpanded = toolsPanel.expandedGroups.has(group.key)}
{@const checked = toolsPanel.isGroupChecked(group)}
{@const favicon = toolsPanel.getFavicon(group)}
{#each toolsPanel.categoryGroups as group (group.key)}
{@render groupRow(group)}
{/each}
<Collapsible.Root
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)}
open={isExpanded}
>
<div class="flex items-center gap-1">
<Collapsible.Trigger
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
{:else}
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{toolsPanel.getEnabledToolCount(group)}/{group.tools.length}
</span>
</Collapsible.Trigger>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Checkbox
{...props}
{checked}
class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
/>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checked ? 'Disable' : 'Enable'}
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each group.tools as entry (entry.key)}
{@const enabled = toolsStore.isToolEnabled(entry.key)}
<button
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50"
onclick={() => toolsStore.toggleTool(entry.key)}
type="button"
>
<span
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
data-slot="checkbox"
data-state={enabled ? 'checked' : 'unchecked'}
>
{#if enabled}
<Check class="size-3.5" />
{/if}
</span>
<span class="min-w-0 flex-1 truncate font-mono text-[12px]">
{entry.definition.function.name}
</span>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{#each toolsPanel.mcpGroups as group (group.key)}
{@render groupRow(group)}
{/each}
</div>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{#snippet groupRow(group: ToolGroup)}
{@const isExpanded = toolsPanel.expandedGroups.has(group.key)}
{@const checkState = toolsPanel.getGroupCheckState(group)}
{@const favicon = toolsPanel.getFavicon(group)}
{@const groupDisabled = toolsPanel.isGroupDisabled(group)}
<Collapsible.Root
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)}
open={isExpanded}
>
<div class="flex items-center gap-1 {groupDisabled ? 'pointer-events-none opacity-50' : ''}">
<Collapsible.Trigger
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
{:else}
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{toolsPanel.getEnabledToolCount(group)}/{group.tools.length}
</span>
</Collapsible.Trigger>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Checkbox
{...props}
checked={checkState.checked}
class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
indeterminate={checkState.indeterminate}
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
/>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checkState.checked ? 'Disable' : 'Enable'}
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each group.tools as entry (entry.key)}
{@const enabled = toolsPanel.isToolEnabled(entry)}
{@const parentDisabled = toolsPanel.isToolParentDisabled(entry)}
<button
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50 {parentDisabled
? 'opacity-50'
: ''}"
onclick={() => toolsPanel.toggleTool(entry)}
type="button"
>
<span
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
data-slot="checkbox"
data-state={enabled ? 'checked' : 'unchecked'}
>
{#if enabled}
<Check class="size-3.5" />
{/if}
</span>
<span class="min-w-0 flex-1 truncate font-mono text-[12px]">
{entry.definition.function.name}
</span>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/snippet}
@@ -13,7 +13,7 @@
import { setChatFormActionsContext } from '$lib/contexts';
import { FileTypeCategory, MessageRole } from '$lib/enums';
import { ChatService } from '$lib/services';
import { chatStore, conversationsStore, mcpStore, settingsStore } from '$lib/stores';
import { chatStore, conversationsStore, settingsStore } from '$lib/stores';
import { getFileTypeCategory } from '$lib/utils';
interface Props {
@@ -31,8 +31,6 @@
onMicClick?: () => void;
onStop?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void;
}
@@ -45,8 +43,6 @@
isReasoning = false,
isRecording = false,
onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onMcpSettingsClick,
onMicClick,
onStop,
@@ -58,18 +54,6 @@
let currentConfig = $derived(settingsStore.config);
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
return mcpStore.hasPromptsCapability(perChatOverrides);
});
let hasMcpResourcesSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
return mcpStore.hasResourcesCapability(perChatOverrides);
});
let hasAudioModality = $state(false);
let hasVideoModality = $state(false);
let hasVisionModality = $state(false);
@@ -142,12 +126,6 @@
get hasAudioModality() {
return hasAudioModality;
},
get hasMcpPromptsSupport() {
return hasMcpPromptsSupport;
},
get hasMcpResourcesSupport() {
return hasMcpResourcesSupport;
},
get hasVideoModality() {
return hasVideoModality;
},
@@ -157,12 +135,6 @@
get onFileUpload() {
return onFileUpload;
},
get onMcpPromptClick() {
return onMcpPromptClick;
},
get onMcpResourcesClick() {
return onMcpResourcesClick;
},
get onMcpSettingsClick() {
return onMcpSettingsClick;
},
@@ -5,12 +5,12 @@
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import * as Popover from '$lib/components/ui/popover';
import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH, UI_DATA_ATTRS } from '$lib/constants';
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
import { BuiltInTool, GlobSearchType, KeyboardKey, ToolSource } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { ToolsService } from '$lib/services/tools.service';
import { toolsStore } from '$lib/stores';
import { conversationsStore, toolsStore } from '$lib/stores';
import type { GlobEntry } from '$lib/types';
import {
abbreviateHome,
@@ -63,8 +63,11 @@
// unavailable instead of firing searches that would only fail. Browse is
// hidden too: it resolves the picked folder name through the same tool.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
// effective policy: the active conversation's tool policy, or global defaults
const fileSearchEnabled = $derived(
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
fileSearchKey !== null &&
conversationsStore.preferences.isToolEnabled(fileSearchKey) &&
conversationsStore.preferences.isCategoryEnabled(ToolSource.SERVER)
);
const searchUnavailableMessage = $derived(
fileSearchKey === null
@@ -9,7 +9,7 @@
} from '$lib/components/app/chat';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { KeyboardKey } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores';
import { mcpStore } from '$lib/stores';
import type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
import { debounce, uuid } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
@@ -87,8 +87,7 @@
isLoading = true;
try {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
const initialized = await mcpStore.ensureInitialized();
if (!initialized) {
prompts = [];
@@ -5,10 +5,16 @@
import * as Popover from '$lib/components/ui/popover';
import * as Tooltip from '$lib/components/ui/tooltip';
import { FILE_GLOB_SEARCH_PICKERS, HOME_TILDE, SEARCH } from '$lib/constants';
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import {
BuiltInTool,
FileMentionEntryType,
GlobSearchType,
KeyboardKey,
ToolSource
} from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { deviceStore, settingsStore, toolsStore } from '$lib/stores';
import { conversationsStore, deviceStore, settingsStore, toolsStore } from '$lib/stores';
import type { FileMentionEntry, GlobEntryResult } from '$lib/types';
import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
@@ -52,8 +58,11 @@
// --tools) or the user disabled it, the picker still opens but explains
// why instead of firing searches that would only fail.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
// effective policy: the active conversation's tool policy, or global defaults
const fileSearchEnabled = $derived(
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
fileSearchKey !== null &&
conversationsStore.preferences.isToolEnabled(fileSearchKey) &&
conversationsStore.preferences.isCategoryEnabled(ToolSource.SERVER)
);
let searchResults = $state<FileMentionEntry[]>([]);
@@ -111,7 +111,6 @@
onValueChange={editCtx.setContent}
placeholder="Edit your message..."
showAddButton={editCtx.messageRole === MessageRole.USER}
showMcpPromptButton
showModelSelector={editCtx.messageRole === MessageRole.USER}
value={editCtx.editedContent}
/>
@@ -160,6 +160,5 @@
onSubmit={handleSubmit}
onSystemPromptClick={handleSystemPromptClick}
onUploadedFileRemove={handleUploadedFileRemove}
showMcpPromptButton
/>
</div>
@@ -220,19 +220,6 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
*/
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
/**
* Dropdown submenu for MCP prompts and resources in the chat form.
*
* Shows an "MCP" sub-menu item with entries for MCP Prompts and MCP
* Resources. Only visible when the server supports them.
*
* @example
* ```svelte
* <ChatFormActionAddMcpSubmenu />
* ```
*/
export { default as ChatFormActionAddMcpSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpSubmenu.svelte';
/**
* Dropdown submenu for selecting reasoning effort level.
*
@@ -8,7 +8,7 @@
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { conversationsStore, mcpStore } from '$lib/stores';
import { mcpStore } from '$lib/stores';
import type { MCPResourceContent, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
import { getResourceDisplayName } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
@@ -48,8 +48,7 @@
});
async function loadResources() {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
const initialized = await mcpStore.ensureInitialized();
if (initialized) {
await mcpStore.fetchAllResources();
@@ -10,7 +10,7 @@
RECOMMENDED_MCP_SERVERS
} from '$lib/constants';
import { BooleanString, HealthCheckStatus } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores';
import { mcpStore } from '$lib/stores';
import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
interface Props {
@@ -234,8 +234,6 @@
useProxy: newServerUseProxy
});
conversationsStore.preferences.setMcpServerOverride(newServerId, true);
handleOpenChange(false);
}
@@ -76,22 +76,19 @@
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content class="@container z-9999 !max-h-[80dvh] !max-w-[60rem] max-w-full">
<style>
@container (max-width: 56rem) {
.resizable-text-container {
max-width: calc(100vw - var(--threshold));
}
}
</style>
<Dialog.Content
class="z-9999 max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! md:w-[calc(100vw-4rem)]! md:max-w-[60rem]! md:max-h-[80dvh]!"
>
<!-- sticky header holds only the close button; the title scrolls with the body -->
<Dialog.Header />
<Dialog.Header>
<Dialog.Title>Model Information</Dialog.Title>
<div class="min-w-0 space-y-6 md:py-4 -mt-4! md:mt-0 pb-4">
<div class="min-w-0 space-y-2">
<Dialog.Title>Model Information</Dialog.Title>
<Dialog.Description>Current model details and capabilities</Dialog.Description>
</Dialog.Header>
<Dialog.Description>Current model details and capabilities</Dialog.Description>
</div>
<div class="space-y-6 py-4">
{#if isLoadingModels || isLoadingRouterProps}
<div class="flex items-center justify-center py-8">
<div class="text-sm text-muted-foreground">Loading model information...</div>
@@ -100,17 +97,15 @@
{@const modelMeta = firstModel.meta}
{#if serverProps}
<Table.Root>
<!-- Desktop: fixed-layout table, long values scroll inside their cell -->
<Table.Root class="hidden table-fixed md:table">
<Table.Header>
<Table.Row>
<Table.Head class="w-[10rem]">Model</Table.Head>
<Table.Head>
<div class="inline-flex items-center gap-2">
<span
style:--threshold="12rem"
class="resizable-text-container min-w-0 flex-1 truncate"
>
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
{modelName}
</span>
@@ -129,20 +124,17 @@
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell>
<Table.Cell
class="inline-flex h-10 items-center gap-2 align-middle font-mono text-xs"
>
<span
style:--threshold="14rem"
class="resizable-text-container min-w-0 flex-1 truncate"
>
{serverProps.model_path}
</span>
<Table.Cell class="h-10 align-middle font-mono text-xs">
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
{serverProps.model_path}
</span>
<ActionIconCopyToClipboard
ariaLabel="Copy model path to clipboard"
text={serverProps.model_path}
/>
<ActionIconCopyToClipboard
ariaLabel="Copy model path to clipboard"
text={serverProps.model_path}
/>
</div>
</Table.Cell>
</Table.Row>
@@ -251,18 +243,113 @@
<!-- Chat Template -->
{#if serverProps.chat_template}
<Table.Row>
<Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
<Table.Cell class="py-4" colspan={2}>
<div class="flex flex-col gap-2">
<span class="font-medium">Chat Template</span>
<Table.Cell class="py-10">
<div class="rounded-md bg-muted p-4">
<pre
class="font-mono text-xs whitespace-pre-wrap">{serverProps.chat_template}</pre>
<div class="overflow-x-auto rounded-md bg-muted p-4">
<pre
class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
</div>
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
<!-- Mobile: stacked layout; long values wrap instead of scrolling the page -->
<div class="flex min-w-0 flex-col gap-4 md:hidden">
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">Model</div>
<div class="flex min-w-0 items-start gap-2">
<span class="min-w-0 flex-1 break-all font-mono text-xs">{modelName}</span>
<ActionIconCopyToClipboard
ariaLabel="Copy model name to clipboard"
canCopy={!!modelName}
text={modelName || ''}
/>
</div>
</div>
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">File Path</div>
<div class="flex min-w-0 items-start gap-2">
<span class="min-w-0 flex-1 break-all font-mono text-xs"
>{serverProps.model_path}</span
>
<ActionIconCopyToClipboard
ariaLabel="Copy model path to clipboard"
text={serverProps.model_path}
/>
</div>
</div>
{#if serverProps?.default_generation_settings?.n_ctx}
{@render infoRow(
'Context Size',
`${formatNumber(serverProps.default_generation_settings.n_ctx)} tokens`
)}
{:else}
{@render infoRow('Context Size', 'Not available', 'text-red-500')}
{/if}
{#if modelMeta?.n_ctx_train}
{@render infoRow('Training Context', `${formatNumber(modelMeta.n_ctx_train)} tokens`)}
{/if}
{#if modelMeta?.size}
{@render infoRow('Model Size', formatFileSize(modelMeta.size))}
{/if}
{#if modelMeta?.n_params}
{@render infoRow('Parameters', formatParameters(modelMeta.n_params))}
{/if}
{#if modelMeta?.n_embd}
{@render infoRow('Embedding Size', formatNumber(modelMeta.n_embd))}
{/if}
{#if modelMeta?.n_vocab}
{@render infoRow('Vocabulary Size', `${formatNumber(modelMeta.n_vocab)} tokens`)}
{/if}
{#if modelMeta?.vocab_type}
{@render infoRow('Vocabulary Type', modelMeta.vocab_type, 'capitalize')}
{/if}
{@render infoRow('Parallel Slots', `${serverProps.total_slots}`)}
{#if modalities.length > 0}
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">Modalities</div>
<div class="flex flex-wrap gap-1">
<BadgesModality {modalities} />
</div>
</div>
{/if}
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">Build Info</div>
<span class="block break-all font-mono text-xs">{serverProps.build_info}</span>
</div>
{#if serverProps.chat_template}
<div class="min-w-0 space-y-2">
<div class="text-xs font-medium text-muted-foreground">Chat Template</div>
<div class="overflow-x-auto rounded-md bg-muted p-4">
<pre class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
</div>
</div>
{/if}
</div>
{/if}
{:else if !isLoadingModels}
<div class="flex items-center justify-center py-8">
@@ -272,3 +359,11 @@
</div>
</Dialog.Content>
</Dialog.Root>
{#snippet infoRow(label: string, value: string, valueClass: string = '')}
<div class="flex items-center justify-between gap-3">
<span class="shrink-0 text-xs font-medium text-muted-foreground {valueClass}">{label}</span>
<span class="text-sm {valueClass}">{value}</span>
</div>
{/snippet}
@@ -2,7 +2,7 @@
import McpLogo from './McpLogo.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT, MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
import { HealthCheckStatus, ToolSource } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores';
interface Props {
@@ -13,9 +13,13 @@
let { class: className = '', onclick }: Props = $props();
let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled));
// respect the active conversation's tool policy, not just global enablement
let enabledMcpServersForChat = $derived(
mcpServers.filter(
(s) => conversationsStore.preferences.isMcpServerEnabledForChat(s.id) && s.url.trim()
(s) =>
s.url.trim() &&
conversationsStore.preferences.isCategoryEnabled(ToolSource.MCP) &&
conversationsStore.preferences.isServerToolsEnabled(s.id)
)
);
let healthyEnabledMcpServers = $derived(
@@ -1,27 +1,44 @@
<script lang="ts">
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
CAPABILITY_FLAG_KEYS,
CAPABILITY_ICONS,
CAPABILITY_LABELS,
MODALITY_FLAG_KEYS,
MODALITY_ICONS,
MODALITY_LABELS
} from '$lib/constants';
import { ModelCapability, ModelModality } from '$lib/enums';
import { ModelsService } from '$lib/services/models.service';
import { settingsStore } from '$lib/stores';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
interface Props {
modelId: string;
hideOrgName?: boolean;
showRaw?: boolean;
showRawTooltip?: boolean;
hideQuantization?: boolean;
hideTags?: boolean;
aliases?: string[];
tags?: string[];
modalities?: ModelModalities;
capabilities?: ModelCapabilities;
class?: string;
}
let {
aliases,
capabilities,
class: className = '',
hideOrgName = false,
hideQuantization,
hideTags,
modalities,
modelId,
showRaw = undefined,
showRawTooltip = false,
tags,
...rest
}: Props = $props();
@@ -43,6 +60,16 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
const allModalities = [ModelModality.VISION, ModelModality.VIDEO, ModelModality.AUDIO] as const;
const allCapabilities: ModelCapability[] = [ModelCapability.REASONING];
let activeModalities = $derived(
allModalities.filter((modality) => modalities?.[MODALITY_FLAG_KEYS[modality]])
);
let activeCapabilities = $derived(
allCapabilities.filter((capability) => capabilities?.[CAPABILITY_FLAG_KEYS[capability]])
);
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
</script>
@@ -50,37 +77,87 @@
{#if resolvedShowRaw}
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
{:else}
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}" {...rest}>
{#snippet nameAndBadges()}
<span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
<span class="inline-flex items-center gap-1">
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{:else if uniqueAliases.length > 1}
{#each uniqueAliases as alias (alias)}
<span class={badgeClass}>{alias}</span>
{/each}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
{/if}
{:else if uniqueAliases.length > 1}
{#each uniqueAliases as alias (alias)}
<span class={badgeClass}>{alias}</span>
{/each}
{/if}
{#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{/if}
</span>
{/snippet}
<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{/if}
{#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{#if activeCapabilities.length > 0 || activeModalities.length > 0}
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
{#each activeCapabilities as capability (capability)}
{@const CapabilityIcon = CAPABILITY_ICONS[capability]}
<Tooltip.Root>
<Tooltip.Trigger>
<CapabilityIcon class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>{CAPABILITY_LABELS[capability]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
{#each activeModalities as modality (modality)}
{@const ModalityIcon = MODALITY_ICONS[modality]}
<Tooltip.Root>
<Tooltip.Trigger>
<ModalityIcon class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>{MODALITY_LABELS[modality]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</span>
{/if}
</span>
{/if}
@@ -1,8 +1,9 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import type { ModelItem } from './utils';
import { ChevronDown, Loader2 } from '@lucide/svelte';
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation,
DropdownMenuSearchable,
ModelId,
@@ -11,10 +12,11 @@
} from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { modelsStore } from '$lib/stores';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { modelsStore, settingsStore } from '$lib/stores';
import { modelLoadFraction } from '$lib/utils';
interface Props {
@@ -37,6 +39,9 @@
let isOpen = $state(false);
let highlightedId = $state<string | null>(null);
// The model submenu opens together with the menu so the list and its search
// box are immediately available, as before the submenu was introduced
let modelSubOpen = $state(false);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -44,24 +49,41 @@
onOpenChange: (open) => {
isOpen = open;
highlightedId = null;
if (open) {
// Defer submenu open so the Sub component is mounted first;
// setting bind:open synchronously can be lost if the Sub hasn't
// rendered yet.
queueMicrotask(() => {
if (isOpen) modelSubOpen = true;
});
} else {
modelSubOpen = false;
}
},
useGlobalSelection: () => useGlobalSelection
});
const reasoning = useReasoningMenu();
const showOrgNameInTrigger = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
);
$effect(() => {
void ms.searchTerm;
highlightedId = null;
});
// Focus the dropdown's search box without scrolling the page. bits-ui
// Focus the model submenu's search box without scrolling the page. bits-ui
// auto-focuses the opened content by default, which can yank the page
// scroll; we prevent that on the Content and refocus the search here.
$effect(() => {
if (!isOpen) return;
if (!isOpen || !modelSubOpen) return;
requestAnimationFrame(() => {
const search = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-content"] input'
'[data-slot="dropdown-menu-sub-content"] input'
);
search?.focus({ preventScroll: true });
@@ -188,7 +210,7 @@
<DropdownMenu.Trigger
{...props}
class={[
`relative 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`,
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 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
@@ -203,16 +225,22 @@
>
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideQuantization
modelId={selectedOption.model}
/>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
<span class="flex min-w-0 items-center gap-1">
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
{#if reasoning.isReasoningActive}
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
</span>
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
@@ -236,73 +264,94 @@
<DropdownMenu.Content
align="end"
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSearchable
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
onSearchChange={(v) => ms.setSearchTerm(v)}
onSearchKeyDown={handleSearchKeyDown}
placeholder="Search models..."
searchValue={ms.searchTerm}
>
<div class="models-list">
{#if !ms.isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
aria-disabled="true"
aria-selected="true"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
disabled
role="option"
type="button"
>
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
<DropdownMenu.Sub bind:open={modelSubOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<MODEL_SELECTOR_ICON class="h-4 w-4" />
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
{/if}
{#if ms.filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{hideOrgName}
{isFav}
{isHighlighted}
{isSelected}
onInfoClick={ms.handleInfoClick}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
void handleModelKeyAction(option.id, event.altKey);
}
}}
onMouseEnter={() => (highlightedId = option.id)}
onSelect={ms.handleSelect}
{option}
{#if selectedOption}
<ModelId
class="min-w-0 flex-1 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{/snippet}
{:else}
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
{/if}
</DropdownMenu.SubTrigger>
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
/>
</div>
</DropdownMenuSearchable>
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
<DropdownMenuSearchable
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
onSearchChange={(v) => ms.setSearchTerm(v)}
onSearchKeyDown={handleSearchKeyDown}
placeholder="Search models..."
searchValue={ms.searchTerm}
>
<div class="models-list">
{#if !ms.isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
aria-disabled="true"
aria-selected="true"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
disabled
role="option"
type="button"
>
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
{/if}
{#if ms.filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{hideOrgName}
{isFav}
{isHighlighted}
{isSelected}
onInfoClick={ms.handleInfoClick}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
void handleModelKeyAction(option.id, event.altKey);
}
}}
onMouseEnter={() => (highlightedId = option.id)}
onSelect={ms.handleSelect}
{option}
/>
{/snippet}
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
/>
</div>
</DropdownMenuSearchable>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<ChatFormActionAddReasoningSubmenu />
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
@@ -332,12 +381,16 @@
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{/if}
{#if reasoning.isReasoningActive}
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
{#if ms.updating}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{/if}
@@ -58,6 +58,10 @@
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
let loadTitle = $derived(modelLoadProgressText(loadProgress));
let modalities = $derived(option.modalities);
let capabilities = $derived.by(() => ({
reasoning: modelsStore.props.checkModelSupportsThinking(option.model)
}));
</script>
<div
@@ -65,9 +69,11 @@
class={[
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer',
isSelected && 'bg-accent/50 text-accent-foreground',
isSelected && !isHighlighted && 'bg-accent/50',
isHighlighted && 'bg-accent',
!isSelected && !isHighlighted && 'hover:bg-muted',
(isSelected || isHighlighted) && 'text-accent-foreground',
'hover:bg-accent',
'focus:bg-accent',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
]}
onclick={() => onSelect(option.id)}
@@ -79,9 +85,12 @@
>
<ModelId
aliases={option.aliases}
{capabilities}
class="flex-1"
{hideOrgName}
{modalities}
modelId={option.model}
showRawTooltip
tags={option.tags}
/>
@@ -1,3 +1,4 @@
import { ModelModality } from '$lib/enums';
import type { ModelOption } from '$lib/types/models';
import { SvelteMap } from 'svelte/reactivity';
@@ -17,6 +18,23 @@ export interface GroupedModelOptions {
available: OrgGroup[];
}
function matchesModality(option: ModelOption, term: string): boolean {
const modalities = option.modalities;
if (!modalities) return false;
switch (term) {
case ModelModality.VISION.toLowerCase():
return modalities.vision;
case ModelModality.AUDIO.toLowerCase():
return modalities.audio;
case ModelModality.VIDEO.toLowerCase():
return modalities.video;
default:
return false;
}
}
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
const term = searchTerm.trim().toLowerCase();
@@ -27,7 +45,8 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
option.model.toLowerCase().includes(term) ||
option.name?.toLowerCase().includes(term) ||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
matchesModality(option, term)
);
}
@@ -25,6 +25,10 @@
<div class="py-8 text-center text-sm text-muted-foreground">No tools available</div>
{:else}
<div class="space-y-2">
<p class="text-sm text-muted-foreground">
Applies to new conversations. Tool picks inside a chat only affect that chat.
</p>
{#each groups as group (group.key)}
{@const isExpanded = expandedGroups.has(group.key)}
<Collapsible.Root onOpenChange={() => toggleExpanded(group.key)} open={isExpanded}>
@@ -37,6 +41,17 @@
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
{@const isCategoryEnabled =
group.source !== ToolSource.MCP && toolsStore.isCategoryEnabled(group.source)}
{#if group.source !== ToolSource.MCP}
<Checkbox
checked={isCategoryEnabled}
onCheckedChange={() => toolsStore.toggleCategory(group.source)}
onclick={(e) => e.stopPropagation()}
/>
{/if}
{@const faviconUrl = group.serverId ? mcpStore.getServerFavicon(group.serverId) : null}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
@@ -7,7 +7,7 @@
import { Button } from '$lib/components/ui/button';
import * as Empty from '$lib/components/ui/empty';
import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore, mcpStore, toolsStore } from '$lib/stores';
import { mcpStore, toolsStore } from '$lib/stores';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
@@ -86,15 +86,13 @@
<McpServerCardSkeleton />
{:else}
<McpServerCard
enabled={conversationsStore.preferences.isMcpServerEnabledForChat(server.id)}
enabled={server.enabled}
onBrowseResources={() => (isResourcesDialogOpen = true)}
onDelete={() => mcpStore.removeServer(server.id)}
onToggle={async () => {
const wasEnabled = conversationsStore.preferences.isMcpServerEnabledForChat(
server.id
);
const wasEnabled = server.enabled;
await conversationsStore.preferences.toggleMcpServerForChat(server.id);
mcpStore.updateServer(server.id, { enabled: !wasEnabled });
if (!wasEnabled) {
// Promote the connection so tools/prompts/resources become
@@ -26,10 +26,10 @@
>
{#snippet children({ checked, indeterminate })}
<div class="text-current transition-none" data-slot="checkbox-indicator">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
{#if indeterminate}
<MinusIcon class="size-3.5" />
{:else if checked}
<CheckIcon class="size-3.5" />
{/if}
</div>
{/snippet}
@@ -1,11 +1,5 @@
import { FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import { FILE_TYPE_ICONS } from '$lib/constants';
import {
AttachmentAction,
AttachmentItemEnabledWhen,
AttachmentItemVisibleWhen,
AttachmentMenuItemId
} from '$lib/enums';
import { AttachmentAction, AttachmentItemEnabledWhen, AttachmentMenuItemId } from '$lib/enums';
import type { AttachmentMenuItem } from '$lib/types';
/**
@@ -58,36 +52,4 @@ export const ATTACHMENT_FILE_ITEMS: AttachmentMenuItem[] = [
}
];
export const ATTACHMENT_EXTRA_ITEMS: AttachmentMenuItem[] = [];
export const ATTACHMENT_PROMPT_ITEMS: AttachmentMenuItem[] = [
{
action: AttachmentAction.SYSTEM_PROMPT_CLICK,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
hasEnabledTooltip: true,
icon: MessageSquare,
id: AttachmentMenuItemId.SYSTEM_MESSAGE,
label: 'System Message'
},
{
action: AttachmentAction.MCP_PROMPT_CLICK,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
icon: Zap,
id: AttachmentMenuItemId.MCP_PROMPT,
label: 'MCP Prompts',
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT
}
];
export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
{
action: AttachmentAction.MCP_RESOURCES_CLICK,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
icon: FolderOpen,
id: AttachmentMenuItemId.MCP_RESOURCES,
label: 'MCP Resources',
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_RESOURCES_SUPPORT
}
];
export const ATTACHMENT_TOOLTIP_TEXT = 'Add files, prompts, tools or MCP Servers';
+27 -1
View File
@@ -8,10 +8,13 @@ import {
File as FileIcon,
FileText as FileTextIcon,
Image as ImageIcon,
Lightbulb as ReasoningIcon,
Mic as AudioIcon,
Video as VideoIcon
} from '@lucide/svelte';
import { FileTypeCategory, ModelModality } from '$lib/enums';
import { FileTypeCategory, ModelCapability, ModelModality } from '$lib/enums';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
import type { Component } from 'svelte';
export const FILE_TYPE_ICONS = {
[FileTypeCategory.AUDIO]: AudioIcon,
@@ -35,6 +38,29 @@ export const MODALITY_LABELS = {
[ModelModality.VISION]: 'Vision'
} as const;
/** Maps an input ModelModality to the boolean flag it drives on the ModelModalities type */
export const MODALITY_FLAG_KEYS: Record<
Exclude<ModelModality, ModelModality.TEXT>,
keyof ModelModalities
> = {
[ModelModality.AUDIO]: 'audio',
[ModelModality.VIDEO]: 'video',
[ModelModality.VISION]: 'vision'
};
export const CAPABILITY_ICONS: Record<ModelCapability, Component> = {
[ModelCapability.REASONING]: ReasoningIcon
} as const;
export const CAPABILITY_LABELS: Record<ModelCapability, string> = {
[ModelCapability.REASONING]: 'Reasoning'
} as const;
/** Maps a ModelCapability to the boolean flag it drives on the ModelCapabilities type */
export const CAPABILITY_FLAG_KEYS: Record<ModelCapability, keyof ModelCapabilities> = {
[ModelCapability.REASONING]: 'reasoning'
};
// Shared SVG icon strings for copy and preview buttons
export const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
@@ -54,6 +54,7 @@ export const SETTINGS_KEYS = {
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
// Display
SHOW_MESSAGE_STATS: 'showMessageStats',
SHOW_MODEL_ORG_NAME_IN_TRIGGER: 'showModelOrgNameInTrigger',
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
SHOW_MODEL_TAGS: 'showModelTags',
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
@@ -111,9 +111,8 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
defaultValue: true,
help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
isExperimental: true,
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
label: 'Show microphone on empty input',
type: SettingsFieldType.CHECKBOX
@@ -283,6 +282,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Show model tags',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
help: 'Display the organization name in the model selector trigger button.',
key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER,
label: 'Show organization name in model selector trigger',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
help: 'Display the current build version in the bottom-right corner of the interface.',
@@ -20,6 +20,9 @@ export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTool
/** Disabled tools keyed by stable selection identity, no migration from the name based key */
export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`;
/** Default disabled tool categories, seeded into newly created conversations */
export const DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolCategories`;
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`;
@@ -19,8 +19,6 @@ export enum AttachmentType {
export enum AttachmentMenuItemId {
AUDIO = 'audio',
IMAGES = 'images',
MCP_PROMPT = 'mcp-prompt',
MCP_RESOURCES = 'mcp-resources',
PDF = 'pdf',
SYSTEM_MESSAGE = 'system-message',
TEXT = 'text',
@@ -42,8 +40,6 @@ export enum AttachmentItemEnabledWhen {
*/
export enum AttachmentAction {
FILE_UPLOAD = 'onFileUpload',
MCP_PROMPT_CLICK = 'onMcpPromptClick',
MCP_RESOURCES_CLICK = 'onMcpResourcesClick',
SYSTEM_PROMPT_CLICK = 'onSystemPromptClick'
}
@@ -56,11 +52,3 @@ export enum AttachmentLabel {
MCP_RESOURCE = 'MCP Resource',
PDF_FILE = 'PDF File'
}
/**
* Visibility conditions for attachment menu items.
*/
export enum AttachmentItemVisibleWhen {
HAS_MCP_PROMPTS_SUPPORT = 'hasMcpPromptsSupport',
HAS_MCP_RESOURCES_SUPPORT = 'hasMcpResourcesSupport'
}
+2 -3
View File
@@ -3,8 +3,7 @@ export {
AttachmentType,
AttachmentMenuItemId,
AttachmentItemEnabledWhen,
AttachmentAction,
AttachmentItemVisibleWhen
AttachmentAction
} from './attachment.enums';
export {
@@ -68,7 +67,7 @@ export {
JsonSchemaType
} from './mcp.enums';
export { ModelModality } from './model.enums';
export { ModelCapability, ModelModality } from './model.enums';
export { ServerRole, ServerModelStatus, ServerModelsSseEventType } from './server.enums';
+4
View File
@@ -4,3 +4,7 @@ export enum ModelModality {
VIDEO = 'VIDEO',
VISION = 'VISION'
}
export enum ModelCapability {
REASONING = 'REASONING'
}
@@ -5,21 +5,16 @@ export interface AttachmentModalityFlags {
hasVisionModality: boolean;
hasAudioModality: boolean;
hasVideoModality: boolean;
hasMcpPromptsSupport: boolean;
hasMcpResourcesSupport: boolean;
}
export interface AttachmentActionCallbacks {
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
}
export interface UseAttachmentMenuReturn {
readonly callbacks: Record<string, () => void>;
isItemEnabled(enabledWhen: string | undefined): boolean;
isItemVisible(visibleWhen: string | undefined): boolean;
getSystemMessageTooltip(): string;
}
@@ -49,8 +44,6 @@ export function useAttachmentMenu(
return {
[AttachmentAction.FILE_UPLOAD]: wrap(cbs.onFileUpload),
[AttachmentAction.MCP_PROMPT_CLICK]: wrap(cbs.onMcpPromptClick),
[AttachmentAction.MCP_RESOURCES_CLICK]: wrap(cbs.onMcpResourcesClick),
[AttachmentAction.SYSTEM_PROMPT_CLICK]: wrap(cbs.onSystemPromptClick)
};
});
@@ -61,12 +54,6 @@ export function useAttachmentMenu(
return !!modalityFlags[enabledWhen as keyof AttachmentModalityFlags];
}
function isItemVisible(visibleWhen: string | undefined): boolean {
if (!visibleWhen) return true;
return !!modalityFlags[visibleWhen as keyof AttachmentModalityFlags];
}
function getSystemMessageTooltip(): string {
return !page.params.id
? 'Add custom system message for a new conversation'
@@ -78,7 +65,6 @@ export function useAttachmentMenu(
return callbacks;
},
getSystemMessageTooltip,
isItemEnabled,
isItemVisible
isItemEnabled
};
}
@@ -8,6 +8,7 @@ import { getConversationModel } from '$lib/utils';
export interface UseReasoningMenuReturn {
readonly modelSupportsThinking: boolean;
readonly thinkingEnabled: boolean;
readonly isReasoningActive: boolean;
readonly isOff: boolean;
readonly currentEffort: ReasoningEffort;
readonly levels: ReasoningEffortLevel[];
@@ -59,6 +60,12 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
const thinkingEnabled = $derived(
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
);
// Thinking is effectively on (lightbulb lit) either when an explicit effort
// is selected, or when the effort is left at "Default" and the model
// supports thinking.
const isReasoningActive = $derived(
thinkingEnabled || (currentEffort === ReasoningEffort.DEFAULT && modelSupportsThinking)
);
return {
get currentEffort() {
@@ -67,6 +74,9 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
get isOff() {
return currentEffort === ReasoningEffort.OFF;
},
get isReasoningActive() {
return isReasoningActive;
},
isSelected(level: ReasoningEffortLevel): boolean {
return currentEffort === level.value;
},
@@ -1,19 +1,23 @@
import { CLI_FLAGS } from '$lib/constants';
import { ToolSource } from '$lib/enums';
import { conversationsStore, mcpStore, toolsStore } from '$lib/stores';
import type { ToolGroup } from '$lib/types';
import type { ToolEntry, ToolGroup } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
export interface UseToolsPanelReturn {
readonly expandedGroups: SvelteSet<string>;
readonly groups: ToolGroup[];
readonly activeGroups: ToolGroup[];
readonly categoryGroups: ToolGroup[];
readonly mcpGroups: ToolGroup[];
readonly totalToolCount: number;
readonly noToolsInfoMessage: string | null;
isGroupChecked(group: ToolGroup): boolean;
getEnabledToolCount(group: ToolGroup): number;
getGroupCheckState(group: ToolGroup): { checked: boolean; indeterminate: boolean };
getFavicon(group: ToolGroup): string | null;
isGroupDisabled(group: ToolGroup): boolean;
isToolEnabled(entry: ToolEntry): boolean;
isToolParentDisabled(entry: ToolEntry): boolean;
toggleTool(entry: ToolEntry): void;
toggleGroupExpanded(key: string): void;
/** Toggle all tools in a group by its stable key (avoids stale group object references). */
toggleGroupByKey(key: string): void;
@@ -26,19 +30,18 @@ export interface UseToolsPanelReturn {
* Used by both the desktop dropdown (`ChatFormActionAddToolsSubmenu`)
* and the mobile sheet (`ChatFormActionAddSheet`) to avoid
* duplicating group filtering, checked-state derivation, and favicon logic.
*
* All toggle state routes through `conversationsStore.preferences`: with an
* active conversation it edits that conversation's tool policy, on the
* new-chat screen it edits the global defaults seeded into new conversations.
*/
export function useToolsPanel(): UseToolsPanelReturn {
const expandedGroups = new SvelteSet<string>();
const groups = $derived(toolsStore.toolGroups);
const activeGroups = $derived(
groups.filter(
(g) =>
g.source !== ToolSource.MCP ||
!g.serverId ||
conversationsStore.preferences.isMcpServerEnabledForChat(g.serverId)
)
);
const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
// non-MCP groups are 1:1 with tool categories; MCP tools group per server
const categoryGroups = $derived(groups.filter((g) => g.source !== ToolSource.MCP));
const mcpGroups = $derived(groups.filter((g) => g.source === ToolSource.MCP));
const totalToolCount = $derived(groups.reduce((n, g) => n + g.tools.length, 0));
const noToolsInfoMessage = $derived.by(() => {
if (toolsStore.loading) return null;
@@ -55,11 +58,27 @@ export function useToolsPanel(): UseToolsPanelReturn {
});
function isGroupChecked(group: ToolGroup): boolean {
return toolsStore.isGroupFullyEnabled(group);
return conversationsStore.preferences.isGroupChecked(group);
}
function getEnabledToolCount(group: ToolGroup): number {
return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.key)).length;
return group.tools.filter((tool) => conversationsStore.preferences.isToolActive(tool)).length;
}
/**
* Group checkbox state: checked is the parent flag (category on, or the
* server key on for MCP groups); indeterminate marks the mixed case where
* the parent is on but nothing or only part of the group is enabled.
* isToolActive folds the parent gates into the count, so a disabled parent
* always yields plain unchecked.
*/
function getGroupCheckState(group: ToolGroup): { checked: boolean; indeterminate: boolean } {
const checked = isGroupChecked(group);
const enabledCount = getEnabledToolCount(group);
const indeterminate =
group.tools.length > 0 && (enabledCount === 0 ? checked : enabledCount < group.tools.length);
return { checked, indeterminate };
}
function getFavicon(group: ToolGroup): string | null {
@@ -69,13 +88,25 @@ export function useToolsPanel(): UseToolsPanelReturn {
}
function isGroupDisabled(group: ToolGroup): boolean {
// MCP server groups gray out while the whole MCP category is off
return (
group.source === ToolSource.MCP &&
!!group.serverId &&
!conversationsStore.preferences.isMcpServerEnabledForChat(group.serverId)
!conversationsStore.preferences.isCategoryEnabled(ToolSource.MCP)
);
}
function isToolEnabled(entry: ToolEntry): boolean {
return conversationsStore.preferences.isToolEnabled(entry.key);
}
function isToolParentDisabled(entry: ToolEntry): boolean {
return conversationsStore.preferences.isToolParentDisabled(entry);
}
function toggleTool(entry: ToolEntry): void {
void conversationsStore.preferences.toggleTool(entry.key);
}
function toggleGroupExpanded(key: string): void {
if (expandedGroups.has(key)) {
expandedGroups.delete(key);
@@ -86,11 +117,11 @@ export function useToolsPanel(): UseToolsPanelReturn {
function toggleGroupByKey(key: string): void {
// Find current group by key to get up-to-date tool references
const group = activeGroups.find((g) => g.key === key);
const group = groups.find((g) => g.key === key);
if (!group) return;
toolsStore.toggleGroup(group);
void conversationsStore.preferences.toggleGroup(group);
}
function handleOpen(): void {
@@ -102,23 +133,27 @@ export function useToolsPanel(): UseToolsPanelReturn {
}
return {
get activeGroups() {
return activeGroups;
get categoryGroups() {
return categoryGroups;
},
expandedGroups,
getEnabledToolCount,
getFavicon,
get groups() {
return groups;
},
getGroupCheckState,
handleOpen,
isGroupChecked,
isGroupDisabled,
isToolEnabled,
isToolParentDisabled,
get mcpGroups() {
return mcpGroups;
},
get noToolsInfoMessage() {
return noToolsInfoMessage;
},
toggleGroupByKey,
toggleGroupExpanded,
toggleTool,
get totalToolCount() {
return totalToolCount;
}
+59 -1
View File
@@ -11,6 +11,7 @@
import {
CONFIG_LOCALSTORAGE_KEY,
DB_APP_NAME_DEPRECATED,
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
IDXDB_STORES,
IDXDB_TABLES,
LEGACY_AGENTIC_REGEX,
@@ -21,6 +22,7 @@ import {
STORAGE_APP_NAME_DEPRECATED
} from '$lib/constants';
import { BooleanString, MessageRole } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
import Dexie from 'dexie';
// Types
@@ -737,6 +739,61 @@ const mcpDefaultOverridesMergeMigration: Migration = {
);
}
};
const MCP_SERVER_OVERRIDES_TO_TOOL_POLICY_MIGRATION_ID = 'mcp-server-overrides-to-tool-policy-v1';
const mcpServerOverridesToToolPolicyMigration: Migration = {
description:
'Seed per-conversation disabled tool keys from the global defaults and legacy per-conversation MCP server overrides (legacy field preserved)',
id: MCP_SERVER_OVERRIDES_TO_TOOL_POLICY_MIGRATION_ID,
async run(): Promise<void> {
// The global disabled set used to apply to every conversation; it is now
// the defaults seeded into newly created conversations, so existing rows
// are seeded with it to keep their behavior unchanged.
let defaults: string[] = [];
try {
const raw = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
if (raw) {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
defaults = parsed.filter((k): k is string => typeof k === 'string');
}
}
} catch {
// fall through with empty defaults so legacy overrides still migrate
}
const db = await getDatabaseService();
const conversations = await db.getAllConversations();
let migratedCount = 0;
for (const conv of conversations) {
// re-run safety: a row that already has a policy is left alone
if (conv.disabledTools !== undefined) continue;
// A legacy per-conversation server disable becomes a server-scoped tool
// key (same format as toolsStore.getMcpServerToolsKey). Per-conversation
// enables are dropped: the global server flag governs now.
const serverGroupKeys = (conv.mcpServerOverrides ?? [])
.filter((o: McpServerOverride) => !o.enabled)
.map((o: McpServerOverride) => `mcp:${o.serverId}`);
const disabledTools = [...new Set([...defaults, ...serverGroupKeys])];
if (disabledTools.length === 0) continue;
await db.updateConversation(conv.id, { disabledTools });
migratedCount++;
}
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(
`[Migration] MCP server overrides -> tool policy: updated ${migratedCount} conversations`
);
}
};
const migrations: Migration[] = [
localStorageMigration,
idxdbMigration,
@@ -746,7 +803,8 @@ const migrations: Migration[] = [
mcpDefaultEnabledMigration,
mcpDefaultOverridesMergeMigration,
configTypesMigration,
renderKeysMigration
renderKeysMigration,
mcpServerOverridesToToolPolicyMigration
];
export const MigrationService = {
+10 -1
View File
@@ -187,8 +187,17 @@ export class ModelsService {
// 6. Model name = segments before params; tags = remaining segments after params
const pivotIdx = paramsIdx !== MODEL_ID.NOT_FOUND ? paramsIdx : segments.length;
const modelSegments = segments.slice(0, pivotIdx);
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID.SEGMENT_SEPARATOR) || null;
// strip trailing container-format segments (e.g. GGUF) from the model name
while (
modelSegments.length > 0 &&
MODEL_ID.IGNORED_SEGMENTS.has(modelSegments[modelSegments.length - 1].toUpperCase())
) {
modelSegments.pop();
}
result.modelName = modelSegments.join(MODEL_ID.SEGMENT_SEPARATOR) || null;
if (paramsIdx !== MODEL_ID.NOT_FOUND) {
result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => {
@@ -44,7 +44,6 @@ import type {
AgenticFlowParams,
AgenticFlowResult,
AgenticSession,
McpServerOverride,
MCPToolCall,
SettingsConfigType,
ToolExecutionResult
@@ -201,10 +200,10 @@ class AgenticStore {
return active;
}
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
getConfig(settings: SettingsConfigType): AgenticConfig {
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
const hasTools =
mcpStore.hasEnabledServers(perChatOverrides) ||
mcpStore.hasEnabledServers() ||
toolsStore.serverTools.length > 0 ||
toolsStore.browserTools.length > 0 ||
toolsStore.customTools.length > 0;
@@ -309,8 +308,8 @@ class AgenticStore {
flowRootMessageId,
messages,
options = {},
perChatOverrides,
signal
signal,
toolPolicy
} = params;
// Clear any pending permissions/continue requests for this conversation when starting a new flow
@@ -321,21 +320,28 @@ class AgenticStore {
await toolsStore.fetchServerTools();
}
const agenticConfig = this.getConfig(settingsStore.config, perChatOverrides);
const agenticConfig = this.getConfig(settingsStore.config);
if (!agenticConfig.enabled) return { handled: false };
const hasMcpServers = mcpStore.hasEnabledServers(perChatOverrides);
// callers without an explicit policy fall back to the global defaults
const disabledTools = new Set(toolPolicy?.disabledTools ?? toolsStore.disabledTools);
const disabledToolCategories = new Set(
toolPolicy?.disabledToolCategories ?? toolsStore.disabledToolCategories
);
// initialize every settings-enabled server; tool collection filters by this
// flow's policy, so switching policies never re-initializes connections
const hasMcpServers = conversationsStore.preferences.policyEnabledServerIds().length > 0;
if (hasMcpServers) {
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
const initialized = await mcpStore.ensureInitialized();
if (!initialized) {
console.log('[AgenticStore] MCP not initialized');
}
}
const tools = toolsStore.getEnabledToolsForLLM();
const tools = toolsStore.getEnabledToolsForLLM(disabledTools, disabledToolCategories);
if (tools.length === 0) {
return { handled: false };
+6 -3
View File
@@ -1132,7 +1132,10 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
await DatabaseService.updateMessage(messageId, updates);
}
};
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
const toolPolicy = {
disabledToolCategories: conversationsStore.preferences.getDisabledToolCategories(),
disabledTools: conversationsStore.preferences.getDisabledTools()
};
{
const agenticResult = await agenticStore.runAgenticFlow({
@@ -1144,8 +1147,8 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
...this.getApiOptions(),
...(effectiveModel ? { model: effectiveModel } : {})
},
perChatOverrides,
signal: abortController.signal
signal: abortController.signal,
toolPolicy
});
if (agenticResult.handled) {
@@ -251,12 +251,15 @@ class ConversationsStore implements ConversationsPreferencesHost {
*/
async createConversation(name?: string): Promise<string> {
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
// Working directory and reasoning effort picked on the new-chat screen
// get threaded into the new conversation here, then cleared so they
// don't bleed onto subsequent new chats.
// The tool policy is seeded from the current defaults: edits made inside
// the conversation afterwards live on its row and do not flow back into
// the defaults. Working directory picked on the new-chat screen gets
// threaded in here too, then cleared so it doesn't bleed onto subsequent
// new chats.
const conversation = await DatabaseService.createConversation(conversationName, {
cwd: this.preferences.pendingCwd ?? undefined,
reasoningEffort: this.preferences.pendingReasoningEffort
reasoningEffort: this.preferences.pendingReasoningEffort,
...this.preferences.getToolPolicySnapshot()
});
this.preferences.pendingCwd = null;
@@ -1,21 +1,23 @@
/**
* ConversationPreferences - Per-chat options with global fallback
*
* Owns the options that resolve per conversation: MCP server overrides,
* reasoning effort, and the working directory. Cwd and reasoning effort are
* buffered as pending state and threaded into the next created conversation
* by the host; MCP server overrides edit the sparse `mcpServerOverrides`
* list on the active row (new-chat toggles edit the server's global flag).
* Owns the options that resolve per conversation: the tool policy (disabled
* categories and tool keys), reasoning effort, and the working directory.
* Tool picks made on the empty new-chat screen edit the global defaults
* directly (they seed every newly created conversation); cwd and reasoning
* effort are buffered as pending state and threaded into the next created
* conversation by the host.
* Created and owned by conversationsStore; the host owns the conversation
* rows these options persist onto.
*/
import { REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY } from '$lib/constants';
import { ReasoningEffort } from '$lib/enums';
import { ReasoningEffort, ToolSource } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { mcpStore } from '$lib/stores/mcp/index.svelte';
import type { McpServerOverride } from '$lib/types/database';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { DatabaseConversation, ToolEntry, ToolGroup } from '$lib/types';
/** Load reasoning effort default from localStorage, DEFAULT defers to the server */
function loadReasoningEffortDefault(): ReasoningEffort {
@@ -48,6 +50,26 @@ export interface ConversationsPreferencesHost {
applyConversationUpdate(id: string, updates: Partial<DatabaseConversation>): void;
}
/**
* Effective disabled tool keys: the active conversation row, or the global
* defaults when there is no conversation. An existing row with an unset
* field has an empty policy, not a fallback to defaults.
*/
function buildDisabledTools(conv: DatabaseConversation | null): Set<string> {
return new Set(conv ? (conv.disabledTools ?? []) : [...toolsStore.disabledTools]);
}
/**
* Effective disabled tool categories: the active conversation row, or the
* global defaults when there is no conversation. An existing row with an
* unset field has an empty policy, not a fallback to defaults.
*/
function buildDisabledToolCategories(conv: DatabaseConversation | null): Set<ToolSource> {
return new Set(
conv ? (conv.disabledToolCategories ?? []) : [...toolsStore.disabledToolCategories]
);
}
export class ConversationPreferences {
/**
* Working directory picked on the empty new-chat screen, before any
@@ -61,36 +83,29 @@ export class ConversationPreferences {
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(loadReasoningEffortDefault());
constructor(private host: ConversationsPreferencesHost) {}
/**
* Gets the effective override list for the current conversation:
* one entry per configured server, resolved per server. The stored
* per-conversation list is sparse and only holds explicit toggles.
*/
getAllMcpServerOverrides(): McpServerOverride[] {
const overrides = this.host.activeConversation?.mcpServerOverrides;
return mcpStore.getServers().map((s) => {
const override = overrides?.find((o: McpServerOverride) => o.serverId === s.id);
return { enabled: override?.enabled ?? s.enabled, serverId: s.id };
});
private get _disabledToolCategories(): Set<ToolSource> {
return buildDisabledToolCategories(this.host.activeConversation);
}
/**
* Gets the effective MCP server override for a specific server.
* A per-conversation override wins when present; a server without one
* resolves to its `mcpServers[i].enabled` default.
*/
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
const override = this.host.activeConversation?.mcpServerOverrides?.find(
(o: McpServerOverride) => o.serverId === serverId
);
// Tool Policy
if (override) return override;
// getters, not $derived fields: lazy evaluation keeps them off the class
// field initialization order (host is assigned by the constructor), and
// reads of the underlying $state stay tracked in reactive contexts
private get _disabledTools(): Set<string> {
return buildDisabledTools(this.host.activeConversation);
}
return this.getDefaultOverride(serverId);
constructor(private host: ConversationsPreferencesHost) {}
/** Effective disabled tool categories for the current context, captured at flow start. */
getDisabledToolCategories(): ToolSource[] {
return [...this._disabledToolCategories];
}
/** Effective disabled tool keys for the current context, captured at flow start. */
getDisabledTools(): string[] {
return [...this._disabledTools];
}
/**
@@ -114,16 +129,71 @@ export class ConversationPreferences {
return this.pendingReasoningEffort;
}
/** Checks if an MCP server is enabled for the active conversation. */
isMcpServerEnabledForChat(serverId: string): boolean {
const override = this.getMcpServerOverride(serverId);
/** Defaults snapshot for seeding a newly created conversation. */
getToolPolicySnapshot(): { disabledTools?: string[]; disabledToolCategories?: ToolSource[] } {
const disabledTools = [...toolsStore.disabledTools];
const disabledToolCategories = [...toolsStore.disabledToolCategories];
return override?.enabled ?? false;
return {
disabledToolCategories: disabledToolCategories.length ? disabledToolCategories : undefined,
disabledTools: disabledTools.length ? disabledTools : undefined
};
}
/** Removes MCP server override for the active conversation. */
async removeMcpServerOverride(serverId: string): Promise<void> {
await this.setMcpServerOverride(serverId, undefined);
hasEnabledCwdTools(): boolean {
return toolsStore.hasEnabledCwdTools(this._disabledTools, this._disabledToolCategories);
}
isCategoryEnabled(source: ToolSource): boolean {
return !this._disabledToolCategories.has(source);
}
/** Group checkbox state: the category flag, or the server key for MCP groups. */
isGroupChecked(group: ToolGroup): boolean {
return group.source === ToolSource.MCP && group.serverId
? this.isServerToolsEnabled(group.serverId)
: this.isCategoryEnabled(group.source);
}
/** Server-scoped MCP group state: one key disables all of that server's tools. */
isServerToolsEnabled(serverId: string): boolean {
return this.isToolEnabled(toolsStore.getMcpServerToolsKey(serverId));
}
/** Effective state: own key, MCP server group key, and category all on. */
isToolActive(entry: ToolEntry): boolean {
return toolsStore.isEntryEnabled(entry, this._disabledTools, this._disabledToolCategories);
}
/** Own-level state: the tool key itself, ignoring category and server group. */
isToolEnabled(key: string): boolean {
return !this._disabledTools.has(key);
}
/** True when a parent level (category or MCP server group) disables this entry. */
isToolParentDisabled(entry: ToolEntry): boolean {
if (!this.isCategoryEnabled(entry.source)) return true;
return (
entry.source === ToolSource.MCP &&
!!entry.serverId &&
!this.isServerToolsEnabled(entry.serverId)
);
}
/**
* MCP servers usable under the effective policy: globally enabled, url set,
* MCP category on and the server-scoped key not disabled.
*/
policyEnabledServerIds(): string[] {
if (!this.isCategoryEnabled(ToolSource.MCP)) return [];
return mcpStore
.getServers()
.filter(
(server) => server.enabled && server.url.trim() && this.isServerToolsEnabled(server.id)
)
.map((server) => server.id);
}
/** Reload persisted defaults, e.g. when the active conversation is cleared. */
@@ -132,6 +202,8 @@ export class ConversationPreferences {
this.pendingCwd = null;
}
// Working Directory
/**
* Sets the working directory for the active conversation. Pass `null` or
* an empty string to clear it, which restores the picker's empty state.
@@ -165,56 +237,7 @@ export class ConversationPreferences {
this.pendingCwd = null;
}
/**
* Sets or removes MCP server override for the active conversation.
* If no conversation exists, persists `enabled` onto `mcpServers[i].enabled`
* (the single source of truth for new-chat defaults).
*/
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
if (!this.host.activeConversation) {
if (enabled !== undefined) {
mcpStore.updateServer(serverId, { enabled });
}
return;
}
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
const currentOverrides = (this.host.activeConversation.mcpServerOverrides || []).map(
(o: McpServerOverride) => ({
enabled: o.enabled,
serverId: o.serverId
})
);
let newOverrides: McpServerOverride[];
if (enabled === undefined) {
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
} else {
const existingIndex = currentOverrides.findIndex(
(o: McpServerOverride) => o.serverId === serverId
);
if (existingIndex >= 0) {
newOverrides = [...currentOverrides];
newOverrides[existingIndex] = { enabled, serverId };
} else {
newOverrides = [...currentOverrides, { enabled, serverId }];
}
}
const overrides = newOverrides.length > 0 ? newOverrides : undefined;
const id = this.host.activeConversation.id;
this.host.applyConversationUpdate(id, {
mcpServerOverrides: overrides
});
await DatabaseService.updateConversation(id, {
mcpServerOverrides: overrides
});
}
// Reasoning Effort
/**
* Sets the reasoning effort for the active conversation.
@@ -229,33 +252,82 @@ export class ConversationPreferences {
return;
}
const id = this.host.activeConversation.id;
this.host.applyConversationUpdate(id, {
this.host.applyConversationUpdate(this.host.activeConversation.id, {
reasoningEffort: effort
});
await DatabaseService.updateConversation(id, {
await DatabaseService.updateConversation(this.host.activeConversation.id, {
reasoningEffort: effort
});
}
/** Toggles MCP server enabled state for the active conversation. */
async toggleMcpServerForChat(serverId: string): Promise<void> {
const currentEnabled = this.isMcpServerEnabledForChat(serverId);
async toggleCategory(source: ToolSource): Promise<void> {
const conv: DatabaseConversation | null = this.host.activeConversation;
await this.setMcpServerOverride(serverId, !currentEnabled);
if (!conv) {
toolsStore.toggleCategory(source);
return;
}
const next = buildDisabledToolCategories(conv);
if (next.has(source)) next.delete(source);
else next.add(source);
await this.persistDisabledToolCategories(next);
}
/**
* Resolve the default enabled value for a server: its own `enabled`
* flag in `mcpServers`, so the global on/off state lives in one place.
*/
private getDefaultOverride(serverId: string): McpServerOverride | undefined {
const server = mcpStore.getServers().find((s) => s.id === serverId);
async toggleGroup(group: ToolGroup): Promise<void> {
if (group.source === ToolSource.MCP && group.serverId) {
await this.toggleServerTools(group.serverId);
} else {
await this.toggleCategory(group.source);
}
}
if (!server) return undefined;
async toggleServerTools(serverId: string): Promise<void> {
await this.toggleTool(toolsStore.getMcpServerToolsKey(serverId));
}
return { enabled: server.enabled, serverId };
async toggleTool(key: string): Promise<void> {
const conv: DatabaseConversation | null = this.host.activeConversation;
if (!conv) {
toolsStore.toggleTool(key);
return;
}
const next = buildDisabledTools(conv);
if (next.has(key)) next.delete(key);
else next.add(key);
await this.persistDisabledTools(next);
}
private async persistDisabledToolCategories(disabled: Set<ToolSource>): Promise<void> {
const conv = this.host.activeConversation;
if (!conv) return;
const disabledToolCategories = disabled.size ? [...disabled] : undefined;
this.host.applyConversationUpdate(conv.id, { disabledToolCategories });
await DatabaseService.updateConversation(conv.id, { disabledToolCategories });
}
private async persistDisabledTools(disabled: Set<string>): Promise<void> {
const conv = this.host.activeConversation;
if (!conv) return;
const disabledTools = disabled.size ? [...disabled] : undefined;
this.host.applyConversationUpdate(conv.id, { disabledTools });
await DatabaseService.updateConversation(conv.id, { disabledTools });
}
}
+20 -130
View File
@@ -37,7 +37,7 @@ import type {
Tool,
ToolExecutionResult
} from '$lib/types';
import type { DatabaseMessageExtraMcpResource, McpServerOverride } from '$lib/types/database';
import type { DatabaseMessageExtraMcpResource } from '$lib/types/database';
import type { SettingsConfigType } from '$lib/types/settings';
import {
detectMcpTransportFromUrl,
@@ -306,12 +306,16 @@ class MCPStore implements McpHealthHost {
return extras;
}
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
/**
* Initialize every settings-enabled server. Policy filtering happens at tool
* collection time, so switching conversation policies never re-initializes.
*/
async ensureInitialized(): Promise<boolean> {
if (!browser) {
return false;
}
const mcpConfig = this.buildMcpClientConfig(settingsStore.config, perChatOverrides);
const mcpConfig = this.buildMcpClientConfig(settingsStore.config);
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
if (!signature) {
@@ -512,14 +516,6 @@ class MCPStore implements McpHealthHost {
return this.connections;
}
getEnabledServersForConversation(
perChatOverrides?: McpServerOverride[]
): MCPServerSettingsEntry[] {
return this.getServers().filter((server) => {
return this.checkServerEnabled(server, perChatOverrides);
});
}
/**
* Check if a server already has an active connection that can be reused.
* Returns the existing connection if available.
@@ -811,106 +807,8 @@ class MCPStore implements McpHealthHost {
);
}
hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean {
return Boolean(this.buildMcpClientConfig(settingsStore.config, perChatOverrides));
}
/**
* Check if any enabled server with successful health check supports prompts.
* Uses health check state since servers may not have active connections until
* the user actually sends a message or uses prompts.
*/
hasPromptsCapability(perChatOverrides?: McpServerOverride[]): boolean {
let enabledServerIds: Set<string>;
if (perChatOverrides !== undefined) {
enabledServerIds = new Set(perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId));
} else {
enabledServerIds = new Set(
this.getServers()
.filter((s) => s.enabled)
.map((s) => s.id)
);
}
if (enabledServerIds.size === 0) {
return false;
}
for (const [serverId, state] of Object.entries(this.health.checks)) {
if (!enabledServerIds.has(serverId)) continue;
if (
state.status === HealthCheckStatus.SUCCESS &&
state.capabilities?.server?.prompts !== undefined
) {
return true;
}
}
for (const [serverName, connection] of this.connections) {
if (!enabledServerIds.has(serverName)) continue;
if (connection.serverCapabilities?.prompts) {
return true;
}
}
return false;
}
hasPromptsSupport(): boolean {
for (const connection of this.connections.values()) {
if (connection.serverCapabilities?.prompts) {
return true;
}
}
return false;
}
/**
* Check if any enabled server with successful health check supports resources.
* Uses health check state since servers may not have active connections until
* the user actually sends a message or uses prompts.
*/
hasResourcesCapability(perChatOverrides?: McpServerOverride[]): boolean {
let enabledServerIds: Set<string>;
if (perChatOverrides !== undefined) {
enabledServerIds = new Set(perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId));
} else {
enabledServerIds = new Set(
this.getServers()
.filter((s) => s.enabled)
.map((s) => s.id)
);
}
if (enabledServerIds.size === 0) {
return false;
}
for (const [serverId, state] of Object.entries(this.health.checks)) {
if (!enabledServerIds.has(serverId)) continue;
if (
state.status === HealthCheckStatus.SUCCESS &&
state.capabilities?.server?.resources !== undefined
) {
return true;
}
}
for (const [serverName, connection] of this.connections) {
if (!enabledServerIds.has(serverName)) continue;
if (MCPService.supportsResources(connection)) {
return true;
}
}
return false;
hasEnabledServers(): boolean {
return Boolean(this.buildMcpClientConfig(settingsStore.config));
}
/**
@@ -1185,10 +1083,7 @@ class MCPStore implements McpHealthHost {
/**
* Builds MCP client configuration from settings.
*/
private buildMcpClientConfig(
cfg: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
private buildMcpClientConfig(cfg: SettingsConfigType): MCPClientConfig | undefined {
const rawServers = parseMcpServerSettings(cfg.mcpServers);
if (!rawServers.length) {
@@ -1198,7 +1093,7 @@ class MCPStore implements McpHealthHost {
const servers: Record<string, MCPServerConfig> = {};
for (const [index, entry] of rawServers.entries()) {
if (!this.checkServerEnabled(entry, perChatOverrides)) continue;
if (!entry.enabled) continue;
const normalized = this.buildServerConfig(entry);
@@ -1252,20 +1147,6 @@ class MCPStore implements McpHealthHost {
};
}
/**
* Checks if a server is enabled for a given chat.
* A per-chat override wins when present; a server without one resolves
* to its own `enabled` flag in `mcpServers`.
*/
private checkServerEnabled(
server: MCPServerSettingsEntry,
perChatOverrides?: McpServerOverride[]
): boolean {
const override = perChatOverrides?.find((o) => o.serverId === server.id);
return override?.enabled ?? server.enabled;
}
private createListChangedHandlers(serverName: string): ListChangedHandlers {
return {
prompts: {
@@ -1378,6 +1259,15 @@ class MCPStore implements McpHealthHost {
return `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
}
/** Server ids that are usable right now: globally enabled ones. */
private globalEnabledServerIds(): Set<string> {
return new Set(
this.getServers()
.filter((s) => s.enabled)
.map((s) => s.id)
);
}
private handleToolsListChanged(serverName: string, tools: Tool[]): void {
const connection = this.connections.get(serverName);
+110 -42
View File
@@ -12,6 +12,7 @@ import {
buildBrowserInfoToolDefinition,
buildGetDatetimeToolDefinition,
buildReadMediaToolDefinition,
DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY,
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
HOME_TILDE,
TOOL_GROUP_LABELS,
@@ -37,6 +38,9 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
/** Stable selection identity for a tool, shared by the disabled set and the permission store */
class ToolsStore {
// default disabled tool categories, seeded into newly created conversations;
// the per-conversation policy lives on the conversation row
private _disabledToolCategories = $state(new SvelteSet<ToolSource>());
private _disabledTools = $state(new SvelteSet<string>());
private _error = $state<string | null>(null);
private _loading = $state(false);
@@ -150,6 +154,10 @@ class ToolsStore {
}
}
get disabledToolCategories(): ReadonlySet<ToolSource> {
return this._disabledToolCategories;
}
get disabledTools(): SvelteSet<string> {
return this._disabledTools;
}
@@ -158,26 +166,6 @@ class ToolsStore {
return this._error;
}
/**
* Check if a working directory is worth setting: at least one server tool
* that reads it is both served and left enabled by the user.
*/
get hasEnabledCwdTools(): boolean {
return this._serverTools.some((def) => {
const name = def.function.name;
return (
this.cwdAwareTools.has(name) &&
!this._disabledTools.has(this.toolKey(ToolSource.SERVER, name))
);
});
}
/** Check if there are any enabled tools available (server, MCP, or custom) */
get hasEnabledTools(): boolean {
return this.getEnabledToolsForLLM().length > 0;
}
get isToolsEndpointUnreachable(): boolean {
return this._toolsEndpointUnreachable;
}
@@ -233,9 +221,13 @@ class ToolsStore {
if (!connection) return;
// the server-scoped group key disables every tool regardless of per-tool keys
this._disabledTools.delete(this.getMcpServerToolsKey(serverId));
for (const tool of connection.tools) {
this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId));
}
this.persistDisabledTools();
}
@@ -272,16 +264,21 @@ class ToolsStore {
}
/**
* Enabled tool definitions for sending to the LLM.
* Enabled tool definitions for sending to the LLM. Callers pass an
* explicit policy (the active conversation's, resolved with global
* defaults when absent); without arguments the store defaults apply.
* MCP tool schemas are normalized here so the wire payload is consistent
* across all four sources (server, browser/sandbox, MCP, custom JSON).
* The API identifies tools by name, so a name is sent at most once.
*/
getEnabledToolsForLLM(): OpenAIToolDefinition[] {
getEnabledToolsForLLM(
disabledTools: ReadonlySet<string> = this._disabledTools,
disabledCategories: ReadonlySet<ToolSource> = this._disabledToolCategories
): OpenAIToolDefinition[] {
const enabledNames = new SvelteSet<string>();
for (const entry of this.allTools) {
if (!this._disabledTools.has(entry.key)) {
if (this.isEntryEnabled(entry, disabledTools, disabledCategories)) {
enabledNames.add(entry.definition.function.name);
}
}
@@ -306,6 +303,11 @@ class ToolsStore {
return result;
}
/** Server-scoped tool key: disabling it disables all of that server's tools. */
getMcpServerToolsKey(serverId: string): string {
return `mcp:${serverId}`;
}
/** Permission key for a tool name, identical to the selection key */
getPermissionKey(toolName: string): string | null {
return this.findEntryByName(toolName)?.key ?? null;
@@ -333,6 +335,26 @@ class ToolsStore {
return this.findEntryByName(toolName)?.source ?? null;
}
/**
* Check if a working directory is worth setting: at least one server tool
* that reads it is both served and left enabled by the given policy
* (defaults to the global defaults).
*/
hasEnabledCwdTools(
disabledTools: ReadonlySet<string> = this._disabledTools,
disabledCategories: ReadonlySet<ToolSource> = this._disabledToolCategories
): boolean {
if (disabledCategories.has(ToolSource.SERVER)) return false;
return this._serverTools.some((def) => {
const name = def.function.name;
return (
this.cwdAwareTools.has(name) && !disabledTools.has(this.toolKey(ToolSource.SERVER, name))
);
});
}
/**
* Load persisted disabled tools and fetch the builtin tool list.
* Called by initStores() after migrations have run.
@@ -357,11 +379,45 @@ class ToolsStore {
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
}
try {
const stored = localStorage.getItem(DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
for (const key of parsed) {
if (Object.values(ToolSource).includes(key)) {
this._disabledToolCategories.add(key as ToolSource);
}
}
}
}
} catch (err) {
console.error('[ToolsStore] Failed to load disabled tool categories from localStorage:', err);
}
this.fetchServerTools();
}
isGroupFullyEnabled(group: ToolGroup): boolean {
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.key));
isCategoryEnabled(source: ToolSource): boolean {
return !this._disabledToolCategories.has(source);
}
isEntryEnabled(
entry: ToolEntry,
disabledTools: ReadonlySet<string>,
disabledCategories: ReadonlySet<ToolSource>
): boolean {
if (disabledCategories.has(entry.source)) return false;
if (disabledTools.has(entry.key)) return false;
if (entry.source === ToolSource.MCP && entry.serverId) {
return !disabledTools.has(this.getMcpServerToolsKey(entry.serverId));
}
return true;
}
isToolEnabled(key: string): boolean {
@@ -394,33 +450,32 @@ class ToolsStore {
return this._serverHome;
}
setCategoryEnabled(source: ToolSource, enabled: boolean): void {
if (enabled) {
this._disabledToolCategories.delete(source);
} else {
this._disabledToolCategories.add(source);
}
this.persistDisabledToolCategories();
}
setToolEnabled(key: string, enabled: boolean): void {
if (enabled) {
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
this.persistDisabledTools();
}
toggleGroup(group: ToolGroup): void {
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key));
const target = !allEnabled;
for (const tool of group.tools) {
if (target) this._disabledTools.delete(tool.key);
else this._disabledTools.add(tool.key);
}
this.persistDisabledTools();
toggleCategory(source: ToolSource): void {
this.setCategoryEnabled(source, !this.isCategoryEnabled(source));
}
toggleTool(key: string): void {
if (this._disabledTools.has(key)) {
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
this.persistDisabledTools();
this.setToolEnabled(key, !this.isToolEnabled(key));
}
/** First canonical entry matching a tool name, runtime tool calls resolve by name */
@@ -602,6 +657,17 @@ class ToolsStore {
return normalized;
}
private persistDisabledToolCategories(): void {
try {
localStorage.setItem(
DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY,
JSON.stringify([...this._disabledToolCategories])
);
} catch {
// ignore storage errors
}
}
private persistDisabledTools(): void {
try {
localStorage.setItem(
@@ -637,7 +703,9 @@ class ToolsStore {
private toolKey(source: ToolSource, name: string, serverId?: string): string {
switch (source) {
case ToolSource.MCP:
return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
// with a serverId this is a per-tool key; without one it hits the
// server group key shape, which no MCP entry ever does
return serverId ? `mcp-${serverId}:${name}` : this.getMcpServerToolsKey(name);
case ToolSource.CUSTOM:
return `custom:${name}`;
case ToolSource.BROWSER:
+8 -2
View File
@@ -15,7 +15,7 @@ import type {
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraImageFile
} from './database';
import type { MessageRole } from '$lib/enums';
import type { MessageRole, ToolSource } from '$lib/enums';
import { AgenticSectionType, ContinueIntentKind, ToolCallType } from '$lib/enums';
/**
@@ -162,6 +162,12 @@ export interface AgenticFlowOptions {
/**
* Parameters for starting an agentic flow
*/
/** Per-conversation tool policy, captured at flow start */
export interface AgenticToolPolicy {
disabledToolCategories: ToolSource[];
disabledTools: string[];
}
export interface AgenticFlowParams {
conversationId: string;
/** ID of the flow's first assistant message, used to keep its stats live */
@@ -170,7 +176,7 @@ export interface AgenticFlowParams {
options?: AgenticFlowOptions;
callbacks: AgenticFlowCallbacks;
signal?: AbortSignal;
perChatOverrides?: McpServerOverride[];
toolPolicy?: AgenticToolPolicy;
}
/**
-7
View File
@@ -3,7 +3,6 @@ import type { DatabaseMessage, DatabaseMessageExtra } from './database';
import type {
AttachmentAction,
AttachmentItemEnabledWhen,
AttachmentItemVisibleWhen,
AttachmentMenuItemId,
ChatFormCommandAction,
ErrorDialogType,
@@ -30,8 +29,6 @@ export interface AttachmentMenuItem {
disabledTooltip?: string;
/** Callback key on the Props interface to invoke when clicked */
action: AttachmentAction;
/** Whether the item is only shown when a specific capability is present */
visibleWhen?: AttachmentItemVisibleWhen;
/** Whether this item has a tooltip even when enabled (uses dynamic text) */
hasEnabledTooltip?: boolean;
}
@@ -336,11 +333,7 @@ export interface ChatFormActionsContext {
readonly hasAudioModality: boolean;
readonly hasVideoModality: boolean;
readonly hasVisionModality: boolean;
readonly hasMcpPromptsSupport: boolean;
readonly hasMcpResourcesSupport: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void;
}
+11 -1
View File
@@ -1,6 +1,11 @@
import { AttachmentType, ReasoningEffort } from '$lib/enums';
import { AttachmentType, ReasoningEffort, ToolSource } from '$lib/enums';
import type { ChatMessageTimings, ChatMessageType, ChatRole } from '$lib/types/chat';
/**
* @deprecated Legacy per-conversation MCP server flags. MCP server enabled
* state is global now; per-conversation tool policy lives in
* `disabledTools` / `disabledToolCategories`. Read by the migration only.
*/
export interface McpServerOverride {
serverId: string;
enabled: boolean;
@@ -11,10 +16,15 @@ export interface DatabaseConversation {
id: string;
lastModified: number;
name: string;
/** @deprecated See {@link McpServerOverride}. Kept on rows for downgrade compatibility. */
mcpServerOverrides?: McpServerOverride[];
thinkingEnabled?: boolean;
reasoningEffort?: ReasoningEffort;
cwd?: string;
/** Tool keys disabled for this conversation, incl. server-scoped MCP group keys (`mcp:<serverId>`) */
disabledTools?: string[];
/** Tool categories disabled for this conversation */
disabledToolCategories?: ToolSource[];
forkedFromConversationId?: string;
pinned?: boolean;
}
+1
View File
@@ -89,6 +89,7 @@ export type {
// Model types
export type {
ModelCapabilities,
ModelModalities,
ModelOption,
ModelLoadProgress,
+4
View File
@@ -6,6 +6,10 @@ export interface ModelModalities {
video: boolean;
}
export interface ModelCapabilities {
reasoning: boolean;
}
export interface ModelOption {
id: string;
name: string;