mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-07 16:37:57 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d2d1c2437 | ||
|
|
0b24f4a36c | ||
|
|
515b993632 | ||
|
|
c390ec891b | ||
|
|
3d7ca1a82a | ||
|
|
8573111704 | ||
|
|
29776f6a48 | ||
|
|
d72e57944a | ||
|
|
dcbf990808 | ||
|
|
553f3bc944 | ||
|
|
45b0c1756a | ||
|
|
a8aef5d05e | ||
|
|
c2ef793ea3 | ||
|
|
b9c3bb6ec5 | ||
|
|
20ec1c9539 | ||
|
|
c68470019d | ||
|
|
dfd6ed8564 | ||
|
|
7af64f3a29 | ||
|
|
8961962316 | ||
|
|
2fab2f41b2 | ||
|
|
4d6874bbda | ||
|
|
3d0da6f4eb | ||
|
|
2190337d6c | ||
|
|
7c8ece9a3a |
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Eye, Mic, Video } from '@lucide/svelte';
|
||||
import { Image, Mic, Video } from '@lucide/svelte';
|
||||
import { ModelModality } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
@@ -19,7 +19,7 @@
|
||||
]}
|
||||
>
|
||||
{#if modality === ModelModality.VISION}
|
||||
<Eye class="h-3 w-3" />
|
||||
<Image class="h-3 w-3" />
|
||||
|
||||
Vision (Image)
|
||||
{:else if modality === ModelModality.VIDEO}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
ChatFormInputFileInputInvisible,
|
||||
ChatFormMcpResourcesList,
|
||||
ChatFormPickers,
|
||||
DialogMcpResourcesBrowser
|
||||
DialogMcpResourcesBrowser,
|
||||
DialogMcpServers
|
||||
} from '$lib/components/app';
|
||||
import {
|
||||
CLIPBOARD_CONTENT_QUOTE_PREFIX,
|
||||
@@ -151,9 +152,8 @@
|
||||
getServerHome: () => toolsStore.serverHome ?? null,
|
||||
getShowModelSelector: () => showModelSelector,
|
||||
getValue: () => value,
|
||||
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
|
||||
hasPrompts: () =>
|
||||
mcpStore.hasPromptsCapability(conversationsStore.preferences.getAllMcpServerOverrides()),
|
||||
hasCwdTools: () => conversationsStore.preferences.hasEnabledCwdTools(),
|
||||
hasPrompts: () => mcpStore.hasPromptsCapability(),
|
||||
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
|
||||
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
|
||||
setValue: (v) => {
|
||||
@@ -183,6 +183,9 @@
|
||||
let isResourceDialogOpen = $state(false);
|
||||
let preSelectedResourceUri = $state<string | undefined>(undefined);
|
||||
|
||||
// MCP Servers Dialog State
|
||||
let isMcpServersDialogOpen = $state(false);
|
||||
|
||||
let currentConfig = $derived(settingsStore.config);
|
||||
|
||||
let pasteLongTextToFileLength = $derived.by(() => {
|
||||
@@ -618,6 +621,7 @@
|
||||
onFileUpload={handleFileUpload}
|
||||
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
|
||||
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
|
||||
onMcpSettingsClick={() => (isMcpServersDialogOpen = true)}
|
||||
onMicClick={handleMicClick}
|
||||
{onStop}
|
||||
onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
|
||||
@@ -630,7 +634,7 @@
|
||||
|
||||
<ContextGaugePopup />
|
||||
|
||||
{#if toolsStore.hasEnabledCwdTools}
|
||||
{#if conversationsStore.preferences.hasEnabledCwdTools()}
|
||||
<ChatFormCurrentWorkingDirectory
|
||||
bind:query={pickers.workingDirectoryQuery}
|
||||
customAnchor={mentionAnchor}
|
||||
@@ -656,3 +660,5 @@
|
||||
}}
|
||||
preSelectedUri={preSelectedResourceUri}
|
||||
/>
|
||||
|
||||
<DialogMcpServers bind:open={isMcpServersDialogOpen} />
|
||||
|
||||
+45
-81
@@ -1,10 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { File, FolderOpen, MessageSquare, Plus, Zap } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddMcpServersSubmenu,
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
ChatFormActionAddToolsSubmenu
|
||||
} from '$lib/components/app';
|
||||
import { File, Image, MessageSquare, Mic, Plus, Video } from '@lucide/svelte';
|
||||
import { ChatFormActionAddToolsSubmenu, McpLogo } from '$lib/components/app';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
@@ -12,10 +8,10 @@
|
||||
import {
|
||||
ATTACHMENT_FILE_ITEMS,
|
||||
ATTACHMENT_TOOLTIP_TEXT,
|
||||
ICON_CLASS_DEFAULT,
|
||||
TOOLTIP_DELAY_DURATION
|
||||
ICON_CLASS_DEFAULT
|
||||
} from '$lib/constants';
|
||||
import { getChatFormActionsContext } from '$lib/contexts';
|
||||
import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums';
|
||||
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -31,11 +27,6 @@
|
||||
// must not restore focus to the trigger on close
|
||||
let suppressCloseAutoFocus = false;
|
||||
|
||||
function handleMcpSettingsClick() {
|
||||
dropdownOpen = false;
|
||||
chatFormActions.onMcpSettingsClick?.();
|
||||
}
|
||||
|
||||
const attachmentMenu = useAttachmentMenu(
|
||||
() => ({
|
||||
hasAudioModality: chatFormActions.hasAudioModality,
|
||||
@@ -54,6 +45,18 @@
|
||||
dropdownOpen = false;
|
||||
}
|
||||
);
|
||||
|
||||
const FILE_MODALITY_ICONS: Record<string, { icon: typeof Image; label: string }> = {
|
||||
[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: { icon: Mic, label: 'Audio' },
|
||||
[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: { icon: Video, label: 'Video' },
|
||||
[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: { icon: Image, label: 'Vision' }
|
||||
};
|
||||
|
||||
const supportedModalities = $derived.by(() =>
|
||||
ATTACHMENT_FILE_ITEMS.filter((item) => attachmentMenu.isItemEnabled(item.enabledWhen))
|
||||
.map((item) => FILE_MODALITY_ICONS[item.enabledWhen ?? ''])
|
||||
.filter((modality) => modality !== undefined)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
@@ -93,54 +96,32 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<File class={ICON_CLASS_DEFAULT} />
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={() => attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()}
|
||||
>
|
||||
<File class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span>Add files</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-48">
|
||||
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
|
||||
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
|
||||
{#if enabled}
|
||||
<DropdownMenu.Item
|
||||
class="{item.class ?? ''} flex cursor-pointer items-center gap-2"
|
||||
onclick={() => attachmentMenu.callbacks[item.action]()}
|
||||
>
|
||||
<item.icon class={ICON_CLASS_DEFAULT} />
|
||||
{#if supportedModalities.length > 0}
|
||||
<span class="flex items-center gap-0.75 text-muted-foreground">
|
||||
{#each supportedModalities as modality (modality.label)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<modality.icon class="size-2.75" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else if item.disabledTooltip}
|
||||
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
|
||||
<Tooltip.Trigger tabindex={-1}>
|
||||
{#snippet child({ props })}
|
||||
<div {...props} class="cursor-default">
|
||||
<DropdownMenu.Item
|
||||
class="{item.class ?? ''} flex items-center gap-2"
|
||||
disabled
|
||||
>
|
||||
<item.icon class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>{item.label}</span>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="right">
|
||||
<p>{item.disabledTooltip}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
<Tooltip.Content>
|
||||
<p>{modality.label}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
@@ -156,31 +137,14 @@
|
||||
|
||||
<ChatFormActionAddToolsSubmenu />
|
||||
|
||||
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpSettingsClick}
|
||||
>
|
||||
<McpLogo class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
{#if chatFormActions.hasMcpPromptsSupport}
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={chatFormActions.onMcpPromptClick}
|
||||
>
|
||||
<Zap class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>MCP Prompt</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>MCP Resources</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<span>MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Plus, Settings } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { DropdownMenuSearchable, McpLogo, McpServerIdentity } from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { conversationsStore, mcpStore } from '$lib/stores';
|
||||
import type { MCPServerSettingsEntry } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
onMcpSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
let { onMcpSettingsClick }: Props = $props();
|
||||
|
||||
let mcpSearchQuery = $state('');
|
||||
// Every configured server is listed; `enabled` is an on/off state,
|
||||
// not a visibility filter, so a disabled server stays toggleable.
|
||||
let mcpServers = $derived(mcpStore.getServers());
|
||||
let hasMcpServers = $derived(mcpServers.length > 0);
|
||||
let filteredMcpServers = $derived.by(() => {
|
||||
const query = mcpSearchQuery.toLowerCase().trim();
|
||||
|
||||
if (!query) return mcpServers;
|
||||
|
||||
return mcpServers.filter((s) => {
|
||||
const name = getServerLabel(s).toLowerCase();
|
||||
const url = s.url.toLowerCase();
|
||||
|
||||
return name.includes(query) || url.includes(query);
|
||||
});
|
||||
});
|
||||
|
||||
function getServerLabel(server: MCPServerSettingsEntry): string {
|
||||
return mcpStore.getServerLabel(server);
|
||||
}
|
||||
|
||||
function isServerEnabledForChat(serverId: string): boolean {
|
||||
return conversationsStore.preferences.isMcpServerEnabledForChat(serverId);
|
||||
}
|
||||
|
||||
async function toggleServerForChat(serverId: string) {
|
||||
await conversationsStore.preferences.toggleMcpServerForChat(serverId);
|
||||
}
|
||||
|
||||
function handleMcpSubMenuOpen(open: boolean) {
|
||||
if (open) {
|
||||
mcpSearchQuery = '';
|
||||
mcpStore.runHealthChecksForServers(mcpServers);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMcpSettingsClick() {
|
||||
onMcpSettingsClick?.();
|
||||
|
||||
goto(`${hasMcpServers ? '' : '?add'}${ROUTES.MCP_SERVERS}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<McpLogo class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>MCP Servers</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-72 pt-0">
|
||||
{#if hasMcpServers}
|
||||
<DropdownMenuSearchable
|
||||
bind:searchValue={mcpSearchQuery}
|
||||
emptyMessage="No servers found"
|
||||
isEmpty={filteredMcpServers.length === 0}
|
||||
placeholder="Search servers..."
|
||||
>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#each filteredMcpServers as server (server.id)}
|
||||
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
||||
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
|
||||
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
|
||||
{@const displayName = getServerLabel(server)}
|
||||
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
|
||||
|
||||
<button
|
||||
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={hasError}
|
||||
onclick={() => !hasError && toggleServerForChat(server.id)}
|
||||
type="button"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<McpServerIdentity
|
||||
{displayName}
|
||||
{faviconUrl}
|
||||
iconClass={ICON_CLASS_DEFAULT}
|
||||
iconRounded="rounded-sm"
|
||||
nameClass="text-sm"
|
||||
showVersion={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if hasError}
|
||||
<span
|
||||
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
|
||||
>
|
||||
Error
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={isEnabledForChat}
|
||||
disabled={hasError}
|
||||
onCheckedChange={() => toggleServerForChat(server.id)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpSettingsClick}
|
||||
>
|
||||
<Settings class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>Manage MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
{/snippet}
|
||||
</DropdownMenuSearchable>
|
||||
{:else}
|
||||
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
|
||||
No MCP servers configured
|
||||
</div>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
onclick={handleMcpSettingsClick}
|
||||
>
|
||||
<Plus class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span>Add MCP Servers</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
</DropdownMenu.Root>
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<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>
|
||||
+59
-61
@@ -8,70 +8,68 @@
|
||||
const reasoning = useReasoningMenu();
|
||||
</script>
|
||||
|
||||
{#if reasoning.modelSupportsThinking}
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
{#if reasoning.thinkingEnabled}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="text-sm inline-flex gap-2 {!reasoning.thinkingEnabled
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
>
|
||||
Reasoning
|
||||
|
||||
<span class="capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent
|
||||
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
|
||||
<span
|
||||
class="text-sm inline-flex gap-2 {!reasoning.isReasoningActive
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
>
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
|
||||
level
|
||||
)
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
onclick={() => reasoning.select(level)}
|
||||
>
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
Reasoning
|
||||
|
||||
<span class="flex-1">{level.label}</span>
|
||||
<span class="capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="text-[11px] text-muted-foreground opacity-60">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
<DropdownMenu.SubContent
|
||||
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
|
||||
>
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
|
||||
level
|
||||
)
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
onclick={() => reasoning.select(level)}
|
||||
>
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
{#if level.hasInfo}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<span class="flex-1">{level.label}</span>
|
||||
|
||||
<Tooltip.Content side="left">
|
||||
<p>Maximum reasoning effort with extended context usage</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
{/if}
|
||||
{#if tokenLabel}
|
||||
<span class="text-[11px] text-muted-foreground opacity-60">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if level.hasInfo}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="left">
|
||||
<p>Maximum reasoning effort with extended context usage</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
+59
-210
@@ -1,18 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { File, FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Lightbulb,
|
||||
LightbulbOff,
|
||||
PencilRuler
|
||||
} from '@lucide/svelte';
|
||||
import { File, MessageSquare } from '@lucide/svelte';
|
||||
import { ChevronDown, ChevronRight, 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 +12,10 @@
|
||||
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,10 +28,8 @@
|
||||
const chatFormActions = getChatFormActionsContext();
|
||||
|
||||
let sheetOpen = $state(false);
|
||||
let reasoningExpanded = $state(false);
|
||||
let filesExpanded = $state(true);
|
||||
let toolsExpanded = $state(false);
|
||||
let mcpExpanded = $state(false);
|
||||
|
||||
const attachmentMenu = useAttachmentMenu(
|
||||
() => ({
|
||||
@@ -63,15 +51,12 @@
|
||||
);
|
||||
|
||||
const toolsPanel = useToolsPanel();
|
||||
const reasoning = useReasoningMenu();
|
||||
|
||||
const sheetItemClass =
|
||||
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
const sheetItemRowClass =
|
||||
'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
|
||||
|
||||
let mcpServers = $derived(mcpStore.getServers());
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
@@ -88,65 +73,6 @@
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex flex-col gap-1 px-1.5 pb-2">
|
||||
{#if reasoning.modelSupportsThinking}
|
||||
<Collapsible.Root
|
||||
onOpenChange={(open) => (reasoningExpanded = open)}
|
||||
open={reasoningExpanded}
|
||||
>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if reasoningExpanded}
|
||||
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if reasoning.thinkingEnabled}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span class="flex-1">Reasoning</span>
|
||||
|
||||
<span class="text-xs capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="flex flex-col gap-0.5 pl-4">
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<button
|
||||
class:bg-accent={reasoning.isSelected(level)}
|
||||
class={sheetItemRowClass}
|
||||
onclick={() => reasoning.select(level)}
|
||||
type="button"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
<span class="text-sm">{level.label}</span>
|
||||
</div>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground opacity-60">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
|
||||
<Collapsible.Root onOpenChange={(open) => (filesExpanded = open)} open={filesExpanded}>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if filesExpanded}
|
||||
@@ -194,80 +120,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 +150,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 +164,54 @@
|
||||
|
||||
<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 checked = toolsPanel.isGroupChecked(group)}
|
||||
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
|
||||
{@const favicon = toolsPanel.getFavicon(group)}
|
||||
{@const groupDisabled = toolsPanel.isGroupDisabled(group)}
|
||||
|
||||
<button
|
||||
class="{sheetItemRowClass} {groupDisabled ? '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}
|
||||
class="{ICON_CLASS_DEFAULT} shrink-0"
|
||||
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
+99
-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,107 @@
|
||||
{/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 checked = toolsPanel.isGroupChecked(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 ? '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}
|
||||
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 = 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}
|
||||
|
||||
+6
-13
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { SkipForward, Square } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
ChatFormActionModels,
|
||||
@@ -10,7 +9,7 @@
|
||||
ChatFormContextGauge
|
||||
} from '$lib/components/app';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { setChatFormActionsContext } from '$lib/contexts';
|
||||
import { FileTypeCategory, MessageRole } from '$lib/enums';
|
||||
import { ChatService } from '$lib/services';
|
||||
@@ -34,6 +33,7 @@
|
||||
onSystemPromptClick?: () => void;
|
||||
onMcpPromptClick?: () => void;
|
||||
onMcpResourcesClick?: () => void;
|
||||
onMcpSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -47,6 +47,7 @@
|
||||
onFileUpload,
|
||||
onMcpPromptClick,
|
||||
onMcpResourcesClick,
|
||||
onMcpSettingsClick,
|
||||
onMicClick,
|
||||
onStop,
|
||||
onSystemPromptClick,
|
||||
@@ -57,17 +58,9 @@
|
||||
|
||||
let currentConfig = $derived(settingsStore.config);
|
||||
|
||||
let hasMcpPromptsSupport = $derived.by(() => {
|
||||
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
|
||||
let hasMcpPromptsSupport = $derived.by(() => mcpStore.hasPromptsCapability());
|
||||
|
||||
return mcpStore.hasPromptsCapability(perChatOverrides);
|
||||
});
|
||||
|
||||
let hasMcpResourcesSupport = $derived.by(() => {
|
||||
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
|
||||
|
||||
return mcpStore.hasResourcesCapability(perChatOverrides);
|
||||
});
|
||||
let hasMcpResourcesSupport = $derived.by(() => mcpStore.hasResourcesCapability());
|
||||
|
||||
let hasAudioModality = $state(false);
|
||||
let hasVideoModality = $state(false);
|
||||
@@ -163,7 +156,7 @@
|
||||
return onMcpResourcesClick;
|
||||
},
|
||||
get onMcpSettingsClick() {
|
||||
return () => goto(ROUTES.MCP_SERVERS);
|
||||
return onMcpSettingsClick;
|
||||
},
|
||||
get onSystemPromptClick() {
|
||||
return onSystemPromptClick;
|
||||
|
||||
+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 = [];
|
||||
|
||||
@@ -221,25 +221,17 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
|
||||
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
|
||||
|
||||
/**
|
||||
* Dropdown submenu for managing MCP servers in the chat form.
|
||||
* Dropdown submenu for MCP prompts and resources in the chat form.
|
||||
*
|
||||
* Displays a searchable list of enabled MCP servers with toggle switches
|
||||
* to enable/disable each server for chat. Shows server favicon, health status,
|
||||
* and a "Manage MCP Servers" settings link.
|
||||
*
|
||||
* Features:
|
||||
* - Search/filter servers by name or URL
|
||||
* - Per-server toggle to enable/disable for chat
|
||||
* - Health check indicator (shows "Error" badge for failed servers)
|
||||
* - Server favicon display
|
||||
* - Settings link to manage MCP server configuration
|
||||
* Shows an "MCP" sub-menu item with entries for MCP Prompts and MCP
|
||||
* Resources. Only visible when the server supports them.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
|
||||
* <ChatFormActionAddMcpSubmenu />
|
||||
* ```
|
||||
*/
|
||||
export { default as ChatFormActionAddMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte';
|
||||
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();
|
||||
@@ -253,7 +252,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content class="max-h-[80vh] !max-w-4xl overflow-hidden p-0">
|
||||
<Dialog.Content class="max-h-[80vh] md:max-w-4xl! w-full! overflow-hidden p-0">
|
||||
<Dialog.Header class="border-b border-border/30 px-6 py-4">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<FolderOpen class="h-5 w-5" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -246,7 +244,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content class="sm:max-w-2xl">
|
||||
<Dialog.Content class="max-w-2xl!">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="select-none">Add New MCP Server</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
import { SettingsMcpServers } from '$lib/components/app/settings';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
let { onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
open = value;
|
||||
onOpenChange?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content
|
||||
class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360! flex flex-col"
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<McpLogo class="h-5 w-5" />
|
||||
|
||||
<span>MCP Servers</span>
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<SettingsMcpServers class="mt-4" />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -14,6 +14,7 @@
|
||||
<Dialog.Root bind:open {onOpenChange}>
|
||||
<Dialog.Content
|
||||
class="z-999999 grid max-h-full max-w-full! grid-rows-[1fr_auto] overflow-hidden p-0 md:h-[90vh] md:max-w-[90vw]!"
|
||||
showCloseButton
|
||||
>
|
||||
<MermaidPreview {svgHtml} />
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { Settings } from '@lucide/svelte';
|
||||
import { SettingsChat } from '$lib/components/app/settings';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
initialSection?: string;
|
||||
}
|
||||
|
||||
let { initialSection, onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
open = value;
|
||||
onOpenChange?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content
|
||||
class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-6xl! flex flex-col p-0 md:p-6 gap-0"
|
||||
>
|
||||
<Dialog.Header class="md:p-0 p-4">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Settings class="h-5 w-5" />
|
||||
|
||||
<span>Settings</span>
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<SettingsChat {initialSection} onClose={() => (open = false)} onSectionChange={() => {}} />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -18,6 +18,23 @@
|
||||
*/
|
||||
export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte';
|
||||
|
||||
/**
|
||||
* **DialogMcpServers** - MCP servers dialog shown from the chat form
|
||||
*
|
||||
* Shows the same MCP server list as the `/mcp-servers` route inside a modal
|
||||
* dialog.
|
||||
*/
|
||||
export { default as DialogMcpServers } from './DialogMcpServers.svelte';
|
||||
|
||||
/**
|
||||
* **DialogSettingsChat** - Chat settings shown in a modal dialog
|
||||
*
|
||||
* Wraps the full SettingsChat layout (sidebar, mobile header, fields, footer)
|
||||
* inside a ShadCN Dialog instead of a dedicated route. Section switching is
|
||||
* handled in-app via `onSectionChange` rather than URL navigation.
|
||||
*/
|
||||
export { default as DialogSettingsChat } from './DialogSettingsChat.svelte';
|
||||
|
||||
/**
|
||||
* **DialogExportSettings** - Settings export dialog with sensitive data warning
|
||||
*
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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 { conversationsStore, mcpStore } from '$lib/stores';
|
||||
import { mcpStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
@@ -13,11 +13,7 @@
|
||||
let { class: className = '', onclick }: Props = $props();
|
||||
|
||||
let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled));
|
||||
let enabledMcpServersForChat = $derived(
|
||||
mcpServers.filter(
|
||||
(s) => conversationsStore.preferences.isMcpServerEnabledForChat(s.id) && s.url.trim()
|
||||
)
|
||||
);
|
||||
let enabledMcpServersForChat = $derived(mcpServers.filter((s) => s.url.trim()));
|
||||
let healthyEnabledMcpServers = $derived(
|
||||
enabledMcpServersForChat.filter((s) => {
|
||||
const healthState = mcpStore.getHealthCheckState(s.id);
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Database, FileText, ListChecks, MessageSquare, Sparkles, Wrench } from '@lucide/svelte';
|
||||
import {
|
||||
Database,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
ListChecks,
|
||||
MessageSquare,
|
||||
Sparkles,
|
||||
Wrench
|
||||
} from '@lucide/svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import type { MCPCapabilitiesInfo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
capabilities?: MCPCapabilitiesInfo;
|
||||
onBrowseResources?: () => void;
|
||||
}
|
||||
|
||||
let { capabilities }: Props = $props();
|
||||
let { capabilities, onBrowseResources }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if capabilities}
|
||||
@@ -20,10 +29,24 @@
|
||||
{/if}
|
||||
|
||||
{#if capabilities.server.resources}
|
||||
<Badge class="h-5 gap-1 bg-blue-50 px-1.5 text-[10px] dark:bg-blue-950" variant="outline">
|
||||
<Badge
|
||||
class="h-5 cursor-pointer gap-1 bg-blue-50 px-1.5 text-[10px] transition-colors hover:bg-blue-100 dark:bg-blue-950 dark:hover:bg-blue-900"
|
||||
onclick={onBrowseResources}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onBrowseResources?.();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabindex={0}
|
||||
variant="outline"
|
||||
>
|
||||
<Database class="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
||||
|
||||
Resources
|
||||
|
||||
<ExternalLink class="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
|
||||
onDelete: () => void;
|
||||
onBrowseResources?: () => void;
|
||||
}
|
||||
|
||||
let { enabled, onDelete, onToggle, onUpdate, server }: Props = $props();
|
||||
let { enabled, onBrowseResources, onDelete, onToggle, onUpdate, server }: Props = $props();
|
||||
|
||||
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
|
||||
let displayName = $derived(mcpStore.getServerLabel(server));
|
||||
@@ -125,6 +126,7 @@
|
||||
{displayName}
|
||||
enabled={enabled ?? server.enabled}
|
||||
{faviconUrl}
|
||||
{onBrowseResources}
|
||||
{onToggle}
|
||||
{serverInfo}
|
||||
{transportType}
|
||||
@@ -191,12 +193,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<McpServerCardActions
|
||||
{isHealthChecking}
|
||||
onDelete={handleDeleteClick}
|
||||
onEdit={startEditing}
|
||||
onRefresh={handleHealthCheck}
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<McpServerCardActions
|
||||
{isHealthChecking}
|
||||
onDelete={handleDeleteClick}
|
||||
onEdit={startEditing}
|
||||
onRefresh={handleHealthCheck}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
enabled: boolean;
|
||||
disabled?: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onBrowseResources?: () => void;
|
||||
serverInfo?: MCPServerInfo;
|
||||
capabilities?: MCPCapabilitiesInfo;
|
||||
transportType?: MCPTransportType;
|
||||
@@ -23,6 +24,7 @@
|
||||
displayName,
|
||||
enabled,
|
||||
faviconUrl,
|
||||
onBrowseResources,
|
||||
onToggle,
|
||||
serverInfo,
|
||||
transportType
|
||||
@@ -57,7 +59,7 @@
|
||||
{/if}
|
||||
|
||||
{#if capabilities}
|
||||
<McpCapabilitiesBadges {capabilities} />
|
||||
<McpCapabilitiesBadges {capabilities} {onBrowseResources} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Image, Lightbulb, Mic, Video } from '@lucide/svelte';
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { ModelModalities } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
hideOrgName?: boolean;
|
||||
showRaw?: boolean;
|
||||
showRawTooltip?: boolean;
|
||||
hideQuantization?: boolean;
|
||||
hideTags?: boolean;
|
||||
aliases?: string[];
|
||||
tags?: string[];
|
||||
modalities?: ModelModalities;
|
||||
supportsThinking?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
@@ -20,8 +26,11 @@
|
||||
hideOrgName = false,
|
||||
hideQuantization,
|
||||
hideTags,
|
||||
modalities,
|
||||
modelId,
|
||||
showRaw = undefined,
|
||||
showRawTooltip = false,
|
||||
supportsThinking = false,
|
||||
tags,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
@@ -42,6 +51,9 @@
|
||||
|
||||
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
|
||||
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
|
||||
let hasModalityIcons = $derived(
|
||||
supportsThinking || modalities?.vision || modalities?.video || modalities?.audio
|
||||
);
|
||||
|
||||
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
|
||||
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
|
||||
@@ -50,37 +62,107 @@
|
||||
{#if resolvedShowRaw}
|
||||
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
|
||||
{:else}
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}" {...rest}>
|
||||
{#snippet nameAndBadges()}
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
|
||||
</span>
|
||||
|
||||
{#if parsed.params}
|
||||
<span class={badgeClass}>
|
||||
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if parsed.quantization && !resolvedHideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if primaryAlias}
|
||||
{#if primaryAlias !== parsed.modelName}
|
||||
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{#if parsed.params}
|
||||
<span class={badgeClass}>
|
||||
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
{:else if uniqueAliases.length > 1}
|
||||
{#each uniqueAliases as alias (alias)}
|
||||
<span class={badgeClass}>{alias}</span>
|
||||
{/each}
|
||||
|
||||
{#if parsed.quantization && !resolvedHideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if primaryAlias}
|
||||
{#if primaryAlias !== parsed.modelName}
|
||||
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
|
||||
{/if}
|
||||
{:else if uniqueAliases.length > 1}
|
||||
{#each uniqueAliases as alias (alias)}
|
||||
<span class={badgeClass}>{alias}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if uniqueTags.length > 0 && !resolvedHideTags}
|
||||
{#each uniqueTags as tag (tag)}
|
||||
<span class={tagBadgeClass}>{tag}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
|
||||
{#if showRawTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
|
||||
{@render nameAndBadges()}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{modelId}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
{@render nameAndBadges()}
|
||||
{/if}
|
||||
|
||||
{#if uniqueTags.length > 0 && !resolvedHideTags}
|
||||
{#each uniqueTags as tag (tag)}
|
||||
<span class={tagBadgeClass}>{tag}</span>
|
||||
{/each}
|
||||
{#if hasModalityIcons}
|
||||
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
|
||||
{#if supportsThinking}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Lightbulb class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Reasoning</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.vision}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Image class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Vision</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.video}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Video class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Video</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
|
||||
{#if modalities?.audio}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Mic class="h-3 w-3 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>Audio</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import type { ModelItem } from './utils';
|
||||
import { ChevronDown, Loader2 } from '@lucide/svelte';
|
||||
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
DialogModelInformation,
|
||||
DropdownMenuSearchable,
|
||||
ModelId,
|
||||
@@ -11,10 +12,11 @@
|
||||
} from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { MODEL_SELECTOR_ICON } from '$lib/constants';
|
||||
import { MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
import { modelLoadFraction } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
@@ -37,6 +39,9 @@
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
// The model submenu opens together with the menu so the list and its search
|
||||
// box are immediately available, as before the submenu was introduced
|
||||
let modelSubOpen = $state(false);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
@@ -44,24 +49,41 @@
|
||||
onOpenChange: (open) => {
|
||||
isOpen = open;
|
||||
highlightedId = null;
|
||||
|
||||
if (open) {
|
||||
// Defer submenu open so the Sub component is mounted first;
|
||||
// setting bind:open synchronously can be lost if the Sub hasn't
|
||||
// rendered yet.
|
||||
queueMicrotask(() => {
|
||||
if (isOpen) modelSubOpen = true;
|
||||
});
|
||||
} else {
|
||||
modelSubOpen = false;
|
||||
}
|
||||
},
|
||||
useGlobalSelection: () => useGlobalSelection
|
||||
});
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
|
||||
const showOrgNameInTrigger = $derived(
|
||||
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
void ms.searchTerm;
|
||||
highlightedId = null;
|
||||
});
|
||||
|
||||
// Focus the dropdown's search box without scrolling the page. bits-ui
|
||||
// Focus the model submenu's search box without scrolling the page. bits-ui
|
||||
// auto-focuses the opened content by default, which can yank the page
|
||||
// scroll; we prevent that on the Content and refocus the search here.
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
if (!isOpen || !modelSubOpen) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const search = document.querySelector<HTMLElement>(
|
||||
'[data-slot="dropdown-menu-content"] input'
|
||||
'[data-slot="dropdown-menu-sub-content"] input'
|
||||
);
|
||||
|
||||
search?.focus({ preventScroll: true });
|
||||
@@ -188,7 +210,7 @@
|
||||
<DropdownMenu.Trigger
|
||||
{...props}
|
||||
class={[
|
||||
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
@@ -203,16 +225,22 @@
|
||||
>
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={false}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{/if}
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
@@ -236,73 +264,94 @@
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
|
||||
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search models..."
|
||||
searchValue={ms.searchTerm}
|
||||
>
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-selected="true"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
disabled
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
|
||||
<DropdownMenu.Sub bind:open={modelSubOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<MODEL_SELECTOR_ICON class="h-4 w-4" />
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
{isHighlighted}
|
||||
{isSelected}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onSelect={ms.handleSelect}
|
||||
{option}
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{/snippet}
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
|
||||
{/if}
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
renderOption={modelOption}
|
||||
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search models..."
|
||||
searchValue={ms.searchTerm}
|
||||
>
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-selected="true"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
disabled
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
{isHighlighted}
|
||||
{isSelected}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onSelect={ms.handleSelect}
|
||||
{option}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
renderOption={modelOption}
|
||||
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
@@ -332,12 +381,16 @@
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 overflow-hidden"
|
||||
hideOrgName={false}
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{/if}
|
||||
|
||||
@@ -58,6 +58,8 @@
|
||||
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
|
||||
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
|
||||
let loadTitle = $derived(modelLoadProgressText(loadProgress));
|
||||
let modalities = $derived(option.modalities);
|
||||
let supportsThinking = $derived(modelsStore.props.checkModelSupportsThinking(option.model));
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -65,9 +67,11 @@
|
||||
class={[
|
||||
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
|
||||
'cursor-pointer',
|
||||
isSelected && 'bg-accent/50 text-accent-foreground',
|
||||
isSelected && !isHighlighted && 'bg-accent/50',
|
||||
isHighlighted && 'bg-accent',
|
||||
!isSelected && !isHighlighted && 'hover:bg-muted',
|
||||
(isSelected || isHighlighted) && 'text-accent-foreground',
|
||||
'hover:bg-accent',
|
||||
'focus:bg-accent',
|
||||
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||
]}
|
||||
onclick={() => onSelect(option.id)}
|
||||
@@ -81,7 +85,10 @@
|
||||
aliases={option.aliases}
|
||||
class="flex-1"
|
||||
{hideOrgName}
|
||||
{modalities}
|
||||
modelId={option.model}
|
||||
showRawTooltip
|
||||
{supportsThinking}
|
||||
tags={option.tags}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ModelModality } from '$lib/enums';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
@@ -17,6 +18,23 @@ export interface GroupedModelOptions {
|
||||
available: OrgGroup[];
|
||||
}
|
||||
|
||||
function matchesModality(option: ModelOption, term: string): boolean {
|
||||
const modalities = option.modalities;
|
||||
|
||||
if (!modalities) return false;
|
||||
|
||||
switch (term) {
|
||||
case ModelModality.VISION.toLowerCase():
|
||||
return modalities.vision;
|
||||
case ModelModality.AUDIO.toLowerCase():
|
||||
return modalities.audio;
|
||||
case ModelModality.VIDEO.toLowerCase():
|
||||
return modalities.video;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
|
||||
@@ -27,7 +45,8 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
|
||||
option.model.toLowerCase().includes(term) ||
|
||||
option.name?.toLowerCase().includes(term) ||
|
||||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
|
||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
|
||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
|
||||
matchesModality(option, term)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -5,6 +5,7 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
DialogConversationRename,
|
||||
DialogSettingsChat,
|
||||
Logo,
|
||||
SidebarNavigationActions,
|
||||
SidebarNavigationConversationList
|
||||
@@ -91,6 +92,7 @@
|
||||
let selectedIds = new SvelteSet<string>();
|
||||
|
||||
let renameDialogOpen = $state(false);
|
||||
let settingsDialogOpen = $state(false);
|
||||
let renameTargetConversationId = $state<string | null>(null);
|
||||
let renameDraft = $state('');
|
||||
let renameOriginalTitle = $state('');
|
||||
@@ -308,7 +310,7 @@
|
||||
|
||||
<svelte:window bind:innerWidth onkeydown={handleKeydown} />
|
||||
|
||||
{#if innerWidth > 768 || (!page.url.hash.includes(ROUTES.SETTINGS) && !page.url.hash.includes(ROUTES.MCP_SERVERS) && !page.url.hash.includes(ROUTES.SEARCH))}
|
||||
{#if innerWidth > 768 || !page.url.hash.includes(ROUTES.SEARCH)}
|
||||
<aside
|
||||
class={[
|
||||
'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 w-[calc(100dvw-1rem)]',
|
||||
@@ -400,6 +402,7 @@
|
||||
isSearchModeActive = false;
|
||||
searchQuery = '';
|
||||
}}
|
||||
onSettingsClick={() => (settingsDialogOpen = true)}
|
||||
/>
|
||||
|
||||
{#if uiStore.isSidebarExpanded || isOnMobile}
|
||||
@@ -447,6 +450,8 @@
|
||||
onConfirm={handleRenameConfirm}
|
||||
/>
|
||||
|
||||
<DialogSettingsChat bind:open={settingsDialogOpen} />
|
||||
|
||||
<style>
|
||||
aside {
|
||||
@media (max-width: 768px) {
|
||||
|
||||
+22
-16
@@ -26,6 +26,7 @@
|
||||
onSearchDeactivated?: () => void;
|
||||
onSearchClick?: () => void;
|
||||
onNewChat?: () => void;
|
||||
onSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,6 +36,7 @@
|
||||
onNewChat,
|
||||
onSearchClick,
|
||||
onSearchDeactivated,
|
||||
onSettingsClick,
|
||||
searchQuery = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
@@ -115,14 +117,16 @@
|
||||
onNewChat?.();
|
||||
void conversationsStore.openNewChat();
|
||||
}
|
||||
: item.route
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
goto(item.route!);
|
||||
}
|
||||
: isSearchOnMobile
|
||||
? undefined
|
||||
: onSearchClick}
|
||||
: item.action === SidebarAction.SETTINGS
|
||||
? () => onSettingsClick?.()
|
||||
: item.route
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
goto(item.route!);
|
||||
}
|
||||
: isSearchOnMobile
|
||||
? undefined
|
||||
: onSearchClick}
|
||||
{@const itemTransition = {
|
||||
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
|
||||
duration: ICON_STRIP_TRANSITION_DURATION,
|
||||
@@ -169,14 +173,16 @@
|
||||
onNewChat?.();
|
||||
void conversationsStore.openNewChat();
|
||||
}
|
||||
: item.route
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
goto(item.route!);
|
||||
}
|
||||
: isSearchOnMobile
|
||||
? undefined
|
||||
: onSearchClick}
|
||||
: item.action === SidebarAction.SETTINGS
|
||||
? () => onSettingsClick?.()
|
||||
: item.route
|
||||
? () => {
|
||||
onNewChat?.();
|
||||
goto(item.route!);
|
||||
}
|
||||
: isSearchOnMobile
|
||||
? undefined
|
||||
: onSearchClick}
|
||||
{@const itemTransition = {
|
||||
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
|
||||
duration: ICON_STRIP_TRANSITION_DURATION,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { RefreshCw } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
SettingsChatDesktopSidebar,
|
||||
SettingsChatFields,
|
||||
@@ -18,21 +16,29 @@
|
||||
SETTINGS_SECTION_SLUGS
|
||||
} from '$lib/constants';
|
||||
import { ColorMode } from '$lib/enums/ui.enums';
|
||||
import { RouterService } from '$lib/services/router.service';
|
||||
import { modelsStore, serverStore, settingsReferrer, settingsStore } from '$lib/stores';
|
||||
import type { SettingsSection } from '$lib/types';
|
||||
import { modelsStore, serverStore, settingsStore } from '$lib/stores';
|
||||
import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { fade } from 'svelte/transition';
|
||||
interface Props {
|
||||
initialSection?: string;
|
||||
getSectionHref?: (section: SettingsSection) => string;
|
||||
onSectionChange?: (section: SettingsSectionTitle) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let { getSectionHref, initialSection }: Props = $props();
|
||||
let { initialSection, onClose, onSectionChange }: Props = $props();
|
||||
|
||||
let activeSlug = $derived(
|
||||
initialSection ?? (page.params as Record<string, string | undefined>).section ?? 'general'
|
||||
);
|
||||
let activeSlug = $derived(initialSection ?? 'general');
|
||||
|
||||
function handleSectionChange(section: SettingsSectionTitle) {
|
||||
const found = SETTINGS_CHAT_SECTIONS.find((s) => s.title === section);
|
||||
|
||||
if (found) {
|
||||
activeSlug = found.slug;
|
||||
}
|
||||
|
||||
onSectionChange?.(section);
|
||||
}
|
||||
|
||||
let currentSection = $derived(
|
||||
SETTINGS_CHAT_SECTIONS.find((section) => section.slug === activeSlug) ||
|
||||
@@ -115,7 +121,7 @@
|
||||
}
|
||||
|
||||
settingsStore.updateMultipleConfig(processedConfig);
|
||||
goto(settingsReferrer.url);
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
export function reset() {
|
||||
@@ -123,32 +129,24 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div in:fade={{ duration: 150 }} class="mx-auto flex h-full w-full flex-col md:pl-8">
|
||||
<div class="flex flex-1 flex-col gap-4 md:flex-row">
|
||||
<div in:fade={{ duration: 150 }} class="mx-auto flex h-full w-full flex-col">
|
||||
<div class="flex flex-1 flex-col md:flex-row md:gap-4">
|
||||
<SettingsChatDesktopSidebar
|
||||
getHref={getSectionHref ??
|
||||
((section: SettingsSection) => RouterService.settings(section.slug))}
|
||||
isActive={(section: SettingsSection) => section.slug === activeSlug}
|
||||
onSectionChange={handleSectionChange}
|
||||
sections={SETTINGS_CHAT_SECTIONS}
|
||||
/>
|
||||
|
||||
<SettingsChatMobileHeader
|
||||
bind:this={mobileHeader}
|
||||
getHref={getSectionHref ??
|
||||
((section: SettingsSection) => RouterService.settings(section.slug))}
|
||||
isActive={(section: SettingsSection) => section.slug === activeSlug}
|
||||
onSectionChange={handleSectionChange}
|
||||
sections={SETTINGS_CHAT_SECTIONS}
|
||||
/>
|
||||
|
||||
<div class="mx-auto max-w-3xl flex-1">
|
||||
<div class="space-y-6 p-4 md:p-6 md:pt-28">
|
||||
<div class="mx-auto max-w-2xl px-4 flex-1 md:mt-4">
|
||||
<div class="space-y-6 pt-3">
|
||||
<div class="grid">
|
||||
<div class="mb-6 flex items-center gap-2 border-b border-border/30 pb-6 md:flex">
|
||||
<currentSection.icon class="h-5 w-5" />
|
||||
|
||||
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
|
||||
</div>
|
||||
|
||||
{#if currentSection.slug === SETTINGS_SECTION_SLUGS.TOOLS}
|
||||
<SettingsChatToolsTab />
|
||||
{:else if currentSection.slug === SETTINGS_SECTION_SLUGS.IMPORT_EXPORT}
|
||||
|
||||
@@ -1,54 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Settings } from '@lucide/svelte';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
sections: SettingsSection[];
|
||||
isActive: (section: SettingsSection) => boolean;
|
||||
getHref?: (section: SettingsSection) => string;
|
||||
onSectionChange?: (section: SettingsSectionTitle) => void;
|
||||
}
|
||||
|
||||
let { getHref, isActive, onSectionChange, sections }: Props = $props();
|
||||
let { isActive, onSectionChange, sections }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="sticky top-2 hidden w-64 flex-col self-start bg-background py-4 md:flex gap-6">
|
||||
<div class="flex items-center gap-2 py-2">
|
||||
<Settings class="h-5 w-5 md:h-6 md:w-6" />
|
||||
|
||||
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="sticky top-12 hidden w-64 flex-col self-start bg-background md:flex gap-6">
|
||||
<nav class="space-y-1">
|
||||
{#each sections as section (section.title)}
|
||||
{#if getHref}
|
||||
<a
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm no-underline transition-colors hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
href={getHref(section)}
|
||||
>
|
||||
<section.icon class={ICON_CLASS_DEFAULT} />
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={() => onSectionChange?.(section.title)}
|
||||
>
|
||||
<section.icon class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span class="ml-2">{section.title}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
onclick={() => onSectionChange?.(section.title)}
|
||||
>
|
||||
<section.icon class={ICON_CLASS_DEFAULT} />
|
||||
|
||||
<span class="ml-2">{section.title}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<span class="ml-2">{section.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { Settings } from '@lucide/svelte';
|
||||
import { ScrollCarousel } from '$lib/components/app';
|
||||
import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
|
||||
import { BooleanString } from '$lib/enums';
|
||||
@@ -10,11 +9,10 @@
|
||||
interface Props {
|
||||
sections: SettingsSection[];
|
||||
isActive: (section: SettingsSection) => boolean;
|
||||
getHref?: (section: SettingsSection) => string;
|
||||
onSectionChange?: (section: SettingsSectionTitle) => void;
|
||||
}
|
||||
|
||||
let { getHref, isActive, onSectionChange, sections }: Props = $props();
|
||||
let { isActive, onSectionChange, sections }: Props = $props();
|
||||
|
||||
const carousel = useScrollCarousel();
|
||||
|
||||
@@ -37,51 +35,26 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-10 flex flex-col bg-background md:hidden">
|
||||
<div class="flex items-center gap-2 px-4 pt-4 pb-2 md:pt-6">
|
||||
<Settings class="h-5 w-5 md:h-6 md:w-6" />
|
||||
|
||||
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-border/30 py-2">
|
||||
<div class="flex flex-col bg-background md:hidden sticky top-13 z-50">
|
||||
<div class="border-b border-border/30">
|
||||
<ScrollCarousel alwaysShowArrows {carousel} containerClass="py-2" innerClass="gap-2">
|
||||
{#each sections as section (section.title)}
|
||||
{#if getHref}
|
||||
<a
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap no-underline transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
|
||||
href={getHref(section)}
|
||||
onclick={(e: MouseEvent) => {
|
||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
|
||||
<button
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
|
||||
onclick={(e: MouseEvent) => {
|
||||
onSectionChange?.(section.title);
|
||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
|
||||
|
||||
<span>{section.title}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
|
||||
section
|
||||
)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground'}"
|
||||
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
|
||||
onclick={(e: MouseEvent) => {
|
||||
onSectionChange?.(section.title);
|
||||
carousel.scrollToCenter(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
>
|
||||
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
|
||||
|
||||
<span>{section.title}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<span>{section.title}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</ScrollCarousel>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sticky bottom-0 mx-auto mt-4 flex w-full justify-between p-6">
|
||||
<div class="sticky bottom-0 mx-auto mt-4 flex w-full justify-between pb-4 md:pb-0">
|
||||
<div class="flex gap-2">
|
||||
<Button onclick={handleResetClick} variant="outline">
|
||||
<RotateCcw class="h-3 w-3" />
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<script lang="ts">
|
||||
import McpLogo from '../mcp/McpLogo.svelte';
|
||||
import { Plus, X } from '@lucide/svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto, replaceState } from '$app/navigation';
|
||||
import { Plus } from '@lucide/svelte';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { ActionIcon, McpServerCard, McpServerCardSkeleton } from '$lib/components/app';
|
||||
import { DialogMcpServerAddNew } from '$lib/components/app/dialogs';
|
||||
import { McpServerCard, McpServerCardSkeleton } from '$lib/components/app';
|
||||
import { DialogMcpResourcesBrowser, DialogMcpServerAddNew } from '$lib/components/app/dialogs';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Empty from '$lib/components/ui/empty';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
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';
|
||||
|
||||
@@ -23,26 +20,7 @@
|
||||
let servers = $derived(mcpStore.getServers());
|
||||
|
||||
let isAddingServer = $state(false);
|
||||
|
||||
let previousRouteId = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const currentId = page.route.id;
|
||||
|
||||
return () => {
|
||||
previousRouteId = currentId;
|
||||
};
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
const prevIsMcpServers = previousRouteId === '/mcp-servers';
|
||||
|
||||
if (browser && window.history.length > 1 && !prevIsMcpServers) {
|
||||
history.back();
|
||||
} else {
|
||||
goto(ROUTES.START);
|
||||
}
|
||||
}
|
||||
let isResourcesDialogOpen = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (page.url.searchParams.has('add')) {
|
||||
@@ -71,25 +49,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div in:fade={{ duration: 150 }} class="flex min-h-[calc(100dvh-4rem)] flex-col">
|
||||
<div class="fixed top-4.5 right-4 z-50 md:hidden">
|
||||
<ActionIcon icon={X} onclick={handleClose} tooltip="Close" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="sticky top-0 z-10 mt-4 mb-2 flex items-start gap-4 md:p-4 p-0 px-4 md:justify-between md:px-8"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<McpLogo class="h-5 w-5 md:h-6 md:w-6" />
|
||||
|
||||
<h1 class="text-lg font-semibold md:text-2xl">MCP Servers</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div in:fade={{ duration: 150 }} class="flex flex-col h-full">
|
||||
<DialogMcpServerAddNew bind:open={isAddingServer} />
|
||||
|
||||
<DialogMcpResourcesBrowser bind:open={isResourcesDialogOpen} />
|
||||
|
||||
{#if servers.length === 0}
|
||||
<div class="flex flex-1 items-center justify-center py-16">
|
||||
<div class="flex flex-1 items-center justify-center pb-20 pt-10 my-auto">
|
||||
<Empty.Root class="max-w-md">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
@@ -112,22 +78,21 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="grid gap-3 {className}"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
|
||||
class="grid gap-4 {className}"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(min(25rem, calc(100dvw - 4rem)), 1fr));"
|
||||
>
|
||||
{#each servers as server (server.id)}
|
||||
{#if isServerPending(server.id, server.enabled)}
|
||||
<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
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* Full chat settings page layout with sidebar, mobile header, and content area.
|
||||
* Manages local configuration state, section navigation, and context setup.
|
||||
* Accepts an optional `initialSection` prop to override the URL-based section resolution.
|
||||
* Accepts an optional `initialSection` prop to set the initial active section.
|
||||
*/
|
||||
export { default as SettingsChat } from './SettingsChat/SettingsChat.svelte';
|
||||
|
||||
/**
|
||||
* Desktop sidebar navigation for chat settings.
|
||||
* Displays a list of settings sections with icons and titles.
|
||||
* Supports both hash-link navigation (via `getHref`) and in-app section switching (via `onSectionChange`).
|
||||
* Switches sections in-app via `onSectionChange`.
|
||||
*/
|
||||
export { default as SettingsChatDesktopSidebar } from './SettingsChatDesktopSidebar.svelte';
|
||||
|
||||
/**
|
||||
* Mobile header with a horizontally scrollable section picker for chat settings.
|
||||
* Shows chevron buttons for scroll navigation and highlights the active section.
|
||||
* Supports both hash-link navigation (via `getHref`) and in-app section switching (via `onSectionChange`).
|
||||
* Switches sections in-app via `onSectionChange`.
|
||||
*/
|
||||
export { default as SettingsChatMobileHeader } from './SettingsChatMobileHeader.svelte';
|
||||
|
||||
|
||||
@@ -5,3 +5,9 @@
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||
|
||||
<style>
|
||||
:global([data-dialog-close]) {
|
||||
z-index: 999;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
class: className,
|
||||
portalProps,
|
||||
ref = $bindable(null),
|
||||
showCloseButton = true,
|
||||
showCloseButton = false,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||
portalProps?: DialogPrimitive.PortalProps;
|
||||
@@ -25,7 +25,8 @@
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
class={cn(
|
||||
`fixed top-[50%] left-[50%] z-50 grid max-h-[100dvh] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border border-border/30 bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg md:max-h-[100vh]`,
|
||||
`fixed top-[50%] left-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border border-border/30 bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95`,
|
||||
'max-h-[100dvh] max-w-full size-auto sm:max-w-lg md:w-auto md:size-auto md:max-w-[calc(100%-2rem)]',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-content"
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { cn, type WithElementRef } from '$lib/components/ui/utils';
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
children,
|
||||
class: className,
|
||||
ref = $bindable(null),
|
||||
showCloseButton = true,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
showCloseButton?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Header is `sticky`, so it stays at the top while the dialog body scrolls. The close
|
||||
button lives here (not in the body) so it sticks together with the title. `sticky`
|
||||
makes it the containing block, so the close can be absolutely placed at its corner.
|
||||
-->
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
class={cn(
|
||||
'flex flex-col gap-2 text-center sm:text-left sticky top-0 z-50 bg-background md:bg-transparent',
|
||||
className
|
||||
)}
|
||||
data-slot="dialog-header"
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
{#if showCloseButton}
|
||||
<DialogPrimitive.Close
|
||||
class="absolute top-0 right-0 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -15,10 +15,6 @@ export const ROUTES = {
|
||||
MCP_SERVERS: '#/mcp-servers',
|
||||
/** Search — mobile-only full-page conversation search. */
|
||||
SEARCH: '#/search',
|
||||
/** Settings base — for dynamic settings URLs use RouterService. */
|
||||
SETTINGS: '#/settings',
|
||||
/** Exit destination for the settings view (fallback when no referrer). */
|
||||
SETTINGS_EXIT: '#/',
|
||||
/** Root — start of the app. */
|
||||
START: '#/'
|
||||
} as const;
|
||||
|
||||
@@ -54,6 +54,7 @@ export const SETTINGS_KEYS = {
|
||||
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
|
||||
// Display
|
||||
SHOW_MESSAGE_STATS: 'showMessageStats',
|
||||
SHOW_MODEL_ORG_NAME_IN_TRIGGER: 'showModelOrgNameInTrigger',
|
||||
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
|
||||
SHOW_MODEL_TAGS: 'showModelTags',
|
||||
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
|
||||
|
||||
@@ -111,9 +111,8 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
defaultValue: true,
|
||||
help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
|
||||
isExperimental: true,
|
||||
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
|
||||
label: 'Show microphone on empty input',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
@@ -283,6 +282,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
|
||||
label: 'Show model tags',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Display the organization name in the model selector trigger button.',
|
||||
key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER,
|
||||
label: 'Show organization name in model selector trigger',
|
||||
type: SettingsFieldType.CHECKBOX
|
||||
},
|
||||
{
|
||||
defaultValue: false,
|
||||
help: 'Display the current build version in the bottom-right corner of the interface.',
|
||||
|
||||
@@ -20,6 +20,9 @@ export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTool
|
||||
|
||||
/** Disabled tools keyed by stable selection identity, no migration from the name based key */
|
||||
export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`;
|
||||
|
||||
/** Default disabled tool categories, seeded into newly created conversations */
|
||||
export const DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolCategories`;
|
||||
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
|
||||
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
|
||||
export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { ROUTES } from './routes.constants';
|
||||
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
|
||||
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
|
||||
import { SidebarAction, ToolSource } from '$lib/enums';
|
||||
import type { DesktopIconStripItem } from '$lib/types';
|
||||
|
||||
@@ -64,15 +62,8 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
|
||||
},
|
||||
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
|
||||
{
|
||||
activeRouteId: '/mcp-servers',
|
||||
icon: McpLogo,
|
||||
route: ROUTES.MCP_SERVERS,
|
||||
tooltip: 'MCP Servers'
|
||||
},
|
||||
{
|
||||
activeUrlIncludes: '#/settings',
|
||||
action: SidebarAction.SETTINGS,
|
||||
icon: Settings,
|
||||
route: `${ROUTES.SETTINGS}/general`,
|
||||
tooltip: 'Settings'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -23,7 +23,8 @@ export enum ScrollCarouselVariant {
|
||||
* Sidebar icon strip actions handled directly by the sidebar.
|
||||
*/
|
||||
export enum SidebarAction {
|
||||
NEW_CHAT = 'new-chat'
|
||||
NEW_CHAT = 'new-chat',
|
||||
SETTINGS = 'settings'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getConversationModel } from '$lib/utils';
|
||||
export interface UseReasoningMenuReturn {
|
||||
readonly modelSupportsThinking: boolean;
|
||||
readonly thinkingEnabled: boolean;
|
||||
readonly isReasoningActive: boolean;
|
||||
readonly isOff: boolean;
|
||||
readonly currentEffort: ReasoningEffort;
|
||||
readonly levels: ReasoningEffortLevel[];
|
||||
@@ -59,6 +60,12 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
|
||||
const thinkingEnabled = $derived(
|
||||
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
|
||||
);
|
||||
// Thinking is effectively on (lightbulb lit) either when an explicit effort
|
||||
// is selected, or when the effort is left at "Default" and the model
|
||||
// supports thinking.
|
||||
const isReasoningActive = $derived(
|
||||
thinkingEnabled || (currentEffort === ReasoningEffort.DEFAULT && modelSupportsThinking)
|
||||
);
|
||||
|
||||
return {
|
||||
get currentEffort() {
|
||||
@@ -67,6 +74,9 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
|
||||
get isOff() {
|
||||
return currentEffort === ReasoningEffort.OFF;
|
||||
},
|
||||
get isReasoningActive() {
|
||||
return isReasoningActive;
|
||||
},
|
||||
isSelected(level: ReasoningEffortLevel): boolean {
|
||||
return currentEffort === level.value;
|
||||
},
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
import { settingsReferrer } from '$lib/stores';
|
||||
|
||||
export interface ChatSettings {
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useSettingsNavigation() {
|
||||
const subroute = $state({
|
||||
activePanel: 'chat' as 'chat' | 'settings' | 'mcp',
|
||||
chatSettingsRef: undefined as ChatSettings | undefined
|
||||
});
|
||||
const isSettingsRoute = $derived(!!page.route.id?.startsWith('/settings'));
|
||||
|
||||
beforeNavigate(({ from, to }) => {
|
||||
if (to?.route?.id?.startsWith('/settings') && !from?.route?.id?.startsWith('/settings')) {
|
||||
settingsReferrer.url = window.location.hash || ROUTES.START;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (subroute.activePanel === 'settings' && subroute.chatSettingsRef) {
|
||||
subroute.chatSettingsRef.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Return to chat when navigating to a new route
|
||||
$effect(() => {
|
||||
void page.url;
|
||||
|
||||
subroute.activePanel = 'chat';
|
||||
});
|
||||
|
||||
return {
|
||||
get isSettingsRoute() {
|
||||
return isSettingsRoute;
|
||||
},
|
||||
|
||||
get panel() {
|
||||
return subroute;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
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;
|
||||
readonly mcpCategoryEnabled: boolean;
|
||||
isGroupChecked(group: ToolGroup): boolean;
|
||||
getEnabledToolCount(group: ToolGroup): number;
|
||||
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;
|
||||
toggleMcpCategory(): void;
|
||||
handleOpen(): void;
|
||||
}
|
||||
|
||||
@@ -26,19 +31,21 @@ 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)
|
||||
)
|
||||
// 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 mcpCategoryEnabled = $derived(
|
||||
conversationsStore.preferences.isCategoryEnabled(ToolSource.MCP)
|
||||
);
|
||||
const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
|
||||
const noToolsInfoMessage = $derived.by(() => {
|
||||
if (toolsStore.loading) return null;
|
||||
|
||||
@@ -56,11 +63,11 @@ 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;
|
||||
}
|
||||
|
||||
function getFavicon(group: ToolGroup): string | null {
|
||||
@@ -70,13 +77,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 +106,15 @@ 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 toggleMcpCategory(): void {
|
||||
void conversationsStore.preferences.toggleCategory(ToolSource.MCP);
|
||||
}
|
||||
|
||||
function handleOpen(): void {
|
||||
@@ -103,23 +126,30 @@ export function useToolsPanel(): UseToolsPanelReturn {
|
||||
}
|
||||
|
||||
return {
|
||||
get activeGroups() {
|
||||
return activeGroups;
|
||||
get categoryGroups() {
|
||||
return categoryGroups;
|
||||
},
|
||||
expandedGroups,
|
||||
getEnabledToolCount,
|
||||
getFavicon,
|
||||
get groups() {
|
||||
return groups;
|
||||
},
|
||||
handleOpen,
|
||||
isGroupChecked,
|
||||
isGroupDisabled,
|
||||
isToolEnabled,
|
||||
isToolParentDisabled,
|
||||
get mcpCategoryEnabled() {
|
||||
return mcpCategoryEnabled;
|
||||
},
|
||||
get mcpGroups() {
|
||||
return mcpGroups;
|
||||
},
|
||||
get noToolsInfoMessage() {
|
||||
return noToolsInfoMessage;
|
||||
},
|
||||
toggleGroupByKey,
|
||||
toggleGroupExpanded,
|
||||
toggleMcpCategory,
|
||||
toggleTool,
|
||||
get totalToolCount() {
|
||||
return totalToolCount;
|
||||
}
|
||||
|
||||
@@ -307,7 +307,6 @@ export { SandboxService } from './sandbox.service';
|
||||
*
|
||||
* **Key Responsibilities:**
|
||||
* - Build chat URLs for specific conversations: `RouterService.chat(id)` → `#/chat/:id`
|
||||
* - Build settings URLs for sections: `RouterService.settings(section)` → `#/settings/:section`
|
||||
*
|
||||
* @see ROUTES in constants/routes.ts — static route base paths
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
return;
|
||||
}
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -187,8 +187,17 @@ export class ModelsService {
|
||||
|
||||
// 6. Model name = segments before params; tags = remaining segments after params
|
||||
const pivotIdx = paramsIdx !== MODEL_ID.NOT_FOUND ? paramsIdx : segments.length;
|
||||
const modelSegments = segments.slice(0, pivotIdx);
|
||||
|
||||
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID.SEGMENT_SEPARATOR) || null;
|
||||
// strip trailing container-format segments (e.g. GGUF) from the model name
|
||||
while (
|
||||
modelSegments.length > 0 &&
|
||||
MODEL_ID.IGNORED_SEGMENTS.has(modelSegments[modelSegments.length - 1].toUpperCase())
|
||||
) {
|
||||
modelSegments.pop();
|
||||
}
|
||||
|
||||
result.modelName = modelSegments.join(MODEL_ID.SEGMENT_SEPARATOR) || null;
|
||||
|
||||
if (paramsIdx !== MODEL_ID.NOT_FOUND) {
|
||||
result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* RouterService - Builds app route paths
|
||||
*
|
||||
* Returns chat and settings route strings from a single source of truth
|
||||
* (ROUTES). No state.
|
||||
* Returns chat route strings from a single source of truth (ROUTES). No state.
|
||||
*/
|
||||
|
||||
import { ROUTES } from '$lib/constants';
|
||||
@@ -11,8 +10,4 @@ export class RouterService {
|
||||
static chat(id: string): string {
|
||||
return `${ROUTES.CHAT}/${id}`;
|
||||
}
|
||||
|
||||
static settings(section: string): string {
|
||||
return `${ROUTES.SETTINGS}/${section}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,24 @@ 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);
|
||||
const hasMcpServers = mcpStore.hasEnabledServers();
|
||||
|
||||
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(
|
||||
new Set(toolPolicy?.disabledTools ?? []),
|
||||
new Set(toolPolicy?.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,22 @@
|
||||
/**
|
||||
* 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 +49,18 @@ export interface ConversationsPreferencesHost {
|
||||
applyConversationUpdate(id: string, updates: Partial<DatabaseConversation>): void;
|
||||
}
|
||||
|
||||
/** Effective disabled tool keys: conversation row, falling back to defaults. */
|
||||
function buildDisabledTools(conv: DatabaseConversation | null): Set<string> {
|
||||
return new Set(conv ? (conv.disabledTools ?? []) : [...toolsStore.disabledTools]);
|
||||
}
|
||||
|
||||
/** Effective disabled tool categories: conversation row, falling back 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 +74,35 @@ export class ConversationPreferences {
|
||||
/** Global (non-conversation-specific) reasoning effort default */
|
||||
pendingReasoningEffort = $state<ReasoningEffort>(loadReasoningEffortDefault());
|
||||
|
||||
constructor(private host: ConversationsPreferencesHost) {}
|
||||
private get _disabledToolCategories(): Set<ToolSource> {
|
||||
return buildDisabledToolCategories(this.host.activeConversation);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
* Tool Policy
|
||||
*
|
||||
*
|
||||
*/
|
||||
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
const override = this.host.activeConversation?.mcpServerOverrides?.find(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
|
||||
if (override) return override;
|
||||
constructor(private host: ConversationsPreferencesHost) {}
|
||||
|
||||
return this.getDefaultOverride(serverId);
|
||||
/** 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 +126,56 @@ 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)
|
||||
);
|
||||
}
|
||||
|
||||
/** Reload persisted defaults, e.g. when the active conversation is cleared. */
|
||||
@@ -165,57 +217,6 @@ 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
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the reasoning effort for the active conversation.
|
||||
* If no conversation exists, stores the global default.
|
||||
@@ -229,33 +230,98 @@ 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);
|
||||
}
|
||||
|
||||
async toggleGroup(group: ToolGroup): Promise<void> {
|
||||
if (group.source === ToolSource.MCP && group.serverId) {
|
||||
await this.toggleServerTools(group.serverId);
|
||||
} else {
|
||||
await this.toggleCategory(group.source);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleServerTools(serverId: string): Promise<void> {
|
||||
await this.toggleTool(toolsStore.getMcpServerToolsKey(serverId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default enabled value for a server: its own `enabled`
|
||||
* flag in `mcpServers`, so the global on/off state lives in one place.
|
||||
*
|
||||
*
|
||||
* Reasoning Effort
|
||||
*
|
||||
*
|
||||
*/
|
||||
private getDefaultOverride(serverId: string): McpServerOverride | undefined {
|
||||
const server = mcpStore.getServers().find((s) => s.id === serverId);
|
||||
|
||||
if (!server) return undefined;
|
||||
async toggleTool(key: string): Promise<void> {
|
||||
const conv: DatabaseConversation | null = this.host.activeConversation;
|
||||
|
||||
return { enabled: server.enabled, serverId };
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Working Directory
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +49,6 @@ export { uiStore } from './ui.svelte';
|
||||
// SETTINGS / UI PREFERENCES
|
||||
export { settingsStore } from './settings/index.svelte';
|
||||
|
||||
export { settingsReferrer } from './settings/referrer.svelte';
|
||||
|
||||
export { permissionsStore } from './permissions.svelte';
|
||||
|
||||
// TOOLS
|
||||
|
||||
@@ -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,12 @@ class MCPStore implements McpHealthHost {
|
||||
return extras;
|
||||
}
|
||||
|
||||
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
|
||||
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 +512,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,8 +803,8 @@ class MCPStore implements McpHealthHost {
|
||||
);
|
||||
}
|
||||
|
||||
hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean {
|
||||
return Boolean(this.buildMcpClientConfig(settingsStore.config, perChatOverrides));
|
||||
hasEnabledServers(): boolean {
|
||||
return Boolean(this.buildMcpClientConfig(settingsStore.config));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -820,18 +812,12 @@ class MCPStore implements McpHealthHost {
|
||||
* 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)
|
||||
);
|
||||
}
|
||||
hasPromptsCapability(): boolean {
|
||||
const enabledServerIds = new Set(
|
||||
this.getServers()
|
||||
.filter((s) => s.enabled)
|
||||
.map((s) => s.id)
|
||||
);
|
||||
|
||||
if (enabledServerIds.size === 0) {
|
||||
return false;
|
||||
@@ -874,18 +860,12 @@ class MCPStore implements McpHealthHost {
|
||||
* 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)
|
||||
);
|
||||
}
|
||||
hasResourcesCapability(): boolean {
|
||||
const enabledServerIds = new Set(
|
||||
this.getServers()
|
||||
.filter((s) => s.enabled)
|
||||
.map((s) => s.id)
|
||||
);
|
||||
|
||||
if (enabledServerIds.size === 0) {
|
||||
return false;
|
||||
@@ -1185,10 +1165,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 +1175,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 +1229,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: {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* settingsReferrer - Remembers the settings route to return to after exit
|
||||
*
|
||||
* Tracks the last settings section the user was on so the app can return
|
||||
* there after a fallback exit. Standalone reactive value, no host.
|
||||
*/
|
||||
|
||||
import { ROUTES } from '$lib/constants';
|
||||
|
||||
let _url = $state<string>(ROUTES.SETTINGS_EXIT);
|
||||
|
||||
export const settingsReferrer = {
|
||||
get url() {
|
||||
return _url;
|
||||
},
|
||||
set url(value: string) {
|
||||
_url = value;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -271,17 +259,14 @@ class ToolsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled tool definitions for sending to the LLM.
|
||||
* 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 +291,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 +323,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 +367,56 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled tool definitions for sending to the LLM.
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* The single enable rule for a tool entry: its category must be on, its
|
||||
* own key must not be disabled, and for MCP tools the server-scoped group
|
||||
* key must not be disabled either.
|
||||
*/
|
||||
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,6 +449,16 @@ 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);
|
||||
@@ -402,15 +467,8 @@ class ToolsStore {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -602,6 +660,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(
|
||||
|
||||
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
+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,5 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { SettingsMcpServers } from '$lib/components/app/settings';
|
||||
</script>
|
||||
|
||||
<SettingsMcpServers class="mx-auto w-full p-4 md:p-8 md:py-8" />
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { X } from '@lucide/svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { ROUTES } from '$lib/constants';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let previousRouteId = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const currentId = page.route.id;
|
||||
|
||||
return () => {
|
||||
previousRouteId = currentId;
|
||||
};
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
const prevIsSettings = previousRouteId?.startsWith('/settings');
|
||||
|
||||
if (browser && window.history.length > 1 && !prevIsSettings) {
|
||||
history.back();
|
||||
} else {
|
||||
goto(ROUTES.SETTINGS_EXIT);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed top-4.5 right-4 z-50 md:hidden">
|
||||
<ActionIcon icon={X} onclick={handleClose} tooltip="Close" />
|
||||
</div>
|
||||
|
||||
<div class="min-h-full">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, replaceState } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { SettingsChat } from '$lib/components/app/settings';
|
||||
import { SETTINGS_SECTION_SLUGS } from '$lib/constants';
|
||||
import { RouterService } from '$lib/services';
|
||||
|
||||
afterNavigate(() => {
|
||||
if (!page.params.section) {
|
||||
replaceState(RouterService.settings(SETTINGS_SECTION_SLUGS.GENERAL), {});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<SettingsChat initialSection={(page.params as Record<string, string | undefined>).section} />
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,38 @@ describe('parseModelId', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('strips trailing container format segments from model names', () => {
|
||||
expect(parseModelId('unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'DeepSeek-V4-Flash-0731',
|
||||
orgName: 'unsloth',
|
||||
params: null,
|
||||
quantization: 'Q2_K_XL',
|
||||
raw: 'unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('unsloth/Laguna-S-2.1-GGUF:Q4_K_XL')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Laguna-S-2.1',
|
||||
orgName: 'unsloth',
|
||||
params: null,
|
||||
quantization: 'Q4_K_XL',
|
||||
raw: 'unsloth/Laguna-S-2.1-GGUF:Q4_K_XL',
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('org/Model-Name-GGUF')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Model-Name',
|
||||
orgName: 'org',
|
||||
params: null,
|
||||
quantization: null,
|
||||
raw: 'org/Model-Name-GGUF',
|
||||
tags: []
|
||||
});
|
||||
});
|
||||
|
||||
it('handles real-world examples correctly', () => {
|
||||
expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
|
||||
Reference in New Issue
Block a user