@saltcorn/copilot 0.7.5 → 0.8.1
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/actions/generate-js-action.js +43 -5
- package/actions/generate-tables.js +281 -96
- package/actions/generate-trigger.js +61 -0
- package/actions/generate-workflow.js +89 -37
- package/actions/install-plugin-action.js +103 -0
- package/agent-skills/database-design.js +139 -87
- package/agent-skills/install-plugin.js +111 -0
- package/agent-skills/js-action.js +183 -0
- package/agent-skills/pagegen.js +19 -6
- package/agent-skills/registry-editor.js +911 -0
- package/agent-skills/triggergen.js +263 -0
- package/agent-skills/viewgen.js +431 -29
- package/agent-skills/workflow.js +52 -2
- package/app-constructor/common.js +12 -0
- package/app-constructor/errors.js +102 -0
- package/app-constructor/feedback-action.js +175 -0
- package/app-constructor/feedback.js +112 -0
- package/app-constructor/progress.js +116 -0
- package/app-constructor/prompts.js +120 -0
- package/app-constructor/requirements.js +156 -0
- package/app-constructor/run_task.js +146 -0
- package/app-constructor/schema.js +199 -0
- package/app-constructor/taskchart.js +70 -0
- package/app-constructor/tasks.js +585 -0
- package/app-constructor/tools.js +81 -0
- package/app-constructor/view.js +209 -0
- package/builder-gen.js +590 -68
- package/builder-schema.js +26 -6
- package/chat-copilot.js +1 -0
- package/common.js +20 -0
- package/copilot-as-agent.js +7 -1
- package/index.js +23 -1
- package/js-code-gen.js +65 -0
- package/package.json +1 -1
- package/relation-paths.js +236 -0
- package/tests/builder-gen.test.js +56 -0
package/builder-gen.js
CHANGED
|
@@ -4,15 +4,108 @@ const Trigger = require("@saltcorn/data/models/trigger");
|
|
|
4
4
|
const View = require("@saltcorn/data/models/view");
|
|
5
5
|
const { edit_build_in_actions } = require("@saltcorn/data/viewable_fields");
|
|
6
6
|
const { buildBuilderSchema } = require("./builder-schema");
|
|
7
|
+
const { getLlmConfigurationSafe, canUseResponseFormat } = require("./common");
|
|
8
|
+
const { build_schema_data } = require("@saltcorn/data/plugin-helper");
|
|
9
|
+
const {
|
|
10
|
+
GET_RELATION_PATHS_FUNCTION,
|
|
11
|
+
getRelationPaths,
|
|
12
|
+
getRelationPathsForPairs,
|
|
13
|
+
pickBestRelation,
|
|
14
|
+
} = require("./relation-paths");
|
|
7
15
|
|
|
8
16
|
const ACTION_SIZES = ["btn-sm", "btn-lg"];
|
|
17
|
+
|
|
18
|
+
// ── Edit-mode guidance blocks ─────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const EDIT_FIELD_INCLUSION = `\
|
|
21
|
+
Layout is a form for editing a single row. \
|
|
22
|
+
Include ALL non-primary-key, non-calculated fields from the table as inputs — \
|
|
23
|
+
not just fields mentioned in the task description. \
|
|
24
|
+
Never include calculated fields (aggregations, derived values) in an edit form — they are read-only. \
|
|
25
|
+
The only fields that may be omitted are FK fields to the users table that are explicitly \
|
|
26
|
+
designated as ownership fields (set automatically from the logged-in user). \
|
|
27
|
+
All other foreign key fields — including parent-context keys like trip_id — MUST appear as \
|
|
28
|
+
selector inputs; Saltcorn auto-fills them from the URL state when opened in context.`;
|
|
29
|
+
|
|
30
|
+
const EDIT_FIELDVIEW_SELECTION = `\
|
|
31
|
+
For date fields always prefer fieldview "flatpickr" when available — it provides the best user experience \
|
|
32
|
+
and works for both regular dates and day-only dates. \
|
|
33
|
+
Only use fieldview "edit_day" as a fallback when the field has day_only=true and flatpickr is not installed. \
|
|
34
|
+
For String fields that have an options attribute (a comma-separated list of fixed choices), \
|
|
35
|
+
use fieldview "select" — this renders a dropdown with those options. \
|
|
36
|
+
Do not use "select_by_code" for fields with fixed options.`;
|
|
37
|
+
|
|
38
|
+
const EDIT_LAYOUT_STRUCTURE = `\
|
|
39
|
+
Every field MUST be preceded by a label. \
|
|
40
|
+
Use a blank segment with the field's label text immediately above each field inside an above array, like: \
|
|
41
|
+
{"above": [{"type":"blank","contents":"Field Label","class":""}, {"type":"field","field_name":"...","fieldview":"edit"}]}. \
|
|
42
|
+
Alternatively wrap groups of fields in a card with a descriptive title.`;
|
|
43
|
+
|
|
44
|
+
const EDIT_ACTIONS = `\
|
|
45
|
+
Use edit fieldviews, group related inputs, and finish with a row of actions: \
|
|
46
|
+
a Save button (action_name "Save", style "btn btn-primary") and \
|
|
47
|
+
a Cancel button (action_name "GoBack", style "btn btn-outline-secondary"). \
|
|
48
|
+
To add a trigger button, add an extra action segment with action_name set to the trigger's name \
|
|
49
|
+
exactly as it appears in the available actions list (ctx.actions).`;
|
|
50
|
+
|
|
51
|
+
// ── Show-mode guidance blocks ─────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const SHOW_PURPOSE = `\
|
|
54
|
+
Layout displays one record read-only. \
|
|
55
|
+
Use show fieldviews only (e.g. "show", "as_text", "showDay").`;
|
|
56
|
+
|
|
57
|
+
const SHOW_NO_EDIT_FIELDVIEWS = `\
|
|
58
|
+
A show layout is read-only and must never use edit fieldviews \
|
|
59
|
+
(textarea, edit, input, select) — those are for form inputs, not display.`;
|
|
60
|
+
|
|
61
|
+
const SHOW_LABELS = `\
|
|
62
|
+
Every field MUST be preceded by a label using a blank segment or \
|
|
63
|
+
grouped in a card with a descriptive title.`;
|
|
64
|
+
|
|
65
|
+
// ── List-columns guidance blocks ──────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const LISTCOLUMNS_STRUCTURE = `\
|
|
68
|
+
Layout defines list columns for a list view. \
|
|
69
|
+
Use a list_columns wrapper with besides entries containing list_column segments. \
|
|
70
|
+
Each list_column has a header_label for the column heading — \
|
|
71
|
+
do NOT add any blank or label segment inside a list_column. \
|
|
72
|
+
Typically each list_column contains a single field, action, or view_link.`;
|
|
73
|
+
|
|
74
|
+
const LISTCOLUMNS_FIELDVIEWS = `\
|
|
75
|
+
Include all meaningful fields as columns, including foreign key fields. \
|
|
76
|
+
For a foreign key field use fieldview "show" so the related record's label is displayed rather than a raw ID. \
|
|
77
|
+
For string/text fields use fieldview "as_text" or "show" by default; \
|
|
78
|
+
only use "show_with_html" when the task explicitly requires rendering HTML content.`;
|
|
79
|
+
|
|
80
|
+
const LISTCOLUMNS_VIEW_LINK = `\
|
|
81
|
+
A view_link that opens the detail of a row MUST point to a Show-type view (viewtemplate "Show"). \
|
|
82
|
+
Only include such a view_link if a Show view for this table is listed in the available views.`;
|
|
83
|
+
|
|
84
|
+
// ── Composed MODE_GUIDANCE ────────────────────────────────────────────────────
|
|
85
|
+
|
|
9
86
|
const MODE_GUIDANCE = {
|
|
10
|
-
edit:
|
|
11
|
-
|
|
87
|
+
edit: [
|
|
88
|
+
EDIT_FIELD_INCLUSION,
|
|
89
|
+
EDIT_ACTIONS,
|
|
90
|
+
EDIT_FIELDVIEW_SELECTION,
|
|
91
|
+
EDIT_LAYOUT_STRUCTURE,
|
|
92
|
+
].join(" "),
|
|
93
|
+
|
|
94
|
+
show: [SHOW_PURPOSE, SHOW_NO_EDIT_FIELDVIEWS, SHOW_LABELS].join(" "),
|
|
95
|
+
|
|
12
96
|
list: "Layout represents a single row in a list. Highlight key fields, keep actions compact, and support filtering if requested.",
|
|
97
|
+
|
|
98
|
+
listcolumns: [
|
|
99
|
+
LISTCOLUMNS_STRUCTURE,
|
|
100
|
+
LISTCOLUMNS_FIELDVIEWS,
|
|
101
|
+
LISTCOLUMNS_VIEW_LINK,
|
|
102
|
+
].join(" "),
|
|
103
|
+
|
|
13
104
|
filter:
|
|
14
105
|
"Layout lets users define filters. Provide appropriate filter inputs plus an action to run or reset filters.",
|
|
106
|
+
|
|
15
107
|
page: "Layout builds a general app page. Combine hero text, cards, containers, and call-to-action buttons.",
|
|
108
|
+
|
|
16
109
|
default:
|
|
17
110
|
"Use Saltcorn layout primitives (above, besides, container, card, tabs, blank, field, action, view_link, view). Do not return HTML snippets.",
|
|
18
111
|
};
|
|
@@ -188,11 +281,200 @@ const derefSchema = (schema, maxDepth = 3) => {
|
|
|
188
281
|
return expand(root, []);
|
|
189
282
|
};
|
|
190
283
|
|
|
284
|
+
const simplifySchemaForLlm = (schema) => {
|
|
285
|
+
const deref = derefSchema(schema, 2); // Reduced maxDepth
|
|
286
|
+
const scrub = (node, keyName, depth = 0) => {
|
|
287
|
+
if (!node || typeof node !== "object" || depth > 4) {
|
|
288
|
+
// Added depth limit
|
|
289
|
+
if (
|
|
290
|
+
keyName === "contents" ||
|
|
291
|
+
keyName === "besides" ||
|
|
292
|
+
keyName === "above"
|
|
293
|
+
)
|
|
294
|
+
return {
|
|
295
|
+
type: "string",
|
|
296
|
+
description: "Simplified content: a string or a basic segment.",
|
|
297
|
+
};
|
|
298
|
+
return node;
|
|
299
|
+
}
|
|
300
|
+
if (Array.isArray(node))
|
|
301
|
+
return node.map((item) => scrub(item, undefined, depth + 1));
|
|
302
|
+
if (keyName === "style") {
|
|
303
|
+
return {
|
|
304
|
+
type: "string",
|
|
305
|
+
description: "CSS style string (e.g. background-color: red;).",
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (typeof node.$ref === "string") {
|
|
309
|
+
return {
|
|
310
|
+
type: "object",
|
|
311
|
+
description: "Simplified recursive segment.",
|
|
312
|
+
additionalProperties: false,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const out = {};
|
|
316
|
+
for (const [key, val] of Object.entries(node)) {
|
|
317
|
+
if (key === "$defs" || key === "definitions") continue;
|
|
318
|
+
out[key] = scrub(val, key, depth + 1);
|
|
319
|
+
}
|
|
320
|
+
const nodeType = out.type;
|
|
321
|
+
const isObjectType =
|
|
322
|
+
nodeType === "object" ||
|
|
323
|
+
(Array.isArray(nodeType) && nodeType.includes("object")) ||
|
|
324
|
+
Object.prototype.hasOwnProperty.call(out, "properties");
|
|
325
|
+
const isArrayType =
|
|
326
|
+
nodeType === "array" ||
|
|
327
|
+
(Array.isArray(nodeType) && nodeType.includes("array")) ||
|
|
328
|
+
Object.prototype.hasOwnProperty.call(out, "items");
|
|
329
|
+
const isNumberType =
|
|
330
|
+
nodeType === "number" ||
|
|
331
|
+
nodeType === "integer" ||
|
|
332
|
+
(Array.isArray(nodeType) &&
|
|
333
|
+
(nodeType.includes("number") || nodeType.includes("integer")));
|
|
334
|
+
if (isObjectType) {
|
|
335
|
+
out.additionalProperties = false;
|
|
336
|
+
}
|
|
337
|
+
if (isArrayType && typeof out.minItems !== "undefined") {
|
|
338
|
+
const minItems = Number(out.minItems);
|
|
339
|
+
if (Number.isFinite(minItems) && minItems > 1) out.minItems = 1;
|
|
340
|
+
}
|
|
341
|
+
if (isNumberType) {
|
|
342
|
+
delete out.minimum;
|
|
343
|
+
delete out.maximum;
|
|
344
|
+
delete out.exclusiveMinimum;
|
|
345
|
+
delete out.exclusiveMaximum;
|
|
346
|
+
delete out.multipleOf;
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
};
|
|
350
|
+
return scrub(deref);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const countOptionalParams = (schema) => {
|
|
354
|
+
let count = 0;
|
|
355
|
+
const walk = (node) => {
|
|
356
|
+
if (!node || typeof node !== "object") return;
|
|
357
|
+
if (Array.isArray(node)) {
|
|
358
|
+
node.forEach(walk);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const props = node.properties;
|
|
362
|
+
if (props && typeof props === "object") {
|
|
363
|
+
const required = Array.isArray(node.required) ? node.required : [];
|
|
364
|
+
const optional = Object.keys(props).filter(
|
|
365
|
+
(key) => !required.includes(key)
|
|
366
|
+
);
|
|
367
|
+
count += optional.length;
|
|
368
|
+
Object.values(props).forEach(walk);
|
|
369
|
+
}
|
|
370
|
+
if (node.items) walk(node.items);
|
|
371
|
+
if (node.anyOf) node.anyOf.forEach(walk);
|
|
372
|
+
if (node.oneOf) node.oneOf.forEach(walk);
|
|
373
|
+
if (node.allOf) node.allOf.forEach(walk);
|
|
374
|
+
};
|
|
375
|
+
walk(schema);
|
|
376
|
+
return count;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const trimOptionalProperties = (schema, maxOptional) => {
|
|
380
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
381
|
+
|
|
382
|
+
const clonedSchema = JSON.parse(JSON.stringify(schema));
|
|
383
|
+
let optionalCount = countOptionalParams(clonedSchema);
|
|
384
|
+
if (optionalCount <= maxOptional) {
|
|
385
|
+
return clonedSchema;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const optionalPropsByNode = new Map();
|
|
389
|
+
|
|
390
|
+
const findOptional = (node, path = []) => {
|
|
391
|
+
if (!node || typeof node !== "object") return;
|
|
392
|
+
if (Array.isArray(node)) {
|
|
393
|
+
node.forEach((item, i) => findOptional(item, [...path, i]));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const props = node.properties;
|
|
398
|
+
if (props && typeof props === "object") {
|
|
399
|
+
const required = new Set(node.required || []);
|
|
400
|
+
const optionalKeys = Object.keys(props).filter((k) => !required.has(k));
|
|
401
|
+
if (optionalKeys.length > 0) {
|
|
402
|
+
optionalPropsByNode.set(node, optionalKeys);
|
|
403
|
+
}
|
|
404
|
+
Object.values(props).forEach((propNode) => findOptional(propNode));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (node.items) findOptional(node.items, [...path, "items"]);
|
|
408
|
+
if (node.anyOf)
|
|
409
|
+
node.anyOf.forEach((item, i) =>
|
|
410
|
+
findOptional(item, [...path, "anyOf", i])
|
|
411
|
+
);
|
|
412
|
+
if (node.oneOf)
|
|
413
|
+
node.oneOf.forEach((item, i) =>
|
|
414
|
+
findOptional(item, [...path, "oneOf", i])
|
|
415
|
+
);
|
|
416
|
+
if (node.allOf)
|
|
417
|
+
node.allOf.forEach((item, i) =>
|
|
418
|
+
findOptional(item, [...path, "allOf", i])
|
|
419
|
+
);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
findOptional(clonedSchema);
|
|
423
|
+
|
|
424
|
+
const nodesWithOptionals = Array.from(optionalPropsByNode.keys());
|
|
425
|
+
let nodeIndex = 0;
|
|
426
|
+
|
|
427
|
+
while (optionalCount > maxOptional && nodeIndex < nodesWithOptionals.length) {
|
|
428
|
+
const node = nodesWithOptionals[nodeIndex];
|
|
429
|
+
const optionalKeys = optionalPropsByNode.get(node);
|
|
430
|
+
|
|
431
|
+
if (optionalKeys && optionalKeys.length > 0) {
|
|
432
|
+
const keyToRemove = optionalKeys.pop();
|
|
433
|
+
delete node.properties[keyToRemove];
|
|
434
|
+
optionalCount--;
|
|
435
|
+
} else {
|
|
436
|
+
nodeIndex++;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return clonedSchema;
|
|
441
|
+
};
|
|
442
|
+
|
|
191
443
|
const randomId = () =>
|
|
192
444
|
Math.floor(Math.random() * 0xffffff)
|
|
193
445
|
.toString(16)
|
|
194
446
|
.padStart(6, "0");
|
|
195
447
|
|
|
448
|
+
const toCamelCaseStyle = (key) =>
|
|
449
|
+
key.replace(/-([a-z])/g, (_m, chr) => chr.toUpperCase());
|
|
450
|
+
|
|
451
|
+
const parseStyleString = (styleStr) => {
|
|
452
|
+
if (typeof styleStr !== "string") return styleStr;
|
|
453
|
+
const trimmed = styleStr.trim();
|
|
454
|
+
if (!trimmed) return {};
|
|
455
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
456
|
+
try {
|
|
457
|
+
const parsed = JSON.parse(trimmed);
|
|
458
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
459
|
+
} catch (err) {
|
|
460
|
+
// fall through to CSS parsing
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const out = {};
|
|
464
|
+
trimmed.split(";").forEach((part) => {
|
|
465
|
+
const section = part.trim();
|
|
466
|
+
if (!section) return;
|
|
467
|
+
const idx = section.indexOf(":");
|
|
468
|
+
if (idx === -1) return;
|
|
469
|
+
const rawKey = section.slice(0, idx).trim();
|
|
470
|
+
const value = section.slice(idx + 1).trim();
|
|
471
|
+
if (!rawKey || !value) return;
|
|
472
|
+
if (rawKey.startsWith("--")) out[rawKey] = value;
|
|
473
|
+
else out[toCamelCaseStyle(rawKey)] = value;
|
|
474
|
+
});
|
|
475
|
+
return out;
|
|
476
|
+
};
|
|
477
|
+
|
|
196
478
|
const ensureArray = (value) =>
|
|
197
479
|
Array.isArray(value) ? value : value == null ? [] : [value];
|
|
198
480
|
|
|
@@ -206,7 +488,10 @@ const prettifyActionName = (name) =>
|
|
|
206
488
|
// Picks a valid fieldview from the field's available fieldviews only.
|
|
207
489
|
// Never returns a fieldview that doesn't exist in field.fieldviews
|
|
208
490
|
const pickFieldview = (field, mode, requestedFieldview = null) => {
|
|
209
|
-
const
|
|
491
|
+
const isEditMode = mode === "edit" || mode === "filter";
|
|
492
|
+
const availableViews = isEditMode
|
|
493
|
+
? field?.editFieldviews || field?.fieldviews || []
|
|
494
|
+
: field?.fieldviews || [];
|
|
210
495
|
|
|
211
496
|
// If no available fieldviews, return the first one or a safe default
|
|
212
497
|
if (!availableViews.length) {
|
|
@@ -214,29 +499,60 @@ const pickFieldview = (field, mode, requestedFieldview = null) => {
|
|
|
214
499
|
return mode === "edit" || mode === "filter" ? "edit" : "show";
|
|
215
500
|
}
|
|
216
501
|
|
|
217
|
-
// Helper to validate and return a fieldview only if it exists
|
|
502
|
+
// Helper to validate and return a fieldview only if it exists (exact match only)
|
|
218
503
|
const validateAndReturn = (candidate) => {
|
|
219
504
|
if (!candidate) return null;
|
|
220
505
|
const lower = String(candidate).toLowerCase();
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
(fv) => String(fv).toLowerCase() === lower,
|
|
506
|
+
return (
|
|
507
|
+
availableViews.find((fv) => String(fv).toLowerCase() === lower) || null
|
|
224
508
|
);
|
|
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
509
|
};
|
|
233
510
|
|
|
511
|
+
// Edit-only fieldviews must never be used in show/list modes
|
|
512
|
+
const EDIT_ONLY_FIELDVIEWS = new Set(["textarea", "edit", "input", "select"]);
|
|
513
|
+
const isShowMode =
|
|
514
|
+
mode === "show" || mode === "list" || mode === "listcolumns";
|
|
515
|
+
|
|
516
|
+
// For date fields in edit mode: flatpickr is always preferred (handles all date types).
|
|
517
|
+
// Only fall back to edit_day when day_only=true and flatpickr is not installed.
|
|
518
|
+
const fieldTypeLower = String(field?.type || "").toLowerCase();
|
|
519
|
+
const isDateField =
|
|
520
|
+
(mode === "edit" || mode === "filter") &&
|
|
521
|
+
(fieldTypeLower === "date" || fieldTypeLower.includes("date"));
|
|
522
|
+
if (isDateField) {
|
|
523
|
+
const flatpickrMatch = availableViews.find(
|
|
524
|
+
(fv) => String(fv).toLowerCase() === "flatpickr"
|
|
525
|
+
);
|
|
526
|
+
if (flatpickrMatch) return flatpickrMatch;
|
|
527
|
+
if (field?.attributes?.day_only) {
|
|
528
|
+
const dayMatch = availableViews.find(
|
|
529
|
+
(fv) => String(fv).toLowerCase() === "edit_day"
|
|
530
|
+
);
|
|
531
|
+
if (dayMatch) return dayMatch;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Fields with fixed options (options attribute) always use plain select, never select_by_code
|
|
536
|
+
if ((mode === "edit" || mode === "filter") && field?.attributes?.options) {
|
|
537
|
+
const selectMatch = availableViews.find(
|
|
538
|
+
(fv) => String(fv).toLowerCase() === "select"
|
|
539
|
+
);
|
|
540
|
+
if (selectMatch) return selectMatch;
|
|
541
|
+
}
|
|
542
|
+
|
|
234
543
|
// If a specific fieldview was requested by the user, try to honor it
|
|
235
|
-
// but ONLY if it actually exists in available views
|
|
544
|
+
// but ONLY if it actually exists in available views and is appropriate for the mode
|
|
236
545
|
if (requestedFieldview) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
546
|
+
if (
|
|
547
|
+
isShowMode &&
|
|
548
|
+
EDIT_ONLY_FIELDVIEWS.has(String(requestedFieldview).toLowerCase())
|
|
549
|
+
) {
|
|
550
|
+
// Fall through to show-mode defaults — don't use edit fieldviews in show context
|
|
551
|
+
} else {
|
|
552
|
+
const validated = validateAndReturn(requestedFieldview);
|
|
553
|
+
if (validated) return validated;
|
|
554
|
+
// Requested fieldview not available for this field - fall through to defaults
|
|
555
|
+
}
|
|
240
556
|
}
|
|
241
557
|
|
|
242
558
|
// Get the field's configured default fieldview
|
|
@@ -249,21 +565,21 @@ const pickFieldview = (field, mode, requestedFieldview = null) => {
|
|
|
249
565
|
}
|
|
250
566
|
|
|
251
567
|
// Mode-based selection from available fieldviews
|
|
252
|
-
if (mode === "show" || mode === "list") {
|
|
568
|
+
if (mode === "show" || mode === "list" || mode === "listcolumns") {
|
|
253
569
|
// For show mode, prefer simple text-based views, but only from available views
|
|
254
570
|
const showPreferences = ["as_text", "show", "as_string", "text", "showas"];
|
|
255
571
|
for (const pref of showPreferences) {
|
|
256
|
-
const match = availableViews.find(
|
|
257
|
-
String(fv).toLowerCase()
|
|
572
|
+
const match = availableViews.find(
|
|
573
|
+
(fv) => String(fv).toLowerCase() === pref
|
|
258
574
|
);
|
|
259
575
|
if (match) return match;
|
|
260
576
|
}
|
|
261
577
|
} else if (mode === "edit" || mode === "filter") {
|
|
262
|
-
// For edit mode, prefer edit-capable fieldviews from available views
|
|
578
|
+
// For edit mode, prefer edit-capable fieldviews from available views (exact match)
|
|
263
579
|
const editPreferences = ["edit", "input", "select", "textarea"];
|
|
264
580
|
for (const pref of editPreferences) {
|
|
265
|
-
const match = availableViews.find(
|
|
266
|
-
String(fv).toLowerCase()
|
|
581
|
+
const match = availableViews.find(
|
|
582
|
+
(fv) => String(fv).toLowerCase() === pref
|
|
267
583
|
);
|
|
268
584
|
if (match) return match;
|
|
269
585
|
}
|
|
@@ -343,9 +659,12 @@ const normalizeSegment = (segment, ctx) => {
|
|
|
343
659
|
if (typeof segment !== "object") return null;
|
|
344
660
|
|
|
345
661
|
const clone = { ...segment };
|
|
662
|
+
if (typeof clone.style === "string") {
|
|
663
|
+
clone.style = parseStyleString(clone.style);
|
|
664
|
+
}
|
|
346
665
|
if (clone.type === "prompt") return null;
|
|
347
666
|
|
|
348
|
-
if (!clone.type && clone.above) {
|
|
667
|
+
if ((!clone.type || clone.type === "stack") && clone.above) {
|
|
349
668
|
const above = ensureArray(clone.above)
|
|
350
669
|
.map((child) => normalizeSegment(child, ctx))
|
|
351
670
|
.filter(Boolean);
|
|
@@ -353,7 +672,7 @@ const normalizeSegment = (segment, ctx) => {
|
|
|
353
672
|
}
|
|
354
673
|
if (!clone.type && clone.besides) {
|
|
355
674
|
const besides = ensureArray(clone.besides).map((child) =>
|
|
356
|
-
child == null ? null : normalizeSegment(child, ctx)
|
|
675
|
+
child == null ? null : normalizeSegment(child, ctx)
|
|
357
676
|
);
|
|
358
677
|
if (!besides.some((child) => child)) return null;
|
|
359
678
|
return {
|
|
@@ -362,8 +681,38 @@ const normalizeSegment = (segment, ctx) => {
|
|
|
362
681
|
widths: normalizeWidths(clone.widths, besides.length),
|
|
363
682
|
};
|
|
364
683
|
}
|
|
684
|
+
// Handle {columns: {besides: [...], widths: [...]}} wrapper the LLM sometimes emits
|
|
685
|
+
if (!clone.type && clone.columns?.besides) {
|
|
686
|
+
const inner = clone.columns;
|
|
687
|
+
const besides = ensureArray(inner.besides).map((child) =>
|
|
688
|
+
child == null ? null : normalizeSegment(child, ctx)
|
|
689
|
+
);
|
|
690
|
+
if (!besides.some(Boolean)) return null;
|
|
691
|
+
return {
|
|
692
|
+
besides,
|
|
693
|
+
widths: normalizeWidths(inner.widths, besides.length),
|
|
694
|
+
};
|
|
695
|
+
}
|
|
365
696
|
|
|
366
697
|
switch (clone.type) {
|
|
698
|
+
case "columns":
|
|
699
|
+
case "row":
|
|
700
|
+
case "grid": {
|
|
701
|
+
// LLM uses these as a besides wrapper with widths
|
|
702
|
+
if (clone.besides) {
|
|
703
|
+
const besides = ensureArray(clone.besides).map((child) =>
|
|
704
|
+
child == null ? null : normalizeSegment(child, ctx)
|
|
705
|
+
);
|
|
706
|
+
if (!besides.some(Boolean)) return null;
|
|
707
|
+
return {
|
|
708
|
+
besides,
|
|
709
|
+
widths: normalizeWidths(clone.widths, besides.length),
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
// fall through to container-like handling if no besides
|
|
713
|
+
const contents = normalizeChild(clone.contents, ctx);
|
|
714
|
+
return contents ? { ...clone, contents } : null;
|
|
715
|
+
}
|
|
367
716
|
case "container": {
|
|
368
717
|
const contents = normalizeChild(clone.contents, ctx);
|
|
369
718
|
return contents
|
|
@@ -388,7 +737,10 @@ const normalizeSegment = (segment, ctx) => {
|
|
|
388
737
|
}
|
|
389
738
|
case "tabs": {
|
|
390
739
|
const tabs = normalizeTabs(clone.tabs, ctx);
|
|
391
|
-
|
|
740
|
+
if (!tabs.length) return null;
|
|
741
|
+
const titles = clone.titles ?? tabs.map((t) => t.title || "");
|
|
742
|
+
const contents = clone.contents ?? tabs.map((t) => t.contents);
|
|
743
|
+
return { ...clone, tabs, titles, contents, class: clone.class || "" };
|
|
392
744
|
}
|
|
393
745
|
case "blank":
|
|
394
746
|
return {
|
|
@@ -428,17 +780,46 @@ const normalizeSegment = (segment, ctx) => {
|
|
|
428
780
|
state: clone.state || {},
|
|
429
781
|
class: clone.class || "",
|
|
430
782
|
};
|
|
431
|
-
case "view_link":
|
|
783
|
+
case "view_link": {
|
|
432
784
|
if (!ctx.viewNames.length) return null;
|
|
785
|
+
const resolvedView = ctx.viewNames.includes(clone.view)
|
|
786
|
+
? clone.view
|
|
787
|
+
: ctx.viewNames[0];
|
|
788
|
+
let relation = clone.relation;
|
|
789
|
+
if (!relation && ctx.table && ctx.schemaData) {
|
|
790
|
+
try {
|
|
791
|
+
const relations = getRelationPaths(
|
|
792
|
+
ctx.table.name,
|
|
793
|
+
resolvedView,
|
|
794
|
+
ctx.schemaData
|
|
795
|
+
);
|
|
796
|
+
if (relations.length > 0) {
|
|
797
|
+
const picked = pickBestRelation(relations);
|
|
798
|
+
if (picked) relation = picked.relationString;
|
|
799
|
+
}
|
|
800
|
+
} catch (e) {
|
|
801
|
+
console.error("view_link relation lookup failed:", e.message);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
// Convert {{ expr }} template syntax to isFormula.label: true with JS expression
|
|
805
|
+
let viewLabel = clone.view_label || clone.view;
|
|
806
|
+
let isFormula = clone.isFormula || {};
|
|
807
|
+
const tmplMatch =
|
|
808
|
+
typeof viewLabel === "string" && viewLabel.match(/^\{\{\s*(.+?)\s*\}\}$/);
|
|
809
|
+
if (tmplMatch) {
|
|
810
|
+
viewLabel = tmplMatch[1];
|
|
811
|
+
isFormula = { ...isFormula, label: true };
|
|
812
|
+
}
|
|
433
813
|
return {
|
|
434
814
|
...clone,
|
|
435
|
-
view:
|
|
436
|
-
|
|
437
|
-
: ctx.viewNames[0],
|
|
438
|
-
view_label: clone.view_label || clone.view,
|
|
815
|
+
view: resolvedView,
|
|
816
|
+
view_label: viewLabel,
|
|
439
817
|
link_style: clone.link_style || "",
|
|
440
818
|
class: clone.class || "",
|
|
819
|
+
isFormula,
|
|
820
|
+
...(relation ? { relation } : {}),
|
|
441
821
|
};
|
|
822
|
+
}
|
|
442
823
|
case "field": {
|
|
443
824
|
if (!ctx.fields.length) return null;
|
|
444
825
|
const fieldMeta = ctx.fieldMap[clone.field_name] || ctx.fields[0];
|
|
@@ -447,7 +828,7 @@ const normalizeSegment = (segment, ctx) => {
|
|
|
447
828
|
const validFieldview = pickFieldview(
|
|
448
829
|
fieldMeta,
|
|
449
830
|
ctx.mode,
|
|
450
|
-
clone.fieldview
|
|
831
|
+
clone.fieldview
|
|
451
832
|
);
|
|
452
833
|
return {
|
|
453
834
|
...clone,
|
|
@@ -457,6 +838,26 @@ const normalizeSegment = (segment, ctx) => {
|
|
|
457
838
|
class: clone.class || "",
|
|
458
839
|
};
|
|
459
840
|
}
|
|
841
|
+
case "list_columns": {
|
|
842
|
+
const besides = ensureArray(clone.besides).map((child) =>
|
|
843
|
+
child == null ? null : normalizeSegment(child, ctx)
|
|
844
|
+
);
|
|
845
|
+
if (!besides.some(Boolean)) return null;
|
|
846
|
+
return { ...clone, besides, list_columns: true };
|
|
847
|
+
}
|
|
848
|
+
case "list_column": {
|
|
849
|
+
let raw = clone.contents;
|
|
850
|
+
// Saltcorn's list renderer handles `above` in a cell (wraps in Container)
|
|
851
|
+
// but silently drops a typeless `besides`. Convert it so both links render
|
|
852
|
+
// stacked in one column rather than disappearing.
|
|
853
|
+
if (raw && !raw.type && Array.isArray(raw.besides)) {
|
|
854
|
+
raw = { above: raw.besides };
|
|
855
|
+
}
|
|
856
|
+
const contents = normalizeChild(raw, ctx);
|
|
857
|
+
return contents
|
|
858
|
+
? { ...clone, contents, header_label: clone.header_label || "" }
|
|
859
|
+
: null;
|
|
860
|
+
}
|
|
460
861
|
case "action": {
|
|
461
862
|
if (!ctx.actions.length) return null;
|
|
462
863
|
const actionName = ctx.actions.includes(clone.action_name)
|
|
@@ -508,7 +909,7 @@ const collectSegments = (segment, out = []) => {
|
|
|
508
909
|
if (segment.contents) collectSegments(segment.contents, out);
|
|
509
910
|
if (segment.tabs) {
|
|
510
911
|
ensureArray(segment.tabs).forEach((tab) =>
|
|
511
|
-
collectSegments(tab.contents, out)
|
|
912
|
+
collectSegments(tab.contents, out)
|
|
512
913
|
);
|
|
513
914
|
}
|
|
514
915
|
return out;
|
|
@@ -559,25 +960,48 @@ const normalizeLayoutCandidate = (candidate, ctx) => {
|
|
|
559
960
|
if (!normalized) {
|
|
560
961
|
normalized = convertForeignLayout(candidate, ctx);
|
|
561
962
|
}
|
|
562
|
-
if (!normalized)
|
|
963
|
+
if (!normalized) return null;
|
|
563
964
|
const layout = Array.isArray(normalized) ? { above: normalized } : normalized;
|
|
564
965
|
return sanitizeNoHtmlSegments(layout);
|
|
565
966
|
};
|
|
566
967
|
|
|
968
|
+
const buildFieldMetadata = (fields) => {
|
|
969
|
+
if (!fields || !fields.length) return null;
|
|
970
|
+
const lines = fields
|
|
971
|
+
.filter((f) => !f.primary_key)
|
|
972
|
+
.map((f) => {
|
|
973
|
+
const attrs = Object.entries(f.attributes || {})
|
|
974
|
+
.filter(
|
|
975
|
+
([, v]) => v !== null && v !== undefined && v !== "" && v !== false
|
|
976
|
+
)
|
|
977
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
978
|
+
.join(", ");
|
|
979
|
+
return ` ${f.name} (${f.type}${f.calculated ? ", calculated" : ""}${
|
|
980
|
+
attrs ? ` — attrs: ${attrs}` : ""
|
|
981
|
+
})`;
|
|
982
|
+
});
|
|
983
|
+
return `Table fields available for this layout:\n${lines.join("\n")}`;
|
|
984
|
+
};
|
|
985
|
+
|
|
567
986
|
const buildPromptText = (userPrompt, ctx, schema) => {
|
|
568
987
|
const parts = [
|
|
569
988
|
`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
989
|
'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
990
|
'The "layout" object MUST conform entirely to the provided JSON Schema. Do not invent properties, types, or structure not defined in the schema.',
|
|
572
991
|
];
|
|
992
|
+
parts.push(ctx.modeGuidance);
|
|
993
|
+
const fieldMeta = buildFieldMetadata(ctx.fields);
|
|
994
|
+
if (fieldMeta) parts.push(fieldMeta);
|
|
573
995
|
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."
|
|
996
|
+
"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
997
|
);
|
|
576
998
|
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(
|
|
999
|
+
`Here is the strict Saltcorn layout JSON schema you MUST follow to construct the layout. Do not deviate from these definitions:\n${JSON.stringify(
|
|
1000
|
+
schema
|
|
1001
|
+
)}`
|
|
578
1002
|
);
|
|
579
1003
|
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}"
|
|
1004
|
+
`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
1005
|
);
|
|
582
1006
|
return parts.join("\n\n");
|
|
583
1007
|
};
|
|
@@ -796,6 +1220,7 @@ const buildBracketObject = (node) => {
|
|
|
796
1220
|
return obj;
|
|
797
1221
|
};
|
|
798
1222
|
|
|
1223
|
+
|
|
799
1224
|
const buildContext = async (mode, tableName) => {
|
|
800
1225
|
const normalizedMode = (mode || "show").toLowerCase();
|
|
801
1226
|
const ctx = {
|
|
@@ -806,15 +1231,25 @@ const buildContext = async (mode, tableName) => {
|
|
|
806
1231
|
fieldMap: {},
|
|
807
1232
|
actions: [],
|
|
808
1233
|
viewNames: [],
|
|
1234
|
+
viewTableMap: {},
|
|
1235
|
+
schemaData: null,
|
|
809
1236
|
};
|
|
810
1237
|
|
|
811
1238
|
// Global actions and views are useful even when no table is specified (page builder)
|
|
812
1239
|
const stateActions = Object.keys(getState().actions || {});
|
|
813
1240
|
try {
|
|
814
|
-
const
|
|
815
|
-
ctx.
|
|
1241
|
+
const schemaData = await build_schema_data();
|
|
1242
|
+
ctx.schemaData = schemaData;
|
|
1243
|
+
ctx.viewNames = schemaData.views.map((v) => v.name).filter(Boolean);
|
|
1244
|
+
for (const v of schemaData.views) {
|
|
1245
|
+
if (v.name && v.table_id) {
|
|
1246
|
+
const vt = Table.findOne({ id: v.table_id });
|
|
1247
|
+
if (vt) ctx.viewTableMap[v.name] = vt.name;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
816
1250
|
} catch (err) {
|
|
817
1251
|
ctx.viewNames = [];
|
|
1252
|
+
ctx.viewTableMap = {};
|
|
818
1253
|
}
|
|
819
1254
|
|
|
820
1255
|
if (!tableName) {
|
|
@@ -823,7 +1258,7 @@ const buildContext = async (mode, tableName) => {
|
|
|
823
1258
|
}).filter((tr) => tr.name && !tr.table_id);
|
|
824
1259
|
|
|
825
1260
|
ctx.actions = Array.from(
|
|
826
|
-
new Set([...stateActions, ...triggers.map((tr) => tr.name)])
|
|
1261
|
+
new Set([...stateActions, ...triggers.map((tr) => tr.name)])
|
|
827
1262
|
).filter(Boolean);
|
|
828
1263
|
return ctx;
|
|
829
1264
|
}
|
|
@@ -843,7 +1278,19 @@ const buildContext = async (mode, tableName) => {
|
|
|
843
1278
|
}
|
|
844
1279
|
if (rawFields?.then) rawFields = await rawFields;
|
|
845
1280
|
const fields = (rawFields || []).map((field) => {
|
|
846
|
-
const
|
|
1281
|
+
const fvDefs = field.type?.fieldviews || {};
|
|
1282
|
+
let fieldviews = Object.keys(fvDefs);
|
|
1283
|
+
// FK fields have type "Key" (a string) so field.type?.fieldviews is empty;
|
|
1284
|
+
// ensure select and show are always available for them
|
|
1285
|
+
const typeName = field.type?.name || field.type || "";
|
|
1286
|
+
if (!fieldviews.length && String(typeName).startsWith("Key")) {
|
|
1287
|
+
fieldviews = ["select", "show"];
|
|
1288
|
+
}
|
|
1289
|
+
// editFieldviews: only fieldviews where isEdit is not explicitly false
|
|
1290
|
+
const editFieldviews = fieldviews.filter(
|
|
1291
|
+
(fv) => fvDefs[fv]?.isEdit !== false
|
|
1292
|
+
);
|
|
1293
|
+
|
|
847
1294
|
const isPkName =
|
|
848
1295
|
table.pk_name &&
|
|
849
1296
|
typeof field.name === "string" &&
|
|
@@ -868,6 +1315,8 @@ const buildContext = async (mode, tableName) => {
|
|
|
868
1315
|
is_pk_name: !!isPkName,
|
|
869
1316
|
default_fieldview: defaultFieldview,
|
|
870
1317
|
fieldviews: fieldviews.length ? fieldviews : ["show"],
|
|
1318
|
+
editFieldviews: editFieldviews.length ? editFieldviews : fieldviews,
|
|
1319
|
+
attributes: field.attributes || {},
|
|
871
1320
|
};
|
|
872
1321
|
});
|
|
873
1322
|
|
|
@@ -888,7 +1337,7 @@ const buildContext = async (mode, tableName) => {
|
|
|
888
1337
|
? edit_build_in_actions || []
|
|
889
1338
|
: ["Delete", "GoBack"];
|
|
890
1339
|
const actions = Array.from(
|
|
891
|
-
new Set([...builtIns, ...stateActions, ...triggers.map((tr) => tr.name)])
|
|
1340
|
+
new Set([...builtIns, ...stateActions, ...triggers.map((tr) => tr.name)])
|
|
892
1341
|
).filter(Boolean);
|
|
893
1342
|
|
|
894
1343
|
ctx.table = table;
|
|
@@ -951,9 +1400,60 @@ const buildErrorLayout = ({ message, mode, table }) => {
|
|
|
951
1400
|
};
|
|
952
1401
|
};
|
|
953
1402
|
|
|
1403
|
+
const GET_RELATION_PATHS_TOOL = { type: "function", function: GET_RELATION_PATHS_FUNCTION };
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* Run the LLM giving it one opportunity to call get_relation_paths before
|
|
1407
|
+
* producing the final layout JSON. Returns the final text response.
|
|
1408
|
+
*/
|
|
1409
|
+
const runWithRelationTools = async (llm, mainPrompt, opts) => {
|
|
1410
|
+
const llm_add_message = getState().functions.llm_add_message;
|
|
1411
|
+
|
|
1412
|
+
// Local chat copy with the main prompt pre-loaded so all iterations see it.
|
|
1413
|
+
const runChat = Array.isArray(opts.chat) ? [...opts.chat] : [];
|
|
1414
|
+
runChat.push({ role: "user", content: mainPrompt });
|
|
1415
|
+
|
|
1416
|
+
// Drop response_format during tool phase; repair chain handles JSON after.
|
|
1417
|
+
const toolOpts = { ...opts, tools: [GET_RELATION_PATHS_TOOL] };
|
|
1418
|
+
delete toolOpts.response_format;
|
|
1419
|
+
delete toolOpts.chat;
|
|
1420
|
+
|
|
1421
|
+
// Call 1: model either returns JSON directly or calls get_relation_paths.
|
|
1422
|
+
const raw = await llm.run(null, { ...toolOpts, chat: runChat });
|
|
1423
|
+
if (!raw?.hasToolCalls) {
|
|
1424
|
+
return typeof raw === "string" ? raw : raw?.content ?? "";
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Append the assistant message so call 2 has full context.
|
|
1428
|
+
if (raw.ai_sdk && Array.isArray(raw.messages)) {
|
|
1429
|
+
raw.messages.filter((m) => m.role === "assistant").forEach((m) => runChat.push(m));
|
|
1430
|
+
} else if (raw.tool_calls) {
|
|
1431
|
+
runChat.push({ role: "assistant", content: raw.content || null, tool_calls: raw.tool_calls });
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// Resolve the tool call(s) and push results.
|
|
1435
|
+
const schemaData = await build_schema_data();
|
|
1436
|
+
for (const tc of raw.getToolCalls()) {
|
|
1437
|
+
let resultText;
|
|
1438
|
+
if (tc.tool_name === "get_relation_paths") {
|
|
1439
|
+
const sections = getRelationPathsForPairs(tc.input.pairs || [], schemaData);
|
|
1440
|
+
resultText =
|
|
1441
|
+
sections.join("\n\n") +
|
|
1442
|
+
`\n\nSet the "relation" property to one of the strings listed above for each view_link.`;
|
|
1443
|
+
} else {
|
|
1444
|
+
resultText = `Unknown tool: ${tc.tool_name}`;
|
|
1445
|
+
}
|
|
1446
|
+
await llm_add_message.run("tool_response", resultText, { chat: runChat, tool_call: tc });
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Call 2: model has relation paths, now generates the layout JSON.
|
|
1450
|
+
const final = await llm.run(null, { ...opts, chat: runChat });
|
|
1451
|
+
return typeof final === "string" ? final : final?.content ?? "";
|
|
1452
|
+
};
|
|
1453
|
+
|
|
954
1454
|
module.exports = {
|
|
955
|
-
|
|
956
|
-
|
|
1455
|
+
normalizeLayoutCandidate,
|
|
1456
|
+
run: async (prompt, mode, table, existing_layout, chat) => {
|
|
957
1457
|
prompt = prompt.trim().replace(/^\[\w+\]:\s*/, "");
|
|
958
1458
|
|
|
959
1459
|
const ctx = await buildContext(mode, table);
|
|
@@ -961,28 +1461,46 @@ module.exports = {
|
|
|
961
1461
|
const llm = getState().functions.llm_generate;
|
|
962
1462
|
if (!llm?.run) throw new Error("LLM generator not configured");
|
|
963
1463
|
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
1464
|
+
const llmConfig = await getLlmConfigurationSafe();
|
|
1465
|
+
const allowResponseFormat = canUseResponseFormat(llmConfig);
|
|
1466
|
+
|
|
1467
|
+
let llmPrompt = buildPromptText(prompt, ctx, schema);
|
|
1468
|
+
if (existing_layout !== undefined && existing_layout !== null) {
|
|
1469
|
+
const layoutJson =
|
|
1470
|
+
typeof existing_layout === "string"
|
|
1471
|
+
? existing_layout
|
|
1472
|
+
: JSON.stringify(existing_layout);
|
|
1473
|
+
llmPrompt = `${llmPrompt}\n\nExisting layout (reproduce this with the requested changes applied):\n${layoutJson}`;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
967
1476
|
let responseFormat;
|
|
968
1477
|
try {
|
|
969
1478
|
if (!schema || !schema.schema) {
|
|
970
1479
|
throw new Error("Builder schema unavailable");
|
|
971
1480
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
if (schemaHasRef(deref)) {
|
|
975
|
-
console.warn(
|
|
976
|
-
"Builder response schema still contains $ref; skipping response_format",
|
|
977
|
-
);
|
|
1481
|
+
if (!allowResponseFormat) {
|
|
1482
|
+
console.warn("LLM backend does not support response_format; skipping");
|
|
978
1483
|
} else {
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
}
|
|
1484
|
+
validateSchemaRefs(schema.schema);
|
|
1485
|
+
let simplified = simplifySchemaForLlm(schema.schema);
|
|
1486
|
+
const optionalCount = countOptionalParams(simplified);
|
|
1487
|
+
console.log({ optionalCount });
|
|
1488
|
+
if (optionalCount > 24) {
|
|
1489
|
+
simplified = trimOptionalProperties(simplified, 24);
|
|
1490
|
+
}
|
|
1491
|
+
if (simplified && schemaHasRef(simplified)) {
|
|
1492
|
+
console.warn(
|
|
1493
|
+
"Builder response schema still contains $ref; skipping response_format"
|
|
1494
|
+
);
|
|
1495
|
+
} else if (simplified) {
|
|
1496
|
+
responseFormat = {
|
|
1497
|
+
type: "json_schema",
|
|
1498
|
+
json_schema: {
|
|
1499
|
+
name: "saltcorn_layout",
|
|
1500
|
+
schema: simplified,
|
|
1501
|
+
},
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
986
1504
|
}
|
|
987
1505
|
} catch (err) {
|
|
988
1506
|
console.warn("Builder response schema validation failed", err);
|
|
@@ -1003,13 +1521,12 @@ module.exports = {
|
|
|
1003
1521
|
if (!schema || !schema.schema) {
|
|
1004
1522
|
throw new Error("Builder schema unavailable");
|
|
1005
1523
|
}
|
|
1006
|
-
|
|
1007
|
-
rawResponse = await llm.run(llmPrompt, options);
|
|
1008
|
-
console.log(JSON.stringify(rawResponse, null, 2));
|
|
1524
|
+
rawResponse = await runWithRelationTools(llm, llmPrompt, options);
|
|
1009
1525
|
payload = parseJsonPayload(rawResponse);
|
|
1010
|
-
console.log(JSON.stringify({ payload }, null, 2));
|
|
1011
1526
|
const candidate = payload.layout ?? payload;
|
|
1012
|
-
|
|
1527
|
+
const result = normalizeLayoutCandidate(candidate, ctx);
|
|
1528
|
+
if (!result) throw new Error("Empty layout after normalization");
|
|
1529
|
+
return result;
|
|
1013
1530
|
} catch (err) {
|
|
1014
1531
|
let lastError = err;
|
|
1015
1532
|
try {
|
|
@@ -1017,7 +1534,9 @@ module.exports = {
|
|
|
1017
1534
|
rawResponse = await llm.run(repairPrompt, options);
|
|
1018
1535
|
payload = parseJsonPayload(rawResponse);
|
|
1019
1536
|
const candidate = payload.layout ?? payload;
|
|
1020
|
-
|
|
1537
|
+
const result = normalizeLayoutCandidate(candidate, ctx);
|
|
1538
|
+
if (!result) throw new Error("Empty layout after normalization");
|
|
1539
|
+
return result;
|
|
1021
1540
|
} catch (repairErr) {
|
|
1022
1541
|
lastError = repairErr;
|
|
1023
1542
|
}
|
|
@@ -1027,7 +1546,9 @@ module.exports = {
|
|
|
1027
1546
|
rawResponse = await llm.run(reducedPrompt, options);
|
|
1028
1547
|
payload = parseJsonPayload(rawResponse);
|
|
1029
1548
|
const candidate = payload.layout ?? payload;
|
|
1030
|
-
|
|
1549
|
+
const result = normalizeLayoutCandidate(candidate, ctx);
|
|
1550
|
+
if (!result) throw new Error("Empty layout after normalization");
|
|
1551
|
+
return result;
|
|
1031
1552
|
} catch (reducedErr) {
|
|
1032
1553
|
lastError = reducedErr;
|
|
1033
1554
|
}
|
|
@@ -1047,6 +1568,7 @@ module.exports = {
|
|
|
1047
1568
|
{ name: "prompt", type: "String" },
|
|
1048
1569
|
{ name: "mode", type: "String" },
|
|
1049
1570
|
{ name: "table", type: "String" },
|
|
1571
|
+
{ name: "existing_layout", type: "JSON" },
|
|
1050
1572
|
{ name: "chat", type: "JSON" },
|
|
1051
1573
|
],
|
|
1052
1574
|
};
|