@webmcp-auto-ui/ui 2.5.27 → 2.5.29
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 +18 -5
- package/src/agent/LLMSelector.svelte +11 -3
- package/src/agent/ModelCacheManager.svelte +359 -0
- package/src/index.ts +42 -30
- package/src/theme/scale.ts +128 -0
- package/src/widgets/WidgetRenderer.svelte +144 -107
- package/src/widgets/export-widget.ts +28 -1
- package/src/widgets/helpers/safe-image.ts +78 -0
- package/src/widgets/notebook/.gitkeep +0 -0
- package/src/widgets/notebook/chart-renderer.ts +63 -0
- package/src/widgets/notebook/executors/.gitkeep +1 -0
- package/src/widgets/notebook/executors/index.ts +4 -0
- package/src/widgets/notebook/executors/js-worker.ts +269 -0
- package/src/widgets/notebook/executors/sql.ts +206 -0
- package/src/widgets/notebook/import-modals.ts +560 -0
- package/src/widgets/notebook/left-pane.ts +256 -0
- package/src/widgets/notebook/notebook.ts +930 -0
- package/src/widgets/notebook/prose.ts +615 -0
- package/src/widgets/notebook/recipe-browser.ts +350 -0
- package/src/widgets/notebook/recipes/notebook.md +124 -0
- package/src/widgets/notebook/resource-extractor.ts +162 -0
- package/src/widgets/notebook/share-handlers.ts +222 -0
- package/src/widgets/notebook/shared.ts +1633 -0
- package/src/widgets/rich/cards.ts +181 -0
- package/src/widgets/rich/carousel.ts +319 -0
- package/src/widgets/rich/chart-rich.ts +386 -0
- package/src/widgets/rich/d3.ts +503 -0
- package/src/widgets/rich/data-table.ts +342 -0
- package/src/widgets/rich/gallery.ts +350 -0
- package/src/widgets/rich/grid-data.ts +173 -0
- package/src/widgets/rich/hemicycle.ts +313 -0
- package/src/widgets/rich/js-sandbox.ts +122 -0
- package/src/widgets/rich/json-viewer.ts +202 -0
- package/src/widgets/rich/log.ts +143 -0
- package/src/widgets/rich/map.ts +218 -0
- package/src/widgets/rich/profile.ts +256 -0
- package/src/widgets/rich/sankey.ts +257 -0
- package/src/widgets/rich/stat-card.ts +125 -0
- package/src/widgets/rich/timeline.ts +179 -0
- package/src/widgets/rich/trombinoscope.ts +246 -0
- package/src/widgets/simple/actions.ts +89 -0
- package/src/widgets/simple/alert.ts +100 -0
- package/src/widgets/simple/chart.ts +189 -0
- package/src/widgets/simple/code.ts +79 -0
- package/src/widgets/simple/kv.ts +68 -0
- package/src/widgets/simple/list.ts +89 -0
- package/src/widgets/simple/stat.ts +58 -0
- package/src/widgets/simple/tags.ts +125 -0
- package/src/widgets/simple/text.ts +198 -0
- package/src/widgets/SafeImage.svelte +0 -76
- package/src/widgets/rich/Cards.svelte +0 -39
- package/src/widgets/rich/Carousel.svelte +0 -88
- package/src/widgets/rich/Chart.svelte +0 -142
- package/src/widgets/rich/D3Widget.svelte +0 -378
- package/src/widgets/rich/DataTable.svelte +0 -62
- package/src/widgets/rich/Gallery.svelte +0 -94
- package/src/widgets/rich/GridData.svelte +0 -44
- package/src/widgets/rich/Hemicycle.svelte +0 -78
- package/src/widgets/rich/JsSandbox.svelte +0 -51
- package/src/widgets/rich/JsonViewer.svelte +0 -42
- package/src/widgets/rich/LogViewer.svelte +0 -24
- package/src/widgets/rich/MapView.svelte +0 -140
- package/src/widgets/rich/ProfileCard.svelte +0 -59
- package/src/widgets/rich/Sankey.svelte +0 -56
- package/src/widgets/rich/StatCard.svelte +0 -35
- package/src/widgets/rich/Timeline.svelte +0 -43
- package/src/widgets/rich/Trombinoscope.svelte +0 -48
- package/src/widgets/simple/ActionsBlock.svelte +0 -15
- package/src/widgets/simple/AlertBlock.svelte +0 -11
- package/src/widgets/simple/ChartBlock.svelte +0 -21
- package/src/widgets/simple/CodeBlock.svelte +0 -11
- package/src/widgets/simple/KVBlock.svelte +0 -16
- package/src/widgets/simple/ListBlock.svelte +0 -17
- package/src/widgets/simple/StatBlock.svelte +0 -14
- package/src/widgets/simple/TagsBlock.svelte +0 -15
- package/src/widgets/simple/TextBlock.svelte +0 -122
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// recipe-browser — vanilla widget renderer
|
|
4
|
+
// Interactive browser: search, filter by kind/tags, layout toggle, preview, pick/download.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
// NOTE: this widget lives in @webmcp-auto-ui/ui but imports runtime helpers from
|
|
8
|
+
// @webmcp-auto-ui/agent, creating an ESM cycle ui <-> agent. The cycle is safe
|
|
9
|
+
// because these imports are only used inside functions (no top-level eval).
|
|
10
|
+
import { filterRecipes, sortRecipes, recipeToDownloadBlob, recipeToMarkdown } from '@webmcp-auto-ui/agent';
|
|
11
|
+
import type { Recipe } from '@webmcp-auto-ui/agent';
|
|
12
|
+
|
|
13
|
+
export interface RecipeBrowserData {
|
|
14
|
+
recipes: Recipe[];
|
|
15
|
+
filters?: { tags?: string[]; kind?: 'webmcp' | 'mcp' | 'all'; q?: string };
|
|
16
|
+
layout?: 'list' | 'grid';
|
|
17
|
+
onPick?: (recipe: Recipe) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STYLE_ID = 'rb-recipe-browser-styles';
|
|
21
|
+
|
|
22
|
+
function injectStyles(): void {
|
|
23
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
24
|
+
const s = document.createElement('style');
|
|
25
|
+
s.id = STYLE_ID;
|
|
26
|
+
s.textContent = `
|
|
27
|
+
.recipe-browser-root {
|
|
28
|
+
display: flex; flex-direction: column; gap: 10px;
|
|
29
|
+
font-family: var(--font-sans, system-ui, sans-serif);
|
|
30
|
+
color: var(--color-text1, #111);
|
|
31
|
+
background: var(--color-surface, #fff);
|
|
32
|
+
border: 1px solid var(--color-border, #e4e4e7);
|
|
33
|
+
border-radius: 10px;
|
|
34
|
+
padding: 14px;
|
|
35
|
+
min-height: 360px;
|
|
36
|
+
box-sizing: border-box;
|
|
37
|
+
}
|
|
38
|
+
.recipe-browser-root * { box-sizing: border-box; }
|
|
39
|
+
.rb-head {
|
|
40
|
+
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
|
41
|
+
padding-bottom: 10px;
|
|
42
|
+
border-bottom: 1px solid var(--color-border, #e4e4e7);
|
|
43
|
+
}
|
|
44
|
+
.rb-search {
|
|
45
|
+
flex: 1 1 200px; min-width: 160px;
|
|
46
|
+
padding: 7px 10px; font-size: 13px;
|
|
47
|
+
border: 1px solid var(--color-border, #e4e4e7);
|
|
48
|
+
border-radius: 6px;
|
|
49
|
+
background: var(--color-surface, #fff);
|
|
50
|
+
color: var(--color-text1, #111);
|
|
51
|
+
outline: none;
|
|
52
|
+
}
|
|
53
|
+
.rb-search:focus { border-color: var(--color-accent, #6a55ff); }
|
|
54
|
+
.rb-select {
|
|
55
|
+
padding: 7px 10px; font-size: 12px;
|
|
56
|
+
border: 1px solid var(--color-border, #e4e4e7);
|
|
57
|
+
border-radius: 6px;
|
|
58
|
+
background: var(--color-surface, #fff);
|
|
59
|
+
color: var(--color-text1, #111);
|
|
60
|
+
}
|
|
61
|
+
.rb-layout-toggle { display: inline-flex; border: 1px solid var(--color-border, #e4e4e7); border-radius: 6px; overflow: hidden; }
|
|
62
|
+
.rb-layout-btn {
|
|
63
|
+
background: var(--color-surface, #fff); color: var(--color-text2, #666);
|
|
64
|
+
border: 0; padding: 7px 10px; font-size: 12px; cursor: pointer;
|
|
65
|
+
}
|
|
66
|
+
.rb-layout-btn.rb-on { background: var(--color-accent, #6a55ff); color: #fff; }
|
|
67
|
+
.rb-body {
|
|
68
|
+
display: grid; grid-template-columns: minmax(220px, 320px) 1fr;
|
|
69
|
+
gap: 12px; min-height: 280px;
|
|
70
|
+
}
|
|
71
|
+
.rb-body[data-layout="grid"] { grid-template-columns: 1fr; }
|
|
72
|
+
.rb-body[data-layout="grid"] .rb-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; max-height: none; }
|
|
73
|
+
.rb-list {
|
|
74
|
+
display: flex; flex-direction: column; gap: 6px;
|
|
75
|
+
max-height: 420px; overflow-y: auto;
|
|
76
|
+
padding-right: 4px;
|
|
77
|
+
}
|
|
78
|
+
.rb-item {
|
|
79
|
+
padding: 9px 11px; border-radius: 8px;
|
|
80
|
+
background: var(--color-surface2, #f4f4f5);
|
|
81
|
+
border: 1px solid transparent;
|
|
82
|
+
cursor: pointer; transition: background .12s, border-color .12s;
|
|
83
|
+
}
|
|
84
|
+
.rb-item:hover { background: var(--color-surface3, #eeeef0); }
|
|
85
|
+
.rb-item.rb-selected { border-color: var(--color-accent, #6a55ff); background: var(--color-surface3, #eeeef0); }
|
|
86
|
+
.rb-item-name { font-weight: 600; font-size: 13px; margin-bottom: 3px; }
|
|
87
|
+
.rb-item-desc { font-size: 11.5px; color: var(--color-text2, #666); margin-bottom: 6px; line-height: 1.35; }
|
|
88
|
+
.rb-item-foot { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
|
89
|
+
.rb-chip {
|
|
90
|
+
font-size: 10.5px; font-family: var(--font-mono, monospace);
|
|
91
|
+
padding: 1px 6px; border-radius: 999px;
|
|
92
|
+
background: var(--color-surface, #fff);
|
|
93
|
+
border: 1px solid var(--color-border, #e4e4e7);
|
|
94
|
+
color: var(--color-text2, #666);
|
|
95
|
+
}
|
|
96
|
+
.rb-chip.rb-srv { background: var(--color-accent, #6a55ff); color: #fff; border-color: transparent; }
|
|
97
|
+
.rb-preview {
|
|
98
|
+
display: flex; flex-direction: column; min-height: 260px;
|
|
99
|
+
border: 1px solid var(--color-border, #e4e4e7);
|
|
100
|
+
border-radius: 8px; overflow: hidden;
|
|
101
|
+
background: var(--color-surface, #fff);
|
|
102
|
+
}
|
|
103
|
+
.rb-preview-head {
|
|
104
|
+
display: flex; align-items: center; gap: 8px;
|
|
105
|
+
padding: 9px 12px;
|
|
106
|
+
border-bottom: 1px solid var(--color-border, #e4e4e7);
|
|
107
|
+
background: var(--color-surface2, #f4f4f5);
|
|
108
|
+
}
|
|
109
|
+
.rb-preview-title { flex: 1; font-weight: 600; font-size: 13px; }
|
|
110
|
+
.rb-preview-body {
|
|
111
|
+
flex: 1; padding: 12px; overflow-y: auto;
|
|
112
|
+
font-size: 12.5px; line-height: 1.5;
|
|
113
|
+
max-height: 420px;
|
|
114
|
+
}
|
|
115
|
+
.rb-preview-body pre {
|
|
116
|
+
background: var(--color-surface2, #f4f4f5);
|
|
117
|
+
padding: 10px; border-radius: 6px;
|
|
118
|
+
overflow-x: auto;
|
|
119
|
+
font-family: var(--font-mono, monospace);
|
|
120
|
+
font-size: 11.5px;
|
|
121
|
+
white-space: pre-wrap;
|
|
122
|
+
margin: 0;
|
|
123
|
+
}
|
|
124
|
+
.rb-btn {
|
|
125
|
+
background: var(--color-surface2, #f4f4f5);
|
|
126
|
+
border: 1px solid var(--color-border, #e4e4e7);
|
|
127
|
+
border-radius: 6px; padding: 6px 12px; font-size: 12px;
|
|
128
|
+
color: var(--color-text1, #111);
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
}
|
|
131
|
+
.rb-btn:hover { background: var(--color-surface3, #eeeef0); }
|
|
132
|
+
.rb-btn-primary { background: var(--color-accent, #6a55ff); color: #fff; border-color: transparent; }
|
|
133
|
+
.rb-btn-primary:hover { filter: brightness(1.08); }
|
|
134
|
+
.rb-empty { padding: 24px; text-align: center; color: var(--color-text2, #666); font-size: 12px; }
|
|
135
|
+
.rb-placeholder { color: var(--color-text2, #666); font-size: 12px; padding: 24px; text-align: center; }
|
|
136
|
+
`;
|
|
137
|
+
document.head.appendChild(s);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function escapeHtml(s: unknown): string {
|
|
141
|
+
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function detectKind(r: Recipe): 'webmcp' | 'mcp' {
|
|
145
|
+
// Heuristic: if frontmatter 'servers' present and non-empty, treat as MCP-connected.
|
|
146
|
+
const srv = (r as any).server ?? (r as any).serverName;
|
|
147
|
+
if (typeof srv === 'string' && srv && srv !== 'webmcp') return 'mcp';
|
|
148
|
+
return 'webmcp';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function recipeTags(r: Recipe): string[] {
|
|
152
|
+
const out: string[] = [];
|
|
153
|
+
if (Array.isArray(r.servers)) out.push(...r.servers);
|
|
154
|
+
if (Array.isArray(r.components_used)) out.push(...r.components_used.slice(0, 3));
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function serverLabel(r: Recipe): string {
|
|
159
|
+
const srv = (r as any).server ?? (r as any).serverName;
|
|
160
|
+
if (typeof srv === 'string' && srv) return srv;
|
|
161
|
+
if (Array.isArray(r.servers) && r.servers.length) return r.servers[0];
|
|
162
|
+
return 'webmcp';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function simpleMarkdown(body: string): string {
|
|
166
|
+
// Minimal safe rendering: escape + preserve paragraphs + wrap fences in <pre>.
|
|
167
|
+
const escaped = escapeHtml(body);
|
|
168
|
+
// Code fences
|
|
169
|
+
const withFences = escaped.replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
|
170
|
+
return `<pre><code>${code}</code></pre>`;
|
|
171
|
+
});
|
|
172
|
+
// Paragraphs (double newlines)
|
|
173
|
+
const paras = withFences.split(/\n{2,}/).map((p) => {
|
|
174
|
+
if (p.startsWith('<pre>')) return p;
|
|
175
|
+
return `<p>${p.replace(/\n/g, '<br/>')}</p>`;
|
|
176
|
+
});
|
|
177
|
+
return paras.join('\n');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function render(container: HTMLElement, data: RecipeBrowserData): () => void {
|
|
181
|
+
injectStyles();
|
|
182
|
+
|
|
183
|
+
const all: Recipe[] = Array.isArray(data?.recipes) ? data.recipes : [];
|
|
184
|
+
if (!all.length) {
|
|
185
|
+
container.innerHTML = '<div class="recipe-browser-root"><div class="rb-empty">No recipes available.</div></div>';
|
|
186
|
+
return () => { container.innerHTML = ''; };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const initialFilters = data?.filters ?? {};
|
|
190
|
+
let q = initialFilters.q ?? '';
|
|
191
|
+
let kind: 'all' | 'webmcp' | 'mcp' = initialFilters.kind ?? 'all';
|
|
192
|
+
let layout: 'list' | 'grid' = data?.layout === 'grid' ? 'grid' : 'list';
|
|
193
|
+
let selectedId: string | null = null;
|
|
194
|
+
|
|
195
|
+
const root = document.createElement('div');
|
|
196
|
+
root.className = 'recipe-browser-root';
|
|
197
|
+
root.innerHTML = `
|
|
198
|
+
<div class="rb-head">
|
|
199
|
+
<input type="search" class="rb-search" placeholder="Search recipes..." value="${escapeHtml(q)}" />
|
|
200
|
+
<select class="rb-select" data-role="kind">
|
|
201
|
+
<option value="all"${kind === 'all' ? ' selected' : ''}>All kinds</option>
|
|
202
|
+
<option value="webmcp"${kind === 'webmcp' ? ' selected' : ''}>WebMCP</option>
|
|
203
|
+
<option value="mcp"${kind === 'mcp' ? ' selected' : ''}>MCP</option>
|
|
204
|
+
</select>
|
|
205
|
+
<div class="rb-layout-toggle">
|
|
206
|
+
<button type="button" class="rb-layout-btn${layout === 'list' ? ' rb-on' : ''}" data-layout="list">list</button>
|
|
207
|
+
<button type="button" class="rb-layout-btn${layout === 'grid' ? ' rb-on' : ''}" data-layout="grid">grid</button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="rb-body" data-layout="${layout}">
|
|
211
|
+
<div class="rb-list" data-role="list"></div>
|
|
212
|
+
<div class="rb-preview" data-role="preview">
|
|
213
|
+
<div class="rb-placeholder">Select a recipe to preview.</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
`;
|
|
217
|
+
container.innerHTML = '';
|
|
218
|
+
container.appendChild(root);
|
|
219
|
+
|
|
220
|
+
const searchEl = root.querySelector('.rb-search') as HTMLInputElement;
|
|
221
|
+
const kindEl = root.querySelector('[data-role="kind"]') as HTMLSelectElement;
|
|
222
|
+
const bodyEl = root.querySelector('.rb-body') as HTMLElement;
|
|
223
|
+
const listEl = root.querySelector('[data-role="list"]') as HTMLElement;
|
|
224
|
+
const previewEl = root.querySelector('[data-role="preview"]') as HTMLElement;
|
|
225
|
+
const layoutBtns = Array.from(root.querySelectorAll('.rb-layout-btn')) as HTMLButtonElement[];
|
|
226
|
+
|
|
227
|
+
const listeners: Array<{ el: EventTarget; type: string; fn: EventListener }> = [];
|
|
228
|
+
function on<T extends EventTarget>(el: T, type: string, fn: EventListener) {
|
|
229
|
+
el.addEventListener(type, fn);
|
|
230
|
+
listeners.push({ el, type, fn });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function applyFilters(): Recipe[] {
|
|
234
|
+
let filtered = all.slice();
|
|
235
|
+
if (kind !== 'all') filtered = filtered.filter((r) => detectKind(r) === kind);
|
|
236
|
+
// Tag filter (optional): keep only recipes whose tags intersect data.filters.tags
|
|
237
|
+
const wantTags = initialFilters.tags;
|
|
238
|
+
if (Array.isArray(wantTags) && wantTags.length) {
|
|
239
|
+
const wanted = new Set(wantTags.map((t) => String(t).toLowerCase()));
|
|
240
|
+
filtered = filtered.filter((r) => recipeTags(r).some((t) => wanted.has(String(t).toLowerCase())));
|
|
241
|
+
}
|
|
242
|
+
filtered = filterRecipes(filtered, q);
|
|
243
|
+
return sortRecipes(filtered);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderList() {
|
|
247
|
+
const items = applyFilters();
|
|
248
|
+
listEl.innerHTML = '';
|
|
249
|
+
if (!items.length) {
|
|
250
|
+
const empty = document.createElement('div');
|
|
251
|
+
empty.className = 'rb-empty';
|
|
252
|
+
empty.textContent = 'No matches.';
|
|
253
|
+
listEl.appendChild(empty);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
for (const r of items) {
|
|
257
|
+
const id = r.id ?? r.name ?? '';
|
|
258
|
+
const node = document.createElement('div');
|
|
259
|
+
node.className = 'rb-item' + (id === selectedId ? ' rb-selected' : '');
|
|
260
|
+
const tags = recipeTags(r).slice(0, 4);
|
|
261
|
+
node.innerHTML = `
|
|
262
|
+
<div class="rb-item-name">${escapeHtml(r.name || 'Untitled')}</div>
|
|
263
|
+
<div class="rb-item-desc">${escapeHtml(r.description || 'No description')}</div>
|
|
264
|
+
<div class="rb-item-foot">
|
|
265
|
+
<span class="rb-chip rb-srv">${escapeHtml(serverLabel(r))}</span>
|
|
266
|
+
${tags.map((t) => `<span class="rb-chip">${escapeHtml(t)}</span>`).join('')}
|
|
267
|
+
</div>
|
|
268
|
+
`;
|
|
269
|
+
on(node, 'click', () => {
|
|
270
|
+
selectedId = id;
|
|
271
|
+
renderList();
|
|
272
|
+
renderPreview(r);
|
|
273
|
+
});
|
|
274
|
+
listEl.appendChild(node);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderPreview(r: Recipe) {
|
|
279
|
+
const md = typeof r.body === 'string' && r.body.trim()
|
|
280
|
+
? r.body
|
|
281
|
+
: (() => { try { return recipeToMarkdown(r as any); } catch { return ''; } })();
|
|
282
|
+
previewEl.innerHTML = `
|
|
283
|
+
<div class="rb-preview-head">
|
|
284
|
+
<span class="rb-preview-title">${escapeHtml(r.name || 'Untitled')}</span>
|
|
285
|
+
<button type="button" class="rb-btn" data-act="download">Download .md</button>
|
|
286
|
+
<button type="button" class="rb-btn rb-btn-primary" data-act="pick">Pick</button>
|
|
287
|
+
</div>
|
|
288
|
+
<div class="rb-preview-body">${md ? simpleMarkdown(md) : '<div class="rb-placeholder">No body.</div>'}</div>
|
|
289
|
+
`;
|
|
290
|
+
|
|
291
|
+
const pickBtn = previewEl.querySelector('[data-act="pick"]') as HTMLButtonElement | null;
|
|
292
|
+
const dlBtn = previewEl.querySelector('[data-act="download"]') as HTMLButtonElement | null;
|
|
293
|
+
|
|
294
|
+
if (pickBtn) {
|
|
295
|
+
on(pickBtn, 'click', () => {
|
|
296
|
+
if (typeof data.onPick === 'function') {
|
|
297
|
+
data.onPick(r);
|
|
298
|
+
} else {
|
|
299
|
+
container.dispatchEvent(new CustomEvent('widget:interact', {
|
|
300
|
+
bubbles: true,
|
|
301
|
+
detail: { action: 'pick', payload: r },
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (dlBtn) {
|
|
307
|
+
on(dlBtn, 'click', () => {
|
|
308
|
+
try {
|
|
309
|
+
const { blob, filename } = recipeToDownloadBlob(r as any);
|
|
310
|
+
triggerDownload(blob, filename);
|
|
311
|
+
} catch {
|
|
312
|
+
// fallback
|
|
313
|
+
const body = typeof r.body === 'string' ? r.body : '';
|
|
314
|
+
const blob = new Blob([body], { type: 'text/markdown' });
|
|
315
|
+
const filename = (r.name || r.id || 'recipe').replace(/[^a-z0-9_.-]/gi, '-').toLowerCase() + '.md';
|
|
316
|
+
triggerDownload(blob, filename);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function triggerDownload(blob: Blob, filename: string) {
|
|
323
|
+
const url = URL.createObjectURL(blob);
|
|
324
|
+
const a = document.createElement('a');
|
|
325
|
+
a.href = url;
|
|
326
|
+
a.download = filename;
|
|
327
|
+
document.body.appendChild(a);
|
|
328
|
+
a.click();
|
|
329
|
+
a.remove();
|
|
330
|
+
setTimeout(() => URL.revokeObjectURL(url), 500);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
on(searchEl, 'input', () => { q = searchEl.value; renderList(); });
|
|
334
|
+
on(kindEl, 'change', () => { kind = (kindEl.value as any); renderList(); });
|
|
335
|
+
for (const btn of layoutBtns) {
|
|
336
|
+
on(btn, 'click', () => {
|
|
337
|
+
layout = (btn.dataset.layout as 'list' | 'grid') || 'list';
|
|
338
|
+
bodyEl.setAttribute('data-layout', layout);
|
|
339
|
+
layoutBtns.forEach((b) => b.classList.toggle('rb-on', b === btn));
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
renderList();
|
|
344
|
+
|
|
345
|
+
return () => {
|
|
346
|
+
for (const { el, type, fn } of listeners) el.removeEventListener(type, fn);
|
|
347
|
+
listeners.length = 0;
|
|
348
|
+
container.innerHTML = '';
|
|
349
|
+
};
|
|
350
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
widget: notebook
|
|
3
|
+
description: Publication-ready notebook with serif prose and inline cells, all drag-and-droppable in a single ordered flow. Inspired by Observable — cells can be prose paragraphs, sql queries, or js charts, mixed freely in any order to build an article-like narrative.
|
|
4
|
+
schema:
|
|
5
|
+
type: object
|
|
6
|
+
properties:
|
|
7
|
+
id:
|
|
8
|
+
type: string
|
|
9
|
+
title:
|
|
10
|
+
type: string
|
|
11
|
+
mode:
|
|
12
|
+
type: string
|
|
13
|
+
enum: [edit, view]
|
|
14
|
+
kicker:
|
|
15
|
+
type: string
|
|
16
|
+
description: Small uppercase label above the title (e.g. "analysis", "memo", "brief"). Editable inline. Defaults to "untitled".
|
|
17
|
+
hideLiveToggle:
|
|
18
|
+
type: boolean
|
|
19
|
+
default: false
|
|
20
|
+
description: When true, hides the "Live data" toggle in the header (useful for embedded/host-controlled contexts where the autoRun flag is managed externally).
|
|
21
|
+
cells:
|
|
22
|
+
type: array
|
|
23
|
+
description: Mixed flow of prose and code cells. All share the same ordering and can be reordered together.
|
|
24
|
+
items:
|
|
25
|
+
type: object
|
|
26
|
+
required: [type, content]
|
|
27
|
+
properties:
|
|
28
|
+
type:
|
|
29
|
+
type: string
|
|
30
|
+
enum: [md, sql, js]
|
|
31
|
+
description: md = prose paragraph (markdown rendered + sanitized), sql = query cell with table output, js = code cell with chart output
|
|
32
|
+
content:
|
|
33
|
+
type: string
|
|
34
|
+
hideSource:
|
|
35
|
+
type: boolean
|
|
36
|
+
hideResult:
|
|
37
|
+
type: boolean
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## When to use
|
|
41
|
+
|
|
42
|
+
Use `notebook` when the notebook is meant to be published or shared as a finished artifact:
|
|
43
|
+
- Research memos with code appendices visible on demand
|
|
44
|
+
- Blog-style writeups mixing narrative and runnable code
|
|
45
|
+
- Final deliverables where prose leads and code supports
|
|
46
|
+
|
|
47
|
+
The distinguishing feature: prose paragraphs and code cells share a single ordered list, both drag-and-droppable with the same handle. This lets users rearrange the story freely without thinking about "sections".
|
|
48
|
+
|
|
49
|
+
## How to use
|
|
50
|
+
|
|
51
|
+
1. **Start with prose-first seed content** and intersperse code cells:
|
|
52
|
+
```
|
|
53
|
+
widget_display({name: "notebook", params: {
|
|
54
|
+
title: "Q3 observations",
|
|
55
|
+
kicker: "memo",
|
|
56
|
+
cells: [
|
|
57
|
+
{type: "md", content: "This memo covers the highlights of last quarter."},
|
|
58
|
+
{type: "md", content: "We first look at revenue, then at churn."},
|
|
59
|
+
{type: "sql", content: "select * from source limit 10"},
|
|
60
|
+
{type: "md", content: "The table above suggests..."},
|
|
61
|
+
{type: "js", content: "// render a chart"}
|
|
62
|
+
]
|
|
63
|
+
}})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
2. **Use prose paragraphs as transitions** between code blocks. The layout emphasizes reading flow.
|
|
67
|
+
|
|
68
|
+
3. **Code cells render their result in the editorial style**:
|
|
69
|
+
- SQL cells show a minimal mono-spaced table (live data from the connected MCP server's `*_query_sql` tool).
|
|
70
|
+
- JS cells run in an isolated Web Worker with upstream named outputs in scope.
|
|
71
|
+
|
|
72
|
+
4. **Reorder cells freely** — the user can drag a prose paragraph from the bottom to the top, or swap a chart and its introduction, all via the same handle.
|
|
73
|
+
|
|
74
|
+
## Notes
|
|
75
|
+
|
|
76
|
+
- The serif font (EB Garamond, with Georgia fallback) applies only to prose content inside this widget — it signals "publication" the moment the user sees it.
|
|
77
|
+
- The **kicker** above the title ("analysis", "memo", "internal") is editable inline — click to rename. Keep it short.
|
|
78
|
+
- Prose cells are rendered via an HTML-sanitizing markdown pipeline: markdown syntax is resolved, unsafe tags are stripped (XSS closed), `<mark>` and other editorial tags are preserved.
|
|
79
|
+
- The footer exposes a single `share` button.
|
|
80
|
+
- Run / Stop controls are at the left of each code cell's header, same as the other notebook layouts.
|
|
81
|
+
- Unlike the other widgets, `notebook` does not separate prose and code into different flows — they are the same flow in one list.
|
|
82
|
+
|
|
83
|
+
## Left pane — resources from connected servers
|
|
84
|
+
|
|
85
|
+
A collapsible **left pane** (bookmark-bar styling, collapsed by default) lists recipes and tools exposed by connected MCP data servers. Clicking any recipe opens a viewer modal; fenced code blocks expose a `↳ inject` button that drops the snippet into the article flow as a new cell.
|
|
86
|
+
|
|
87
|
+
Two toolbar buttons flank this pane:
|
|
88
|
+
|
|
89
|
+
- **`+ md`** — 3-tab modal (New / File / URL) to insert a prose paragraph from scratch, a local `.md` file, or a URL.
|
|
90
|
+
- **`+ recipe`** — 3-tab modal (Browser / File / URL) to import a recipe from a connected server, a local `.recipe.md` file, or a URL.
|
|
91
|
+
|
|
92
|
+
## Share
|
|
93
|
+
|
|
94
|
+
The `share` button in the footer offers **four formats**:
|
|
95
|
+
|
|
96
|
+
- **Hyperskill link** — copies both the canonical Hyperskill URL and a short domain-scoped URL (`?n=<token>`). The read-only public viewer lives at `nb.hyperskills.net`.
|
|
97
|
+
- **Markdown** — downloads a `.md` file.
|
|
98
|
+
- **PNG** — snapshots the rendered article.
|
|
99
|
+
- **JSON** — exports full widget state.
|
|
100
|
+
|
|
101
|
+
## Integration with connected data servers
|
|
102
|
+
|
|
103
|
+
An editorial piece earns its weight when the prose is anchored to real material. If a MCP **data** server is connected (say `tricoteuses` or `metmuseum`), the agent should — before composing the memo — go and see what the server has to offer:
|
|
104
|
+
|
|
105
|
+
1. Call `{server}_list_recipes()` or `{server}_search_recipes(query)` to find recipes that speak to the subject at hand.
|
|
106
|
+
2. Call `{server}_list_tools()` to survey the available tables and endpoints.
|
|
107
|
+
3. For each recipe or table worth citing, seed one cell that lets the reader touch the evidence — a modest SQL `SELECT ... LIMIT 10`, a `run_script` call, or a prose paragraph that introduces the figure to come. Let prose and code alternate; the editorial flow is built for exactly that.
|
|
108
|
+
4. Pass the server metadata through the `servers:` param so the footer's share affordance, the left pane, and the connect modal reflect the provenance of the piece:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
widget_display({
|
|
112
|
+
name: 'notebook',
|
|
113
|
+
params: {
|
|
114
|
+
title: '...',
|
|
115
|
+
kicker: 'memo',
|
|
116
|
+
cells: [...],
|
|
117
|
+
servers: [{ name: 'tricoteuses', url: 'https://...', kind: 'data' }]
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
When no data server is connected, seed a prose-first skeleton that stakes out the argument and gently invites the reader to connect an MCP server so the memo can take on flesh.
|
|
123
|
+
|
|
124
|
+
**Filter rule**: only MCP *data* servers (`kind: 'data'`) belong in `servers:`. WebMCP UI servers like `autoui` are kept out — they hold no queryable material and have no place in the editorial masthead.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Extract notebook cells from imported resources (recipe body, tool def, md).
|
|
4
|
+
// Consumed by import-modals.ts and left-pane.ts.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
import { parseBody } from '@webmcp-auto-ui/sdk';
|
|
8
|
+
import { uid, defaultCellContent } from './shared.js';
|
|
9
|
+
import type { NotebookCell, CellType } from './shared.js';
|
|
10
|
+
|
|
11
|
+
// Languages we map directly to a code cell type
|
|
12
|
+
const LANG_TO_TYPE: Record<string, CellType> = {
|
|
13
|
+
sql: 'sql',
|
|
14
|
+
psql: 'sql',
|
|
15
|
+
mysql: 'sql',
|
|
16
|
+
sqlite: 'sql',
|
|
17
|
+
js: 'js',
|
|
18
|
+
javascript: 'js',
|
|
19
|
+
ts: 'js', // treat TS as js source (stripped at runtime is user's concern)
|
|
20
|
+
typescript: 'js',
|
|
21
|
+
node: 'js',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Languages that map to 'js' but use a syntax superset that will fail at
|
|
25
|
+
* runtime (interfaces, type annotations, `as` casts, etc.). We log a warning
|
|
26
|
+
* whenever such a fence is mapped so the user gets a hint. */
|
|
27
|
+
const TS_LIKE_LANGS = new Set(['ts', 'typescript']);
|
|
28
|
+
|
|
29
|
+
export function fenceLangToCellType(lang: string): CellType | null {
|
|
30
|
+
const key = (lang || '').toLowerCase().trim();
|
|
31
|
+
if (TS_LIKE_LANGS.has(key)) {
|
|
32
|
+
try {
|
|
33
|
+
console.warn(
|
|
34
|
+
`[notebook] Fence language "${key}" mapped to JS — TS-specific syntax (interfaces, type annotations, "as" casts) will fail at runtime.`
|
|
35
|
+
);
|
|
36
|
+
} catch { /* ignore */ }
|
|
37
|
+
}
|
|
38
|
+
return LANG_TO_TYPE[key] ?? null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a notebook cell from a single fence (lang + content).
|
|
43
|
+
* Unsupported languages fall back to a markdown cell wrapping the fence.
|
|
44
|
+
*/
|
|
45
|
+
export function extractCellFromFence(lang: string, content: string): NotebookCell {
|
|
46
|
+
const cellType = fenceLangToCellType(lang);
|
|
47
|
+
if (cellType) {
|
|
48
|
+
return { id: uid(), type: cellType, content: content.trim(), hideSource: false, hideResult: false };
|
|
49
|
+
}
|
|
50
|
+
// Detect pseudo-code MCP tool calls like: query_sql({sql: "..."})
|
|
51
|
+
// Only attempted when the fence language is unknown/text (cellType === null).
|
|
52
|
+
const trimmed = content.trim();
|
|
53
|
+
const callMatch = trimmed.match(/^([A-Za-z_][\w]*)\s*\(\s*(\{[\s\S]*\})\s*\)\s*;?\s*$/);
|
|
54
|
+
if (callMatch) {
|
|
55
|
+
const name = callMatch[1];
|
|
56
|
+
const argsRaw = callMatch[2];
|
|
57
|
+
if (name === 'query_sql') {
|
|
58
|
+
const sql = extractSqlFromLooseObject(argsRaw);
|
|
59
|
+
if (sql != null) {
|
|
60
|
+
return { id: uid(), type: 'sql', content: sql.trim(), hideSource: false, hideResult: false };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
id: uid(),
|
|
65
|
+
type: 'js',
|
|
66
|
+
content: `// MCP tool call: ${name}\nawait callTool('${name}', ${argsRaw});`,
|
|
67
|
+
hideSource: false,
|
|
68
|
+
hideResult: false,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Preserve the original fence in markdown so users see it verbatim
|
|
72
|
+
return {
|
|
73
|
+
id: uid(),
|
|
74
|
+
type: 'md',
|
|
75
|
+
content: '```' + (lang || '') + '\n' + content.trim() + '\n```',
|
|
76
|
+
hideSource: false,
|
|
77
|
+
hideResult: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Best-effort extraction of the `sql` property value from a loose JS-object literal
|
|
83
|
+
* (unquoted keys, possibly single-quoted strings). Returns null if no sql key found.
|
|
84
|
+
*/
|
|
85
|
+
function extractSqlFromLooseObject(argsRaw: string): string | null {
|
|
86
|
+
// Essai 1: JSON.parse direct (cheap, rarely works for loose objects)
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(argsRaw);
|
|
89
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.sql === 'string') {
|
|
90
|
+
return parsed.sql;
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
/* fallthrough */
|
|
94
|
+
}
|
|
95
|
+
// Essai 2: regex extract sql: "..." (double-quoted)
|
|
96
|
+
const dq = argsRaw.match(/sql\s*:\s*"([\s\S]*?)"/);
|
|
97
|
+
if (dq) return dq[1];
|
|
98
|
+
// Essai 3: regex extract sql: '...' (single-quoted)
|
|
99
|
+
const sq = argsRaw.match(/sql\s*:\s*'([\s\S]*?)'/);
|
|
100
|
+
if (sq) return sq[1];
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extract cells from a full recipe body (markdown with frontmatter already stripped).
|
|
106
|
+
* Returns: a single intro markdown cell (first prose block) + one cell per fenced block.
|
|
107
|
+
*/
|
|
108
|
+
export function extractCellsFromRecipe(body: string, opts?: { title?: string; description?: string }): NotebookCell[] {
|
|
109
|
+
const cells: NotebookCell[] = [];
|
|
110
|
+
if (opts?.title || opts?.description) {
|
|
111
|
+
const md = ['# ' + (opts?.title ?? 'Imported recipe'), opts?.description ?? ''].filter(Boolean).join('\n\n');
|
|
112
|
+
cells.push({ id: uid(), type: 'md', content: md, hideSource: false, hideResult: false });
|
|
113
|
+
}
|
|
114
|
+
const segments = parseBody(body || '');
|
|
115
|
+
for (const seg of segments) {
|
|
116
|
+
if (seg.type === 'markdown') {
|
|
117
|
+
cells.push({ id: uid(), type: 'md', content: seg.content.trim(), hideSource: false, hideResult: false });
|
|
118
|
+
} else {
|
|
119
|
+
cells.push(extractCellFromFence(seg.lang || 'text', seg.content));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return cells;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Produce 2 cells for a tool: md (name + description + schema) + a starter call cell.
|
|
127
|
+
*/
|
|
128
|
+
export interface McpToolLike {
|
|
129
|
+
name: string;
|
|
130
|
+
description?: string;
|
|
131
|
+
inputSchema?: unknown;
|
|
132
|
+
schema?: unknown;
|
|
133
|
+
serverName?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function extractCellsFromTool(tool: McpToolLike): NotebookCell[] {
|
|
137
|
+
const schema = tool.inputSchema ?? tool.schema ?? {};
|
|
138
|
+
const schemaStr = JSON.stringify(schema, null, 2);
|
|
139
|
+
const mdParts: string[] = [
|
|
140
|
+
`## ${tool.name}${tool.serverName ? ` · \`${tool.serverName}\`` : ''}`,
|
|
141
|
+
];
|
|
142
|
+
if (tool.description) mdParts.push(tool.description);
|
|
143
|
+
mdParts.push('```json\n' + schemaStr + '\n```');
|
|
144
|
+
|
|
145
|
+
const isSql = /(_|^)query_sql$|(^|_)sql_query$/i.test(tool.name);
|
|
146
|
+
const cellType: CellType = isSql ? 'sql' : 'js';
|
|
147
|
+
const template = isSql
|
|
148
|
+
? '-- call via MCP bridge: ' + tool.name + '\n' + defaultCellContent('sql')
|
|
149
|
+
: '// call via MCP bridge\nawait callTool(' + JSON.stringify(tool.name) + ', {});';
|
|
150
|
+
|
|
151
|
+
return [
|
|
152
|
+
{ id: uid(), type: 'md', content: mdParts.join('\n\n'), hideSource: false, hideResult: false },
|
|
153
|
+
{ id: uid(), type: cellType, content: template, hideSource: false, hideResult: false },
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Wrap raw markdown content as a single md cell.
|
|
159
|
+
*/
|
|
160
|
+
export function extractCellFromMarkdown(md: string): NotebookCell {
|
|
161
|
+
return { id: uid(), type: 'md', content: (md || '').trim(), hideSource: false, hideResult: false };
|
|
162
|
+
}
|