Files
koboldcpp/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte
T
Aleksander Grygier 32beb244f5 ui: Agentic Content UX improvements (#25450)
* feat: Add shimmer text animation for processing state indicators

* feat: Redesign CollapsibleContentBlock component with improved UX

* feat: Add conditional setting display support with dependsOn field

* feat: Add showAgenticTurnStats setting for per-turn statistics

* feat: Update ChatMessageAgenticContent with improved UI and new features

* feat: Enhance file read tool UI/UX

* feat: Refine styling of collapsible content and code preview blocks

* feat: add terminal variant to CollapsibleContentBlock

* feat: add built-in tools UI registry

* feat: extract ChatMessageReasoningBlock and ChatMessageToolCallBlock

* refactor: simplify ChatMessageAgenticContent to use extracted blocks

* fix: correct markdown content block margin spacing

* fix: reorganize SettingsChatFields layout and reset button positioning

* fix: use direct map access in agentic store session methods

* refactor: remove reasoning preview/throttle system from CollapsibleContentBlock

* feat: add auto-scroll to reasoning block and remove showThoughtInProgress

* feat: add ChatMessageToolCallDateTime component and support for new tool types

* feat: improve auto-scroll reliability in reasoning block with RAF coalescing and MutationObserver

* feat: show MCP server favicon for tools without a built-in icon

* feat: add search-results parsing utilities and tests

* feat: add ChatMessageToolCallSearchResults component

* feat: integrate search results rendering into ChatMessageAgenticContent

* feat: display tool call input alongside output in ChatMessageToolCallBlock

* style: use muted foreground color in reasoning block content

* chore: Format

* feat: Refine reasoning block layout and make pending thoughts display configurable

* feat: Stream tool call code blocks with auto-scroll and handle partial JSON

* feat: add streaming permission gate infrastructure

* feat: wire permission gate into the agentic loop

* fix: bail out on abort and skip already-approved tool calls

* fix: clear partial tool calls on abort and savePartialResponse

* test: cover partial tool call cleanup end-to-end

* refactor: Remove streaming permission gate logic

* fix: Correct autoscroll and streaming gates for tool calls and reasoning blocks

* refactor: Chat Message Assistant componentization

* fix: Show health metadata for disabled MCP servers and promote connections on enable

* fix: Inherit global enabled state for missing MCP per-chat overrides

* refactor: Cleanup

* refactor: Split ChatMessageToolCallBlock into dedicated components

* feat: Add live streaming and auto-scroll for tool execution output

* feat: Add line numbers and change markers to file edit diffs

* chore: Formatting

* feat: Add type definitions and utilities for recommended MCP servers

* feat: Add recommended MCP servers configuration and storage key

* feat: Add McpServerCardCompact component for recommended servers

* feat: Add recommended servers section to Add New Server dialog

* feat: Update McpServerForm to support authorization requirements

* feat: Add select-none classes for text selection prevention

* feat: Add recommended MCP server icon assets

* refactor: Store dismissed MCP recommendations as a boolean flag

* feat: Render tool results as JSON or Markdown based on detected content type

* feat: UI improvement

* feat: Render search block early and update heading to show execution state

* fix: Prevent non-web-search tools from triggering the search UI block

* refactor: Cleanup

* refactor: Extract hardcoded icon size classes into shared constants

* refactor: Extract hardcoded tool result separator into a shared constant

* refactor: Tool Calls UI/logic

* refactor: Cleanup

* refactor: Cleanup

* refactor: Cleanup
2026-07-15 20:31:45 +02:00

237 lines
6.7 KiB
Svelte

<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { McpServerCardCompact, McpServerForm } from '$lib/components/app/mcp';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { parseHeadersToArray, uuid, canonicalizeServerUrl } from '$lib/utils';
import {
BEARER_PREFIX,
BOOL_FALSE_STRING,
BOOL_TRUE_STRING,
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
MCP_SERVER_ID_PREFIX,
RECOMMENDED_MCP_SERVERS,
REDACTED_HEADERS
} from '$lib/constants';
import { browser } from '$app/environment';
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
}
let { open = $bindable(), onOpenChange }: Props = $props();
let newServerUrl = $state('');
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 = BEARER_PREFIX.toLowerCase();
const bearer = pairs.find(
(p) =>
REDACTED_HEADERS.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 === BOOL_TRUE_STRING) return true;
if (raw === BOOL_FALSE_STRING) 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 ? BOOL_TRUE_STRING : BOOL_FALSE_STRING
);
}
}
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;
}
});
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 = '';
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({
id: newServerId,
enabled: true,
url: newServerUrl.trim(),
headers: newServerHeaders.trim() || undefined,
useProxy: newServerUseProxy
});
conversationsStore.setMcpServerOverride(newServerId, true);
handleOpenChange(false);
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
saveNewServer();
}
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content class="sm: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" variant="ghost" size="sm" onclick={handleDismissAll}
>Dismiss</Button
>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{#each recommendationsToShow as recommendation (recommendation.id)}
<McpServerCardCompact
server={recommendation}
onClick={() => handleRecommendationClick(recommendation.id)}
selected={selectedRecommendationId === recommendation.id}
dimmed={hasSelection && selectedRecommendationId !== recommendation.id}
/>
{/each}
</div>
</div>
{/if}
<form onsubmit={handleSubmit} class="contents">
<div class="space-y-4 py-4">
<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"
bind:wantsAuthorization={newServerWantsAuthorization}
required={authRequired}
/>
</div>
<Dialog.Footer>
<Button variant="secondary" size="sm" onclick={() => handleOpenChange(false)}>
Cancel
</Button>
<Button variant="default" size="sm" type="submit" disabled={!canSave} aria-label="Save">
Add
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>