@webmcp-auto-ui/ui 2.5.37 → 2.5.39
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 +487 -47
- package/src/widgets/notebook/resource-extractor.ts +16 -1
- package/src/widgets/notebook/share-handlers.ts +24 -3
- package/src/widgets/notebook/shared.ts +187 -27
- 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,16 @@ 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 + logs visibility per mode (toggle remains usable for per-cell override).
|
|
83
|
+
for (const c of state.cells) {
|
|
84
|
+
c.hideSource = state.mode === 'view';
|
|
85
|
+
if (state.mode === 'view' && c.hideLogs === undefined) c.hideLogs = true;
|
|
86
|
+
}
|
|
42
87
|
|
|
43
88
|
// Live mode runtime overlay (created lazily). Never mutates state.
|
|
44
89
|
let overlay: RuntimeOverlay | null = null;
|
|
@@ -57,8 +102,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
57
102
|
<div class="nbe-shell">
|
|
58
103
|
<div class="nbe-kicker">
|
|
59
104
|
<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>
|
|
105
|
+
<button class="nb-mode-edit ${state.mode === 'edit' ? 'nb-on' : ''}">edit</button>
|
|
106
|
+
<button class="nb-mode-view ${state.mode === 'view' ? 'nb-on' : ''}">view</button>
|
|
62
107
|
</div>
|
|
63
108
|
<button class="nb-btn nbe-history-btn">⟲ history</button>
|
|
64
109
|
<span class="nbe-publish-badge-slot"></span>
|
|
@@ -71,11 +116,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
71
116
|
<div class="nb-history-panel nbe-history-panel"></div>
|
|
72
117
|
<div class="nbe-cells"></div>
|
|
73
118
|
<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
119
|
<span class="nbe-share-btn" title="Share">share</span>
|
|
80
120
|
<span class="nbe-publish-slot"></span>
|
|
81
121
|
</div>
|
|
@@ -233,7 +273,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
233
273
|
});
|
|
234
274
|
const publishCleanup = createPublishControls(state, {
|
|
235
275
|
buttonSlot: shell.querySelector('.nbe-publish-slot') as HTMLElement,
|
|
236
|
-
badgeSlot: shell.querySelector('.nbe-publish-badge-slot') as HTMLElement,
|
|
276
|
+
badgeSlot: state.hidePublishBadge ? undefined : (shell.querySelector('.nbe-publish-badge-slot') as HTMLElement),
|
|
237
277
|
footerSlot: shell.querySelector('.nbe-publish-footer-slot') as HTMLElement,
|
|
238
278
|
onPublished: () => rerender(),
|
|
239
279
|
});
|
|
@@ -245,6 +285,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
245
285
|
const viewBtn = shell.querySelector('.nb-mode-view') as HTMLElement;
|
|
246
286
|
editBtn.addEventListener('click', () => {
|
|
247
287
|
state.mode = 'edit';
|
|
288
|
+
for (const c of state.cells) { c.hideSource = false; c.hideLogs = false; }
|
|
248
289
|
container.classList.remove('nb-view-mode');
|
|
249
290
|
editBtn.classList.add('nb-on'); viewBtn.classList.remove('nb-on');
|
|
250
291
|
// Leaving view: stop live refresh and clear overlay so frozen snapshots show.
|
|
@@ -253,14 +294,21 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
253
294
|
});
|
|
254
295
|
viewBtn.addEventListener('click', () => {
|
|
255
296
|
state.mode = 'view';
|
|
297
|
+
for (const c of state.cells) { c.hideSource = true; c.hideLogs = true; }
|
|
256
298
|
container.classList.add('nb-view-mode');
|
|
257
299
|
viewBtn.classList.add('nb-on'); editBtn.classList.remove('nb-on');
|
|
258
300
|
if (state.autoRun === true) bootstrapLive();
|
|
259
301
|
rerender();
|
|
260
302
|
});
|
|
261
303
|
|
|
262
|
-
|
|
263
|
-
|
|
304
|
+
|
|
305
|
+
// Left pane (collapsed by default). Lists remote data servers AND bundled
|
|
306
|
+
// WebMCP servers (autoui, d3, …) in two distinct groups.
|
|
307
|
+
const collectAll = () => [
|
|
308
|
+
...collectDataServers(data),
|
|
309
|
+
...collectWebmcpServers(state.webmcpServers),
|
|
310
|
+
];
|
|
311
|
+
const pane = mountLeftPane(leftPaneHost, state, collectAll(), {
|
|
264
312
|
onInjectCells: (cells) => {
|
|
265
313
|
addImportedCells(state, cells, activeCellIdx());
|
|
266
314
|
rerender();
|
|
@@ -269,14 +317,14 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
269
317
|
|
|
270
318
|
// Auto-connect data servers declared in the recipe frontmatter (data.servers).
|
|
271
319
|
// The notebook reads MCP state passively from canvas.dataServers.
|
|
272
|
-
autoConnectFrontmatterServers(data, () => pane.setServers(
|
|
320
|
+
autoConnectFrontmatterServers(data, () => pane.setServers(collectAll()));
|
|
273
321
|
|
|
274
322
|
// Keep pane servers in sync with canvas changes
|
|
275
323
|
let canvasUnsub: (() => void) | null = null;
|
|
276
324
|
try {
|
|
277
325
|
const canvasAny: any = (globalThis as any).__canvasVanilla || (globalThis as any).canvasVanilla;
|
|
278
326
|
if (canvasAny?.subscribe) {
|
|
279
|
-
canvasUnsub = canvasAny.subscribe(() => pane.setServers(
|
|
327
|
+
canvasUnsub = canvasAny.subscribe(() => pane.setServers(collectAll()));
|
|
280
328
|
}
|
|
281
329
|
} catch { /* ignore */ }
|
|
282
330
|
|
|
@@ -304,38 +352,50 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
304
352
|
// Executors (same pattern as compact/workspace/document agents)
|
|
305
353
|
// ---------------------------------------------------------------------------
|
|
306
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Convert RunLog[] (objects with {t, msg}) returned by runCode into the
|
|
357
|
+
* string[] format expected by CellResult.logs. Each entry becomes a
|
|
358
|
+
* human-readable "<offset>ms <message>" line. Without this conversion every
|
|
359
|
+
* log entry would coerce to "[object Object]" in renderCellLogs.
|
|
360
|
+
*/
|
|
361
|
+
function logsToStrings(logs: Array<{ t: number; msg: string }> | undefined): string[] {
|
|
362
|
+
if (!logs || logs.length === 0) return [];
|
|
363
|
+
return logs.map((l) => `${l.t}ms ${l.msg}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
307
366
|
async function jsExecutor(ctx: CellExecContext): Promise<CellResult> {
|
|
308
367
|
const start = Date.now();
|
|
309
368
|
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
|
-
|
|
369
|
+
// Accept both `call(...)` (SDK) and `callTool(...)` (legacy / AI-generated)
|
|
370
|
+
// as aliases. runCode injects `call` and `widget` helpers around an
|
|
371
|
+
// AsyncFunction body, so top-level `await` and MCP calls work uniformly.
|
|
372
|
+
const code = cell.content.replace(/\bcallTool\s*\(/g, 'call(');
|
|
373
|
+
const res = await runCode(code, 'js', canvas.multiClient, scope);
|
|
374
|
+
const durationMs = Date.now() - start;
|
|
375
|
+
const logs = logsToStrings(res.logs as any);
|
|
376
|
+
if (res.status === 'error') {
|
|
377
|
+
return { ok: false, error: res.error ?? 'error', errorKind: 'runtime', durationMs, logs };
|
|
378
|
+
}
|
|
379
|
+
// Widgets emitted via `widget(name, params)` or via `*_widget_display(...)`
|
|
380
|
+
// calls — surface them as a dedicated kind so the host can mount them.
|
|
381
|
+
const widgets = res.widgets ?? (res.widget ? [res.widget] : []);
|
|
382
|
+
if (widgets.length > 0) {
|
|
383
|
+
return { ok: true, kind: 'widget', widgets, durationMs, logs };
|
|
384
|
+
}
|
|
385
|
+
const result = res.output;
|
|
386
|
+
if (result === undefined || result === null) return { ok: true, kind: 'empty', durationMs, logs };
|
|
387
|
+
if (Array.isArray(result)) {
|
|
388
|
+
const rows = result.filter((r) => r && typeof r === 'object') as Record<string, unknown>[];
|
|
389
|
+
const columns = rows.length ? Array.from(new Set(rows.flatMap((r) => Object.keys(r)))) : [];
|
|
390
|
+
return { ok: true, kind: 'table', rows, columns, rowCount: rows.length, durationMs, logs };
|
|
391
|
+
}
|
|
392
|
+
if (result && typeof result === 'object') {
|
|
393
|
+
const r: any = result;
|
|
394
|
+
if (r.data || r.marks || r.mark || r.$schema) {
|
|
395
|
+
return { ok: true, kind: 'chart', spec: result, durationMs, logs };
|
|
334
396
|
}
|
|
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
397
|
}
|
|
398
|
+
return { ok: true, kind: 'value', value: result, durationMs, logs };
|
|
339
399
|
}
|
|
340
400
|
|
|
341
401
|
// ---------------------------------------------------------------------------
|
|
@@ -397,17 +457,25 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
|
|
|
397
457
|
liveBadge = `<span class="nbe-cell-badge nbe-cell-frozen" title="JS cells are not re-executed in live mode">frozen</span>`;
|
|
398
458
|
}
|
|
399
459
|
}
|
|
460
|
+
const lastRes = effectiveResult(cell, overlay) ?? cell.lastResult;
|
|
461
|
+
const logCount = (lastRes?.logs as string[] | undefined)?.length ?? 0;
|
|
462
|
+
const logsToggle = logCount > 0
|
|
463
|
+
? `<button class="nb-icon-btn nb-toggle-logs" title="toggle console">${cell.hideLogs ? '▸' : '▾'} ${logCount}</button>`
|
|
464
|
+
: '';
|
|
400
465
|
head.innerHTML = `
|
|
401
466
|
<span class="nbe-run-controls"></span>
|
|
402
467
|
<span class="nbe-type-${cell.type}">${cell.type}</span>
|
|
403
468
|
<span class="nbe-meta-info">${escapeHtml(metaInfoFor(cell, overlay))}</span>
|
|
404
469
|
${liveBadge}
|
|
405
470
|
<div class="nbe-actions">
|
|
471
|
+
${logsToggle}
|
|
406
472
|
<button class="nb-icon-btn nb-toggle-src">${cell.hideSource ? '▸ src' : '◂ src'}</button>
|
|
407
473
|
<button class="nb-icon-btn nb-toggle-res">${cell.hideResult ? '▸ res' : '◂ res'}</button>
|
|
408
474
|
</div>`;
|
|
409
475
|
codeCell.appendChild(head);
|
|
410
476
|
mountRunControls(head.querySelector('.nbe-run-controls') as HTMLElement, cell, wrap, state, rerender);
|
|
477
|
+
const logsBtn = head.querySelector('.nb-toggle-logs') as HTMLElement | null;
|
|
478
|
+
if (logsBtn) logsBtn.addEventListener('click', () => { cell.hideLogs = !cell.hideLogs; rerender(); });
|
|
411
479
|
|
|
412
480
|
const body = document.createElement('div');
|
|
413
481
|
body.className = 'nbe-code-body' + (cell.hideSource ? ' nbe-hidden' : '');
|
|
@@ -434,7 +502,7 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
|
|
|
434
502
|
if (!cell.hideResult) {
|
|
435
503
|
const res = document.createElement('div');
|
|
436
504
|
res.className = 'nbe-result';
|
|
437
|
-
renderResultInto(res, cell, overlay);
|
|
505
|
+
renderResultInto(res, cell, overlay, state);
|
|
438
506
|
codeCell.appendChild(res);
|
|
439
507
|
}
|
|
440
508
|
|
|
@@ -443,9 +511,324 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
|
|
|
443
511
|
(head.querySelector('.nb-toggle-src') as HTMLElement).addEventListener('click', () => { cell.hideSource = !cell.hideSource; rerender(); });
|
|
444
512
|
(head.querySelector('.nb-toggle-res') as HTMLElement).addEventListener('click', () => { cell.hideResult = !cell.hideResult; rerender(); });
|
|
445
513
|
|
|
514
|
+
if (state.mode !== 'view') wrap.appendChild(renderCellActionBar(state, cell, rerender));
|
|
515
|
+
|
|
446
516
|
return wrap;
|
|
447
517
|
}
|
|
448
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Per-cell action bar: [+ text] [+ sql] [+ JS] [+ widget] [+ agent]
|
|
521
|
+
* Inserts a new cell directly after this one, or opens a picker / agent input.
|
|
522
|
+
* `widget` and `agent` are wired in dedicated handlers below.
|
|
523
|
+
*/
|
|
524
|
+
function renderCellActionBar(state: NotebookState, cell: NotebookCell, rerender: () => void): HTMLElement {
|
|
525
|
+
const bar = document.createElement('div');
|
|
526
|
+
bar.className = 'nbe-cell-actionbar';
|
|
527
|
+
const idx = state.cells.findIndex((c) => c.id === cell.id);
|
|
528
|
+
const insertAfter = (type: 'md' | 'sql' | 'js', content?: string) => {
|
|
529
|
+
const newCell: NotebookCell = {
|
|
530
|
+
id: uid(),
|
|
531
|
+
type,
|
|
532
|
+
content: content ?? defaultCellContent(type),
|
|
533
|
+
hideSource: false, hideResult: false, status: 'stale',
|
|
534
|
+
};
|
|
535
|
+
addImportedCells(state, [newCell], idx);
|
|
536
|
+
rerender();
|
|
537
|
+
};
|
|
538
|
+
bar.innerHTML = `
|
|
539
|
+
<button class="nb-btn nb-cellbar-btn" data-add="md">+ text</button>
|
|
540
|
+
<button class="nb-btn nb-cellbar-btn" data-add="sql">+ sql</button>
|
|
541
|
+
<button class="nb-btn nb-cellbar-btn" data-add="js">+ JS</button>
|
|
542
|
+
<button class="nb-btn nb-cellbar-btn" data-cellbar="widget">+ widget</button>
|
|
543
|
+
<button class="nb-btn nb-cellbar-btn" data-cellbar="agent">+ agent</button>`;
|
|
544
|
+
bar.querySelectorAll<HTMLElement>('[data-add]').forEach((btn) => {
|
|
545
|
+
btn.addEventListener('click', () => insertAfter(btn.dataset.add as 'md' | 'sql' | 'js'));
|
|
546
|
+
});
|
|
547
|
+
bar.querySelector<HTMLElement>('[data-cellbar="widget"]')?.addEventListener('click', () => {
|
|
548
|
+
openWidgetPicker(state, cell, rerender);
|
|
549
|
+
});
|
|
550
|
+
bar.querySelector<HTMLElement>('[data-cellbar="agent"]')?.addEventListener('click', () => {
|
|
551
|
+
toggleAgentBar(bar, state, cell, rerender);
|
|
552
|
+
});
|
|
553
|
+
return bar;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* +widget picker — lists the recipes exposed by every active WebMCP server
|
|
558
|
+
* (state.webmcpServers, populated from frontmatter.webmcp_servers). On pick,
|
|
559
|
+
* inserts a JS cell after the current one with a template that reads the
|
|
560
|
+
* upstream cell's varname (default 'rows') and renders the widget.
|
|
561
|
+
*/
|
|
562
|
+
function openWidgetPicker(state: NotebookState, cell: NotebookCell, rerender: () => void): void {
|
|
563
|
+
const servers = state.webmcpServers ?? [];
|
|
564
|
+
type Item = { server: string; name: string; description?: string };
|
|
565
|
+
const items: Item[] = [];
|
|
566
|
+
for (const s of servers) {
|
|
567
|
+
const recipes = s.layer().recipes ?? [];
|
|
568
|
+
for (const r of recipes) items.push({ server: s.name, name: r.name, description: r.description });
|
|
569
|
+
}
|
|
570
|
+
if (items.length === 0) {
|
|
571
|
+
toastNoServers();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const upstreamVar = findUpstreamVarname(state, cell);
|
|
575
|
+
const overlay = document.createElement('div');
|
|
576
|
+
overlay.className = 'nbe-picker-overlay';
|
|
577
|
+
overlay.innerHTML = `
|
|
578
|
+
<div class="nbe-picker">
|
|
579
|
+
<header>
|
|
580
|
+
<input class="nbe-picker-q" placeholder="filter widgets…" autofocus />
|
|
581
|
+
<button class="nbe-picker-close" type="button">✕</button>
|
|
582
|
+
</header>
|
|
583
|
+
<ul class="nbe-picker-list"></ul>
|
|
584
|
+
</div>`;
|
|
585
|
+
document.body.appendChild(overlay);
|
|
586
|
+
const close = () => { try { overlay.remove(); } catch { /* ignore */ } };
|
|
587
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
|
588
|
+
(overlay.querySelector('.nbe-picker-close') as HTMLElement).addEventListener('click', close);
|
|
589
|
+
const list = overlay.querySelector('.nbe-picker-list') as HTMLElement;
|
|
590
|
+
const q = overlay.querySelector('.nbe-picker-q') as HTMLInputElement;
|
|
591
|
+
const render = (filter = '') => {
|
|
592
|
+
const f = filter.toLowerCase().trim();
|
|
593
|
+
list.innerHTML = items
|
|
594
|
+
.filter((i) => !f || i.name.toLowerCase().includes(f) || (i.description ?? '').toLowerCase().includes(f) || i.server.toLowerCase().includes(f))
|
|
595
|
+
.map((i) => `<li data-name="${escapeAttr(i.name)}" data-server="${escapeAttr(i.server)}">
|
|
596
|
+
<span class="nbe-picker-name">${escapeHtml(i.name)}</span>
|
|
597
|
+
<span class="nbe-picker-server">${escapeHtml(i.server)}</span>
|
|
598
|
+
<span class="nbe-picker-desc">${escapeHtml(i.description ?? '')}</span>
|
|
599
|
+
</li>`).join('');
|
|
600
|
+
};
|
|
601
|
+
render();
|
|
602
|
+
q.addEventListener('input', () => render(q.value));
|
|
603
|
+
list.addEventListener('click', (e) => {
|
|
604
|
+
const li = (e.target as HTMLElement).closest('li[data-name]') as HTMLElement | null;
|
|
605
|
+
if (!li) return;
|
|
606
|
+
const name = li.dataset.name as string;
|
|
607
|
+
const idx = state.cells.findIndex((c) => c.id === cell.id);
|
|
608
|
+
const template = upstreamVar
|
|
609
|
+
? `return widget('${name}', { rows: ${upstreamVar} })`
|
|
610
|
+
: `return widget('${name}', {})`;
|
|
611
|
+
const newCell: NotebookCell = {
|
|
612
|
+
id: uid(), type: 'js',
|
|
613
|
+
content: template,
|
|
614
|
+
hideSource: false, hideResult: false, status: 'stale',
|
|
615
|
+
};
|
|
616
|
+
addImportedCells(state, [newCell], idx);
|
|
617
|
+
close();
|
|
618
|
+
rerender();
|
|
619
|
+
});
|
|
620
|
+
function toastNoServers() {
|
|
621
|
+
const t = document.createElement('div');
|
|
622
|
+
t.className = 'nbe-toast nbe-toast-error nbe-toast-in';
|
|
623
|
+
t.textContent = 'No WebMCP server is enabled — add some in flex first (autoui, d3, observable-plot…)';
|
|
624
|
+
document.body.appendChild(t);
|
|
625
|
+
setTimeout(() => t.remove(), 3500);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Walk back from `cell` to find the nearest cell with a varname (typically a
|
|
631
|
+
* SQL cell). The widget template will bind to that variable so the chosen
|
|
632
|
+
* widget visualises the upstream cell's output. Returns null if none.
|
|
633
|
+
*/
|
|
634
|
+
function findUpstreamVarname(state: NotebookState, cell: NotebookCell): string | null {
|
|
635
|
+
const idx = state.cells.findIndex((c) => c.id === cell.id);
|
|
636
|
+
for (let i = idx; i >= 0; i--) {
|
|
637
|
+
const c = state.cells[i];
|
|
638
|
+
if (c?.varname) return c.varname;
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* +agent bar — toggled inline below the cell action bar. The user types a
|
|
645
|
+
* prompt in <auto-chat-input>, runAgentLoop is invoked with a focused tool
|
|
646
|
+
* layer that lets the LLM:
|
|
647
|
+
* - read the current cell + scope
|
|
648
|
+
* - rewrite the current cell content
|
|
649
|
+
* - insert a new cell after it (text / sql / js / widget)
|
|
650
|
+
* Every mutation logs into state.history so the user can ⟲ revert.
|
|
651
|
+
* Provider: RemoteLLMProvider via /api/chat (proxy reads env LLM_API_KEY).
|
|
652
|
+
*/
|
|
653
|
+
function toggleAgentBar(host: HTMLElement, state: NotebookState, cell: NotebookCell, rerender: () => void): void {
|
|
654
|
+
const existing = host.parentElement?.querySelector(':scope > .nbe-agent-bar');
|
|
655
|
+
if (existing) { existing.remove(); return; }
|
|
656
|
+
const bar = document.createElement('div');
|
|
657
|
+
bar.className = 'nbe-agent-bar';
|
|
658
|
+
bar.innerHTML = `
|
|
659
|
+
<auto-chat-input placeholder="ask agent — e.g. 'filter rows where votes > 50' / 'add a sankey of this'"></auto-chat-input>
|
|
660
|
+
<div class="nbe-agent-status" hidden></div>`;
|
|
661
|
+
host.insertAdjacentElement('afterend', bar);
|
|
662
|
+
const input = bar.querySelector('auto-chat-input') as HTMLElement & { disabled?: boolean };
|
|
663
|
+
const status = bar.querySelector('.nbe-agent-status') as HTMLElement;
|
|
664
|
+
let aborter: AbortController | null = null;
|
|
665
|
+
input.addEventListener('widget:interact', (e: Event) => {
|
|
666
|
+
const detail = (e as CustomEvent).detail ?? {};
|
|
667
|
+
const action = (detail as { action?: string }).action;
|
|
668
|
+
// Prevent the event bubbling to the host (flex/etc.) — otherwise their
|
|
669
|
+
// own widget-interaction handler would launch a SECOND, unconstrained
|
|
670
|
+
// agent loop that renders widgets outside the notebook (canvas blocks).
|
|
671
|
+
if (action === 'stop') { e.stopPropagation(); aborter?.abort(); return; }
|
|
672
|
+
if (action !== 'submit') return;
|
|
673
|
+
e.stopPropagation();
|
|
674
|
+
const text = ((detail as { payload?: { text?: string } }).payload?.text ?? '').trim();
|
|
675
|
+
if (!text) return;
|
|
676
|
+
void runAgentForCell(text, state, cell, rerender, status, input, () => aborter ??= new AbortController());
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function runAgentForCell(
|
|
681
|
+
prompt: string,
|
|
682
|
+
state: NotebookState,
|
|
683
|
+
cell: NotebookCell,
|
|
684
|
+
rerender: () => void,
|
|
685
|
+
status: HTMLElement,
|
|
686
|
+
input: HTMLElement & { disabled?: boolean },
|
|
687
|
+
getAborter: () => AbortController,
|
|
688
|
+
): Promise<void> {
|
|
689
|
+
status.hidden = false;
|
|
690
|
+
status.textContent = '…';
|
|
691
|
+
input.disabled = true;
|
|
692
|
+
const aborter = getAborter();
|
|
693
|
+
try {
|
|
694
|
+
// Lazy import to keep notebook bundle slim when agent is unused.
|
|
695
|
+
const { RemoteLLMProvider, runAgentLoop } = await import('@webmcp-auto-ui/agent');
|
|
696
|
+
// Host injects chatApiBase via data (flex serves under /flex, notebook-viewer
|
|
697
|
+
// at root). Default '/api/chat' kept for hosts mounting at the root.
|
|
698
|
+
const proxyUrl = (state as { chatApiBase?: string }).chatApiBase ?? '/api/chat';
|
|
699
|
+
const provider = new RemoteLLMProvider({ proxyUrl, model: 'haiku' });
|
|
700
|
+
const layer = buildAgentLayerForCell(state, cell, rerender);
|
|
701
|
+
const systemPrompt = buildAgentSystemPromptForCell(state, cell);
|
|
702
|
+
await runAgentLoop(prompt, {
|
|
703
|
+
provider,
|
|
704
|
+
layers: [layer],
|
|
705
|
+
systemPrompt,
|
|
706
|
+
maxIterations: 6,
|
|
707
|
+
signal: aborter.signal,
|
|
708
|
+
callbacks: {
|
|
709
|
+
onToolCall: (call: { name?: string }) => { status.textContent = `· ${call?.name ?? '?'}…`; },
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
status.textContent = '✓ done';
|
|
713
|
+
} catch (err) {
|
|
714
|
+
status.textContent = 'error: ' + String((err as { message?: unknown })?.message ?? err);
|
|
715
|
+
} finally {
|
|
716
|
+
input.disabled = false;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Build the WebMCP tool layer the agent sees. Tools mutate state via the
|
|
722
|
+
* shared helpers (addImportedCells, logHistory) so revert via ⟲ history works
|
|
723
|
+
* exactly like a manual edit.
|
|
724
|
+
*/
|
|
725
|
+
function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerender: () => void) {
|
|
726
|
+
const idx = () => state.cells.findIndex((c) => c.id === cell.id);
|
|
727
|
+
const tools = [
|
|
728
|
+
{
|
|
729
|
+
name: 'get_current_cell',
|
|
730
|
+
description: 'Read the cell the user invoked the agent on (id, type, content, varname).',
|
|
731
|
+
inputSchema: { type: 'object', properties: {} },
|
|
732
|
+
execute: async () => ({
|
|
733
|
+
id: cell.id, type: cell.type,
|
|
734
|
+
content: cell.content,
|
|
735
|
+
varname: (cell as { varname?: string }).varname,
|
|
736
|
+
}),
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
name: 'list_cells',
|
|
740
|
+
description: 'List all cells in the notebook (id, type, short content preview).',
|
|
741
|
+
inputSchema: { type: 'object', properties: {} },
|
|
742
|
+
execute: async () => state.cells.map((c) => ({
|
|
743
|
+
id: c.id, type: c.type,
|
|
744
|
+
preview: (c.content ?? '').slice(0, 120),
|
|
745
|
+
varname: (c as { varname?: string }).varname,
|
|
746
|
+
})),
|
|
747
|
+
},
|
|
748
|
+
{
|
|
749
|
+
name: 'update_cell',
|
|
750
|
+
description: 'Replace the current cell content with new code/markdown. The cell type is preserved.',
|
|
751
|
+
inputSchema: {
|
|
752
|
+
type: 'object',
|
|
753
|
+
properties: { content: { type: 'string', description: 'New full content for the cell.' } },
|
|
754
|
+
required: ['content'],
|
|
755
|
+
},
|
|
756
|
+
execute: async (args: Record<string, unknown>) => {
|
|
757
|
+
const next = String(args.content ?? '');
|
|
758
|
+
const before = cell.content;
|
|
759
|
+
if (before === next) return { ok: true, unchanged: true };
|
|
760
|
+
// Snapshot prior content so logHistory can support revert.
|
|
761
|
+
cell.content = next;
|
|
762
|
+
cell.status = 'stale';
|
|
763
|
+
state.lastEditAt = Date.now();
|
|
764
|
+
// Manual history entry — reuse `edit` kind, summary mentions agent.
|
|
765
|
+
try {
|
|
766
|
+
(state.history ?? []).push({
|
|
767
|
+
ts: Date.now(), kind: 'edit',
|
|
768
|
+
summary: `agent edited ${cell.type} cell`,
|
|
769
|
+
snapshot: { cellId: cell.id, before, after: next },
|
|
770
|
+
} as never);
|
|
771
|
+
} catch { /* ignore — best-effort */ }
|
|
772
|
+
rerender();
|
|
773
|
+
return { ok: true };
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
name: 'insert_cell_after',
|
|
778
|
+
description: 'Insert a new cell directly after the current one. Use type=text for prose, sql for queries, js for scripts/widgets.',
|
|
779
|
+
inputSchema: {
|
|
780
|
+
type: 'object',
|
|
781
|
+
properties: {
|
|
782
|
+
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).' },
|
|
783
|
+
content: { type: 'string', description: 'Cell content.' },
|
|
784
|
+
varname: { type: 'string', description: 'Optional varname for sql/js cells — exposes the result as a variable to subsequent cells.' },
|
|
785
|
+
},
|
|
786
|
+
required: ['type', 'content'],
|
|
787
|
+
},
|
|
788
|
+
execute: async (args: Record<string, unknown>) => {
|
|
789
|
+
const t = String(args.type ?? '');
|
|
790
|
+
const type: NotebookCell['type'] = t === 'text' ? 'md' : t === 'sql' ? 'sql' : 'js';
|
|
791
|
+
const newCell: NotebookCell = {
|
|
792
|
+
id: uid(), type,
|
|
793
|
+
content: String(args.content ?? ''),
|
|
794
|
+
hideSource: false, hideResult: false, status: 'stale',
|
|
795
|
+
...(args.varname ? { varname: String(args.varname) } : {}),
|
|
796
|
+
};
|
|
797
|
+
addImportedCells(state, [newCell], idx());
|
|
798
|
+
rerender();
|
|
799
|
+
return { ok: true, id: newCell.id };
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
name: 'list_widgets',
|
|
804
|
+
description: 'List widgets exposed by the connected WebMCP servers — pickable in JS cells via widget(name, params).',
|
|
805
|
+
inputSchema: { type: 'object', properties: {} },
|
|
806
|
+
execute: async () => {
|
|
807
|
+
const out: Array<{ server: string; name: string; description?: string }> = [];
|
|
808
|
+
for (const s of state.webmcpServers ?? []) {
|
|
809
|
+
for (const r of s.layer().recipes ?? []) out.push({ server: s.name, name: r.name, description: r.description });
|
|
810
|
+
}
|
|
811
|
+
return out;
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
];
|
|
815
|
+
return { protocol: 'webmcp' as const, serverName: 'notebook-editor', tools, recipes: [] };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function buildAgentSystemPromptForCell(_state: NotebookState, cell: NotebookCell): string {
|
|
819
|
+
return [
|
|
820
|
+
'You are an in-notebook editing assistant. The user invoked you on a specific cell.',
|
|
821
|
+
'Your job: rewrite that cell, OR insert a follow-up cell after it, based on the user\'s prompt.',
|
|
822
|
+
`The current cell is of type "${cell.type}".`,
|
|
823
|
+
'Workflow:',
|
|
824
|
+
' 1. Call get_current_cell to read the cell content + varname.',
|
|
825
|
+
' 2. If the user wants to TRANSFORM the existing cell (filter, refactor, fix), call update_cell.',
|
|
826
|
+
' 3. If the user wants a FOLLOW-UP step (chart, summary, follow-up query), call insert_cell_after.',
|
|
827
|
+
' 4. For widgets: call list_widgets first to discover available names, then write a JS cell with `return widget("<name>", { rows, ...params })`.',
|
|
828
|
+
'Be terse. One or two tool calls is usually enough. Do not explain at length.',
|
|
829
|
+
].join('\n');
|
|
830
|
+
}
|
|
831
|
+
|
|
449
832
|
// ---------------------------------------------------------------------------
|
|
450
833
|
// Result rendering — editorial flavour (serif headers, mono cells, discreet)
|
|
451
834
|
// ---------------------------------------------------------------------------
|
|
@@ -471,16 +854,29 @@ function formatMs(ms: number): string {
|
|
|
471
854
|
return (ms / 1000).toFixed(2) + 's';
|
|
472
855
|
}
|
|
473
856
|
|
|
474
|
-
function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null): void {
|
|
857
|
+
function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeOverlay | null, stateRef?: NotebookState): void {
|
|
475
858
|
const r = effectiveResult(cell, overlay) ?? cell.lastResult;
|
|
476
859
|
el.innerHTML = '';
|
|
477
860
|
if (!r) {
|
|
478
|
-
|
|
861
|
+
const isView = stateRef?.mode === 'view';
|
|
862
|
+
const rtStatus = cellRuntimeStatus(cell, overlay);
|
|
863
|
+
// In view mode (autoRun), any unresolved cell is effectively loading —
|
|
864
|
+
// the auto-runner will pick it up shortly. Show a continuous spinner so
|
|
865
|
+
// users don't see a static "—" placeholder during the idle→running gap.
|
|
866
|
+
if (isView && rtStatus !== 'frozen') {
|
|
867
|
+
el.innerHTML = `<div class="nbe-result-running"><span class="nbe-spinner"></span> running</div>`;
|
|
868
|
+
} else if (rtStatus === 'running') {
|
|
869
|
+
el.innerHTML = `<div class="nbe-result-running"><span class="nbe-spinner"></span> running</div>`;
|
|
870
|
+
} else {
|
|
871
|
+
el.innerHTML = `<div class="nbe-result-empty">press ▶ to run</div>`;
|
|
872
|
+
}
|
|
479
873
|
return;
|
|
480
874
|
}
|
|
481
875
|
// Logs panel (shared across all widgets), prepended above the main result
|
|
482
|
-
|
|
483
|
-
|
|
876
|
+
if (!cell.hideLogs) {
|
|
877
|
+
const logsEl = renderCellLogs(r);
|
|
878
|
+
if (logsEl) el.appendChild(logsEl);
|
|
879
|
+
}
|
|
484
880
|
if (!r.ok) {
|
|
485
881
|
const err = document.createElement('div');
|
|
486
882
|
err.className = 'nbe-result-error';
|
|
@@ -509,6 +905,22 @@ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeO
|
|
|
509
905
|
renderChart(chart, r.spec).catch(() => { /* fallback handled internally */ });
|
|
510
906
|
return;
|
|
511
907
|
}
|
|
908
|
+
if (r.kind === 'widget') {
|
|
909
|
+
const fallbackServers = stateRef?.webmcpServers ?? [];
|
|
910
|
+
for (const w of r.widgets) {
|
|
911
|
+
const host = document.createElement('div');
|
|
912
|
+
host.className = 'nb-widget-host';
|
|
913
|
+
el.appendChild(host);
|
|
914
|
+
try { mountWidget(host, w.name, w.params, fallbackServers); }
|
|
915
|
+
catch (err) {
|
|
916
|
+
const pre = document.createElement('pre');
|
|
917
|
+
pre.className = 'nbe-result-error';
|
|
918
|
+
pre.textContent = `widget "${w.name}" failed: ${String((err as { message?: unknown })?.message ?? err)}`;
|
|
919
|
+
host.appendChild(pre);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
512
924
|
// table — editorial style: serif header row, mono cells, minimal chrome.
|
|
513
925
|
const cols = r.columns && r.columns.length ? r.columns
|
|
514
926
|
: (r.rows[0] ? Object.keys(r.rows[0]) : []);
|
|
@@ -692,6 +1104,30 @@ function injectLayoutStyles(): void {
|
|
|
692
1104
|
.nbe-code-body { padding: 14px 16px; }
|
|
693
1105
|
.nbe-hidden { display: none !important; }
|
|
694
1106
|
|
|
1107
|
+
/* View-mode Option B: chrome hidden by default, revealed on hover in top-right corner. */
|
|
1108
|
+
.nb-root.nb-view-mode .nbe-code-cell { position: relative; min-height: 28px; }
|
|
1109
|
+
.nb-root.nb-view-mode .nbe-cell-head {
|
|
1110
|
+
position: absolute; top: 0; right: 0; z-index: 2;
|
|
1111
|
+
padding: 4px 8px; gap: 6px;
|
|
1112
|
+
background: var(--color-surface2);
|
|
1113
|
+
border: 1px solid var(--color-border);
|
|
1114
|
+
border-top: none; border-right: none;
|
|
1115
|
+
border-bottom-left-radius: 6px;
|
|
1116
|
+
opacity: 0; pointer-events: none;
|
|
1117
|
+
transition: opacity 120ms ease;
|
|
1118
|
+
font-size: 9.5px;
|
|
1119
|
+
}
|
|
1120
|
+
.nb-root.nb-view-mode .nbe-code-cell:hover .nbe-cell-head,
|
|
1121
|
+
.nb-root.nb-view-mode .nbe-cell-head:focus-within {
|
|
1122
|
+
opacity: 0.95; pointer-events: auto;
|
|
1123
|
+
}
|
|
1124
|
+
.nb-root.nb-view-mode .nbe-cell-head .nbe-meta-info,
|
|
1125
|
+
.nb-root.nb-view-mode .nbe-cell-head .nbe-type-sql,
|
|
1126
|
+
.nb-root.nb-view-mode .nbe-cell-head .nbe-type-js,
|
|
1127
|
+
.nb-root.nb-view-mode .nbe-cell-head .nbe-type-md { display: none; }
|
|
1128
|
+
.nb-root.nb-view-mode .nbe-code-body { padding: 10px 14px; }
|
|
1129
|
+
.nb-root.nb-view-mode .nbe-result { padding: 8px 14px; }
|
|
1130
|
+
|
|
695
1131
|
.nbe-result {
|
|
696
1132
|
background: var(--color-bg);
|
|
697
1133
|
border-top: 1px solid var(--color-border);
|
|
@@ -702,6 +1138,10 @@ function injectLayoutStyles(): void {
|
|
|
702
1138
|
.nbe-result-empty {
|
|
703
1139
|
color: var(--color-text2); font-style: italic; font-size: 11.5px;
|
|
704
1140
|
}
|
|
1141
|
+
.nbe-result-running {
|
|
1142
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
1143
|
+
color: #2ea043; font-size: 11.5px;
|
|
1144
|
+
}
|
|
705
1145
|
.nbe-result-error {
|
|
706
1146
|
color: var(--color-accent2); white-space: pre-wrap; font-size: 12px;
|
|
707
1147
|
}
|