@saltcorn/copilot 0.7.4 → 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/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 { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
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
- module.exports = {
8
- run: async (prompt, mode, table) => {
9
- const str = await getState().functions.llm_generate.run(
10
- `Generate an HTML snippet according to the requirement below. Your snippet will be
11
- placed inside a page which has loaded the Bootstrap 5 CSS framework, so you can use any
12
- Bootstrap 5 classes.
13
-
14
- If you need to run javascript in script tag that depends on external reosurces, you wrap this
15
- in a DOMContentLoaded event handler as external javascript resources may be loaded after your HTML snippet is included.
16
-
17
- Include only the HTML snippet with no explanation before or after the code snippet.
18
-
19
- Generate the HTML5 snippet for this request: ${prompt}
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
- const strHtml = str.includes("```html")
23
- ? str.split("```html")[1].split("```")[0]
24
- : str;
358
+ if (!besides.some((child) => child)) return null;
25
359
  return {
26
- type: "blank",
27
- isHTML: true,
28
- contents: strHtml,
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
  };