@webmcp-auto-ui/ui 2.5.36 → 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/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/base/chat-inline.svelte +81 -9
- package/src/index.ts +1 -0
- package/src/primitives/MarkdownView.svelte +12 -2
- package/src/recipe/RecipeCodeBlock.svelte +10 -2
- package/src/recipe/RecipeRunModal.svelte +245 -0
- package/src/widgets/WidgetRenderer.svelte +8 -5
- package/src/widgets/notebook/executors/sql.ts +9 -6
- package/src/widgets/notebook/import-modal-api.ts +15 -19
- package/src/widgets/notebook/import-modal.svelte +5 -4
- package/src/widgets/notebook/left-pane.ts +23 -3
- package/src/widgets/notebook/notebook.svelte +0 -1
- package/src/widgets/notebook/notebook.ts +437 -80
- package/src/widgets/notebook/resource-extractor.ts +16 -1
- package/src/widgets/notebook/share-handlers.ts +90 -1
- package/src/widgets/notebook/shared.ts +260 -88
- 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();
|
|
@@ -36,7 +72,15 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
36
72
|
mode: (data.mode as any) ?? 'edit',
|
|
37
73
|
cells: data.cells as any,
|
|
38
74
|
autoRun: (data as any).autoRun === true,
|
|
39
|
-
|
|
75
|
+
publishedSlug: (data as any).publishedSlug,
|
|
76
|
+
publishedToken: (data as any).publishedToken,
|
|
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';
|
|
40
84
|
|
|
41
85
|
// Live mode runtime overlay (created lazily). Never mutates state.
|
|
42
86
|
let overlay: RuntimeOverlay | null = null;
|
|
@@ -54,10 +98,9 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
54
98
|
<div class="nbe-leftpane-slot"></div>
|
|
55
99
|
<div class="nbe-shell">
|
|
56
100
|
<div class="nbe-kicker">
|
|
57
|
-
<span class="nbe-live-toggle-slot"></span>
|
|
58
101
|
<div class="nb-mode-switch" style="margin-left:auto;">
|
|
59
|
-
<button class="nb-mode-edit nb-on">edit</button>
|
|
60
|
-
<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>
|
|
61
104
|
</div>
|
|
62
105
|
<button class="nb-btn nbe-history-btn">⟲ history</button>
|
|
63
106
|
<span class="nbe-publish-badge-slot"></span>
|
|
@@ -70,11 +113,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
70
113
|
<div class="nb-history-panel nbe-history-panel"></div>
|
|
71
114
|
<div class="nbe-cells"></div>
|
|
72
115
|
<div class="nbe-footer">
|
|
73
|
-
<button class="nb-btn nb-add-cell" data-add="md">+ prose</button>
|
|
74
|
-
<button class="nb-btn nb-add-cell" data-add="sql">+ sql</button>
|
|
75
|
-
<button class="nb-btn nb-add-cell" data-add="js">+ chart</button>
|
|
76
|
-
<button class="nb-btn nb-add-cell" data-add-modal="md">+ md</button>
|
|
77
|
-
<button class="nb-btn nb-add-cell" data-add-modal="recipe">+ recipe</button>
|
|
78
116
|
<span class="nbe-share-btn" title="Share">share</span>
|
|
79
117
|
<span class="nbe-publish-slot"></span>
|
|
80
118
|
</div>
|
|
@@ -104,24 +142,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
104
142
|
});
|
|
105
143
|
}
|
|
106
144
|
|
|
107
|
-
const hideLiveToggle = (data as any).hideLiveToggle === true;
|
|
108
|
-
|
|
109
|
-
function renderLiveToggle() {
|
|
110
|
-
const slot = shell.querySelector('.nbe-live-toggle-slot') as HTMLElement;
|
|
111
|
-
if (hideLiveToggle) { slot.innerHTML = ''; return; }
|
|
112
|
-
if (state.mode === 'edit') {
|
|
113
|
-
const checked = state.autoRun === true ? 'checked' : '';
|
|
114
|
-
slot.innerHTML = `<label class="nbe-live-toggle" title="Re-execute SQL cells against connected servers when this notebook is opened in view mode."><input type="checkbox" ${checked} />Live data</label>`;
|
|
115
|
-
const cb = slot.querySelector('input[type=checkbox]') as HTMLInputElement;
|
|
116
|
-
cb.addEventListener('change', () => {
|
|
117
|
-
state.autoRun = cb.checked;
|
|
118
|
-
rerender();
|
|
119
|
-
});
|
|
120
|
-
} else {
|
|
121
|
-
slot.innerHTML = '';
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
145
|
function renderLiveBadge() {
|
|
126
146
|
const slot = shell.querySelector('.nbe-live-badge-slot') as HTMLElement;
|
|
127
147
|
if (state.mode === 'view' && state.autoRun === true) {
|
|
@@ -194,7 +214,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
194
214
|
function rerender() {
|
|
195
215
|
const restore = preserveScrollAround(cellsEl);
|
|
196
216
|
mountHistoryPanel(historyPanel, state, (snap) => { restoreCellFromSnapshot(state, snap); rerender(); });
|
|
197
|
-
renderLiveToggle();
|
|
198
217
|
renderLiveBadge();
|
|
199
218
|
renderEmptyState();
|
|
200
219
|
renderCells();
|
|
@@ -221,9 +240,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
221
240
|
rerender();
|
|
222
241
|
});
|
|
223
242
|
} else if (which === 'recipe') {
|
|
224
|
-
const mcpServers = (
|
|
225
|
-
.map((s
|
|
226
|
-
.filter((s: any) => s.name);
|
|
243
|
+
const mcpServers = collectDataServers(data)
|
|
244
|
+
.map((s) => ({ name: s.name, url: s.url }));
|
|
227
245
|
openAddRecipeModal({
|
|
228
246
|
mcpServers,
|
|
229
247
|
scope: 'data',
|
|
@@ -252,7 +270,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
252
270
|
});
|
|
253
271
|
const publishCleanup = createPublishControls(state, {
|
|
254
272
|
buttonSlot: shell.querySelector('.nbe-publish-slot') as HTMLElement,
|
|
255
|
-
badgeSlot: shell.querySelector('.nbe-publish-badge-slot') as HTMLElement,
|
|
273
|
+
badgeSlot: state.hidePublishBadge ? undefined : (shell.querySelector('.nbe-publish-badge-slot') as HTMLElement),
|
|
256
274
|
footerSlot: shell.querySelector('.nbe-publish-footer-slot') as HTMLElement,
|
|
257
275
|
onPublished: () => rerender(),
|
|
258
276
|
});
|
|
@@ -264,6 +282,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
264
282
|
const viewBtn = shell.querySelector('.nb-mode-view') as HTMLElement;
|
|
265
283
|
editBtn.addEventListener('click', () => {
|
|
266
284
|
state.mode = 'edit';
|
|
285
|
+
for (const c of state.cells) c.hideSource = false;
|
|
267
286
|
container.classList.remove('nb-view-mode');
|
|
268
287
|
editBtn.classList.add('nb-on'); viewBtn.classList.remove('nb-on');
|
|
269
288
|
// Leaving view: stop live refresh and clear overlay so frozen snapshots show.
|
|
@@ -272,14 +291,21 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
272
291
|
});
|
|
273
292
|
viewBtn.addEventListener('click', () => {
|
|
274
293
|
state.mode = 'view';
|
|
294
|
+
for (const c of state.cells) c.hideSource = true;
|
|
275
295
|
container.classList.add('nb-view-mode');
|
|
276
296
|
viewBtn.classList.add('nb-on'); editBtn.classList.remove('nb-on');
|
|
277
297
|
if (state.autoRun === true) bootstrapLive();
|
|
278
298
|
rerender();
|
|
279
299
|
});
|
|
280
300
|
|
|
281
|
-
|
|
282
|
-
|
|
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(), {
|
|
283
309
|
onInjectCells: (cells) => {
|
|
284
310
|
addImportedCells(state, cells, activeCellIdx());
|
|
285
311
|
rerender();
|
|
@@ -287,15 +313,15 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
287
313
|
});
|
|
288
314
|
|
|
289
315
|
// Auto-connect data servers declared in the recipe frontmatter (data.servers).
|
|
290
|
-
// The notebook reads MCP state passively from
|
|
291
|
-
autoConnectFrontmatterServers(data, () => pane.setServers(
|
|
316
|
+
// The notebook reads MCP state passively from canvas.dataServers.
|
|
317
|
+
autoConnectFrontmatterServers(data, () => pane.setServers(collectAll()));
|
|
292
318
|
|
|
293
319
|
// Keep pane servers in sync with canvas changes
|
|
294
320
|
let canvasUnsub: (() => void) | null = null;
|
|
295
321
|
try {
|
|
296
322
|
const canvasAny: any = (globalThis as any).__canvasVanilla || (globalThis as any).canvasVanilla;
|
|
297
323
|
if (canvasAny?.subscribe) {
|
|
298
|
-
canvasUnsub = canvasAny.subscribe(() => pane.setServers(
|
|
324
|
+
canvasUnsub = canvasAny.subscribe(() => pane.setServers(collectAll()));
|
|
299
325
|
}
|
|
300
326
|
} catch { /* ignore */ }
|
|
301
327
|
|
|
@@ -323,38 +349,50 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
323
349
|
// Executors (same pattern as compact/workspace/document agents)
|
|
324
350
|
// ---------------------------------------------------------------------------
|
|
325
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
|
+
|
|
326
363
|
async function jsExecutor(ctx: CellExecContext): Promise<CellResult> {
|
|
327
364
|
const start = Date.now();
|
|
328
365
|
const { cell, scope } = ctx;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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 };
|
|
353
393
|
}
|
|
354
|
-
return { ok: true, kind: 'value', value: result, durationMs };
|
|
355
|
-
} catch (err: any) {
|
|
356
|
-
return { ok: false, error: String(err?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - start };
|
|
357
394
|
}
|
|
395
|
+
return { ok: true, kind: 'value', value: result, durationMs, logs };
|
|
358
396
|
}
|
|
359
397
|
|
|
360
398
|
// ---------------------------------------------------------------------------
|
|
@@ -453,7 +491,7 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
|
|
|
453
491
|
if (!cell.hideResult) {
|
|
454
492
|
const res = document.createElement('div');
|
|
455
493
|
res.className = 'nbe-result';
|
|
456
|
-
renderResultInto(res, cell, overlay);
|
|
494
|
+
renderResultInto(res, cell, overlay, state);
|
|
457
495
|
codeCell.appendChild(res);
|
|
458
496
|
}
|
|
459
497
|
|
|
@@ -462,9 +500,324 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
|
|
|
462
500
|
(head.querySelector('.nb-toggle-src') as HTMLElement).addEventListener('click', () => { cell.hideSource = !cell.hideSource; rerender(); });
|
|
463
501
|
(head.querySelector('.nb-toggle-res') as HTMLElement).addEventListener('click', () => { cell.hideResult = !cell.hideResult; rerender(); });
|
|
464
502
|
|
|
503
|
+
if (state.mode !== 'view') wrap.appendChild(renderCellActionBar(state, cell, rerender));
|
|
504
|
+
|
|
465
505
|
return wrap;
|
|
466
506
|
}
|
|
467
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
|
+
|
|
468
821
|
// ---------------------------------------------------------------------------
|
|
469
822
|
// Result rendering — editorial flavour (serif headers, mono cells, discreet)
|
|
470
823
|
// ---------------------------------------------------------------------------
|
|
@@ -490,7 +843,7 @@ function formatMs(ms: number): string {
|
|
|
490
843
|
return (ms / 1000).toFixed(2) + 's';
|
|
491
844
|
}
|
|
492
845
|
|
|
493
|
-
function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null): void {
|
|
846
|
+
function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null, stateRef?: NotebookState): void {
|
|
494
847
|
const r = effectiveResult(cell, overlay) ?? cell.lastResult;
|
|
495
848
|
el.innerHTML = '';
|
|
496
849
|
if (!r) {
|
|
@@ -528,6 +881,22 @@ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeO
|
|
|
528
881
|
renderChart(chart, r.spec).catch(() => { /* fallback handled internally */ });
|
|
529
882
|
return;
|
|
530
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
|
+
}
|
|
531
900
|
// table — editorial style: serif header row, mono cells, minimal chrome.
|
|
532
901
|
const cols = r.columns && r.columns.length ? r.columns
|
|
533
902
|
: (r.rows[0] ? Object.keys(r.rows[0]) : []);
|
|
@@ -801,18 +1170,6 @@ function injectLayoutStyles(): void {
|
|
|
801
1170
|
.nbe-toast.nbe-toast-in { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
802
1171
|
.nbe-toast.nbe-toast-error { color: var(--color-accent2); border-color: var(--color-accent2); }
|
|
803
1172
|
|
|
804
|
-
/* Live mode — discreet toggle in header (edit mode only) */
|
|
805
|
-
.nbe-live-toggle {
|
|
806
|
-
display: inline-flex; align-items: center; gap: 6px;
|
|
807
|
-
font-family: var(--font-mono, 'IBM Plex Mono', monospace);
|
|
808
|
-
font-size: 10.5px; color: var(--color-text2);
|
|
809
|
-
letter-spacing: 0.06em; text-transform: uppercase;
|
|
810
|
-
cursor: pointer; user-select: none;
|
|
811
|
-
padding: 2px 7px; border: 1px solid var(--color-border); border-radius: 4px;
|
|
812
|
-
}
|
|
813
|
-
.nbe-live-toggle:hover { color: var(--color-text1); border-color: var(--color-border2); }
|
|
814
|
-
.nbe-live-toggle input { margin: 0; cursor: pointer; }
|
|
815
|
-
|
|
816
1173
|
/* Title row + Live badge (view mode + autoRun) */
|
|
817
1174
|
.nbe-title-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
|
818
1175
|
.nbe-title-row .nbe-title { flex: 1; min-width: 0; }
|