@jxsuite/studio 0.32.0 → 0.33.0
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/studio.js +9694 -306
- package/dist/studio.js.map +105 -9
- package/package.json +10 -5
- package/src/panels/ai-panel.ts +388 -328
- package/src/platforms/devserver.ts +3 -47
- package/src/services/ai-settings.ts +107 -0
- package/src/services/ai-system-prompt.ts +617 -0
- package/src/services/ai-tools.ts +854 -0
- package/src/services/context-manager.ts +200 -0
- package/src/services/document-assistant.ts +183 -0
- package/src/services/jx-validate.ts +65 -0
- package/src/services/render-critic.ts +75 -0
- package/src/services/token-lint.ts +140 -0
- package/src/services/tool-executor.ts +156 -0
- package/src/state.ts +29 -0
- package/src/tabs/transact.ts +37 -1
- package/src/types.ts +2 -6
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ai-tools.js — Jx document manipulation tools for the AI assistant
|
|
3
|
+
*
|
|
4
|
+
* Concrete `.jx` AST tools registered into a `@jxsuite/ai` ToolRegistry. Each tool wraps an
|
|
5
|
+
* existing `transactDoc()` mutation helper so AI edits get the same undo/redo history as manual
|
|
6
|
+
* edits (ADR docs/ai-assistant-decision.md §5 — optimistic apply + undo).
|
|
7
|
+
*
|
|
8
|
+
* @license MIT
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createToolDefinition } from "@jxsuite/ai/tools";
|
|
12
|
+
import type { ToolRegistry, ToolResult } from "@jxsuite/ai/tools";
|
|
13
|
+
import type { JxMutableNode, JxPath, JxStateDefinition } from "@jxsuite/schema/types";
|
|
14
|
+
import { getNodeAtPath } from "../state";
|
|
15
|
+
import { toRaw } from "../reactivity";
|
|
16
|
+
import type { Tab } from "../tabs/tab";
|
|
17
|
+
import {
|
|
18
|
+
beginBatch,
|
|
19
|
+
endBatch,
|
|
20
|
+
isBatching,
|
|
21
|
+
mutateInsertNode,
|
|
22
|
+
mutateMoveNode,
|
|
23
|
+
mutateRemoveNode,
|
|
24
|
+
mutateUpdateProperty,
|
|
25
|
+
mutateUpdateStyle,
|
|
26
|
+
transactDoc,
|
|
27
|
+
} from "../tabs/transact";
|
|
28
|
+
import type { JxNodeValue } from "../tabs/transact";
|
|
29
|
+
import { validateDoc } from "./jx-validate";
|
|
30
|
+
import { flagHardcodedTokens, formatTokenHints } from "./token-lint";
|
|
31
|
+
|
|
32
|
+
const PATH_DESCRIPTION =
|
|
33
|
+
"Path to a node in the document, as a JSON array of keys/indices from the root " +
|
|
34
|
+
'(e.g. ["children", 0, "children", 1]). Use read_document to discover valid paths.';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Translate a raw JSON Schema validation error into a Jx-specific actionable message. The LLM needs
|
|
38
|
+
* concrete guidance on HOW to fix errors, not just what rule was violated.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} rawError - Message from ajv (e.g. "/children/0/style: must NOT have additional
|
|
41
|
+
* property")
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
function translateValidationError(rawError: string): string {
|
|
45
|
+
const lower = rawError.toLowerCase();
|
|
46
|
+
|
|
47
|
+
// Additional property — extract the offending key from the message if present
|
|
48
|
+
if (
|
|
49
|
+
lower.includes("must not have additional property") ||
|
|
50
|
+
lower.includes("additional properties")
|
|
51
|
+
) {
|
|
52
|
+
return `${rawError}\n → Fix: Remove or move the unexpected property. Style properties must be camelCase (e.g. "backgroundColor", not "background-color"). Non-IDL HTML attributes (aria-*, data-*, role, ...) must go inside an "attributes" object: { "attributes": { "aria-label": "..." } }.`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Pattern — usually tagName hyphen rule
|
|
56
|
+
if (lower.includes("must match pattern")) {
|
|
57
|
+
return `${rawError}\n → Fix: Custom element tag names must contain a hyphen (e.g. "newsletter-form", "feature-card"). Standard HTML elements use their exact name (e.g. "div", "input", "button").`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Type error
|
|
61
|
+
if (lower.includes("must be string")) {
|
|
62
|
+
return `${rawError}\n → Fix: Wrap the value in quotes — all Jx property values should be strings. For example, use "10px" (string) not 10px (unquoted).`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (lower.includes("must be number") || lower.includes("must be integer")) {
|
|
66
|
+
return `${rawError}\n → Fix: Remove quotes from the numeric value — it should be a plain number, not a string.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (lower.includes("must be object") || lower.includes("must be array")) {
|
|
70
|
+
return `${rawError}\n → Fix: The value must be an object/array (use {} or []), not a string or number.`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (lower.includes("must be boolean")) {
|
|
74
|
+
return `${rawError}\n → Fix: Use true or false without quotes for boolean values.`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Required property
|
|
78
|
+
if (lower.includes("must have required property")) {
|
|
79
|
+
return `${rawError}\n → Fix: Add the missing required property. Every Jx element must have at least a "tagName" field.`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Enum / allowed values
|
|
83
|
+
if (lower.includes("must be equal to one of the allowed values")) {
|
|
84
|
+
return `${rawError}\n → Fix: Change the value to one of the allowed options listed in the error.`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return rawError;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Apply a mutation, then validate the document and report only the schema errors the edit newly
|
|
92
|
+
* introduced (the eval signal — ADR §6b). The change stays applied either way (optimistic apply +
|
|
93
|
+
* undo, ADR §5); reporting the errors lets the agent loop self-correct on the next round.
|
|
94
|
+
*
|
|
95
|
+
* When a renderCheck function is provided, a second gate runs after schema validation passes: the
|
|
96
|
+
* mutated document is rendered in a detached DOM context and any render-time throws are surfaced as
|
|
97
|
+
* tool errors (same contract as schema errors).
|
|
98
|
+
*
|
|
99
|
+
* @param {import("../tabs/tab").Tab} tab
|
|
100
|
+
* @param {(t: import("../tabs/tab").Tab) => void} mutationFn
|
|
101
|
+
* @param {string} summary
|
|
102
|
+
* @param {(doc: unknown) => Promise<string[]>} validate
|
|
103
|
+
* @param {((doc: unknown) => Promise<{ ok: true } | { ok: false; error: string }>) | undefined} renderCheck
|
|
104
|
+
* @param {Record<string, string> | undefined} projectStyle
|
|
105
|
+
* @returns {Promise<import("@jxsuite/ai/tools").ToolResult>}
|
|
106
|
+
*/
|
|
107
|
+
async function applyAndValidate(
|
|
108
|
+
tab: Tab,
|
|
109
|
+
mutationFn: (t: Tab) => void,
|
|
110
|
+
summary: string,
|
|
111
|
+
validate: (doc: unknown) => Promise<string[]>,
|
|
112
|
+
renderCheck: ((doc: unknown) => Promise<{ ok: true } | { ok: false; error: string }>) | undefined,
|
|
113
|
+
projectStyle: Record<string, string> | undefined,
|
|
114
|
+
): Promise<ToolResult> {
|
|
115
|
+
const rawBefore = toRaw(tab.doc.document);
|
|
116
|
+
const before = new Set(await validate(rawBefore));
|
|
117
|
+
const renderOkBefore = renderCheck ? await renderCheck(rawBefore) : { ok: true };
|
|
118
|
+
|
|
119
|
+
transactDoc(tab, mutationFn);
|
|
120
|
+
|
|
121
|
+
const rawAfter = toRaw(tab.doc.document);
|
|
122
|
+
const after = await validate(rawAfter);
|
|
123
|
+
const newErrors = after.filter((e) => !before.has(e));
|
|
124
|
+
if (newErrors.length > 0) {
|
|
125
|
+
const formatted = newErrors.map((e) => `- ${translateValidationError(e)}`).join("\n");
|
|
126
|
+
return {
|
|
127
|
+
success: false,
|
|
128
|
+
error: `Change applied, but it introduced schema errors. Fix these issues with follow-up edits:\n${formatted}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (renderCheck && renderOkBefore.ok) {
|
|
133
|
+
const renderResult = await renderCheck(rawAfter);
|
|
134
|
+
if (!renderResult.ok) {
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
error: `Change applied and schema-valid, but it broke rendering. Fix with follow-up edits:\n- ${renderResult.error}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Soft token-discipline hints (never fail the mutation)
|
|
143
|
+
if (projectStyle) {
|
|
144
|
+
const findings = flagHardcodedTokens(rawAfter, projectStyle);
|
|
145
|
+
const hints = formatTokenHints(findings);
|
|
146
|
+
if (hints) {
|
|
147
|
+
return { success: true, summary: `${summary}\n\n${hints}` };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { success: true, summary };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Register the document-manipulation tools into a tool registry.
|
|
156
|
+
*
|
|
157
|
+
* @param {import("@jxsuite/ai/tools").ToolRegistry} registry
|
|
158
|
+
* @param {{
|
|
159
|
+
* getTab: () => import("../tabs/tab").Tab | null;
|
|
160
|
+
* validate?: (doc: unknown) => Promise<string[]>;
|
|
161
|
+
* saveFile?: (relPath: string, content: string) => Promise<void>;
|
|
162
|
+
* renderCheck?: (doc: unknown) => Promise<{ ok: true } | { ok: false; error: string }>;
|
|
163
|
+
* openDocument?: (path: string) => Promise<void>;
|
|
164
|
+
* projectStyle?: Record<string, string>;
|
|
165
|
+
* }} ctx
|
|
166
|
+
*/
|
|
167
|
+
export function registerAiTools(
|
|
168
|
+
registry: Pick<ToolRegistry, "register">,
|
|
169
|
+
{
|
|
170
|
+
getTab,
|
|
171
|
+
validate = validateDoc,
|
|
172
|
+
saveFile,
|
|
173
|
+
renderCheck,
|
|
174
|
+
openDocument,
|
|
175
|
+
projectStyle,
|
|
176
|
+
}: {
|
|
177
|
+
getTab: () => Tab | null;
|
|
178
|
+
validate?: (doc: unknown) => Promise<string[]>;
|
|
179
|
+
saveFile?: (relPath: string, content: string) => Promise<void>;
|
|
180
|
+
renderCheck?: (doc: unknown) => Promise<{ ok: true } | { ok: false; error: string }>;
|
|
181
|
+
openDocument?: (path: string) => Promise<void>;
|
|
182
|
+
projectStyle?: Record<string, string> | undefined;
|
|
183
|
+
},
|
|
184
|
+
) {
|
|
185
|
+
registry.register(
|
|
186
|
+
createToolDefinition({
|
|
187
|
+
name: "read_document",
|
|
188
|
+
description:
|
|
189
|
+
"Read the current Jx document, or the subtree at a given path. Use this to discover " +
|
|
190
|
+
"node paths before calling set_property, add_child, or remove_node.",
|
|
191
|
+
parameters: {
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: {
|
|
194
|
+
path: {
|
|
195
|
+
type: "array",
|
|
196
|
+
description: `${PATH_DESCRIPTION} Omit to read the whole document.`,
|
|
197
|
+
items: { type: ["string", "number"] },
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
required: [],
|
|
201
|
+
},
|
|
202
|
+
execute(args) {
|
|
203
|
+
const tab = getTab();
|
|
204
|
+
if (!tab) {
|
|
205
|
+
return { success: false, error: "No document is open." };
|
|
206
|
+
}
|
|
207
|
+
const { path } = args as { path?: JxPath };
|
|
208
|
+
const node =
|
|
209
|
+
path && path.length > 0 ? getNodeAtPath(tab.doc.document, path) : tab.doc.document;
|
|
210
|
+
if (node === undefined) {
|
|
211
|
+
return { success: false, error: `No node exists at path ${JSON.stringify(path)}.` };
|
|
212
|
+
}
|
|
213
|
+
return { success: true, data: node };
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
registry.register(
|
|
219
|
+
createToolDefinition({
|
|
220
|
+
name: "set_property",
|
|
221
|
+
description:
|
|
222
|
+
"Set or remove a property on a node (e.g. tagName, textContent, className, style, " +
|
|
223
|
+
"attributes, $props). Pass value: null to remove the property.",
|
|
224
|
+
parameters: {
|
|
225
|
+
type: "object",
|
|
226
|
+
properties: {
|
|
227
|
+
path: {
|
|
228
|
+
type: "array",
|
|
229
|
+
description: PATH_DESCRIPTION,
|
|
230
|
+
items: { type: ["string", "number"] },
|
|
231
|
+
},
|
|
232
|
+
key: {
|
|
233
|
+
type: "string",
|
|
234
|
+
description: 'The property name to set, e.g. "textContent" or "className".',
|
|
235
|
+
},
|
|
236
|
+
value: {
|
|
237
|
+
description: "The new value, or null to remove the property.",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
/*
|
|
241
|
+
* "value" omitted from required: passing null / omitting it means "remove the property",
|
|
242
|
+
* but the registry rejects null on required args (tools.js:181). See §14.
|
|
243
|
+
*/
|
|
244
|
+
required: ["path", "key"],
|
|
245
|
+
},
|
|
246
|
+
async execute(args) {
|
|
247
|
+
const tab = getTab();
|
|
248
|
+
if (!tab) {
|
|
249
|
+
return { success: false, error: "No document is open." };
|
|
250
|
+
}
|
|
251
|
+
const { path, key, value } = args as { path: JxPath; key: string; value?: JxNodeValue };
|
|
252
|
+
const node = getNodeAtPath(tab.doc.document, path);
|
|
253
|
+
if (node === undefined) {
|
|
254
|
+
return { success: false, error: `No node exists at path ${JSON.stringify(path)}.` };
|
|
255
|
+
}
|
|
256
|
+
return applyAndValidate(
|
|
257
|
+
tab,
|
|
258
|
+
(t) => mutateUpdateProperty(t, path, key, value ?? undefined),
|
|
259
|
+
`Set "${key}" at ${JSON.stringify(path)}.`,
|
|
260
|
+
validate,
|
|
261
|
+
renderCheck,
|
|
262
|
+
projectStyle,
|
|
263
|
+
);
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
registry.register(
|
|
269
|
+
createToolDefinition({
|
|
270
|
+
name: "add_child",
|
|
271
|
+
description:
|
|
272
|
+
"Insert a new child node into the children array of the node at parentPath, at the " +
|
|
273
|
+
"given index (0 = first child). The node definition follows the Jx document schema " +
|
|
274
|
+
'(e.g. { "tagName": "p", "textContent": "Hello" }).',
|
|
275
|
+
parameters: {
|
|
276
|
+
type: "object",
|
|
277
|
+
properties: {
|
|
278
|
+
parentPath: {
|
|
279
|
+
type: "array",
|
|
280
|
+
description: `${PATH_DESCRIPTION} Must point at a node, not a children array.`,
|
|
281
|
+
items: { type: ["string", "number"] },
|
|
282
|
+
},
|
|
283
|
+
index: { type: "integer", description: "Position in the children array to insert at." },
|
|
284
|
+
node: {
|
|
285
|
+
type: "object",
|
|
286
|
+
description:
|
|
287
|
+
'The Jx node definition to insert, e.g. { "tagName": "div", "children": [] }.',
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
required: ["parentPath", "index", "node"],
|
|
291
|
+
},
|
|
292
|
+
async execute(args) {
|
|
293
|
+
const tab = getTab();
|
|
294
|
+
if (!tab) {
|
|
295
|
+
return { success: false, error: "No document is open." };
|
|
296
|
+
}
|
|
297
|
+
const {
|
|
298
|
+
parentPath,
|
|
299
|
+
index,
|
|
300
|
+
node: childNode,
|
|
301
|
+
} = args as {
|
|
302
|
+
parentPath: JxPath;
|
|
303
|
+
index: number;
|
|
304
|
+
node: JxMutableNode;
|
|
305
|
+
};
|
|
306
|
+
const parent = getNodeAtPath(tab.doc.document, parentPath);
|
|
307
|
+
if (parent === undefined) {
|
|
308
|
+
return { success: false, error: `No node exists at path ${JSON.stringify(parentPath)}.` };
|
|
309
|
+
}
|
|
310
|
+
/*
|
|
311
|
+
* Guard against a parentPath that points at a children *array* rather than a node — the
|
|
312
|
+
* common failure is a trailing "children" segment (e.g. ["children",0,"children"]).
|
|
313
|
+
* add_child appends "children" + index itself, so an array-valued parentPath would splice
|
|
314
|
+
* into a bogus `.children` property on the array (childArray() creates one) and the node
|
|
315
|
+
* would be stored where nothing renders, yet the tool would report success. Reject it with
|
|
316
|
+
* a precise message so the loop self-corrects.
|
|
317
|
+
*/
|
|
318
|
+
if (Array.isArray(parent)) {
|
|
319
|
+
return {
|
|
320
|
+
success: false,
|
|
321
|
+
error:
|
|
322
|
+
`parentPath ${JSON.stringify(parentPath)} points at a children array, not a node. ` +
|
|
323
|
+
`Drop the trailing "children" segment — add_child appends "children" and the index ` +
|
|
324
|
+
`automatically. For example, to insert into the node at ["children",0,"children",1], ` +
|
|
325
|
+
`pass parentPath: ["children",0,"children",1].`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (parent.children !== undefined && !Array.isArray(parent.children)) {
|
|
329
|
+
return {
|
|
330
|
+
success: false,
|
|
331
|
+
error: "Cannot insert into mapped-array children; edit the map template instead.",
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
return applyAndValidate(
|
|
335
|
+
tab,
|
|
336
|
+
(t) => mutateInsertNode(t, parentPath, index, childNode),
|
|
337
|
+
`Inserted node at ${JSON.stringify([...parentPath, "children", index])}.`,
|
|
338
|
+
validate,
|
|
339
|
+
renderCheck,
|
|
340
|
+
projectStyle,
|
|
341
|
+
);
|
|
342
|
+
},
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// ── set_style ──────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
registry.register(
|
|
349
|
+
createToolDefinition({
|
|
350
|
+
name: "set_style",
|
|
351
|
+
description:
|
|
352
|
+
"Set or remove a CSS style property on a node. Style property names use camelCase " +
|
|
353
|
+
'(e.g. "backgroundColor", "fontSize", "borderRadius"). Values are always strings ' +
|
|
354
|
+
'(e.g. "10px", "center", "var(--color-accent)"). Pass value: null to remove.',
|
|
355
|
+
parameters: {
|
|
356
|
+
type: "object",
|
|
357
|
+
properties: {
|
|
358
|
+
path: {
|
|
359
|
+
type: "array",
|
|
360
|
+
description: PATH_DESCRIPTION,
|
|
361
|
+
items: { type: ["string", "number"] },
|
|
362
|
+
},
|
|
363
|
+
property: {
|
|
364
|
+
type: "string",
|
|
365
|
+
description: 'CSS property name in camelCase, e.g. "backgroundColor".',
|
|
366
|
+
},
|
|
367
|
+
value: {
|
|
368
|
+
description:
|
|
369
|
+
'CSS value as a string (e.g. "10px", "var(--color-accent)"), or null to remove.',
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
/*
|
|
373
|
+
* "value" omitted from required so null / omitted = remove (registry rejects null on
|
|
374
|
+
* required args, tools.js:181). See §14.
|
|
375
|
+
*/
|
|
376
|
+
required: ["path", "property"],
|
|
377
|
+
},
|
|
378
|
+
async execute(args) {
|
|
379
|
+
const tab = getTab();
|
|
380
|
+
if (!tab) {
|
|
381
|
+
return { success: false, error: "No document is open." };
|
|
382
|
+
}
|
|
383
|
+
const { path, property, value } = args as {
|
|
384
|
+
path: JxPath;
|
|
385
|
+
property: string;
|
|
386
|
+
value?: unknown;
|
|
387
|
+
};
|
|
388
|
+
if (getNodeAtPath(tab.doc.document, path) === undefined) {
|
|
389
|
+
return { success: false, error: `No node exists at path ${JSON.stringify(path)}.` };
|
|
390
|
+
}
|
|
391
|
+
const prop = property;
|
|
392
|
+
const val = value == null ? undefined : String(value);
|
|
393
|
+
return applyAndValidate(
|
|
394
|
+
tab,
|
|
395
|
+
(t) => mutateUpdateStyle(t, path, prop, val),
|
|
396
|
+
`Set style "${prop}" at ${JSON.stringify(path)}.`,
|
|
397
|
+
validate,
|
|
398
|
+
renderCheck,
|
|
399
|
+
projectStyle,
|
|
400
|
+
);
|
|
401
|
+
},
|
|
402
|
+
}),
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// ── set_text ───────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
registry.register(
|
|
408
|
+
createToolDefinition({
|
|
409
|
+
name: "set_text",
|
|
410
|
+
description:
|
|
411
|
+
"Set the textContent of a node. Convenience alias for set_property with key: 'textContent'.",
|
|
412
|
+
parameters: {
|
|
413
|
+
type: "object",
|
|
414
|
+
properties: {
|
|
415
|
+
path: {
|
|
416
|
+
type: "array",
|
|
417
|
+
description: PATH_DESCRIPTION,
|
|
418
|
+
items: { type: ["string", "number"] },
|
|
419
|
+
},
|
|
420
|
+
value: { type: "string", description: "Text content." },
|
|
421
|
+
},
|
|
422
|
+
required: ["path", "value"],
|
|
423
|
+
},
|
|
424
|
+
async execute(args) {
|
|
425
|
+
const tab = getTab();
|
|
426
|
+
if (!tab) {
|
|
427
|
+
return { success: false, error: "No document is open." };
|
|
428
|
+
}
|
|
429
|
+
const { path, value } = args as { path: JxPath; value: string };
|
|
430
|
+
if (getNodeAtPath(tab.doc.document, path) === undefined) {
|
|
431
|
+
return { success: false, error: `No node exists at path ${JSON.stringify(path)}.` };
|
|
432
|
+
}
|
|
433
|
+
return applyAndValidate(
|
|
434
|
+
tab,
|
|
435
|
+
(t) => {
|
|
436
|
+
const node = getNodeAtPath(t.doc.document, path);
|
|
437
|
+
delete node.textContent;
|
|
438
|
+
node.children = [value];
|
|
439
|
+
},
|
|
440
|
+
`Set text at ${JSON.stringify(path)}.`,
|
|
441
|
+
validate,
|
|
442
|
+
renderCheck,
|
|
443
|
+
projectStyle,
|
|
444
|
+
);
|
|
445
|
+
},
|
|
446
|
+
}),
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
// ── add_state ──────────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
registry.register(
|
|
452
|
+
createToolDefinition({
|
|
453
|
+
name: "add_state",
|
|
454
|
+
description:
|
|
455
|
+
"Add a new reactive state variable under the document's `state` object. The value can be a scalar " +
|
|
456
|
+
'(e.g. 0, ""), a typed object ({ "type": "string", "default": "" }), a computed ' +
|
|
457
|
+
'string ("${state.other}"), a function ({ "$prototype": "Function", "body": "..." }), ' +
|
|
458
|
+
'or a data source ({ "$prototype": "Data", "$src": "./data.json" }).',
|
|
459
|
+
parameters: {
|
|
460
|
+
type: "object",
|
|
461
|
+
properties: {
|
|
462
|
+
key: { type: "string", description: 'State variable name, e.g. "count", "isOpen".' },
|
|
463
|
+
value: {
|
|
464
|
+
description:
|
|
465
|
+
"Initial value or state shape object (scalar, typed, computed, function, or data source).",
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
required: ["key", "value"],
|
|
469
|
+
},
|
|
470
|
+
async execute(args) {
|
|
471
|
+
const tab = getTab();
|
|
472
|
+
if (!tab) {
|
|
473
|
+
return { success: false, error: "No document is open." };
|
|
474
|
+
}
|
|
475
|
+
const { key, value } = args as { key: string; value: JxStateDefinition };
|
|
476
|
+
if (tab.doc.document.state && tab.doc.document.state[key] !== undefined) {
|
|
477
|
+
return {
|
|
478
|
+
success: false,
|
|
479
|
+
error: `State key "${key}" already exists. Use update_state to change it, or remove it first.`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return applyAndValidate(
|
|
483
|
+
tab,
|
|
484
|
+
(t) => {
|
|
485
|
+
// Ensure the state object exists before setting a key on it.
|
|
486
|
+
if (!t.doc.document.state) {
|
|
487
|
+
t.doc.document.state = {};
|
|
488
|
+
}
|
|
489
|
+
/*
|
|
490
|
+
* Directly mutate — bypass mutateUpdateProperty because its "" → delete behaviour
|
|
491
|
+
* (transact.ts:248) is wrong for state defaults (e.g. "title": "").
|
|
492
|
+
*/
|
|
493
|
+
t.doc.document.state[key] = value;
|
|
494
|
+
},
|
|
495
|
+
`Added state "${key}".`,
|
|
496
|
+
validate,
|
|
497
|
+
renderCheck,
|
|
498
|
+
projectStyle,
|
|
499
|
+
);
|
|
500
|
+
},
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
// ── update_state ───────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
registry.register(
|
|
507
|
+
createToolDefinition({
|
|
508
|
+
name: "update_state",
|
|
509
|
+
description:
|
|
510
|
+
"Update or remove an existing state variable at the document root. Pass value: null to remove.",
|
|
511
|
+
parameters: {
|
|
512
|
+
type: "object",
|
|
513
|
+
properties: {
|
|
514
|
+
key: { type: "string", description: 'State variable name to update, e.g. "count".' },
|
|
515
|
+
value: {
|
|
516
|
+
description:
|
|
517
|
+
"New value (same shapes as add_state), or null to remove the state variable.",
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
/*
|
|
521
|
+
* "value" omitted from required so null = remove (registry rejects null on required
|
|
522
|
+
* args, tools.js:181). See §14.
|
|
523
|
+
*/
|
|
524
|
+
required: ["key"],
|
|
525
|
+
},
|
|
526
|
+
async execute(args) {
|
|
527
|
+
const tab = getTab();
|
|
528
|
+
if (!tab) {
|
|
529
|
+
return { success: false, error: "No document is open." };
|
|
530
|
+
}
|
|
531
|
+
const { key, value } = args as { key: string; value?: JxStateDefinition | null };
|
|
532
|
+
if (!tab.doc.document.state || tab.doc.document.state[key] === undefined) {
|
|
533
|
+
return {
|
|
534
|
+
success: false,
|
|
535
|
+
error: `State key "${key}" does not exist. Use add_state to create it, or check the name. Current state keys: ${Object.keys(tab.doc.document.state || {}).join(", ") || "(none)"}`,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
return applyAndValidate(
|
|
539
|
+
tab,
|
|
540
|
+
(t) => {
|
|
541
|
+
/*
|
|
542
|
+
* Directly mutate — bypass mutateUpdateProperty because its "" → delete behaviour
|
|
543
|
+
* (transact.ts:248) is wrong for state defaults (e.g. "title": "").
|
|
544
|
+
*/
|
|
545
|
+
const { state } = t.doc.document;
|
|
546
|
+
if (!state) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (value == null) {
|
|
550
|
+
delete state[key];
|
|
551
|
+
} else {
|
|
552
|
+
state[key] = value;
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
value == null ? `Removed state "${key}".` : `Updated state "${key}".`,
|
|
556
|
+
validate,
|
|
557
|
+
renderCheck,
|
|
558
|
+
projectStyle,
|
|
559
|
+
);
|
|
560
|
+
},
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
// ── move_node ──────────────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
registry.register(
|
|
567
|
+
createToolDefinition({
|
|
568
|
+
name: "move_node",
|
|
569
|
+
description:
|
|
570
|
+
"Move a node from one location to another in the document tree. The node is removed " +
|
|
571
|
+
"from fromPath and inserted into toParentPath at toIndex.",
|
|
572
|
+
parameters: {
|
|
573
|
+
type: "object",
|
|
574
|
+
properties: {
|
|
575
|
+
fromPath: {
|
|
576
|
+
type: "array",
|
|
577
|
+
description: `${PATH_DESCRIPTION} The node to move.`,
|
|
578
|
+
items: { type: ["string", "number"] },
|
|
579
|
+
},
|
|
580
|
+
toParentPath: {
|
|
581
|
+
type: "array",
|
|
582
|
+
description: `${PATH_DESCRIPTION} The target parent (a node, not a children array).`,
|
|
583
|
+
items: { type: ["string", "number"] },
|
|
584
|
+
},
|
|
585
|
+
toIndex: {
|
|
586
|
+
type: "integer",
|
|
587
|
+
description: "Insertion index in the target parent's children array.",
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
required: ["fromPath", "toParentPath", "toIndex"],
|
|
591
|
+
},
|
|
592
|
+
async execute(args) {
|
|
593
|
+
const tab = getTab();
|
|
594
|
+
if (!tab) {
|
|
595
|
+
return { success: false, error: "No document is open." };
|
|
596
|
+
}
|
|
597
|
+
const { fromPath, toParentPath, toIndex } = args as {
|
|
598
|
+
fromPath: JxPath;
|
|
599
|
+
toParentPath: JxPath;
|
|
600
|
+
toIndex: number;
|
|
601
|
+
};
|
|
602
|
+
if (fromPath.length < 2) {
|
|
603
|
+
return { success: false, error: "Cannot move the document root." };
|
|
604
|
+
}
|
|
605
|
+
if (getNodeAtPath(tab.doc.document, fromPath) === undefined) {
|
|
606
|
+
return {
|
|
607
|
+
success: false,
|
|
608
|
+
error: `No node exists at fromPath ${JSON.stringify(fromPath)}.`,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
if (getNodeAtPath(tab.doc.document, toParentPath) === undefined) {
|
|
612
|
+
return {
|
|
613
|
+
success: false,
|
|
614
|
+
error: `No node exists at toParentPath ${JSON.stringify(toParentPath)}.`,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
return applyAndValidate(
|
|
618
|
+
tab,
|
|
619
|
+
(t) => mutateMoveNode(t, fromPath, toParentPath, toIndex),
|
|
620
|
+
`Moved node from ${JSON.stringify(fromPath)} to ${JSON.stringify([...toParentPath, "children", toIndex])}.`,
|
|
621
|
+
validate,
|
|
622
|
+
renderCheck,
|
|
623
|
+
projectStyle,
|
|
624
|
+
);
|
|
625
|
+
},
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
// ── create_component ───────────────────────────────────────────────────
|
|
630
|
+
|
|
631
|
+
registry.register(
|
|
632
|
+
createToolDefinition({
|
|
633
|
+
name: "create_component",
|
|
634
|
+
description:
|
|
635
|
+
"Create a new Jx component file on disk. Writes the component JSON to the given " +
|
|
636
|
+
"relative path within the project. The content must be a valid Jx component document.",
|
|
637
|
+
parameters: {
|
|
638
|
+
type: "object",
|
|
639
|
+
properties: {
|
|
640
|
+
path: {
|
|
641
|
+
type: "string",
|
|
642
|
+
description:
|
|
643
|
+
'File path relative to the project root, e.g. "components/newsletter-form.json".',
|
|
644
|
+
},
|
|
645
|
+
content: {
|
|
646
|
+
type: "object",
|
|
647
|
+
description:
|
|
648
|
+
"The complete Jx component JSON (must include tagName, optionally state, style, children, $elements).",
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
required: ["path", "content"],
|
|
652
|
+
},
|
|
653
|
+
async execute(args) {
|
|
654
|
+
if (!saveFile) {
|
|
655
|
+
return {
|
|
656
|
+
success: false,
|
|
657
|
+
error: "File operations are not available in this environment.",
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
const { path: relPath, content } = args as { path: string; content: object };
|
|
661
|
+
const errors = await validate(content);
|
|
662
|
+
if (errors.length > 0) {
|
|
663
|
+
const formatted = errors.map((e) => `- ${translateValidationError(e)}`).join("\n");
|
|
664
|
+
return {
|
|
665
|
+
success: false,
|
|
666
|
+
error: `Component content has schema errors. Fix these before creating the file:\n${formatted}`,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
if (renderCheck) {
|
|
670
|
+
const renderResult = await renderCheck(content);
|
|
671
|
+
if (!renderResult.ok) {
|
|
672
|
+
return {
|
|
673
|
+
success: false,
|
|
674
|
+
error: `Component is schema-valid but fails to render. Fix before creating:\n- ${renderResult.error}`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
await saveFile(relPath, JSON.stringify(content, null, 2));
|
|
680
|
+
return { success: true, summary: `Created component at "${relPath}".` };
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return {
|
|
683
|
+
success: false,
|
|
684
|
+
error: `Failed to write file: ${error instanceof Error ? error.message : String(error)}`,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
},
|
|
688
|
+
}),
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
// ── create_page ────────────────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
registry.register(
|
|
694
|
+
createToolDefinition({
|
|
695
|
+
name: "create_page",
|
|
696
|
+
description:
|
|
697
|
+
"Create a new Jx page file on disk. A page typically includes a layout component and " +
|
|
698
|
+
"section children. The content must be a valid Jx page document.",
|
|
699
|
+
parameters: {
|
|
700
|
+
type: "object",
|
|
701
|
+
properties: {
|
|
702
|
+
path: {
|
|
703
|
+
type: "string",
|
|
704
|
+
description: 'File path relative to the project root, e.g. "pages/about.json".',
|
|
705
|
+
},
|
|
706
|
+
content: {
|
|
707
|
+
type: "object",
|
|
708
|
+
description:
|
|
709
|
+
"The complete Jx page JSON (typically includes $elements to import a layout, children for sections).",
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
required: ["path", "content"],
|
|
713
|
+
},
|
|
714
|
+
async execute(args) {
|
|
715
|
+
if (!saveFile) {
|
|
716
|
+
return {
|
|
717
|
+
success: false,
|
|
718
|
+
error: "File operations are not available in this environment.",
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
const { path: relPath, content } = args as { path: string; content: object };
|
|
722
|
+
const errors = await validate(content);
|
|
723
|
+
if (errors.length > 0) {
|
|
724
|
+
const formatted = errors.map((e) => `- ${translateValidationError(e)}`).join("\n");
|
|
725
|
+
return {
|
|
726
|
+
success: false,
|
|
727
|
+
error: `Page content has schema errors. Fix these before creating the file:\n${formatted}`,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
if (renderCheck) {
|
|
731
|
+
const renderResult = await renderCheck(content);
|
|
732
|
+
if (!renderResult.ok) {
|
|
733
|
+
return {
|
|
734
|
+
success: false,
|
|
735
|
+
error: `Page is schema-valid but fails to render. Fix before creating:\n- ${renderResult.error}`,
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
try {
|
|
740
|
+
await saveFile(relPath, JSON.stringify(content, null, 2));
|
|
741
|
+
return { success: true, summary: `Created page at "${relPath}".` };
|
|
742
|
+
} catch (error) {
|
|
743
|
+
return {
|
|
744
|
+
success: false,
|
|
745
|
+
error: `Failed to write file: ${error instanceof Error ? error.message : String(error)}`,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
}),
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
// ── open_document ─────────────────────────────────────────────────────
|
|
753
|
+
|
|
754
|
+
registry.register(
|
|
755
|
+
createToolDefinition({
|
|
756
|
+
name: "open_document",
|
|
757
|
+
description:
|
|
758
|
+
"Switch the active document to another file in the project. After opening, all " +
|
|
759
|
+
"tools (read_document, set_property, add_child, etc.) operate on the newly-active " +
|
|
760
|
+
"document. Use this to iteratively refine pages or components after creating them " +
|
|
761
|
+
"with create_page or create_component.",
|
|
762
|
+
parameters: {
|
|
763
|
+
type: "object",
|
|
764
|
+
properties: {
|
|
765
|
+
path: {
|
|
766
|
+
type: "string",
|
|
767
|
+
description:
|
|
768
|
+
'File path relative to the project root, e.g. "pages/about.json" or ' +
|
|
769
|
+
'"components/nav-bar.json". Must be an existing file.',
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
required: ["path"],
|
|
773
|
+
},
|
|
774
|
+
async execute(args) {
|
|
775
|
+
if (!openDocument) {
|
|
776
|
+
return {
|
|
777
|
+
success: false,
|
|
778
|
+
error: "File navigation is not available in this environment.",
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
const { path: relPath } = args as { path: string };
|
|
782
|
+
try {
|
|
783
|
+
await openDocument(relPath);
|
|
784
|
+
const tab = getTab();
|
|
785
|
+
if (!tab) {
|
|
786
|
+
return {
|
|
787
|
+
success: false,
|
|
788
|
+
error: `File "${relPath}" could not be opened — no active tab after navigation.`,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/*
|
|
792
|
+
* The agent loop opens a single undo batch on the tab that was active at loop start
|
|
793
|
+
* (tool-executor.js → beginBatch). Switching the active document mid-loop would strand
|
|
794
|
+
* the new tab's edits with no history snapshot — undo would have nothing to roll back.
|
|
795
|
+
* Flush the previous tab's batch and re-open one on the newly-active tab so edits in
|
|
796
|
+
* each document remain individually undoable.
|
|
797
|
+
*/
|
|
798
|
+
if (isBatching()) {
|
|
799
|
+
endBatch();
|
|
800
|
+
beginBatch(tab);
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
success: true,
|
|
804
|
+
summary: `Switched to "${relPath}". All tools now operate on this document.`,
|
|
805
|
+
};
|
|
806
|
+
} catch (error) {
|
|
807
|
+
return {
|
|
808
|
+
success: false,
|
|
809
|
+
error: `Failed to open "${relPath}": ${error instanceof Error ? error.message : String(error)}`,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
},
|
|
813
|
+
}),
|
|
814
|
+
);
|
|
815
|
+
|
|
816
|
+
registry.register(
|
|
817
|
+
createToolDefinition({
|
|
818
|
+
name: "remove_node",
|
|
819
|
+
description: "Remove the node at the given path from its parent's children array.",
|
|
820
|
+
parameters: {
|
|
821
|
+
type: "object",
|
|
822
|
+
properties: {
|
|
823
|
+
path: {
|
|
824
|
+
type: "array",
|
|
825
|
+
description: `${PATH_DESCRIPTION} Cannot be the document root.`,
|
|
826
|
+
items: { type: ["string", "number"] },
|
|
827
|
+
},
|
|
828
|
+
},
|
|
829
|
+
required: ["path"],
|
|
830
|
+
},
|
|
831
|
+
async execute(args) {
|
|
832
|
+
const tab = getTab();
|
|
833
|
+
if (!tab) {
|
|
834
|
+
return { success: false, error: "No document is open." };
|
|
835
|
+
}
|
|
836
|
+
const { path } = args as { path: JxPath };
|
|
837
|
+
if (path.length < 2) {
|
|
838
|
+
return { success: false, error: "Cannot remove the document root." };
|
|
839
|
+
}
|
|
840
|
+
if (getNodeAtPath(tab.doc.document, path) === undefined) {
|
|
841
|
+
return { success: false, error: `No node exists at path ${JSON.stringify(path)}.` };
|
|
842
|
+
}
|
|
843
|
+
return applyAndValidate(
|
|
844
|
+
tab,
|
|
845
|
+
(t) => mutateRemoveNode(t, path),
|
|
846
|
+
`Removed node at ${JSON.stringify(path)}.`,
|
|
847
|
+
validate,
|
|
848
|
+
renderCheck,
|
|
849
|
+
projectStyle,
|
|
850
|
+
);
|
|
851
|
+
},
|
|
852
|
+
}),
|
|
853
|
+
);
|
|
854
|
+
}
|