mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-01 01:27:50 +02:00
ui: Replace per-conversation MCP overrides with per-conversation tool policy (#27745)
* ui: replace per-conversation MCP overrides with per-conversation tool policy MCP server enabled state is now global (server.enabled); per-conversation control moves to disabled tool keys and categories seeded into each new conversation. Aligns the add sheet with the dropdown options and flattens MCP tool groups in the tools submenu. Assisted-by: pi * ui: keep tool policy migration running when defaults parse fails A corrupt disabledToolKeys localStorage entry no longer aborts the migration; it falls through with empty defaults so legacy MCP server overrides still get converted. Assisted-by: pi * ui: fall back to global defaults when agentic flow has no tool policy Passing empty disabled sets bypassed the global defaults and could enable tools for callers that do not pass a policy yet. Assisted-by: pi * ui: align preferences section headers with their methods The Reasoning Effort and Working Directory headers sat above tool policy methods; move them above setCwd and setReasoningEffort. Also clarify the disabled tools JSDoc: existing rows with an unset field have an empty policy, defaults apply only when there is no active conversation. Assisted-by: pi * ui: gate MCP server avatars on conversation tool policy Servers whose tools are disabled for the current conversation (MCP category or server-scoped key) no longer show as enabled for the chat. Assisted-by: pi * ui: drop unused MCP category toggle from tools panel hook Per-conversation MCP control is server-granular; no component renders a whole-category toggle, so remove the dead API. Assisted-by: pi * ui: skip MCP init when flow policy disables the MCP category Resolve the effective tool policy before deciding whether to initialize MCP so flows that will not send any MCP tools skip the init work. Callers without a policy keep falling back to global defaults. Assisted-by: pi * chore: format * ui: restore reasoning section in mobile add sheet The sheet rewrite dropped it; the desktop dropdown still has it. MCP Prompts and Resources stay out of the sheet on purpose. Assisted-by: pi * ui: clear MCP server group key in enableAllToolsForServer The group key disables every tool of the server regardless of per-tool keys, so re-enabling a server from Settings did nothing while it was set. Assisted-by: pi * ui: skip MCP init when no policy-enabled server remains Extends the category-level check: the flow also skips MCP init when every globally-enabled server has its server-scoped group key disabled in the tool policy. Assisted-by: pi * ui: make Settings tools tab edit defaults with category toggles Adds per-category checkboxes and a caption stating the tab applies to new conversations; tool picks inside a chat only affect that chat. Assisted-by: pi * ui: gate cwd picker and mention picker on effective tool policy Both checked the global disabled set directly, so a conversation that disabled file_search still showed search as available. Assisted-by: pi * ui: clean up tool key helpers and store docs Documents getEnabledToolsForLLM properly, unstacks the JSDoc at isEntryEnabled, makes setToolEnabled persist like setCategoryEnabled (toggleTool now delegates to it), and routes the serverId-less MCP branch of toolKey through getMcpServerToolsKey so both key formats come from one place. Preferences banner comments become plain comments so they no longer read as class member docs. Assisted-by: pi * ui: indeterminate group checkboxes and inert grayed rows A category that is on with nothing enabled under it now shows the mixed checkbox state instead of a checked box next to 0/N. Rows grayed out by a disabled parent no longer stay clickable behind opacity. Assisted-by: pi * ui: gate MCP prompt and resource capabilities on tool policy hasPromptsCapability and hasResourcesCapability accept an optional set of usable server ids; ChatFormActions resolves it from global enablement minus the active conversation's policy. Restores the per-chat gating the old mcpServerOverrides provided; callers without arguments keep global behavior. Assisted-by: pi * ui: remove unmounted MCP submenu component Never rendered anywhere; its entries are duplicates (prompts and resources live in the attachment menu, servers in the add menu and sheet) that would need capability wiring maintained for nothing. Assisted-by: pi * ui: fix model information dialog width on all screen sizes The dialog sets container-type: inline-size, so auto width ignores its contents and collapses to padding. Give it an explicit viewport width on mobile and cap at 60rem on desktop. Assisted-by: pi * ui: scroll wide chat template in model information dialog Long unbreakable Jinja tokens blew out the table and dialog width; the block now scrolls horizontally instead of stretching. Assisted-by: pi * ui: use fixed table layout in model information dialog Auto table layout sizes columns to content min-content, so the chat template's long lines kept inflating the dialog despite the scroll wrapper. Fixed layout pins the first column and gives the value column a definite width the wrapper can scroll within. min-w-0 on the grid item guards the same path on the grid side. Assisted-by: pi * ui: make model information dialog full-screen on mobile Matches the settings dialog pattern: full viewport below md, calc-sized and capped at 60rem on desktop. Assisted-by: pi * ui: stack chat template row in model information dialog Label above the block in a single full-width cell, so the template gets the whole table width and its horizontal scroll is usable on narrow screens. Assisted-by: pi * ui: scroll model information header with the content The base dialog header is sticky; this dialog overrides it to relative so the title and description scroll away with the body. relative keeps the header as the close button's containing block. Assisted-by: pi * ui: replace literal comment text in sheet group snippet A // line inside the Svelte snippet rendered as visible text; use an HTML comment. Assisted-by: pi * ui: let indeterminate state win over checked in group checkboxes The checkbox indicator snippet renders the check icon whenever checked, so the mixed state never showed. Pass the checked prop as false while indeterminate. Assisted-by: pi * ui: initialize only policy-enabled MCP servers for a flow ensureInitialized accepts an optional server id set; the agentic flow passes the servers its tool policy leaves usable, so servers disabled for the conversation no longer get connected. Callers without arguments keep the global behavior. Assisted-by: pi * ui: derive group checkbox state in useToolsPanel Moves the mixed-state derivation out of the submenu and sheet snippets into one getGroupCheckState accessor; the snippets just consume checked and indeterminate. Assisted-by: pi * ui: gate /prompt command on the conversation tool policy The slash command's availability now follows the same rule as the agentic flow instead of the global capability check, so it disables itself when the conversation's policy leaves no usable MCP server. Assisted-by: pi * ui: remove dead MCP prompt menu trigger chain The /prompt slash command is the surviving trigger; the menu-button path (onMcpPromptClick, hasMcpPromptsSupport, showMcpPromptButton, the MCP_PROMPT attachment item and its unrendered item arrays) has no consumer left. Message display for inserted prompts is untouched. Assisted-by: pi * ui: render dash for mixed-state group checkboxes The accessor refactor dropped the checked-and-not-indeterminate guard, so the category-on flag won and the dash never showed. The tooltip keeps using the raw parent flag since clicking a mixed group still disables it. Assisted-by: pi * ui: fix group checkbox sticking checked after disable Clicking a mixed-state group box let bits-ui optimistically flip its internal checked flag; the derived checked prop did not change across the transition (both mixed and off map to checked=false), so Svelte never applied the settled value and the check icon stuck while the count already read 0/7. Pass the parent flag as checked and the mix as indeterminate, so every group toggle changes checked; render the dash on top of a checked box for the mixed state. Assisted-by: pi * fix: UI for Model Information dialog * ui: keep MCP connections stable across policy switches ensureInitialized folds the policy into its config signature, so alternating two conversations with different policies tore down and reconnected every server with health checks included. Tool collection already filters by the flow policy, so initialize every settings-enabled server instead and never pass a policy into the MCP config. The duplicated policy-server check becomes one accessor on ConversationPreferences. Assisted-by: pi * ui: remove dead MCP resources menu trigger chain Same shape as the earlier prompt trigger cleanup: nothing renders the MCP resources menu button, and the only live entry into resource browsing is Settings > MCP Servers plus the attachment resource picker. Drop onMcpResourcesClick, hasMcpResourcesSupport, MCP_RESOURCES_CLICK, the AttachmentItemVisibleWhen enum and hasResourcesCapability; the resources display, browser and picker components are untouched. Assisted-by: pi
This commit is contained in:
@@ -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