mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-19 17:25:07 +02:00
Merge branch 'upstream' into concedo_experimental
# Conflicts: # ggml/src/ggml-cpu/CMakeLists.txt # ggml/src/ggml-cpu/kleidiai/kernels.cpp # ggml/src/ggml-cpu/kleidiai/kernels.h # ggml/src/ggml-cpu/kleidiai/kleidiai.cpp # ggml/src/ggml-cpu/ops.cpp # ggml/src/ggml-cuda/mmq.cuh # ggml/src/ggml-hexagon/htp/hmx-queue.c # ggml/src/ggml-opencl/ggml-opencl.cpp # ggml/src/ggml-opencl/kernels/gemv_noshuffle_iq4_nl_f32.cl # ggml/src/ggml-opencl/kernels/gemv_noshuffle_q1_0_f32.cl # ggml/src/ggml-opencl/kernels/gemv_noshuffle_q4_0_f32.cl # ggml/src/ggml-opencl/kernels/gemv_noshuffle_q4_0_f32_spec.cl # ggml/src/ggml-opencl/kernels/gemv_noshuffle_q4_1_f32.cl # ggml/src/ggml-opencl/kernels/gemv_noshuffle_q4_k_f32.cl # ggml/src/ggml-opencl/kernels/gemv_noshuffle_q5_0_f32.cl # ggml/src/ggml-opencl/kernels/gemv_noshuffle_q5_1_f32.cl # ggml/src/ggml-opencl/kernels/gemv_noshuffle_q5_k_f32.cl # ggml/src/ggml-opencl/kernels/gemv_noshuffle_q6_k_f32.cl # ggml/src/ggml-opencl/kernels/gemv_noshuffle_q8_0_f32.cl # ggml/src/ggml-opencl/kernels/mul_mv_f16_f16.cl # ggml/src/ggml-opencl/kernels/mul_mv_f16_f32.cl # ggml/src/ggml-opencl/kernels/mul_mv_f16_f32_1row.cl # ggml/src/ggml-sycl/fattn-vec.hpp # tests/test-backend-ops.cpp # tests/test-chat-auto-parser.cpp # tests/test-export-graph-ops.cpp # tests/test-jinja.cpp # tests/test-llama-archs.cpp # tools/tokenize/tokenize.cpp
This commit is contained in:
+4
-4
@@ -17,10 +17,10 @@
|
||||
let { onMcpSettingsClick }: Props = $props();
|
||||
|
||||
let mcpSearchQuery = $state('');
|
||||
let allMcpServers = $derived(mcpStore.getServers());
|
||||
let mcpServers = $derived(mcpStore.visibleMcpServers);
|
||||
// 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 hasAnyMcpServers = $derived(allMcpServers.length > 0);
|
||||
let filteredMcpServers = $derived.by(() => {
|
||||
const query = mcpSearchQuery.toLowerCase().trim();
|
||||
if (!query) return mcpServers;
|
||||
@@ -46,7 +46,7 @@
|
||||
function handleMcpSubMenuOpen(open: boolean) {
|
||||
if (open) {
|
||||
mcpSearchQuery = '';
|
||||
mcpStore.runHealthChecksForServers(allMcpServers);
|
||||
mcpStore.runHealthChecksForServers(mcpServers);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-72
@@ -2,85 +2,31 @@
|
||||
import { Lightbulb, LightbulbOff, Check, Info } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ReasoningEffort } from '$lib/enums';
|
||||
import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
|
||||
import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
|
||||
import type { ReasoningEffortLevel } from '$lib/types';
|
||||
import {
|
||||
modelsStore,
|
||||
checkModelSupportsThinking,
|
||||
supportsThinking,
|
||||
propsCacheVersion,
|
||||
loadedModelIds
|
||||
} from '$lib/stores/models.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
|
||||
let subOpen = $state(false);
|
||||
|
||||
let conversationModel = $derived(
|
||||
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
|
||||
);
|
||||
|
||||
let modelSupportsThinkingFromMessages = $derived.by(() => {
|
||||
const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
|
||||
if (!modelId) return false;
|
||||
|
||||
const messages = conversationsStore.activeMessages;
|
||||
|
||||
return messages.some(
|
||||
(m) => m.role === 'assistant' && m.model === modelId && !!m.reasoningContent
|
||||
);
|
||||
});
|
||||
|
||||
let modelSupportsThinking = $derived.by(() => {
|
||||
loadedModelIds();
|
||||
propsCacheVersion();
|
||||
|
||||
if (isRouterMode()) {
|
||||
const modelId = modelsStore.selectedModelName || conversationModel;
|
||||
return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
|
||||
}
|
||||
|
||||
return supportsThinking() || modelSupportsThinkingFromMessages;
|
||||
});
|
||||
|
||||
let thinkingEnabled = $derived(conversationsStore.getThinkingEnabled());
|
||||
let currentEffort = $derived(conversationsStore.getReasoningEffort());
|
||||
let isOff = $derived(!thinkingEnabled);
|
||||
|
||||
function isSelected(item: ReasoningEffortLevel): boolean {
|
||||
if (item.isOff) return isOff;
|
||||
return thinkingEnabled && currentEffort === item.value;
|
||||
}
|
||||
|
||||
function handleSelection(item: ReasoningEffortLevel) {
|
||||
if (item.isOff) {
|
||||
conversationsStore.setThinkingEnabled(false);
|
||||
} else {
|
||||
conversationsStore.setThinkingEnabled(true);
|
||||
conversationsStore.setReasoningEffort(item.value as ReasoningEffort);
|
||||
}
|
||||
subOpen = false;
|
||||
}
|
||||
const reasoning = useReasoningMenu();
|
||||
</script>
|
||||
|
||||
{#if modelSupportsThinking}
|
||||
{#if reasoning.modelSupportsThinking}
|
||||
<DropdownMenu.Sub bind:open={subOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
{#if thinkingEnabled}
|
||||
{#if reasoning.thinkingEnabled}
|
||||
<Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
|
||||
{:else}
|
||||
<LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span class="text-sm inline-flex gap-2 {!thinkingEnabled ? 'text-muted-foreground' : ''}">
|
||||
<span
|
||||
class="text-sm inline-flex gap-2 {!reasoning.thinkingEnabled
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
>
|
||||
Reasoning
|
||||
|
||||
<span class="capitalize text-muted-foreground">
|
||||
{thinkingEnabled ? currentEffort : 'off'}
|
||||
{reasoning.thinkingEnabled ? reasoning.currentEffort : 'off'}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
@@ -88,14 +34,18 @@
|
||||
<DropdownMenu.SubContent
|
||||
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
|
||||
>
|
||||
{#each REASONING_EFFORT_LEVELS as level (level.value)}
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
class:bg-accent={isSelected(level)}
|
||||
onclick={() => handleSelection(level)}
|
||||
class:bg-accent={reasoning.isSelected(level)}
|
||||
onclick={() => {
|
||||
reasoning.select(level);
|
||||
subOpen = false;
|
||||
}}
|
||||
>
|
||||
{#if isSelected(level)}
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="h-4 w-4 shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="h-4 w-4 shrink-0"></div>
|
||||
@@ -103,11 +53,9 @@
|
||||
|
||||
<span class="flex-1">{level.label}</span>
|
||||
|
||||
{#if !level.isOff}
|
||||
{#if tokenLabel}
|
||||
<span class="text-[11px] text-muted-foreground opacity-60">
|
||||
{REASONING_EFFORT_TOKENS[level.value] === -1
|
||||
? 'Unlimited'
|
||||
: `Max ${REASONING_EFFORT_TOKENS[level.value].toLocaleString()} tokens`}
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
|
||||
+72
-5
@@ -10,10 +10,18 @@
|
||||
import { ATTACHMENT_FILE_ITEMS } from '$lib/constants/attachment-menu';
|
||||
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
|
||||
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { McpLogo } from '$lib/components/app';
|
||||
import { PencilRuler, ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||
import {
|
||||
PencilRuler,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Lightbulb,
|
||||
LightbulbOff,
|
||||
Check
|
||||
} from '@lucide/svelte';
|
||||
import { HealthCheckStatus } from '$lib/enums';
|
||||
import { AttachmentAction } from '$lib/enums/attachment.enums';
|
||||
|
||||
@@ -48,6 +56,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
let sheetOpen = $state(false);
|
||||
let reasoningExpanded = $state(false);
|
||||
let filesExpanded = $state(true);
|
||||
let toolsExpanded = $state(false);
|
||||
let mcpExpanded = $state(false);
|
||||
@@ -67,6 +76,7 @@
|
||||
);
|
||||
|
||||
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';
|
||||
@@ -74,7 +84,7 @@
|
||||
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 visibleMcpServers = $derived(mcpStore.visibleMcpServers);
|
||||
let mcpServers = $derived(mcpStore.getServers());
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1 {className}">
|
||||
@@ -91,6 +101,63 @@
|
||||
</Sheet.Header>
|
||||
|
||||
<div class="flex flex-col gap-1 px-1.5 pb-2">
|
||||
{#if reasoning.modelSupportsThinking}
|
||||
<Collapsible.Root
|
||||
open={reasoningExpanded}
|
||||
onOpenChange={(open) => (reasoningExpanded = open)}
|
||||
>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if reasoningExpanded}
|
||||
<ChevronDown class="h-4 w-4 shrink-0" />
|
||||
{:else}
|
||||
<ChevronRight class="h-4 w-4 shrink-0" />
|
||||
{/if}
|
||||
|
||||
{#if reasoning.thinkingEnabled}
|
||||
<Lightbulb class="h-4 w-4 shrink-0 text-amber-400" />
|
||||
{:else}
|
||||
<LightbulbOff class="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span class="flex-1">Reasoning</span>
|
||||
|
||||
<span class="text-xs capitalize text-muted-foreground">
|
||||
{reasoning.thinkingEnabled ? reasoning.currentEffort : 'off'}
|
||||
</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
|
||||
type="button"
|
||||
class={sheetItemRowClass}
|
||||
class:bg-accent={reasoning.isSelected(level)}
|
||||
onclick={() => reasoning.select(level)}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="h-4 w-4 shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="h-4 w-4 shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
<span class="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 open={filesExpanded} onOpenChange={(open) => (filesExpanded = open)}>
|
||||
<Collapsible.Trigger class={sheetItemClass}>
|
||||
{#if filesExpanded}
|
||||
@@ -151,13 +218,13 @@
|
||||
<span class="flex-1">MCP Servers</span>
|
||||
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{visibleMcpServers.length} server{visibleMcpServers.length !== 1 ? 's' : ''}
|
||||
{mcpServers.length} server{mcpServers.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Content>
|
||||
<div class="flex flex-col gap-0.5 pl-4">
|
||||
{#each visibleMcpServers as server (server.id)}
|
||||
{#each mcpServers as server (server.id)}
|
||||
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
||||
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
|
||||
{@const displayName = mcpStore.getServerLabel(server)}
|
||||
@@ -200,7 +267,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if visibleMcpServers.length === 0}
|
||||
{#if mcpServers.length === 0}
|
||||
<div class="px-3 py-2 text-center text-sm text-muted-foreground">
|
||||
No MCP servers configured
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
<ChatMessageActionCard icon={ShieldQuestion}>
|
||||
{#snippet message()}
|
||||
Allow use of <span class="font-semibold">{toolName}</span>{#if serverLabel}
|
||||
from <span class="font-semibold">{serverLabel}</span>{/if}?
|
||||
from <span class="font-semibold">{serverLabel}</span>{/if}?
|
||||
{/snippet}
|
||||
|
||||
{#snippet actions()}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
let newServerUrl = $state('');
|
||||
let newServerHeaders = $state('');
|
||||
let newServerUseProxy = $state(false);
|
||||
let newServerUrlError = $derived.by(() => {
|
||||
if (!newServerUrl.trim()) return 'URL is required';
|
||||
try {
|
||||
@@ -35,6 +36,7 @@
|
||||
if (!value) {
|
||||
newServerUrl = '';
|
||||
newServerHeaders = '';
|
||||
newServerUseProxy = false;
|
||||
}
|
||||
open = value;
|
||||
onOpenChange?.(value);
|
||||
@@ -49,7 +51,8 @@
|
||||
id: newServerId,
|
||||
enabled: true,
|
||||
url: newServerUrl.trim(),
|
||||
headers: newServerHeaders.trim() || undefined
|
||||
headers: newServerHeaders.trim() || undefined,
|
||||
useProxy: newServerUseProxy
|
||||
});
|
||||
|
||||
conversationsStore.setMcpServerOverride(newServerId, true);
|
||||
@@ -74,8 +77,10 @@
|
||||
<McpServerForm
|
||||
url={newServerUrl}
|
||||
headers={newServerHeaders}
|
||||
useProxy={newServerUseProxy}
|
||||
onUrlChange={(v) => (newServerUrl = v)}
|
||||
onHeadersChange={(v) => (newServerHeaders = v)}
|
||||
onUseProxyChange={(v) => (newServerUseProxy = v)}
|
||||
urlError={newServerUrl ? newServerUrlError : null}
|
||||
id="new-server"
|
||||
/>
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.CONNECTING);
|
||||
let isConnected = $derived(healthState.status === HealthCheckStatus.SUCCESS);
|
||||
let isError = $derived(healthState.status === HealthCheckStatus.ERROR);
|
||||
let showSkeleton = $derived(isIdle || isHealthChecking);
|
||||
// Disabled servers stay IDLE (no startup health check), so the body
|
||||
// skeleton only applies while a check is running or expected to run.
|
||||
let showSkeleton = $derived(isHealthChecking || (isIdle && server.enabled));
|
||||
let errorMessage = $derived(
|
||||
healthState.status === HealthCheckStatus.ERROR ? healthState.message : undefined
|
||||
);
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
|
||||
let servers = $derived(mcpStore.visibleMcpServers);
|
||||
// Every configured server is listed; `enabled` is an on/off state,
|
||||
// not a visibility filter, so a disabled server stays toggleable.
|
||||
let servers = $derived(mcpStore.getServers());
|
||||
|
||||
let isAddingServer = $state(false);
|
||||
|
||||
@@ -58,9 +60,14 @@
|
||||
// Each card decides for itself whether to render based on its own
|
||||
// health-check state, so adding a server only flashes the new card
|
||||
// (not every other already-loaded card) until its health check resolves.
|
||||
function isServerPending(serverId: string): boolean {
|
||||
// Disabled servers never receive a startup health check, so IDLE only
|
||||
// counts as pending when the server is enabled; otherwise the real card
|
||||
// renders and keeps the enable toggle reachable.
|
||||
function isServerPending(serverId: string, enabled: boolean): boolean {
|
||||
const status = mcpStore.getHealthCheckState(serverId).status;
|
||||
return status === HealthCheckStatus.IDLE || status === HealthCheckStatus.CONNECTING;
|
||||
return (
|
||||
status === HealthCheckStatus.CONNECTING || (status === HealthCheckStatus.IDLE && enabled)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -109,7 +116,7 @@
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
|
||||
>
|
||||
{#each servers as server (server.id)}
|
||||
{#if isServerPending(server.id)}
|
||||
{#if isServerPending(server.id, server.enabled)}
|
||||
<McpServerCardSkeleton />
|
||||
{:else}
|
||||
<McpServerCard
|
||||
|
||||
@@ -6,8 +6,7 @@ export const NEWLINE_SEPARATOR = '\n';
|
||||
|
||||
export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
|
||||
enabled: true,
|
||||
maxTurns: 100,
|
||||
maxToolPreviewLines: 25
|
||||
maxTurns: 100
|
||||
} as const;
|
||||
|
||||
export const REASONING_TAGS = {
|
||||
|
||||
@@ -24,8 +24,10 @@ export const MODEL_CUSTOM_QUANTIZATION_PREFIX_RE = /^UD$/i;
|
||||
|
||||
/**
|
||||
* Matches a parameter-count segment, e.g. `7B`, `1.5b`, `120M`.
|
||||
* The optional leading `E` covers effective-parameter sizes, e.g. Gemma's
|
||||
* `E2B`/`E4B` (MatFormer models sized by resident params).
|
||||
*/
|
||||
export const MODEL_PARAMS_RE = /^\d+(\.\d+)?[BbMmKkTt]$/;
|
||||
export const MODEL_PARAMS_RE = /^[Ee]?\d+(\.\d+)?[BbMmKkTt]$/;
|
||||
|
||||
/**
|
||||
* Matches an activated-parameter-count segment, e.g. `A10B`, `a2.4b`.
|
||||
|
||||
@@ -8,7 +8,6 @@ export const SETTINGS_SECTION_SLUGS = {
|
||||
PENALTIES: 'penalties',
|
||||
AGENTIC: 'agentic',
|
||||
DEVELOPER: 'developer',
|
||||
MCP: 'mcp',
|
||||
TOOLS: 'tools',
|
||||
IMPORT_EXPORT: 'import-export'
|
||||
} as const;
|
||||
|
||||
@@ -59,7 +59,6 @@ export const SETTINGS_KEYS = {
|
||||
MCP_SERVERS: 'mcpServers',
|
||||
MCP_REQUEST_TIMEOUT_SECONDS: 'mcpRequestTimeoutSeconds',
|
||||
AGENTIC_MAX_TURNS: 'agenticMaxTurns',
|
||||
AGENTIC_MAX_TOOL_PREVIEW_LINES: 'agenticMaxToolPreviewLines',
|
||||
SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress',
|
||||
// Performance
|
||||
PRE_ENCODE_CONVERSATION: 'preEncodeConversation',
|
||||
|
||||
@@ -24,7 +24,6 @@ import type {
|
||||
SettingsSection
|
||||
} from '$lib/types';
|
||||
import { CLI_FLAGS, DEFAULT_MCP_CONFIG } from '$lib/constants';
|
||||
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
|
||||
import { SETTINGS_KEYS } from './settings-keys';
|
||||
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
|
||||
import { TITLE_GENERATION } from './title-generation';
|
||||
@@ -36,7 +35,6 @@ export const SETTINGS_SECTION_TITLES = {
|
||||
PENALTIES: 'Penalties',
|
||||
AGENTIC: 'Agentic',
|
||||
TOOLS: 'Tools',
|
||||
MCP: 'MCP',
|
||||
IMPORT_EXPORT: 'Import/Export',
|
||||
DEVELOPER: 'Developer'
|
||||
} as const;
|
||||
@@ -635,15 +633,15 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
}
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
|
||||
label: 'Max lines per tool preview',
|
||||
help: 'Number of lines shown in tool output previews (last N lines). Only these previews and the final LLM response persist after the agentic loop completes.',
|
||||
defaultValue: 25,
|
||||
key: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
|
||||
label: 'MCP request timeout (seconds)',
|
||||
help: 'Timeout for individual MCP tool calls.',
|
||||
defaultValue: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
isPositiveInteger: true,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.AGENTIC_MAX_TOOL_PREVIEW_LINES,
|
||||
serverKey: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
|
||||
paramType: SyncableParameterType.NUMBER
|
||||
}
|
||||
}
|
||||
@@ -735,26 +733,6 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
[SETTINGS_SECTION_SLUGS.MCP]: {
|
||||
title: SETTINGS_SECTION_TITLES.MCP,
|
||||
slug: SETTINGS_SECTION_SLUGS.MCP,
|
||||
icon: McpLogo,
|
||||
settings: [
|
||||
{
|
||||
key: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
|
||||
label: 'Request timeout (seconds)',
|
||||
help: 'Default timeout for individual MCP tool calls. Can be overridden per server.',
|
||||
defaultValue: DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.MCP,
|
||||
isPositiveInteger: true,
|
||||
sync: {
|
||||
serverKey: SETTINGS_KEYS.MCP_REQUEST_TIMEOUT_SECONDS,
|
||||
paramType: SyncableParameterType.NUMBER
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ReasoningEffort } from '$lib/enums';
|
||||
import { REASONING_EFFORT_LEVELS } from '$lib/constants/reasoning-effort';
|
||||
import { REASONING_EFFORT_TOKENS } from '$lib/constants/reasoning-effort-tokens';
|
||||
import type { ReasoningEffortLevel } from '$lib/types';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import {
|
||||
modelsStore,
|
||||
checkModelSupportsThinking,
|
||||
supportsThinking,
|
||||
propsCacheVersion,
|
||||
loadedModelIds
|
||||
} from '$lib/stores/models.svelte';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
|
||||
export interface UseReasoningMenuReturn {
|
||||
readonly modelSupportsThinking: boolean;
|
||||
readonly thinkingEnabled: boolean;
|
||||
readonly currentEffort: ReasoningEffort;
|
||||
readonly levels: ReasoningEffortLevel[];
|
||||
isSelected(level: ReasoningEffortLevel): boolean;
|
||||
tokenLabel(level: ReasoningEffortLevel): string | null;
|
||||
select(level: ReasoningEffortLevel): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared reactive state and helpers for the reasoning effort menu.
|
||||
*
|
||||
* Used by both the desktop dropdown (`ChatFormActionAddReasoningSubmenu`)
|
||||
* and the mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the
|
||||
* thinking-support derivation and the effort selection logic.
|
||||
*/
|
||||
export function useReasoningMenu(): UseReasoningMenuReturn {
|
||||
const conversationModel = $derived(
|
||||
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
|
||||
);
|
||||
|
||||
// a router chat can carry reasoning from an earlier turn before the props
|
||||
// cache is primed, so a model that already produced thinking still qualifies
|
||||
const modelSupportsThinkingFromMessages = $derived.by(() => {
|
||||
const modelId = isRouterMode() ? modelsStore.selectedModelName || conversationModel : null;
|
||||
if (!modelId) return false;
|
||||
|
||||
return conversationsStore.activeMessages.some(
|
||||
(m) => m.role === 'assistant' && m.model === modelId && !!m.reasoningContent
|
||||
);
|
||||
});
|
||||
|
||||
const modelSupportsThinking = $derived.by(() => {
|
||||
loadedModelIds();
|
||||
propsCacheVersion();
|
||||
|
||||
if (isRouterMode()) {
|
||||
const modelId = modelsStore.selectedModelName || conversationModel;
|
||||
return checkModelSupportsThinking(modelId ?? '') || modelSupportsThinkingFromMessages;
|
||||
}
|
||||
|
||||
return supportsThinking() || modelSupportsThinkingFromMessages;
|
||||
});
|
||||
|
||||
const thinkingEnabled = $derived(conversationsStore.getThinkingEnabled());
|
||||
const currentEffort = $derived(conversationsStore.getReasoningEffort());
|
||||
|
||||
return {
|
||||
get modelSupportsThinking() {
|
||||
return modelSupportsThinking;
|
||||
},
|
||||
get thinkingEnabled() {
|
||||
return thinkingEnabled;
|
||||
},
|
||||
get currentEffort() {
|
||||
return currentEffort;
|
||||
},
|
||||
get levels() {
|
||||
return REASONING_EFFORT_LEVELS;
|
||||
},
|
||||
isSelected(level: ReasoningEffortLevel): boolean {
|
||||
if (level.isOff) return !thinkingEnabled;
|
||||
return thinkingEnabled && currentEffort === level.value;
|
||||
},
|
||||
tokenLabel(level: ReasoningEffortLevel): string | null {
|
||||
if (level.isOff) return null;
|
||||
const tokens = REASONING_EFFORT_TOKENS[level.value];
|
||||
return tokens === -1 ? 'Unlimited' : `Max ${tokens.toLocaleString()} tokens`;
|
||||
},
|
||||
select(level: ReasoningEffortLevel): void {
|
||||
if (level.isOff) {
|
||||
conversationsStore.setThinkingEnabled(false);
|
||||
return;
|
||||
}
|
||||
conversationsStore.setThinkingEnabled(true);
|
||||
conversationsStore.setReasoningEffort(level.value as ReasoningEffort);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -692,8 +692,31 @@ export class MCPService {
|
||||
this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...')
|
||||
);
|
||||
|
||||
// The SDK timeout only covers the initialize request, not transport.start(),
|
||||
// which can hang forever on an unreachable host (SSE endpoint wait, WebSocket
|
||||
// handshake, proxied fetch). This race bounds the whole handshake and closes
|
||||
// the transport on expiry so the underlying fetch or socket is aborted.
|
||||
const handshakeTimeoutMs =
|
||||
serverConfig.handshakeTimeoutMs ?? DEFAULT_MCP_CONFIG.connectionTimeoutMs;
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const handshakeDeadline = new Promise<never>((_, reject) => {
|
||||
handshakeTimer = setTimeout(() => {
|
||||
void transport.close().catch(() => {});
|
||||
reject(new Error(`Connection timed out after ${Math.round(handshakeTimeoutMs / 1000)}s`));
|
||||
}, handshakeTimeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
client.connect(transport, { timeout: handshakeTimeoutMs }),
|
||||
handshakeDeadline
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(handshakeTimer);
|
||||
}
|
||||
|
||||
// Transport diagnostics are only for the initial handshake, not long-lived traffic.
|
||||
stopPhaseLogging();
|
||||
client.onerror = runtimeErrorHandler;
|
||||
|
||||
@@ -280,16 +280,13 @@ class AgenticStore {
|
||||
|
||||
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
|
||||
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
|
||||
const maxToolPreviewLines =
|
||||
Number(settings.agenticMaxToolPreviewLines) || DEFAULT_AGENTIC_CONFIG.maxToolPreviewLines;
|
||||
const hasTools =
|
||||
mcpStore.hasEnabledServers(perChatOverrides) ||
|
||||
toolsStore.builtinTools.length > 0 ||
|
||||
toolsStore.customTools.length > 0;
|
||||
return {
|
||||
enabled: hasTools && DEFAULT_AGENTIC_CONFIG.enabled,
|
||||
maxTurns,
|
||||
maxToolPreviewLines
|
||||
maxTurns
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1334,7 +1334,7 @@ class ChatStore {
|
||||
}
|
||||
};
|
||||
|
||||
const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
|
||||
{
|
||||
const agenticResult = await agenticStore.runAgenticFlow({
|
||||
|
||||
@@ -243,9 +243,9 @@ class ConversationsStore {
|
||||
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
|
||||
const conversation = await DatabaseService.createConversation(conversationName);
|
||||
|
||||
// New conversations inherit per-server enabled defaults directly from
|
||||
// `mcpServers[i].enabled` (see #checkServerEnabled). No per-conversation
|
||||
// override list needs to be seeded.
|
||||
// No MCP override list is seeded: getAllMcpServerOverrides resolves
|
||||
// servers without a per-conversation override to `mcpServers[i].enabled`,
|
||||
// and only explicit toggles are stored on the conversation.
|
||||
|
||||
// Inherit global thinking/reasoning defaults into the new conversation
|
||||
const thinkingEnabled = this.getThinkingEnabled();
|
||||
@@ -601,48 +601,41 @@ class ConversationsStore {
|
||||
*/
|
||||
|
||||
/**
|
||||
/**
|
||||
* Resolve the per-server enabled value when no active conversation exists.
|
||||
* The default for new chats is the server's own `enabled` flag in `mcpServers`.
|
||||
* Resolve the default enabled value for a server: its own `enabled`
|
||||
* flag in `mcpServers`, so the global on/off state lives in one place.
|
||||
*/
|
||||
#getDefaultOverrideForNoConversation(serverId: string): McpServerOverride | undefined {
|
||||
#getDefaultOverride(serverId: string): McpServerOverride | undefined {
|
||||
const server = mcpStore.getServers().find((s) => s.id === serverId);
|
||||
if (!server) return undefined;
|
||||
return { serverId, enabled: server.enabled };
|
||||
}
|
||||
|
||||
/**
|
||||
* Default overrides for new chats are derived from `mcpServers[i].enabled`,
|
||||
* so the global on/off state lives in one place.
|
||||
*/
|
||||
#getAllDefaultOverridesForNoConversation(): McpServerOverride[] {
|
||||
return mcpStore.getServers().map((s) => ({ serverId: s.id, enabled: s.enabled }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets MCP server override for a specific server in the active conversation.
|
||||
* Falls back to `mcpServers[i].enabled` if no active conversation exists.
|
||||
* 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.
|
||||
* @param serverId - The server ID to check
|
||||
* @returns The override if set, undefined if no matching server
|
||||
* @returns The effective override, undefined if no matching server
|
||||
*/
|
||||
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
|
||||
if (this.activeConversation) {
|
||||
return this.activeConversation.mcpServerOverrides?.find(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
}
|
||||
return this.#getDefaultOverrideForNoConversation(serverId);
|
||||
const override = this.activeConversation?.mcpServerOverrides?.find(
|
||||
(o: McpServerOverride) => o.serverId === serverId
|
||||
);
|
||||
if (override) return override;
|
||||
return this.#getDefaultOverride(serverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all MCP server overrides for the current conversation.
|
||||
* When no active conversation, derives from `mcpServers[i].enabled`.
|
||||
* 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[] {
|
||||
if (this.activeConversation?.mcpServerOverrides) {
|
||||
return this.activeConversation.mcpServerOverrides;
|
||||
}
|
||||
return this.#getAllDefaultOverridesForNoConversation();
|
||||
const overrides = this.activeConversation?.mcpServerOverrides;
|
||||
return mcpStore.getServers().map((s) => {
|
||||
const override = overrides?.find((o: McpServerOverride) => o.serverId === s.id);
|
||||
return { serverId: s.id, enabled: override?.enabled ?? s.enabled };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -148,15 +148,22 @@ class MCPStore {
|
||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||
url,
|
||||
name: (entry as { name?: string })?.name,
|
||||
requestTimeoutSeconds:
|
||||
(entry as { requestTimeoutSeconds?: number })?.requestTimeoutSeconds ??
|
||||
DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
headers: headers || undefined,
|
||||
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
|
||||
} satisfies MCPServerSettingsEntry;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request timeout in milliseconds, read live from the global setting
|
||||
* so a change in Settings applies to every server immediately.
|
||||
*/
|
||||
#requestTimeoutMs(): number {
|
||||
const seconds =
|
||||
Number(config().mcpRequestTimeoutSeconds) || DEFAULT_MCP_CONFIG.requestTimeoutSeconds;
|
||||
return Math.round(seconds * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds server configuration from a settings entry.
|
||||
*/
|
||||
@@ -183,7 +190,7 @@ class MCPStore {
|
||||
url: entry.url,
|
||||
transport: detectMcpTransportFromUrl(entry.url),
|
||||
handshakeTimeoutMs: connectionTimeoutMs,
|
||||
requestTimeoutMs: Math.round(entry.requestTimeoutSeconds * 1000),
|
||||
requestTimeoutMs: this.#requestTimeoutMs(),
|
||||
headers,
|
||||
useProxy: entry.useProxy
|
||||
};
|
||||
@@ -191,15 +198,15 @@ class MCPStore {
|
||||
|
||||
/**
|
||||
* Checks if a server is enabled for a given chat.
|
||||
* Only per-chat overrides (persisted in localStorage for new chats,
|
||||
* or in IndexedDB for existing conversations) control enabled state.
|
||||
* A per-chat override wins when present; a server without one resolves
|
||||
* to its own `enabled` flag in `mcpServers`.
|
||||
*/
|
||||
#checkServerEnabled(
|
||||
server: MCPServerSettingsEntry,
|
||||
perChatOverrides?: McpServerOverride[]
|
||||
): boolean {
|
||||
const override = perChatOverrides?.find((o) => o.serverId === server.id);
|
||||
return override?.enabled ?? false;
|
||||
return override?.enabled ?? server.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,7 +237,7 @@ class MCPStore {
|
||||
protocolVersion: DEFAULT_MCP_CONFIG.protocolVersion,
|
||||
capabilities: DEFAULT_MCP_CONFIG.capabilities,
|
||||
clientInfo: DEFAULT_MCP_CONFIG.clientInfo,
|
||||
requestTimeoutMs: Math.round(DEFAULT_MCP_CONFIG.requestTimeoutSeconds * 1000),
|
||||
requestTimeoutMs: this.#requestTimeoutMs(),
|
||||
servers
|
||||
};
|
||||
}
|
||||
@@ -500,7 +507,7 @@ class MCPStore {
|
||||
}
|
||||
|
||||
addServer(
|
||||
serverData: Omit<MCPServerSettingsEntry, 'id' | 'requestTimeoutSeconds'> & { id?: string }
|
||||
serverData: Omit<MCPServerSettingsEntry, 'id'> & { id?: string }
|
||||
): MCPServerSettingsEntry {
|
||||
const servers = this.getServers();
|
||||
const newServer: MCPServerSettingsEntry = {
|
||||
@@ -509,8 +516,6 @@ class MCPStore {
|
||||
url: serverData.url.trim(),
|
||||
name: serverData.name,
|
||||
headers: serverData.headers?.trim() || undefined,
|
||||
requestTimeoutSeconds:
|
||||
Number(config().mcpRequestTimeoutSeconds) || DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
useProxy: serverData.useProxy
|
||||
};
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.MCP_SERVERS, JSON.stringify([...servers, newServer]));
|
||||
@@ -551,14 +556,6 @@ class MCPStore {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP servers selectable in chat-add UIs and the settings page,
|
||||
* in the order they were added to the config.
|
||||
*/
|
||||
get visibleMcpServers(): MCPServerSettingsEntry[] {
|
||||
return this.getServers().filter((server) => server.enabled);
|
||||
}
|
||||
|
||||
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
|
||||
if (!browser) {
|
||||
return false;
|
||||
@@ -1226,7 +1223,6 @@ class MCPStore {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
requestTimeoutSeconds: number;
|
||||
headers?: string;
|
||||
}[],
|
||||
skipIfChecked = true,
|
||||
@@ -1317,7 +1313,7 @@ class MCPStore {
|
||||
logs: []
|
||||
});
|
||||
|
||||
const timeoutMs = Math.round(server.requestTimeoutSeconds * 1000);
|
||||
const timeoutMs = this.#requestTimeoutMs();
|
||||
const headers = this.parseHeaders(server.headers);
|
||||
|
||||
try {
|
||||
|
||||
@@ -412,7 +412,8 @@ class ToolsStore {
|
||||
tools: { name: string; description?: string }[];
|
||||
}[] {
|
||||
const result: ReturnType<ToolsStore['getMcpToolsFromHealthChecks']> = [];
|
||||
for (const server of mcpStore.visibleMcpServers) {
|
||||
for (const server of mcpStore.getServers()) {
|
||||
if (!server.enabled) continue;
|
||||
const health = mcpStore.getHealthCheckState(server.id);
|
||||
if (health.status === HealthCheckStatus.SUCCESS && health.tools.length > 0) {
|
||||
result.push({
|
||||
|
||||
Vendored
-1
@@ -15,7 +15,6 @@ import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from '.
|
||||
export interface AgenticConfig {
|
||||
enabled: boolean;
|
||||
maxTurns: number;
|
||||
maxToolPreviewLines: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
-2
@@ -174,7 +174,6 @@ export interface HealthCheckParams {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
requestTimeoutSeconds: number;
|
||||
headers?: string;
|
||||
useProxy?: boolean;
|
||||
}
|
||||
@@ -220,7 +219,6 @@ export interface MCPServerDisplayInfo {
|
||||
|
||||
export type MCPServerSettingsEntry = MCPServerDisplayInfo & {
|
||||
enabled: boolean;
|
||||
requestTimeoutSeconds: number;
|
||||
headers?: string;
|
||||
iconUrl?: string;
|
||||
useProxy?: boolean;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
MimeTypeText
|
||||
} from '$lib/enums';
|
||||
import {
|
||||
DEFAULT_MCP_CONFIG,
|
||||
MCP_SERVER_ID_PREFIX,
|
||||
IMAGE_FILE_EXTENSION_REGEX,
|
||||
CODE_FILE_EXTENSION_REGEX,
|
||||
@@ -64,7 +63,6 @@ export function detectMcpTransportFromUrl(url: string): MCPTransportType {
|
||||
|
||||
/**
|
||||
* Parses MCP server settings from a JSON string or array.
|
||||
* Preserves per-server requestTimeoutSeconds if stored, otherwise falls back to the global default.
|
||||
* @param rawServers - The raw servers to parse
|
||||
* @returns An empty array if the input is invalid.
|
||||
*/
|
||||
@@ -103,9 +101,6 @@ export function parseMcpServerSettings(rawServers: unknown): MCPServerSettingsEn
|
||||
enabled: Boolean((entry as { enabled?: unknown })?.enabled),
|
||||
url,
|
||||
name: (entry as { name?: string })?.name,
|
||||
requestTimeoutSeconds:
|
||||
(entry as { requestTimeoutSeconds?: number })?.requestTimeoutSeconds ??
|
||||
DEFAULT_MCP_CONFIG.requestTimeoutSeconds,
|
||||
headers: headers || undefined,
|
||||
useProxy: Boolean((entry as { useProxy?: unknown })?.useProxy)
|
||||
} satisfies MCPServerSettingsEntry;
|
||||
|
||||
@@ -36,6 +36,11 @@ describe('parseModelId', () => {
|
||||
expect(parseModelId('model-100b:q4_k_m')).toMatchObject({ params: '100B' });
|
||||
});
|
||||
|
||||
it('extracts effective parameters correctly', () => {
|
||||
expect(parseModelId('model-E4B-BF16')).toMatchObject({ params: 'E4B' });
|
||||
expect(parseModelId('model-e2b:q4_k_m')).toMatchObject({ params: 'E2B' });
|
||||
});
|
||||
|
||||
it('extracts activated parameters correctly', () => {
|
||||
expect(parseModelId('model-100B-A10B-BF16')).toMatchObject({ activatedParams: 'A10B' });
|
||||
expect(parseModelId('model-100B-A10B:Q4_K_M')).toMatchObject({ activatedParams: 'A10B' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { parseMcpServerSettings } from '$lib/utils/mcp';
|
||||
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
|
||||
import { MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
|
||||
|
||||
/**
|
||||
* Tests for the mcpServers settings parser.
|
||||
@@ -58,24 +58,16 @@ describe('parseMcpServerSettings', () => {
|
||||
expect(parsed[2]?.id).toBe('custom-3');
|
||||
});
|
||||
|
||||
it('falls back to the configured default requestTimeoutSeconds only for nullish values', () => {
|
||||
const fallback = DEFAULT_MCP_CONFIG.requestTimeoutSeconds;
|
||||
|
||||
it('does not emit a per-server timeout, the request timeout is a live global setting', () => {
|
||||
// A stored per-server requestTimeoutSeconds was never editable in
|
||||
// any UI and froze the global setting at server creation time,
|
||||
// making the Settings value a no-op for existing servers. The
|
||||
// parser drops the field so the global applies live everywhere.
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([
|
||||
{ id: 'a', url: 'https://a.test' },
|
||||
{ id: 'b', url: 'https://b.test', requestTimeoutSeconds: undefined },
|
||||
{ id: 'c', url: 'https://c.test', requestTimeoutSeconds: 0 },
|
||||
{ id: 'd', url: 'https://d.test', requestTimeoutSeconds: 45 }
|
||||
])
|
||||
JSON.stringify([{ id: 'a', url: 'https://a.test', requestTimeoutSeconds: 45 }])
|
||||
);
|
||||
|
||||
// The parser uses ?? for timeout fallback, which only triggers on
|
||||
// null/undefined. Explicit 0 is preserved at face value.
|
||||
expect(parsed[0]?.requestTimeoutSeconds).toBe(fallback);
|
||||
expect(parsed[1]?.requestTimeoutSeconds).toBe(fallback);
|
||||
expect(parsed[2]?.requestTimeoutSeconds).toBe(0);
|
||||
expect(parsed[3]?.requestTimeoutSeconds).toBe(45);
|
||||
expect(parsed[0]).not.toHaveProperty('requestTimeoutSeconds');
|
||||
});
|
||||
|
||||
it('treats whitespace-only headers strings as undefined', () => {
|
||||
@@ -108,6 +100,22 @@ describe('parseMcpServerSettings', () => {
|
||||
expect(parsed[3]?.useProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps disabled entries in the list, enabled is state and never a visibility filter', () => {
|
||||
// Regression guard for issue #25625: filtering the server list on
|
||||
// `enabled` hides a toggled-off server from every UI surface with
|
||||
// no way to re-enable it. Any list derived from this parser must
|
||||
// contain disabled entries.
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([
|
||||
{ id: 'on', url: 'https://on.test', enabled: true },
|
||||
{ id: 'off', url: 'https://off.test', enabled: false }
|
||||
])
|
||||
);
|
||||
|
||||
expect(parsed.map((entry) => entry.id)).toEqual(['on', 'off']);
|
||||
expect(parsed[1]?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves input order when mapping entries', () => {
|
||||
const source = [
|
||||
{ id: 'gamma', url: 'https://c.test' },
|
||||
|
||||
Reference in New Issue
Block a user