mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-07 16:37:57 +02:00
Compare commits
34
Commits
@@ -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}
|
||||
|
||||
-4
@@ -30,15 +30,11 @@
|
||||
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
|
||||
}),
|
||||
() => {
|
||||
|
||||
-51
@@ -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>
|
||||
+61
-145
@@ -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}
|
||||
|
||||
+100
-86
@@ -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}
|
||||
|
||||
+1
-29
@@ -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;
|
||||
},
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
+2
-3
@@ -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 = [];
|
||||
|
||||
+12
-3
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@ export {
|
||||
AttachmentType,
|
||||
AttachmentMenuItemId,
|
||||
AttachmentItemEnabledWhen,
|
||||
AttachmentAction,
|
||||
AttachmentItemVisibleWhen
|
||||
AttachmentAction
|
||||
} from './attachment.enums';
|
||||
|
||||
export {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -56,11 +59,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 {
|
||||
@@ -70,13 +89,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);
|
||||
@@ -87,11 +118,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 {
|
||||
@@ -103,23 +134,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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+8
-2
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
-7
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+11
-1
@@ -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,151 +0,0 @@
|
||||
import { CONFIG_LOCALSTORAGE_KEY, SETTINGS_KEYS } from '$lib/constants';
|
||||
import type { DatabaseConversation } from '$lib/types/database';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
// node env unit project has no DOM, install a minimal localStorage backed by a Map
|
||||
beforeAll(() => {
|
||||
const store = new Map<string, string>();
|
||||
const polyfill: Storage = {
|
||||
clear: () => store.clear(),
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
removeItem: (k) => {
|
||||
store.delete(k);
|
||||
},
|
||||
setItem: (k, v) => {
|
||||
store.set(k, String(v));
|
||||
}
|
||||
};
|
||||
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression coverage for the bug where MCP servers flipped to "disabled"
|
||||
* after sending the first message on a fresh chat (see comment in
|
||||
* `MCPStore.createConversation`: empty `mcpServerOverrides` should inherit
|
||||
* `mcpServers[i].enabled`, not be treated as all-off).
|
||||
*/
|
||||
describe('conversationsStore MCP override resolution', () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
// Two configured servers: alpha is globally disabled, bravo enabled.
|
||||
localStorage.setItem(
|
||||
CONFIG_LOCALSTORAGE_KEY,
|
||||
JSON.stringify({
|
||||
[SETTINGS_KEYS.MCP_SERVERS]: JSON.stringify([
|
||||
{ enabled: false, id: 'alpha', url: 'https://alpha.example.com/mcp' },
|
||||
{ enabled: true, id: 'bravo', url: 'https://bravo.example.com/mcp' }
|
||||
])
|
||||
})
|
||||
);
|
||||
|
||||
// The settings store constructor bails in node env (no `browser`),
|
||||
// so seed the config directly. The shape mirrors what `loadConfig`
|
||||
// would build from localStorage.
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
const raw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}';
|
||||
const saved = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
settingsStore.config = {
|
||||
...settingsStore.config,
|
||||
[SETTINGS_KEYS.MCP_SERVERS]: saved[SETTINGS_KEYS.MCP_SERVERS]
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function makeConversation(
|
||||
overrides?: { serverId: string; enabled: boolean }[]
|
||||
): DatabaseConversation {
|
||||
return {
|
||||
currNode: null,
|
||||
id: 'conv-1',
|
||||
lastModified: 0,
|
||||
mcpServerOverrides: overrides,
|
||||
name: 'Test chat'
|
||||
};
|
||||
}
|
||||
|
||||
it('inherits server.enabled when no conversation is active', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
|
||||
|
||||
conversationsStore.activeConversation = null;
|
||||
|
||||
expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false);
|
||||
expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(true);
|
||||
});
|
||||
|
||||
it('inherits server.enabled on a newly created chat with no overrides', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
|
||||
|
||||
conversationsStore.activeConversation = makeConversation();
|
||||
|
||||
// Empty override list: must fall back to global server.enabled, not all-off.
|
||||
expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false);
|
||||
expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(true);
|
||||
});
|
||||
|
||||
it('inherits server.enabled on a newly created chat when overrides is undefined', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
|
||||
|
||||
conversationsStore.activeConversation = makeConversation(undefined);
|
||||
|
||||
expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false);
|
||||
expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses explicit per-chat overrides, with defaults for non-overridden servers', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
|
||||
|
||||
// Override flips bravo off for this chat, alpha keeps its global default.
|
||||
conversationsStore.activeConversation = makeConversation([
|
||||
{ enabled: false, serverId: 'bravo' }
|
||||
]);
|
||||
|
||||
expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false);
|
||||
expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(false);
|
||||
});
|
||||
|
||||
it('getAllMcpServerOverrides returns a complete list merged from defaults', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
|
||||
|
||||
conversationsStore.activeConversation = makeConversation([
|
||||
{ enabled: true, serverId: 'alpha' }
|
||||
]);
|
||||
|
||||
expect(conversationsStore.preferences.getAllMcpServerOverrides()).toEqual([
|
||||
{ enabled: true, serverId: 'alpha' },
|
||||
{ enabled: true, serverId: 'bravo' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('getAllMcpServerOverrides falls back to defaults when there are no explicit overrides', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
|
||||
|
||||
conversationsStore.activeConversation = makeConversation();
|
||||
|
||||
expect(conversationsStore.preferences.getAllMcpServerOverrides()).toEqual([
|
||||
{ enabled: false, serverId: 'alpha' },
|
||||
{ enabled: true, serverId: 'bravo' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('getMcpServerOverride returns the global default when the server has no explicit override', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
|
||||
|
||||
conversationsStore.activeConversation = makeConversation([
|
||||
{ enabled: true, serverId: 'alpha' }
|
||||
]);
|
||||
|
||||
expect(conversationsStore.preferences.getMcpServerOverride('bravo')).toEqual({
|
||||
enabled: true,
|
||||
serverId: 'bravo'
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user