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:
Concedo
2026-07-15 15:39:31 +08:00
73 changed files with 6859 additions and 3973 deletions
@@ -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);
}
}
@@ -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}
@@ -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>
@@ -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}?
&nbsp;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
+1 -2
View File
@@ -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 = {
+3 -1
View File
@@ -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`.
-1
View File
@@ -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);
}
};
}
+24 -1
View File
@@ -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;
+1 -4
View File
@@ -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
};
}
+1 -1
View File
@@ -1334,7 +1334,7 @@ class ChatStore {
}
};
const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
{
const agenticResult = await agenticStore.runAgenticFlow({
+23 -30
View File
@@ -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 };
});
}
/**
+17 -21
View File
@@ -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 {
+2 -1
View File
@@ -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({
-1
View File
@@ -15,7 +15,6 @@ import type { DatabaseMessage, DatabaseMessageExtra, McpServerOverride } from '.
export interface AgenticConfig {
enabled: boolean;
maxTurns: number;
maxToolPreviewLines: number;
}
/**
-2
View File
@@ -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;
-5
View File
@@ -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' },