@saltcorn/copilot 0.7.3 → 0.7.5
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/agent-skills/pagegen.js +0 -10
- package/agent-skills/viewgen.js +229 -0
- package/builder-gen.js +1037 -24
- package/builder-schema.js +686 -0
- package/copilot-as-agent.js +1 -0
- package/index.js +2 -0
- package/package.json +1 -1
- package/standard-prompt.js +83 -0
package/builder-gen.js
CHANGED
|
@@ -1,33 +1,1045 @@
|
|
|
1
1
|
const { getState } = require("@saltcorn/data/db/state");
|
|
2
|
-
const WorkflowStep = require("@saltcorn/data/models/workflow_step");
|
|
3
|
-
const Trigger = require("@saltcorn/data/models/trigger");
|
|
4
2
|
const Table = require("@saltcorn/data/models/table");
|
|
5
|
-
const
|
|
3
|
+
const Trigger = require("@saltcorn/data/models/trigger");
|
|
4
|
+
const View = require("@saltcorn/data/models/view");
|
|
5
|
+
const { edit_build_in_actions } = require("@saltcorn/data/viewable_fields");
|
|
6
|
+
const { buildBuilderSchema } = require("./builder-schema");
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
8
|
+
const ACTION_SIZES = ["btn-sm", "btn-lg"];
|
|
9
|
+
const MODE_GUIDANCE = {
|
|
10
|
+
edit: "Layout is a form for editing a single row. Include required inputs with edit fieldviews, group related inputs, and finish with a Save action.",
|
|
11
|
+
show: "Layout displays one record read-only. Use show fieldviews, blank headings, and optional follow-up actions.",
|
|
12
|
+
list: "Layout represents a single row in a list. Highlight key fields, keep actions compact, and support filtering if requested.",
|
|
13
|
+
filter:
|
|
14
|
+
"Layout lets users define filters. Provide appropriate filter inputs plus an action to run or reset filters.",
|
|
15
|
+
page: "Layout builds a general app page. Combine hero text, cards, containers, and call-to-action buttons.",
|
|
16
|
+
default:
|
|
17
|
+
"Use Saltcorn layout primitives (above, besides, container, card, tabs, blank, field, action, view_link, view). Do not return HTML snippets.",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const stripCodeFences = (text) => text.replace(/```(?:json)?/gi, "").trim();
|
|
21
|
+
|
|
22
|
+
const stripHtmlTags = (text) =>
|
|
23
|
+
String(text || "")
|
|
24
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
25
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
26
|
+
.replace(/<[^>]+>/g, " ")
|
|
27
|
+
.replace(/\s+/g, " ")
|
|
28
|
+
.trim();
|
|
29
|
+
|
|
30
|
+
const attrValue = (node, key) => {
|
|
31
|
+
if (!node) return undefined;
|
|
32
|
+
if (Object.prototype.hasOwnProperty.call(node, key)) return node[key];
|
|
33
|
+
if (
|
|
34
|
+
node.attributes &&
|
|
35
|
+
Object.prototype.hasOwnProperty.call(node.attributes, key)
|
|
36
|
+
)
|
|
37
|
+
return node.attributes[key];
|
|
38
|
+
return undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const pickAttrValue = (node, keys) => {
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
const val = attrValue(node, key);
|
|
44
|
+
if (val !== undefined) return val;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const firstItem = (value) => (Array.isArray(value) ? value[0] : value);
|
|
50
|
+
|
|
51
|
+
const findBalancedBlock = (text, openChar, closeChar) => {
|
|
52
|
+
const start = text.indexOf(openChar);
|
|
53
|
+
if (start === -1) return null;
|
|
54
|
+
let depth = 0;
|
|
55
|
+
let inString = false;
|
|
56
|
+
let escape = false;
|
|
57
|
+
for (let i = start; i < text.length; i++) {
|
|
58
|
+
const ch = text[i];
|
|
59
|
+
if (inString) {
|
|
60
|
+
if (escape) escape = false;
|
|
61
|
+
else if (ch === "\\") escape = true;
|
|
62
|
+
else if (ch === '"') inString = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (ch === '"') {
|
|
66
|
+
inString = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (ch === openChar) depth++;
|
|
70
|
+
else if (ch === closeChar) {
|
|
71
|
+
depth--;
|
|
72
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const extractJsonStructure = (text) => {
|
|
79
|
+
if (!text) return null;
|
|
80
|
+
const cleaned = stripCodeFences(String(text));
|
|
81
|
+
const attempt = (candidate) => {
|
|
82
|
+
if (!candidate) return null;
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(candidate);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const trimmed = cleaned.trim();
|
|
91
|
+
let parsed = attempt(trimmed);
|
|
92
|
+
if (parsed) return parsed;
|
|
93
|
+
|
|
94
|
+
const eqIdx = trimmed.indexOf("=");
|
|
95
|
+
if (eqIdx !== -1) {
|
|
96
|
+
parsed = attempt(trimmed.slice(eqIdx + 1).trim());
|
|
97
|
+
if (parsed) return parsed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const arrayBlock = findBalancedBlock(trimmed, "[", "]");
|
|
101
|
+
if (arrayBlock) {
|
|
102
|
+
parsed = attempt(arrayBlock);
|
|
103
|
+
if (parsed) return parsed;
|
|
104
|
+
}
|
|
105
|
+
const objectBlock = findBalancedBlock(trimmed, "{", "}");
|
|
106
|
+
if (objectBlock) {
|
|
107
|
+
parsed = attempt(objectBlock);
|
|
108
|
+
if (parsed) return parsed;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const getSchemaByRef = (root, ref) => {
|
|
114
|
+
if (!ref || typeof ref !== "string" || !ref.startsWith("#/")) return null;
|
|
115
|
+
const parts = ref
|
|
116
|
+
.slice(2)
|
|
117
|
+
.split("/")
|
|
118
|
+
.map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
119
|
+
let current = root;
|
|
120
|
+
for (const part of parts) {
|
|
121
|
+
if (!current || typeof current !== "object") return null;
|
|
122
|
+
current = current[part];
|
|
123
|
+
}
|
|
124
|
+
return current || null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const validateSchemaRefs = (schema) => {
|
|
128
|
+
const missing = [];
|
|
129
|
+
const walk = (node) => {
|
|
130
|
+
if (!node || typeof node !== "object") return;
|
|
131
|
+
if (Array.isArray(node)) {
|
|
132
|
+
node.forEach((item) => walk(item));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (typeof node.$ref === "string") {
|
|
136
|
+
const target = getSchemaByRef(schema, node.$ref);
|
|
137
|
+
if (!target) missing.push(node.$ref);
|
|
138
|
+
}
|
|
139
|
+
for (const key of Object.keys(node)) walk(node[key]);
|
|
140
|
+
};
|
|
141
|
+
walk(schema);
|
|
142
|
+
if (missing.length) {
|
|
143
|
+
throw new Error(`Schema $ref targets not found: ${missing.join(", ")}`);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const schemaHasRef = (schema) => {
|
|
148
|
+
let found = false;
|
|
149
|
+
const walk = (node) => {
|
|
150
|
+
if (found || !node || typeof node !== "object") return;
|
|
151
|
+
if (Array.isArray(node)) {
|
|
152
|
+
node.forEach((item) => walk(item));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (typeof node.$ref === "string") {
|
|
156
|
+
found = true;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
Object.keys(node).forEach((key) => walk(node[key]));
|
|
160
|
+
};
|
|
161
|
+
walk(schema);
|
|
162
|
+
return found;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const derefSchema = (schema, maxDepth = 3) => {
|
|
166
|
+
const root = JSON.parse(JSON.stringify(schema));
|
|
167
|
+
const expand = (node, stack) => {
|
|
168
|
+
if (!node || typeof node !== "object") return node;
|
|
169
|
+
if (Array.isArray(node)) return node.map((item) => expand(item, stack));
|
|
170
|
+
if (typeof node.$ref === "string") {
|
|
171
|
+
if (stack.length >= maxDepth) {
|
|
172
|
+
return { $ref: node.$ref };
|
|
173
|
+
}
|
|
174
|
+
if (stack.includes(node.$ref)) {
|
|
175
|
+
return { $ref: node.$ref };
|
|
176
|
+
}
|
|
177
|
+
const target = getSchemaByRef(root, node.$ref);
|
|
178
|
+
if (!target) throw new Error(`Schema $ref not found: ${node.$ref}`);
|
|
179
|
+
return expand({ ...target }, [...stack, node.$ref]);
|
|
180
|
+
}
|
|
181
|
+
const out = {};
|
|
182
|
+
for (const key of Object.keys(node)) {
|
|
183
|
+
if (key === "$ref") continue;
|
|
184
|
+
out[key] = expand(node[key], stack);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
};
|
|
188
|
+
return expand(root, []);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const randomId = () =>
|
|
192
|
+
Math.floor(Math.random() * 0xffffff)
|
|
193
|
+
.toString(16)
|
|
194
|
+
.padStart(6, "0");
|
|
195
|
+
|
|
196
|
+
const ensureArray = (value) =>
|
|
197
|
+
Array.isArray(value) ? value : value == null ? [] : [value];
|
|
198
|
+
|
|
199
|
+
const prettifyActionName = (name) =>
|
|
200
|
+
(name || "")
|
|
201
|
+
.replace(/_/g, " ")
|
|
202
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
203
|
+
.replace(/\s+/g, " ")
|
|
204
|
+
.trim();
|
|
205
|
+
|
|
206
|
+
// Picks a valid fieldview from the field's available fieldviews only.
|
|
207
|
+
// Never returns a fieldview that doesn't exist in field.fieldviews
|
|
208
|
+
const pickFieldview = (field, mode, requestedFieldview = null) => {
|
|
209
|
+
const availableViews = field?.fieldviews || [];
|
|
210
|
+
|
|
211
|
+
// If no available fieldviews, return the first one or a safe default
|
|
212
|
+
if (!availableViews.length) {
|
|
213
|
+
// Return the first available or fall back based on mode
|
|
214
|
+
return mode === "edit" || mode === "filter" ? "edit" : "show";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Helper to validate and return a fieldview only if it exists
|
|
218
|
+
const validateAndReturn = (candidate) => {
|
|
219
|
+
if (!candidate) return null;
|
|
220
|
+
const lower = String(candidate).toLowerCase();
|
|
221
|
+
// Exact match
|
|
222
|
+
const exact = availableViews.find(
|
|
223
|
+
(fv) => String(fv).toLowerCase() === lower,
|
|
224
|
+
);
|
|
225
|
+
if (exact) return exact;
|
|
226
|
+
// Fuzzy match (contains)
|
|
227
|
+
const fuzzy = availableViews.find((fv) =>
|
|
228
|
+
String(fv).toLowerCase().includes(lower),
|
|
229
|
+
);
|
|
230
|
+
if (fuzzy) return fuzzy;
|
|
231
|
+
return null;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// If a specific fieldview was requested by the user, try to honor it
|
|
235
|
+
// but ONLY if it actually exists in available views
|
|
236
|
+
if (requestedFieldview) {
|
|
237
|
+
const validated = validateAndReturn(requestedFieldview);
|
|
238
|
+
if (validated) return validated;
|
|
239
|
+
// Requested fieldview not available for this field - fall through to defaults
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Get the field's configured default fieldview
|
|
243
|
+
const defaultFieldview =
|
|
244
|
+
field?.default_fieldview || field?.defaultFieldview || field?.fieldview;
|
|
245
|
+
|
|
246
|
+
if (defaultFieldview) {
|
|
247
|
+
const validated = validateAndReturn(defaultFieldview);
|
|
248
|
+
if (validated) return validated;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Mode-based selection from available fieldviews
|
|
252
|
+
if (mode === "show" || mode === "list") {
|
|
253
|
+
// For show mode, prefer simple text-based views, but only from available views
|
|
254
|
+
const showPreferences = ["as_text", "show", "as_string", "text", "showas"];
|
|
255
|
+
for (const pref of showPreferences) {
|
|
256
|
+
const match = availableViews.find((fv) =>
|
|
257
|
+
String(fv).toLowerCase().includes(pref),
|
|
258
|
+
);
|
|
259
|
+
if (match) return match;
|
|
260
|
+
}
|
|
261
|
+
} else if (mode === "edit" || mode === "filter") {
|
|
262
|
+
// For edit mode, prefer edit-capable fieldviews from available views
|
|
263
|
+
const editPreferences = ["edit", "input", "select", "textarea"];
|
|
264
|
+
for (const pref of editPreferences) {
|
|
265
|
+
const match = availableViews.find((fv) =>
|
|
266
|
+
String(fv).toLowerCase().includes(pref),
|
|
267
|
+
);
|
|
268
|
+
if (match) return match;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Fall back to first available fieldview - this is always valid
|
|
273
|
+
return availableViews[0];
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const evenWidths = (count) => {
|
|
277
|
+
if (!count) return [];
|
|
278
|
+
const widths = Array(count).fill(Math.max(1, Math.floor(12 / count)));
|
|
279
|
+
let total = widths.reduce((sum, n) => sum + n, 0);
|
|
280
|
+
let idx = 0;
|
|
281
|
+
while (total < 12) {
|
|
282
|
+
widths[idx] += 1;
|
|
283
|
+
total += 1;
|
|
284
|
+
idx = (idx + 1) % count;
|
|
285
|
+
}
|
|
286
|
+
while (total > 12 && widths.some((w) => w > 1)) {
|
|
287
|
+
if (widths[idx] > 1) {
|
|
288
|
+
widths[idx] -= 1;
|
|
289
|
+
total -= 1;
|
|
290
|
+
}
|
|
291
|
+
idx = (idx + 1) % count;
|
|
292
|
+
}
|
|
293
|
+
return widths;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const normalizeWidths = (current, count) => {
|
|
297
|
+
if (!count) return [];
|
|
298
|
+
if (Array.isArray(current) && current.length === count) {
|
|
299
|
+
const sanitized = current.map((val) => {
|
|
300
|
+
const num = Number(val);
|
|
301
|
+
return Number.isFinite(num) && num > 0
|
|
302
|
+
? Math.min(12, Math.round(num))
|
|
303
|
+
: null;
|
|
304
|
+
});
|
|
305
|
+
if (sanitized.every((n) => n && n > 0)) {
|
|
306
|
+
const total = sanitized.reduce((sum, n) => sum + n, 0);
|
|
307
|
+
if (total === 12) return sanitized;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return evenWidths(count);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const parseJsonPayload = (raw) => {
|
|
314
|
+
if (raw == null) throw new Error("Empty response from LLM");
|
|
315
|
+
if (typeof raw === "object") return raw;
|
|
316
|
+
const cleaned = stripCodeFences(String(raw));
|
|
317
|
+
const extracted = extractJsonStructure(cleaned);
|
|
318
|
+
if (extracted) return extracted;
|
|
319
|
+
throw new Error("Could not parse JSON payload from LLM response");
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const normalizeChild = (value, ctx) => {
|
|
323
|
+
if (value == null) return null;
|
|
324
|
+
if (typeof value === "string") return { type: "blank", contents: value };
|
|
325
|
+
return normalizeSegment(value, ctx);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const normalizeTabs = (tabs, ctx) =>
|
|
329
|
+
ensureArray(tabs)
|
|
330
|
+
.map((tab) => ({ ...tab, contents: normalizeChild(tab?.contents, ctx) }))
|
|
331
|
+
.filter((tab) => tab?.title && tab.contents)
|
|
332
|
+
.map((tab) => ({ ...tab, class: tab.class || "" }));
|
|
333
|
+
|
|
334
|
+
const normalizeSegment = (segment, ctx) => {
|
|
335
|
+
if (segment == null) return null;
|
|
336
|
+
if (typeof segment === "string") return { type: "blank", contents: segment };
|
|
337
|
+
if (Array.isArray(segment)) {
|
|
338
|
+
const arr = segment
|
|
339
|
+
.map((child) => normalizeSegment(child, ctx))
|
|
340
|
+
.filter(Boolean);
|
|
341
|
+
return arr.length ? arr : null;
|
|
342
|
+
}
|
|
343
|
+
if (typeof segment !== "object") return null;
|
|
344
|
+
|
|
345
|
+
const clone = { ...segment };
|
|
346
|
+
if (clone.type === "prompt") return null;
|
|
347
|
+
|
|
348
|
+
if (!clone.type && clone.above) {
|
|
349
|
+
const above = ensureArray(clone.above)
|
|
350
|
+
.map((child) => normalizeSegment(child, ctx))
|
|
351
|
+
.filter(Boolean);
|
|
352
|
+
return above.length ? { ...clone, above } : null;
|
|
353
|
+
}
|
|
354
|
+
if (!clone.type && clone.besides) {
|
|
355
|
+
const besides = ensureArray(clone.besides).map((child) =>
|
|
356
|
+
child == null ? null : normalizeSegment(child, ctx),
|
|
21
357
|
);
|
|
22
|
-
|
|
23
|
-
? str.split("```html")[1].split("```")[0]
|
|
24
|
-
: str;
|
|
358
|
+
if (!besides.some((child) => child)) return null;
|
|
25
359
|
return {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
text_strings: [],
|
|
360
|
+
...clone,
|
|
361
|
+
besides,
|
|
362
|
+
widths: normalizeWidths(clone.widths, besides.length),
|
|
30
363
|
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
switch (clone.type) {
|
|
367
|
+
case "container": {
|
|
368
|
+
const contents = normalizeChild(clone.contents, ctx);
|
|
369
|
+
return contents
|
|
370
|
+
? {
|
|
371
|
+
...clone,
|
|
372
|
+
contents,
|
|
373
|
+
class: clone.class || "",
|
|
374
|
+
customClass: clone.customClass || "",
|
|
375
|
+
}
|
|
376
|
+
: null;
|
|
377
|
+
}
|
|
378
|
+
case "card": {
|
|
379
|
+
const contents = normalizeChild(clone.contents, ctx);
|
|
380
|
+
return contents
|
|
381
|
+
? {
|
|
382
|
+
...clone,
|
|
383
|
+
contents,
|
|
384
|
+
title: clone.title || "",
|
|
385
|
+
class: clone.class || "",
|
|
386
|
+
}
|
|
387
|
+
: null;
|
|
388
|
+
}
|
|
389
|
+
case "tabs": {
|
|
390
|
+
const tabs = normalizeTabs(clone.tabs, ctx);
|
|
391
|
+
return tabs.length ? { ...clone, tabs, class: clone.class || "" } : null;
|
|
392
|
+
}
|
|
393
|
+
case "blank":
|
|
394
|
+
return {
|
|
395
|
+
...clone,
|
|
396
|
+
contents: typeof clone.contents === "string" ? clone.contents : "",
|
|
397
|
+
class: clone.class || "",
|
|
398
|
+
};
|
|
399
|
+
case "line_break":
|
|
400
|
+
return { type: "line_break", class: clone.class || "" };
|
|
401
|
+
case "image":
|
|
402
|
+
return clone.url || clone.src
|
|
403
|
+
? {
|
|
404
|
+
...clone,
|
|
405
|
+
url: clone.url || clone.src || "",
|
|
406
|
+
alt: clone.alt || "",
|
|
407
|
+
class: clone.class || "",
|
|
408
|
+
}
|
|
409
|
+
: null;
|
|
410
|
+
case "link":
|
|
411
|
+
return clone.url
|
|
412
|
+
? {
|
|
413
|
+
...clone,
|
|
414
|
+
text: clone.text || clone.url,
|
|
415
|
+
link_style: clone.link_style || "",
|
|
416
|
+
class: clone.class || "",
|
|
417
|
+
}
|
|
418
|
+
: null;
|
|
419
|
+
case "search_bar":
|
|
420
|
+
return { ...clone, class: clone.class || "" };
|
|
421
|
+
case "view":
|
|
422
|
+
if (!ctx.viewNames.length) return null;
|
|
423
|
+
return {
|
|
424
|
+
...clone,
|
|
425
|
+
view: ctx.viewNames.includes(clone.view)
|
|
426
|
+
? clone.view
|
|
427
|
+
: ctx.viewNames[0],
|
|
428
|
+
state: clone.state || {},
|
|
429
|
+
class: clone.class || "",
|
|
430
|
+
};
|
|
431
|
+
case "view_link":
|
|
432
|
+
if (!ctx.viewNames.length) return null;
|
|
433
|
+
return {
|
|
434
|
+
...clone,
|
|
435
|
+
view: ctx.viewNames.includes(clone.view)
|
|
436
|
+
? clone.view
|
|
437
|
+
: ctx.viewNames[0],
|
|
438
|
+
view_label: clone.view_label || clone.view,
|
|
439
|
+
link_style: clone.link_style || "",
|
|
440
|
+
class: clone.class || "",
|
|
441
|
+
};
|
|
442
|
+
case "field": {
|
|
443
|
+
if (!ctx.fields.length) return null;
|
|
444
|
+
const fieldMeta = ctx.fieldMap[clone.field_name] || ctx.fields[0];
|
|
445
|
+
// Use pickFieldview which validates that the fieldview exists in fieldMeta.fieldviews
|
|
446
|
+
// If clone.fieldview is invalid, pickFieldview will return a valid alternative
|
|
447
|
+
const validFieldview = pickFieldview(
|
|
448
|
+
fieldMeta,
|
|
449
|
+
ctx.mode,
|
|
450
|
+
clone.fieldview,
|
|
451
|
+
);
|
|
452
|
+
return {
|
|
453
|
+
...clone,
|
|
454
|
+
field_name: fieldMeta.name,
|
|
455
|
+
fieldview: validFieldview,
|
|
456
|
+
configuration: clone.configuration || {},
|
|
457
|
+
class: clone.class || "",
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
case "action": {
|
|
461
|
+
if (!ctx.actions.length) return null;
|
|
462
|
+
const actionName = ctx.actions.includes(clone.action_name)
|
|
463
|
+
? clone.action_name
|
|
464
|
+
: ctx.actions[0];
|
|
465
|
+
return {
|
|
466
|
+
...clone,
|
|
467
|
+
action_name: actionName,
|
|
468
|
+
action_label: clone.action_label || prettifyActionName(actionName),
|
|
469
|
+
action_style: clone.action_style || "btn-primary",
|
|
470
|
+
action_size: ACTION_SIZES.includes(clone.action_size)
|
|
471
|
+
? clone.action_size
|
|
472
|
+
: undefined,
|
|
473
|
+
rndid: clone.rndid || randomId(),
|
|
474
|
+
minRole: clone.minRole || 100,
|
|
475
|
+
nsteps: clone.nsteps || 1,
|
|
476
|
+
isFormula: clone.isFormula || {},
|
|
477
|
+
configuration: clone.configuration || {},
|
|
478
|
+
class: clone.class || "",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
default: {
|
|
482
|
+
if (clone.children) {
|
|
483
|
+
const childSegments = ensureArray(clone.children)
|
|
484
|
+
.map((child) => normalizeSegment(child, ctx))
|
|
485
|
+
.filter(Boolean);
|
|
486
|
+
if (childSegments.length === 1) return childSegments[0];
|
|
487
|
+
if (childSegments.length > 1) return { above: childSegments };
|
|
488
|
+
}
|
|
489
|
+
if (clone.contents) {
|
|
490
|
+
const contents = normalizeChild(clone.contents, ctx);
|
|
491
|
+
return contents ? { ...clone, contents } : null;
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const collectSegments = (segment, out = []) => {
|
|
499
|
+
if (segment == null) return out;
|
|
500
|
+
if (Array.isArray(segment)) {
|
|
501
|
+
segment.forEach((s) => collectSegments(s, out));
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
if (typeof segment !== "object") return out;
|
|
505
|
+
out.push(segment);
|
|
506
|
+
if (segment.above) collectSegments(segment.above, out);
|
|
507
|
+
if (segment.besides) collectSegments(segment.besides, out);
|
|
508
|
+
if (segment.contents) collectSegments(segment.contents, out);
|
|
509
|
+
if (segment.tabs) {
|
|
510
|
+
ensureArray(segment.tabs).forEach((tab) =>
|
|
511
|
+
collectSegments(tab.contents, out),
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const sanitizeNoHtmlSegments = (segment) => {
|
|
518
|
+
if (segment == null) return segment;
|
|
519
|
+
if (Array.isArray(segment))
|
|
520
|
+
return segment
|
|
521
|
+
.map((child) => sanitizeNoHtmlSegments(child))
|
|
522
|
+
.filter(Boolean);
|
|
523
|
+
if (typeof segment !== "object") return segment;
|
|
524
|
+
|
|
525
|
+
const clone = { ...segment };
|
|
526
|
+
if (clone.type === "blank") {
|
|
527
|
+
const usedHtml = !!clone.isHTML;
|
|
528
|
+
delete clone.isHTML;
|
|
529
|
+
delete clone.text_strings;
|
|
530
|
+
if (usedHtml && typeof clone.contents === "string") {
|
|
531
|
+
clone.contents = stripHtmlTags(clone.contents);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (clone.contents !== undefined)
|
|
536
|
+
clone.contents = sanitizeNoHtmlSegments(clone.contents);
|
|
537
|
+
if (clone.above !== undefined)
|
|
538
|
+
clone.above = sanitizeNoHtmlSegments(clone.above);
|
|
539
|
+
if (clone.besides !== undefined)
|
|
540
|
+
clone.besides = sanitizeNoHtmlSegments(clone.besides);
|
|
541
|
+
if (clone.tabs !== undefined)
|
|
542
|
+
clone.tabs = ensureArray(clone.tabs)
|
|
543
|
+
.map((tab) => ({
|
|
544
|
+
...tab,
|
|
545
|
+
contents: sanitizeNoHtmlSegments(tab?.contents),
|
|
546
|
+
}))
|
|
547
|
+
.filter((tab) => tab?.contents);
|
|
548
|
+
return clone;
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const truncateText = (value, maxLen) => {
|
|
552
|
+
const str = String(value ?? "");
|
|
553
|
+
if (str.length <= maxLen) return str;
|
|
554
|
+
return `${str.slice(0, maxLen)}\n...truncated...`;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const normalizeLayoutCandidate = (candidate, ctx) => {
|
|
558
|
+
let normalized = normalizeSegment(candidate, ctx);
|
|
559
|
+
if (!normalized) {
|
|
560
|
+
normalized = convertForeignLayout(candidate, ctx);
|
|
561
|
+
}
|
|
562
|
+
if (!normalized) throw new Error("Empty layout after normalization");
|
|
563
|
+
const layout = Array.isArray(normalized) ? { above: normalized } : normalized;
|
|
564
|
+
return sanitizeNoHtmlSegments(layout);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
const buildPromptText = (userPrompt, ctx, schema) => {
|
|
568
|
+
const parts = [
|
|
569
|
+
`You are an expert Saltcorn layout builder assistant. Your task is to generate a layout for mode "${ctx.mode}" that precisely fulfills the user's request.`,
|
|
570
|
+
'CRITICAL: You must return ONLY a single valid JSON object. Do not include introductory text, explanations, markdown formatting (like ```json), or any pseudo-markup. The output must strictly follow this shape: {"layout": <layout-object>}.',
|
|
571
|
+
'The "layout" object MUST conform entirely to the provided JSON Schema. Do not invent properties, types, or structure not defined in the schema.',
|
|
572
|
+
];
|
|
573
|
+
parts.push(
|
|
574
|
+
"When a card or container background is requested, set bgType explicitly (None, Color, Image, or Gradient). For Color use bgColor, for Image use bgFileId plus imageLocation (Top, Card, Body) and optionally imageSize (cover, contain, repeat using cover as default) when location is Card or Body, and for Gradient use gradStartColor, gradEndColor, and numeric gradDirection. Use hex color codes when specifying colors.",
|
|
575
|
+
);
|
|
576
|
+
parts.push(
|
|
577
|
+
`Here is the strict Saltcorn layout JSON schema you MUST follow to construct the layout. Do not deviate from these definitions:\n${JSON.stringify(schema)}`,
|
|
578
|
+
);
|
|
579
|
+
parts.push(
|
|
580
|
+
`Based on the schema above, process the following user request and generate the layout JSON. Reminder: ONLY output valid JSON starting with { and ending with }, no markdown fences.\nUser request:\n"${userPrompt}"`,
|
|
581
|
+
);
|
|
582
|
+
return parts.join("\n\n");
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const buildRepairPrompt = (rawOutput, schema) => {
|
|
586
|
+
const cleaned = truncateText(rawOutput, 6000);
|
|
587
|
+
return [
|
|
588
|
+
"You are repairing a JSON payload for Saltcorn. Return ONLY valid JSON.",
|
|
589
|
+
"Do not add explanations, markdown, or code fences.",
|
|
590
|
+
"If the JSON is incomplete, finish it. If it has extra text, remove it.",
|
|
591
|
+
`Schema (must conform):\n${JSON.stringify(schema)}`,
|
|
592
|
+
`Invalid output to fix:\n${cleaned}`,
|
|
593
|
+
].join("\n\n");
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
const buildReducedPrompt = (userPrompt, ctx, schema) => {
|
|
597
|
+
const limitedPrompt = `${userPrompt}\n\nLimit the layout to at most 4 containers/cards and no more than 2 levels of nesting. Keep the output compact.`;
|
|
598
|
+
return buildPromptText(limitedPrompt, ctx, schema);
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const convertChildList = (children, ctx) => {
|
|
602
|
+
const segments = ensureArray(children)
|
|
603
|
+
.map((child) => convertForeignLayout(child, ctx))
|
|
604
|
+
.filter(Boolean);
|
|
605
|
+
if (!segments.length) return null;
|
|
606
|
+
if (segments.length === 1) return segments[0];
|
|
607
|
+
return { above: segments };
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const convertChildrenArray = (children, ctx) =>
|
|
611
|
+
ensureArray(children)
|
|
612
|
+
.map((child) => convertForeignLayout(child, ctx))
|
|
613
|
+
.filter(Boolean);
|
|
614
|
+
|
|
615
|
+
const convertForeignField = (node, ctx) => {
|
|
616
|
+
const fieldName = pickAttrValue(node, ["field", "field_name", "name"]);
|
|
617
|
+
if (!fieldName && !ctx.fields.length) return null;
|
|
618
|
+
const fieldMeta = ctx.fieldMap[fieldName] || ctx.fields[0];
|
|
619
|
+
if (!fieldMeta) return null;
|
|
620
|
+
let userView = pickAttrValue(node, ["fieldview", "view"]);
|
|
621
|
+
const viewsAttr = attrValue(node, "views");
|
|
622
|
+
if (!userView && viewsAttr !== undefined) userView = firstItem(viewsAttr);
|
|
623
|
+
const typeHint = attrValue(node, "type");
|
|
624
|
+
if (
|
|
625
|
+
!userView &&
|
|
626
|
+
typeof typeHint === "string" &&
|
|
627
|
+
typeHint.toLowerCase() === "textarea"
|
|
628
|
+
) {
|
|
629
|
+
userView = "textarea";
|
|
630
|
+
}
|
|
631
|
+
// Use pickFieldview to validate userView against field's available fieldviews
|
|
632
|
+
const validFieldview = pickFieldview(fieldMeta, ctx.mode, userView);
|
|
633
|
+
return {
|
|
634
|
+
type: "field",
|
|
635
|
+
field_name: fieldMeta.name,
|
|
636
|
+
fieldview: validFieldview,
|
|
637
|
+
configuration: node.configuration || {},
|
|
638
|
+
};
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const convertForeignAction = (node, ctx) => {
|
|
642
|
+
const actionName =
|
|
643
|
+
pickAttrValue(node, ["action", "action_name", "name"]) || ctx.actions[0];
|
|
644
|
+
if (!actionName) return null;
|
|
645
|
+
const style = pickAttrValue(node, ["style", "action_style"]);
|
|
646
|
+
const label = pickAttrValue(node, ["label", "action_label"]);
|
|
647
|
+
const size = pickAttrValue(node, ["size", "action_size"]);
|
|
648
|
+
const confirm = attrValue(node, "confirm");
|
|
649
|
+
return {
|
|
650
|
+
type: "action",
|
|
651
|
+
action_name: actionName,
|
|
652
|
+
action_label: label || prettifyActionName(actionName),
|
|
653
|
+
action_style: style || "btn-primary",
|
|
654
|
+
action_size: ACTION_SIZES.includes(size) ? size : undefined,
|
|
655
|
+
confirm,
|
|
656
|
+
rndid: randomId(),
|
|
657
|
+
minRole: 100,
|
|
658
|
+
nsteps: 1,
|
|
659
|
+
isFormula: {},
|
|
660
|
+
configuration: node.configuration || {},
|
|
661
|
+
};
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const convertForeignLayout = (node, ctx) => {
|
|
665
|
+
if (!node && node !== 0) return null;
|
|
666
|
+
if (Array.isArray(node)) {
|
|
667
|
+
const segments = node
|
|
668
|
+
.map((child) => convertForeignLayout(child, ctx))
|
|
669
|
+
.filter(Boolean);
|
|
670
|
+
if (!segments.length) return null;
|
|
671
|
+
if (segments.length === 1) return segments[0];
|
|
672
|
+
return { above: segments };
|
|
673
|
+
}
|
|
674
|
+
if (typeof node === "string") return { type: "blank", contents: node };
|
|
675
|
+
if (typeof node !== "object") return null;
|
|
676
|
+
if (node.layout) return convertForeignLayout(node.layout, ctx);
|
|
677
|
+
|
|
678
|
+
const type = node.type || node.kind;
|
|
679
|
+
switch (type) {
|
|
680
|
+
case "container": {
|
|
681
|
+
const contents =
|
|
682
|
+
convertForeignLayout(node.contents, ctx) ||
|
|
683
|
+
convertChildList(node.children, ctx);
|
|
684
|
+
return contents ? { type: "container", contents } : null;
|
|
685
|
+
}
|
|
686
|
+
case "card": {
|
|
687
|
+
const contents =
|
|
688
|
+
convertForeignLayout(node.contents, ctx) ||
|
|
689
|
+
convertChildList(node.children, ctx);
|
|
690
|
+
return contents ? { type: "card", title: node.title, contents } : null;
|
|
691
|
+
}
|
|
692
|
+
case "columns": {
|
|
693
|
+
const columns = convertChildrenArray(node.columns || node.children, ctx);
|
|
694
|
+
return columns.length
|
|
695
|
+
? {
|
|
696
|
+
besides: columns,
|
|
697
|
+
widths: normalizeWidths(node.widths, columns.length),
|
|
698
|
+
}
|
|
699
|
+
: null;
|
|
700
|
+
}
|
|
701
|
+
case "column":
|
|
702
|
+
return (
|
|
703
|
+
convertForeignLayout(node.contents, ctx) ||
|
|
704
|
+
convertChildList(node.children, ctx)
|
|
705
|
+
);
|
|
706
|
+
case "row":
|
|
707
|
+
case "section":
|
|
708
|
+
case "stack":
|
|
709
|
+
case "group":
|
|
710
|
+
case "form":
|
|
711
|
+
case "form_group":
|
|
712
|
+
case "formgroup":
|
|
713
|
+
case "form-row":
|
|
714
|
+
case "form-group":
|
|
715
|
+
return convertChildList(node.children, ctx);
|
|
716
|
+
case "tabs": {
|
|
717
|
+
const tabs = ensureArray(node.tabs || node.children)
|
|
718
|
+
.map((tab) => ({
|
|
719
|
+
title: tab.title || tab.label || "Tab",
|
|
720
|
+
contents: convertForeignLayout(tab.contents || tab.children, ctx),
|
|
721
|
+
}))
|
|
722
|
+
.filter((tab) => tab.contents);
|
|
723
|
+
return tabs.length ? { type: "tabs", tabs } : null;
|
|
724
|
+
}
|
|
725
|
+
case "actions":
|
|
726
|
+
return convertChildList(node.children, ctx);
|
|
727
|
+
case "fieldview":
|
|
728
|
+
case "field":
|
|
729
|
+
case "input":
|
|
730
|
+
case "textarea":
|
|
731
|
+
case "select":
|
|
732
|
+
return convertForeignField(node, ctx);
|
|
733
|
+
case "action":
|
|
734
|
+
case "button":
|
|
735
|
+
return convertForeignAction(node, ctx);
|
|
736
|
+
case "label":
|
|
737
|
+
case "heading":
|
|
738
|
+
case "title":
|
|
739
|
+
return {
|
|
740
|
+
type: "blank",
|
|
741
|
+
contents: node.text || node.value || node.contents || "",
|
|
742
|
+
};
|
|
743
|
+
case "text":
|
|
744
|
+
return {
|
|
745
|
+
type: "blank",
|
|
746
|
+
contents: node.text || node.value || node.contents || "",
|
|
747
|
+
};
|
|
748
|
+
case "html":
|
|
749
|
+
return {
|
|
750
|
+
type: "blank",
|
|
751
|
+
contents: stripHtmlTags(node.html || node.contents || ""),
|
|
752
|
+
};
|
|
753
|
+
case "image":
|
|
754
|
+
return {
|
|
755
|
+
type: "image",
|
|
756
|
+
url: node.url || node.src || "",
|
|
757
|
+
alt: node.alt || "",
|
|
758
|
+
};
|
|
759
|
+
default:
|
|
760
|
+
if (node.children) return convertChildList(node.children, ctx);
|
|
761
|
+
if (node.contents) return convertForeignLayout(node.contents, ctx);
|
|
762
|
+
if (node.text) return { type: "blank", contents: node.text };
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
const splitNodeText = (text) => {
|
|
768
|
+
const attrs = {};
|
|
769
|
+
const body = [];
|
|
770
|
+
(text || "")
|
|
771
|
+
.split(/\r?\n/)
|
|
772
|
+
.map((line) => line.trim())
|
|
773
|
+
.filter(Boolean)
|
|
774
|
+
.forEach((line) => {
|
|
775
|
+
const eqIdx = line.indexOf("=");
|
|
776
|
+
if (eqIdx > 0) {
|
|
777
|
+
const key = line.slice(0, eqIdx).trim();
|
|
778
|
+
const value = line.slice(eqIdx + 1).trim();
|
|
779
|
+
if (key) attrs[key] = value;
|
|
780
|
+
else body.push(line);
|
|
781
|
+
} else body.push(line);
|
|
782
|
+
});
|
|
783
|
+
return { attrs, text: body.join(" ").trim() };
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
const buildBracketObject = (node) => {
|
|
787
|
+
if (!node || !node.tag) return null;
|
|
788
|
+
const children = (node.children || [])
|
|
789
|
+
.map((child) => buildBracketObject(child))
|
|
790
|
+
.filter(Boolean);
|
|
791
|
+
const { attrs, text } = splitNodeText(node.text || "");
|
|
792
|
+
const obj = { type: node.tag };
|
|
793
|
+
if (children.length) obj.children = children;
|
|
794
|
+
if (Object.keys(attrs).length) obj.attributes = attrs;
|
|
795
|
+
if (text) obj.text = text;
|
|
796
|
+
return obj;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const buildContext = async (mode, tableName) => {
|
|
800
|
+
const normalizedMode = (mode || "show").toLowerCase();
|
|
801
|
+
const ctx = {
|
|
802
|
+
mode: normalizedMode,
|
|
803
|
+
modeGuidance: MODE_GUIDANCE[normalizedMode] || MODE_GUIDANCE.default,
|
|
804
|
+
table: null,
|
|
805
|
+
fields: [],
|
|
806
|
+
fieldMap: {},
|
|
807
|
+
actions: [],
|
|
808
|
+
viewNames: [],
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
// Global actions and views are useful even when no table is specified (page builder)
|
|
812
|
+
const stateActions = Object.keys(getState().actions || {});
|
|
813
|
+
try {
|
|
814
|
+
const allViews = await View.find();
|
|
815
|
+
ctx.viewNames = allViews.map((v) => v.name).filter(Boolean);
|
|
816
|
+
} catch (err) {
|
|
817
|
+
ctx.viewNames = [];
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (!tableName) {
|
|
821
|
+
const triggers = Trigger.find({
|
|
822
|
+
when_trigger: { or: ["API call", "Never"] },
|
|
823
|
+
}).filter((tr) => tr.name && !tr.table_id);
|
|
824
|
+
|
|
825
|
+
ctx.actions = Array.from(
|
|
826
|
+
new Set([...stateActions, ...triggers.map((tr) => tr.name)]),
|
|
827
|
+
).filter(Boolean);
|
|
828
|
+
return ctx;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const lookup =
|
|
832
|
+
typeof tableName === "number" || /^[0-9]+$/.test(String(tableName))
|
|
833
|
+
? { id: Number(tableName) }
|
|
834
|
+
: { name: tableName };
|
|
835
|
+
const table = Table.findOne(lookup);
|
|
836
|
+
if (!table) return ctx;
|
|
837
|
+
|
|
838
|
+
let rawFields = [];
|
|
839
|
+
try {
|
|
840
|
+
rawFields = table.getFields ? table.getFields() : table.fields || [];
|
|
841
|
+
} catch (err) {
|
|
842
|
+
rawFields = table.fields || [];
|
|
843
|
+
}
|
|
844
|
+
if (rawFields?.then) rawFields = await rawFields;
|
|
845
|
+
const fields = (rawFields || []).map((field) => {
|
|
846
|
+
const fieldviews = Object.keys(field.type?.fieldviews || {});
|
|
847
|
+
const isPkName =
|
|
848
|
+
table.pk_name &&
|
|
849
|
+
typeof field.name === "string" &&
|
|
850
|
+
field.name === table.pk_name;
|
|
851
|
+
|
|
852
|
+
// Capture the default fieldview from various possible sources
|
|
853
|
+
// Priority: field-level configured > field's attributes > type default
|
|
854
|
+
const defaultFieldview =
|
|
855
|
+
field.fieldview ||
|
|
856
|
+
field.default_fieldview ||
|
|
857
|
+
(field.attributes && field.attributes.fieldview) ||
|
|
858
|
+
field.type?.default_fieldview ||
|
|
859
|
+
null;
|
|
860
|
+
|
|
861
|
+
return {
|
|
862
|
+
name: field.name,
|
|
863
|
+
label: field.label || field.name,
|
|
864
|
+
type: field.type?.name || field.type || field.input_type || "String",
|
|
865
|
+
required: !!field.required,
|
|
866
|
+
primary_key: !!field.primary_key,
|
|
867
|
+
calculated: !!field.calculated,
|
|
868
|
+
is_pk_name: !!isPkName,
|
|
869
|
+
default_fieldview: defaultFieldview,
|
|
870
|
+
fieldviews: fieldviews.length ? fieldviews : ["show"],
|
|
871
|
+
};
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
const triggers = Trigger.find({
|
|
875
|
+
when_trigger: { or: ["API call", "Never"] },
|
|
876
|
+
}).filter((tr) => tr.name && (!tr.table_id || tr.table_id === table.id));
|
|
877
|
+
|
|
878
|
+
let viewNames = [];
|
|
879
|
+
try {
|
|
880
|
+
const views = await View.find_table_views_where(table.id, () => true);
|
|
881
|
+
viewNames = views.map((v) => v.name);
|
|
882
|
+
} catch (err) {
|
|
883
|
+
viewNames = [];
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
const builtIns =
|
|
887
|
+
ctx.mode === "edit" || ctx.mode === "filter"
|
|
888
|
+
? edit_build_in_actions || []
|
|
889
|
+
: ["Delete", "GoBack"];
|
|
890
|
+
const actions = Array.from(
|
|
891
|
+
new Set([...builtIns, ...stateActions, ...triggers.map((tr) => tr.name)]),
|
|
892
|
+
).filter(Boolean);
|
|
893
|
+
|
|
894
|
+
ctx.table = table;
|
|
895
|
+
ctx.fields = fields;
|
|
896
|
+
ctx.fieldMap = Object.fromEntries(fields.map((f) => [f.name, f]));
|
|
897
|
+
ctx.actions = actions;
|
|
898
|
+
ctx.viewNames = viewNames;
|
|
899
|
+
return ctx;
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
const buildErrorLayout = ({ message, mode, table }) => {
|
|
903
|
+
const trimmedMessage = String(message || "Unknown error").slice(0, 500);
|
|
904
|
+
const contextLine = table
|
|
905
|
+
? `Mode: ${mode || "show"} | Table: ${table}`
|
|
906
|
+
: `Mode: ${mode || "show"}`;
|
|
907
|
+
return {
|
|
908
|
+
above: [
|
|
909
|
+
{
|
|
910
|
+
type: "container",
|
|
911
|
+
customClass: "p-3 border rounded",
|
|
912
|
+
style: {
|
|
913
|
+
backgroundColor: "#fff3cd",
|
|
914
|
+
borderColor: "#ffecb5",
|
|
915
|
+
color: "#000000",
|
|
916
|
+
},
|
|
917
|
+
contents: {
|
|
918
|
+
above: [
|
|
919
|
+
{
|
|
920
|
+
type: "blank",
|
|
921
|
+
contents: "Builder generation failed",
|
|
922
|
+
textStyle: ["h4", "fw-bold"],
|
|
923
|
+
block: true,
|
|
924
|
+
inline: false,
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
type: "blank",
|
|
928
|
+
contents: contextLine,
|
|
929
|
+
textStyle: ["small"],
|
|
930
|
+
block: true,
|
|
931
|
+
inline: false,
|
|
932
|
+
},
|
|
933
|
+
{
|
|
934
|
+
type: "blank",
|
|
935
|
+
contents:
|
|
936
|
+
"We could not generate a layout from your request. Please try rephrasing or simplifying the prompt.",
|
|
937
|
+
block: true,
|
|
938
|
+
inline: false,
|
|
939
|
+
},
|
|
940
|
+
{
|
|
941
|
+
type: "blank",
|
|
942
|
+
contents: `Error: ${trimmedMessage}`,
|
|
943
|
+
textStyle: ["font-monospace", "small"],
|
|
944
|
+
block: true,
|
|
945
|
+
inline: false,
|
|
946
|
+
},
|
|
947
|
+
],
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
],
|
|
951
|
+
};
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
module.exports = {
|
|
955
|
+
run: async (prompt, mode, table, chat) => {
|
|
956
|
+
// Remove any leading "container:" or similar so as to remain with only the user prompt.
|
|
957
|
+
prompt = prompt.trim().replace(/^\[\w+\]:\s*/, "");
|
|
958
|
+
|
|
959
|
+
const ctx = await buildContext(mode, table);
|
|
960
|
+
const schema = buildBuilderSchema({ mode, ctx });
|
|
961
|
+
const llm = getState().functions.llm_generate;
|
|
962
|
+
if (!llm?.run) throw new Error("LLM generator not configured");
|
|
963
|
+
|
|
964
|
+
const llmPrompt = buildPromptText(prompt, ctx, schema);
|
|
965
|
+
console.log({ schema });
|
|
966
|
+
console.log({ llmPrompt: llmPrompt.slice(0, 1000) });
|
|
967
|
+
let responseFormat;
|
|
968
|
+
try {
|
|
969
|
+
if (!schema || !schema.schema) {
|
|
970
|
+
throw new Error("Builder schema unavailable");
|
|
971
|
+
}
|
|
972
|
+
validateSchemaRefs(schema.schema);
|
|
973
|
+
const deref = derefSchema(schema.schema);
|
|
974
|
+
if (schemaHasRef(deref)) {
|
|
975
|
+
console.warn(
|
|
976
|
+
"Builder response schema still contains $ref; skipping response_format",
|
|
977
|
+
);
|
|
978
|
+
} else {
|
|
979
|
+
responseFormat = {
|
|
980
|
+
type: "json_schema",
|
|
981
|
+
json_schema: {
|
|
982
|
+
name: "saltcorn_layout",
|
|
983
|
+
schema: deref,
|
|
984
|
+
},
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
} catch (err) {
|
|
988
|
+
console.warn("Builder response schema validation failed", err);
|
|
989
|
+
// responseFormat = null;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const options = {};
|
|
993
|
+
if (responseFormat) {
|
|
994
|
+
options.response_format = responseFormat;
|
|
995
|
+
}
|
|
996
|
+
if (Array.isArray(chat) && chat.length) options.chat = chat;
|
|
997
|
+
|
|
998
|
+
// const deterministicLayout = buildDeterministicLayout(ctx, prompt);
|
|
999
|
+
|
|
1000
|
+
let payload;
|
|
1001
|
+
let rawResponse;
|
|
1002
|
+
try {
|
|
1003
|
+
if (!schema || !schema.schema) {
|
|
1004
|
+
throw new Error("Builder schema unavailable");
|
|
1005
|
+
}
|
|
1006
|
+
console.log("¢¢¢¢", { options });
|
|
1007
|
+
rawResponse = await llm.run(llmPrompt, options);
|
|
1008
|
+
console.log(JSON.stringify(rawResponse, null, 2));
|
|
1009
|
+
payload = parseJsonPayload(rawResponse);
|
|
1010
|
+
console.log(JSON.stringify({ payload }, null, 2));
|
|
1011
|
+
const candidate = payload.layout ?? payload;
|
|
1012
|
+
return normalizeLayoutCandidate(candidate, ctx);
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
let lastError = err;
|
|
1015
|
+
try {
|
|
1016
|
+
const repairPrompt = buildRepairPrompt(rawResponse, schema);
|
|
1017
|
+
rawResponse = await llm.run(repairPrompt, options);
|
|
1018
|
+
payload = parseJsonPayload(rawResponse);
|
|
1019
|
+
const candidate = payload.layout ?? payload;
|
|
1020
|
+
return normalizeLayoutCandidate(candidate, ctx);
|
|
1021
|
+
} catch (repairErr) {
|
|
1022
|
+
lastError = repairErr;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
try {
|
|
1026
|
+
const reducedPrompt = buildReducedPrompt(prompt, ctx, schema);
|
|
1027
|
+
rawResponse = await llm.run(reducedPrompt, options);
|
|
1028
|
+
payload = parseJsonPayload(rawResponse);
|
|
1029
|
+
const candidate = payload.layout ?? payload;
|
|
1030
|
+
return normalizeLayoutCandidate(candidate, ctx);
|
|
1031
|
+
} catch (reducedErr) {
|
|
1032
|
+
lastError = reducedErr;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
console.warn("Copilot layout generation failed", lastError);
|
|
1036
|
+
const errorLayout = buildErrorLayout({
|
|
1037
|
+
message: lastError?.message || String(lastError),
|
|
1038
|
+
mode,
|
|
1039
|
+
table,
|
|
1040
|
+
});
|
|
1041
|
+
return errorLayout;
|
|
1042
|
+
}
|
|
31
1043
|
},
|
|
32
1044
|
isAsync: true,
|
|
33
1045
|
description: "Generate a builder layout",
|
|
@@ -35,5 +1047,6 @@ Generate the HTML5 snippet for this request: ${prompt}
|
|
|
35
1047
|
{ name: "prompt", type: "String" },
|
|
36
1048
|
{ name: "mode", type: "String" },
|
|
37
1049
|
{ name: "table", type: "String" },
|
|
1050
|
+
{ name: "chat", type: "JSON" },
|
|
38
1051
|
],
|
|
39
1052
|
};
|