@webmcp-auto-ui/ui 2.5.37 → 2.5.38
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 +3 -1
- package/src/base/chat-inline.svelte +81 -9
- package/src/widgets/WidgetRenderer.svelte +6 -5
- package/src/widgets/notebook/left-pane.ts +20 -0
- package/src/widgets/notebook/notebook.ts +432 -44
- package/src/widgets/notebook/resource-extractor.ts +16 -1
- package/src/widgets/notebook/share-handlers.ts +20 -1
- package/src/widgets/notebook/shared.ts +185 -25
- package/src/widgets/rich/cards.svelte +3 -1
- package/src/widgets/rich/chart-rich.svelte +73 -7
- package/src/widgets/rich/data-table.svelte +28 -7
- package/src/widgets/rich/map.svelte +392 -0
- package/src/widgets/rich/stat-card.svelte +119 -20
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
import {
|
|
9
9
|
createState, injectStyles, mountRunControls, mountHistoryPanel,
|
|
10
10
|
setupDnD, deleteCellWithConfirm, restoreCellFromSnapshot, addCell,
|
|
11
|
-
addImportedCells, registerExecutor, collectDataServers,
|
|
11
|
+
addImportedCells, registerExecutor, collectDataServers, collectWebmcpServers,
|
|
12
12
|
autosize, openShareModal, registerHistoryObserver,
|
|
13
|
-
renderCellLogs,
|
|
13
|
+
renderCellLogs, uid, defaultCellContent,
|
|
14
14
|
createPublishControls, autoConnectFrontmatterServers,
|
|
15
15
|
createRuntimeOverlay, effectiveResult, cellRuntimeStatus,
|
|
16
16
|
lastRefreshedAt, bootstrapLiveRefresh, fmtRelTime, preserveScrollAround,
|
|
@@ -25,6 +25,42 @@ import { extractCellsFromRecipe, extractCellFromMarkdown } from './resource-extr
|
|
|
25
25
|
import { mountLeftPane } from './left-pane.js';
|
|
26
26
|
import { highlightCode } from '../../primitives/markdown-renderer.js';
|
|
27
27
|
import { createSqlExecutor } from './executors/sql.js';
|
|
28
|
+
import { runCode } from '@webmcp-auto-ui/sdk';
|
|
29
|
+
import { canvas } from '@webmcp-auto-ui/sdk/canvas';
|
|
30
|
+
import { mountWidget } from '@webmcp-auto-ui/core';
|
|
31
|
+
|
|
32
|
+
// Side-effect imports — register native widget custom elements so that
|
|
33
|
+
// `mountWidget(host, name, params, servers)` can resolve `<auto-${name}>`
|
|
34
|
+
// even when WidgetRenderer.svelte is not mounted on the page (e.g. notebook-
|
|
35
|
+
// viewer, which mounts the notebook widget via vanilla renderNotebook but
|
|
36
|
+
// never imports WidgetRenderer). Without these imports `customElements.get`
|
|
37
|
+
// returns undefined and every `await widget(...)` result falls through to the
|
|
38
|
+
// silent `container.textContent = '[${type}]'` branch.
|
|
39
|
+
import '../simple/stat.svelte';
|
|
40
|
+
import '../simple/kv.svelte';
|
|
41
|
+
import '../simple/list.svelte';
|
|
42
|
+
import '../simple/chart.svelte';
|
|
43
|
+
import '../simple/alert.svelte';
|
|
44
|
+
import '../simple/code.svelte';
|
|
45
|
+
import '../simple/text.svelte';
|
|
46
|
+
import '../simple/actions.svelte';
|
|
47
|
+
import '../simple/tags.svelte';
|
|
48
|
+
import '../rich/stat-card.svelte';
|
|
49
|
+
import '../rich/profile.svelte';
|
|
50
|
+
import '../rich/json-viewer.svelte';
|
|
51
|
+
import '../rich/chart-rich.svelte';
|
|
52
|
+
import '../rich/sankey.svelte';
|
|
53
|
+
import '../rich/hemicycle.svelte';
|
|
54
|
+
import '../rich/data-table.svelte';
|
|
55
|
+
import '../rich/timeline.svelte';
|
|
56
|
+
import '../rich/trombinoscope.svelte';
|
|
57
|
+
import '../rich/cards.svelte';
|
|
58
|
+
import '../rich/grid-data.svelte';
|
|
59
|
+
import '../rich/js-sandbox.svelte';
|
|
60
|
+
import '../rich/log.svelte';
|
|
61
|
+
import '../rich/gallery.svelte';
|
|
62
|
+
import '../rich/carousel.svelte';
|
|
63
|
+
import '../rich/map.svelte';
|
|
28
64
|
|
|
29
65
|
export async function render(container: HTMLElement, data: Record<string, unknown>): Promise<() => void> {
|
|
30
66
|
injectStyles();
|
|
@@ -38,7 +74,13 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
38
74
|
autoRun: (data as any).autoRun === true,
|
|
39
75
|
publishedSlug: (data as any).publishedSlug,
|
|
40
76
|
publishedToken: (data as any).publishedToken,
|
|
41
|
-
|
|
77
|
+
webmcpServers: (data as any).webmcpServers,
|
|
78
|
+
chatApiBase: (data as any).chatApiBase,
|
|
79
|
+
hidePublishBadge: (data as any).hidePublishBadge === true,
|
|
80
|
+
} as any);
|
|
81
|
+
|
|
82
|
+
// Default src visibility per mode (toggle remains usable for per-cell override).
|
|
83
|
+
for (const c of state.cells) c.hideSource = state.mode === 'view';
|
|
42
84
|
|
|
43
85
|
// Live mode runtime overlay (created lazily). Never mutates state.
|
|
44
86
|
let overlay: RuntimeOverlay | null = null;
|
|
@@ -57,8 +99,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
57
99
|
<div class="nbe-shell">
|
|
58
100
|
<div class="nbe-kicker">
|
|
59
101
|
<div class="nb-mode-switch" style="margin-left:auto;">
|
|
60
|
-
<button class="nb-mode-edit nb-on">edit</button>
|
|
61
|
-
<button class="nb-mode-view">view</button>
|
|
102
|
+
<button class="nb-mode-edit ${state.mode === 'edit' ? 'nb-on' : ''}">edit</button>
|
|
103
|
+
<button class="nb-mode-view ${state.mode === 'view' ? 'nb-on' : ''}">view</button>
|
|
62
104
|
</div>
|
|
63
105
|
<button class="nb-btn nbe-history-btn">⟲ history</button>
|
|
64
106
|
<span class="nbe-publish-badge-slot"></span>
|
|
@@ -71,11 +113,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
71
113
|
<div class="nb-history-panel nbe-history-panel"></div>
|
|
72
114
|
<div class="nbe-cells"></div>
|
|
73
115
|
<div class="nbe-footer">
|
|
74
|
-
<button class="nb-btn nb-add-cell" data-add="md">+ prose</button>
|
|
75
|
-
<button class="nb-btn nb-add-cell" data-add="sql">+ sql</button>
|
|
76
|
-
<button class="nb-btn nb-add-cell" data-add="js">+ chart</button>
|
|
77
|
-
<button class="nb-btn nb-add-cell" data-add-modal="md">+ md</button>
|
|
78
|
-
<button class="nb-btn nb-add-cell" data-add-modal="recipe">+ recipe</button>
|
|
79
116
|
<span class="nbe-share-btn" title="Share">share</span>
|
|
80
117
|
<span class="nbe-publish-slot"></span>
|
|
81
118
|
</div>
|
|
@@ -233,7 +270,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
233
270
|
});
|
|
234
271
|
const publishCleanup = createPublishControls(state, {
|
|
235
272
|
buttonSlot: shell.querySelector('.nbe-publish-slot') as HTMLElement,
|
|
236
|
-
badgeSlot: shell.querySelector('.nbe-publish-badge-slot') as HTMLElement,
|
|
273
|
+
badgeSlot: state.hidePublishBadge ? undefined : (shell.querySelector('.nbe-publish-badge-slot') as HTMLElement),
|
|
237
274
|
footerSlot: shell.querySelector('.nbe-publish-footer-slot') as HTMLElement,
|
|
238
275
|
onPublished: () => rerender(),
|
|
239
276
|
});
|
|
@@ -245,6 +282,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
245
282
|
const viewBtn = shell.querySelector('.nb-mode-view') as HTMLElement;
|
|
246
283
|
editBtn.addEventListener('click', () => {
|
|
247
284
|
state.mode = 'edit';
|
|
285
|
+
for (const c of state.cells) c.hideSource = false;
|
|
248
286
|
container.classList.remove('nb-view-mode');
|
|
249
287
|
editBtn.classList.add('nb-on'); viewBtn.classList.remove('nb-on');
|
|
250
288
|
// Leaving view: stop live refresh and clear overlay so frozen snapshots show.
|
|
@@ -253,14 +291,21 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
253
291
|
});
|
|
254
292
|
viewBtn.addEventListener('click', () => {
|
|
255
293
|
state.mode = 'view';
|
|
294
|
+
for (const c of state.cells) c.hideSource = true;
|
|
256
295
|
container.classList.add('nb-view-mode');
|
|
257
296
|
viewBtn.classList.add('nb-on'); editBtn.classList.remove('nb-on');
|
|
258
297
|
if (state.autoRun === true) bootstrapLive();
|
|
259
298
|
rerender();
|
|
260
299
|
});
|
|
261
300
|
|
|
262
|
-
|
|
263
|
-
|
|
301
|
+
|
|
302
|
+
// Left pane (collapsed by default). Lists remote data servers AND bundled
|
|
303
|
+
// WebMCP servers (autoui, d3, …) in two distinct groups.
|
|
304
|
+
const collectAll = () => [
|
|
305
|
+
...collectDataServers(data),
|
|
306
|
+
...collectWebmcpServers(state.webmcpServers),
|
|
307
|
+
];
|
|
308
|
+
const pane = mountLeftPane(leftPaneHost, state, collectAll(), {
|
|
264
309
|
onInjectCells: (cells) => {
|
|
265
310
|
addImportedCells(state, cells, activeCellIdx());
|
|
266
311
|
rerender();
|
|
@@ -269,14 +314,14 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
269
314
|
|
|
270
315
|
// Auto-connect data servers declared in the recipe frontmatter (data.servers).
|
|
271
316
|
// The notebook reads MCP state passively from canvas.dataServers.
|
|
272
|
-
autoConnectFrontmatterServers(data, () => pane.setServers(
|
|
317
|
+
autoConnectFrontmatterServers(data, () => pane.setServers(collectAll()));
|
|
273
318
|
|
|
274
319
|
// Keep pane servers in sync with canvas changes
|
|
275
320
|
let canvasUnsub: (() => void) | null = null;
|
|
276
321
|
try {
|
|
277
322
|
const canvasAny: any = (globalThis as any).__canvasVanilla || (globalThis as any).canvasVanilla;
|
|
278
323
|
if (canvasAny?.subscribe) {
|
|
279
|
-
canvasUnsub = canvasAny.subscribe(() => pane.setServers(
|
|
324
|
+
canvasUnsub = canvasAny.subscribe(() => pane.setServers(collectAll()));
|
|
280
325
|
}
|
|
281
326
|
} catch { /* ignore */ }
|
|
282
327
|
|
|
@@ -304,38 +349,50 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
304
349
|
// Executors (same pattern as compact/workspace/document agents)
|
|
305
350
|
// ---------------------------------------------------------------------------
|
|
306
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Convert RunLog[] (objects with {t, msg}) returned by runCode into the
|
|
354
|
+
* string[] format expected by CellResult.logs. Each entry becomes a
|
|
355
|
+
* human-readable "<offset>ms <message>" line. Without this conversion every
|
|
356
|
+
* log entry would coerce to "[object Object]" in renderCellLogs.
|
|
357
|
+
*/
|
|
358
|
+
function logsToStrings(logs: Array<{ t: number; msg: string }> | undefined): string[] {
|
|
359
|
+
if (!logs || logs.length === 0) return [];
|
|
360
|
+
return logs.map((l) => `${l.t}ms ${l.msg}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
307
363
|
async function jsExecutor(ctx: CellExecContext): Promise<CellResult> {
|
|
308
364
|
const start = Date.now();
|
|
309
365
|
const { cell, scope } = ctx;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
366
|
+
// Accept both `call(...)` (SDK) and `callTool(...)` (legacy / AI-generated)
|
|
367
|
+
// as aliases. runCode injects `call` and `widget` helpers around an
|
|
368
|
+
// AsyncFunction body, so top-level `await` and MCP calls work uniformly.
|
|
369
|
+
const code = cell.content.replace(/\bcallTool\s*\(/g, 'call(');
|
|
370
|
+
const res = await runCode(code, 'js', canvas.multiClient, scope);
|
|
371
|
+
const durationMs = Date.now() - start;
|
|
372
|
+
const logs = logsToStrings(res.logs as any);
|
|
373
|
+
if (res.status === 'error') {
|
|
374
|
+
return { ok: false, error: res.error ?? 'error', errorKind: 'runtime', durationMs, logs };
|
|
375
|
+
}
|
|
376
|
+
// Widgets emitted via `widget(name, params)` or via `*_widget_display(...)`
|
|
377
|
+
// calls — surface them as a dedicated kind so the host can mount them.
|
|
378
|
+
const widgets = res.widgets ?? (res.widget ? [res.widget] : []);
|
|
379
|
+
if (widgets.length > 0) {
|
|
380
|
+
return { ok: true, kind: 'widget', widgets, durationMs, logs };
|
|
381
|
+
}
|
|
382
|
+
const result = res.output;
|
|
383
|
+
if (result === undefined || result === null) return { ok: true, kind: 'empty', durationMs, logs };
|
|
384
|
+
if (Array.isArray(result)) {
|
|
385
|
+
const rows = result.filter((r) => r && typeof r === 'object') as Record<string, unknown>[];
|
|
386
|
+
const columns = rows.length ? Array.from(new Set(rows.flatMap((r) => Object.keys(r)))) : [];
|
|
387
|
+
return { ok: true, kind: 'table', rows, columns, rowCount: rows.length, durationMs, logs };
|
|
388
|
+
}
|
|
389
|
+
if (result && typeof result === 'object') {
|
|
390
|
+
const r: any = result;
|
|
391
|
+
if (r.data || r.marks || r.mark || r.$schema) {
|
|
392
|
+
return { ok: true, kind: 'chart', spec: result, durationMs, logs };
|
|
334
393
|
}
|
|
335
|
-
return { ok: true, kind: 'value', value: result, durationMs };
|
|
336
|
-
} catch (err: any) {
|
|
337
|
-
return { ok: false, error: String(err?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - start };
|
|
338
394
|
}
|
|
395
|
+
return { ok: true, kind: 'value', value: result, durationMs, logs };
|
|
339
396
|
}
|
|
340
397
|
|
|
341
398
|
// ---------------------------------------------------------------------------
|
|
@@ -434,7 +491,7 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
|
|
|
434
491
|
if (!cell.hideResult) {
|
|
435
492
|
const res = document.createElement('div');
|
|
436
493
|
res.className = 'nbe-result';
|
|
437
|
-
renderResultInto(res, cell, overlay);
|
|
494
|
+
renderResultInto(res, cell, overlay, state);
|
|
438
495
|
codeCell.appendChild(res);
|
|
439
496
|
}
|
|
440
497
|
|
|
@@ -443,9 +500,324 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
|
|
|
443
500
|
(head.querySelector('.nb-toggle-src') as HTMLElement).addEventListener('click', () => { cell.hideSource = !cell.hideSource; rerender(); });
|
|
444
501
|
(head.querySelector('.nb-toggle-res') as HTMLElement).addEventListener('click', () => { cell.hideResult = !cell.hideResult; rerender(); });
|
|
445
502
|
|
|
503
|
+
if (state.mode !== 'view') wrap.appendChild(renderCellActionBar(state, cell, rerender));
|
|
504
|
+
|
|
446
505
|
return wrap;
|
|
447
506
|
}
|
|
448
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Per-cell action bar: [+ text] [+ sql] [+ JS] [+ widget] [+ agent]
|
|
510
|
+
* Inserts a new cell directly after this one, or opens a picker / agent input.
|
|
511
|
+
* `widget` and `agent` are wired in dedicated handlers below.
|
|
512
|
+
*/
|
|
513
|
+
function renderCellActionBar(state: NotebookState, cell: NotebookCell, rerender: () => void): HTMLElement {
|
|
514
|
+
const bar = document.createElement('div');
|
|
515
|
+
bar.className = 'nbe-cell-actionbar';
|
|
516
|
+
const idx = state.cells.findIndex((c) => c.id === cell.id);
|
|
517
|
+
const insertAfter = (type: 'md' | 'sql' | 'js', content?: string) => {
|
|
518
|
+
const newCell: NotebookCell = {
|
|
519
|
+
id: uid(),
|
|
520
|
+
type,
|
|
521
|
+
content: content ?? defaultCellContent(type),
|
|
522
|
+
hideSource: false, hideResult: false, status: 'stale',
|
|
523
|
+
};
|
|
524
|
+
addImportedCells(state, [newCell], idx);
|
|
525
|
+
rerender();
|
|
526
|
+
};
|
|
527
|
+
bar.innerHTML = `
|
|
528
|
+
<button class="nb-btn nb-cellbar-btn" data-add="md">+ text</button>
|
|
529
|
+
<button class="nb-btn nb-cellbar-btn" data-add="sql">+ sql</button>
|
|
530
|
+
<button class="nb-btn nb-cellbar-btn" data-add="js">+ JS</button>
|
|
531
|
+
<button class="nb-btn nb-cellbar-btn" data-cellbar="widget">+ widget</button>
|
|
532
|
+
<button class="nb-btn nb-cellbar-btn" data-cellbar="agent">+ agent</button>`;
|
|
533
|
+
bar.querySelectorAll<HTMLElement>('[data-add]').forEach((btn) => {
|
|
534
|
+
btn.addEventListener('click', () => insertAfter(btn.dataset.add as 'md' | 'sql' | 'js'));
|
|
535
|
+
});
|
|
536
|
+
bar.querySelector<HTMLElement>('[data-cellbar="widget"]')?.addEventListener('click', () => {
|
|
537
|
+
openWidgetPicker(state, cell, rerender);
|
|
538
|
+
});
|
|
539
|
+
bar.querySelector<HTMLElement>('[data-cellbar="agent"]')?.addEventListener('click', () => {
|
|
540
|
+
toggleAgentBar(bar, state, cell, rerender);
|
|
541
|
+
});
|
|
542
|
+
return bar;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* +widget picker — lists the recipes exposed by every active WebMCP server
|
|
547
|
+
* (state.webmcpServers, populated from frontmatter.webmcp_servers). On pick,
|
|
548
|
+
* inserts a JS cell after the current one with a template that reads the
|
|
549
|
+
* upstream cell's varname (default 'rows') and renders the widget.
|
|
550
|
+
*/
|
|
551
|
+
function openWidgetPicker(state: NotebookState, cell: NotebookCell, rerender: () => void): void {
|
|
552
|
+
const servers = state.webmcpServers ?? [];
|
|
553
|
+
type Item = { server: string; name: string; description?: string };
|
|
554
|
+
const items: Item[] = [];
|
|
555
|
+
for (const s of servers) {
|
|
556
|
+
const recipes = s.layer().recipes ?? [];
|
|
557
|
+
for (const r of recipes) items.push({ server: s.name, name: r.name, description: r.description });
|
|
558
|
+
}
|
|
559
|
+
if (items.length === 0) {
|
|
560
|
+
toastNoServers();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const upstreamVar = findUpstreamVarname(state, cell);
|
|
564
|
+
const overlay = document.createElement('div');
|
|
565
|
+
overlay.className = 'nbe-picker-overlay';
|
|
566
|
+
overlay.innerHTML = `
|
|
567
|
+
<div class="nbe-picker">
|
|
568
|
+
<header>
|
|
569
|
+
<input class="nbe-picker-q" placeholder="filter widgets…" autofocus />
|
|
570
|
+
<button class="nbe-picker-close" type="button">✕</button>
|
|
571
|
+
</header>
|
|
572
|
+
<ul class="nbe-picker-list"></ul>
|
|
573
|
+
</div>`;
|
|
574
|
+
document.body.appendChild(overlay);
|
|
575
|
+
const close = () => { try { overlay.remove(); } catch { /* ignore */ } };
|
|
576
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
|
577
|
+
(overlay.querySelector('.nbe-picker-close') as HTMLElement).addEventListener('click', close);
|
|
578
|
+
const list = overlay.querySelector('.nbe-picker-list') as HTMLElement;
|
|
579
|
+
const q = overlay.querySelector('.nbe-picker-q') as HTMLInputElement;
|
|
580
|
+
const render = (filter = '') => {
|
|
581
|
+
const f = filter.toLowerCase().trim();
|
|
582
|
+
list.innerHTML = items
|
|
583
|
+
.filter((i) => !f || i.name.toLowerCase().includes(f) || (i.description ?? '').toLowerCase().includes(f) || i.server.toLowerCase().includes(f))
|
|
584
|
+
.map((i) => `<li data-name="${escapeAttr(i.name)}" data-server="${escapeAttr(i.server)}">
|
|
585
|
+
<span class="nbe-picker-name">${escapeHtml(i.name)}</span>
|
|
586
|
+
<span class="nbe-picker-server">${escapeHtml(i.server)}</span>
|
|
587
|
+
<span class="nbe-picker-desc">${escapeHtml(i.description ?? '')}</span>
|
|
588
|
+
</li>`).join('');
|
|
589
|
+
};
|
|
590
|
+
render();
|
|
591
|
+
q.addEventListener('input', () => render(q.value));
|
|
592
|
+
list.addEventListener('click', (e) => {
|
|
593
|
+
const li = (e.target as HTMLElement).closest('li[data-name]') as HTMLElement | null;
|
|
594
|
+
if (!li) return;
|
|
595
|
+
const name = li.dataset.name as string;
|
|
596
|
+
const idx = state.cells.findIndex((c) => c.id === cell.id);
|
|
597
|
+
const template = upstreamVar
|
|
598
|
+
? `return widget('${name}', { rows: ${upstreamVar} })`
|
|
599
|
+
: `return widget('${name}', {})`;
|
|
600
|
+
const newCell: NotebookCell = {
|
|
601
|
+
id: uid(), type: 'js',
|
|
602
|
+
content: template,
|
|
603
|
+
hideSource: false, hideResult: false, status: 'stale',
|
|
604
|
+
};
|
|
605
|
+
addImportedCells(state, [newCell], idx);
|
|
606
|
+
close();
|
|
607
|
+
rerender();
|
|
608
|
+
});
|
|
609
|
+
function toastNoServers() {
|
|
610
|
+
const t = document.createElement('div');
|
|
611
|
+
t.className = 'nbe-toast nbe-toast-error nbe-toast-in';
|
|
612
|
+
t.textContent = 'No WebMCP server is enabled — add some in flex first (autoui, d3, observable-plot…)';
|
|
613
|
+
document.body.appendChild(t);
|
|
614
|
+
setTimeout(() => t.remove(), 3500);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Walk back from `cell` to find the nearest cell with a varname (typically a
|
|
620
|
+
* SQL cell). The widget template will bind to that variable so the chosen
|
|
621
|
+
* widget visualises the upstream cell's output. Returns null if none.
|
|
622
|
+
*/
|
|
623
|
+
function findUpstreamVarname(state: NotebookState, cell: NotebookCell): string | null {
|
|
624
|
+
const idx = state.cells.findIndex((c) => c.id === cell.id);
|
|
625
|
+
for (let i = idx; i >= 0; i--) {
|
|
626
|
+
const c = state.cells[i];
|
|
627
|
+
if (c?.varname) return c.varname;
|
|
628
|
+
}
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* +agent bar — toggled inline below the cell action bar. The user types a
|
|
634
|
+
* prompt in <auto-chat-input>, runAgentLoop is invoked with a focused tool
|
|
635
|
+
* layer that lets the LLM:
|
|
636
|
+
* - read the current cell + scope
|
|
637
|
+
* - rewrite the current cell content
|
|
638
|
+
* - insert a new cell after it (text / sql / js / widget)
|
|
639
|
+
* Every mutation logs into state.history so the user can ⟲ revert.
|
|
640
|
+
* Provider: RemoteLLMProvider via /api/chat (proxy reads env LLM_API_KEY).
|
|
641
|
+
*/
|
|
642
|
+
function toggleAgentBar(host: HTMLElement, state: NotebookState, cell: NotebookCell, rerender: () => void): void {
|
|
643
|
+
const existing = host.parentElement?.querySelector(':scope > .nbe-agent-bar');
|
|
644
|
+
if (existing) { existing.remove(); return; }
|
|
645
|
+
const bar = document.createElement('div');
|
|
646
|
+
bar.className = 'nbe-agent-bar';
|
|
647
|
+
bar.innerHTML = `
|
|
648
|
+
<auto-chat-input placeholder="ask agent — e.g. 'filter rows where votes > 50' / 'add a sankey of this'"></auto-chat-input>
|
|
649
|
+
<div class="nbe-agent-status" hidden></div>`;
|
|
650
|
+
host.insertAdjacentElement('afterend', bar);
|
|
651
|
+
const input = bar.querySelector('auto-chat-input') as HTMLElement & { disabled?: boolean };
|
|
652
|
+
const status = bar.querySelector('.nbe-agent-status') as HTMLElement;
|
|
653
|
+
let aborter: AbortController | null = null;
|
|
654
|
+
input.addEventListener('widget:interact', (e: Event) => {
|
|
655
|
+
const detail = (e as CustomEvent).detail ?? {};
|
|
656
|
+
const action = (detail as { action?: string }).action;
|
|
657
|
+
// Prevent the event bubbling to the host (flex/etc.) — otherwise their
|
|
658
|
+
// own widget-interaction handler would launch a SECOND, unconstrained
|
|
659
|
+
// agent loop that renders widgets outside the notebook (canvas blocks).
|
|
660
|
+
if (action === 'stop') { e.stopPropagation(); aborter?.abort(); return; }
|
|
661
|
+
if (action !== 'submit') return;
|
|
662
|
+
e.stopPropagation();
|
|
663
|
+
const text = ((detail as { payload?: { text?: string } }).payload?.text ?? '').trim();
|
|
664
|
+
if (!text) return;
|
|
665
|
+
void runAgentForCell(text, state, cell, rerender, status, input, () => aborter ??= new AbortController());
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function runAgentForCell(
|
|
670
|
+
prompt: string,
|
|
671
|
+
state: NotebookState,
|
|
672
|
+
cell: NotebookCell,
|
|
673
|
+
rerender: () => void,
|
|
674
|
+
status: HTMLElement,
|
|
675
|
+
input: HTMLElement & { disabled?: boolean },
|
|
676
|
+
getAborter: () => AbortController,
|
|
677
|
+
): Promise<void> {
|
|
678
|
+
status.hidden = false;
|
|
679
|
+
status.textContent = '…';
|
|
680
|
+
input.disabled = true;
|
|
681
|
+
const aborter = getAborter();
|
|
682
|
+
try {
|
|
683
|
+
// Lazy import to keep notebook bundle slim when agent is unused.
|
|
684
|
+
const { RemoteLLMProvider, runAgentLoop } = await import('@webmcp-auto-ui/agent');
|
|
685
|
+
// Host injects chatApiBase via data (flex serves under /flex, notebook-viewer
|
|
686
|
+
// at root). Default '/api/chat' kept for hosts mounting at the root.
|
|
687
|
+
const proxyUrl = (state as { chatApiBase?: string }).chatApiBase ?? '/api/chat';
|
|
688
|
+
const provider = new RemoteLLMProvider({ proxyUrl, model: 'haiku' });
|
|
689
|
+
const layer = buildAgentLayerForCell(state, cell, rerender);
|
|
690
|
+
const systemPrompt = buildAgentSystemPromptForCell(state, cell);
|
|
691
|
+
await runAgentLoop(prompt, {
|
|
692
|
+
provider,
|
|
693
|
+
layers: [layer],
|
|
694
|
+
systemPrompt,
|
|
695
|
+
maxIterations: 6,
|
|
696
|
+
signal: aborter.signal,
|
|
697
|
+
callbacks: {
|
|
698
|
+
onToolCall: (call: { name?: string }) => { status.textContent = `· ${call?.name ?? '?'}…`; },
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
status.textContent = '✓ done';
|
|
702
|
+
} catch (err) {
|
|
703
|
+
status.textContent = 'error: ' + String((err as { message?: unknown })?.message ?? err);
|
|
704
|
+
} finally {
|
|
705
|
+
input.disabled = false;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Build the WebMCP tool layer the agent sees. Tools mutate state via the
|
|
711
|
+
* shared helpers (addImportedCells, logHistory) so revert via ⟲ history works
|
|
712
|
+
* exactly like a manual edit.
|
|
713
|
+
*/
|
|
714
|
+
function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerender: () => void) {
|
|
715
|
+
const idx = () => state.cells.findIndex((c) => c.id === cell.id);
|
|
716
|
+
const tools = [
|
|
717
|
+
{
|
|
718
|
+
name: 'get_current_cell',
|
|
719
|
+
description: 'Read the cell the user invoked the agent on (id, type, content, varname).',
|
|
720
|
+
inputSchema: { type: 'object', properties: {} },
|
|
721
|
+
execute: async () => ({
|
|
722
|
+
id: cell.id, type: cell.type,
|
|
723
|
+
content: cell.content,
|
|
724
|
+
varname: (cell as { varname?: string }).varname,
|
|
725
|
+
}),
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
name: 'list_cells',
|
|
729
|
+
description: 'List all cells in the notebook (id, type, short content preview).',
|
|
730
|
+
inputSchema: { type: 'object', properties: {} },
|
|
731
|
+
execute: async () => state.cells.map((c) => ({
|
|
732
|
+
id: c.id, type: c.type,
|
|
733
|
+
preview: (c.content ?? '').slice(0, 120),
|
|
734
|
+
varname: (c as { varname?: string }).varname,
|
|
735
|
+
})),
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
name: 'update_cell',
|
|
739
|
+
description: 'Replace the current cell content with new code/markdown. The cell type is preserved.',
|
|
740
|
+
inputSchema: {
|
|
741
|
+
type: 'object',
|
|
742
|
+
properties: { content: { type: 'string', description: 'New full content for the cell.' } },
|
|
743
|
+
required: ['content'],
|
|
744
|
+
},
|
|
745
|
+
execute: async (args: Record<string, unknown>) => {
|
|
746
|
+
const next = String(args.content ?? '');
|
|
747
|
+
const before = cell.content;
|
|
748
|
+
if (before === next) return { ok: true, unchanged: true };
|
|
749
|
+
// Snapshot prior content so logHistory can support revert.
|
|
750
|
+
cell.content = next;
|
|
751
|
+
cell.status = 'stale';
|
|
752
|
+
state.lastEditAt = Date.now();
|
|
753
|
+
// Manual history entry — reuse `edit` kind, summary mentions agent.
|
|
754
|
+
try {
|
|
755
|
+
(state.history ?? []).push({
|
|
756
|
+
ts: Date.now(), kind: 'edit',
|
|
757
|
+
summary: `agent edited ${cell.type} cell`,
|
|
758
|
+
snapshot: { cellId: cell.id, before, after: next },
|
|
759
|
+
} as never);
|
|
760
|
+
} catch { /* ignore — best-effort */ }
|
|
761
|
+
rerender();
|
|
762
|
+
return { ok: true };
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
name: 'insert_cell_after',
|
|
767
|
+
description: 'Insert a new cell directly after the current one. Use type=text for prose, sql for queries, js for scripts/widgets.',
|
|
768
|
+
inputSchema: {
|
|
769
|
+
type: 'object',
|
|
770
|
+
properties: {
|
|
771
|
+
type: { type: 'string', enum: ['text', 'sql', 'js'], description: '"text" for markdown prose, "sql" for a query, "js" for a script (use widget(name, params) to render a widget).' },
|
|
772
|
+
content: { type: 'string', description: 'Cell content.' },
|
|
773
|
+
varname: { type: 'string', description: 'Optional varname for sql/js cells — exposes the result as a variable to subsequent cells.' },
|
|
774
|
+
},
|
|
775
|
+
required: ['type', 'content'],
|
|
776
|
+
},
|
|
777
|
+
execute: async (args: Record<string, unknown>) => {
|
|
778
|
+
const t = String(args.type ?? '');
|
|
779
|
+
const type: NotebookCell['type'] = t === 'text' ? 'md' : t === 'sql' ? 'sql' : 'js';
|
|
780
|
+
const newCell: NotebookCell = {
|
|
781
|
+
id: uid(), type,
|
|
782
|
+
content: String(args.content ?? ''),
|
|
783
|
+
hideSource: false, hideResult: false, status: 'stale',
|
|
784
|
+
...(args.varname ? { varname: String(args.varname) } : {}),
|
|
785
|
+
};
|
|
786
|
+
addImportedCells(state, [newCell], idx());
|
|
787
|
+
rerender();
|
|
788
|
+
return { ok: true, id: newCell.id };
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
name: 'list_widgets',
|
|
793
|
+
description: 'List widgets exposed by the connected WebMCP servers — pickable in JS cells via widget(name, params).',
|
|
794
|
+
inputSchema: { type: 'object', properties: {} },
|
|
795
|
+
execute: async () => {
|
|
796
|
+
const out: Array<{ server: string; name: string; description?: string }> = [];
|
|
797
|
+
for (const s of state.webmcpServers ?? []) {
|
|
798
|
+
for (const r of s.layer().recipes ?? []) out.push({ server: s.name, name: r.name, description: r.description });
|
|
799
|
+
}
|
|
800
|
+
return out;
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
];
|
|
804
|
+
return { protocol: 'webmcp' as const, serverName: 'notebook-editor', tools, recipes: [] };
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function buildAgentSystemPromptForCell(_state: NotebookState, cell: NotebookCell): string {
|
|
808
|
+
return [
|
|
809
|
+
'You are an in-notebook editing assistant. The user invoked you on a specific cell.',
|
|
810
|
+
'Your job: rewrite that cell, OR insert a follow-up cell after it, based on the user\'s prompt.',
|
|
811
|
+
`The current cell is of type "${cell.type}".`,
|
|
812
|
+
'Workflow:',
|
|
813
|
+
' 1. Call get_current_cell to read the cell content + varname.',
|
|
814
|
+
' 2. If the user wants to TRANSFORM the existing cell (filter, refactor, fix), call update_cell.',
|
|
815
|
+
' 3. If the user wants a FOLLOW-UP step (chart, summary, follow-up query), call insert_cell_after.',
|
|
816
|
+
' 4. For widgets: call list_widgets first to discover available names, then write a JS cell with `return widget("<name>", { rows, ...params })`.',
|
|
817
|
+
'Be terse. One or two tool calls is usually enough. Do not explain at length.',
|
|
818
|
+
].join('\n');
|
|
819
|
+
}
|
|
820
|
+
|
|
449
821
|
// ---------------------------------------------------------------------------
|
|
450
822
|
// Result rendering — editorial flavour (serif headers, mono cells, discreet)
|
|
451
823
|
// ---------------------------------------------------------------------------
|
|
@@ -471,7 +843,7 @@ function formatMs(ms: number): string {
|
|
|
471
843
|
return (ms / 1000).toFixed(2) + 's';
|
|
472
844
|
}
|
|
473
845
|
|
|
474
|
-
function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null): void {
|
|
846
|
+
function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null, stateRef?: NotebookState): void {
|
|
475
847
|
const r = effectiveResult(cell, overlay) ?? cell.lastResult;
|
|
476
848
|
el.innerHTML = '';
|
|
477
849
|
if (!r) {
|
|
@@ -509,6 +881,22 @@ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeO
|
|
|
509
881
|
renderChart(chart, r.spec).catch(() => { /* fallback handled internally */ });
|
|
510
882
|
return;
|
|
511
883
|
}
|
|
884
|
+
if (r.kind === 'widget') {
|
|
885
|
+
const fallbackServers = stateRef?.webmcpServers ?? [];
|
|
886
|
+
for (const w of r.widgets) {
|
|
887
|
+
const host = document.createElement('div');
|
|
888
|
+
host.className = 'nb-widget-host';
|
|
889
|
+
el.appendChild(host);
|
|
890
|
+
try { mountWidget(host, w.name, w.params, fallbackServers); }
|
|
891
|
+
catch (err) {
|
|
892
|
+
const pre = document.createElement('pre');
|
|
893
|
+
pre.className = 'nbe-result-error';
|
|
894
|
+
pre.textContent = `widget "${w.name}" failed: ${String((err as { message?: unknown })?.message ?? err)}`;
|
|
895
|
+
host.appendChild(pre);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
512
900
|
// table — editorial style: serif header row, mono cells, minimal chrome.
|
|
513
901
|
const cols = r.columns && r.columns.length ? r.columns
|
|
514
902
|
: (r.rows[0] ? Object.keys(r.rows[0]) : []);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Consumed by import-modals.ts and left-pane.ts.
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
|
|
7
|
-
import { parseBody } from '@webmcp-auto-ui/sdk';
|
|
7
|
+
import { parseBody, parseWidgetDisplayCall } from '@webmcp-auto-ui/sdk';
|
|
8
8
|
import { uid, defaultCellContent } from './shared.js';
|
|
9
9
|
import type { NotebookCell, CellType } from './shared.js';
|
|
10
10
|
|
|
@@ -80,6 +80,21 @@ export function extractCellFromFence(lang: string, content: string): NotebookCel
|
|
|
80
80
|
return { id: uid(), type: 'sql', content: sql.trim(), hideSource: false, hideResult: false };
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
// widget_display is a local WebMCP tool (autoui), not a remote MCP tool —
|
|
84
|
+
// route it to the JS sandbox `widget()` helper which captures and renders
|
|
85
|
+
// inline. callTool() would fail with "No MCP server exposes tool".
|
|
86
|
+
if (/^(?:[A-Za-z_]\w*_)?widget_display$/.test(name)) {
|
|
87
|
+
const parsed = parseWidgetDisplayCall(trimmed);
|
|
88
|
+
if (parsed) {
|
|
89
|
+
const paramsLiteral = JSON.stringify(parsed.params, null, 2);
|
|
90
|
+
return {
|
|
91
|
+
id: uid(),
|
|
92
|
+
type: 'js',
|
|
93
|
+
content: `return widget('${parsed.name}', ${paramsLiteral})`,
|
|
94
|
+
hideSource: false, hideResult: false,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
83
98
|
return {
|
|
84
99
|
id: uid(),
|
|
85
100
|
type: 'js',
|
|
@@ -71,7 +71,8 @@ function buildFrontmatter(state: NotebookState): string {
|
|
|
71
71
|
const title = (state.title || '').trim();
|
|
72
72
|
const description = extractDescription(state);
|
|
73
73
|
const servers = collectEnabledServers();
|
|
74
|
-
|
|
74
|
+
const webmcpServers = collectEnabledWebmcpServers();
|
|
75
|
+
if (!title && !description && servers.length === 0 && webmcpServers.length === 0) return '';
|
|
75
76
|
|
|
76
77
|
const lines: string[] = ['---'];
|
|
77
78
|
if (title) lines.push(`title: ${yamlQuote(title)}`);
|
|
@@ -83,10 +84,28 @@ function buildFrontmatter(state: NotebookState): string {
|
|
|
83
84
|
lines.push(` url: ${yamlQuote(s.url)}`);
|
|
84
85
|
}
|
|
85
86
|
}
|
|
87
|
+
if (webmcpServers.length > 0) {
|
|
88
|
+
// YAML flow-style for compactness (registry ids, no spaces).
|
|
89
|
+
lines.push(`webmcp_servers: [${webmcpServers.map(yamlQuote).join(', ')}]`);
|
|
90
|
+
}
|
|
86
91
|
lines.push('---', '');
|
|
87
92
|
return lines.join('\n');
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Read canvas.enabledServerIds — the registry ids (e.g. 'autoui', 'd3',
|
|
97
|
+
* 'observable-plot') of bundled WebMCP servers active in this notebook.
|
|
98
|
+
* The viewer re-instantiates them from @webmcp-auto-ui/servers on load.
|
|
99
|
+
*/
|
|
100
|
+
function collectEnabledWebmcpServers(): string[] {
|
|
101
|
+
try {
|
|
102
|
+
const ids = (canvasVanilla as { enabledServerIds?: string[] }).enabledServerIds ?? [];
|
|
103
|
+
return ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
90
109
|
function extractDescription(state: NotebookState): string {
|
|
91
110
|
for (const cell of state.cells) {
|
|
92
111
|
if (cell.type !== 'md') continue;
|