@webmcp-auto-ui/ui 2.5.35 → 2.5.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/MCPserversList.svelte +44 -32
- package/src/agent/RecipeBrowser.svelte +54 -18
- package/src/agent/RemoteMCPserversDemo.svelte +7 -7
- package/src/agent/ToolBrowser.svelte +34 -5
- package/src/agent/WebMCPserversList.svelte +85 -40
- package/src/index.ts +5 -0
- package/src/primitives/MarkdownView.svelte +12 -2
- package/src/recipe/RecipeCodeBlock.svelte +331 -0
- package/src/recipe/RecipeRunModal.svelte +245 -0
- package/src/recipe/types.ts +10 -0
- package/src/widgets/WidgetRenderer.svelte +2 -0
- package/src/widgets/notebook/executors/sql.ts +21 -7
- package/src/widgets/notebook/import-modal-api.ts +15 -43
- package/src/widgets/notebook/import-modal.svelte +36 -66
- package/src/widgets/notebook/left-pane.ts +30 -12
- package/src/widgets/notebook/notebook.svelte +0 -2
- package/src/widgets/notebook/notebook.ts +12 -56
- package/src/widgets/notebook/prose.ts +0 -78
- package/src/widgets/notebook/recipes/notebook.md +0 -6
- package/src/widgets/notebook/resource-extractor.ts +21 -1
- package/src/widgets/notebook/share-handlers.ts +76 -3
- package/src/widgets/notebook/shared.ts +113 -79
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<script lang="ts">
|
|
4
4
|
interface Server {
|
|
5
5
|
id: string;
|
|
6
|
-
|
|
6
|
+
label: string;
|
|
7
7
|
description: string;
|
|
8
8
|
url: string;
|
|
9
9
|
tags?: string[];
|
|
@@ -11,21 +11,30 @@
|
|
|
11
11
|
|
|
12
12
|
interface Props {
|
|
13
13
|
servers: Server[];
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
/** Set of registry ids currently enabled/connected. Aligned with WebMCPserversList. */
|
|
15
|
+
enabledServers?: Set<string>;
|
|
16
|
+
/** Set of registry ids currently in a loading/connecting state. */
|
|
17
|
+
loading?: Set<string>;
|
|
18
|
+
onconnect?: (id: string) => void;
|
|
17
19
|
onconnectall?: () => void;
|
|
18
|
-
ondisconnect?: (
|
|
20
|
+
ondisconnect?: (id: string) => void;
|
|
21
|
+
/** Recipe counts keyed by registry id. */
|
|
19
22
|
recipeCountByServer?: Record<string, number>;
|
|
20
|
-
onrecipeclick?: (
|
|
23
|
+
onrecipeclick?: (id: string) => void;
|
|
24
|
+
/** Tool counts keyed by registry id. */
|
|
21
25
|
toolCountByServer?: Record<string, number>;
|
|
22
|
-
ontoolclick?: (
|
|
26
|
+
ontoolclick?: (id: string) => void;
|
|
27
|
+
/** Hide the built-in "Available MCP servers" header (when caller wraps the
|
|
28
|
+
* list in its own disclosure/section that already provides a title). */
|
|
29
|
+
hideHeader?: boolean;
|
|
23
30
|
}
|
|
24
31
|
|
|
32
|
+
const EMPTY_SET: Set<string> = new Set();
|
|
33
|
+
|
|
25
34
|
let {
|
|
26
35
|
servers,
|
|
27
|
-
|
|
28
|
-
loading =
|
|
36
|
+
enabledServers = EMPTY_SET,
|
|
37
|
+
loading = EMPTY_SET,
|
|
29
38
|
onconnect,
|
|
30
39
|
onconnectall,
|
|
31
40
|
ondisconnect,
|
|
@@ -33,32 +42,35 @@
|
|
|
33
42
|
onrecipeclick,
|
|
34
43
|
toolCountByServer,
|
|
35
44
|
ontoolclick,
|
|
45
|
+
hideHeader = false,
|
|
36
46
|
}: Props = $props();
|
|
37
47
|
|
|
38
48
|
const allConnected = $derived(
|
|
39
|
-
servers.length > 0 && servers.every(s =>
|
|
49
|
+
servers.length > 0 && servers.every(s => enabledServers.has(s.id))
|
|
40
50
|
);
|
|
41
51
|
const anyConnected = $derived(
|
|
42
|
-
servers.some(s =>
|
|
52
|
+
servers.some(s => enabledServers.has(s.id))
|
|
43
53
|
);
|
|
44
54
|
|
|
45
|
-
function isConnected(
|
|
46
|
-
return
|
|
55
|
+
function isConnected(id: string) {
|
|
56
|
+
return enabledServers.has(id);
|
|
47
57
|
}
|
|
48
|
-
function isLoading(
|
|
49
|
-
return loading.
|
|
58
|
+
function isLoading(id: string) {
|
|
59
|
+
return loading.has(id);
|
|
50
60
|
}
|
|
51
61
|
</script>
|
|
52
62
|
|
|
53
63
|
<div class="flex flex-col gap-2">
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
{#if !hideHeader}
|
|
65
|
+
<span class="text-[9px] font-mono uppercase tracking-wider text-text2">
|
|
66
|
+
Available MCP servers
|
|
67
|
+
</span>
|
|
68
|
+
{/if}
|
|
57
69
|
|
|
58
70
|
<div class="flex flex-col gap-1">
|
|
59
71
|
{#each servers as server (server.id)}
|
|
60
|
-
{@const connected = isConnected(server.
|
|
61
|
-
{@const busy = isLoading(server.
|
|
72
|
+
{@const connected = isConnected(server.id)}
|
|
73
|
+
{@const busy = isLoading(server.id)}
|
|
62
74
|
<div
|
|
63
75
|
class="group flex items-center gap-2 px-2 py-1.5 rounded border border-border2 bg-surface2 hover:border-accent/30 transition-colors"
|
|
64
76
|
>
|
|
@@ -73,23 +85,23 @@
|
|
|
73
85
|
|
|
74
86
|
<!-- info -->
|
|
75
87
|
<div class="flex-1 min-w-0 flex flex-col">
|
|
76
|
-
<span class="font-mono text-xs font-medium text-text1">{server.
|
|
88
|
+
<span class="font-mono text-xs font-medium text-text1">{server.label}</span>
|
|
77
89
|
<span class="text-[10px] text-text2 truncate">{server.description}</span>
|
|
78
|
-
{#if connected && (recipeCountByServer?.[server.
|
|
90
|
+
{#if connected && (recipeCountByServer?.[server.id] || toolCountByServer?.[server.id])}
|
|
79
91
|
<span class="flex items-center gap-1.5 mt-0.5">
|
|
80
|
-
{#if recipeCountByServer?.[server.
|
|
92
|
+
{#if recipeCountByServer?.[server.id]}
|
|
81
93
|
<button class="text-[10px] font-mono text-accent hover:underline"
|
|
82
|
-
onclick={(e) => { e.stopPropagation(); onrecipeclick?.(server.
|
|
83
|
-
{recipeCountByServer[server.
|
|
94
|
+
onclick={(e) => { e.stopPropagation(); onrecipeclick?.(server.id); }}>
|
|
95
|
+
{recipeCountByServer[server.id]} recipes
|
|
84
96
|
</button>
|
|
85
97
|
{/if}
|
|
86
|
-
{#if recipeCountByServer?.[server.
|
|
98
|
+
{#if recipeCountByServer?.[server.id] && toolCountByServer?.[server.id]}
|
|
87
99
|
<span class="text-[10px] text-text2">·</span>
|
|
88
100
|
{/if}
|
|
89
|
-
{#if toolCountByServer?.[server.
|
|
101
|
+
{#if toolCountByServer?.[server.id]}
|
|
90
102
|
<button class="text-[10px] font-mono text-accent hover:underline"
|
|
91
|
-
onclick={(e) => { e.stopPropagation(); ontoolclick?.(server.
|
|
92
|
-
{toolCountByServer[server.
|
|
103
|
+
onclick={(e) => { e.stopPropagation(); ontoolclick?.(server.id); }}>
|
|
104
|
+
{toolCountByServer[server.id]} tools
|
|
93
105
|
</button>
|
|
94
106
|
{/if}
|
|
95
107
|
</span>
|
|
@@ -102,7 +114,7 @@
|
|
|
102
114
|
<div class="w-4 h-4 border border-accent/50 border-t-accent rounded-full animate-spin"></div>
|
|
103
115
|
{:else if connected}
|
|
104
116
|
<button
|
|
105
|
-
onclick={() => ondisconnect?.(server.
|
|
117
|
+
onclick={() => ondisconnect?.(server.id)}
|
|
106
118
|
class="text-xs font-mono px-1.5 h-6 rounded text-teal group-hover:text-accent2 transition-colors"
|
|
107
119
|
title="Disconnect"
|
|
108
120
|
>
|
|
@@ -111,7 +123,7 @@
|
|
|
111
123
|
</button>
|
|
112
124
|
{:else}
|
|
113
125
|
<button
|
|
114
|
-
onclick={() => onconnect?.(server.
|
|
126
|
+
onclick={() => onconnect?.(server.id)}
|
|
115
127
|
class="text-[10px] font-mono px-1.5 h-6 rounded border border-border2 bg-surface2 hover:border-accent/50 hover:text-accent text-text2 transition-colors"
|
|
116
128
|
>
|
|
117
129
|
connect
|
|
@@ -135,7 +147,7 @@
|
|
|
135
147
|
<button
|
|
136
148
|
onclick={() => {
|
|
137
149
|
for (const s of servers) {
|
|
138
|
-
if (isConnected(s.
|
|
150
|
+
if (isConnected(s.id)) ondisconnect?.(s.id);
|
|
139
151
|
}
|
|
140
152
|
}}
|
|
141
153
|
class="text-xs font-mono px-2 h-7 rounded border border-border2 bg-surface2 hover:border-accent2/50 hover:text-accent2 text-text2 transition-colors"
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
import { fade, fly } from 'svelte/transition';
|
|
5
5
|
import { filterRecipes, sortRecipes, recipeToMarkdown, recipeToDownloadBlob } from '@webmcp-auto-ui/agent';
|
|
6
6
|
import { encode } from '@webmcp-auto-ui/sdk';
|
|
7
|
+
import { canvas } from '@webmcp-auto-ui/sdk/canvas';
|
|
7
8
|
import { extractCellsFromRecipe } from '@webmcp-auto-ui/ui';
|
|
8
|
-
import type { McpMultiClient } from '@webmcp-auto-ui/core';
|
|
9
9
|
import type { RecipeData } from '@webmcp-auto-ui/sdk';
|
|
10
10
|
|
|
11
11
|
interface RecipeItem {
|
|
@@ -34,6 +34,14 @@
|
|
|
34
34
|
onOpenInNotebook?: (type: string, data: Record<string, unknown>) => void;
|
|
35
35
|
/** Called when user clicks a recipe to view its detail. Host shows the recipe modal. */
|
|
36
36
|
onOpenRecipe?: (recipe: RecipeItem) => void;
|
|
37
|
+
/** Agent widget_display payload — when present, takes priority over named props. */
|
|
38
|
+
data?: {
|
|
39
|
+
recipes?: RecipeItem[];
|
|
40
|
+
mcpRecipes?: RecipeItem[];
|
|
41
|
+
webmcpRecipes?: RecipeItem[];
|
|
42
|
+
layout?: 'list' | 'grid';
|
|
43
|
+
filters?: { q?: string; kind?: 'all' | 'mcp' | 'webmcp' };
|
|
44
|
+
} | null;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
let {
|
|
@@ -44,10 +52,19 @@
|
|
|
44
52
|
layout: initialLayout = 'list',
|
|
45
53
|
onOpenInNotebook,
|
|
46
54
|
onOpenRecipe,
|
|
55
|
+
data = null,
|
|
47
56
|
}: Props = $props();
|
|
48
57
|
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
// Agent mode: when `data` is present, use it as source of truth and force open.
|
|
59
|
+
let agentClosed = $state(false);
|
|
60
|
+
const effectiveMcp = $derived<RecipeItem[]>(data?.recipes ?? data?.mcpRecipes ?? mcpRecipes);
|
|
61
|
+
const effectiveWebmcp = $derived<RecipeItem[]>(data?.webmcpRecipes ?? webmcpRecipes);
|
|
62
|
+
const effectiveOpen = $derived(data ? !agentClosed : open);
|
|
63
|
+
|
|
64
|
+
/** Look up the canvas server name (= registry id) matching the given URL. */
|
|
65
|
+
function findServerNameByUrl(url: string | undefined): string | undefined {
|
|
66
|
+
if (!url) return undefined;
|
|
67
|
+
return canvas.dataServers.find((s) => s.url === url)?.name;
|
|
51
68
|
}
|
|
52
69
|
|
|
53
70
|
let query = $state('');
|
|
@@ -61,32 +78,50 @@
|
|
|
61
78
|
let copyState = $state<'idle' | 'copied'>('idle');
|
|
62
79
|
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
63
80
|
|
|
64
|
-
// Sync initialFilter into search when modal opens
|
|
81
|
+
// Sync initialFilter into search when modal opens (Settings mode)
|
|
65
82
|
$effect(() => {
|
|
66
|
-
if (open) {
|
|
83
|
+
if (open && !data) {
|
|
67
84
|
query = initialFilter ?? '';
|
|
68
85
|
kind = 'all';
|
|
69
86
|
selected = null;
|
|
70
87
|
}
|
|
71
88
|
});
|
|
72
89
|
|
|
90
|
+
// Sync agent payload into local state when `data` arrives
|
|
91
|
+
$effect(() => {
|
|
92
|
+
if (data) {
|
|
93
|
+
query = data.filters?.q ?? '';
|
|
94
|
+
kind = data.filters?.kind ?? 'all';
|
|
95
|
+
layout = data.layout ?? initialLayout;
|
|
96
|
+
selected = null;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
73
100
|
function applyKindFilter(recipes: RecipeItem[], filterKind: 'all' | 'mcp' | 'webmcp'): RecipeItem[] {
|
|
74
101
|
if (filterKind === 'all') return recipes;
|
|
75
102
|
return recipes.filter((_r) => {
|
|
76
103
|
// webmcpRecipes are already separated — use the array membership to determine kind
|
|
77
|
-
return filterKind === 'webmcp' ?
|
|
104
|
+
return filterKind === 'webmcp' ? effectiveWebmcp.includes(_r) : effectiveMcp.includes(_r);
|
|
78
105
|
});
|
|
79
106
|
}
|
|
80
107
|
|
|
81
108
|
const filteredMcp = $derived(
|
|
82
|
-
kind === 'webmcp' ? [] : sortRecipes(filterRecipes(
|
|
109
|
+
kind === 'webmcp' ? [] : sortRecipes(filterRecipes(effectiveMcp, query))
|
|
83
110
|
);
|
|
84
111
|
const filteredWebmcp = $derived(
|
|
85
|
-
kind === 'mcp' ? [] : sortRecipes(filterRecipes(
|
|
112
|
+
kind === 'mcp' ? [] : sortRecipes(filterRecipes(effectiveWebmcp, query))
|
|
86
113
|
);
|
|
87
114
|
const totalResults = $derived(filteredMcp.length + filteredWebmcp.length);
|
|
88
115
|
|
|
89
|
-
|
|
116
|
+
// Reset agent-close override when a fresh payload arrives
|
|
117
|
+
$effect(() => {
|
|
118
|
+
if (data) agentClosed = false;
|
|
119
|
+
});
|
|
120
|
+
function close() {
|
|
121
|
+
open = false;
|
|
122
|
+
selected = null;
|
|
123
|
+
if (data) agentClosed = true;
|
|
124
|
+
}
|
|
90
125
|
|
|
91
126
|
function onKeydown(e: KeyboardEvent) {
|
|
92
127
|
if (e.key === 'Escape') close();
|
|
@@ -111,15 +146,16 @@
|
|
|
111
146
|
}
|
|
112
147
|
|
|
113
148
|
async function ensureBody(recipe: RecipeItem) {
|
|
114
|
-
|
|
115
|
-
|
|
149
|
+
if (recipe.body || !recipe.serverUrl) return;
|
|
150
|
+
const serverName = findServerNameByUrl(recipe.serverUrl);
|
|
151
|
+
if (!serverName) return;
|
|
116
152
|
try {
|
|
117
153
|
const identifier = recipe.originalName ?? recipe.name;
|
|
118
|
-
const res = await
|
|
154
|
+
const res = await canvas.callTool(serverName, 'get_recipe', {
|
|
119
155
|
name: identifier,
|
|
120
156
|
id: (recipe as any).id ?? identifier,
|
|
121
|
-
});
|
|
122
|
-
const text = res
|
|
157
|
+
}) as { content?: { type: string; text?: string }[] };
|
|
158
|
+
const text = res?.content?.find((c: { type: string }) => c.type === 'text') as { text?: string } | undefined;
|
|
123
159
|
if (text?.text) {
|
|
124
160
|
let body = text.text;
|
|
125
161
|
try {
|
|
@@ -144,11 +180,11 @@
|
|
|
144
180
|
description: recipe.description,
|
|
145
181
|
});
|
|
146
182
|
|
|
147
|
-
const connected =
|
|
183
|
+
const connected = canvas.dataServers ?? [];
|
|
148
184
|
const serverNames = Array.isArray(recipe.servers) ? recipe.servers : [];
|
|
149
185
|
const servers = serverNames
|
|
150
186
|
.map((name) => {
|
|
151
|
-
const hit = connected.find((s) => s.name === name);
|
|
187
|
+
const hit = connected.find((s) => s.name === name || s.serverName === name || s.label === name);
|
|
152
188
|
const url = hit?.url ?? (typeof recipe.serverUrl === 'string' ? recipe.serverUrl : undefined);
|
|
153
189
|
return url ? { name, url, kind: 'data' as const } : null;
|
|
154
190
|
})
|
|
@@ -181,7 +217,7 @@
|
|
|
181
217
|
|
|
182
218
|
<svelte:window onkeydown={onKeydown} />
|
|
183
219
|
|
|
184
|
-
{#if
|
|
220
|
+
{#if effectiveOpen}
|
|
185
221
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
186
222
|
<div
|
|
187
223
|
class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6"
|
|
@@ -248,7 +284,7 @@
|
|
|
248
284
|
{#if layout === 'grid'}
|
|
249
285
|
<div class="grid grid-cols-2 gap-2 mt-2">
|
|
250
286
|
{#each [...filteredMcp, ...filteredWebmcp] as recipe, i (`grid:${recipe.name}:${i}`)}
|
|
251
|
-
{@const isWebmcp =
|
|
287
|
+
{@const isWebmcp = effectiveWebmcp.includes(recipe)}
|
|
252
288
|
<div class="group flex flex-col gap-1 p-3 bg-surface2/50 rounded-lg hover:bg-surface2 transition-colors cursor-pointer border border-border2/50"
|
|
253
289
|
onclick={() => openRecipe(recipe)}>
|
|
254
290
|
<div class="font-mono text-[11px] text-text1 font-medium truncate">{recipe.name}</div>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
interface Server {
|
|
6
6
|
id: string;
|
|
7
|
-
|
|
7
|
+
label: string;
|
|
8
8
|
description: string;
|
|
9
9
|
url: string;
|
|
10
10
|
tags?: string[];
|
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|
|
|
13
13
|
interface Props {
|
|
14
14
|
servers: Server[];
|
|
15
|
-
|
|
16
|
-
loading?: string
|
|
17
|
-
onconnect?: (
|
|
15
|
+
enabledServers?: Set<string>;
|
|
16
|
+
loading?: Set<string>;
|
|
17
|
+
onconnect?: (id: string) => void;
|
|
18
18
|
onconnectall?: () => void;
|
|
19
|
-
ondisconnect?: (
|
|
19
|
+
ondisconnect?: (id: string) => void;
|
|
20
20
|
recipeCountByServer?: Record<string, number>;
|
|
21
|
-
onrecipeclick?: (
|
|
21
|
+
onrecipeclick?: (id: string) => void;
|
|
22
22
|
toolCountByServer?: Record<string, number>;
|
|
23
|
-
ontoolclick?: (
|
|
23
|
+
ontoolclick?: (id: string) => void;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
let props: Props = $props();
|
|
@@ -9,26 +9,55 @@
|
|
|
9
9
|
open: boolean;
|
|
10
10
|
tools: BrowsableTool[];
|
|
11
11
|
initialFilter?: string;
|
|
12
|
+
/** Layout toggle: list (default) or grid — reserved for future grid view */
|
|
13
|
+
layout?: 'list' | 'grid';
|
|
14
|
+
/** Agent widget_display payload — when present, takes priority over named props. */
|
|
15
|
+
data?: {
|
|
16
|
+
tools?: BrowsableTool[];
|
|
17
|
+
layout?: 'list' | 'grid';
|
|
18
|
+
filters?: { q?: string };
|
|
19
|
+
} | null;
|
|
12
20
|
}
|
|
13
21
|
|
|
14
|
-
let {
|
|
22
|
+
let {
|
|
23
|
+
open = $bindable(false),
|
|
24
|
+
tools = [],
|
|
25
|
+
initialFilter = '',
|
|
26
|
+
layout: _initialLayout = 'list',
|
|
27
|
+
data = null,
|
|
28
|
+
}: Props = $props();
|
|
29
|
+
|
|
30
|
+
let agentClosed = $state(false);
|
|
31
|
+
const effectiveTools = $derived<BrowsableTool[]>(data?.tools ?? tools);
|
|
32
|
+
const effectiveOpen = $derived(data ? !agentClosed : open);
|
|
15
33
|
|
|
16
34
|
let query = $state('');
|
|
17
35
|
let selected = $state<BrowsableTool | null>(null);
|
|
18
36
|
|
|
37
|
+
// Settings mode: sync initialFilter when modal opens
|
|
19
38
|
$effect(() => {
|
|
20
|
-
if (open) {
|
|
39
|
+
if (open && !data) {
|
|
21
40
|
query = initialFilter || '';
|
|
22
41
|
selected = null;
|
|
23
42
|
}
|
|
24
43
|
});
|
|
25
44
|
|
|
26
|
-
|
|
45
|
+
// Agent mode: sync payload when `data` arrives
|
|
46
|
+
$effect(() => {
|
|
47
|
+
if (data) {
|
|
48
|
+
query = data.filters?.q ?? '';
|
|
49
|
+
selected = null;
|
|
50
|
+
agentClosed = false;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const filtered = $derived(sortRecipes(filterRecipes(effectiveTools, query)));
|
|
27
55
|
const grouped = $derived(groupToolsByServer(filtered));
|
|
28
56
|
|
|
29
57
|
function close() {
|
|
30
58
|
open = false;
|
|
31
59
|
selected = null;
|
|
60
|
+
if (data) agentClosed = true;
|
|
32
61
|
}
|
|
33
62
|
|
|
34
63
|
function handleKeydown(e: KeyboardEvent) {
|
|
@@ -39,9 +68,9 @@
|
|
|
39
68
|
}
|
|
40
69
|
</script>
|
|
41
70
|
|
|
42
|
-
<svelte:window onkeydown={
|
|
71
|
+
<svelte:window onkeydown={effectiveOpen ? handleKeydown : undefined} />
|
|
43
72
|
|
|
44
|
-
{#if
|
|
73
|
+
{#if effectiveOpen}
|
|
45
74
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
46
75
|
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
|
|
47
76
|
onclick={close}>
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
label: string;
|
|
7
7
|
description: string;
|
|
8
8
|
widgetCount: number;
|
|
9
|
+
category?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CategoryGroup {
|
|
13
|
+
key: string;
|
|
14
|
+
label: string;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
interface Props {
|
|
@@ -16,6 +22,12 @@
|
|
|
16
22
|
toolCountByServer?: Record<string, number>;
|
|
17
23
|
onrecipeclick?: (id: string) => void;
|
|
18
24
|
ontoolclick?: (id: string) => void;
|
|
25
|
+
/** Optional category grouping. When provided AND any server has a `category`,
|
|
26
|
+
* servers are rendered grouped under category headers. Order from this array
|
|
27
|
+
* is preserved; servers whose category isn't listed fall into a tail "Other"
|
|
28
|
+
* group. When absent, behavior is unchanged (flat list). */
|
|
29
|
+
categories?: CategoryGroup[];
|
|
30
|
+
initialCollapsed?: boolean;
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
let {
|
|
@@ -26,9 +38,25 @@
|
|
|
26
38
|
toolCountByServer,
|
|
27
39
|
onrecipeclick,
|
|
28
40
|
ontoolclick,
|
|
41
|
+
categories,
|
|
42
|
+
initialCollapsed = true,
|
|
29
43
|
}: Props = $props();
|
|
30
44
|
|
|
31
|
-
let collapsed = $state(
|
|
45
|
+
let collapsed = $state(initialCollapsed);
|
|
46
|
+
|
|
47
|
+
const grouped = $derived.by(() => {
|
|
48
|
+
if (!categories || categories.length === 0) return null;
|
|
49
|
+
if (!servers.some(s => s.category)) return null;
|
|
50
|
+
const groups = categories.map(c => ({
|
|
51
|
+
key: c.key,
|
|
52
|
+
label: c.label,
|
|
53
|
+
items: servers.filter(s => (s.category ?? '') === c.key),
|
|
54
|
+
}));
|
|
55
|
+
const known = new Set(categories.map(c => c.key));
|
|
56
|
+
const others = servers.filter(s => !s.category || !known.has(s.category));
|
|
57
|
+
if (others.length > 0) groups.push({ key: '_other', label: 'Other', items: others });
|
|
58
|
+
return groups.filter(g => g.items.length > 0);
|
|
59
|
+
});
|
|
32
60
|
</script>
|
|
33
61
|
|
|
34
62
|
<div class="flex flex-col gap-2">
|
|
@@ -40,47 +68,64 @@
|
|
|
40
68
|
<span class="text-[10px] text-text2 ml-auto transition-transform {collapsed ? '' : 'rotate-90'}">{@html '▶'}</span>
|
|
41
69
|
</div>
|
|
42
70
|
|
|
43
|
-
{#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
{#snippet row(srv: Server)}
|
|
72
|
+
{@const enabled = enabledServers.has(srv.id)}
|
|
73
|
+
{@const recipes = recipeCountByServer?.[srv.id] ?? 0}
|
|
74
|
+
{@const tools = toolCountByServer?.[srv.id] ?? 0}
|
|
75
|
+
<div class="group flex items-center gap-2 px-2 py-1.5 rounded border border-border2 bg-surface2 hover:border-accent/30 transition-colors">
|
|
76
|
+
<input
|
|
77
|
+
type="checkbox"
|
|
78
|
+
checked={enabled}
|
|
79
|
+
onchange={() => onToggle?.(srv.id)}
|
|
80
|
+
class="w-3.5 h-3.5 rounded border-border2 accent-accent cursor-pointer flex-shrink-0"
|
|
81
|
+
/>
|
|
82
|
+
<div class="flex-1 min-w-0 flex flex-col">
|
|
83
|
+
<span class="font-mono text-xs font-medium text-text1 truncate">{srv.label}</span>
|
|
84
|
+
{#if srv.description}
|
|
85
|
+
<span class="text-[10px] text-text2 truncate">{srv.description}</span>
|
|
86
|
+
{/if}
|
|
87
|
+
{#if enabled && (recipes > 0 || tools > 0)}
|
|
88
|
+
<span class="flex items-center gap-1.5 mt-0.5">
|
|
89
|
+
{#if recipes > 0}
|
|
90
|
+
<button class="text-[10px] font-mono text-accent hover:underline"
|
|
91
|
+
onclick={(e) => { e.stopPropagation(); onrecipeclick?.(srv.id); }}>
|
|
92
|
+
{recipes} recipes
|
|
93
|
+
</button>
|
|
60
94
|
{/if}
|
|
61
|
-
{#if
|
|
62
|
-
<span class="
|
|
63
|
-
{#if recipes > 0}
|
|
64
|
-
<button class="text-[10px] font-mono text-accent hover:underline"
|
|
65
|
-
onclick={(e) => { e.stopPropagation(); onrecipeclick?.(srv.id); }}>
|
|
66
|
-
{recipes} recipes
|
|
67
|
-
</button>
|
|
68
|
-
{/if}
|
|
69
|
-
{#if recipes > 0 && tools > 0}
|
|
70
|
-
<span class="text-[10px] text-text2">·</span>
|
|
71
|
-
{/if}
|
|
72
|
-
{#if tools > 0}
|
|
73
|
-
<button class="text-[10px] font-mono text-accent hover:underline"
|
|
74
|
-
onclick={(e) => { e.stopPropagation(); ontoolclick?.(srv.id); }}>
|
|
75
|
-
{tools} tools
|
|
76
|
-
</button>
|
|
77
|
-
{/if}
|
|
78
|
-
</span>
|
|
95
|
+
{#if recipes > 0 && tools > 0}
|
|
96
|
+
<span class="text-[10px] text-text2">·</span>
|
|
79
97
|
{/if}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
98
|
+
{#if tools > 0}
|
|
99
|
+
<button class="text-[10px] font-mono text-accent hover:underline"
|
|
100
|
+
onclick={(e) => { e.stopPropagation(); ontoolclick?.(srv.id); }}>
|
|
101
|
+
{tools} tools
|
|
102
|
+
</button>
|
|
103
|
+
{/if}
|
|
104
|
+
</span>
|
|
105
|
+
{/if}
|
|
106
|
+
</div>
|
|
107
|
+
<span class="text-[9px] font-mono text-text2/50 flex-shrink-0">{srv.widgetCount}w</span>
|
|
84
108
|
</div>
|
|
109
|
+
{/snippet}
|
|
110
|
+
|
|
111
|
+
{#if !collapsed}
|
|
112
|
+
{#if grouped}
|
|
113
|
+
<div class="flex flex-col gap-3">
|
|
114
|
+
{#each grouped as group (group.key)}
|
|
115
|
+
<div class="flex flex-col gap-1">
|
|
116
|
+
<div class="text-[9px] font-mono text-text2/70 uppercase tracking-wider pl-0.5">{group.label}</div>
|
|
117
|
+
{#each group.items as srv (srv.id)}
|
|
118
|
+
{@render row(srv)}
|
|
119
|
+
{/each}
|
|
120
|
+
</div>
|
|
121
|
+
{/each}
|
|
122
|
+
</div>
|
|
123
|
+
{:else}
|
|
124
|
+
<div class="flex flex-col gap-1">
|
|
125
|
+
{#each servers as srv (srv.id)}
|
|
126
|
+
{@render row(srv)}
|
|
127
|
+
{/each}
|
|
128
|
+
</div>
|
|
129
|
+
{/if}
|
|
85
130
|
{/if}
|
|
86
131
|
</div>
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,11 @@ export { default as MarkdownView } from './primitives/MarkdownView.svelte';
|
|
|
19
19
|
export { default as CodeView } from './primitives/CodeView.svelte';
|
|
20
20
|
export { renderMarkdown, highlightCode, createMarkdownRenderer } from './primitives/markdown-renderer.js';
|
|
21
21
|
|
|
22
|
+
// Recipe building blocks (used by RecipeModal and notebook recipe-viewer)
|
|
23
|
+
export { default as RecipeCodeBlock } from './recipe/RecipeCodeBlock.svelte';
|
|
24
|
+
export { default as RecipeRunModal } from './recipe/RecipeRunModal.svelte';
|
|
25
|
+
export type { RecipeBlockAction } from './recipe/types.js';
|
|
26
|
+
|
|
22
27
|
// Widgets are shipped as Svelte 5 custom elements — import the widget file
|
|
23
28
|
// side-effect to register its tag (e.g. `import '@webmcp-auto-ui/ui/widgets/simple/stat.svelte';`
|
|
24
29
|
// then use `<auto-stat data={spec}></auto-stat>`). `WidgetRenderer` does this for you.
|
|
@@ -5,13 +5,23 @@
|
|
|
5
5
|
interface Props {
|
|
6
6
|
source: string;
|
|
7
7
|
class?: string;
|
|
8
|
+
onLinkClick?: (href: string, ev: MouseEvent) => void;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
let { source, class: className = '' }: Props = $props();
|
|
11
|
+
let { source, class: className = '', onLinkClick }: Props = $props();
|
|
11
12
|
const html = $derived(renderMarkdown(source ?? ''));
|
|
13
|
+
|
|
14
|
+
function handleClick(ev: MouseEvent) {
|
|
15
|
+
if (!onLinkClick) return;
|
|
16
|
+
const a = (ev.target as HTMLElement | null)?.closest?.('a');
|
|
17
|
+
if (!a) return;
|
|
18
|
+
const href = a.getAttribute('href');
|
|
19
|
+
if (!href) return;
|
|
20
|
+
onLinkClick(href, ev);
|
|
21
|
+
}
|
|
12
22
|
</script>
|
|
13
23
|
|
|
14
|
-
<div class="markdown-body {className}">
|
|
24
|
+
<div class="markdown-body {className}" onclick={handleClick} role="presentation">
|
|
15
25
|
{@html html}
|
|
16
26
|
</div>
|
|
17
27
|
|