@webmcp-auto-ui/ui 2.5.37 → 2.5.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/base/chat-inline.svelte +81 -9
- package/src/widgets/WidgetRenderer.svelte +6 -5
- package/src/widgets/notebook/left-pane.ts +20 -0
- package/src/widgets/notebook/notebook.ts +432 -44
- package/src/widgets/notebook/resource-extractor.ts +16 -1
- package/src/widgets/notebook/share-handlers.ts +20 -1
- package/src/widgets/notebook/shared.ts +185 -25
- package/src/widgets/rich/cards.svelte +3 -1
- package/src/widgets/rich/chart-rich.svelte +73 -7
- package/src/widgets/rich/data-table.svelte +28 -7
- package/src/widgets/rich/map.svelte +392 -0
- package/src/widgets/rich/stat-card.svelte +119 -20
|
@@ -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 {
|
|
@@ -189,6 +200,9 @@ export function createState(initial?: Partial<NotebookState>): NotebookState {
|
|
|
189
200
|
autoRun: initial?.autoRun ?? false,
|
|
190
201
|
publishedSlug: initial?.publishedSlug,
|
|
191
202
|
publishedToken: initial?.publishedToken,
|
|
203
|
+
webmcpServers: initial?.webmcpServers,
|
|
204
|
+
chatApiBase: initial?.chatApiBase,
|
|
205
|
+
hidePublishBadge: initial?.hidePublishBadge,
|
|
192
206
|
};
|
|
193
207
|
}
|
|
194
208
|
|
|
@@ -308,17 +322,18 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
|
|
|
308
322
|
onTick?.(overlay);
|
|
309
323
|
|
|
310
324
|
const summary: AutoRefreshSummary = { rerun: 0, frozen: 0, stale: 0, failed: 0 };
|
|
325
|
+
const sharedSignal = signal ?? new AbortController().signal;
|
|
311
326
|
|
|
312
|
-
|
|
313
|
-
if (
|
|
314
|
-
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; }
|
|
315
330
|
|
|
316
331
|
overlay.status.set(cell.id, 'running');
|
|
317
332
|
onCellChange?.(cell.id);
|
|
318
333
|
onTick?.(overlay);
|
|
319
334
|
|
|
320
335
|
try {
|
|
321
|
-
const result = await runner(cell,
|
|
336
|
+
const result = await runner(cell, sharedSignal);
|
|
322
337
|
if (result.ok) {
|
|
323
338
|
overlay.outputs.set(cell.id, { result, refreshedAt: Date.now() });
|
|
324
339
|
overlay.status.set(cell.id, 'fresh');
|
|
@@ -334,7 +349,9 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
|
|
|
334
349
|
}
|
|
335
350
|
onCellChange?.(cell.id);
|
|
336
351
|
onTick?.(overlay);
|
|
337
|
-
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
await Promise.all(tasks);
|
|
338
355
|
|
|
339
356
|
overlay.finishedAt = Date.now();
|
|
340
357
|
onTick?.(overlay);
|
|
@@ -683,7 +700,8 @@ export interface DataServerDescriptor {
|
|
|
683
700
|
/** Real server name from MCP handshake (initResult.serverInfo.name, aliased). */
|
|
684
701
|
serverName?: string;
|
|
685
702
|
url?: string;
|
|
686
|
-
|
|
703
|
+
/** 'data' = remote MCP via canvas.dataServers, 'webmcp' = bundled JS server. */
|
|
704
|
+
kind?: 'data' | 'webmcp' | string;
|
|
687
705
|
tools?: DataServerTool[];
|
|
688
706
|
recipes?: DataServerRecipe[];
|
|
689
707
|
}
|
|
@@ -734,6 +752,30 @@ export function collectDataServers(data: Record<string, unknown>): DataServerDes
|
|
|
734
752
|
}));
|
|
735
753
|
}
|
|
736
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
|
+
|
|
737
779
|
|
|
738
780
|
// ---------------------------------------------------------------------------
|
|
739
781
|
// Publish controls (button + optional badge + optional footer)
|
|
@@ -765,16 +807,25 @@ function publishUrlFor(slug: string): string {
|
|
|
765
807
|
return `${NB_PUBLISH_HOST}/p/${slug}`;
|
|
766
808
|
}
|
|
767
809
|
|
|
810
|
+
function publishMode(state: NotebookState): 'publish' | 'save' | 'fork' {
|
|
811
|
+
if (!state.publishedSlug) return 'publish';
|
|
812
|
+
return state.publishedToken ? 'save' : 'fork';
|
|
813
|
+
}
|
|
814
|
+
|
|
768
815
|
function publishBtnLabel(state: NotebookState): string {
|
|
769
|
-
|
|
816
|
+
const m = publishMode(state);
|
|
817
|
+
return m === 'save' ? '💾 save' : m === 'fork' ? '🍴 fork' : '📤 publish';
|
|
770
818
|
}
|
|
771
819
|
|
|
772
820
|
function refreshPublishControls(state: NotebookState, controls: PublishControlsHandles): void {
|
|
773
821
|
const { btn, badge, footer } = controls;
|
|
774
822
|
btn.textContent = publishBtnLabel(state);
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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';
|
|
778
829
|
} else {
|
|
779
830
|
btn.title = 'Publish this notebook';
|
|
780
831
|
}
|
|
@@ -790,8 +841,18 @@ function refreshPublishControls(state: NotebookState, controls: PublishControlsH
|
|
|
790
841
|
}
|
|
791
842
|
if (footer) {
|
|
792
843
|
if (state.publishedSlug) {
|
|
793
|
-
const
|
|
794
|
-
|
|
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
|
+
}
|
|
795
856
|
footer.style.display = '';
|
|
796
857
|
} else {
|
|
797
858
|
footer.innerHTML = '';
|
|
@@ -847,20 +908,24 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
|
|
|
847
908
|
|
|
848
909
|
const onClick = async () => {
|
|
849
910
|
const prevLabel = btn.textContent ?? '';
|
|
911
|
+
const mode = publishMode(state);
|
|
850
912
|
btn.disabled = true;
|
|
851
|
-
btn.textContent =
|
|
913
|
+
btn.textContent = mode === 'save' ? '… saving' : mode === 'fork' ? '… forking' : '… publishing';
|
|
852
914
|
try {
|
|
853
915
|
// HyperSkill standalone markdown — frontmatter (title/description/servers)
|
|
854
916
|
// + body with fenced cells. Re-parsable via parseFrontmatter + parseBody.
|
|
855
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
|
+
}
|
|
856
925
|
const res = await fetch(`${NB_PUBLISH_HOST}/api/publish`, {
|
|
857
926
|
method: 'POST',
|
|
858
927
|
headers: { 'content-type': 'application/json' },
|
|
859
|
-
body: JSON.stringify(
|
|
860
|
-
markdown,
|
|
861
|
-
slug: state.publishedSlug,
|
|
862
|
-
token: state.publishedToken,
|
|
863
|
-
}),
|
|
928
|
+
body: JSON.stringify(payload),
|
|
864
929
|
});
|
|
865
930
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
866
931
|
const reply: any = await res.json();
|
|
@@ -873,15 +938,20 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
|
|
|
873
938
|
// URL). Public visitors share the bare URL without ?t.
|
|
874
939
|
const authorUrl = `${baseUrl}?t=${encodeURIComponent(String(reply.token))}`;
|
|
875
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
|
+
}
|
|
876
949
|
const updated = Boolean(reply.updated);
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
? `saved · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`
|
|
880
|
-
: `published · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`
|
|
881
|
-
);
|
|
950
|
+
const action = mode === 'fork' ? 'forked' : updated ? 'saved' : 'published';
|
|
951
|
+
toast(`${action} · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`);
|
|
882
952
|
opts.onPublished?.({ slug: String(reply.slug), url: baseUrl, updated });
|
|
883
953
|
} catch (err: any) {
|
|
884
|
-
toast(
|
|
954
|
+
toast(`${mode} failed · ${String(err?.message ?? err)}`, true);
|
|
885
955
|
btn.textContent = prevLabel;
|
|
886
956
|
} finally {
|
|
887
957
|
btn.disabled = false;
|
|
@@ -891,8 +961,26 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
|
|
|
891
961
|
|
|
892
962
|
btn.addEventListener('click', onClick);
|
|
893
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
|
+
|
|
894
981
|
return () => {
|
|
895
982
|
btn.removeEventListener('click', onClick);
|
|
983
|
+
if (footer) footer.removeEventListener('click', onFooterClick);
|
|
896
984
|
btn.parentNode?.removeChild(btn);
|
|
897
985
|
badge?.parentNode?.removeChild(badge);
|
|
898
986
|
footer?.parentNode?.removeChild(footer);
|
|
@@ -1162,7 +1250,79 @@ const NOTEBOOK_STYLES = `
|
|
|
1162
1250
|
.nb-root.nb-view-mode .nb-icon-btn.nb-danger,
|
|
1163
1251
|
.nb-root.nb-view-mode .nb-toggle-src,
|
|
1164
1252
|
.nb-root.nb-view-mode .nb-toggle-res,
|
|
1165
|
-
.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
|
+
}
|
|
1166
1326
|
/* Run controls (.nb-ctl-pill) remain active in view mode — users can execute cells
|
|
1167
1327
|
* even when the notebook is read-only; only editing the source is locked. */
|
|
1168
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));
|
|
@@ -23,15 +23,36 @@
|
|
|
23
23
|
let sortCol = $state<string|null>(null);
|
|
24
24
|
let sortAsc = $state(true);
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
// ── Tolerance 1: alias `headers` → `columns` ─────────────────────────────
|
|
27
|
+
// Some callers pass `headers` (array of strings or column objects) instead of
|
|
28
|
+
// `columns`. Normalise so downstream logic only sees `columns`.
|
|
29
|
+
const resolvedColumnDefs = $derived.by<DataTableColumn[] | null>(() => {
|
|
30
|
+
const src = (data as DataTableData & { headers?: unknown }).headers ?? data?.columns;
|
|
31
|
+
if (!Array.isArray(src) || !src.length) return null;
|
|
32
|
+
// headers can be plain strings ["A","B"] or full column objects [{key,label}]
|
|
33
|
+
return (src as unknown[]).map((h) =>
|
|
34
|
+
typeof h === 'string' ? { key: h, label: h } : h as DataTableColumn
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ── Tolerance 2: rows as array-of-arrays ─────────────────────────────────
|
|
39
|
+
// If the first row is an array, map each row to an object using `resolvedColumnDefs`
|
|
40
|
+
// (falling back to positional keys "0","1",… when no column defs are available).
|
|
41
|
+
const rows = $derived.by<Record<string,unknown>[]>(() => {
|
|
42
|
+
if (!Array.isArray(data?.rows) || !(data!.rows as unknown[]).length) return [];
|
|
43
|
+
const raw = data!.rows as unknown[];
|
|
44
|
+
if (!Array.isArray(raw[0])) return raw as Record<string,unknown>[];
|
|
45
|
+
// array-of-arrays: convert to array-of-objects
|
|
46
|
+
const keys = resolvedColumnDefs?.map(c => c.key)
|
|
47
|
+
?? (raw[0] as unknown[]).map((_, i) => String(i));
|
|
48
|
+
return (raw as unknown[][]).map(arr =>
|
|
49
|
+
Object.fromEntries(keys.map((k, i) => [k, arr[i]]))
|
|
50
|
+
);
|
|
51
|
+
});
|
|
31
52
|
|
|
32
53
|
const columns = $derived<DataTableColumn[]>(
|
|
33
|
-
|
|
34
|
-
?
|
|
54
|
+
resolvedColumnDefs && resolvedColumnDefs.length
|
|
55
|
+
? resolvedColumnDefs
|
|
35
56
|
: rows.length > 0
|
|
36
57
|
? Object.keys(rows[0] as object).map(k => ({ key: k, label: k }))
|
|
37
58
|
: []
|