@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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Used by the four notebook layout renderers (compact/workspace/document/editorial)
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { serializeToMarkdown } from './share-handlers.js';
|
|
8
8
|
|
|
9
9
|
export const NB_PUBLISH_HOST: string = (() => {
|
|
10
10
|
try {
|
|
@@ -25,6 +25,7 @@ export type CellResult =
|
|
|
25
25
|
| { ok: true; kind: 'table'; rows: Record<string, unknown>[]; columns: string[]; rowCount: number; truncated?: boolean; durationMs: number; logs?: string[] }
|
|
26
26
|
| { ok: true; kind: 'value'; value: unknown; durationMs: number; logs?: string[] }
|
|
27
27
|
| { ok: true; kind: 'chart'; spec: unknown; durationMs: number; logs?: string[] }
|
|
28
|
+
| { ok: true; kind: 'widget'; widgets: Array<{ name: string; params: Record<string, unknown> }>; durationMs: number; logs?: string[] }
|
|
28
29
|
| { ok: true; kind: 'empty'; durationMs: number; logs?: string[] }
|
|
29
30
|
| { ok: false; error: string; errorKind?: 'syntax' | 'runtime' | 'timeout' | 'schema'; durationMs: number; logs?: string[] };
|
|
30
31
|
|
|
@@ -71,6 +72,16 @@ export interface NotebookState {
|
|
|
71
72
|
* a RuntimeOverlay consumed at render. Default false (frozen snapshots).
|
|
72
73
|
*/
|
|
73
74
|
autoRun?: boolean;
|
|
75
|
+
/** Bundled WebMCP server instances active in this notebook. Populated from
|
|
76
|
+
* `data.webmcpServers` at mount; used by the widget picker (+widget) and as
|
|
77
|
+
* fallback for `mountWidget` when a custom-element tag isn't defined. */
|
|
78
|
+
webmcpServers?: import('@webmcp-auto-ui/core').WebMcpServer[];
|
|
79
|
+
/** Host-supplied LLM proxy endpoint. The notebook agent posts to this URL.
|
|
80
|
+
* Defaults to '/api/chat' when absent — flex sets '/flex/api/chat' to
|
|
81
|
+
* match its base path; notebook-viewer keeps the default. */
|
|
82
|
+
chatApiBase?: string;
|
|
83
|
+
/** Suppress the published-slug chip in the header (notebook-viewer use). */
|
|
84
|
+
hidePublishBadge?: boolean;
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
export interface HistoryEntry {
|
|
@@ -187,6 +198,11 @@ export function createState(initial?: Partial<NotebookState>): NotebookState {
|
|
|
187
198
|
executors: initial?.executors ?? {},
|
|
188
199
|
lastEditAt: initial?.lastEditAt ?? Date.now(),
|
|
189
200
|
autoRun: initial?.autoRun ?? false,
|
|
201
|
+
publishedSlug: initial?.publishedSlug,
|
|
202
|
+
publishedToken: initial?.publishedToken,
|
|
203
|
+
webmcpServers: initial?.webmcpServers,
|
|
204
|
+
chatApiBase: initial?.chatApiBase,
|
|
205
|
+
hidePublishBadge: initial?.hidePublishBadge,
|
|
190
206
|
};
|
|
191
207
|
}
|
|
192
208
|
|
|
@@ -260,9 +276,9 @@ export function cellRuntimeStatus(cell: NotebookCell, overlay: RuntimeOverlay |
|
|
|
260
276
|
return 'idle';
|
|
261
277
|
}
|
|
262
278
|
|
|
263
|
-
/** Live-mode whitelist.
|
|
279
|
+
/** Live-mode whitelist. SQL and JS cells are re-executable; markdown stays frozen. */
|
|
264
280
|
export function isReRunnable(cell: NotebookCell): boolean {
|
|
265
|
-
return cell.type === 'sql';
|
|
281
|
+
return cell.type === 'sql' || cell.type === 'js';
|
|
266
282
|
}
|
|
267
283
|
|
|
268
284
|
/**
|
|
@@ -306,17 +322,18 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
|
|
|
306
322
|
onTick?.(overlay);
|
|
307
323
|
|
|
308
324
|
const summary: AutoRefreshSummary = { rerun: 0, frozen: 0, stale: 0, failed: 0 };
|
|
325
|
+
const sharedSignal = signal ?? new AbortController().signal;
|
|
309
326
|
|
|
310
|
-
|
|
311
|
-
if (
|
|
312
|
-
if (!isReRunnable(cell)) { summary.frozen++;
|
|
327
|
+
const tasks = state.cells.map(async (cell) => {
|
|
328
|
+
if (sharedSignal.aborted) return;
|
|
329
|
+
if (!isReRunnable(cell)) { summary.frozen++; return; }
|
|
313
330
|
|
|
314
331
|
overlay.status.set(cell.id, 'running');
|
|
315
332
|
onCellChange?.(cell.id);
|
|
316
333
|
onTick?.(overlay);
|
|
317
334
|
|
|
318
335
|
try {
|
|
319
|
-
const result = await runner(cell,
|
|
336
|
+
const result = await runner(cell, sharedSignal);
|
|
320
337
|
if (result.ok) {
|
|
321
338
|
overlay.outputs.set(cell.id, { result, refreshedAt: Date.now() });
|
|
322
339
|
overlay.status.set(cell.id, 'fresh');
|
|
@@ -332,7 +349,9 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
|
|
|
332
349
|
}
|
|
333
350
|
onCellChange?.(cell.id);
|
|
334
351
|
onTick?.(overlay);
|
|
335
|
-
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await Promise.all(tasks);
|
|
336
355
|
|
|
337
356
|
overlay.finishedAt = Date.now();
|
|
338
357
|
onTick?.(overlay);
|
|
@@ -348,22 +367,17 @@ export function lastRefreshedAt(overlay: RuntimeOverlay | null | undefined): num
|
|
|
348
367
|
}
|
|
349
368
|
|
|
350
369
|
/**
|
|
351
|
-
* Build a CellRunner
|
|
352
|
-
* on the connected servers (matching `*_query_sql` then
|
|
353
|
-
* calls it with `{ sql: cell.content }`, parses
|
|
370
|
+
* Build a CellRunner that issues SQL via `callTool`. Discovers a SQL-capable
|
|
371
|
+
* tool on the connected servers (matching `*_query_sql` then
|
|
372
|
+
* `query|run|execute`), calls it with `{ sql: cell.content }`, parses the
|
|
373
|
+
* content-array into a table.
|
|
354
374
|
*
|
|
355
|
-
*
|
|
356
|
-
* to surface this as a 'stale' status, not crash.
|
|
375
|
+
* Returns a runtime error result if no server is reachable / no SQL tool found.
|
|
357
376
|
*/
|
|
358
|
-
export function createBridgeSqlRunner(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
listTools?: (url: string) => Promise<{ name: string }[]>;
|
|
363
|
-
getToolsForUrl?: (url: string) => { name: string }[];
|
|
364
|
-
};
|
|
365
|
-
callTool: (serverName: string, toolName: string, args: unknown) => Promise<unknown>;
|
|
366
|
-
}, getServerDescriptors: () => DataServerDescriptor[]): CellRunner {
|
|
377
|
+
export function createBridgeSqlRunner(
|
|
378
|
+
callTool: (serverName: string, toolName: string, args: unknown) => Promise<unknown>,
|
|
379
|
+
getServerDescriptors: () => DataServerDescriptor[],
|
|
380
|
+
): CellRunner {
|
|
367
381
|
const PATTERN_PRIMARY = /^.*query_sql$/i;
|
|
368
382
|
const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
|
|
369
383
|
|
|
@@ -423,20 +437,18 @@ export function createBridgeSqlRunner(bridge: {
|
|
|
423
437
|
if (!hit) {
|
|
424
438
|
return { ok: false, error: 'No SQL tool exposed by reachable servers', errorKind: 'schema', durationMs: 0 };
|
|
425
439
|
}
|
|
426
|
-
const raw = await
|
|
440
|
+
const raw = await callTool(hit.serverName, hit.toolName, { sql: cell.content });
|
|
427
441
|
return parseResult(raw, startedAt);
|
|
428
442
|
};
|
|
429
443
|
}
|
|
430
444
|
|
|
431
445
|
/**
|
|
432
|
-
* High-level bootstrap: auto-connect declared servers, wait for handshake,
|
|
433
|
-
* a
|
|
434
|
-
* mount time when `state.autoRun && state.mode === 'view'`. Returns
|
|
446
|
+
* High-level bootstrap: auto-connect declared servers, wait for handshake,
|
|
447
|
+
* build a SQL runner, fire runAutoRefresh. Safe to call from any layout at
|
|
448
|
+
* mount time when `state.autoRun && state.mode === 'view'`. Returns cleanup.
|
|
435
449
|
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
* bridge. If no singleton exists yet we install it here, and only the installer
|
|
439
|
-
* is allowed to stop it on cleanup.
|
|
450
|
+
* Drives connection state through the canvas store; the store's internal
|
|
451
|
+
* sync owns the McpMultiClient and reconciles handshakes on every mutation.
|
|
440
452
|
*/
|
|
441
453
|
export interface BootstrapLiveRefreshOptions {
|
|
442
454
|
state: NotebookState;
|
|
@@ -447,36 +459,54 @@ export interface BootstrapLiveRefreshOptions {
|
|
|
447
459
|
timeoutMs?: number;
|
|
448
460
|
}
|
|
449
461
|
|
|
462
|
+
interface CanvasLike {
|
|
463
|
+
dataServers: { name: string; url: string; enabled?: boolean; connected?: boolean }[];
|
|
464
|
+
callTool: (name: string, toolName: string, args: unknown) => Promise<unknown>;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function waitForEnabledServers(canvas: CanvasLike, timeoutMs: number): Promise<void> {
|
|
468
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
469
|
+
while (Date.now() < deadline) {
|
|
470
|
+
const enabled = (canvas.dataServers ?? []).filter((s) => s.enabled !== false);
|
|
471
|
+
if (enabled.length === 0) return;
|
|
472
|
+
if (enabled.every((s) => s.connected)) return;
|
|
473
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
450
477
|
export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => void {
|
|
451
478
|
const { state, data, overlay, onCellChange, onTick, timeoutMs } = opts;
|
|
452
479
|
const ac = new AbortController();
|
|
453
|
-
let weCreatedBridge = false;
|
|
454
|
-
let bridgeRef: any = null;
|
|
455
480
|
|
|
456
481
|
void (async () => {
|
|
457
482
|
try {
|
|
458
483
|
autoConnectFrontmatterServers(data);
|
|
459
|
-
const canvas
|
|
460
|
-
.__canvasVanilla ?? (globalThis as { canvasVanilla?:
|
|
484
|
+
const canvas = ((globalThis as { __canvasVanilla?: CanvasLike; canvasVanilla?: CanvasLike })
|
|
485
|
+
.__canvasVanilla ?? (globalThis as { canvasVanilla?: CanvasLike }).canvasVanilla) as CanvasLike | undefined;
|
|
461
486
|
if (!canvas) {
|
|
462
487
|
overlay.error = 'No canvas available';
|
|
463
488
|
overlay.finishedAt = Date.now();
|
|
464
489
|
onTick?.(overlay);
|
|
465
490
|
return;
|
|
466
491
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
492
|
+
await waitForEnabledServers(canvas, timeoutMs ?? 5000);
|
|
493
|
+
|
|
494
|
+
const sqlRunner = createBridgeSqlRunner(
|
|
495
|
+
canvas.callTool.bind(canvas),
|
|
496
|
+
() => collectDataServers(data).filter((s) => canvas.dataServers.find((d) => d.name === s.name)?.connected),
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const runner: CellRunner = async (cell, signal) => {
|
|
500
|
+
if (cell.type === 'sql') return sqlRunner(cell, signal);
|
|
501
|
+
if (cell.type === 'js') {
|
|
502
|
+
const exec = state.executors?.js;
|
|
503
|
+
if (!exec) {
|
|
504
|
+
return { ok: false, error: 'No JS executor registered', errorKind: 'runtime', durationMs: 0 };
|
|
505
|
+
}
|
|
506
|
+
return exec({ cell, state, scope: state.scope, signal });
|
|
507
|
+
}
|
|
508
|
+
return { ok: false, error: `Cell type '${cell.type}' is not re-runnable`, errorKind: 'runtime', durationMs: 0 };
|
|
509
|
+
};
|
|
480
510
|
|
|
481
511
|
await runAutoRefresh({ state, overlay, runner, onCellChange, onTick, signal: ac.signal });
|
|
482
512
|
} catch (err) {
|
|
@@ -486,16 +516,7 @@ export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => v
|
|
|
486
516
|
}
|
|
487
517
|
})();
|
|
488
518
|
|
|
489
|
-
return () => {
|
|
490
|
-
ac.abort();
|
|
491
|
-
if (weCreatedBridge && bridgeRef && typeof bridgeRef.stop === 'function') {
|
|
492
|
-
try { bridgeRef.stop(); } catch { /* ignore */ }
|
|
493
|
-
try {
|
|
494
|
-
const g: any = globalThis as any;
|
|
495
|
-
if (g.__multiMcp === bridgeRef) g.__multiMcp = undefined;
|
|
496
|
-
} catch { /* ignore */ }
|
|
497
|
-
}
|
|
498
|
-
};
|
|
519
|
+
return () => { ac.abort(); };
|
|
499
520
|
}
|
|
500
521
|
|
|
501
522
|
export function registerExecutor(state: NotebookState, type: CellType, fn: CellExecutor): void {
|
|
@@ -672,9 +693,15 @@ export interface DataServerRecipe {
|
|
|
672
693
|
}
|
|
673
694
|
|
|
674
695
|
export interface DataServerDescriptor {
|
|
696
|
+
/** Canvas key (= registry id, e.g. 'wikipedia'). Stable identity used for routing. */
|
|
675
697
|
name: string;
|
|
698
|
+
/** Display label (registry label, or URL host for manual entries). */
|
|
699
|
+
label?: string;
|
|
700
|
+
/** Real server name from MCP handshake (initResult.serverInfo.name, aliased). */
|
|
701
|
+
serverName?: string;
|
|
676
702
|
url?: string;
|
|
677
|
-
|
|
703
|
+
/** 'data' = remote MCP via canvas.dataServers, 'webmcp' = bundled JS server. */
|
|
704
|
+
kind?: 'data' | 'webmcp' | string;
|
|
678
705
|
tools?: DataServerTool[];
|
|
679
706
|
recipes?: DataServerRecipe[];
|
|
680
707
|
}
|
|
@@ -704,6 +731,8 @@ export function collectDataServers(data: Record<string, unknown>): DataServerDes
|
|
|
704
731
|
.filter((s) => !isUiServer(String(s?.name ?? ''), s?.kind))
|
|
705
732
|
.map((s) => ({
|
|
706
733
|
name: String(s.name),
|
|
734
|
+
label: typeof s.label === 'string' ? s.label : undefined,
|
|
735
|
+
serverName: typeof s.serverName === 'string' ? s.serverName : undefined,
|
|
707
736
|
url: s.url ? String(s.url) : undefined,
|
|
708
737
|
recipes: Array.isArray(s.recipes) ? s.recipes : [],
|
|
709
738
|
tools: Array.isArray(s.tools) ? s.tools : [],
|
|
@@ -715,12 +744,38 @@ export function collectDataServers(data: Record<string, unknown>): DataServerDes
|
|
|
715
744
|
.filter((s) => s?.name && !isUiServer(String(s.name), s?.kind))
|
|
716
745
|
.map((s) => ({
|
|
717
746
|
name: String(s.name),
|
|
747
|
+
label: typeof s.label === 'string' ? s.label : undefined,
|
|
748
|
+
serverName: typeof s.serverName === 'string' ? s.serverName : undefined,
|
|
718
749
|
url: s.url ? String(s.url) : undefined,
|
|
719
750
|
recipes: Array.isArray(s.recipes) ? s.recipes : [],
|
|
720
751
|
tools: Array.isArray(s.tools) ? s.tools : [],
|
|
721
752
|
}));
|
|
722
753
|
}
|
|
723
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Project bundled WebMCP server instances into the same descriptor shape used
|
|
757
|
+
* by the left pane, so they render alongside remote data servers in a separate
|
|
758
|
+
* "Widgets" group. Recipe bodies are inlined (no get_recipe roundtrip needed).
|
|
759
|
+
*/
|
|
760
|
+
export function collectWebmcpServers(
|
|
761
|
+
servers: import('@webmcp-auto-ui/core').WebMcpServer[] | undefined,
|
|
762
|
+
): DataServerDescriptor[] {
|
|
763
|
+
if (!servers?.length) return [];
|
|
764
|
+
return servers.map((s) => {
|
|
765
|
+
const layer = (() => { try { return s.layer(); } catch { return { recipes: [], tools: [] }; } })();
|
|
766
|
+
return {
|
|
767
|
+
name: s.name,
|
|
768
|
+
label: s.name,
|
|
769
|
+
kind: 'webmcp' as const,
|
|
770
|
+
recipes: (layer.recipes ?? []).map((r: any) => ({ name: r.name, description: r.description, body: r.body })),
|
|
771
|
+
// Tools of bundled WebMCP servers (search_recipes, widget_display, canvas, …)
|
|
772
|
+
// are agent-facing primitives, not callable from a JS cell via callTool().
|
|
773
|
+
// Hide them from the Resources pane to avoid generating broken cells.
|
|
774
|
+
tools: [],
|
|
775
|
+
};
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
724
779
|
|
|
725
780
|
// ---------------------------------------------------------------------------
|
|
726
781
|
// Publish controls (button + optional badge + optional footer)
|
|
@@ -752,16 +807,25 @@ function publishUrlFor(slug: string): string {
|
|
|
752
807
|
return `${NB_PUBLISH_HOST}/p/${slug}`;
|
|
753
808
|
}
|
|
754
809
|
|
|
810
|
+
function publishMode(state: NotebookState): 'publish' | 'save' | 'fork' {
|
|
811
|
+
if (!state.publishedSlug) return 'publish';
|
|
812
|
+
return state.publishedToken ? 'save' : 'fork';
|
|
813
|
+
}
|
|
814
|
+
|
|
755
815
|
function publishBtnLabel(state: NotebookState): string {
|
|
756
|
-
|
|
816
|
+
const m = publishMode(state);
|
|
817
|
+
return m === 'save' ? '💾 save' : m === 'fork' ? '🍴 fork' : '📤 publish';
|
|
757
818
|
}
|
|
758
819
|
|
|
759
820
|
function refreshPublishControls(state: NotebookState, controls: PublishControlsHandles): void {
|
|
760
821
|
const { btn, badge, footer } = controls;
|
|
761
822
|
btn.textContent = publishBtnLabel(state);
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
823
|
+
const mode = publishMode(state);
|
|
824
|
+
btn.dataset.state = mode === 'publish' ? 'draft' : mode === 'fork' ? 'fork' : 'published';
|
|
825
|
+
if (mode === 'save') {
|
|
826
|
+
btn.title = `Save changes to ${publishUrlFor(state.publishedSlug!)}`;
|
|
827
|
+
} else if (mode === 'fork') {
|
|
828
|
+
btn.title = 'Fork this notebook — publish a copy you own';
|
|
765
829
|
} else {
|
|
766
830
|
btn.title = 'Publish this notebook';
|
|
767
831
|
}
|
|
@@ -777,8 +841,18 @@ function refreshPublishControls(state: NotebookState, controls: PublishControlsH
|
|
|
777
841
|
}
|
|
778
842
|
if (footer) {
|
|
779
843
|
if (state.publishedSlug) {
|
|
780
|
-
const
|
|
781
|
-
|
|
844
|
+
const publicUrl = publishUrlFor(state.publishedSlug);
|
|
845
|
+
if (state.publishedToken) {
|
|
846
|
+
// Author view — show the author URL (with ?t=) so the author keeps their
|
|
847
|
+
// edit access even after the clipboard is overwritten. The public URL is
|
|
848
|
+
// exposed via a copy button next to it for sharing.
|
|
849
|
+
const authorUrl = `${publicUrl}?t=${encodeURIComponent(state.publishedToken)}`;
|
|
850
|
+
footer.innerHTML =
|
|
851
|
+
`Published at <a href="${escapeHtml(authorUrl)}" target="_blank" rel="noopener">${escapeHtml(publicUrl)}</a>` +
|
|
852
|
+
` <button type="button" class="nb-copy-public-link" title="Copy public link (no edit token)" data-public-url="${escapeHtml(publicUrl)}">copy public link</button>`;
|
|
853
|
+
} else {
|
|
854
|
+
footer.innerHTML = `Published at <a href="${escapeHtml(publicUrl)}" target="_blank" rel="noopener">${escapeHtml(publicUrl)}</a>`;
|
|
855
|
+
}
|
|
782
856
|
footer.style.display = '';
|
|
783
857
|
} else {
|
|
784
858
|
footer.innerHTML = '';
|
|
@@ -834,42 +908,50 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
|
|
|
834
908
|
|
|
835
909
|
const onClick = async () => {
|
|
836
910
|
const prevLabel = btn.textContent ?? '';
|
|
911
|
+
const mode = publishMode(state);
|
|
837
912
|
btn.disabled = true;
|
|
838
|
-
btn.textContent =
|
|
913
|
+
btn.textContent = mode === 'save' ? '… saving' : mode === 'fork' ? '… forking' : '… publishing';
|
|
839
914
|
try {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
915
|
+
// HyperSkill standalone markdown — frontmatter (title/description/servers)
|
|
916
|
+
// + body with fenced cells. Re-parsable via parseFrontmatter + parseBody.
|
|
917
|
+
const markdown = serializeToMarkdown(state);
|
|
918
|
+
// Fork = publish a fresh copy (no slug/token) — the server allocates a new
|
|
919
|
+
// slug and token, and the visitor becomes the author of the copy.
|
|
920
|
+
const payload: Record<string, unknown> = { markdown };
|
|
921
|
+
if (mode === 'save') {
|
|
922
|
+
payload.slug = state.publishedSlug;
|
|
923
|
+
payload.token = state.publishedToken;
|
|
924
|
+
}
|
|
848
925
|
const res = await fetch(`${NB_PUBLISH_HOST}/api/publish`, {
|
|
849
926
|
method: 'POST',
|
|
850
927
|
headers: { 'content-type': 'application/json' },
|
|
851
|
-
body: JSON.stringify(
|
|
852
|
-
state: minimal,
|
|
853
|
-
slug: state.publishedSlug,
|
|
854
|
-
token: state.publishedToken,
|
|
855
|
-
}),
|
|
928
|
+
body: JSON.stringify(payload),
|
|
856
929
|
});
|
|
857
930
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
858
931
|
const reply: any = await res.json();
|
|
859
932
|
state.publishedSlug = reply.slug;
|
|
860
933
|
state.publishedToken = reply.token;
|
|
861
934
|
state.lastEditAt = Date.now();
|
|
862
|
-
const
|
|
863
|
-
|
|
935
|
+
const baseUrl: string = reply.url ?? publishUrlFor(String(reply.slug));
|
|
936
|
+
// Author URL embeds the token via `?t=` so the viewer can hydrate it
|
|
937
|
+
// (one-shot — the viewer extracts it into localStorage and cleans the
|
|
938
|
+
// URL). Public visitors share the bare URL without ?t.
|
|
939
|
+
const authorUrl = `${baseUrl}?t=${encodeURIComponent(String(reply.token))}`;
|
|
940
|
+
try { await navigator.clipboard?.writeText?.(authorUrl); } catch { /* ignore */ }
|
|
941
|
+
// On fork, also rewrite the browser URL so the visitor sees the new slug
|
|
942
|
+
// and so a refresh keeps them as author (token persisted in localStorage).
|
|
943
|
+
if (mode === 'fork' && typeof window !== 'undefined') {
|
|
944
|
+
try {
|
|
945
|
+
window.localStorage.setItem(`nb-token-${reply.slug}`, String(reply.token));
|
|
946
|
+
} catch {}
|
|
947
|
+
try { window.history.replaceState({}, '', baseUrl); } catch {}
|
|
948
|
+
}
|
|
864
949
|
const updated = Boolean(reply.updated);
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
: `published · ${url.replace(/^https?:\/\//, '')} (copied)`
|
|
869
|
-
);
|
|
870
|
-
opts.onPublished?.({ slug: String(reply.slug), url, updated });
|
|
950
|
+
const action = mode === 'fork' ? 'forked' : updated ? 'saved' : 'published';
|
|
951
|
+
toast(`${action} · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`);
|
|
952
|
+
opts.onPublished?.({ slug: String(reply.slug), url: baseUrl, updated });
|
|
871
953
|
} catch (err: any) {
|
|
872
|
-
toast(
|
|
954
|
+
toast(`${mode} failed · ${String(err?.message ?? err)}`, true);
|
|
873
955
|
btn.textContent = prevLabel;
|
|
874
956
|
} finally {
|
|
875
957
|
btn.disabled = false;
|
|
@@ -879,8 +961,26 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
|
|
|
879
961
|
|
|
880
962
|
btn.addEventListener('click', onClick);
|
|
881
963
|
|
|
964
|
+
// Delegated handler for the "copy public link" button rendered in the footer
|
|
965
|
+
// when the author has a token. We can't bind directly because the footer
|
|
966
|
+
// innerHTML is rebuilt by refreshPublishControls.
|
|
967
|
+
const onFooterClick = (e: Event) => {
|
|
968
|
+
const target = e.target as HTMLElement | null;
|
|
969
|
+
const copyBtn = target?.closest('.nb-copy-public-link') as HTMLElement | null;
|
|
970
|
+
if (!copyBtn) return;
|
|
971
|
+
e.preventDefault();
|
|
972
|
+
const url = copyBtn.dataset.publicUrl ?? '';
|
|
973
|
+
if (!url) return;
|
|
974
|
+
void (async () => {
|
|
975
|
+
try { await navigator.clipboard?.writeText?.(url); toast(`public link copied · ${url.replace(/^https?:\/\//, '')}`); }
|
|
976
|
+
catch { toast('copy failed', true); }
|
|
977
|
+
})();
|
|
978
|
+
};
|
|
979
|
+
if (footer) footer.addEventListener('click', onFooterClick);
|
|
980
|
+
|
|
882
981
|
return () => {
|
|
883
982
|
btn.removeEventListener('click', onClick);
|
|
983
|
+
if (footer) footer.removeEventListener('click', onFooterClick);
|
|
884
984
|
btn.parentNode?.removeChild(btn);
|
|
885
985
|
badge?.parentNode?.removeChild(badge);
|
|
886
986
|
footer?.parentNode?.removeChild(footer);
|
|
@@ -1150,7 +1250,79 @@ const NOTEBOOK_STYLES = `
|
|
|
1150
1250
|
.nb-root.nb-view-mode .nb-icon-btn.nb-danger,
|
|
1151
1251
|
.nb-root.nb-view-mode .nb-toggle-src,
|
|
1152
1252
|
.nb-root.nb-view-mode .nb-toggle-res,
|
|
1153
|
-
.nb-root.nb-view-mode .nb-add-cell
|
|
1253
|
+
.nb-root.nb-view-mode .nb-add-cell,
|
|
1254
|
+
.nb-root.nb-view-mode .nbe-cell-actionbar { display: none !important; }
|
|
1255
|
+
|
|
1256
|
+
/* Per-cell action bar — slim row of insert/agent shortcuts shown under each cell in edit mode. */
|
|
1257
|
+
.nbe-cell-actionbar {
|
|
1258
|
+
display: flex; gap: 4px; align-items: center;
|
|
1259
|
+
margin: 4px 0 14px 0; padding-left: 28px;
|
|
1260
|
+
opacity: 0.55; transition: opacity 120ms ease;
|
|
1261
|
+
}
|
|
1262
|
+
.nbe-cell-actionbar:hover { opacity: 1; }
|
|
1263
|
+
.nb-cellbar-btn {
|
|
1264
|
+
font-size: 11px; padding: 2px 8px; line-height: 1.4;
|
|
1265
|
+
background: transparent; border: 1px dashed var(--color-border, #c8c2b4);
|
|
1266
|
+
color: var(--color-text2, #645d4f); border-radius: 6px; cursor: pointer;
|
|
1267
|
+
}
|
|
1268
|
+
.nb-cellbar-btn:hover { background: var(--color-bg2, #f4efdf); color: var(--color-text1, #1c1a16); border-style: solid; }
|
|
1269
|
+
|
|
1270
|
+
/* Widget picker overlay (+widget action) */
|
|
1271
|
+
.nbe-picker-overlay {
|
|
1272
|
+
position: fixed; inset: 0; background: rgba(20, 18, 14, 0.55);
|
|
1273
|
+
display: flex; align-items: flex-start; justify-content: center;
|
|
1274
|
+
padding-top: 80px; z-index: 9999;
|
|
1275
|
+
font-family: var(--font-sans, system-ui);
|
|
1276
|
+
}
|
|
1277
|
+
.nbe-picker {
|
|
1278
|
+
background: var(--color-bg1, #faf6e6); color: var(--color-text1, #1c1a16);
|
|
1279
|
+
border-radius: 12px; box-shadow: 0 24px 64px rgba(0,0,0,0.18);
|
|
1280
|
+
width: min(620px, calc(100vw - 32px)); max-height: 70vh;
|
|
1281
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
1282
|
+
}
|
|
1283
|
+
.nbe-picker > header {
|
|
1284
|
+
display: flex; gap: 8px; align-items: center;
|
|
1285
|
+
padding: 10px 12px; border-bottom: 1px solid var(--color-border, #c8c2b4);
|
|
1286
|
+
}
|
|
1287
|
+
.nbe-picker-q {
|
|
1288
|
+
flex: 1; font-family: inherit; font-size: 14px;
|
|
1289
|
+
padding: 6px 10px; border: 1px solid var(--color-border, #c8c2b4);
|
|
1290
|
+
background: var(--color-bg0, #ffffff); border-radius: 6px;
|
|
1291
|
+
}
|
|
1292
|
+
.nbe-picker-close {
|
|
1293
|
+
background: transparent; border: none; cursor: pointer;
|
|
1294
|
+
font-size: 16px; color: var(--color-text2, #645d4f); padding: 4px 8px;
|
|
1295
|
+
}
|
|
1296
|
+
.nbe-picker-list {
|
|
1297
|
+
list-style: none; margin: 0; padding: 4px 0;
|
|
1298
|
+
overflow-y: auto; flex: 1;
|
|
1299
|
+
}
|
|
1300
|
+
.nbe-picker-list li {
|
|
1301
|
+
display: grid; grid-template-columns: minmax(120px, auto) auto 1fr;
|
|
1302
|
+
gap: 12px; align-items: baseline;
|
|
1303
|
+
padding: 8px 14px; cursor: pointer;
|
|
1304
|
+
border-bottom: 1px solid var(--color-bg2, #f0eada);
|
|
1305
|
+
}
|
|
1306
|
+
.nbe-picker-list li:hover { background: var(--color-bg2, #f4efdf); }
|
|
1307
|
+
.nbe-picker-name { font-family: var(--font-mono, monospace); font-size: 13px; color: var(--color-text1, #1c1a16); }
|
|
1308
|
+
.nbe-picker-server { font-size: 11px; color: var(--color-accent, #1d6f5f); text-transform: lowercase; }
|
|
1309
|
+
.nbe-picker-desc { font-size: 12px; color: var(--color-text2, #645d4f); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1310
|
+
|
|
1311
|
+
/* Agent bar (+agent) */
|
|
1312
|
+
.nbe-agent-bar {
|
|
1313
|
+
margin: 0 0 16px 28px;
|
|
1314
|
+
padding: 8px 10px;
|
|
1315
|
+
background: var(--color-bg2, #f4efdf);
|
|
1316
|
+
border-radius: 8px;
|
|
1317
|
+
border: 1px solid var(--color-border, #c8c2b4);
|
|
1318
|
+
display: flex; flex-direction: column; gap: 6px;
|
|
1319
|
+
}
|
|
1320
|
+
.nbe-agent-bar auto-chat-input { display: block; }
|
|
1321
|
+
.nbe-agent-status {
|
|
1322
|
+
font-family: var(--font-mono, monospace); font-size: 11px;
|
|
1323
|
+
color: var(--color-text2, #645d4f);
|
|
1324
|
+
padding: 0 4px;
|
|
1325
|
+
}
|
|
1154
1326
|
/* Run controls (.nb-ctl-pill) remain active in view mode — users can execute cells
|
|
1155
1327
|
* even when the notebook is read-only; only editing the source is locked. */
|
|
1156
1328
|
.nb-root.nb-view-mode textarea,
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
export interface CardsData {
|
|
16
16
|
title?: string;
|
|
17
17
|
cards?: CardItem[];
|
|
18
|
+
items?: CardItem[];
|
|
18
19
|
minCardWidth?: string;
|
|
19
20
|
gap?: string;
|
|
20
21
|
emptyMessage?: string;
|
|
@@ -28,7 +29,8 @@
|
|
|
28
29
|
let { data = {}, oncardclick }: Props = $props();
|
|
29
30
|
|
|
30
31
|
const cards = $derived.by<CardItem[]>(() => {
|
|
31
|
-
|
|
32
|
+
const list = data?.cards ?? data?.items;
|
|
33
|
+
if (Array.isArray(list) && (list as unknown[]).length) return list as CardItem[];
|
|
32
34
|
return [];
|
|
33
35
|
});
|
|
34
36
|
</script>
|
|
@@ -41,20 +41,86 @@
|
|
|
41
41
|
return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Normalize a single series item:
|
|
46
|
+
* - `values: number[]` → kept as-is
|
|
47
|
+
* - `value: number` → wrapped as `[value]` (tolerance #1)
|
|
48
|
+
*/
|
|
49
|
+
function normalizeItem(o: Record<string, unknown>): ChartDataset | null {
|
|
50
|
+
// Prefer explicit `values` array; fall back to scalar `value`
|
|
51
|
+
let values: number[];
|
|
52
|
+
if (Array.isArray(o.values)) {
|
|
53
|
+
values = coerceValues(o.values);
|
|
54
|
+
} else if (typeof o.value === 'number' && Number.isFinite(o.value)) {
|
|
55
|
+
values = [o.value];
|
|
56
|
+
} else if (typeof o.value === 'string') {
|
|
57
|
+
const n = Number(o.value);
|
|
58
|
+
values = Number.isFinite(n) ? [n] : [];
|
|
59
|
+
} else {
|
|
60
|
+
values = [];
|
|
61
|
+
}
|
|
62
|
+
if (!values.length) return null;
|
|
63
|
+
return {
|
|
64
|
+
label: isPrimitiveLabel(o.label) ? String(o.label) : undefined,
|
|
65
|
+
values,
|
|
66
|
+
color: typeof o.color === 'string' ? o.color : undefined,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Detect categorical shorthand: [{label?, value}, ...] — one point per item, single series. */
|
|
71
|
+
function isCategoricalArray(raw: unknown): boolean {
|
|
72
|
+
if (!Array.isArray(raw) || raw.length === 0) return false;
|
|
73
|
+
return raw.every(item =>
|
|
74
|
+
item && typeof item === 'object'
|
|
75
|
+
&& !Array.isArray((item as Record<string, unknown>).values)
|
|
76
|
+
&& (item as Record<string, unknown>).value !== undefined
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
44
80
|
function normalizeDatasets(raw: unknown): ChartDataset[] {
|
|
45
81
|
if (!Array.isArray(raw)) return [];
|
|
82
|
+
// Categorical shorthand: collapse N items into ONE series of N points.
|
|
83
|
+
// Without this, recipes that send `data: [{label, value}, ...]` produce N
|
|
84
|
+
// datasets of 1 point each, all rendering at x=0 (all dots stack vertically).
|
|
85
|
+
if (isCategoricalArray(raw)) {
|
|
86
|
+
const values = raw.map(item => {
|
|
87
|
+
const v = (item as Record<string, unknown>).value;
|
|
88
|
+
const n = typeof v === 'number' ? v : Number(v as string);
|
|
89
|
+
return Number.isFinite(n) ? n : 0;
|
|
90
|
+
});
|
|
91
|
+
return [{ values }];
|
|
92
|
+
}
|
|
46
93
|
return raw.flatMap(item => {
|
|
47
94
|
if (!item || typeof item !== 'object') return [];
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
const values = coerceValues(o.values);
|
|
51
|
-
if (!values.length) return [];
|
|
52
|
-
return [{ label: isPrimitiveLabel(o.label) ? String(o.label) : undefined, values, color: typeof o.color === 'string' ? o.color : undefined }];
|
|
95
|
+
const result = normalizeItem(item as Record<string, unknown>);
|
|
96
|
+
return result ? [result] : [];
|
|
53
97
|
});
|
|
54
98
|
}
|
|
55
99
|
|
|
56
|
-
|
|
57
|
-
|
|
100
|
+
function extractCategoricalLabels(raw: unknown): string[] {
|
|
101
|
+
if (!isCategoricalArray(raw)) return [];
|
|
102
|
+
return (raw as unknown[]).map(item => {
|
|
103
|
+
const l = (item as Record<string, unknown>).label;
|
|
104
|
+
return typeof l === 'string' || typeof l === 'number' ? String(l) : '';
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve datasets from `data` prop, tolerating two shapes:
|
|
110
|
+
* - `{ datasets: [...] }` — canonical
|
|
111
|
+
* - `{ data: [...] }` — agent shorthand (schema uses `data` key)
|
|
112
|
+
*/
|
|
113
|
+
const rawArray = $derived(
|
|
114
|
+
Array.isArray((data as Record<string, unknown> | undefined)?.['datasets'])
|
|
115
|
+
? (data as Record<string, unknown>)['datasets']
|
|
116
|
+
: (data as Record<string, unknown> | undefined)?.['data'],
|
|
117
|
+
);
|
|
118
|
+
const datasets = $derived<ChartDataset[]>(normalizeDatasets(rawArray));
|
|
119
|
+
const labels = $derived<string[]>(
|
|
120
|
+
Array.isArray(data?.labels)
|
|
121
|
+
? (data!.labels! as unknown[]).filter(isPrimitiveLabel).map(String)
|
|
122
|
+
: extractCategoricalLabels(rawArray),
|
|
123
|
+
);
|
|
58
124
|
const type = $derived(data?.type ?? 'bar');
|
|
59
125
|
const isPie = $derived(type === 'pie' || type === 'donut');
|
|
60
126
|
const allVals = $derived(datasets.flatMap(d => d.values));
|