import { VertesiaClient } from "@vertesia/client";
import { normalizeToolCollection } from "@vertesia/common";
import { FusionFragmentHandler } from "@vertesia/fusion-ux";
import { useUserSession } from "@vertesia/ui/session";
import { CodeBlockRendererProps, CodeBlockRendererProvider } from "@vertesia/ui/widgets";
import { memo, useEffect, useMemo, useState } from "react";
import { VegaLiteChartSpec } from "./AgentChart";
import { VegaLiteChart } from "./VegaLiteChart";
interface SkillWidgetProviderProperties {
children: React.ReactNode;
}
/**
* Widget for rendering chart code blocks.
* `chart` and `vega-lite` now both render through Vega-Lite.
*/
const ChartWidget = memo(function ChartWidget({ code }: { code: string }) {
const spec = useMemo(() => {
try {
const parsed = JSON.parse(code);
// Wrapped Vega-Lite format
if (parsed?.library === 'vega-lite' && parsed?.spec && typeof parsed.spec === 'object') {
return parsed as VegaLiteChartSpec;
}
// Native Vega-Lite spec format
if (typeof parsed?.$schema === 'string' && parsed.$schema.includes('vega')) {
return { library: 'vega-lite' as const, spec: parsed };
}
return null;
} catch {
// During streaming, code may be incomplete JSON - return null to skip rendering
return null;
}
}, [code]);
// Don't render anything while JSON is incomplete (during streaming)
if (!spec) {
return null;
}
return
});
/**
* Widget for rendering Vega-Lite charts directly
* Used for `vega-lite` and `vegalite` code blocks
*/
const VegaLiteChartWidget = memo(function VegaLiteChartWidget({ code }: { code: string }) {
const spec = useMemo(() => {
try {
const parsed = JSON.parse(code);
// Wrap native Vega-Lite spec in the expected format
return { library: 'vega-lite' as const, spec: parsed };
} catch {
// During streaming, code may be incomplete JSON - return null to skip rendering
return null;
}
}, [code]);
// Don't render anything while JSON is incomplete (during streaming)
if (!spec) {
return null;
}
// Render VegaLiteChart directly - bypass AgentChart routing
return
});
const defaultComponents: Record> = {
"chart": ChartWidget,
"vega-lite": VegaLiteChartWidget,
"vegalite": VegaLiteChartWidget,
"fusion-fragment": FusionFragmentHandler,
}
function RemoteWidgetComponent({ url, code }: { url: string, code: string }) {
const [Component, setComponent] = useState | null>(null);
useEffect(() => {
import(/* @vite-ignore */url).then(module => {
// register the component
// Wrap in arrow function to prevent React from calling it as a state updater
setComponent(() => module.default)
}).catch(err => {
console.error("Failed to load remote widget component from ", url, err);
})
}, [url]);
return Component ? : null;
}
async function fetchSkillWidgets(client: VertesiaClient): Promise>> {
const installedApps = await client.apps.getInstalledApps("tools");
const urls = new Set();
for (const app of installedApps) {
if (app.manifest.tool_collections && app.manifest.tool_collections.length > 0) {
for (const item of app.manifest.tool_collections || []) {
const collection = normalizeToolCollection(item);
const collUrl = collection.url;
if (collUrl.startsWith("http://") || collUrl.startsWith("https://")) {
const i = collUrl.indexOf("/api/");
if (i > 0) {
const url = collUrl.substring(0, i);
urls.add(url + '/api/widgets');
}
}
}
}
if (app.manifest.endpoint && app.manifest.capabilities?.includes("tools")) {
urls.add(new URL('/api/widgets', app.manifest.endpoint).toString());
}
}
const allWidgets = await Promise.all(Array.from(urls).map(url => {
return fetch(url).then(r => {
if (!r.ok) {
throw new Error(`Failed to fetch widgets: ${r.status} ${r.statusText}`);
}
return r.json();
}).then(data => {
const widgets: { name: string, component: React.FunctionComponent }[] = [];
const widgetsMap = data.widgets as Record;
for (const [name, value] of Object.entries(widgetsMap)) {
widgets.push({ name, component: (props: CodeBlockRendererProps) => });
}
return widgets;
}).catch(() => {
console.error("Failed to fetch skill widgets from ", url);
return null
});
}));
const widgets: Record> = {};
for (const widgetSpec of allWidgets.flat()) {
if (widgetSpec) {
widgets[widgetSpec.name] = widgetSpec.component;
}
}
return widgets;
}
/**
* Provides code block components depending on lagauge form the installed skills
* @param param0
* @returns
*/
export function SkillWidgetProvider({ children }: SkillWidgetProviderProperties) {
const { client } = useUserSession();
const [components, setComponents] = useState>>(defaultComponents);
useEffect(() => {
// fetch all skill components
fetchSkillWidgets(client).then(widgets => {
setComponents({
...defaultComponents,
...widgets,
})
})
}, []);
return (
{children}
)
}