@webmcp-auto-ui/sdk 2.5.38 → 2.5.40
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 +1 -1
- package/src/canvas-to-notebook.ts +55 -2
- package/src/index.ts +3 -2
- package/src/recipes/fence.ts +12 -0
- package/src/recipes/index.ts +2 -1
- package/src/recipes/parse.ts +10 -3
- package/src/recipes/runner.ts +223 -18
- package/src/remote-mcp-registry.ts +1 -1
- package/src/skills/registry.ts +3 -3
- package/src/stores/canvas.ts +3 -20
- package/tests/registry.test.ts +1 -1
- package/tests/runner-widget.test.ts +47 -0
package/package.json
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* present, snapshot fallback otherwise.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { pickFence } from './recipes/fence.js';
|
|
8
|
+
|
|
7
9
|
export interface WidgetLineage {
|
|
8
10
|
widgetType: string;
|
|
9
11
|
widgetParams: Record<string, unknown>;
|
|
@@ -138,13 +140,64 @@ export function canvasToNotebookMarkdown(
|
|
|
138
140
|
return fm.join('\n') + '\n' + body.join('\n').replace(/\n+$/, '') + '\n';
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
export interface CanvasToNotebookData {
|
|
144
|
+
title?: string;
|
|
145
|
+
description?: string;
|
|
146
|
+
/** Active MCP servers (name, url) — to seed the notebook widget's data. */
|
|
147
|
+
servers?: Array<{ name: string; url: string }>;
|
|
148
|
+
/** Active bundled WebMCP server ids. */
|
|
149
|
+
webmcpServers?: string[];
|
|
150
|
+
/** Cells ready to feed into a NotebookState. */
|
|
151
|
+
cells: Array<{
|
|
152
|
+
id: string;
|
|
153
|
+
type: 'md' | 'sql' | 'js';
|
|
154
|
+
content: string;
|
|
155
|
+
varname?: string;
|
|
156
|
+
status?: 'idle';
|
|
157
|
+
}>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Same logic as canvasToNotebookMarkdown but returns structured cells for direct widget mounting. */
|
|
161
|
+
export function canvasToNotebookCells(
|
|
162
|
+
blocks: CanvasBlock[],
|
|
163
|
+
getLineage: (blockId: string) => WidgetLineage | null,
|
|
164
|
+
opts: CanvasToNotebookOptions = {},
|
|
165
|
+
): CanvasToNotebookData {
|
|
166
|
+
let counter = 0;
|
|
167
|
+
const cells: CanvasToNotebookData['cells'] = [];
|
|
168
|
+
|
|
169
|
+
for (const block of blocks) {
|
|
170
|
+
const raw = lineageToCells(block, getLineage(block.id));
|
|
171
|
+
for (const cell of raw) {
|
|
172
|
+
const entry: CanvasToNotebookData['cells'][number] = {
|
|
173
|
+
id: `c${counter++}`,
|
|
174
|
+
type: cell.kind,
|
|
175
|
+
content: cell.content,
|
|
176
|
+
status: 'idle',
|
|
177
|
+
};
|
|
178
|
+
if (cell.varname !== undefined) entry.varname = cell.varname;
|
|
179
|
+
cells.push(entry);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const out: CanvasToNotebookData = { cells };
|
|
184
|
+
if (opts.title) out.title = opts.title;
|
|
185
|
+
if (opts.description) out.description = opts.description;
|
|
186
|
+
if (opts.servers && opts.servers.length > 0) out.servers = opts.servers;
|
|
187
|
+
if (opts.webmcpServers && opts.webmcpServers.length > 0) out.webmcpServers = opts.webmcpServers;
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
141
191
|
function renderCell(cell: NotebookCell): string {
|
|
142
192
|
if (cell.kind === 'md') return cell.content;
|
|
143
193
|
if (cell.kind === 'sql') {
|
|
144
194
|
const meta = cell.varname ? `-- @meta {"varname": "${cell.varname}"}\n` : '';
|
|
145
|
-
|
|
195
|
+
const body = meta + cell.content;
|
|
196
|
+
const fence = pickFence(body);
|
|
197
|
+
return fence + 'sql\n' + body + '\n' + fence;
|
|
146
198
|
}
|
|
147
|
-
|
|
199
|
+
const fence = pickFence(cell.content);
|
|
200
|
+
return fence + 'js\n' + cell.content + '\n' + fence;
|
|
148
201
|
}
|
|
149
202
|
|
|
150
203
|
function pickArrayParamKey(params: Record<string, unknown>): string | null {
|
package/src/index.ts
CHANGED
|
@@ -91,7 +91,7 @@ export type { RemoteMcpRegistryEntry } from './remote-mcp-registry.js';
|
|
|
91
91
|
// import { canvas } from '@webmcp-auto-ui/sdk/canvas'
|
|
92
92
|
|
|
93
93
|
// Recipe runner — markdown-fence parser + JS/TS/SQL/etc executor over MCP
|
|
94
|
-
export { parseBody, runCode, estimateTokens, safeStringify, findCodeParamName, buildToolArgs, parseWidgetDisplayCall } from './recipes/index.js';
|
|
94
|
+
export { parseBody, pickFence, runCode, estimateTokens, safeStringify, findCodeParamName, buildToolArgs, parseWidgetDisplayCall, extractTopLevelDecls, hasIdentifierReference } from './recipes/index.js';
|
|
95
95
|
export type { ParsedSegment, RunResult, RunLog, RunTab, RecipeData } from './recipes/index.js';
|
|
96
96
|
|
|
97
97
|
// Short URL — domain-dependent compact token
|
|
@@ -104,10 +104,11 @@ export { extractSampleData } from './widget-sample-data.js';
|
|
|
104
104
|
export { unwrap } from './unwrap.js';
|
|
105
105
|
|
|
106
106
|
// Canvas → HyperSkill notebook serializer
|
|
107
|
-
export { lineageToCells, canvasToNotebookMarkdown } from './canvas-to-notebook.js';
|
|
107
|
+
export { lineageToCells, canvasToNotebookMarkdown, canvasToNotebookCells } from './canvas-to-notebook.js';
|
|
108
108
|
export type {
|
|
109
109
|
WidgetLineage,
|
|
110
110
|
CanvasToNotebookOptions,
|
|
111
|
+
CanvasToNotebookData,
|
|
111
112
|
NotebookCell,
|
|
112
113
|
CanvasBlock,
|
|
113
114
|
} from './canvas-to-notebook.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pick a backtick fence longer than any run of backticks inside `content`.
|
|
3
|
+
* CommonMark rule: a fenced code block opens with N≥3 backticks; the content
|
|
4
|
+
* must not contain a closing fence of the same length, and the closing fence
|
|
5
|
+
* must be at least as long as the opening. Choosing N = max(3, longestRun+1)
|
|
6
|
+
* makes round-trips safe even when the cell content embeds Markdown fences.
|
|
7
|
+
*/
|
|
8
|
+
export function pickFence(content: string): string {
|
|
9
|
+
const runs = content.match(/`+/g) ?? [];
|
|
10
|
+
const longest = runs.reduce((m, r) => Math.max(m, r.length), 0);
|
|
11
|
+
return '`'.repeat(Math.max(3, longest + 1));
|
|
12
|
+
}
|
package/src/recipes/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { parseBody } from './parse.js';
|
|
2
|
-
export {
|
|
2
|
+
export { pickFence } from './fence.js';
|
|
3
|
+
export { runCode, estimateTokens, safeStringify, findCodeParamName, buildToolArgs, parseWidgetDisplayCall, extractTopLevelDecls, hasIdentifierReference } from './runner.js';
|
|
3
4
|
export type { ParsedSegment, RunResult, RunLog, RunTab, RecipeData } from './types.js';
|
package/src/recipes/parse.ts
CHANGED
|
@@ -16,14 +16,21 @@ export function parseBody(body: string): ParsedSegment[] {
|
|
|
16
16
|
|
|
17
17
|
const segments: ParsedSegment[] = [];
|
|
18
18
|
// Match fenced code blocks with optional language tag.
|
|
19
|
-
//
|
|
20
|
-
|
|
19
|
+
// Variable-length fence (≥3 backticks, CommonMark §4.5): the closing fence
|
|
20
|
+
// must use the same number of backticks as the opening — captured via \1.
|
|
21
|
+
// The length match is what protects against premature close when cell
|
|
22
|
+
// content embeds ``` (e.g. widget('text', { content: "...```js..." }))
|
|
23
|
+
// — pickFence emits 4+ backticks in that case. We deliberately do NOT
|
|
24
|
+
// require a newline before the closing fence so that indented fences
|
|
25
|
+
// (common in numbered Markdown lists, where the close is " ```")
|
|
26
|
+
// still parse correctly.
|
|
27
|
+
const re = /(`{3,})([a-zA-Z0-9_+-]*)\r?\n([\s\S]*?)\1/g;
|
|
21
28
|
|
|
22
29
|
let lastIndex = 0;
|
|
23
30
|
let match: RegExpExecArray | null;
|
|
24
31
|
|
|
25
32
|
while ((match = re.exec(body)) !== null) {
|
|
26
|
-
const [full, langRaw, codeRaw] = match;
|
|
33
|
+
const [full, , langRaw, codeRaw] = match;
|
|
27
34
|
const start = match.index;
|
|
28
35
|
|
|
29
36
|
if (start > lastIndex) {
|
package/src/recipes/runner.ts
CHANGED
|
@@ -78,6 +78,21 @@ function makeCallHelper(multiClient: McpMultiClient | undefined, ctx: RunnerCtx)
|
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Sandbox-wide `unwrap(r)` helper. Many recipes assume MCP results are
|
|
83
|
+
* wrapped in `{ data | results | items: [...] }` and unwrap them with this
|
|
84
|
+
* one-liner. Falls through to the input itself, then to an empty array.
|
|
85
|
+
*/
|
|
86
|
+
const unwrapHelper = (r: unknown): unknown => {
|
|
87
|
+
if (r && typeof r === 'object') {
|
|
88
|
+
const o = r as Record<string, unknown>;
|
|
89
|
+
if (o.data !== undefined) return o.data;
|
|
90
|
+
if (o.results !== undefined) return o.results;
|
|
91
|
+
if (o.items !== undefined) return o.items;
|
|
92
|
+
}
|
|
93
|
+
return r ?? [];
|
|
94
|
+
};
|
|
95
|
+
|
|
81
96
|
/**
|
|
82
97
|
* Build the `widget(name, params)` helper. Captures each call into the run
|
|
83
98
|
* context so the host can mount them stacked in the run panel.
|
|
@@ -85,8 +100,10 @@ function makeCallHelper(multiClient: McpMultiClient | undefined, ctx: RunnerCtx)
|
|
|
85
100
|
function makeWidgetHelper(ctx: RunnerCtx) {
|
|
86
101
|
return async (name: string, params: Record<string, unknown> = {}) => {
|
|
87
102
|
ctx.log(`widget(${name})`);
|
|
88
|
-
|
|
89
|
-
|
|
103
|
+
// Push and return the SAME reference so widget_display(r) can dedup via ===.
|
|
104
|
+
const entry = { name, params };
|
|
105
|
+
ctx.widgets.push(entry);
|
|
106
|
+
return entry;
|
|
90
107
|
};
|
|
91
108
|
}
|
|
92
109
|
|
|
@@ -147,13 +164,16 @@ function namesFromPattern(content: string): string[] {
|
|
|
147
164
|
* Skips matches that occur inside a `{}` block (e.g. callback bodies) by
|
|
148
165
|
* counting unbalanced braces before the match position, on a sanitized copy
|
|
149
166
|
* of the code where strings and comments are blanked out. */
|
|
150
|
-
function extractTopLevelDecls(code: string): string[] {
|
|
167
|
+
export function extractTopLevelDecls(code: string): string[] {
|
|
168
|
+
// Order matters: strings before comments. Otherwise a URL like
|
|
169
|
+
// 'https://x.png' has its `//` consumed as a line comment, leaving an
|
|
170
|
+
// unbalanced quote that cascades into mismatched braces and drops decls.
|
|
151
171
|
const stripped = code
|
|
152
|
-
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
153
|
-
.replace(/\/\/[^\n]*/g, '')
|
|
154
172
|
.replace(/'(?:\\.|[^'\\])*'/g, "''")
|
|
155
173
|
.replace(/"(?:\\.|[^"\\])*"/g, '""')
|
|
156
|
-
.replace(/`(?:\\.|[^`\\])*`/g, '``')
|
|
174
|
+
.replace(/`(?:\\.|[^`\\])*`/g, '``')
|
|
175
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
176
|
+
.replace(/\/\/[^\n]*/g, '');
|
|
157
177
|
const out = new Set<string>();
|
|
158
178
|
// Identifier-form decls (existing behavior)
|
|
159
179
|
const reId = /^[\t ]*(?:const|let|var|function|class)\s+([a-zA-Z_$][\w$]*)/gm;
|
|
@@ -180,6 +200,146 @@ function extractTopLevelDecls(code: string): string[] {
|
|
|
180
200
|
return Array.from(out);
|
|
181
201
|
}
|
|
182
202
|
|
|
203
|
+
/** String/comment stripper that PRESERVES template literal interpolations.
|
|
204
|
+
* Replaces literal text inside backtick strings with spaces but keeps
|
|
205
|
+
* `${...}` expressions intact, so cross-cell dependency detection picks up
|
|
206
|
+
* references like `\`Total: ${totalPoints}\``. Single/double-quoted strings
|
|
207
|
+
* and comments are blanked entirely. */
|
|
208
|
+
function stripPreservingInterpolations(code: string): string {
|
|
209
|
+
let out = '';
|
|
210
|
+
let i = 0;
|
|
211
|
+
while (i < code.length) {
|
|
212
|
+
const c = code[i];
|
|
213
|
+
// Single/double-quoted strings — wholly blanked
|
|
214
|
+
if (c === "'" || c === '"') {
|
|
215
|
+
const quote = c;
|
|
216
|
+
out += ' ';
|
|
217
|
+
i++;
|
|
218
|
+
while (i < code.length && code[i] !== quote) {
|
|
219
|
+
if (code[i] === '\\' && i + 1 < code.length) { out += ' '; i += 2; continue; }
|
|
220
|
+
out += code[i] === '\n' ? '\n' : ' ';
|
|
221
|
+
i++;
|
|
222
|
+
}
|
|
223
|
+
out += ' ';
|
|
224
|
+
i++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
// Block comment
|
|
228
|
+
if (c === '/' && code[i + 1] === '*') {
|
|
229
|
+
out += ' ';
|
|
230
|
+
i += 2;
|
|
231
|
+
while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) {
|
|
232
|
+
out += code[i] === '\n' ? '\n' : ' ';
|
|
233
|
+
i++;
|
|
234
|
+
}
|
|
235
|
+
out += ' ';
|
|
236
|
+
i += 2;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// Line comment
|
|
240
|
+
if (c === '/' && code[i + 1] === '/') {
|
|
241
|
+
while (i < code.length && code[i] !== '\n') { out += ' '; i++; }
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
// Template literal — preserve ${...} interpolations
|
|
245
|
+
if (c === '`') {
|
|
246
|
+
out += ' ';
|
|
247
|
+
i++;
|
|
248
|
+
while (i < code.length && code[i] !== '`') {
|
|
249
|
+
if (code[i] === '\\' && i + 1 < code.length) { out += ' '; i += 2; continue; }
|
|
250
|
+
if (code[i] === '$' && code[i + 1] === '{') {
|
|
251
|
+
out += '${';
|
|
252
|
+
i += 2;
|
|
253
|
+
let depth = 1;
|
|
254
|
+
while (i < code.length && depth > 0) {
|
|
255
|
+
if (code[i] === '{') depth++;
|
|
256
|
+
else if (code[i] === '}') depth--;
|
|
257
|
+
out += code[i];
|
|
258
|
+
i++;
|
|
259
|
+
}
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
out += code[i] === '\n' ? '\n' : ' ';
|
|
263
|
+
i++;
|
|
264
|
+
}
|
|
265
|
+
out += ' ';
|
|
266
|
+
i++;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
out += c;
|
|
270
|
+
i++;
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Whether `name` appears as a free identifier in `code` — i.e. as a
|
|
276
|
+
* standalone token (not part of a longer name) and not as a property
|
|
277
|
+
* accessor (`obj.name` does not count). Strings and comments are stripped
|
|
278
|
+
* but template-literal interpolations are preserved. */
|
|
279
|
+
export function hasIdentifierReference(code: string, name: string): boolean {
|
|
280
|
+
if (!name) return false;
|
|
281
|
+
const stripped = stripPreservingInterpolations(code);
|
|
282
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
283
|
+
const re = new RegExp(`(?:^|[^.\\w$])${escaped}(?:[^\\w$]|$)`);
|
|
284
|
+
return re.test(stripped);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Same string/comment stripper as extractTopLevelDecls but preserves
|
|
288
|
+
* positions (replaces tokens with same-length whitespace) so callers can
|
|
289
|
+
* re-locate matches in the original code. */
|
|
290
|
+
function stripJsTokensSameLength(code: string): string {
|
|
291
|
+
return code
|
|
292
|
+
.replace(/'(?:\\.|[^'\\])*'/g, (s) => ' '.repeat(s.length))
|
|
293
|
+
.replace(/"(?:\\.|[^"\\])*"/g, (s) => ' '.repeat(s.length))
|
|
294
|
+
.replace(/`(?:\\.|[^`\\])*`/g, (s) => ' '.repeat(s.length))
|
|
295
|
+
.replace(/\/\*[\s\S]*?\*\//g, (s) => s.replace(/[^\n]/g, ' '))
|
|
296
|
+
.replace(/\/\/[^\n]*/g, (s) => ' '.repeat(s.length));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Top-level identifier-form const/let/var decls (subset of
|
|
300
|
+
* extractTopLevelDecls — destructuring/function/class excluded). Used to
|
|
301
|
+
* hoist these names outside the try block so a finally-clause writeback can
|
|
302
|
+
* access them even when the user code returns early. */
|
|
303
|
+
function extractTopLevelHoistableNames(code: string): string[] {
|
|
304
|
+
const stripped = stripJsTokensSameLength(code);
|
|
305
|
+
const out = new Set<string>();
|
|
306
|
+
const re = /^[\t ]*(?:const|let|var)\s+([a-zA-Z_$][\w$]*)/gm;
|
|
307
|
+
let m: RegExpExecArray | null;
|
|
308
|
+
while ((m = re.exec(stripped)) !== null) {
|
|
309
|
+
const before = stripped.slice(0, m.index);
|
|
310
|
+
const opens = (before.match(/\{/g) ?? []).length;
|
|
311
|
+
const closes = (before.match(/\}/g) ?? []).length;
|
|
312
|
+
if (opens === closes) out.add(m[1]);
|
|
313
|
+
}
|
|
314
|
+
return [...out];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Replace top-level `const|let|var ` keywords (+ trailing whitespace) with
|
|
318
|
+
* whitespace of identical length, turning declarations into plain
|
|
319
|
+
* assignments. Identifier names are preserved untouched. */
|
|
320
|
+
function stripConstLetVarKeywords(code: string, names: Set<string>): string {
|
|
321
|
+
if (names.size === 0) return code;
|
|
322
|
+
const stripped = stripJsTokensSameLength(code);
|
|
323
|
+
const re = /(^|\n)([\t ]*)(const|let|var)(\s+)([a-zA-Z_$][\w$]*)/g;
|
|
324
|
+
const ranges: { from: number; to: number }[] = [];
|
|
325
|
+
let m: RegExpExecArray | null;
|
|
326
|
+
while ((m = re.exec(stripped)) !== null) {
|
|
327
|
+
if (!names.has(m[5])) continue;
|
|
328
|
+
const before = stripped.slice(0, m.index);
|
|
329
|
+
const opens = (before.match(/\{/g) ?? []).length;
|
|
330
|
+
const closes = (before.match(/\}/g) ?? []).length;
|
|
331
|
+
if (opens !== closes) continue;
|
|
332
|
+
const kwStart = m.index + m[1].length + m[2].length;
|
|
333
|
+
const kwEnd = kwStart + m[3].length + m[4].length;
|
|
334
|
+
ranges.push({ from: kwStart, to: kwEnd });
|
|
335
|
+
}
|
|
336
|
+
let result = code;
|
|
337
|
+
for (const r of ranges.reverse()) {
|
|
338
|
+
result = result.slice(0, r.from) + ' '.repeat(r.to - r.from) + result.slice(r.to);
|
|
339
|
+
}
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
|
|
183
343
|
async function runJsLike(
|
|
184
344
|
code: string,
|
|
185
345
|
ctx: RunnerCtx,
|
|
@@ -188,27 +348,72 @@ async function runJsLike(
|
|
|
188
348
|
): Promise<unknown> {
|
|
189
349
|
// Wrap user code as the body of an async function that executes itself.
|
|
190
350
|
// Users can use `await`, define vars, and return a final value.
|
|
191
|
-
// We inject `call
|
|
351
|
+
// We inject `call`, `widget`, `unwrap` helpers as parameters so recipes can
|
|
192
352
|
// invoke MCP tools and emit widgets without preamble.
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
353
|
+
//
|
|
354
|
+
// Scope propagation: top-level decls of prior blocks are re-injected as
|
|
355
|
+
// local consts (preamble), and the current block's top-level decls are
|
|
356
|
+
// written back so the next block can read them.
|
|
357
|
+
//
|
|
358
|
+
// Early-return safety: identifier-form const/let/var decls are HOISTED as
|
|
359
|
+
// `let` outside a try { ... } finally { writeback } block. Recipes
|
|
360
|
+
// commonly do `if (!x) return;` after a failed call, which would skip a
|
|
361
|
+
// post-code writeback. With finally-clause writeback, scope propagates
|
|
362
|
+
// even when the cell returns early. Destructuring / function / class
|
|
363
|
+
// decls remain inside the try (writeback at end of try) — they only
|
|
364
|
+
// propagate on natural fall-through, same as before.
|
|
365
|
+
const allDecls = new Set(extractTopLevelDecls(code));
|
|
366
|
+
const hoistable = extractTopLevelHoistableNames(code);
|
|
367
|
+
const hoistableSet = new Set(hoistable);
|
|
368
|
+
const innerOnly = [...allDecls].filter((n) => !hoistableSet.has(n));
|
|
369
|
+
const transformedCode = stripConstLetVarKeywords(code, hoistableSet);
|
|
197
370
|
const priorKeys = scope
|
|
198
|
-
? Object.keys(scope).filter((k) => /^[a-zA-Z_$][\w$]*$/.test(k) && !
|
|
371
|
+
? Object.keys(scope).filter((k) => /^[a-zA-Z_$][\w$]*$/.test(k) && !allDecls.has(k))
|
|
199
372
|
: [];
|
|
200
373
|
const preamble = priorKeys.map((k) => `const ${k} = __scope__[${JSON.stringify(k)}];`).join('\n');
|
|
201
|
-
const
|
|
202
|
-
|
|
374
|
+
const hoist = hoistable.length ? `let ${hoistable.join(', ')};` : '';
|
|
375
|
+
const finallyWriteback = scope
|
|
376
|
+
? hoistable.map((k) => `try { __scope__[${JSON.stringify(k)}] = ${k}; } catch {}`).join('\n')
|
|
377
|
+
: '';
|
|
378
|
+
const innerWriteback = scope
|
|
379
|
+
? innerOnly.map((k) => `try { __scope__[${JSON.stringify(k)}] = ${k}; } catch {}`).join('\n')
|
|
203
380
|
: '';
|
|
204
|
-
const wrapped = `return (async () => {\n${preamble}\n${
|
|
381
|
+
const wrapped = `return (async () => {\n${preamble}\n${hoist}\ntry {\n${transformedCode}\n${innerWriteback}\n} finally {\n${finallyWriteback}\n}\n})();`;
|
|
205
382
|
const fn = new (AsyncFunction as unknown as new (
|
|
206
383
|
...args: string[]
|
|
207
|
-
) => (
|
|
208
|
-
|
|
384
|
+
) => (
|
|
385
|
+
call: unknown,
|
|
386
|
+
widget: unknown,
|
|
387
|
+
widget_display: unknown,
|
|
388
|
+
unwrap: unknown,
|
|
389
|
+
__scope__: Record<string, unknown>,
|
|
390
|
+
) => Promise<unknown>)(
|
|
391
|
+
'call', 'widget', 'widget_display', 'unwrap', '__scope__', wrapped,
|
|
209
392
|
);
|
|
210
393
|
ctx.log('dispatched (inline async)');
|
|
211
|
-
|
|
394
|
+
// widget_display is an alias for widget that accepts either ({name,params}) or (name, params).
|
|
395
|
+
// LLMs trained on the autoui MCP tool sometimes emit it directly in JS cells.
|
|
396
|
+
const widgetHelper = makeWidgetHelper(ctx);
|
|
397
|
+
const widgetDisplayAlias = async (a: unknown, b?: unknown) => {
|
|
398
|
+
// Dedup the common LLM pattern `widget_display(widget(...))` /
|
|
399
|
+
// `const r = await widget(...); widget_display(r)`. The argument is then
|
|
400
|
+
// the exact `{name, params}` object that widget() just pushed; re-pushing
|
|
401
|
+
// would render the widget twice.
|
|
402
|
+
const last = ctx.widgets[ctx.widgets.length - 1];
|
|
403
|
+
if (last && a === last) return last;
|
|
404
|
+
if (a && typeof a === 'object' && 'name' in (a as object)) {
|
|
405
|
+
const o = a as { name?: unknown; params?: unknown };
|
|
406
|
+
return widgetHelper(String(o.name ?? ''), (o.params as Record<string, unknown>) ?? {});
|
|
407
|
+
}
|
|
408
|
+
return widgetHelper(String(a ?? ''), (b as Record<string, unknown>) ?? {});
|
|
409
|
+
};
|
|
410
|
+
const out = await fn(
|
|
411
|
+
makeCallHelper(multiClient, ctx),
|
|
412
|
+
widgetHelper,
|
|
413
|
+
widgetDisplayAlias,
|
|
414
|
+
unwrapHelper,
|
|
415
|
+
scope ?? {},
|
|
416
|
+
);
|
|
212
417
|
ctx.log('resolved');
|
|
213
418
|
return out;
|
|
214
419
|
}
|
|
@@ -18,7 +18,7 @@ export const REMOTE_MCP_REGISTRY: RemoteMcpRegistryEntry[] = [
|
|
|
18
18
|
id: 'tricoteuses',
|
|
19
19
|
label: 'Tricoteuses',
|
|
20
20
|
description: 'French parliamentary database — amendments, votes, MPs, political groups.',
|
|
21
|
-
url: 'https://
|
|
21
|
+
url: 'https://demos.hyperskills.net/mcp-code4code/mcp',
|
|
22
22
|
tags: ['politics', 'france', 'parliament', 'open-data'],
|
|
23
23
|
},
|
|
24
24
|
{
|
package/src/skills/registry.ts
CHANGED
|
@@ -99,7 +99,7 @@ export function loadSkills(skills: Skill[]): void {
|
|
|
99
99
|
const DEMO_SKILLS: Omit<Skill, 'id' | 'createdAt' | 'updatedAt'>[] = [
|
|
100
100
|
{
|
|
101
101
|
name: 'weather-dashboard',
|
|
102
|
-
mcp: 'https://
|
|
102
|
+
mcp: 'https://demos.hyperskills.net/mcp-code4code/mcp',
|
|
103
103
|
mcpName: 'tricoteuses',
|
|
104
104
|
description: 'Local weather with temperature, conditions, and forecasts',
|
|
105
105
|
tags: ['weather', 'dashboard'],
|
|
@@ -111,7 +111,7 @@ const DEMO_SKILLS: Omit<Skill, 'id' | 'createdAt' | 'updatedAt'>[] = [
|
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
name: 'kpi-overview',
|
|
114
|
-
mcp: 'https://
|
|
114
|
+
mcp: 'https://demos.hyperskills.net/mcp-code4code/mcp',
|
|
115
115
|
mcpName: 'tricoteuses',
|
|
116
116
|
description: 'KPI overview: revenue, users, churn',
|
|
117
117
|
tags: ['kpi', 'dashboard'],
|
|
@@ -124,7 +124,7 @@ const DEMO_SKILLS: Omit<Skill, 'id' | 'createdAt' | 'updatedAt'>[] = [
|
|
|
124
124
|
},
|
|
125
125
|
{
|
|
126
126
|
name: 'status-monitor',
|
|
127
|
-
mcp: 'https://
|
|
127
|
+
mcp: 'https://demos.hyperskills.net/mcp-code4code/mcp',
|
|
128
128
|
mcpName: 'tricoteuses',
|
|
129
129
|
description: 'Service status monitoring',
|
|
130
130
|
tags: ['ops', 'monitoring'],
|
package/src/stores/canvas.ts
CHANGED
|
@@ -301,26 +301,9 @@ function createCanvasVanilla() {
|
|
|
301
301
|
recipes = parseRecipesFromToolResponse(res) ?? [];
|
|
302
302
|
} catch { /* no recipes */ }
|
|
303
303
|
}
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
recipes = await Promise.all(recipes.map(async (r) => {
|
|
308
|
-
try {
|
|
309
|
-
const res = await multiClient.callToolOn(srv.url, 'get_recipe', { name: r.name, id: r.name });
|
|
310
|
-
const text = (res as { content?: { type?: string; text?: string }[] })
|
|
311
|
-
.content?.find((c) => c?.type === 'text')?.text;
|
|
312
|
-
if (!text) return r;
|
|
313
|
-
let body = text;
|
|
314
|
-
try {
|
|
315
|
-
const p = JSON.parse(text);
|
|
316
|
-
if (p && typeof p === 'object' && typeof (p as { content?: unknown }).content === 'string') {
|
|
317
|
-
body = (p as { content: string }).content;
|
|
318
|
-
}
|
|
319
|
-
} catch { /* raw markdown */ }
|
|
320
|
-
return { ...r, body };
|
|
321
|
-
} catch { return r; }
|
|
322
|
-
}));
|
|
323
|
-
}
|
|
304
|
+
// Bodies are fetched lazily — DiscoveryCache.resolve('get_recipe')
|
|
305
|
+
// returns null when body is missing, letting the agent loop fall
|
|
306
|
+
// through to a live MCP dispatch on demand. Avoids 1×N upfront cost.
|
|
324
307
|
setDataServerMeta(name, {
|
|
325
308
|
connected: true, connecting: false,
|
|
326
309
|
tools: tools as McpToolInfo[],
|
package/tests/registry.test.ts
CHANGED
|
@@ -74,7 +74,7 @@ describe('loadDemoSkills', () => {
|
|
|
74
74
|
loadDemoSkills();
|
|
75
75
|
const skills = listSkills();
|
|
76
76
|
expect(skills.length).toBe(3);
|
|
77
|
-
expect(skills.every(s => s.mcp === 'https://
|
|
77
|
+
expect(skills.every(s => s.mcp === 'https://demos.hyperskills.net/mcp-code4code/mcp')).toBe(true);
|
|
78
78
|
expect(skills.every(s => s.mcpName === 'tricoteuses')).toBe(true);
|
|
79
79
|
});
|
|
80
80
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { runCode } from '../src/recipes/runner.js';
|
|
3
|
+
|
|
4
|
+
describe('JS sandbox widget helpers', () => {
|
|
5
|
+
it('widget(name, params) pushes a single entry', async () => {
|
|
6
|
+
const res = await runCode(`await widget('echarts-pie', { values: [{ name: 'a', value: 1 }] }); return 42;`, 'js');
|
|
7
|
+
expect(res.status).toBe('done');
|
|
8
|
+
expect(res.widgets).toHaveLength(1);
|
|
9
|
+
expect(res.widgets?.[0]).toMatchObject({ name: 'echarts-pie', params: { values: [{ name: 'a', value: 1 }] } });
|
|
10
|
+
expect(res.output).toBe(42);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('widget_display(name, params) works as alias of widget()', async () => {
|
|
14
|
+
const res = await runCode(`widget_display('echarts-pie', { values: [{ name: 'a', value: 1 }] });`, 'js');
|
|
15
|
+
expect(res.status).toBe('done');
|
|
16
|
+
expect(res.widgets).toHaveLength(1);
|
|
17
|
+
expect(res.widgets?.[0]).toMatchObject({ name: 'echarts-pie' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('widget_display({name, params}) works (autoui MCP shape)', async () => {
|
|
21
|
+
const res = await runCode(`widget_display({ name: 'echarts-pie', params: { values: [{ name: 'a', value: 1 }] } });`, 'js');
|
|
22
|
+
expect(res.status).toBe('done');
|
|
23
|
+
expect(res.widgets).toHaveLength(1);
|
|
24
|
+
expect(res.widgets?.[0]).toMatchObject({ name: 'echarts-pie' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('widget_display(widget(...)) does NOT double-push', async () => {
|
|
28
|
+
// The common LLM pattern that previously rendered the widget twice.
|
|
29
|
+
const res = await runCode(
|
|
30
|
+
`const r = await widget('echarts-pie', { values: [] }); widget_display(r);`,
|
|
31
|
+
'js',
|
|
32
|
+
);
|
|
33
|
+
expect(res.status).toBe('done');
|
|
34
|
+
expect(res.widgets).toHaveLength(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('two distinct widget calls produce two entries', async () => {
|
|
38
|
+
const res = await runCode(
|
|
39
|
+
`await widget('echarts-pie', { values: [] }); await widget('echarts-bar', { rows: [] });`,
|
|
40
|
+
'js',
|
|
41
|
+
);
|
|
42
|
+
expect(res.status).toBe('done');
|
|
43
|
+
expect(res.widgets).toHaveLength(2);
|
|
44
|
+
expect(res.widgets?.[0].name).toBe('echarts-pie');
|
|
45
|
+
expect(res.widgets?.[1].name).toBe('echarts-bar');
|
|
46
|
+
});
|
|
47
|
+
});
|