@webmcp-auto-ui/ui 2.5.27 → 2.5.29
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 +18 -5
- package/src/agent/LLMSelector.svelte +11 -3
- package/src/agent/ModelCacheManager.svelte +359 -0
- package/src/index.ts +42 -30
- package/src/theme/scale.ts +128 -0
- package/src/widgets/WidgetRenderer.svelte +144 -107
- package/src/widgets/export-widget.ts +28 -1
- package/src/widgets/helpers/safe-image.ts +78 -0
- package/src/widgets/notebook/.gitkeep +0 -0
- package/src/widgets/notebook/chart-renderer.ts +63 -0
- package/src/widgets/notebook/executors/.gitkeep +1 -0
- package/src/widgets/notebook/executors/index.ts +4 -0
- package/src/widgets/notebook/executors/js-worker.ts +269 -0
- package/src/widgets/notebook/executors/sql.ts +206 -0
- package/src/widgets/notebook/import-modals.ts +560 -0
- package/src/widgets/notebook/left-pane.ts +256 -0
- package/src/widgets/notebook/notebook.ts +930 -0
- package/src/widgets/notebook/prose.ts +615 -0
- package/src/widgets/notebook/recipe-browser.ts +350 -0
- package/src/widgets/notebook/recipes/notebook.md +124 -0
- package/src/widgets/notebook/resource-extractor.ts +162 -0
- package/src/widgets/notebook/share-handlers.ts +222 -0
- package/src/widgets/notebook/shared.ts +1633 -0
- package/src/widgets/rich/cards.ts +181 -0
- package/src/widgets/rich/carousel.ts +319 -0
- package/src/widgets/rich/chart-rich.ts +386 -0
- package/src/widgets/rich/d3.ts +503 -0
- package/src/widgets/rich/data-table.ts +342 -0
- package/src/widgets/rich/gallery.ts +350 -0
- package/src/widgets/rich/grid-data.ts +173 -0
- package/src/widgets/rich/hemicycle.ts +313 -0
- package/src/widgets/rich/js-sandbox.ts +122 -0
- package/src/widgets/rich/json-viewer.ts +202 -0
- package/src/widgets/rich/log.ts +143 -0
- package/src/widgets/rich/map.ts +218 -0
- package/src/widgets/rich/profile.ts +256 -0
- package/src/widgets/rich/sankey.ts +257 -0
- package/src/widgets/rich/stat-card.ts +125 -0
- package/src/widgets/rich/timeline.ts +179 -0
- package/src/widgets/rich/trombinoscope.ts +246 -0
- package/src/widgets/simple/actions.ts +89 -0
- package/src/widgets/simple/alert.ts +100 -0
- package/src/widgets/simple/chart.ts +189 -0
- package/src/widgets/simple/code.ts +79 -0
- package/src/widgets/simple/kv.ts +68 -0
- package/src/widgets/simple/list.ts +89 -0
- package/src/widgets/simple/stat.ts +58 -0
- package/src/widgets/simple/tags.ts +125 -0
- package/src/widgets/simple/text.ts +198 -0
- package/src/widgets/SafeImage.svelte +0 -76
- package/src/widgets/rich/Cards.svelte +0 -39
- package/src/widgets/rich/Carousel.svelte +0 -88
- package/src/widgets/rich/Chart.svelte +0 -142
- package/src/widgets/rich/D3Widget.svelte +0 -378
- package/src/widgets/rich/DataTable.svelte +0 -62
- package/src/widgets/rich/Gallery.svelte +0 -94
- package/src/widgets/rich/GridData.svelte +0 -44
- package/src/widgets/rich/Hemicycle.svelte +0 -78
- package/src/widgets/rich/JsSandbox.svelte +0 -51
- package/src/widgets/rich/JsonViewer.svelte +0 -42
- package/src/widgets/rich/LogViewer.svelte +0 -24
- package/src/widgets/rich/MapView.svelte +0 -140
- package/src/widgets/rich/ProfileCard.svelte +0 -59
- package/src/widgets/rich/Sankey.svelte +0 -56
- package/src/widgets/rich/StatCard.svelte +0 -35
- package/src/widgets/rich/Timeline.svelte +0 -43
- package/src/widgets/rich/Trombinoscope.svelte +0 -48
- package/src/widgets/simple/ActionsBlock.svelte +0 -15
- package/src/widgets/simple/AlertBlock.svelte +0 -11
- package/src/widgets/simple/ChartBlock.svelte +0 -21
- package/src/widgets/simple/CodeBlock.svelte +0 -11
- package/src/widgets/simple/KVBlock.svelte +0 -16
- package/src/widgets/simple/ListBlock.svelte +0 -17
- package/src/widgets/simple/StatBlock.svelte +0 -14
- package/src/widgets/simple/TagsBlock.svelte +0 -15
- package/src/widgets/simple/TextBlock.svelte +0 -122
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI scale — binary 1× / 1.5× toggle, labeled as "2×" in the UI for affordance.
|
|
3
|
+
*
|
|
4
|
+
* Implementation: CSS `zoom` on `<html>`, driven by a custom property set
|
|
5
|
+
* imperatively. This rescales everything uniformly (fonts, icons, borders,
|
|
6
|
+
* modals, widgets) while keeping layout reflow (contrary to `transform: scale`).
|
|
7
|
+
*
|
|
8
|
+
* Persistence: `localStorage['webmcp-ui-scale']` — "x1" | "x2".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type UIScale = 1 | 1.5;
|
|
12
|
+
export type UIScaleKey = 'x1' | 'x2';
|
|
13
|
+
|
|
14
|
+
const STORAGE_KEY = 'webmcp-ui-scale';
|
|
15
|
+
const STYLE_ID = 'webmcp-ui-scale-style';
|
|
16
|
+
|
|
17
|
+
function injectStyle(): void {
|
|
18
|
+
if (typeof document === 'undefined') return;
|
|
19
|
+
if (document.getElementById(STYLE_ID)) return;
|
|
20
|
+
const style = document.createElement('style');
|
|
21
|
+
style.id = STYLE_ID;
|
|
22
|
+
// When scaled:
|
|
23
|
+
// - zoom the root so everything grows uniformly
|
|
24
|
+
// - compensate layouts sized in 100vh/100vw (viewport units stay tied to the
|
|
25
|
+
// physical viewport regardless of zoom, so a `h-screen` container rendered
|
|
26
|
+
// at 1.5× would overflow by 50%). Force these containers to the logical
|
|
27
|
+
// equivalent so they fit after scaling.
|
|
28
|
+
// - keep html/body overflow:hidden (matches the unscaled baseline) so
|
|
29
|
+
// floating bars with flex-shrink-0 stay pinned to the viewport edge
|
|
30
|
+
// instead of drifting mid-screen when the body scrolls.
|
|
31
|
+
style.textContent = `
|
|
32
|
+
html { zoom: var(--ui-scale, 1); }
|
|
33
|
+
html[data-ui-scale="x2"] {
|
|
34
|
+
width: calc(100vw / 1.5);
|
|
35
|
+
height: calc(100vh / 1.5);
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
}
|
|
38
|
+
html[data-ui-scale="x2"] body {
|
|
39
|
+
width: 100%;
|
|
40
|
+
height: 100%;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
}
|
|
43
|
+
html[data-ui-scale="x2"] .h-screen,
|
|
44
|
+
html[data-ui-scale="x2"] [class*="h-screen"] {
|
|
45
|
+
height: calc(100vh / 1.5);
|
|
46
|
+
max-height: calc(100vh / 1.5);
|
|
47
|
+
}
|
|
48
|
+
html[data-ui-scale="x2"] .w-screen,
|
|
49
|
+
html[data-ui-scale="x2"] [class*="w-screen"] {
|
|
50
|
+
width: calc(100vw / 1.5);
|
|
51
|
+
max-width: calc(100vw / 1.5);
|
|
52
|
+
}
|
|
53
|
+
`;
|
|
54
|
+
document.head.appendChild(style);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function applyScale(scale: UIScale): void {
|
|
58
|
+
if (typeof document === 'undefined') return;
|
|
59
|
+
injectStyle();
|
|
60
|
+
const root = document.documentElement;
|
|
61
|
+
root.style.setProperty('--ui-scale', String(scale));
|
|
62
|
+
root.dataset.uiScale = scale === 1 ? 'x1' : 'x2';
|
|
63
|
+
try {
|
|
64
|
+
window.dispatchEvent(new CustomEvent('webmcp:ui-scale-change', { detail: { scale } }));
|
|
65
|
+
} catch { /* ignore */ }
|
|
66
|
+
// Canvas-based widgets (cytoscape, d3, vega-embed, leaflet, custom SVG) size
|
|
67
|
+
// their rendering surface at mount via getBoundingClientRect and don't react
|
|
68
|
+
// to CSS-only zoom changes. Fire a resize event after the browser has applied
|
|
69
|
+
// the zoom (next RAF) so those libs redraw against the new container size.
|
|
70
|
+
// A second tick after ~200ms catches widgets that listen via debounced
|
|
71
|
+
// ResizeObserver + widgets that rebuild on a longer cycle.
|
|
72
|
+
try {
|
|
73
|
+
requestAnimationFrame(() => {
|
|
74
|
+
window.dispatchEvent(new Event('resize'));
|
|
75
|
+
setTimeout(() => window.dispatchEvent(new Event('resize')), 200);
|
|
76
|
+
});
|
|
77
|
+
} catch { /* ignore */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readStored(): UIScale {
|
|
81
|
+
if (typeof localStorage === 'undefined') return 1;
|
|
82
|
+
try {
|
|
83
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
84
|
+
if (raw === 'x2') return 1.5;
|
|
85
|
+
return 1;
|
|
86
|
+
} catch { return 1; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function writeStored(scale: UIScale): void {
|
|
90
|
+
if (typeof localStorage === 'undefined') return;
|
|
91
|
+
try { localStorage.setItem(STORAGE_KEY, scale === 1 ? 'x1' : 'x2'); } catch { /* ignore */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Current scale value. Safe to call before init. */
|
|
95
|
+
export function getUIScale(): UIScale {
|
|
96
|
+
if (typeof document === 'undefined') return readStored();
|
|
97
|
+
const key = document.documentElement.dataset.uiScale;
|
|
98
|
+
if (key === 'x2') return 1.5;
|
|
99
|
+
if (key === 'x1') return 1;
|
|
100
|
+
return readStored();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True when the UI is currently displayed at 1.5× (label "2×" in the UI). */
|
|
104
|
+
export function isUIScaled(): boolean {
|
|
105
|
+
return getUIScale() !== 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Set the scale explicitly. Persists + applies. */
|
|
109
|
+
export function setUIScale(scale: UIScale): void {
|
|
110
|
+
writeStored(scale);
|
|
111
|
+
applyScale(scale);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Toggle between 1× and 1.5×. Returns the new scale. */
|
|
115
|
+
export function toggleUIScale(): UIScale {
|
|
116
|
+
const next: UIScale = getUIScale() === 1 ? 1.5 : 1;
|
|
117
|
+
setUIScale(next);
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Read persisted scale and apply it. Call once at app boot (e.g. in a root
|
|
123
|
+
* +layout onMount) to restore the user's preference before first paint.
|
|
124
|
+
* Safe to call multiple times.
|
|
125
|
+
*/
|
|
126
|
+
export function initUIScale(): void {
|
|
127
|
+
applyScale(readStored());
|
|
128
|
+
}
|
|
@@ -1,96 +1,95 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { type Component, onMount, onDestroy } from 'svelte';
|
|
2
|
+
import { type Component, onMount, onDestroy, untrack } from 'svelte';
|
|
3
3
|
import type { WebMcpServer } from '@webmcp-auto-ui/core';
|
|
4
4
|
import { bus } from '../messaging/bus.svelte.js';
|
|
5
|
-
// Simple widgets
|
|
6
|
-
import StatBlock from './simple/StatBlock.svelte';
|
|
7
|
-
import KVBlock from './simple/KVBlock.svelte';
|
|
8
|
-
import ListBlock from './simple/ListBlock.svelte';
|
|
9
|
-
import ChartBlock from './simple/ChartBlock.svelte';
|
|
10
|
-
import AlertBlock from './simple/AlertBlock.svelte';
|
|
11
|
-
import CodeBlock from './simple/CodeBlock.svelte';
|
|
12
|
-
import TextBlock from './simple/TextBlock.svelte';
|
|
13
|
-
import ActionsBlock from './simple/ActionsBlock.svelte';
|
|
14
|
-
import TagsBlock from './simple/TagsBlock.svelte';
|
|
15
|
-
// Rich widgets
|
|
16
|
-
import StatCard from './rich/StatCard.svelte';
|
|
17
|
-
import DataTable from './rich/DataTable.svelte';
|
|
18
|
-
import Timeline from './rich/Timeline.svelte';
|
|
19
|
-
import ProfileCard from './rich/ProfileCard.svelte';
|
|
20
|
-
import Trombinoscope from './rich/Trombinoscope.svelte';
|
|
21
|
-
import JsonViewer from './rich/JsonViewer.svelte';
|
|
22
|
-
import Hemicycle from './rich/Hemicycle.svelte';
|
|
23
|
-
import Chart from './rich/Chart.svelte';
|
|
24
|
-
import Cards from './rich/Cards.svelte';
|
|
25
|
-
import GridData from './rich/GridData.svelte';
|
|
26
|
-
import Sankey from './rich/Sankey.svelte';
|
|
27
|
-
import MapView from './rich/MapView.svelte';
|
|
28
|
-
import D3Widget from './rich/D3Widget.svelte';
|
|
29
|
-
import JsSandbox from './rich/JsSandbox.svelte';
|
|
30
|
-
import LogViewer from './rich/LogViewer.svelte';
|
|
31
|
-
import Gallery from './rich/Gallery.svelte';
|
|
32
|
-
import Carousel from './rich/Carousel.svelte';
|
|
33
|
-
|
|
34
|
-
type Emit = (action: string, payload: unknown) => void;
|
|
35
|
-
|
|
36
|
-
/** Native widget entry: component + prop builder */
|
|
37
|
-
type NativeEntry = {
|
|
38
|
-
component: Component<any>;
|
|
39
|
-
props: (data: Record<string, unknown>, emit: Emit) => Record<string, unknown>;
|
|
40
|
-
};
|
|
41
5
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
6
|
+
// Monotonic counter for busId generation — avoids collisions on sub-ms remounts.
|
|
7
|
+
let busIdCounter = 0;
|
|
8
|
+
function makeBusId(widgetType: string): string {
|
|
9
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
10
|
+
return `block_${widgetType}_${crypto.randomUUID()}`;
|
|
11
|
+
}
|
|
12
|
+
return `block_${widgetType}_${Date.now()}-${++busIdCounter}`;
|
|
13
|
+
}
|
|
47
14
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
15
|
+
// Safe clone for data passed to vanilla renderers — strips Svelte 5 $state
|
|
16
|
+
// proxies without crashing on BigInt / circular refs / undefined values.
|
|
17
|
+
function safeClone<T>(value: T): T {
|
|
18
|
+
if (value === null || typeof value !== 'object') return value;
|
|
19
|
+
try {
|
|
20
|
+
return structuredClone(value);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.warn('[WidgetRenderer] structuredClone failed, falling back to shallow copy', err);
|
|
23
|
+
return { ...(value as Record<string, unknown>) } as T;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
53
26
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
27
|
+
// ── Native vanilla renderers ─────────────────────────────────────────────
|
|
28
|
+
// Simple widgets
|
|
29
|
+
import { render as renderStat } from './simple/stat.js';
|
|
30
|
+
import { render as renderKv } from './simple/kv.js';
|
|
31
|
+
import { render as renderList } from './simple/list.js';
|
|
32
|
+
import { render as renderChart } from './simple/chart.js';
|
|
33
|
+
import { render as renderAlert } from './simple/alert.js';
|
|
34
|
+
import { render as renderCode } from './simple/code.js';
|
|
35
|
+
import { render as renderText } from './simple/text.js';
|
|
36
|
+
import { render as renderActions } from './simple/actions.js';
|
|
37
|
+
import { render as renderTags } from './simple/tags.js';
|
|
38
|
+
// Rich widgets
|
|
39
|
+
import { render as renderStatCard } from './rich/stat-card.js';
|
|
40
|
+
import { render as renderProfile } from './rich/profile.js';
|
|
41
|
+
import { render as renderJsonViewer } from './rich/json-viewer.js';
|
|
42
|
+
import { render as renderChartRich } from './rich/chart-rich.js';
|
|
43
|
+
import { render as renderSankey } from './rich/sankey.js';
|
|
44
|
+
import { render as renderHemicycle } from './rich/hemicycle.js';
|
|
45
|
+
import { render as renderDataTable } from './rich/data-table.js';
|
|
46
|
+
import { render as renderTimeline } from './rich/timeline.js';
|
|
47
|
+
import { render as renderTrombinoscope } from './rich/trombinoscope.js';
|
|
48
|
+
import { render as renderCards } from './rich/cards.js';
|
|
49
|
+
import { render as renderGridData } from './rich/grid-data.js';
|
|
50
|
+
import { render as renderMap } from './rich/map.js';
|
|
51
|
+
import { render as renderD3 } from './rich/d3.js';
|
|
52
|
+
import { render as renderJsSandbox } from './rich/js-sandbox.js';
|
|
53
|
+
import { render as renderLog } from './rich/log.js';
|
|
54
|
+
import { render as renderGallery } from './rich/gallery.js';
|
|
55
|
+
import { render as renderCarousel } from './rich/carousel.js';
|
|
56
|
+
|
|
57
|
+
/** A vanilla renderer: returns cleanup or Promise thereof. */
|
|
58
|
+
type VanillaRenderer = (
|
|
59
|
+
container: HTMLElement,
|
|
60
|
+
data: Record<string, unknown>,
|
|
61
|
+
) => void | (() => void) | Promise<void | (() => void)>;
|
|
62
62
|
|
|
63
|
-
/** Static map of all native widget types →
|
|
64
|
-
const
|
|
65
|
-
// Simple
|
|
66
|
-
'stat':
|
|
67
|
-
'kv':
|
|
68
|
-
'list':
|
|
69
|
-
'chart':
|
|
70
|
-
'alert':
|
|
71
|
-
'code':
|
|
72
|
-
'text':
|
|
73
|
-
'actions':
|
|
74
|
-
'tags':
|
|
75
|
-
// Rich
|
|
76
|
-
'stat-card':
|
|
77
|
-
'profile':
|
|
78
|
-
'json-viewer':
|
|
79
|
-
'chart-rich':
|
|
80
|
-
'sankey':
|
|
81
|
-
'
|
|
82
|
-
'
|
|
83
|
-
'
|
|
84
|
-
'
|
|
85
|
-
|
|
86
|
-
'data
|
|
87
|
-
'
|
|
88
|
-
'
|
|
89
|
-
'
|
|
90
|
-
'
|
|
91
|
-
'
|
|
92
|
-
'
|
|
93
|
-
'carousel': se(Carousel, (emit) => ({ onslidechange: (slide: unknown, i: unknown) => emit('slidechange', { slide, index: i }) })),
|
|
63
|
+
/** Static map of all native widget types → vanilla renderer */
|
|
64
|
+
const NATIVE_VANILLA_MAP: Record<string, VanillaRenderer> = {
|
|
65
|
+
// Simple
|
|
66
|
+
'stat': renderStat,
|
|
67
|
+
'kv': renderKv,
|
|
68
|
+
'list': renderList,
|
|
69
|
+
'chart': renderChart,
|
|
70
|
+
'alert': renderAlert,
|
|
71
|
+
'code': renderCode,
|
|
72
|
+
'text': renderText,
|
|
73
|
+
'actions': renderActions,
|
|
74
|
+
'tags': renderTags,
|
|
75
|
+
// Rich
|
|
76
|
+
'stat-card': renderStatCard,
|
|
77
|
+
'profile': renderProfile,
|
|
78
|
+
'json-viewer': renderJsonViewer,
|
|
79
|
+
'chart-rich': renderChartRich,
|
|
80
|
+
'sankey': renderSankey,
|
|
81
|
+
'hemicycle': renderHemicycle,
|
|
82
|
+
'data-table': renderDataTable,
|
|
83
|
+
'timeline': renderTimeline,
|
|
84
|
+
'trombinoscope': renderTrombinoscope,
|
|
85
|
+
'cards': renderCards,
|
|
86
|
+
'grid-data': renderGridData,
|
|
87
|
+
'map': renderMap,
|
|
88
|
+
'd3': renderD3 as unknown as VanillaRenderer,
|
|
89
|
+
'js-sandbox': renderJsSandbox as unknown as VanillaRenderer,
|
|
90
|
+
'log': renderLog,
|
|
91
|
+
'gallery': renderGallery,
|
|
92
|
+
'carousel': renderCarousel,
|
|
94
93
|
};
|
|
95
94
|
|
|
96
95
|
interface Props {
|
|
@@ -103,7 +102,7 @@
|
|
|
103
102
|
let { id, type, data, servers, oninteract }: Props = $props();
|
|
104
103
|
|
|
105
104
|
// Auto-register on the FONC message bus
|
|
106
|
-
const busId = id ??
|
|
105
|
+
const busId = id ?? makeBusId(type);
|
|
107
106
|
const unregisterBus = bus.register(busId, type, ['data-update', 'interact', '*'], (msg) => {
|
|
108
107
|
if (msg.channel === 'data-update' && msg.payload && typeof msg.payload === 'object') {
|
|
109
108
|
oninteract?.(type, 'bus-update', msg.payload);
|
|
@@ -116,7 +115,7 @@
|
|
|
116
115
|
bus.broadcast(busId, 'interact', { type, action, payload });
|
|
117
116
|
}
|
|
118
117
|
|
|
119
|
-
// ── Renderer resolution: servers > native > fallback ────────────
|
|
118
|
+
// ── Renderer resolution: servers > native vanilla > fallback ────────────
|
|
120
119
|
|
|
121
120
|
// Look up a custom widget entry from connected WebMCP servers
|
|
122
121
|
const customWidgetEntry = $derived.by(() => {
|
|
@@ -129,36 +128,52 @@
|
|
|
129
128
|
});
|
|
130
129
|
|
|
131
130
|
const customRenderer = $derived(customWidgetEntry?.renderer ?? null);
|
|
131
|
+
const isCustomVanilla = $derived(customWidgetEntry?.vanilla === true);
|
|
132
|
+
|
|
133
|
+
const nativeVanillaRenderer = $derived<VanillaRenderer | undefined>(
|
|
134
|
+
customRenderer ? undefined : NATIVE_VANILLA_MAP[type],
|
|
135
|
+
);
|
|
132
136
|
|
|
133
|
-
/** True when
|
|
134
|
-
const
|
|
137
|
+
/** True when a vanilla renderer (custom or native) should be used */
|
|
138
|
+
const useVanilla = $derived(isCustomVanilla || !!nativeVanillaRenderer);
|
|
135
139
|
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
/** The effective vanilla renderer to invoke */
|
|
141
|
+
const vanillaRenderer = $derived<VanillaRenderer | null>(
|
|
142
|
+
isCustomVanilla
|
|
143
|
+
? (customRenderer as VanillaRenderer)
|
|
144
|
+
: (nativeVanillaRenderer ?? null),
|
|
138
145
|
);
|
|
139
146
|
|
|
140
|
-
// Deep-clone data to strip Svelte 5 $state proxies —
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
// Native Svelte components handle proxied data fine and benefit from
|
|
144
|
-
// fine-grained reactivity, so they receive raw `data` directly.
|
|
145
|
-
const plainData: Record<string, unknown> = $derived(JSON.parse(JSON.stringify(data)));
|
|
147
|
+
// Deep-clone data to strip Svelte 5 $state proxies — vanilla renderers + third-party
|
|
148
|
+
// libs (D3, Leaflet, etc.) rely on Object.defineProperty which conflicts with proxies.
|
|
149
|
+
const plainData: Record<string, unknown> = $derived(safeClone(data) as Record<string, unknown>);
|
|
146
150
|
|
|
147
151
|
// ── Vanilla renderer container + lifecycle ────────────
|
|
148
152
|
let vanillaContainer: HTMLElement | undefined = $state(undefined);
|
|
149
153
|
|
|
154
|
+
// Mount effect — re-runs only when the widget identity changes (type /
|
|
155
|
+
// renderer / container). Data changes are handled separately to avoid a
|
|
156
|
+
// flickering full remount on every agent update.
|
|
150
157
|
$effect(() => {
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
vanillaContainer
|
|
158
|
+
// Touch only the mount deps; `plainData` is intentionally read via untrack
|
|
159
|
+
// so data updates don't retrigger this effect.
|
|
160
|
+
if (!useVanilla || !vanillaRenderer || !vanillaContainer) return;
|
|
161
|
+
const container = vanillaContainer;
|
|
162
|
+
const renderer = vanillaRenderer;
|
|
163
|
+
container.innerHTML = '';
|
|
164
|
+
|
|
165
|
+
const onInteract = (ev: Event) => {
|
|
166
|
+
const ce = ev as CustomEvent<{ action?: string; payload?: unknown }>;
|
|
167
|
+
const action = ce.detail?.action ?? 'interact';
|
|
168
|
+
emit(action, ce.detail?.payload);
|
|
169
|
+
};
|
|
170
|
+
container.addEventListener('widget:interact', onInteract);
|
|
154
171
|
|
|
155
172
|
let cleanup: (() => void) | void;
|
|
156
173
|
let cancelled = false;
|
|
157
174
|
|
|
158
175
|
try {
|
|
159
|
-
const result = (
|
|
160
|
-
vanillaContainer, plainData,
|
|
161
|
-
);
|
|
176
|
+
const result = renderer(container, untrack(() => plainData));
|
|
162
177
|
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
163
178
|
(result as Promise<void | (() => void)>).then(
|
|
164
179
|
(c) => { if (!cancelled) cleanup = c ?? undefined; },
|
|
@@ -172,12 +187,36 @@
|
|
|
172
187
|
|
|
173
188
|
return () => {
|
|
174
189
|
cancelled = true;
|
|
190
|
+
container.removeEventListener('widget:interact', onInteract);
|
|
175
191
|
if (typeof cleanup === 'function') {
|
|
176
192
|
try { cleanup(); } catch (err) { console.error('[WidgetRenderer] cleanup failed:', err); }
|
|
177
193
|
}
|
|
178
194
|
};
|
|
179
195
|
});
|
|
180
196
|
|
|
197
|
+
// Data-update effect — only triggers when plainData changes. Renderers that
|
|
198
|
+
// support in-place updates should listen for `widget:data-update` and call
|
|
199
|
+
// preventDefault() on the event; doing so signals they've handled the new
|
|
200
|
+
// data themselves so we don't need to remount. Renderers that don't listen
|
|
201
|
+
// fall back to a full remount here (innerHTML cleared + mount effect re-run).
|
|
202
|
+
let firstDataCycle = true;
|
|
203
|
+
$effect(() => {
|
|
204
|
+
const data = plainData;
|
|
205
|
+
if (!vanillaContainer || !useVanilla) return;
|
|
206
|
+
if (firstDataCycle) { firstDataCycle = false; return; }
|
|
207
|
+
const ev = new CustomEvent('widget:data-update', { detail: data, cancelable: true });
|
|
208
|
+
const handled = !vanillaContainer.dispatchEvent(ev);
|
|
209
|
+
if (handled || !vanillaRenderer) return;
|
|
210
|
+
// Not handled — fall back to remount by clearing + calling renderer again.
|
|
211
|
+
const container = vanillaContainer;
|
|
212
|
+
container.innerHTML = '';
|
|
213
|
+
try {
|
|
214
|
+
vanillaRenderer(container, data);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.error('[WidgetRenderer] fallback remount failed:', err);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
181
220
|
// ── Auto-register WebMCP tools when modelContext is available ────────────
|
|
182
221
|
type ModelContext = {
|
|
183
222
|
registerTool: (t: unknown) => void;
|
|
@@ -238,12 +277,10 @@
|
|
|
238
277
|
});
|
|
239
278
|
</script>
|
|
240
279
|
|
|
241
|
-
{#if
|
|
280
|
+
{#if useVanilla}
|
|
242
281
|
<div bind:this={vanillaContainer} class="vanilla-container w-full h-full overflow-auto p-2"></div>
|
|
243
282
|
{:else if customRenderer}
|
|
244
283
|
<svelte:component this={customRenderer as Component<any>} {data} {id} />
|
|
245
|
-
{:else if nativeEntry}
|
|
246
|
-
<svelte:component this={nativeEntry.component} {...nativeEntry.props(data, emit)} />
|
|
247
284
|
{:else}
|
|
248
285
|
<div class="p-3 font-mono text-xs text-text2">[{type}]</div>
|
|
249
286
|
{/if}
|
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
import { toPng } from 'html-to-image';
|
|
7
7
|
|
|
8
|
-
const TARGET_PNG_WIDTH =
|
|
8
|
+
const TARGET_PNG_WIDTH = 4096;
|
|
9
|
+
|
|
10
|
+
export type WidgetExportHook = (scale: number) => Promise<Blob>;
|
|
11
|
+
|
|
12
|
+
declare global {
|
|
13
|
+
interface HTMLElement {
|
|
14
|
+
__exportPng?: WidgetExportHook;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
9
17
|
|
|
10
18
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
11
19
|
|
|
@@ -95,6 +103,25 @@ async function exportPng(type: string, containerEl: HTMLElement): Promise<void>
|
|
|
95
103
|
const scrollH = Math.max(containerEl.scrollHeight, containerEl.clientHeight, 1);
|
|
96
104
|
const pixelRatio = TARGET_PNG_WIDTH / scrollW;
|
|
97
105
|
|
|
106
|
+
// Canvas-based widgets (cytoscape, etc.) expose a native exporter via __exportPng.
|
|
107
|
+
// html-to-image would only recapture the already-rasterised on-screen pixels,
|
|
108
|
+
// so we route to the widget's own resolution-independent exporter when available.
|
|
109
|
+
if (typeof containerEl.__exportPng === 'function') {
|
|
110
|
+
const scale = Math.max(2, Math.ceil(TARGET_PNG_WIDTH / scrollW));
|
|
111
|
+
try {
|
|
112
|
+
const blob = await containerEl.__exportPng(scale);
|
|
113
|
+
const url = URL.createObjectURL(blob);
|
|
114
|
+
const a = document.createElement('a');
|
|
115
|
+
a.href = url;
|
|
116
|
+
a.download = name;
|
|
117
|
+
a.click();
|
|
118
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
119
|
+
return;
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error('[exportWidget] native __exportPng failed, falling back to html-to-image', err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
98
125
|
const bg = (typeof document !== 'undefined'
|
|
99
126
|
? getComputedStyle(document.documentElement).getPropertyValue('--color-surface').trim()
|
|
100
127
|
: '') || '#ffffff';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafeImage (vanilla) — URL validation, error fallback, placeholder rendering.
|
|
3
|
+
* Replaces SafeImage.svelte. Used by Profile/Cards/Carousel/Gallery/Trombinoscope renderers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SafeImageOptions {
|
|
7
|
+
src: string | undefined | null;
|
|
8
|
+
alt?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
style?: string;
|
|
11
|
+
loading?: 'lazy' | 'eager';
|
|
12
|
+
fallbackText?: string;
|
|
13
|
+
hideOnError?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const VALID_PREFIXES = ['http://', 'https://', 'data:', '/'];
|
|
17
|
+
|
|
18
|
+
function isValidSrc(src: unknown): src is string {
|
|
19
|
+
return typeof src === 'string' && src.length > 0 && VALID_PREFIXES.some((p) => (src as string).startsWith(p));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function escapeHtml(s: string): string {
|
|
23
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildPlaceholder(label: string, opts: SafeImageOptions): HTMLElement {
|
|
27
|
+
const div = document.createElement('div');
|
|
28
|
+
div.className = `flex items-center justify-center bg-surface2 text-text2 text-xs ${opts.className ?? ''}`.trim();
|
|
29
|
+
if (opts.style) div.setAttribute('style', opts.style);
|
|
30
|
+
div.setAttribute('role', 'img');
|
|
31
|
+
div.setAttribute('aria-label', label);
|
|
32
|
+
div.innerHTML =
|
|
33
|
+
'<svg class="w-5 h-5 opacity-40 mr-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">' +
|
|
34
|
+
'<rect x="3" y="3" width="18" height="18" rx="2"/>' +
|
|
35
|
+
'<circle cx="8.5" cy="8.5" r="1.5"/>' +
|
|
36
|
+
'<path d="M21 15l-5-5L5 21"/></svg>' +
|
|
37
|
+
`<span class="truncate max-w-[80%]">${escapeHtml(label)}</span>`;
|
|
38
|
+
return div;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildHidden(): HTMLElement {
|
|
42
|
+
const el = document.createElement('div');
|
|
43
|
+
el.style.display = 'none';
|
|
44
|
+
el.setAttribute('aria-hidden', 'true');
|
|
45
|
+
return el;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns an HTMLElement for the given image source.
|
|
50
|
+
* - Invalid/missing URL → placeholder (or hidden element if hideOnError).
|
|
51
|
+
* - Valid URL → <img> that self-replaces with placeholder on load error.
|
|
52
|
+
*/
|
|
53
|
+
export function createSafeImage(opts: SafeImageOptions): HTMLElement {
|
|
54
|
+
const label = opts.fallbackText ?? opts.alt ?? 'Image';
|
|
55
|
+
|
|
56
|
+
if (!isValidSrc(opts.src)) {
|
|
57
|
+
return opts.hideOnError ? buildHidden() : buildPlaceholder(label, opts);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const img = document.createElement('img');
|
|
61
|
+
img.src = opts.src;
|
|
62
|
+
img.alt = opts.alt ?? '';
|
|
63
|
+
if (opts.className) img.className = opts.className;
|
|
64
|
+
if (opts.style) img.setAttribute('style', opts.style);
|
|
65
|
+
img.loading = opts.loading ?? 'lazy';
|
|
66
|
+
img.referrerPolicy = 'no-referrer';
|
|
67
|
+
|
|
68
|
+
img.addEventListener(
|
|
69
|
+
'error',
|
|
70
|
+
() => {
|
|
71
|
+
const replacement = opts.hideOnError ? buildHidden() : buildPlaceholder(label, opts);
|
|
72
|
+
img.replaceWith(replacement);
|
|
73
|
+
},
|
|
74
|
+
{ once: true },
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return img;
|
|
78
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Chart renderer — Vega-Lite via lazy dynamic import of vega-embed.
|
|
3
|
+
//
|
|
4
|
+
// Design notes:
|
|
5
|
+
// - vega-embed is imported dynamically so it does not bloat the initial
|
|
6
|
+
// bundle. The first chart render pays a one-time cost; subsequent charts
|
|
7
|
+
// share the cached module.
|
|
8
|
+
// - On failure (missing dep, invalid spec, CSP denies eval, etc.) we fall
|
|
9
|
+
// back to a readable <pre>JSON</pre> preview of the spec so the user
|
|
10
|
+
// still sees *something* rather than an empty panel.
|
|
11
|
+
// - vega-embed is declared as an optional peer dependency in package.json;
|
|
12
|
+
// apps that want interactive charts install it, others degrade gracefully.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface ChartRenderer {
|
|
16
|
+
mount(container: HTMLElement, spec: unknown): void | Promise<void>;
|
|
17
|
+
destroy(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let cachedEmbed: Promise<any> | null = null;
|
|
21
|
+
|
|
22
|
+
function loadEmbed(): Promise<any> {
|
|
23
|
+
if (!cachedEmbed) {
|
|
24
|
+
// @ts-ignore — vega-embed is an optional peer dep; resolved at runtime
|
|
25
|
+
cachedEmbed = import('vega-embed').then((m: any) => m?.default ?? m);
|
|
26
|
+
}
|
|
27
|
+
return cachedEmbed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function escapeHtml(s: string): string {
|
|
31
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) =>
|
|
32
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderFallback(container: HTMLElement, spec: unknown, reason?: string): void {
|
|
36
|
+
let preview: string;
|
|
37
|
+
try { preview = JSON.stringify(spec, null, 2); } catch { preview = String(spec); }
|
|
38
|
+
const note = reason ? `<div class="nb-chart-fallback-note">${escapeHtml(reason)}</div>` : '';
|
|
39
|
+
container.innerHTML = `${note}<pre class="nb-chart-fallback">${escapeHtml(preview)}</pre>`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render a Vega / Vega-Lite spec into the given container. Resolves once the
|
|
44
|
+
* chart is mounted (or falls back to a JSON preview on error). Never throws.
|
|
45
|
+
*/
|
|
46
|
+
export async function renderChart(container: HTMLElement, spec: unknown): Promise<void> {
|
|
47
|
+
if (!container) return;
|
|
48
|
+
if (!spec || typeof spec !== 'object') {
|
|
49
|
+
renderFallback(container, spec, 'Invalid chart spec');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const embed = await loadEmbed();
|
|
54
|
+
if (typeof embed !== 'function') {
|
|
55
|
+
renderFallback(container, spec, 'vega-embed unavailable');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
container.innerHTML = '';
|
|
59
|
+
await embed(container, spec as any, { actions: false, renderer: 'canvas' });
|
|
60
|
+
} catch (err: any) {
|
|
61
|
+
renderFallback(container, spec, `chart render failed: ${String(err?.message ?? err)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
for Agent F (sql.ts, js-worker.ts, worker-runtime.ts)
|