@webmcp-auto-ui/ui 2.5.39 → 2.5.40
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
CHANGED
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
* written back so subsequent blocks can read them. Owner: the host modal.
|
|
27
27
|
*/
|
|
28
28
|
scope?: Record<string, unknown>;
|
|
29
|
+
/**
|
|
30
|
+
* Recipe-level widget hint (from frontmatter `widget:`). When set and
|
|
31
|
+
* lang === 'json', the JSON content is treated as widget params instead
|
|
32
|
+
* of being dispatched as a script.
|
|
33
|
+
*/
|
|
34
|
+
widget?: string;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
let {
|
|
@@ -34,6 +40,7 @@
|
|
|
34
40
|
actions = undefined,
|
|
35
41
|
onrun,
|
|
36
42
|
scope,
|
|
43
|
+
widget,
|
|
37
44
|
}: Props = $props();
|
|
38
45
|
|
|
39
46
|
let editable = $state('');
|
|
@@ -93,7 +100,15 @@
|
|
|
93
100
|
startTimer(t0);
|
|
94
101
|
|
|
95
102
|
const multi = canvas.multiClient as McpMultiClient | undefined;
|
|
96
|
-
|
|
103
|
+
// For widget recipes (frontmatter `widget:`), JSON fences carry params —
|
|
104
|
+
// wrap as widget_display(...) so runCode renders the widget instead of
|
|
105
|
+
// dispatching the JSON to run_script.
|
|
106
|
+
const isJsonWidgetParams = widget && /^json\b/i.test(lang);
|
|
107
|
+
const runCodeStr = isJsonWidgetParams
|
|
108
|
+
? `widget_display({name: ${JSON.stringify(widget)}, params: ${editable}})`
|
|
109
|
+
: editable;
|
|
110
|
+
const runLang = isJsonWidgetParams ? 'text' : lang;
|
|
111
|
+
const result = await runCode(runCodeStr, runLang, multi, scope);
|
|
97
112
|
|
|
98
113
|
stopTimer();
|
|
99
114
|
lastDuration = result.durationMs;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { canvas } from '@webmcp-auto-ui/sdk/canvas';
|
|
2
2
|
import { findCodeParamName, buildToolArgs } from '@webmcp-auto-ui/sdk';
|
|
3
|
+
import { stripSqlPreamble } from '../shared.js';
|
|
3
4
|
import type { CellExecutor, CellExecContext, CellResult, DataServerDescriptor, DataServerTool } from '../shared.js';
|
|
4
5
|
|
|
5
6
|
const PATTERN_PRIMARY = /^.*query_sql$/i;
|
|
@@ -24,7 +25,7 @@ export function createSqlExecutor(getServers: () => DataServerDescriptor[]): Cel
|
|
|
24
25
|
return { ok: false, error: 'No SQL tool available on connected servers.', errorKind: 'schema', durationMs: Date.now() - startedAt };
|
|
25
26
|
}
|
|
26
27
|
const { srv, tool } = hit;
|
|
27
|
-
const sql = (ctx.cell.content ?? '').trim();
|
|
28
|
+
const sql = stripSqlPreamble((ctx.cell.content ?? '').trim());
|
|
28
29
|
if (!sql) return { ok: true, kind: 'empty', durationMs: Date.now() - startedAt };
|
|
29
30
|
|
|
30
31
|
// Build args from the tool's inputSchema:
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
renderCellLogs, uid, defaultCellContent,
|
|
14
14
|
createPublishControls, autoConnectFrontmatterServers,
|
|
15
15
|
createRuntimeOverlay, effectiveResult, cellRuntimeStatus,
|
|
16
|
-
lastRefreshedAt, bootstrapLiveRefresh, fmtRelTime, preserveScrollAround,
|
|
16
|
+
lastRefreshedAt, bootstrapLiveRefresh, fmtRelTime, preserveScrollAround, startRun,
|
|
17
17
|
type NotebookState, type NotebookCell, type CellResult, type CellExecContext,
|
|
18
18
|
type RuntimeOverlay,
|
|
19
19
|
} from './shared.js';
|
|
@@ -285,7 +285,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
285
285
|
const viewBtn = shell.querySelector('.nb-mode-view') as HTMLElement;
|
|
286
286
|
editBtn.addEventListener('click', () => {
|
|
287
287
|
state.mode = 'edit';
|
|
288
|
-
for (const c of state.cells) { c.hideSource = false; c.hideLogs = false; }
|
|
288
|
+
for (const c of state.cells) { c.hideSource = false; c.hideLogs = false; c.chatOpen = false; }
|
|
289
289
|
container.classList.remove('nb-view-mode');
|
|
290
290
|
editBtn.classList.add('nb-on'); viewBtn.classList.remove('nb-on');
|
|
291
291
|
// Leaving view: stop live refresh and clear overlay so frozen snapshots show.
|
|
@@ -339,12 +339,28 @@ export async function render(container: HTMLElement, data: Record<string, unknow
|
|
|
339
339
|
rerender();
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
// Real-time elapsed timer for running cells. Updates every 200ms in-place
|
|
343
|
+
// (no full rerender) by reading data-running-since attributes.
|
|
344
|
+
const tickRunningTimers = () => {
|
|
345
|
+
const els = container.querySelectorAll<HTMLElement>('.nbe-running-time[data-running-since]');
|
|
346
|
+
if (els.length === 0) return;
|
|
347
|
+
const now = Date.now();
|
|
348
|
+
els.forEach((el) => {
|
|
349
|
+
const since = Number(el.getAttribute('data-running-since'));
|
|
350
|
+
if (!since) return;
|
|
351
|
+
const ms = now - since;
|
|
352
|
+
el.textContent = ms < 1000 ? `running ${ms}ms` : `running ${(ms / 1000).toFixed(1)}s`;
|
|
353
|
+
});
|
|
354
|
+
};
|
|
355
|
+
const runningTimerHandle = setInterval(tickRunningTimers, 200);
|
|
356
|
+
|
|
342
357
|
return () => {
|
|
343
358
|
unsubHistory();
|
|
344
359
|
canvasUnsub?.();
|
|
345
360
|
pane.destroy();
|
|
346
361
|
publishCleanup();
|
|
347
362
|
liveCleanup?.();
|
|
363
|
+
clearInterval(runningTimerHandle);
|
|
348
364
|
};
|
|
349
365
|
}
|
|
350
366
|
|
|
@@ -450,7 +466,8 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
|
|
|
450
466
|
let liveBadge = '';
|
|
451
467
|
if (showLive) {
|
|
452
468
|
if (rtStatus === 'running') {
|
|
453
|
-
|
|
469
|
+
const startedAt = overlay?.cellStartedAt.get(cell.id) ?? Date.now();
|
|
470
|
+
liveBadge = `<span class="nbe-cell-badge nbe-cell-running" title="re-executing"><span class="nbe-spinner"></span><span class="nbe-running-time" data-running-since="${startedAt}">running</span></span>`;
|
|
454
471
|
} else if (rtStatus === 'stale') {
|
|
455
472
|
liveBadge = `<span class="nbe-cell-badge nbe-cell-stale" title="last live refresh failed">stale</span>`;
|
|
456
473
|
} else if (rtStatus === 'frozen') {
|
|
@@ -471,6 +488,7 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
|
|
|
471
488
|
${logsToggle}
|
|
472
489
|
<button class="nb-icon-btn nb-toggle-src">${cell.hideSource ? '▸ src' : '◂ src'}</button>
|
|
473
490
|
<button class="nb-icon-btn nb-toggle-res">${cell.hideResult ? '▸ res' : '◂ res'}</button>
|
|
491
|
+
${state.mode === 'view' ? `<span class="nbe-actions-sep">│</span><button class="nb-icon-btn nb-toggle-chat">${cell.chatOpen ? '◂ chat' : '▸ chat'}</button>` : ''}
|
|
474
492
|
</div>`;
|
|
475
493
|
codeCell.appendChild(head);
|
|
476
494
|
mountRunControls(head.querySelector('.nbe-run-controls') as HTMLElement, cell, wrap, state, rerender);
|
|
@@ -510,6 +528,13 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
|
|
|
510
528
|
|
|
511
529
|
(head.querySelector('.nb-toggle-src') as HTMLElement).addEventListener('click', () => { cell.hideSource = !cell.hideSource; rerender(); });
|
|
512
530
|
(head.querySelector('.nb-toggle-res') as HTMLElement).addEventListener('click', () => { cell.hideResult = !cell.hideResult; rerender(); });
|
|
531
|
+
const chatBtn = head.querySelector('.nb-toggle-chat') as HTMLElement | null;
|
|
532
|
+
if (chatBtn) chatBtn.addEventListener('click', () => { cell.chatOpen = !cell.chatOpen; rerender(); });
|
|
533
|
+
|
|
534
|
+
if (state.mode === 'view' && cell.chatOpen) {
|
|
535
|
+
wrap.classList.add('nb-cell-chat-active');
|
|
536
|
+
mountAgentBarAbove(codeCell, state, cell, rerender, overlay);
|
|
537
|
+
}
|
|
513
538
|
|
|
514
539
|
if (state.mode !== 'view') wrap.appendChild(renderCellActionBar(state, cell, rerender));
|
|
515
540
|
|
|
@@ -677,27 +702,77 @@ function toggleAgentBar(host: HTMLElement, state: NotebookState, cell: NotebookC
|
|
|
677
702
|
});
|
|
678
703
|
}
|
|
679
704
|
|
|
705
|
+
/**
|
|
706
|
+
* View-mode variant — renders the agent bar ABOVE the code cell (beforebegin)
|
|
707
|
+
* and persists open state via cell.chatOpen so the bar survives the rerender
|
|
708
|
+
* triggered by `update_cell`. Reuses runAgentForCell unchanged.
|
|
709
|
+
*/
|
|
710
|
+
function mountAgentBarAbove(codeCell: HTMLElement, state: NotebookState, cell: NotebookCell, rerender: () => void, overlay: RuntimeOverlay | null): void {
|
|
711
|
+
const bar = document.createElement('div');
|
|
712
|
+
bar.className = 'nbe-agent-bar';
|
|
713
|
+
bar.innerHTML = `
|
|
714
|
+
<auto-chat-input placeholder="ask agent — e.g. 'filter rows where votes > 50' / 'add a sankey of this'"></auto-chat-input>
|
|
715
|
+
<div class="nbe-agent-progress" hidden>
|
|
716
|
+
<span class="nbe-progress-track"><span class="nbe-progress-fill"></span></span>
|
|
717
|
+
<span class="nbe-progress-label"></span>
|
|
718
|
+
</div>`;
|
|
719
|
+
codeCell.insertAdjacentElement('beforebegin', bar);
|
|
720
|
+
const input = bar.querySelector('auto-chat-input') as HTMLElement & { disabled?: boolean };
|
|
721
|
+
const progress = bar.querySelector('.nbe-agent-progress') as HTMLElement;
|
|
722
|
+
const fill = bar.querySelector('.nbe-progress-fill') as HTMLElement;
|
|
723
|
+
const label = bar.querySelector('.nbe-progress-label') as HTMLElement;
|
|
724
|
+
let aborter: AbortController | null = null;
|
|
725
|
+
input.addEventListener('widget:interact', (e: Event) => {
|
|
726
|
+
const detail = (e as CustomEvent).detail ?? {};
|
|
727
|
+
const action = (detail as { action?: string }).action;
|
|
728
|
+
if (action === 'stop') { e.stopPropagation(); aborter?.abort(); return; }
|
|
729
|
+
if (action !== 'submit') return;
|
|
730
|
+
e.stopPropagation();
|
|
731
|
+
const text = ((detail as { payload?: { text?: string } }).payload?.text ?? '').trim();
|
|
732
|
+
if (!text) return;
|
|
733
|
+
void runAgentForCell(text, state, cell, rerender, progress, fill, label, input, () => aborter ??= new AbortController(), overlay);
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
680
737
|
async function runAgentForCell(
|
|
681
738
|
prompt: string,
|
|
682
739
|
state: NotebookState,
|
|
683
740
|
cell: NotebookCell,
|
|
684
741
|
rerender: () => void,
|
|
685
|
-
|
|
742
|
+
progress: HTMLElement,
|
|
743
|
+
fill: HTMLElement,
|
|
744
|
+
label: HTMLElement,
|
|
686
745
|
input: HTMLElement & { disabled?: boolean },
|
|
687
746
|
getAborter: () => AbortController,
|
|
747
|
+
overlay: RuntimeOverlay | null,
|
|
688
748
|
): Promise<void> {
|
|
689
|
-
|
|
690
|
-
|
|
749
|
+
progress.hidden = false;
|
|
750
|
+
progress.classList.remove('nbe-progress-error', 'nbe-progress-done');
|
|
751
|
+
let currentTool = '…';
|
|
752
|
+
const startedAt = Date.now();
|
|
753
|
+
// Indeterminate slide: a 3-block fill traverses the 8-block track.
|
|
754
|
+
const TRACK = 8;
|
|
755
|
+
const FILL = 3;
|
|
756
|
+
let pos = 0;
|
|
757
|
+
const tick = () => {
|
|
758
|
+
const cells: string[] = [];
|
|
759
|
+
for (let i = 0; i < TRACK; i++) {
|
|
760
|
+
const inFill = i >= pos && i < pos + FILL;
|
|
761
|
+
cells.push(inFill ? '▰' : '▱');
|
|
762
|
+
}
|
|
763
|
+
fill.textContent = cells.join('');
|
|
764
|
+
label.textContent = `${currentTool} · ${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
|
|
765
|
+
pos = (pos + 1) % (TRACK - FILL + 1 + 2); // small pause at each end
|
|
766
|
+
};
|
|
767
|
+
tick();
|
|
768
|
+
const interval = setInterval(tick, 120);
|
|
691
769
|
input.disabled = true;
|
|
692
770
|
const aborter = getAborter();
|
|
693
771
|
try {
|
|
694
|
-
// Lazy import to keep notebook bundle slim when agent is unused.
|
|
695
772
|
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
773
|
const proxyUrl = (state as { chatApiBase?: string }).chatApiBase ?? '/api/chat';
|
|
699
774
|
const provider = new RemoteLLMProvider({ proxyUrl, model: 'haiku' });
|
|
700
|
-
const layer = buildAgentLayerForCell(state, cell, rerender);
|
|
775
|
+
const layer = buildAgentLayerForCell(state, cell, rerender, overlay);
|
|
701
776
|
const systemPrompt = buildAgentSystemPromptForCell(state, cell);
|
|
702
777
|
await runAgentLoop(prompt, {
|
|
703
778
|
provider,
|
|
@@ -706,12 +781,20 @@ async function runAgentForCell(
|
|
|
706
781
|
maxIterations: 6,
|
|
707
782
|
signal: aborter.signal,
|
|
708
783
|
callbacks: {
|
|
709
|
-
onToolCall: (call: { name?: string }) => {
|
|
784
|
+
onToolCall: (call: { name?: string }) => { currentTool = call?.name ?? '?'; },
|
|
710
785
|
},
|
|
711
786
|
});
|
|
712
|
-
|
|
787
|
+
clearInterval(interval);
|
|
788
|
+
progress.classList.add('nbe-progress-done');
|
|
789
|
+
fill.textContent = '▰▰▰▰▰▰▰▰';
|
|
790
|
+
label.textContent = `done · ${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
|
|
791
|
+
// Auto-close happens earlier inside update_cell.execute (avoids the lag of
|
|
792
|
+
// Haiku's trailing assistant turn). Nothing to do here.
|
|
713
793
|
} catch (err) {
|
|
714
|
-
|
|
794
|
+
clearInterval(interval);
|
|
795
|
+
progress.classList.add('nbe-progress-error');
|
|
796
|
+
fill.textContent = '▱▱▱▱▱▱▱▱';
|
|
797
|
+
label.textContent = 'error: ' + String((err as { message?: unknown })?.message ?? err);
|
|
715
798
|
} finally {
|
|
716
799
|
input.disabled = false;
|
|
717
800
|
}
|
|
@@ -722,8 +805,12 @@ async function runAgentForCell(
|
|
|
722
805
|
* shared helpers (addImportedCells, logHistory) so revert via ⟲ history works
|
|
723
806
|
* exactly like a manual edit.
|
|
724
807
|
*/
|
|
725
|
-
function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerender: () => void) {
|
|
808
|
+
function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerender: () => void, overlay: RuntimeOverlay | null) {
|
|
726
809
|
const idx = () => state.cells.findIndex((c) => c.id === cell.id);
|
|
810
|
+
// View-mode chat is per-cell and meant to TRANSFORM the targeted cell.
|
|
811
|
+
// Exception: when the cell is `sql`, the natural follow-up is "add a chart on
|
|
812
|
+
// top of these rows", so we keep insert_cell_after + list_widgets available.
|
|
813
|
+
const transformOnly = state.mode === 'view' && cell.type !== 'sql';
|
|
727
814
|
const tools = [
|
|
728
815
|
{
|
|
729
816
|
name: 'get_current_cell',
|
|
@@ -769,7 +856,20 @@ function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerend
|
|
|
769
856
|
snapshot: { cellId: cell.id, before, after: next },
|
|
770
857
|
} as never);
|
|
771
858
|
} catch { /* ignore — best-effort */ }
|
|
859
|
+
// Drop the live-overlay cache for this cell. effectiveResult prefers
|
|
860
|
+
// overlay.outputs over cell.lastResult, so without this the pre-edit
|
|
861
|
+
// render lingers until the next bootstrapLive (= view-edit-view).
|
|
862
|
+
overlay?.outputs.delete(cell.id);
|
|
863
|
+
overlay?.status.delete(cell.id);
|
|
864
|
+
// Close the chat bar immediately on success in view mode — waiting for
|
|
865
|
+
// runAgentLoop to fully resolve adds a 1-2s lag from Haiku's trailing
|
|
866
|
+
// assistant turn ("Done!") after the tool result.
|
|
867
|
+
if (state.mode === 'view') cell.chatOpen = false;
|
|
772
868
|
rerender();
|
|
869
|
+
// Re-run so the result panel reflects the new content.
|
|
870
|
+
if (cell.type !== 'md' && state.executors?.[cell.type]) {
|
|
871
|
+
startRun(cell, state, rerender);
|
|
872
|
+
}
|
|
773
873
|
return { ok: true };
|
|
774
874
|
},
|
|
775
875
|
},
|
|
@@ -801,21 +901,65 @@ function buildAgentLayerForCell(state: NotebookState, cell: NotebookCell, rerend
|
|
|
801
901
|
},
|
|
802
902
|
{
|
|
803
903
|
name: 'list_widgets',
|
|
804
|
-
description: 'List widgets exposed by the connected WebMCP servers
|
|
904
|
+
description: 'List widgets exposed by the connected WebMCP servers, with one usage example each. Pickable in JS cells via widget(name, params).',
|
|
805
905
|
inputSchema: { type: 'object', properties: {} },
|
|
806
906
|
execute: async () => {
|
|
807
|
-
const out: Array<{ server: string; name: string; description?: string }> = [];
|
|
907
|
+
const out: Array<{ server: string; name: string; description?: string; example?: string }> = [];
|
|
808
908
|
for (const s of state.webmcpServers ?? []) {
|
|
809
|
-
for (const r of s.layer().recipes ?? [])
|
|
909
|
+
for (const r of s.layer().recipes ?? []) {
|
|
910
|
+
const body = (r as { body?: string }).body ?? '';
|
|
911
|
+
// First fenced block in the recipe body is the canonical example,
|
|
912
|
+
// showing the exact param keys the renderer expects.
|
|
913
|
+
const m = /```[a-zA-Z]*\n([\s\S]*?)\n```/.exec(body);
|
|
914
|
+
out.push({
|
|
915
|
+
server: s.name,
|
|
916
|
+
name: r.name,
|
|
917
|
+
description: r.description,
|
|
918
|
+
...(m ? { example: m[1] } : {}),
|
|
919
|
+
});
|
|
920
|
+
}
|
|
810
921
|
}
|
|
811
922
|
return out;
|
|
812
923
|
},
|
|
813
924
|
},
|
|
814
925
|
];
|
|
815
|
-
|
|
926
|
+
// list_widgets is read-only and essential — without it, the agent hallucinates
|
|
927
|
+
// widget names. Only insert_cell_after is dropped in transformOnly.
|
|
928
|
+
const filtered = transformOnly
|
|
929
|
+
? tools.filter((t) => t.name !== 'insert_cell_after')
|
|
930
|
+
: tools;
|
|
931
|
+
return { protocol: 'webmcp' as const, serverName: 'notebook-editor', tools: filtered, recipes: [] };
|
|
816
932
|
}
|
|
817
933
|
|
|
818
|
-
function buildAgentSystemPromptForCell(
|
|
934
|
+
function buildAgentSystemPromptForCell(state: NotebookState, cell: NotebookCell): string {
|
|
935
|
+
if (state.mode === 'view') {
|
|
936
|
+
if (cell.type === 'sql') {
|
|
937
|
+
return [
|
|
938
|
+
'You are an in-notebook editing assistant. The user invoked you on a SQL cell.',
|
|
939
|
+
'Two valid actions:',
|
|
940
|
+
' - TRANSFORM: rewrite this SQL cell. Call get_current_cell, then update_cell.',
|
|
941
|
+
' - FOLLOW-UP (chart/viz/widget): insert a JS cell after this one that renders a widget on top of the rows.',
|
|
942
|
+
'For a follow-up widget:',
|
|
943
|
+
' 1. Call get_current_cell to read this cell\'s `varname` (the rows of this SQL are bound to that name in cross-cell scope).',
|
|
944
|
+
' 2. Call list_widgets to discover the exact widget name + the example showing the required param keys. NEVER invent a widget name.',
|
|
945
|
+
' 3. Call insert_cell_after with type="js" and content like: `return widget("<name>", { ...params per the example, with rows substituted for <varname> });`',
|
|
946
|
+
'For JS cells: use the `widget(name, params)` helper. `widget_display` is not available as a global.',
|
|
947
|
+
'Never write a JS cell that re-issues the SQL — reuse the varname from cross-cell scope.',
|
|
948
|
+
'Be terse. Two or three tool calls is enough. Do not explain at length.',
|
|
949
|
+
].join('\n');
|
|
950
|
+
}
|
|
951
|
+
return [
|
|
952
|
+
'You are an in-notebook editing assistant. The user invoked you on a specific cell to TRANSFORM it.',
|
|
953
|
+
`The current cell is of type "${cell.type}".`,
|
|
954
|
+
'Workflow:',
|
|
955
|
+
' 1. Call get_current_cell to read the cell content.',
|
|
956
|
+
' 2. If the user asks for a chart/widget/viz, call list_widgets to learn the exact widget names + the example showing the required param keys. NEVER invent a widget name.',
|
|
957
|
+
' 3. Call update_cell with the rewritten content.',
|
|
958
|
+
'Always rewrite the existing cell. Never insert new cells.',
|
|
959
|
+
'For JS cells: use the `widget(name, params)` helper. `widget_display` is not available as a global. Call widget once with the final result; do NOT also call widget_display after.',
|
|
960
|
+
'Be terse. Two tool calls is usually enough. Do not explain at length.',
|
|
961
|
+
].join('\n');
|
|
962
|
+
}
|
|
819
963
|
return [
|
|
820
964
|
'You are an in-notebook editing assistant. The user invoked you on a specific cell.',
|
|
821
965
|
'Your job: rewrite that cell, OR insert a follow-up cell after it, based on the user\'s prompt.',
|
|
@@ -860,13 +1004,13 @@ function renderResultInto(el: HTMLElement, cell: NotebookCell, overlay: RuntimeO
|
|
|
860
1004
|
if (!r) {
|
|
861
1005
|
const isView = stateRef?.mode === 'view';
|
|
862
1006
|
const rtStatus = cellRuntimeStatus(cell, overlay);
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
if (isView && rtStatus
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
el.innerHTML = `<div class="nbe-result-running"><span class="nbe-spinner"></span>
|
|
1007
|
+
const startedAt = overlay?.cellStartedAt.get(cell.id);
|
|
1008
|
+
if (rtStatus === 'running' && startedAt != null) {
|
|
1009
|
+
el.innerHTML = `<div class="nbe-result-running"><span class="nbe-spinner"></span> <span class="nbe-running-time" data-running-since="${startedAt}">running</span></div>`;
|
|
1010
|
+
} else if (isView && (rtStatus === 'pending' || rtStatus === 'idle')) {
|
|
1011
|
+
// Queued: cell will run sequentially after upstream cells finish.
|
|
1012
|
+
// Spinner without timer — there's nothing to count yet.
|
|
1013
|
+
el.innerHTML = `<div class="nbe-result-running"><span class="nbe-spinner"></span> queued</div>`;
|
|
870
1014
|
} else {
|
|
871
1015
|
el.innerHTML = `<div class="nbe-result-empty">press ▶ to run</div>`;
|
|
872
1016
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
|
|
7
7
|
import { serializeToMarkdown } from './share-handlers.js';
|
|
8
|
+
import { extractTopLevelDecls, hasIdentifierReference, findCodeParamName, buildToolArgs } from '@webmcp-auto-ui/sdk';
|
|
8
9
|
|
|
9
10
|
export const NB_PUBLISH_HOST: string = (() => {
|
|
10
11
|
try {
|
|
@@ -47,6 +48,8 @@ export interface NotebookCell {
|
|
|
47
48
|
varname?: string; // named output (compact)
|
|
48
49
|
hideSource?: boolean;
|
|
49
50
|
hideResult?: boolean;
|
|
51
|
+
/** view-mode: agent chat bar opened above this cell (persists across rerender). */
|
|
52
|
+
chatOpen?: boolean;
|
|
50
53
|
/** view-mode: collapse the console logs panel under the head bar. Default true in view mode. */
|
|
51
54
|
hideLogs?: boolean;
|
|
52
55
|
runState?: RunState;
|
|
@@ -97,6 +100,25 @@ export interface HistoryEntry {
|
|
|
97
100
|
// Utilities
|
|
98
101
|
// ---------------------------------------------------------------------------
|
|
99
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Strip leading SQL preamble — line comments and block comments,
|
|
105
|
+
* BOM, and whitespace — so the first non-trivial token is the real keyword.
|
|
106
|
+
* Some upstream MCP SQL servers (Tricoteuses) enforce a strict
|
|
107
|
+
* `^(SELECT|WITH)` regex on the raw string; a comment header would fail.
|
|
108
|
+
*/
|
|
109
|
+
export function stripSqlPreamble(sql: string): string {
|
|
110
|
+
let s = sql.replace(/^/, '');
|
|
111
|
+
// Repeat: there can be a mix of line + block comments + blank lines.
|
|
112
|
+
for (;;) {
|
|
113
|
+
const before = s;
|
|
114
|
+
s = s.replace(/^[\s]+/, '');
|
|
115
|
+
s = s.replace(/^--[^\n]*\n?/, '');
|
|
116
|
+
s = s.replace(/^\/\*[\s\S]*?\*\//, '');
|
|
117
|
+
if (s === before) break;
|
|
118
|
+
}
|
|
119
|
+
return s;
|
|
120
|
+
}
|
|
121
|
+
|
|
100
122
|
export function uid(): string {
|
|
101
123
|
return 'c_' + Math.random().toString(36).slice(2, 9);
|
|
102
124
|
}
|
|
@@ -247,6 +269,8 @@ export interface RuntimeOverlay {
|
|
|
247
269
|
outputs: Map<string, { result: CellResult; refreshedAt: number }>;
|
|
248
270
|
/** Per-cell status during/after the refresh cycle. */
|
|
249
271
|
status: Map<string, CellRuntimeStatus>;
|
|
272
|
+
/** Per-cell start timestamp — set when status flips to 'running', cleared on completion. */
|
|
273
|
+
cellStartedAt: Map<string, number>;
|
|
250
274
|
startedAt: number | null;
|
|
251
275
|
finishedAt: number | null;
|
|
252
276
|
/** Last fatal reason (e.g. "no reachable server"). */
|
|
@@ -257,6 +281,7 @@ export function createRuntimeOverlay(): RuntimeOverlay {
|
|
|
257
281
|
return {
|
|
258
282
|
outputs: new Map(),
|
|
259
283
|
status: new Map(),
|
|
284
|
+
cellStartedAt: new Map(),
|
|
260
285
|
startedAt: null,
|
|
261
286
|
finishedAt: null,
|
|
262
287
|
error: null,
|
|
@@ -326,34 +351,77 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
|
|
|
326
351
|
const summary: AutoRefreshSummary = { rerun: 0, frozen: 0, stale: 0, failed: 0 };
|
|
327
352
|
const sharedSignal = signal ?? new AbortController().signal;
|
|
328
353
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
354
|
+
// Build dependency graph: a JS cell depends on the most recent prior cell
|
|
355
|
+
// that declares a top-level identifier referenced in this cell's body.
|
|
356
|
+
// Cells with no inter-cell deps run in parallel; dependent cells await
|
|
357
|
+
// their providers via a Promise map (no batching, no sequential bottleneck).
|
|
358
|
+
const declToProvider = new Map<string, string>(); // identifier → cell id of latest declarer
|
|
359
|
+
const cellDeps = new Map<string, string[]>(); // cell id → [provider cell ids]
|
|
360
|
+
for (const cell of state.cells) {
|
|
361
|
+
if (!isReRunnable(cell)) continue;
|
|
362
|
+
const deps = new Set<string>();
|
|
363
|
+
if (cell.type === 'js') {
|
|
364
|
+
for (const [name, providerId] of declToProvider) {
|
|
365
|
+
if (providerId === cell.id) continue;
|
|
366
|
+
if (hasIdentifierReference(cell.content, name)) deps.add(providerId);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
cellDeps.set(cell.id, [...deps]);
|
|
370
|
+
// Register this cell's provided names AFTER computing its deps so a cell
|
|
371
|
+
// doesn't depend on itself when it both reads and re-declares a name.
|
|
372
|
+
if (cell.type === 'js') {
|
|
373
|
+
for (const decl of extractTopLevelDecls(cell.content)) {
|
|
374
|
+
declToProvider.set(decl, cell.id);
|
|
375
|
+
}
|
|
376
|
+
} else if (cell.type === 'sql' && cell.varname) {
|
|
377
|
+
declToProvider.set(cell.varname, cell.id);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const cellPromises = new Map<string, Promise<void>>();
|
|
382
|
+
for (const cell of state.cells) {
|
|
383
|
+
cellPromises.set(cell.id, (async () => {
|
|
384
|
+
if (sharedSignal.aborted) return;
|
|
385
|
+
if (!isReRunnable(cell)) { summary.frozen++; return; }
|
|
332
386
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
387
|
+
// Wait for dependencies before starting.
|
|
388
|
+
const deps = cellDeps.get(cell.id) ?? [];
|
|
389
|
+
if (deps.length > 0) {
|
|
390
|
+
await Promise.all(deps.map((d) => cellPromises.get(d)).filter(Boolean) as Promise<void>[]);
|
|
391
|
+
}
|
|
392
|
+
if (sharedSignal.aborted) return;
|
|
336
393
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
394
|
+
overlay.status.set(cell.id, 'running');
|
|
395
|
+
overlay.cellStartedAt.set(cell.id, Date.now());
|
|
396
|
+
onCellChange?.(cell.id);
|
|
397
|
+
onTick?.(overlay);
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const result = await runner(cell, sharedSignal);
|
|
340
401
|
overlay.outputs.set(cell.id, { result, refreshedAt: Date.now() });
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
402
|
+
if (result.ok) {
|
|
403
|
+
overlay.status.set(cell.id, 'fresh');
|
|
404
|
+
summary.rerun++;
|
|
405
|
+
} else {
|
|
406
|
+
overlay.status.set(cell.id, 'stale');
|
|
407
|
+
summary.stale++;
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
411
|
+
overlay.outputs.set(cell.id, {
|
|
412
|
+
result: { ok: false, error: message, errorKind: 'runtime', durationMs: 0 },
|
|
413
|
+
refreshedAt: Date.now(),
|
|
414
|
+
});
|
|
344
415
|
overlay.status.set(cell.id, 'stale');
|
|
345
|
-
summary.
|
|
416
|
+
summary.failed++;
|
|
417
|
+
if (!overlay.error) overlay.error = message;
|
|
346
418
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
onTick?.(overlay);
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
await Promise.all(tasks);
|
|
419
|
+
overlay.cellStartedAt.delete(cell.id);
|
|
420
|
+
onCellChange?.(cell.id);
|
|
421
|
+
onTick?.(overlay);
|
|
422
|
+
})());
|
|
423
|
+
}
|
|
424
|
+
await Promise.all([...cellPromises.values()]);
|
|
357
425
|
|
|
358
426
|
overlay.finishedAt = Date.now();
|
|
359
427
|
onTick?.(overlay);
|
|
@@ -383,15 +451,15 @@ export function createBridgeSqlRunner(
|
|
|
383
451
|
const PATTERN_PRIMARY = /^.*query_sql$/i;
|
|
384
452
|
const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
|
|
385
453
|
|
|
386
|
-
function findSqlTool(servers: DataServerDescriptor[]): { serverName: string; toolName: string } | null {
|
|
454
|
+
function findSqlTool(servers: DataServerDescriptor[]): { serverName: string; toolName: string; tool: DataServerTool } | null {
|
|
387
455
|
for (const srv of servers) {
|
|
388
456
|
for (const t of srv.tools ?? []) {
|
|
389
|
-
if (PATTERN_PRIMARY.test(t.name)) return { serverName: srv.name, toolName: t.name };
|
|
457
|
+
if (PATTERN_PRIMARY.test(t.name)) return { serverName: srv.name, toolName: t.name, tool: t };
|
|
390
458
|
}
|
|
391
459
|
}
|
|
392
460
|
for (const srv of servers) {
|
|
393
461
|
for (const t of srv.tools ?? []) {
|
|
394
|
-
if (PATTERN_FALLBACK.test(t.name)) return { serverName: srv.name, toolName: t.name };
|
|
462
|
+
if (PATTERN_FALLBACK.test(t.name)) return { serverName: srv.name, toolName: t.name, tool: t };
|
|
395
463
|
}
|
|
396
464
|
}
|
|
397
465
|
return null;
|
|
@@ -439,7 +507,11 @@ export function createBridgeSqlRunner(
|
|
|
439
507
|
if (!hit) {
|
|
440
508
|
return { ok: false, error: 'No SQL tool exposed by reachable servers', errorKind: 'schema', durationMs: 0 };
|
|
441
509
|
}
|
|
442
|
-
const
|
|
510
|
+
const sql = stripSqlPreamble((cell.content ?? '').trim());
|
|
511
|
+
const codeParam = findCodeParamName(hit.tool.inputSchema) ?? 'sql';
|
|
512
|
+
const args: Record<string, unknown> = { ...buildToolArgs(hit.tool.inputSchema, codeParam, sql, 'sql'), ...(cell.args ?? {}) };
|
|
513
|
+
args[codeParam] = sql;
|
|
514
|
+
const raw = await callTool(hit.serverName, hit.toolName, args);
|
|
443
515
|
return parseResult(raw, startedAt);
|
|
444
516
|
};
|
|
445
517
|
}
|
|
@@ -466,12 +538,21 @@ interface CanvasLike {
|
|
|
466
538
|
callTool: (name: string, toolName: string, args: unknown) => Promise<unknown>;
|
|
467
539
|
}
|
|
468
540
|
|
|
469
|
-
async function waitForEnabledServers(
|
|
541
|
+
async function waitForEnabledServers(
|
|
542
|
+
canvas: CanvasLike,
|
|
543
|
+
timeoutMs: number,
|
|
544
|
+
expectedCount: number,
|
|
545
|
+
): Promise<void> {
|
|
546
|
+
// expectedCount = number of servers declared by the notebook frontmatter.
|
|
547
|
+
// Without this, an empty canvas.dataServers (autoConnect lost a race against
|
|
548
|
+
// canvas.__canvasVanilla being set, or a notify loop hasn't fired yet)
|
|
549
|
+
// would return immediately and the runner would execute against zero servers.
|
|
470
550
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
471
551
|
while (Date.now() < deadline) {
|
|
472
552
|
const enabled = (canvas.dataServers ?? []).filter((s) => s.enabled !== false);
|
|
473
|
-
|
|
474
|
-
if (enabled.
|
|
553
|
+
// Notebook declared no servers — nothing to wait for.
|
|
554
|
+
if (expectedCount === 0 && enabled.length === 0) return;
|
|
555
|
+
if (enabled.length >= expectedCount && enabled.every((s) => s.connected)) return;
|
|
475
556
|
await new Promise((r) => setTimeout(r, 100));
|
|
476
557
|
}
|
|
477
558
|
}
|
|
@@ -491,7 +572,10 @@ export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => v
|
|
|
491
572
|
onTick?.(overlay);
|
|
492
573
|
return;
|
|
493
574
|
}
|
|
494
|
-
|
|
575
|
+
const declaredServers = Array.isArray((data as { servers?: unknown }).servers)
|
|
576
|
+
? (data as { servers: unknown[] }).servers.length
|
|
577
|
+
: 0;
|
|
578
|
+
await waitForEnabledServers(canvas, timeoutMs ?? 10_000, declaredServers);
|
|
495
579
|
|
|
496
580
|
const sqlRunner = createBridgeSqlRunner(
|
|
497
581
|
canvas.callTool.bind(canvas),
|
|
@@ -1318,11 +1402,23 @@ const NOTEBOOK_STYLES = `
|
|
|
1318
1402
|
display: flex; flex-direction: column; gap: 6px;
|
|
1319
1403
|
}
|
|
1320
1404
|
.nbe-agent-bar auto-chat-input { display: block; }
|
|
1405
|
+
.nbe-actions-sep { color: var(--color-border, #c8c2b4); margin: 0 4px; user-select: none; }
|
|
1406
|
+
.nb-cell-chat-active { outline: 2px solid var(--color-accent, #1d6f5f); outline-offset: 4px; border-radius: 6px; }
|
|
1321
1407
|
.nbe-agent-status {
|
|
1322
1408
|
font-family: var(--font-mono, monospace); font-size: 11px;
|
|
1323
1409
|
color: var(--color-text2, #645d4f);
|
|
1324
1410
|
padding: 0 4px;
|
|
1325
1411
|
}
|
|
1412
|
+
.nbe-agent-progress {
|
|
1413
|
+
display: flex; align-items: center; gap: 10px;
|
|
1414
|
+
font-family: var(--font-mono, monospace); font-size: 11px;
|
|
1415
|
+
color: var(--color-text2, #645d4f);
|
|
1416
|
+
padding: 0 4px;
|
|
1417
|
+
}
|
|
1418
|
+
.nbe-progress-track { letter-spacing: 1px; color: var(--color-accent, #1d6f5f); }
|
|
1419
|
+
.nbe-progress-fill { display: inline-block; }
|
|
1420
|
+
.nbe-agent-progress.nbe-progress-done .nbe-progress-track { color: var(--color-accent, #1d6f5f); }
|
|
1421
|
+
.nbe-agent-progress.nbe-progress-error .nbe-progress-track { color: #b54141; }
|
|
1326
1422
|
/* Run controls (.nb-ctl-pill) remain active in view mode — users can execute cells
|
|
1327
1423
|
* even when the notebook is read-only; only editing the source is locked. */
|
|
1328
1424
|
.nb-root.nb-view-mode textarea,
|