mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-10 14:59:11 +02:00
539f24529b
* ui : open MCP servers in a dialog from the chat form Replace the MCP servers submenu with a single "MCP Servers" item that opens a new DialogMcpServers dialog instead of navigating to the /mcp-servers route. Assisted-by: pi * ui : browse MCP resources from the server card Make the Resources capability badge clickable so it opens the MCP resources browser dialog, and drop the page-only chrome from SettingsMcpServers. Assisted-by: pi * ui : remove mcp-servers route and sidebar entry MCP servers are now managed in a dialog, so drop the dedicated route and the sidebar icon that navigated to it. Assisted-by: pi * ui : remove unused MCP servers submenu component The submenu was replaced by the MCP servers dialog, so delete the component and its export. Assisted-by: pi * feat(ui): add DialogSettingsChat dialog * refactor(ui): switch SettingsChat to in-app section navigation * feat(ui): open settings as dialog from sidebar * refactor(ui): remove settings route and URL-based settings navigation * fix(ui): adjust MCP dialogs for new base sizing * chore: Formatting & linting
307 lines
8.5 KiB
Svelte
307 lines
8.5 KiB
Svelte
<script lang="ts">
|
|
import { browser } from '$app/environment';
|
|
import { McpServerCardCompact, McpServerForm } from '$lib/components/app/mcp';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import * as Dialog from '$lib/components/ui/dialog';
|
|
import {
|
|
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
|
|
HEADERS,
|
|
MCP_SERVER_ID_PREFIX,
|
|
RECOMMENDED_MCP_SERVERS
|
|
} from '$lib/constants';
|
|
import { BooleanString, HealthCheckStatus } from '$lib/enums';
|
|
import { conversationsStore, mcpStore } from '$lib/stores';
|
|
import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
}
|
|
|
|
let { onOpenChange, open = $bindable() }: Props = $props();
|
|
|
|
let newServerUrl = $state('');
|
|
let newServerName = $state('');
|
|
let nameAutoFilled = $state('');
|
|
let nameTouched = $state(false);
|
|
|
|
let previewRun = 0;
|
|
|
|
function handleNameChange(value: string) {
|
|
newServerName = value;
|
|
nameTouched = true;
|
|
}
|
|
let newServerHeaders = $state('');
|
|
let newServerUseProxy = $state(false);
|
|
|
|
let newServerWantsAuthorization = $state(false);
|
|
|
|
let selectedRecommendationId = $derived.by(() => {
|
|
const url = newServerUrl.trim();
|
|
|
|
if (!url) return null;
|
|
|
|
const targetCanonical = canonicalizeServerUrl(url);
|
|
|
|
return (
|
|
RECOMMENDED_MCP_SERVERS.find((rec) => canonicalizeServerUrl(rec.url) === targetCanonical)
|
|
?.id ?? null
|
|
);
|
|
});
|
|
let selectedRecommendation = $derived(
|
|
selectedRecommendationId
|
|
? (RECOMMENDED_MCP_SERVERS.find((rec) => rec.id === selectedRecommendationId) ?? null)
|
|
: null
|
|
);
|
|
let authRequired = $derived(selectedRecommendation?.needsAuthorization ?? false);
|
|
|
|
let bearerTokenFilled = $derived.by(() => {
|
|
const pairs = parseHeadersToArray(newServerHeaders);
|
|
const bearerPrefix = HEADERS.BEARER.toLowerCase();
|
|
const bearer = pairs.find(
|
|
(p) =>
|
|
HEADERS.REDACTED.has(p.key.trim().toLowerCase()) &&
|
|
p.value.trim().toLowerCase().startsWith(bearerPrefix)
|
|
);
|
|
|
|
if (!bearer) return false;
|
|
|
|
return bearer.value.trim().slice(bearerPrefix.length).trim().length > 0;
|
|
});
|
|
|
|
let newServerUrlError = $derived.by(() => {
|
|
if (!newServerUrl.trim()) return 'URL is required';
|
|
|
|
try {
|
|
new URL(newServerUrl);
|
|
|
|
return null;
|
|
} catch {
|
|
return 'Invalid URL format';
|
|
}
|
|
});
|
|
let newServerHeaderPairsValid = $derived(
|
|
parseHeadersToArray(newServerHeaders).every((p) => p.key.trim() && p.value.trim())
|
|
);
|
|
let canSave = $derived(
|
|
!newServerUrlError && newServerHeaderPairsValid && (!authRequired || bearerTokenFilled)
|
|
);
|
|
|
|
// Backward-compatible read: older versions stored a JSON array of dismissed ids.
|
|
function readRecommendationsDismissed(): boolean {
|
|
if (!browser) return false;
|
|
|
|
const raw = localStorage.getItem(DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY);
|
|
|
|
if (!raw) return false;
|
|
|
|
if (raw === BooleanString.TRUE) return true;
|
|
|
|
if (raw === BooleanString.FALSE) return false;
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
|
|
return Array.isArray(parsed) && parsed.length > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function writeRecommendationsDismissed(dismissed: boolean) {
|
|
recommendationsDismissed = dismissed;
|
|
|
|
if (browser) {
|
|
localStorage.setItem(
|
|
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
|
|
dismissed ? BooleanString.TRUE : BooleanString.FALSE
|
|
);
|
|
}
|
|
}
|
|
|
|
let recommendationsDismissed = $state<boolean>(readRecommendationsDismissed());
|
|
|
|
// Read-only once a recommendation is picked: switch is disabled, so we keep
|
|
// the Authorization field in sync with the requirement.
|
|
$effect(() => {
|
|
if (authRequired) {
|
|
newServerWantsAuthorization = true;
|
|
}
|
|
});
|
|
|
|
// Debounced preview handshake: once the URL is valid and stable, fetch the
|
|
// server-reported name to prefill the display name field. A manual edit
|
|
// freezes the autofill for good, and failures stay silent.
|
|
$effect(() => {
|
|
const url = newServerUrl.trim();
|
|
const headers = newServerHeaders.trim();
|
|
const useProxy = newServerUseProxy;
|
|
|
|
if (!open || newServerUrlError || !url) return;
|
|
|
|
const run = ++previewRun;
|
|
// One throwaway id per run: concurrent previews (URL typed, then the
|
|
// bearer token pasted) would poison each other's shared health state.
|
|
const previewId = `${MCP_SERVER_ID_PREFIX}-preview-${run}`;
|
|
const timer = setTimeout(async () => {
|
|
await mcpStore.runHealthCheck({
|
|
enabled: false,
|
|
headers: headers || undefined,
|
|
id: previewId,
|
|
url,
|
|
useProxy
|
|
});
|
|
|
|
const state = mcpStore.getHealthCheckState(previewId);
|
|
|
|
mcpStore.clearHealthCheck(previewId);
|
|
|
|
if (run !== previewRun) return;
|
|
|
|
if (state.status !== HealthCheckStatus.SUCCESS) return;
|
|
|
|
const autoName = state.serverInfo?.title || state.serverInfo?.name || '';
|
|
|
|
if (autoName && !nameTouched) {
|
|
newServerName = autoName;
|
|
nameAutoFilled = autoName;
|
|
}
|
|
}, 600);
|
|
|
|
return () => clearTimeout(timer);
|
|
});
|
|
|
|
let hasSelection = $derived(selectedRecommendationId !== null);
|
|
|
|
let unconfiguredRecommendations = $derived.by(() => {
|
|
const configuredCanonicals = new Set(
|
|
mcpStore.getServers().map((s) => canonicalizeServerUrl(s.url))
|
|
);
|
|
|
|
return RECOMMENDED_MCP_SERVERS.filter(
|
|
(rec) => !configuredCanonicals.has(canonicalizeServerUrl(rec.url))
|
|
);
|
|
});
|
|
|
|
let recommendationsToShow = $derived(recommendationsDismissed ? [] : unconfiguredRecommendations);
|
|
|
|
function handleRecommendationClick(recommendedId: string) {
|
|
const recommendation = RECOMMENDED_MCP_SERVERS.find((rec) => rec.id === recommendedId);
|
|
|
|
if (!recommendation) return;
|
|
|
|
newServerUrl = recommendation.url;
|
|
newServerHeaders = '';
|
|
newServerWantsAuthorization = recommendation.needsAuthorization ?? false;
|
|
}
|
|
|
|
function handleDismissAll() {
|
|
writeRecommendationsDismissed(true);
|
|
}
|
|
|
|
function handleOpenChange(value: boolean) {
|
|
if (!value) {
|
|
newServerUrl = '';
|
|
newServerName = '';
|
|
nameAutoFilled = '';
|
|
nameTouched = false;
|
|
previewRun++;
|
|
newServerHeaders = '';
|
|
newServerUseProxy = false;
|
|
newServerWantsAuthorization = false;
|
|
}
|
|
|
|
open = value;
|
|
onOpenChange?.(value);
|
|
}
|
|
|
|
function saveNewServer() {
|
|
if (!canSave) return;
|
|
|
|
const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
|
|
|
|
mcpStore.addServer({
|
|
// A name equal to the autofilled server-reported one is not a
|
|
// customization: keep following the automatic label.
|
|
displayName:
|
|
newServerName.trim() && newServerName.trim() !== nameAutoFilled.trim()
|
|
? newServerName.trim()
|
|
: undefined,
|
|
enabled: true,
|
|
headers: newServerHeaders.trim() || undefined,
|
|
id: newServerId,
|
|
url: newServerUrl.trim(),
|
|
useProxy: newServerUseProxy
|
|
});
|
|
|
|
conversationsStore.preferences.setMcpServerOverride(newServerId, true);
|
|
|
|
handleOpenChange(false);
|
|
}
|
|
|
|
function handleSubmit(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
saveNewServer();
|
|
}
|
|
</script>
|
|
|
|
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
|
<Dialog.Content class="max-w-2xl!">
|
|
<Dialog.Header>
|
|
<Dialog.Title class="select-none">Add New MCP Server</Dialog.Title>
|
|
</Dialog.Header>
|
|
|
|
{#if recommendationsToShow.length > 0}
|
|
<div class="space-y-3 pt-2">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<h3 class="text-sm font-medium">Recommended Servers</h3>
|
|
|
|
<Button class="text-muted-foreground" onclick={handleDismissAll} size="sm" variant="ghost"
|
|
>Dismiss</Button
|
|
>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
{#each recommendationsToShow as recommendation (recommendation.id)}
|
|
<McpServerCardCompact
|
|
dimmed={hasSelection && selectedRecommendationId !== recommendation.id}
|
|
onClick={() => handleRecommendationClick(recommendation.id)}
|
|
selected={selectedRecommendationId === recommendation.id}
|
|
server={recommendation}
|
|
/>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<form class="contents" onsubmit={handleSubmit}>
|
|
<div class="space-y-4 py-4">
|
|
<McpServerForm
|
|
bind:wantsAuthorization={newServerWantsAuthorization}
|
|
headers={newServerHeaders}
|
|
id="new-server"
|
|
name={newServerName}
|
|
onHeadersChange={(v) => (newServerHeaders = v)}
|
|
onNameChange={handleNameChange}
|
|
onUrlChange={(v) => (newServerUrl = v)}
|
|
onUseProxyChange={(v) => (newServerUseProxy = v)}
|
|
required={authRequired}
|
|
url={newServerUrl}
|
|
urlError={newServerUrl ? newServerUrlError : null}
|
|
useProxy={newServerUseProxy}
|
|
/>
|
|
</div>
|
|
|
|
<Dialog.Footer>
|
|
<Button onclick={() => handleOpenChange(false)} size="sm" variant="secondary">
|
|
Cancel
|
|
</Button>
|
|
|
|
<Button aria-label="Save" disabled={!canSave} size="sm" type="submit" variant="default">
|
|
Add
|
|
</Button>
|
|
</Dialog.Footer>
|
|
</form>
|
|
</Dialog.Content>
|
|
</Dialog.Root>
|