webui: Remove Google Favicons & Improve MCP Information logic & UI (#22719)
* refactor: Remove Google favicon utility * fix: MCP Server favicon * refactor: Cleanup * refactor: MCP Server Information * fix: Fix MCP Settings UI * refactor: Cleanup
This commit is contained in:
committed by
GitHub
parent
f08f20a0e3
commit
e3e3f8e46a
File diff suppressed because one or more lines are too long
+1082
-1079
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -2,7 +2,7 @@
|
|||||||
import { Settings, Plus } from '@lucide/svelte';
|
import { Settings, Plus } from '@lucide/svelte';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import { McpLogo, DropdownMenuSearchable } from '$lib/components/app';
|
import { McpLogo, DropdownMenuSearchable, McpServerIdentity } from '$lib/components/app';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { HealthCheckStatus } from '$lib/enums';
|
import { HealthCheckStatus } from '$lib/enums';
|
||||||
@@ -77,6 +77,8 @@
|
|||||||
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
{@const healthState = mcpStore.getHealthCheckState(server.id)}
|
||||||
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
|
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
|
||||||
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
|
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
|
||||||
|
{@const displayName = getServerLabel(server)}
|
||||||
|
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -85,18 +87,16 @@
|
|||||||
disabled={hasError}
|
disabled={hasError}
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||||
{#if mcpStore.getServerFavicon(server.id)}
|
<div class="min-w-0 flex-1">
|
||||||
<img
|
<McpServerIdentity
|
||||||
src={mcpStore.getServerFavicon(server.id)}
|
{displayName}
|
||||||
alt=""
|
{faviconUrl}
|
||||||
class="h-4 w-4 shrink-0 rounded-sm"
|
iconClass="h-4 w-4"
|
||||||
onerror={(e) => {
|
iconRounded="rounded-sm"
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
showVersion={false}
|
||||||
}}
|
nameClass="text-sm"
|
||||||
/>
|
/>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
<span class="truncate text-sm">{getServerLabel(server)}</span>
|
|
||||||
|
|
||||||
{#if hasError}
|
{#if hasError}
|
||||||
<span
|
<span
|
||||||
|
|||||||
+13
-17
@@ -12,6 +12,7 @@
|
|||||||
sortTreeChildren
|
sortTreeChildren
|
||||||
} from './mcp-resources-browser';
|
} from './mcp-resources-browser';
|
||||||
import { getDisplayName, getResourceIcon } from '$lib/utils';
|
import { getDisplayName, getResourceIcon } from '$lib/utils';
|
||||||
|
import { McpServerIdentity } from '$lib/components/app/mcp';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverName: string;
|
serverName: string;
|
||||||
@@ -43,11 +44,12 @@
|
|||||||
searchQuery = ''
|
searchQuery = ''
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let serverDisplayName = $derived(mcpStore.getServerDisplayName(serverName));
|
||||||
|
let serverFaviconUrl = $derived(mcpStore.getServerFavicon(serverName));
|
||||||
|
|
||||||
const hasResources = $derived(serverRes.resources.length > 0);
|
const hasResources = $derived(serverRes.resources.length > 0);
|
||||||
const hasTemplates = $derived(serverRes.templates.length > 0);
|
const hasTemplates = $derived(serverRes.templates.length > 0);
|
||||||
const hasContent = $derived(hasResources || hasTemplates);
|
const hasContent = $derived(hasResources || hasTemplates);
|
||||||
const displayName = $derived(mcpStore.getServerDisplayName(serverName));
|
|
||||||
const favicon = $derived(mcpStore.getServerFavicon(serverName));
|
|
||||||
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName, searchQuery));
|
const resourceTree = $derived(buildResourceTree(serverRes.resources, serverName, searchQuery));
|
||||||
|
|
||||||
const templateInfos = $derived<MCPResourceTemplateInfo[]>(
|
const templateInfos = $derived<MCPResourceTemplateInfo[]>(
|
||||||
@@ -153,21 +155,15 @@
|
|||||||
<ChevronRight class="h-3.5 w-3.5" />
|
<ChevronRight class="h-3.5 w-3.5" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="inline-flex flex-col items-start text-left">
|
<span class="inline-flex flex-col items-start gap-1 text-left">
|
||||||
<span class="inline-flex items-center justify-start gap-1.5 font-medium">
|
<div class="inline-flex min-w-0 items-center gap-1.5">
|
||||||
{#if favicon}
|
<McpServerIdentity
|
||||||
<img
|
displayName={serverDisplayName}
|
||||||
src={favicon}
|
faviconUrl={serverFaviconUrl}
|
||||||
alt=""
|
iconClass="h-4 w-4"
|
||||||
class="h-4 w-4 shrink-0 rounded-sm"
|
showVersion={false}
|
||||||
onerror={(e) => {
|
/>
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
</div>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{displayName}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
({serverRes.resources.length} resource{serverRes.resources.length !== 1
|
({serverRes.resources.length} resource{serverRes.resources.length !== 1
|
||||||
|
|||||||
@@ -17,17 +17,17 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
server: MCPServerSettingsEntry;
|
server: MCPServerSettingsEntry;
|
||||||
faviconUrl: string | null;
|
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
onToggle: (enabled: boolean) => void;
|
onToggle: (enabled: boolean) => void;
|
||||||
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
|
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { server, faviconUrl, enabled, onToggle, onUpdate, onDelete }: Props = $props();
|
let { server, enabled, onToggle, onUpdate, onDelete }: Props = $props();
|
||||||
|
|
||||||
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
|
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
|
||||||
let displayName = $derived(mcpStore.getServerLabel(server));
|
let displayName = $derived(mcpStore.getServerLabel(server));
|
||||||
|
let faviconUrl = $derived(mcpStore.getServerFavicon(server.id));
|
||||||
let isIdle = $derived(healthState.status === HealthCheckStatus.IDLE);
|
let isIdle = $derived(healthState.status === HealthCheckStatus.IDLE);
|
||||||
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.CONNECTING);
|
let isHealthChecking = $derived(healthState.status === HealthCheckStatus.CONNECTING);
|
||||||
let isConnected = $derived(healthState.status === HealthCheckStatus.SUCCESS);
|
let isConnected = $derived(healthState.status === HealthCheckStatus.SUCCESS);
|
||||||
|
|||||||
+12
-39
@@ -1,15 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Cable, ExternalLink } from '@lucide/svelte';
|
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { McpCapabilitiesBadges } from '$lib/components/app/mcp';
|
import { McpCapabilitiesBadges, McpServerIdentity } from '$lib/components/app/mcp';
|
||||||
import { MCP_TRANSPORT_LABELS, MCP_TRANSPORT_ICONS } from '$lib/constants';
|
import { MCP_TRANSPORT_LABELS, MCP_TRANSPORT_ICONS } from '$lib/constants';
|
||||||
import { MCPTransportType } from '$lib/enums';
|
import { MCPTransportType } from '$lib/enums';
|
||||||
import type { MCPServerInfo, MCPCapabilitiesInfo } from '$lib/types';
|
import type { MCPServerInfo, MCPCapabilitiesInfo } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
faviconUrl: string | null;
|
faviconUrl?: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onToggle: (enabled: boolean) => void;
|
onToggle: (enabled: boolean) => void;
|
||||||
@@ -32,42 +31,16 @@
|
|||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="grid min-w-0 gap-3">
|
<div class="flex min-w-0 flex-col gap-3">
|
||||||
<div class="flex items-center gap-2 overflow-hidden">
|
<div class="inline-flex items-center gap-2">
|
||||||
{#if faviconUrl}
|
<McpServerIdentity
|
||||||
<img
|
{displayName}
|
||||||
src={faviconUrl}
|
{faviconUrl}
|
||||||
alt=""
|
{serverInfo}
|
||||||
class="h-5 w-5 shrink-0 rounded"
|
iconClass="h-5 w-5"
|
||||||
onerror={(e) => {
|
iconRounded="rounded"
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
nameClass="leading-6 font-medium"
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted">
|
|
||||||
<Cable class="h-3 w-3 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<p class="min-w-0 shrink-0 truncate leading-none font-medium">{displayName}</p>
|
|
||||||
|
|
||||||
{#if serverInfo?.version}
|
|
||||||
<Badge variant="secondary" class="h-4 min-w-0 truncate px-1 text-[10px]">
|
|
||||||
v{serverInfo.version}
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if serverInfo?.websiteUrl}
|
|
||||||
<a
|
|
||||||
href={serverInfo.websiteUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
|
||||||
aria-label="Open website"
|
|
||||||
>
|
|
||||||
<ExternalLink class="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if capabilities || transportType}
|
{#if capabilities || transportType}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ExternalLink } from '@lucide/svelte';
|
||||||
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
|
import { TruncatedText } from '$lib/components/app/misc';
|
||||||
|
import { sanitizeExternalUrl } from '$lib/utils';
|
||||||
|
import type { MCPServerInfo } from '$lib/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
displayName?: string;
|
||||||
|
faviconUrl?: string | null;
|
||||||
|
serverInfo?: MCPServerInfo;
|
||||||
|
iconClass?: string;
|
||||||
|
iconRounded?: string;
|
||||||
|
showVersion?: boolean;
|
||||||
|
showWebsite?: boolean;
|
||||||
|
nameClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
displayName,
|
||||||
|
faviconUrl = null,
|
||||||
|
serverInfo,
|
||||||
|
iconClass = 'h-5 w-5',
|
||||||
|
iconRounded = 'rounded-sm',
|
||||||
|
showVersion = true,
|
||||||
|
showWebsite = true,
|
||||||
|
nameClass
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let safeWebsiteUrl = $derived(
|
||||||
|
serverInfo?.websiteUrl ? sanitizeExternalUrl(serverInfo.websiteUrl) : null
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="flex min-w-0 items-center gap-1.5">
|
||||||
|
{#if faviconUrl}
|
||||||
|
<img
|
||||||
|
src={faviconUrl}
|
||||||
|
alt=""
|
||||||
|
class={['shrink-0', iconRounded, iconClass]}
|
||||||
|
onerror={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<TruncatedText text={displayName ?? ''} class={nameClass ?? ''} />
|
||||||
|
|
||||||
|
{#if showVersion && serverInfo?.version}
|
||||||
|
<Badge variant="secondary" class="h-4 min-w-0 shrink px-1 text-[10px]">
|
||||||
|
<TruncatedText text={`v${serverInfo.version}`} />
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showWebsite && safeWebsiteUrl}
|
||||||
|
<a
|
||||||
|
href={safeWebsiteUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Open website"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ExternalLink class="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
@@ -180,6 +180,25 @@ export { default as McpServerCardDeleteDialog } from './McpServerCard/McpServerC
|
|||||||
/** Skeleton loading state for server card during health checks. */
|
/** Skeleton loading state for server card during health checks. */
|
||||||
export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
|
export { default as McpServerCardSkeleton } from './McpServerCardSkeleton.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **McpServerIdentity** - Server identity display (icon, name, version)
|
||||||
|
*
|
||||||
|
* Reusable headless component for displaying server name, favicon/icon, and version badge.
|
||||||
|
* Accepts all data via props with no store dependencies for predictable rendering.
|
||||||
|
*
|
||||||
|
* **Features:**
|
||||||
|
* - Server favicon/icon with fallback
|
||||||
|
* - Truncated display name with max-width
|
||||||
|
* - Optional version badge (v1.2.3)
|
||||||
|
* - Optional external link to server website
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <McpServerIdentity displayName={name} faviconUrl={iconUrl} serverInfo={info} />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export { default as McpServerIdentity } from './McpServerIdentity.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* **McpServerInfo** - Server instructions display
|
* **McpServerInfo** - Server instructions display
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
{#if isTruncated && showTooltip}
|
{#if isTruncated && showTooltip}
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger class={className}>
|
<Tooltip.Trigger class="{className} min-w-0">
|
||||||
<span bind:this={textElement} class="block truncate">
|
<span bind:this={textElement} class="block truncate">
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
{:else}
|
{:else}
|
||||||
<span bind:this={textElement} class="{className} block truncate">
|
<span bind:this={textElement} class="{className} block min-w-0 truncate">
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@
|
|||||||
>
|
>
|
||||||
<Package class="h-3.5 w-3.5" />
|
<Package class="h-3.5 w-3.5" />
|
||||||
|
|
||||||
<TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" />
|
<TruncatedText text={selectedOption?.model || ''} class="font-medium" />
|
||||||
|
|
||||||
{#if ms.updating}
|
{#if ms.updating}
|
||||||
<Loader2 class="h-3 w-3.5 animate-spin" />
|
<Loader2 class="h-3 w-3.5 animate-spin" />
|
||||||
|
|||||||
+11
-29
@@ -2,28 +2,15 @@
|
|||||||
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
import { ChevronDown, ChevronRight } from '@lucide/svelte';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||||
import { TruncatedText } from '$lib/components/app';
|
import { TruncatedText, McpServerIdentity } from '$lib/components/app';
|
||||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||||
import { permissionsStore } from '$lib/stores/permissions.svelte';
|
import { permissionsStore } from '$lib/stores/permissions.svelte';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { ToolSource } from '$lib/enums';
|
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
let expandedGroups = new SvelteSet<string>();
|
let expandedGroups = new SvelteSet<string>();
|
||||||
let groups = $derived(toolsStore.toolGroups);
|
let groups = $derived(toolsStore.toolGroups);
|
||||||
|
|
||||||
function getFavicon(group: { source: ToolSource; label: string }): string | null {
|
|
||||||
if (group.source !== ToolSource.MCP) return null;
|
|
||||||
|
|
||||||
for (const server of mcpStore.getServersSorted()) {
|
|
||||||
if (mcpStore.getServerLabel(server) === group.label) {
|
|
||||||
return mcpStore.getServerFavicon(server.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleExpanded(label: string) {
|
function toggleExpanded(label: string) {
|
||||||
if (expandedGroups.has(label)) {
|
if (expandedGroups.has(label)) {
|
||||||
expandedGroups.delete(label);
|
expandedGroups.delete(label);
|
||||||
@@ -39,8 +26,6 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each groups as group (group.label)}
|
{#each groups as group (group.label)}
|
||||||
{@const isExpanded = expandedGroups.has(group.label)}
|
{@const isExpanded = expandedGroups.has(group.label)}
|
||||||
{@const favicon = getFavicon(group)}
|
|
||||||
|
|
||||||
<Collapsible.Root open={isExpanded} onOpenChange={() => toggleExpanded(group.label)}>
|
<Collapsible.Root open={isExpanded} onOpenChange={() => toggleExpanded(group.label)}>
|
||||||
<Collapsible.Trigger
|
<Collapsible.Trigger
|
||||||
class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm hover:bg-muted/50"
|
class="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm hover:bg-muted/50"
|
||||||
@@ -51,19 +36,16 @@
|
|||||||
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
|
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
|
{@const faviconUrl = group.serverId ? mcpStore.getServerFavicon(group.serverId) : null}
|
||||||
{#if favicon}
|
|
||||||
<img
|
|
||||||
src={favicon}
|
|
||||||
alt=""
|
|
||||||
class="h-4 w-4 shrink-0 rounded-sm"
|
|
||||||
onerror={(e) => {
|
|
||||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span class="truncate">{group.label}</span>
|
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
|
||||||
|
<McpServerIdentity
|
||||||
|
iconClass="h-4 w-4"
|
||||||
|
iconRounded="rounded-sm"
|
||||||
|
showVersion={false}
|
||||||
|
displayName={group.label}
|
||||||
|
{faviconUrl}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
|
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||||
@@ -89,7 +71,7 @@
|
|||||||
: false}
|
: false}
|
||||||
|
|
||||||
<div class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50">
|
<div class="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50">
|
||||||
<TruncatedText text={toolName} class="min-w-0 flex-1 truncate" showTooltip={true} />
|
<TruncatedText text={toolName} class="flex-1" showTooltip={true} />
|
||||||
|
|
||||||
<div class="flex w-16 shrink-0 justify-center">
|
<div class="flex w-16 shrink-0 justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -54,14 +54,14 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div in:fade={{ duration: 150 }} class="max-h-full overflow-auto">
|
<div in:fade={{ duration: 150 }} class="h-full max-h-[100dvh] overflow-y-auto">
|
||||||
<div class="flex items-center gap-2 p-4 md:absolute md:top-8 md:left-8 md:px-0 md:py-2">
|
<div class="flex items-center gap-2 p-4 md:absolute md:top-8 md:left-8 md:px-0 md:py-2">
|
||||||
<McpLogo class="h-5 w-5 md:h-6 md:w-6" />
|
<McpLogo class="h-5 w-5 md:h-6 md:w-6" />
|
||||||
|
|
||||||
<h1 class="text-xl font-semibold md:text-2xl">MCP Servers</h1>
|
<h1 class="text-xl font-semibold md:text-2xl">MCP Servers</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sticky top-0 z-10 mt-4 flex items-start justify-end gap-4 px-8 py-4">
|
<div class="sticky top-0 z-10 mt-4 flex items-start gap-4 p-4 md:justify-end md:px-8">
|
||||||
<Button variant="outline" size="sm" class="shrink-0" onclick={() => (isAddingServer = true)}>
|
<Button variant="outline" size="sm" class="shrink-0" onclick={() => (isAddingServer = true)}>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
|
|
||||||
@@ -89,7 +89,6 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<McpServerCard
|
<McpServerCard
|
||||||
{server}
|
{server}
|
||||||
faviconUrl={mcpStore.getServerFavicon(server.id)}
|
|
||||||
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
|
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
|
||||||
onToggle={async () => {
|
onToggle={async () => {
|
||||||
const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
|
const wasEnabled = conversationsStore.isMcpServerEnabledForChat(server.id);
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export const GOOGLE_FAVICON_BASE_URL = 'https://www.google.com/s2/favicons';
|
|
||||||
export const DEFAULT_FAVICON_SIZE = 32;
|
|
||||||
export const DOMAIN_SEPARATOR = '.';
|
|
||||||
export const ROOT_DOMAIN_MIN_PARTS = 2;
|
|
||||||
@@ -13,7 +13,6 @@ export * from './code-blocks';
|
|||||||
export * from './code';
|
export * from './code';
|
||||||
export * from './context-keys';
|
export * from './context-keys';
|
||||||
export * from './css-classes';
|
export * from './css-classes';
|
||||||
export * from './favicon';
|
|
||||||
export * from './floating-ui-constraints';
|
export * from './floating-ui-constraints';
|
||||||
export * from './formatters';
|
export * from './formatters';
|
||||||
export * from './key-value-pairs';
|
export * from './key-value-pairs';
|
||||||
@@ -40,4 +39,5 @@ export * from './tools';
|
|||||||
export * from './tooltip-config';
|
export * from './tooltip-config';
|
||||||
export * from './ui';
|
export * from './ui';
|
||||||
export * from './uri-template';
|
export * from './uri-template';
|
||||||
|
export * from './url';
|
||||||
export * from './viewport';
|
export * from './viewport';
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ export const MCP_ALLOWED_ICON_MIME_TYPES = new Set([
|
|||||||
MimeTypeImage.JPEG,
|
MimeTypeImage.JPEG,
|
||||||
MimeTypeImage.JPG,
|
MimeTypeImage.JPG,
|
||||||
MimeTypeImage.SVG,
|
MimeTypeImage.SVG,
|
||||||
MimeTypeImage.WEBP
|
MimeTypeImage.WEBP,
|
||||||
|
MimeTypeImage.ICO,
|
||||||
|
MimeTypeImage.ICO_MICROSOFT
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
const STD = ['com', 'net', 'org', 'gov', 'edu'] as const;
|
||||||
|
|
||||||
|
const STD_MIL = [...STD, 'mil'] as const;
|
||||||
|
|
||||||
|
const ccTLD_PREFIXES: Record<string, readonly string[]> = {
|
||||||
|
// --- Standard 5 only ---
|
||||||
|
ar: STD,
|
||||||
|
bd: STD,
|
||||||
|
bg: STD,
|
||||||
|
cn: STD_MIL,
|
||||||
|
eg: STD,
|
||||||
|
gr: STD,
|
||||||
|
hk: STD,
|
||||||
|
hr: STD,
|
||||||
|
lk: STD,
|
||||||
|
mx: STD_MIL,
|
||||||
|
my: STD_MIL,
|
||||||
|
ng: STD,
|
||||||
|
ph: STD,
|
||||||
|
pk: STD,
|
||||||
|
pl: STD,
|
||||||
|
ro: STD,
|
||||||
|
ru: STD,
|
||||||
|
sa: STD,
|
||||||
|
si: STD,
|
||||||
|
tr: STD,
|
||||||
|
tw: STD,
|
||||||
|
ua: STD,
|
||||||
|
ve: STD,
|
||||||
|
|
||||||
|
au: [...STD_MIL, 'id', 'asn', 'csiro'],
|
||||||
|
br: [
|
||||||
|
...STD_MIL,
|
||||||
|
'art',
|
||||||
|
'eco',
|
||||||
|
'eng',
|
||||||
|
'inf',
|
||||||
|
'med',
|
||||||
|
'psi',
|
||||||
|
'tmp',
|
||||||
|
'etc',
|
||||||
|
'adm',
|
||||||
|
'adv',
|
||||||
|
'arq',
|
||||||
|
'bio',
|
||||||
|
'bmd',
|
||||||
|
'cim',
|
||||||
|
'cng',
|
||||||
|
'cnt',
|
||||||
|
'coop',
|
||||||
|
'ecn',
|
||||||
|
'esp',
|
||||||
|
'far',
|
||||||
|
'fm',
|
||||||
|
'fnd',
|
||||||
|
'fot',
|
||||||
|
'fst',
|
||||||
|
'g12',
|
||||||
|
'ggf',
|
||||||
|
'imb',
|
||||||
|
'ind',
|
||||||
|
'jor',
|
||||||
|
'jus',
|
||||||
|
'leg',
|
||||||
|
'lel',
|
||||||
|
'mat',
|
||||||
|
'mp',
|
||||||
|
'mus',
|
||||||
|
'not',
|
||||||
|
'ntr',
|
||||||
|
'odo',
|
||||||
|
'ppg',
|
||||||
|
'pro',
|
||||||
|
'psc',
|
||||||
|
'qsl',
|
||||||
|
'rec',
|
||||||
|
'slg',
|
||||||
|
'srv',
|
||||||
|
'trd',
|
||||||
|
'tur',
|
||||||
|
'tv',
|
||||||
|
'vet',
|
||||||
|
'vlog',
|
||||||
|
'wiki',
|
||||||
|
'zlg'
|
||||||
|
],
|
||||||
|
id: [...STD_MIL, 'co', 'go', 'or', 'web', 'sch'],
|
||||||
|
in: [...STD_MIL, 'co', 'gen', 'ind', 'firm', 'ernet', 'nic'],
|
||||||
|
kr: [...STD_MIL, 'co', 'go', 'or', 'ac', 're'],
|
||||||
|
nz: [
|
||||||
|
...STD_MIL,
|
||||||
|
'co',
|
||||||
|
'gen',
|
||||||
|
'geek',
|
||||||
|
'kiwi',
|
||||||
|
'maori',
|
||||||
|
'school',
|
||||||
|
'govt',
|
||||||
|
'health',
|
||||||
|
'iwi',
|
||||||
|
'parliament'
|
||||||
|
],
|
||||||
|
sg: [...STD, 'per'],
|
||||||
|
th: ['co', 'go', 'or', 'in', 'ac', 'mi', 'net'],
|
||||||
|
|
||||||
|
ae: ['co', 'net', 'org', 'gov', 'ac', 'sch'],
|
||||||
|
hu: ['co', 'net', 'org', 'gov', 'edu'],
|
||||||
|
il: ['co', 'net', 'org', 'gov', 'ac', 'muni'],
|
||||||
|
jp: ['ac', 'ad', 'co', 'ed', 'go', 'gr', 'lg', 'ne', 'or'],
|
||||||
|
ke: ['co', 'or', 'ne', 'go', 'ac', 'sc'],
|
||||||
|
rs: ['co', 'net', 'org', 'gov', 'edu'],
|
||||||
|
uk: ['co', 'org', 'net', 'ac', 'gov', 'mil', 'nhs', 'police', 'mod', 'ltd', 'plc', 'me', 'sch'],
|
||||||
|
za: ['co', 'org', 'net', 'web', 'law', 'mil']
|
||||||
|
};
|
||||||
|
|
||||||
|
const WILDCARD_BASES: Record<string, readonly string[]> = {
|
||||||
|
br: ['nom', 'blog'],
|
||||||
|
jp: [
|
||||||
|
'kobe',
|
||||||
|
'kyoto',
|
||||||
|
'nagoya',
|
||||||
|
'osaka',
|
||||||
|
'sapporo',
|
||||||
|
'sendai',
|
||||||
|
'tokyo',
|
||||||
|
'yokohama',
|
||||||
|
'aichi',
|
||||||
|
'akita',
|
||||||
|
'aomori',
|
||||||
|
'chiba',
|
||||||
|
'ehime',
|
||||||
|
'fukui',
|
||||||
|
'fukuoka',
|
||||||
|
'fukushima',
|
||||||
|
'gifu',
|
||||||
|
'gunma',
|
||||||
|
'hiroshima',
|
||||||
|
'hokkaido',
|
||||||
|
'hyogo',
|
||||||
|
'ibaraki',
|
||||||
|
'ishikawa',
|
||||||
|
'iwate',
|
||||||
|
'kagawa',
|
||||||
|
'kagoshima',
|
||||||
|
'kanagawa',
|
||||||
|
'kochi',
|
||||||
|
'kumamoto',
|
||||||
|
'mie',
|
||||||
|
'miyagi',
|
||||||
|
'miyazaki',
|
||||||
|
'nagano',
|
||||||
|
'nara',
|
||||||
|
'niigata',
|
||||||
|
'oita',
|
||||||
|
'okayama',
|
||||||
|
'okinawa',
|
||||||
|
'saga',
|
||||||
|
'saitama',
|
||||||
|
'shiga',
|
||||||
|
'shimane',
|
||||||
|
'shizuoka',
|
||||||
|
'tochigi',
|
||||||
|
'tokushima',
|
||||||
|
'tottori',
|
||||||
|
'toyama',
|
||||||
|
'wakayama',
|
||||||
|
'yamagata',
|
||||||
|
'yamaguchi',
|
||||||
|
'yamanashi'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildSuffixSet(suffixes: Record<string, readonly string[]>): Set<string> {
|
||||||
|
const set = new Set<string>();
|
||||||
|
|
||||||
|
for (const [tld, parts] of Object.entries(suffixes)) {
|
||||||
|
for (const part of parts) {
|
||||||
|
set.add(`${part}.${tld}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TWO_PART_PUBLIC_SUFFIXES = buildSuffixSet(ccTLD_PREFIXES);
|
||||||
|
export const WILDCARD_PUBLIC_SUFFIXES = buildSuffixSet(WILDCARD_BASES);
|
||||||
@@ -182,7 +182,9 @@ export enum MimeTypeImage {
|
|||||||
PNG = 'image/png',
|
PNG = 'image/png',
|
||||||
GIF = 'image/gif',
|
GIF = 'image/gif',
|
||||||
WEBP = 'image/webp',
|
WEBP = 'image/webp',
|
||||||
SVG = 'image/svg+xml'
|
SVG = 'image/svg+xml',
|
||||||
|
ICO = 'image/x-icon',
|
||||||
|
ICO_MICROSOFT = 'image/vnd.microsoft.icon'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MimeTypeText {
|
export enum MimeTypeText {
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ export enum McpPromptVariant {
|
|||||||
*/
|
*/
|
||||||
export enum UrlProtocol {
|
export enum UrlProtocol {
|
||||||
DATA = 'data:',
|
DATA = 'data:',
|
||||||
HTTP = 'http://',
|
HTTP = 'http:',
|
||||||
HTTPS = 'https://',
|
HTTPS = 'https:',
|
||||||
WEBSOCKET = 'ws://',
|
WEBSOCKET = 'ws:',
|
||||||
WEBSOCKET_SECURE = 'wss://'
|
WEBSOCKET_SECURE = 'wss:'
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum HtmlInputType {
|
export enum HtmlInputType {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from '$lib/enums';
|
} from '$lib/enums';
|
||||||
import type {
|
import type {
|
||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
|
MCPResourceIcon,
|
||||||
ToolCallParams,
|
ToolCallParams,
|
||||||
ToolExecutionResult,
|
ToolExecutionResult,
|
||||||
Implementation,
|
Implementation,
|
||||||
@@ -469,10 +470,11 @@ export class MCPService {
|
|||||||
title: impl.title,
|
title: impl.title,
|
||||||
description: impl.description,
|
description: impl.description,
|
||||||
websiteUrl: impl.websiteUrl,
|
websiteUrl: impl.websiteUrl,
|
||||||
icons: impl.icons?.map((icon: { src: string; mimeType?: string; sizes?: string }) => ({
|
icons: impl.icons?.map((icon: MCPResourceIcon) => ({
|
||||||
src: icon.src,
|
src: icon.src,
|
||||||
mimeType: icon.mimeType,
|
mimeType: icon.mimeType,
|
||||||
sizes: icon.sizes
|
sizes: icon.sizes,
|
||||||
|
theme: icon.theme
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -581,7 +583,6 @@ export class MCPService {
|
|||||||
this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...')
|
this.createLog(MCPConnectionPhase.INITIALIZING, 'Sending initialize request...')
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`[MCPService][${serverName}] Connecting to server...`);
|
|
||||||
try {
|
try {
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
// Transport diagnostics are only for the initial handshake, not long-lived traffic.
|
// Transport diagnostics are only for the initial handshake, not long-lived traffic.
|
||||||
|
|||||||
@@ -26,11 +26,10 @@ import { config, settingsStore } from '$lib/stores/settings.svelte';
|
|||||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
import {
|
import {
|
||||||
getProxiedUrlString,
|
|
||||||
parseMcpServerSettings,
|
parseMcpServerSettings,
|
||||||
detectMcpTransportFromUrl,
|
detectMcpTransportFromUrl,
|
||||||
getFaviconUrl,
|
uuid,
|
||||||
uuid
|
extractRootDomain
|
||||||
} from '$lib/utils';
|
} from '$lib/utils';
|
||||||
import {
|
import {
|
||||||
MCPConnectionPhase,
|
MCPConnectionPhase,
|
||||||
@@ -413,7 +412,9 @@ class MCPStore {
|
|||||||
#isValidIconUri(src: string): boolean {
|
#isValidIconUri(src: string): boolean {
|
||||||
try {
|
try {
|
||||||
if (src.startsWith(UrlProtocol.DATA)) return true;
|
if (src.startsWith(UrlProtocol.DATA)) return true;
|
||||||
|
|
||||||
const url = new URL(src);
|
const url = new URL(src);
|
||||||
|
|
||||||
return url.protocol === UrlProtocol.HTTPS;
|
return url.protocol === UrlProtocol.HTTPS;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -446,40 +447,29 @@ class MCPStore {
|
|||||||
|
|
||||||
// 1. Prefer icon explicitly matching the current color scheme
|
// 1. Prefer icon explicitly matching the current color scheme
|
||||||
const themedIcon = validIcons.find((icon) => icon.theme === preferredTheme);
|
const themedIcon = validIcons.find((icon) => icon.theme === preferredTheme);
|
||||||
if (themedIcon) return this.#proxyIconSrc(themedIcon.src);
|
if (themedIcon) return themedIcon.src;
|
||||||
|
|
||||||
// 2. Handle universal icons (no theme specified)
|
// 2. Handle universal icons (no theme specified)
|
||||||
const universalIcons = validIcons.filter((icon) => !icon.theme);
|
const universalIcons = validIcons.filter((icon) => !icon.theme);
|
||||||
|
|
||||||
if (universalIcons.length === EXPECTED_THEMED_ICON_PAIR_COUNT) {
|
if (universalIcons.length === EXPECTED_THEMED_ICON_PAIR_COUNT) {
|
||||||
// Heuristic: two theme-less icons → assume [0] = light, [1] = dark
|
// Heuristic: two theme-less icons → assume [0] = light, [1] = dark
|
||||||
return this.#proxyIconSrc(universalIcons[isDark ? 1 : 0].src);
|
return universalIcons[isDark ? 1 : 0].src;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (universalIcons.length > 0) {
|
if (universalIcons.length > 0) {
|
||||||
return this.#proxyIconSrc(universalIcons[0].src);
|
return universalIcons[0].src;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Last resort: use opposite-theme icon
|
// 3. Last resort: use opposite-theme icon
|
||||||
return this.#proxyIconSrc(validIcons[0].src);
|
return validIcons[0].src;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route an icon src through the CORS proxy if it's an HTTPS URL.
|
|
||||||
* Data URIs are returned as-is.
|
|
||||||
*/
|
|
||||||
#proxyIconSrc(src: string): string {
|
|
||||||
if (src.startsWith('data:')) return src;
|
|
||||||
if (!this._proxyAvailable) return src;
|
|
||||||
|
|
||||||
return getProxiedUrlString(src);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get icon URL for an MCP server by its ID.
|
* Get icon URL for an MCP server by its ID.
|
||||||
* Prefers the server's own icons (from MCP spec) and falls back
|
* Returns the best icon from the MCP server's `icons` array
|
||||||
* to Google's favicon service.
|
* (see MCP spec: spec.modelcontextprotocol.io).
|
||||||
* Returns null if server is not found.
|
* Returns null if no icon is available.
|
||||||
*/
|
*/
|
||||||
getServerFavicon(serverId: string): string | null {
|
getServerFavicon(serverId: string): string | null {
|
||||||
const server = this.getServerById(serverId);
|
const server = this.getServerById(serverId);
|
||||||
@@ -497,7 +487,39 @@ class MCPStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return getFaviconUrl(server.url, this._proxyAvailable);
|
// Fallback: try favicon from root domain
|
||||||
|
const fallbackUrl = this.#getServerFaviconFallback(server.url);
|
||||||
|
if (fallbackUrl) {
|
||||||
|
return fallbackUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a fallback favicon URL from the MCP server URL.
|
||||||
|
* e.g. https://mcp.exa.ai/mcp -> https://exa.ai/favicon.ico
|
||||||
|
*/
|
||||||
|
#getServerFaviconFallback(serverUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(serverUrl);
|
||||||
|
const rootDomain = extractRootDomain(url);
|
||||||
|
if (!rootDomain) return null;
|
||||||
|
|
||||||
|
const origin = `${url.protocol}//${rootDomain}`;
|
||||||
|
const candidates = ['favicon.ico', 'favicon.svg', 'favicon.png'];
|
||||||
|
|
||||||
|
for (const path of candidates) {
|
||||||
|
const faviconUrl = `${origin}/${path}`;
|
||||||
|
if (this.#isValidIconUri(faviconUrl)) {
|
||||||
|
return faviconUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URL, return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
isAnyServerLoading(): boolean {
|
isAnyServerLoading(): boolean {
|
||||||
|
|||||||
@@ -33,12 +33,3 @@ export function buildProxiedHeaders(headers: Record<string, string>): Record<str
|
|||||||
|
|
||||||
return proxiedHeaders;
|
return proxiedHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a proxied URL string for use in fetch requests.
|
|
||||||
* @param targetUrl - The original URL to proxy
|
|
||||||
* @returns Proxied URL as string
|
|
||||||
*/
|
|
||||||
export function getProxiedUrlString(targetUrl: string): string {
|
|
||||||
return buildProxiedUrl(targetUrl).href;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
/**
|
|
||||||
* Favicon utility functions for extracting favicons from URLs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getProxiedUrlString } from './cors-proxy';
|
|
||||||
import {
|
|
||||||
GOOGLE_FAVICON_BASE_URL,
|
|
||||||
DEFAULT_FAVICON_SIZE,
|
|
||||||
DOMAIN_SEPARATOR,
|
|
||||||
ROOT_DOMAIN_MIN_PARTS
|
|
||||||
} from '$lib/constants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a favicon URL for a given URL using Google's favicon service.
|
|
||||||
* Returns null if the URL is invalid.
|
|
||||||
*
|
|
||||||
* @param urlString - The URL to get the favicon for
|
|
||||||
* @returns The favicon URL or null if invalid
|
|
||||||
*/
|
|
||||||
export function getFaviconUrl(urlString: string, useProxy = true): string | null {
|
|
||||||
try {
|
|
||||||
const url = new URL(urlString);
|
|
||||||
const hostnameParts = url.hostname.split(DOMAIN_SEPARATOR);
|
|
||||||
const rootDomain =
|
|
||||||
hostnameParts.length >= ROOT_DOMAIN_MIN_PARTS
|
|
||||||
? hostnameParts.slice(-ROOT_DOMAIN_MIN_PARTS).join(DOMAIN_SEPARATOR)
|
|
||||||
: url.hostname;
|
|
||||||
|
|
||||||
const googleFaviconUrl = `${GOOGLE_FAVICON_BASE_URL}?domain=${rootDomain}&sz=${DEFAULT_FAVICON_SIZE}`;
|
|
||||||
return useProxy ? getProxiedUrlString(googleFaviconUrl) : googleFaviconUrl;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,10 @@ export { highlightCode, detectIncompleteCodeBlock, type IncompleteCodeBlock } fr
|
|||||||
export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
|
export { setConfigValue, getConfigValue, configToParameterRecord } from './config-helpers';
|
||||||
|
|
||||||
// CORS Proxy
|
// CORS Proxy
|
||||||
export { buildProxiedUrl, getProxiedUrlString, buildProxiedHeaders } from './cors-proxy';
|
export { buildProxiedUrl, buildProxiedHeaders } from './cors-proxy';
|
||||||
|
|
||||||
|
// URL utilities
|
||||||
|
export { extractRootDomain, sanitizeExternalUrl } from './url';
|
||||||
|
|
||||||
// Conversation utilities
|
// Conversation utilities
|
||||||
export { createMessageCountMap, getMessageCount } from './conversation-utils';
|
export { createMessageCountMap, getMessageCount } from './conversation-utils';
|
||||||
@@ -146,9 +149,6 @@ export { createBase64DataUrl } from './data-url';
|
|||||||
// Header utilities
|
// Header utilities
|
||||||
export { parseHeadersToArray, serializeHeaders } from './headers';
|
export { parseHeadersToArray, serializeHeaders } from './headers';
|
||||||
|
|
||||||
// Favicon utilities
|
|
||||||
export { getFaviconUrl } from './favicon';
|
|
||||||
|
|
||||||
// Agentic content utilities (structured section derivation)
|
// Agentic content utilities (structured section derivation)
|
||||||
export {
|
export {
|
||||||
deriveAgenticSections,
|
deriveAgenticSections,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { TWO_PART_PUBLIC_SUFFIXES, WILDCARD_PUBLIC_SUFFIXES } from '$lib/constants';
|
||||||
|
import { UrlProtocol } from '$lib/enums';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a hostname looks like an IPv4 or IPv6 address.
|
||||||
|
*/
|
||||||
|
function isIpAddress(hostname: string): boolean {
|
||||||
|
if (hostname.includes(':')) return true;
|
||||||
|
|
||||||
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the registrable root domain from a URL.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* 'mcp.example.com' -> 'example.com'
|
||||||
|
* 'www.example.co.uk' -> 'example.co.uk'
|
||||||
|
* 'bar.foo.nom.br' -> 'bar.foo.nom.br'
|
||||||
|
* '192.168.1.1' -> null
|
||||||
|
* 'localhost' -> null
|
||||||
|
*/
|
||||||
|
export function extractRootDomain(url: URL): string | null {
|
||||||
|
const hostname = url.hostname.toLowerCase();
|
||||||
|
if (!hostname || isIpAddress(hostname)) return null;
|
||||||
|
|
||||||
|
const parts = hostname.split('.');
|
||||||
|
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const suffix2 = `${parts[parts.length - 2]}.${parts[parts.length - 1]}`;
|
||||||
|
|
||||||
|
if (TWO_PART_PUBLIC_SUFFIXES.has(suffix2)) {
|
||||||
|
return parts.slice(-3).join('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 2; i <= parts.length; i++) {
|
||||||
|
const candidate = parts.slice(-i).join('.');
|
||||||
|
|
||||||
|
if (WILDCARD_PUBLIC_SUFFIXES.has(candidate)) {
|
||||||
|
if (parts.length === i + 1) {
|
||||||
|
return hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.slice(-(i + 2)).join('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.slice(-2).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize an external URL string for safe use in an `<a href>`.
|
||||||
|
* Only allows http: and https: schemes. Returns `null` for anything else.
|
||||||
|
*/
|
||||||
|
export function sanitizeExternalUrl(raw: string): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
|
||||||
|
if (url.protocol !== UrlProtocol.HTTP && url.protocol !== UrlProtocol.HTTPS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.href;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user