@fragments-sdk/cli 0.7.14 → 0.7.16
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/dist/bin.js +7 -7
- package/dist/{chunk-CRTN6BIW.js → chunk-QLTLLQBI.js} +2 -2
- package/dist/{chunk-TQOGBAOZ.js → chunk-WLXFE6XW.js} +91 -2
- package/dist/chunk-WLXFE6XW.js.map +1 -0
- package/dist/core/index.d.ts +44 -3
- package/dist/core/index.js +11 -3
- package/dist/{defineFragment-C6PFzZyo.d.ts → defineFragment-BI9KoPrs.d.ts} +1 -1
- package/dist/{generate-ZPERYZLF.js → generate-ICIPKCKV.js} +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/init-DIZ6UNBL.js +806 -0
- package/dist/init-DIZ6UNBL.js.map +1 -0
- package/dist/mcp-bin.js +2 -2
- package/dist/{scan-BSMLGBX4.js → scan-X3DI2X5G.js} +2 -2
- package/dist/{service-QACVPR37.js → service-JEWWTSKI.js} +2 -2
- package/dist/{static-viewer-2RQD5QLR.js → static-viewer-JIWCYKVK.js} +2 -2
- package/dist/{tokens-A3BZIQPB.js → tokens-K2AGUUOJ.js} +2 -2
- package/dist/{viewer-CNLZQUFO.js → viewer-QKIAPTPG.js} +126 -15
- package/dist/viewer-QKIAPTPG.js.map +1 -0
- package/package.json +3 -2
- package/src/commands/init-framework.ts +414 -0
- package/src/commands/init.ts +41 -1
- package/src/core/__tests__/preview-runtime.test.tsx +111 -0
- package/src/core/index.ts +13 -0
- package/src/core/preview-runtime.tsx +144 -0
- package/src/viewer/components/App.tsx +8 -3
- package/src/viewer/components/FragmentRenderer.tsx +61 -0
- package/src/viewer/components/HealthDashboard.tsx +1 -1
- package/src/viewer/components/IsolatedPreviewFrame.tsx +10 -8
- package/src/viewer/components/PreviewFrameHost.tsx +27 -60
- package/src/viewer/components/PropsTable.tsx +2 -2
- package/src/viewer/components/RuntimeToolsRegistrar.tsx +17 -0
- package/src/viewer/components/SkeletonLoader.tsx +114 -125
- package/src/viewer/components/VariantMatrix.tsx +3 -3
- package/src/viewer/components/ViewerStateSync.tsx +52 -0
- package/src/viewer/components/WebMCPDevTools.tsx +509 -0
- package/src/viewer/components/WebMCPIntegration.tsx +47 -0
- package/src/viewer/components/WebMCPStatusIndicator.tsx +60 -0
- package/src/viewer/entry.tsx +32 -5
- package/src/viewer/hooks/useA11yService.ts +1 -135
- package/src/viewer/hooks/useCompiledFragments.ts +42 -0
- package/src/viewer/index.html +1 -1
- package/src/viewer/public/favicon.ico +0 -0
- package/src/viewer/server.ts +59 -3
- package/src/viewer/vendor/shared/src/DocsHeaderBar.tsx +19 -0
- package/src/viewer/vendor/shared/src/DocsPageAsideHost.tsx +1 -1
- package/src/viewer/vendor/shared/src/DocsSearchCommand.tsx +69 -104
- package/src/viewer/vite-plugin.ts +76 -1
- package/src/viewer/webmcp/__tests__/analytics.test.ts +108 -0
- package/src/viewer/webmcp/analytics.ts +165 -0
- package/src/viewer/webmcp/index.ts +3 -0
- package/src/viewer/webmcp/posthog-bridge.ts +39 -0
- package/src/viewer/webmcp/runtime-tools.ts +152 -0
- package/src/viewer/webmcp/scan-utils.ts +135 -0
- package/src/viewer/webmcp/use-tool-analytics.ts +69 -0
- package/src/viewer/webmcp/viewer-state.ts +45 -0
- package/dist/chunk-TQOGBAOZ.js.map +0 -1
- package/dist/init-GID2DXB3.js +0 -498
- package/dist/init-GID2DXB3.js.map +0 -1
- package/dist/viewer-CNLZQUFO.js.map +0 -1
- package/src/viewer/components/StoryRenderer.tsx +0 -121
- /package/dist/{chunk-CRTN6BIW.js.map → chunk-QLTLLQBI.js.map} +0 -0
- /package/dist/{generate-ZPERYZLF.js.map → generate-ICIPKCKV.js.map} +0 -0
- /package/dist/{scan-BSMLGBX4.js.map → scan-X3DI2X5G.js.map} +0 -0
- /package/dist/{service-QACVPR37.js.map → service-JEWWTSKI.js.map} +0 -0
- /package/dist/{static-viewer-2RQD5QLR.js.map → static-viewer-JIWCYKVK.js.map} +0 -0
- /package/dist/{tokens-A3BZIQPB.js.map → tokens-K2AGUUOJ.js.map} +0 -0
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
findStorybookDir,
|
|
25
25
|
findPreviewConfigPath,
|
|
26
26
|
generatePreviewModule,
|
|
27
|
+
parseFragmentFile,
|
|
27
28
|
} from "../core/node.js";
|
|
28
29
|
import svgr from "vite-plugin-svgr";
|
|
29
30
|
import {
|
|
@@ -927,6 +928,24 @@ export function fragmentsPlugin(options: FragmentsPluginOptions): Plugin[] {
|
|
|
927
928
|
return;
|
|
928
929
|
}
|
|
929
930
|
|
|
931
|
+
// Handle /fragments/compiled.json endpoint for WebMCP integration
|
|
932
|
+
if (req.url === "/fragments/compiled.json") {
|
|
933
|
+
try {
|
|
934
|
+
const { join } = await import("node:path");
|
|
935
|
+
const fragmentsJsonPath = join(projectRoot, BRAND.outFile);
|
|
936
|
+
const content = await readFile(fragmentsJsonPath, "utf-8");
|
|
937
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
938
|
+
res.end(content);
|
|
939
|
+
} catch (error) {
|
|
940
|
+
console.warn(`[${BRAND.name}] Failed to serve compiled.json:`, error);
|
|
941
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
942
|
+
res.end(JSON.stringify({
|
|
943
|
+
error: `${BRAND.outFile} not found. Run '${BRAND.cliCommand} build' first.`,
|
|
944
|
+
}));
|
|
945
|
+
}
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
930
949
|
// Handle /fragments/context endpoint for AI context generation
|
|
931
950
|
if (req.url?.startsWith("/fragments/context")) {
|
|
932
951
|
try {
|
|
@@ -1617,6 +1636,33 @@ async function generateFragmentsModule(
|
|
|
1617
1636
|
config: FragmentsConfig,
|
|
1618
1637
|
previewConfigPath: string | null
|
|
1619
1638
|
): Promise<string> {
|
|
1639
|
+
const authoredVariantCodeCache = new Map<string, Record<string, string>>();
|
|
1640
|
+
|
|
1641
|
+
async function loadAuthoredVariantCode(fragmentFilePath: string): Promise<Record<string, string>> {
|
|
1642
|
+
if (authoredVariantCodeCache.has(fragmentFilePath)) {
|
|
1643
|
+
return authoredVariantCodeCache.get(fragmentFilePath)!;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
try {
|
|
1647
|
+
const source = await readFile(fragmentFilePath, "utf-8");
|
|
1648
|
+
const parsed = parseFragmentFile(source, fragmentFilePath);
|
|
1649
|
+
const variantCodeByName: Record<string, string> = {};
|
|
1650
|
+
|
|
1651
|
+
for (const variant of parsed.variants) {
|
|
1652
|
+
if (typeof variant.code !== "string") continue;
|
|
1653
|
+
const normalized = variant.code.trim();
|
|
1654
|
+
if (normalized.length === 0) continue;
|
|
1655
|
+
variantCodeByName[variant.name] = normalized;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
authoredVariantCodeCache.set(fragmentFilePath, variantCodeByName);
|
|
1659
|
+
return variantCodeByName;
|
|
1660
|
+
} catch {
|
|
1661
|
+
authoredVariantCodeCache.set(fragmentFilePath, {});
|
|
1662
|
+
return {};
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1620
1666
|
// Group files by base component path to identify pairs
|
|
1621
1667
|
const filesByBasePath = new Map<string, {
|
|
1622
1668
|
storyFile?: { absolutePath: string; relativePath: string };
|
|
@@ -1653,6 +1699,14 @@ async function generateFragmentsModule(
|
|
|
1653
1699
|
? files.fragmentFile.absolutePath
|
|
1654
1700
|
: null;
|
|
1655
1701
|
|
|
1702
|
+
const codeSourceFile = files.fragmentFile && files.fragmentFile.absolutePath
|
|
1703
|
+
? files.fragmentFile.absolutePath
|
|
1704
|
+
: (!isStory ? primaryFile.absolutePath : null);
|
|
1705
|
+
|
|
1706
|
+
const authoredVariantCode = codeSourceFile
|
|
1707
|
+
? await loadAuthoredVariantCode(codeSourceFile)
|
|
1708
|
+
: {};
|
|
1709
|
+
|
|
1656
1710
|
// Extract component name from file path
|
|
1657
1711
|
const componentName = extractComponentName(primaryFile.relativePath);
|
|
1658
1712
|
|
|
@@ -1666,7 +1720,8 @@ async function generateFragmentsModule(
|
|
|
1666
1720
|
componentName: ${JSON.stringify(componentName)},
|
|
1667
1721
|
dependencies: ${JSON.stringify(dependencies)},
|
|
1668
1722
|
loader: () => import("${primaryFile.absolutePath}"),
|
|
1669
|
-
metadataLoader: ${metadataPath ? `() => import("${metadataPath}")` : 'null'}
|
|
1723
|
+
metadataLoader: ${metadataPath ? `() => import("${metadataPath}")` : 'null'},
|
|
1724
|
+
authoredVariantCode: ${JSON.stringify(authoredVariantCode)}
|
|
1670
1725
|
}`;
|
|
1671
1726
|
})
|
|
1672
1727
|
);
|
|
@@ -1743,6 +1798,20 @@ function mergeMetadata(fragment, metadataModule) {
|
|
|
1743
1798
|
return fragment;
|
|
1744
1799
|
}
|
|
1745
1800
|
|
|
1801
|
+
function mergeAuthoredVariantCode(fragment, authoredVariantCode) {
|
|
1802
|
+
if (!fragment?.variants || !authoredVariantCode) return fragment;
|
|
1803
|
+
|
|
1804
|
+
for (const variant of fragment.variants) {
|
|
1805
|
+
if (!variant || variant.code) continue;
|
|
1806
|
+
const code = authoredVariantCode[variant.name];
|
|
1807
|
+
if (typeof code === "string" && code.trim().length > 0) {
|
|
1808
|
+
variant.code = code;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
return fragment;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1746
1815
|
// Load all fragments (for initial render)
|
|
1747
1816
|
// Gracefully handles individual failures - one bad story won't break all fragments
|
|
1748
1817
|
export async function loadAllFragments() {
|
|
@@ -1780,6 +1849,8 @@ export async function loadAllFragments() {
|
|
|
1780
1849
|
}
|
|
1781
1850
|
}
|
|
1782
1851
|
|
|
1852
|
+
fragment = mergeAuthoredVariantCode(fragment, loader.authoredVariantCode);
|
|
1853
|
+
|
|
1783
1854
|
loadedFragments.set(loader.path, fragment);
|
|
1784
1855
|
return { path: loader.path, fragment };
|
|
1785
1856
|
} catch (error) {
|
|
@@ -1837,6 +1908,10 @@ export async function loadFragment(path) {
|
|
|
1837
1908
|
}
|
|
1838
1909
|
}
|
|
1839
1910
|
|
|
1911
|
+
if (fragment) {
|
|
1912
|
+
fragment = mergeAuthoredVariantCode(fragment, loader.authoredVariantCode);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1840
1915
|
if (fragment) {
|
|
1841
1916
|
loadedFragments.set(path, fragment);
|
|
1842
1917
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ToolAnalyticsCollector } from '../analytics.js';
|
|
3
|
+
import type { ToolCallEvent } from '@fragments-sdk/webmcp';
|
|
4
|
+
|
|
5
|
+
function createEvent(overrides: Partial<ToolCallEvent> = {}): ToolCallEvent {
|
|
6
|
+
return {
|
|
7
|
+
toolName: 'fragments_discover',
|
|
8
|
+
input: {},
|
|
9
|
+
output: {},
|
|
10
|
+
error: null,
|
|
11
|
+
durationMs: 50,
|
|
12
|
+
timestamp: Date.now(),
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('ToolAnalyticsCollector', () => {
|
|
18
|
+
it('records events and computes summary', () => {
|
|
19
|
+
const collector = new ToolAnalyticsCollector();
|
|
20
|
+
collector.record(createEvent({ toolName: 'fragments_discover', durationMs: 10 }));
|
|
21
|
+
collector.record(createEvent({ toolName: 'fragments_discover', durationMs: 20 }));
|
|
22
|
+
collector.record(createEvent({ toolName: 'fragments_inspect', durationMs: 30 }));
|
|
23
|
+
|
|
24
|
+
const summary = collector.toSummary();
|
|
25
|
+
expect(summary.totalCalls).toBe(3);
|
|
26
|
+
expect(summary.uniqueTools).toBe(2);
|
|
27
|
+
expect(summary.toolStats.fragments_discover.callCount).toBe(2);
|
|
28
|
+
expect(summary.toolStats.fragments_discover.avgDuration).toBe(15);
|
|
29
|
+
expect(summary.toolStats.fragments_inspect.callCount).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('tracks error rate', () => {
|
|
33
|
+
const collector = new ToolAnalyticsCollector();
|
|
34
|
+
collector.record(createEvent({ error: null }));
|
|
35
|
+
collector.record(createEvent({ error: 'fail' }));
|
|
36
|
+
|
|
37
|
+
const summary = collector.toSummary();
|
|
38
|
+
expect(summary.errorRate).toBe(0.5);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('detects common flows', () => {
|
|
42
|
+
const collector = new ToolAnalyticsCollector();
|
|
43
|
+
const base = Date.now();
|
|
44
|
+
|
|
45
|
+
// Create a repeated flow: discover -> inspect -> discover -> inspect
|
|
46
|
+
collector.record(createEvent({ toolName: 'fragments_discover', timestamp: base }));
|
|
47
|
+
collector.record(createEvent({ toolName: 'fragments_inspect', timestamp: base + 100 }));
|
|
48
|
+
collector.record(createEvent({ toolName: 'fragments_discover', timestamp: base + 200 }));
|
|
49
|
+
collector.record(createEvent({ toolName: 'fragments_inspect', timestamp: base + 300 }));
|
|
50
|
+
|
|
51
|
+
const summary = collector.toSummary();
|
|
52
|
+
expect(summary.commonFlows.length).toBeGreaterThan(0);
|
|
53
|
+
expect(summary.commonFlows[0].sequence).toEqual(['fragments_discover', 'fragments_inspect']);
|
|
54
|
+
expect(summary.commonFlows[0].count).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('splits sessions on 30s gap', () => {
|
|
58
|
+
const collector = new ToolAnalyticsCollector();
|
|
59
|
+
const base = Date.now();
|
|
60
|
+
|
|
61
|
+
collector.record(createEvent({ toolName: 'a', timestamp: base }));
|
|
62
|
+
collector.record(createEvent({ toolName: 'b', timestamp: base + 100 }));
|
|
63
|
+
// 31s gap
|
|
64
|
+
collector.record(createEvent({ toolName: 'a', timestamp: base + 31_100 }));
|
|
65
|
+
collector.record(createEvent({ toolName: 'b', timestamp: base + 31_200 }));
|
|
66
|
+
|
|
67
|
+
const summary = collector.toSummary();
|
|
68
|
+
// "a -> b" appears in both sessions
|
|
69
|
+
const abFlow = summary.commonFlows.find(f =>
|
|
70
|
+
f.sequence[0] === 'a' && f.sequence[1] === 'b'
|
|
71
|
+
);
|
|
72
|
+
expect(abFlow).toBeDefined();
|
|
73
|
+
expect(abFlow!.count).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('computes percentiles', () => {
|
|
77
|
+
const collector = new ToolAnalyticsCollector();
|
|
78
|
+
for (let i = 1; i <= 100; i++) {
|
|
79
|
+
collector.record(createEvent({ durationMs: i }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const summary = collector.toSummary();
|
|
83
|
+
const stat = summary.toolStats.fragments_discover;
|
|
84
|
+
expect(stat.p50).toBeGreaterThanOrEqual(49);
|
|
85
|
+
expect(stat.p50).toBeLessThanOrEqual(51);
|
|
86
|
+
expect(stat.p95).toBeGreaterThanOrEqual(94);
|
|
87
|
+
expect(stat.p95).toBeLessThanOrEqual(96);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('produces PostHog properties', () => {
|
|
91
|
+
const collector = new ToolAnalyticsCollector();
|
|
92
|
+
collector.record(createEvent());
|
|
93
|
+
|
|
94
|
+
const props = collector.toPostHogProperties();
|
|
95
|
+
expect(props.webmcp_total_calls).toBe(1);
|
|
96
|
+
expect(props.webmcp_unique_tools).toBe(1);
|
|
97
|
+
expect(typeof props.webmcp_tool_breakdown).toBe('string');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('resets state', () => {
|
|
101
|
+
const collector = new ToolAnalyticsCollector();
|
|
102
|
+
collector.record(createEvent());
|
|
103
|
+
collector.reset();
|
|
104
|
+
|
|
105
|
+
const summary = collector.toSummary();
|
|
106
|
+
expect(summary.totalCalls).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { ToolCallEvent } from '@fragments-sdk/webmcp';
|
|
2
|
+
|
|
3
|
+
interface ToolStats {
|
|
4
|
+
callCount: number;
|
|
5
|
+
totalDuration: number;
|
|
6
|
+
durations: number[];
|
|
7
|
+
errorCount: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ToolFlow {
|
|
11
|
+
sequence: string[];
|
|
12
|
+
count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface AnalyticsSummary {
|
|
16
|
+
totalCalls: number;
|
|
17
|
+
uniqueTools: number;
|
|
18
|
+
sessionDurationMs: number;
|
|
19
|
+
errorRate: number;
|
|
20
|
+
toolStats: Record<string, {
|
|
21
|
+
callCount: number;
|
|
22
|
+
avgDuration: number;
|
|
23
|
+
p50: number;
|
|
24
|
+
p95: number;
|
|
25
|
+
errorCount: number;
|
|
26
|
+
}>;
|
|
27
|
+
commonFlows: ToolFlow[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SESSION_GAP_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
export class ToolAnalyticsCollector {
|
|
33
|
+
private stats = new Map<string, ToolStats>();
|
|
34
|
+
private events: ToolCallEvent[] = [];
|
|
35
|
+
private sessionStart: number | null = null;
|
|
36
|
+
|
|
37
|
+
record(event: ToolCallEvent): void {
|
|
38
|
+
this.events.push(event);
|
|
39
|
+
|
|
40
|
+
if (this.sessionStart === null) {
|
|
41
|
+
this.sessionStart = event.timestamp;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const existing = this.stats.get(event.toolName);
|
|
45
|
+
if (existing) {
|
|
46
|
+
existing.callCount++;
|
|
47
|
+
existing.totalDuration += event.durationMs;
|
|
48
|
+
existing.durations.push(event.durationMs);
|
|
49
|
+
if (event.error) existing.errorCount++;
|
|
50
|
+
} else {
|
|
51
|
+
this.stats.set(event.toolName, {
|
|
52
|
+
callCount: 1,
|
|
53
|
+
totalDuration: event.durationMs,
|
|
54
|
+
durations: [event.durationMs],
|
|
55
|
+
errorCount: event.error ? 1 : 0,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
toSummary(): AnalyticsSummary {
|
|
61
|
+
let totalCalls = 0;
|
|
62
|
+
let totalErrors = 0;
|
|
63
|
+
const toolStats: AnalyticsSummary['toolStats'] = {};
|
|
64
|
+
|
|
65
|
+
for (const [name, stat] of this.stats) {
|
|
66
|
+
totalCalls += stat.callCount;
|
|
67
|
+
totalErrors += stat.errorCount;
|
|
68
|
+
|
|
69
|
+
const sorted = [...stat.durations].sort((a, b) => a - b);
|
|
70
|
+
toolStats[name] = {
|
|
71
|
+
callCount: stat.callCount,
|
|
72
|
+
avgDuration: Math.round(stat.totalDuration / stat.callCount),
|
|
73
|
+
p50: percentile(sorted, 50),
|
|
74
|
+
p95: percentile(sorted, 95),
|
|
75
|
+
errorCount: stat.errorCount,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sessionDurationMs = this.sessionStart !== null && this.events.length > 0
|
|
80
|
+
? this.events[this.events.length - 1].timestamp - this.sessionStart
|
|
81
|
+
: 0;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
totalCalls,
|
|
85
|
+
uniqueTools: this.stats.size,
|
|
86
|
+
sessionDurationMs,
|
|
87
|
+
errorRate: totalCalls > 0 ? Math.round((totalErrors / totalCalls) * 100) / 100 : 0,
|
|
88
|
+
toolStats,
|
|
89
|
+
commonFlows: this.detectFlows(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toPostHogProperties(): Record<string, unknown> {
|
|
94
|
+
const summary = this.toSummary();
|
|
95
|
+
return {
|
|
96
|
+
webmcp_total_calls: summary.totalCalls,
|
|
97
|
+
webmcp_unique_tools: summary.uniqueTools,
|
|
98
|
+
webmcp_session_duration_ms: summary.sessionDurationMs,
|
|
99
|
+
webmcp_error_rate: summary.errorRate,
|
|
100
|
+
webmcp_tool_breakdown: JSON.stringify(summary.toolStats),
|
|
101
|
+
webmcp_common_flows: JSON.stringify(summary.commonFlows),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private detectFlows(): ToolFlow[] {
|
|
106
|
+
if (this.events.length < 2) return [];
|
|
107
|
+
|
|
108
|
+
// Split into sessions (30s gap)
|
|
109
|
+
const sessions: ToolCallEvent[][] = [];
|
|
110
|
+
let currentSession: ToolCallEvent[] = [this.events[0]];
|
|
111
|
+
|
|
112
|
+
for (let i = 1; i < this.events.length; i++) {
|
|
113
|
+
if (this.events[i].timestamp - this.events[i - 1].timestamp > SESSION_GAP_MS) {
|
|
114
|
+
sessions.push(currentSession);
|
|
115
|
+
currentSession = [];
|
|
116
|
+
}
|
|
117
|
+
currentSession.push(this.events[i]);
|
|
118
|
+
}
|
|
119
|
+
sessions.push(currentSession);
|
|
120
|
+
|
|
121
|
+
// Count 2-grams and 3-grams
|
|
122
|
+
const flowCounts = new Map<string, number>();
|
|
123
|
+
|
|
124
|
+
for (const session of sessions) {
|
|
125
|
+
const names = session.map(e => e.toolName);
|
|
126
|
+
|
|
127
|
+
// 2-grams
|
|
128
|
+
for (let i = 0; i < names.length - 1; i++) {
|
|
129
|
+
const key = `${names[i]} → ${names[i + 1]}`;
|
|
130
|
+
flowCounts.set(key, (flowCounts.get(key) ?? 0) + 1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3-grams
|
|
134
|
+
for (let i = 0; i < names.length - 2; i++) {
|
|
135
|
+
const key = `${names[i]} → ${names[i + 1]} → ${names[i + 2]}`;
|
|
136
|
+
flowCounts.set(key, (flowCounts.get(key) ?? 0) + 1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Return top flows (count >= 2)
|
|
141
|
+
return [...flowCounts.entries()]
|
|
142
|
+
.filter(([, count]) => count >= 2)
|
|
143
|
+
.sort((a, b) => b[1] - a[1])
|
|
144
|
+
.slice(0, 10)
|
|
145
|
+
.map(([key, count]) => ({
|
|
146
|
+
sequence: key.split(' → '),
|
|
147
|
+
count,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
reset(): void {
|
|
152
|
+
this.stats.clear();
|
|
153
|
+
this.events = [];
|
|
154
|
+
this.sessionStart = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function percentile(sorted: number[], p: number): number {
|
|
159
|
+
if (sorted.length === 0) return 0;
|
|
160
|
+
const index = (p / 100) * (sorted.length - 1);
|
|
161
|
+
const lower = Math.floor(index);
|
|
162
|
+
const upper = Math.ceil(index);
|
|
163
|
+
if (lower === upper) return sorted[lower];
|
|
164
|
+
return Math.round(sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower));
|
|
165
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ToolCallEvent } from '@fragments-sdk/webmcp';
|
|
2
|
+
import type { ToolAnalyticsCollector } from './analytics.js';
|
|
3
|
+
|
|
4
|
+
interface PostHogLike {
|
|
5
|
+
capture(event: string, properties?: Record<string, unknown>): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface PostHogBridge {
|
|
9
|
+
onToolCall(event: ToolCallEvent): void;
|
|
10
|
+
onSessionEnd(): void;
|
|
11
|
+
flush(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createPostHogBridge(
|
|
15
|
+
posthog: PostHogLike,
|
|
16
|
+
collector: ToolAnalyticsCollector
|
|
17
|
+
): PostHogBridge {
|
|
18
|
+
return {
|
|
19
|
+
onToolCall(event: ToolCallEvent): void {
|
|
20
|
+
posthog.capture('webmcp_tool_call', {
|
|
21
|
+
tool_name: event.toolName,
|
|
22
|
+
duration_ms: event.durationMs,
|
|
23
|
+
success: event.error === null,
|
|
24
|
+
error: event.error,
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
onSessionEnd(): void {
|
|
29
|
+
const props = collector.toPostHogProperties();
|
|
30
|
+
if ((props.webmcp_total_calls as number) > 0) {
|
|
31
|
+
posthog.capture('webmcp_session_summary', props);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
flush(): void {
|
|
36
|
+
this.onSessionEnd();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { WebMCPTool, InputSchema } from '@fragments-sdk/webmcp';
|
|
2
|
+
import type { CompiledFragmentsFile } from '@fragments-sdk/context/types';
|
|
3
|
+
import { runAxeScan } from './scan-utils.js';
|
|
4
|
+
import { getViewerState } from './viewer-state.js';
|
|
5
|
+
|
|
6
|
+
export interface RuntimeToolsOptions {
|
|
7
|
+
compiledData?: CompiledFragmentsFile | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createRuntimeWebMCPTools(options: RuntimeToolsOptions = {}): WebMCPTool[] {
|
|
11
|
+
const tools: WebMCPTool[] = [];
|
|
12
|
+
|
|
13
|
+
// --- fragments_a11y ---
|
|
14
|
+
tools.push({
|
|
15
|
+
name: 'fragments_a11y',
|
|
16
|
+
description: 'Run an accessibility audit on the currently previewed component. Returns axe-core violations, a WCAG compliance score, and severity counts. Runs directly on the live DOM for accurate results.',
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
component: { type: 'string', description: 'Component name to audit' },
|
|
21
|
+
variant: { type: 'string', description: 'Specific variant to audit' },
|
|
22
|
+
standard: { type: 'string', enum: ['AA', 'AAA'], default: 'AA', description: 'WCAG compliance level' },
|
|
23
|
+
},
|
|
24
|
+
required: ['component'],
|
|
25
|
+
} as InputSchema,
|
|
26
|
+
annotations: { readOnlyHint: true },
|
|
27
|
+
execute: async (input) => {
|
|
28
|
+
const component = input.component as string;
|
|
29
|
+
const standard = (input.standard as string) || 'AA';
|
|
30
|
+
|
|
31
|
+
const result = await runAxeScan('[data-preview-container="true"]');
|
|
32
|
+
if (!result) {
|
|
33
|
+
return { error: true, message: 'No preview container found. Navigate to a component first.' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const total = result.passes + result.violations.length + result.incomplete;
|
|
37
|
+
const wcagScore = total > 0 ? Math.round((result.passes / total) * 100) : 100;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
component,
|
|
41
|
+
wcagScore,
|
|
42
|
+
standard,
|
|
43
|
+
violations: result.violations,
|
|
44
|
+
passCount: result.passes,
|
|
45
|
+
incompleteCount: result.incomplete,
|
|
46
|
+
counts: result.counts,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// --- fragments_theme ---
|
|
52
|
+
tools.push({
|
|
53
|
+
name: 'fragments_theme',
|
|
54
|
+
description: 'Inspect the current runtime theme state. Returns the resolved theme (light/dark), seed configuration values, and computed CSS token values from the live DOM.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
category: { type: 'string', description: 'Filter tokens by category (e.g., "colors", "spacing")' },
|
|
59
|
+
search: { type: 'string', description: 'Search token names' },
|
|
60
|
+
includeComputed: { type: 'boolean', default: true, description: 'Include computed CSS values' },
|
|
61
|
+
},
|
|
62
|
+
} as InputSchema,
|
|
63
|
+
annotations: { readOnlyHint: true },
|
|
64
|
+
execute: async (input) => {
|
|
65
|
+
const category = input.category as string | undefined;
|
|
66
|
+
const search = input.search as string | undefined;
|
|
67
|
+
const includeComputed = input.includeComputed !== false;
|
|
68
|
+
|
|
69
|
+
const root = document.documentElement;
|
|
70
|
+
const computedStyle = getComputedStyle(root);
|
|
71
|
+
|
|
72
|
+
// Detect theme
|
|
73
|
+
const resolvedTheme = root.classList.contains('dark') ? 'dark' : 'light';
|
|
74
|
+
|
|
75
|
+
// Read seed values
|
|
76
|
+
const seed = {
|
|
77
|
+
brand: computedStyle.getPropertyValue('--fui-seed-brand').trim() || null,
|
|
78
|
+
neutral: computedStyle.getPropertyValue('--fui-seed-neutral').trim() || null,
|
|
79
|
+
radius: computedStyle.getPropertyValue('--fui-seed-radius').trim() || null,
|
|
80
|
+
density: computedStyle.getPropertyValue('--fui-seed-density').trim() || null,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Get tokens from compiled data
|
|
84
|
+
const compiledData = options.compiledData;
|
|
85
|
+
const tokenEntries: Array<{ name: string; value: string; computed?: string; category?: string }> = [];
|
|
86
|
+
|
|
87
|
+
if (compiledData?.tokens?.categories) {
|
|
88
|
+
for (const [cat, entries] of Object.entries(compiledData.tokens.categories)) {
|
|
89
|
+
if (category && cat.toLowerCase() !== category.toLowerCase()) continue;
|
|
90
|
+
|
|
91
|
+
for (const token of entries) {
|
|
92
|
+
if (search && !token.name.toLowerCase().includes(search.toLowerCase())) continue;
|
|
93
|
+
|
|
94
|
+
const entry: { name: string; value: string; computed?: string; category?: string } = {
|
|
95
|
+
name: token.name,
|
|
96
|
+
value: token.value,
|
|
97
|
+
category: cat,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (includeComputed) {
|
|
101
|
+
entry.computed = computedStyle.getPropertyValue(token.name).trim() || token.value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
tokenEntries.push(entry);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
resolvedTheme,
|
|
111
|
+
seed,
|
|
112
|
+
tokens: tokenEntries,
|
|
113
|
+
tokenCount: tokenEntries.length,
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// --- fragments_context ---
|
|
119
|
+
tools.push({
|
|
120
|
+
name: 'fragments_context',
|
|
121
|
+
description: 'Get information about what the developer is currently viewing in the Fragments dev viewer. Returns the active component, variant, viewport settings, theme, and panel state.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
fields: {
|
|
126
|
+
type: 'array',
|
|
127
|
+
items: { type: 'string' },
|
|
128
|
+
description: 'Specific fields to return (e.g., ["currentComponent", "theme"]). Returns all fields if omitted.',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
} as InputSchema,
|
|
132
|
+
annotations: { readOnlyHint: true },
|
|
133
|
+
execute: async (input) => {
|
|
134
|
+
const state = getViewerState();
|
|
135
|
+
const fields = input.fields as string[] | undefined;
|
|
136
|
+
|
|
137
|
+
if (fields && fields.length > 0) {
|
|
138
|
+
const filtered: Record<string, unknown> = {};
|
|
139
|
+
for (const field of fields) {
|
|
140
|
+
if (field in state) {
|
|
141
|
+
filtered[field] = state[field as keyof typeof state];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return filtered;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return state;
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return tools;
|
|
152
|
+
}
|