@saltcorn/copilot 0.8.1 → 0.8.2
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-tables.js +14 -0
- package/agent-skills/pagegen.js +20 -6
- package/agent-skills/registry-editor.js +15 -0
- package/agent-skills/triggergen.js +1 -5
- package/agent-skills/viewgen.js +49 -7
- package/app-constructor/requirements.js +98 -36
- package/app-constructor/run_task.js +67 -37
- package/app-constructor/schema.js +214 -49
- package/app-constructor/tasks.js +401 -46
- package/app-constructor/tools.js +5 -4
- package/app-constructor/view.js +7 -0
- package/builder-gen.js +79 -33
- package/copilot-as-agent.js +1 -0
- package/index.js +0 -1
- package/js-code-gen.js +1 -0
- package/package.json +1 -1
- package/relation-paths.js +73 -40
- package/standard-prompt.js +1 -0
- package/user-copilot.js +2 -0
- package/workflow-gen.js +1 -0
- package/chat-copilot.js +0 -770
package/builder-gen.js
CHANGED
|
@@ -79,7 +79,9 @@ only use "show_with_html" when the task explicitly requires rendering HTML conte
|
|
|
79
79
|
|
|
80
80
|
const LISTCOLUMNS_VIEW_LINK = `\
|
|
81
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
|
|
82
|
+
Only include such a view_link if a Show view for this table is listed in the available views. \
|
|
83
|
+
Every view_link requires a "relation" string — you MUST call get_relation_paths before generating the layout \
|
|
84
|
+
whenever the layout will contain any view_link or embedded view segment.`;
|
|
83
85
|
|
|
84
86
|
// ── Composed MODE_GUIDANCE ────────────────────────────────────────────────────
|
|
85
87
|
|
|
@@ -805,7 +807,8 @@ const normalizeSegment = (segment, ctx) => {
|
|
|
805
807
|
let viewLabel = clone.view_label || clone.view;
|
|
806
808
|
let isFormula = clone.isFormula || {};
|
|
807
809
|
const tmplMatch =
|
|
808
|
-
typeof viewLabel === "string" &&
|
|
810
|
+
typeof viewLabel === "string" &&
|
|
811
|
+
viewLabel.match(/^\{\{\s*(.+?)\s*\}\}$/);
|
|
809
812
|
if (tmplMatch) {
|
|
810
813
|
viewLabel = tmplMatch[1];
|
|
811
814
|
isFormula = { ...isFormula, label: true };
|
|
@@ -843,7 +846,9 @@ const normalizeSegment = (segment, ctx) => {
|
|
|
843
846
|
child == null ? null : normalizeSegment(child, ctx)
|
|
844
847
|
);
|
|
845
848
|
if (!besides.some(Boolean)) return null;
|
|
846
|
-
|
|
849
|
+
// Drop 'type' — List viewtemplate expects { besides, list_columns: true } with no type field
|
|
850
|
+
const { type: _t, ...rest } = clone;
|
|
851
|
+
return { ...rest, besides, list_columns: true };
|
|
847
852
|
}
|
|
848
853
|
case "list_column": {
|
|
849
854
|
let raw = clone.contents;
|
|
@@ -961,7 +966,21 @@ const normalizeLayoutCandidate = (candidate, ctx) => {
|
|
|
961
966
|
normalized = convertForeignLayout(candidate, ctx);
|
|
962
967
|
}
|
|
963
968
|
if (!normalized) return null;
|
|
964
|
-
|
|
969
|
+
let layout = Array.isArray(normalized) ? { above: normalized } : normalized;
|
|
970
|
+
|
|
971
|
+
// For listcolumns mode: if the LLM wrapped the list in a stack, unwrap it
|
|
972
|
+
if (
|
|
973
|
+
ctx.mode === "listcolumns" &&
|
|
974
|
+
layout.type === "stack" &&
|
|
975
|
+
Array.isArray(layout.above)
|
|
976
|
+
) {
|
|
977
|
+
const listChild = layout.above.find((c) => c?.list_columns);
|
|
978
|
+
if (listChild) {
|
|
979
|
+
const { type: _t, ...rest } = listChild;
|
|
980
|
+
layout = rest;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
965
984
|
return sanitizeNoHtmlSegments(layout);
|
|
966
985
|
};
|
|
967
986
|
|
|
@@ -1000,6 +1019,11 @@ const buildPromptText = (userPrompt, ctx, schema) => {
|
|
|
1000
1019
|
schema
|
|
1001
1020
|
)}`
|
|
1002
1021
|
);
|
|
1022
|
+
parts.push(
|
|
1023
|
+
`TOOL CALL REQUIRED: If the layout you are about to generate contains any view_link or embedded view (type "view") segment, ` +
|
|
1024
|
+
`you MUST call get_relation_paths with all required source_table/target_view pairs BEFORE returning JSON. ` +
|
|
1025
|
+
`Do NOT guess or omit the "relation" field — return the tool call first and use the result in the layout.`
|
|
1026
|
+
);
|
|
1003
1027
|
parts.push(
|
|
1004
1028
|
`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}"`
|
|
1005
1029
|
);
|
|
@@ -1220,7 +1244,6 @@ const buildBracketObject = (node) => {
|
|
|
1220
1244
|
return obj;
|
|
1221
1245
|
};
|
|
1222
1246
|
|
|
1223
|
-
|
|
1224
1247
|
const buildContext = async (mode, tableName) => {
|
|
1225
1248
|
const normalizedMode = (mode || "show").toLowerCase();
|
|
1226
1249
|
const ctx = {
|
|
@@ -1400,16 +1423,19 @@ const buildErrorLayout = ({ message, mode, table }) => {
|
|
|
1400
1423
|
};
|
|
1401
1424
|
};
|
|
1402
1425
|
|
|
1403
|
-
const GET_RELATION_PATHS_TOOL = {
|
|
1426
|
+
const GET_RELATION_PATHS_TOOL = {
|
|
1427
|
+
type: "function",
|
|
1428
|
+
function: GET_RELATION_PATHS_FUNCTION,
|
|
1429
|
+
};
|
|
1404
1430
|
|
|
1405
1431
|
/**
|
|
1406
|
-
* Run the LLM
|
|
1407
|
-
*
|
|
1432
|
+
* Run the LLM allowing up to MAX_TOOL_ROUNDS get_relation_paths calls (depth
|
|
1433
|
+
* escalation: 2 → 4 → 6) before producing the final layout JSON.
|
|
1408
1434
|
*/
|
|
1435
|
+
const MAX_TOOL_ROUNDS = 4;
|
|
1409
1436
|
const runWithRelationTools = async (llm, mainPrompt, opts) => {
|
|
1410
1437
|
const llm_add_message = getState().functions.llm_add_message;
|
|
1411
1438
|
|
|
1412
|
-
// Local chat copy with the main prompt pre-loaded so all iterations see it.
|
|
1413
1439
|
const runChat = Array.isArray(opts.chat) ? [...opts.chat] : [];
|
|
1414
1440
|
runChat.push({ role: "user", content: mainPrompt });
|
|
1415
1441
|
|
|
@@ -1418,35 +1444,54 @@ const runWithRelationTools = async (llm, mainPrompt, opts) => {
|
|
|
1418
1444
|
delete toolOpts.response_format;
|
|
1419
1445
|
delete toolOpts.chat;
|
|
1420
1446
|
|
|
1421
|
-
|
|
1422
|
-
const raw = await llm.run(null, { ...toolOpts, chat: runChat });
|
|
1423
|
-
if (!raw?.hasToolCalls) {
|
|
1424
|
-
return typeof raw === "string" ? raw : raw?.content ?? "";
|
|
1425
|
-
}
|
|
1447
|
+
const schemaData = await build_schema_data();
|
|
1426
1448
|
|
|
1427
|
-
|
|
1428
|
-
|
|
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
|
-
}
|
|
1449
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
1450
|
+
const raw = await llm.run(null, { ...toolOpts, chat: runChat });
|
|
1433
1451
|
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
} else {
|
|
1444
|
-
|
|
1452
|
+
if (!raw?.hasToolCalls) {
|
|
1453
|
+
return typeof raw === "string" ? raw : raw?.content ?? "";
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Append the assistant message so the next round has full context.
|
|
1457
|
+
if (raw.ai_sdk && Array.isArray(raw.messages)) {
|
|
1458
|
+
raw.messages
|
|
1459
|
+
.filter((m) => m.role === "assistant")
|
|
1460
|
+
.forEach((m) => runChat.push(m));
|
|
1461
|
+
} else if (raw.tool_calls) {
|
|
1462
|
+
runChat.push({
|
|
1463
|
+
role: "assistant",
|
|
1464
|
+
content: raw.content || null,
|
|
1465
|
+
tool_calls: raw.tool_calls,
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
for (const tc of raw.getToolCalls()) {
|
|
1470
|
+
let resultText;
|
|
1471
|
+
if (tc.tool_name === "get_relation_paths") {
|
|
1472
|
+
const maxDepth = tc.input.max_depth ?? 2;
|
|
1473
|
+
const sections = getRelationPathsForPairs(
|
|
1474
|
+
tc.input.pairs || [],
|
|
1475
|
+
schemaData,
|
|
1476
|
+
maxDepth
|
|
1477
|
+
);
|
|
1478
|
+
resultText =
|
|
1479
|
+
sections.join("\n\n") +
|
|
1480
|
+
`\n\nFor each pair above: analyse whether the listed paths include a suitable one for the intended relation type (ChildList, ParentShow, Own, etc.). ` +
|
|
1481
|
+
`If a suitable path exists, set the "relation" property to the chosen path string for all types including Own. ` +
|
|
1482
|
+
`If no suitable path exists for a pair (no paths listed, or none match the intended type), call get_relation_paths again with a higher max_depth (4, then 6). ` +
|
|
1483
|
+
`Do not escalate just because multiple paths are listed — only escalate when none is appropriate.`;
|
|
1484
|
+
} else {
|
|
1485
|
+
resultText = `Unknown tool: ${tc.tool_name}`;
|
|
1486
|
+
}
|
|
1487
|
+
await llm_add_message.run("tool_response", resultText, {
|
|
1488
|
+
chat: runChat,
|
|
1489
|
+
tool_call: tc,
|
|
1490
|
+
});
|
|
1445
1491
|
}
|
|
1446
|
-
await llm_add_message.run("tool_response", resultText, { chat: runChat, tool_call: tc });
|
|
1447
1492
|
}
|
|
1448
1493
|
|
|
1449
|
-
//
|
|
1494
|
+
// Exhausted tool rounds — force final JSON answer.
|
|
1450
1495
|
const final = await llm.run(null, { ...opts, chat: runChat });
|
|
1451
1496
|
return typeof final === "string" ? final : final?.content ?? "";
|
|
1452
1497
|
};
|
|
@@ -1564,6 +1609,7 @@ module.exports = {
|
|
|
1564
1609
|
},
|
|
1565
1610
|
isAsync: true,
|
|
1566
1611
|
description: "Generate a builder layout",
|
|
1612
|
+
hidden: true,
|
|
1567
1613
|
arguments: [
|
|
1568
1614
|
{ name: "prompt", type: "String" },
|
|
1569
1615
|
{ name: "mode", type: "String" },
|
package/copilot-as-agent.js
CHANGED
package/index.js
CHANGED
|
@@ -18,7 +18,6 @@ module.exports = {
|
|
|
18
18
|
dependencies: ["@saltcorn/large-language-model", "@saltcorn/agents"],
|
|
19
19
|
viewtemplates: features.workflows
|
|
20
20
|
? [
|
|
21
|
-
require("./chat-copilot"),
|
|
22
21
|
require("./user-copilot"),
|
|
23
22
|
require("./copilot-as-agent"),
|
|
24
23
|
require("./app-constructor/view.js"),
|
package/js-code-gen.js
CHANGED
package/package.json
CHANGED
package/relation-paths.js
CHANGED
|
@@ -36,66 +36,72 @@ const { RelationsFinder, RelationType } = require("@saltcorn/common-code");
|
|
|
36
36
|
const RELATION_PATH_DOC = `
|
|
37
37
|
## Relation paths
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
Every view_link and embedded view segment requires two fields:
|
|
40
|
+
- \`view\`: the view name (plain string, e.g. \`"packing_items_list"\`)
|
|
41
|
+
- \`relation\`: a dot-separated path string (e.g. \`".trips.packing_items$trip_id"\`)
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
**Always use this format. Never generate anything else.**
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
---
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
- \`view\`: just the view name, e.g. \`"packing_items_list"\`
|
|
48
|
-
- \`relation\`: a dot-separated path string, e.g. \`".trips.packing_items$trip_id"\`
|
|
47
|
+
### Relation path format
|
|
49
48
|
|
|
50
|
-
|
|
49
|
+
\`.sourcetable.segment1.segment2...\`
|
|
51
50
|
|
|
52
51
|
Segment types:
|
|
53
|
-
- **Outbound FK** (
|
|
54
|
-
- **Inbound FK** (
|
|
52
|
+
- **Outbound FK** (to a parent): FK field name alone — e.g. \`trip_id\`
|
|
53
|
+
- **Inbound FK** (child rows): \`childtable$fkfield\` — e.g. \`packing_items$trip_id\`
|
|
54
|
+
- **Same table** (no FK traversal): just the source table, no segments — e.g. \`".invoice_line_items"\`
|
|
55
55
|
|
|
56
56
|
Examples:
|
|
57
|
-
| relation string |
|
|
58
|
-
|
|
59
|
-
| \`.
|
|
60
|
-
| \`.packing_items
|
|
61
|
-
| \`.
|
|
62
|
-
| \`.
|
|
57
|
+
| relation string | meaning |
|
|
58
|
+
|---|---|
|
|
59
|
+
| \`.invoice_line_items\` | same-table link (no FK traversal) |
|
|
60
|
+
| \`.trips.packing_items$trip_id\` | all packing_items for a trip |
|
|
61
|
+
| \`.packing_items.trip_id\` | the trip that owns a packing_item |
|
|
62
|
+
| \`.artists.artist_plays_on_album$artist.album\` | albums via join table |
|
|
63
|
+
| \`.users.orders$user_id.order_lines$order_id\` | multi-level |
|
|
63
64
|
|
|
64
65
|
---
|
|
65
66
|
|
|
66
|
-
### Legacy format
|
|
67
|
+
### Legacy format — read-only, never generate
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
Any \`view\` field value that contains a colon (e.g. \`"ChildList:trips_list.packing_items.trip_id"\`,
|
|
70
|
+
\`"Own:viewname"\`, \`"ParentShow:viewname.table.fkfield"\`) is legacy. You may encounter it in
|
|
71
|
+
existing configs. Parse it to understand the relation, then always write back in the new format.
|
|
70
72
|
|
|
71
|
-
| legacy view field |
|
|
73
|
+
| legacy view field | new format equivalent |
|
|
72
74
|
|---|---|
|
|
73
|
-
| \`"Own:viewname"\` | \`
|
|
75
|
+
| \`"Own:viewname"\` | \`relation: ".sourcetable"\` |
|
|
76
|
+
| \`"Independent:viewname"\` | no \`relation\` field needed |
|
|
74
77
|
| \`"ParentShow:viewname.table.fkfield"\` | \`relation: ".sourcetable.fkfield"\` |
|
|
75
78
|
| \`"ChildList:viewname.table.inbkey"\` | \`relation: ".sourcetable.childtable$inbkey"\` |
|
|
76
79
|
| \`"OneToOneShow:viewname.table.inbkey"\` | \`relation: ".sourcetable.childtable$inbkey"\` |
|
|
77
|
-
| \`"Independent:viewname"\` | \`view: "viewname"\`, no relation |
|
|
78
|
-
|
|
79
|
-
When you read an existing view config and see a legacy \`view\` value like \`"ChildList:trips_list.packing_items.trip_id"\`, parse it as: type=ChildList, view=trips_list, relation=.sourcetable.packing_items$trip_id. When writing back, convert to the new format.
|
|
80
80
|
|
|
81
81
|
---
|
|
82
82
|
|
|
83
83
|
### Using get_relation_paths
|
|
84
84
|
|
|
85
|
-
Call it **once** with all source_table/target_view pairs you need
|
|
85
|
+
Call it **once** with all source_table/target_view pairs you need. The tool always returns new-format
|
|
86
|
+
path strings — use them as the \`relation\` field directly.
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
**Depth escalation:** Start with \`max_depth=2\`. After receiving results, analyse each pair: does the
|
|
89
|
+
result contain a path that matches the intended relation type? If yes, use it. If a pair has no
|
|
90
|
+
suitable path (no paths at all, or none of the right type), call again with \`max_depth=4\`, then
|
|
91
|
+
\`max_depth=6\` if still none. Do not escalate just because multiple paths are listed.
|
|
92
|
+
|
|
93
|
+
Selecting among returned paths:
|
|
94
|
+
- **ChildList** — target view shows multiple rows belonging to the current row.
|
|
95
|
+
- **ParentShow** — target view shows the single parent the current row belongs to.
|
|
90
96
|
- **OneToOneShow** — exactly one related child row via a unique FK.
|
|
91
|
-
- **Own** —
|
|
92
|
-
- If multiple paths of the same type exist
|
|
97
|
+
- **Own** — same table, no FK traversal. Relation string is just \`.sourcetable\`.
|
|
98
|
+
- If multiple paths of the same type exist, pick the one whose FK field name best matches the task.
|
|
93
99
|
- Prefer shorter paths (fewer segments) unless a longer one is clearly more appropriate.
|
|
94
100
|
`;
|
|
95
101
|
|
|
96
102
|
const typeToLabel = (type) => {
|
|
97
103
|
if (type === RelationType.OWN)
|
|
98
|
-
return "Own – source and target are the same table (no
|
|
104
|
+
return "Own – source and target are the same table. Use this relation string as-is (no extra segments after the table name).";
|
|
99
105
|
if (type === RelationType.INDEPENDENT)
|
|
100
106
|
return "Independent – no FK relationship exists";
|
|
101
107
|
if (type === RelationType.PARENT_SHOW)
|
|
@@ -113,10 +119,19 @@ const typeToLabel = (type) => {
|
|
|
113
119
|
* @param {{ tables, views }} schemaData pre-fetched via build_schema_data()
|
|
114
120
|
* @returns {Array<Relation>} raw Relation objects from RelationsFinder
|
|
115
121
|
*/
|
|
116
|
-
function getRelationPaths(
|
|
122
|
+
function getRelationPaths(
|
|
123
|
+
sourceTableName,
|
|
124
|
+
targetViewName,
|
|
125
|
+
schemaData,
|
|
126
|
+
maxDepth = 6
|
|
127
|
+
) {
|
|
117
128
|
if (!schemaData) return [];
|
|
118
129
|
try {
|
|
119
|
-
const finder = new RelationsFinder(
|
|
130
|
+
const finder = new RelationsFinder(
|
|
131
|
+
schemaData.tables,
|
|
132
|
+
schemaData.views,
|
|
133
|
+
maxDepth
|
|
134
|
+
);
|
|
120
135
|
return finder.findRelations(sourceTableName, targetViewName, []);
|
|
121
136
|
} catch {
|
|
122
137
|
return [];
|
|
@@ -130,13 +145,22 @@ function getRelationPaths(sourceTableName, targetViewName, schemaData) {
|
|
|
130
145
|
* @param {{ tables, views }} schemaData
|
|
131
146
|
* @returns {Array<string>} one formatted result string per pair
|
|
132
147
|
*/
|
|
133
|
-
function getRelationPathsForPairs(pairs, schemaData) {
|
|
134
|
-
if (!schemaData)
|
|
135
|
-
|
|
148
|
+
function getRelationPathsForPairs(pairs, schemaData, maxDepth = 6) {
|
|
149
|
+
if (!schemaData)
|
|
150
|
+
return pairs.map(({ source_table, target_view }) =>
|
|
151
|
+
formatRelationPathResult(source_table, target_view, {
|
|
152
|
+
error: "Schema data unavailable",
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
const finder = new RelationsFinder(
|
|
156
|
+
schemaData.tables,
|
|
157
|
+
schemaData.views,
|
|
158
|
+
maxDepth
|
|
136
159
|
);
|
|
137
|
-
const finder = new RelationsFinder(schemaData.tables, schemaData.views, 6);
|
|
138
160
|
return pairs.map(({ source_table, target_view }) => {
|
|
139
|
-
const targetView = (schemaData.views || []).find(
|
|
161
|
+
const targetView = (schemaData.views || []).find(
|
|
162
|
+
(v) => v.name === target_view
|
|
163
|
+
);
|
|
140
164
|
if (!targetView)
|
|
141
165
|
return formatRelationPathResult(source_table, target_view, {
|
|
142
166
|
error: `View "${target_view}" not found in current schema`,
|
|
@@ -198,8 +222,11 @@ const GET_RELATION_PATHS_FUNCTION = {
|
|
|
198
222
|
name: "get_relation_paths",
|
|
199
223
|
description:
|
|
200
224
|
"Get all valid relation path strings for one or more source_table/target_view pairs. " +
|
|
201
|
-
"Call this
|
|
202
|
-
"
|
|
225
|
+
"Call this before setting any 'relation' property on view_link columns or embedded view segments. " +
|
|
226
|
+
"Always start with max_depth=2 to keep the result compact. " +
|
|
227
|
+
"After receiving the results, analyse whether each pair has a suitable path for its intended relation type. " +
|
|
228
|
+
"If a pair has no suitable path, call again with max_depth=4, then max_depth=6 if still none. " +
|
|
229
|
+
"Stop as soon as every pair has a suitable path.",
|
|
203
230
|
parameters: {
|
|
204
231
|
type: "object",
|
|
205
232
|
required: ["pairs"],
|
|
@@ -223,6 +250,12 @@ const GET_RELATION_PATHS_FUNCTION = {
|
|
|
223
250
|
},
|
|
224
251
|
},
|
|
225
252
|
},
|
|
253
|
+
max_depth: {
|
|
254
|
+
type: "integer",
|
|
255
|
+
description:
|
|
256
|
+
"Maximum join depth to search. Start with 2. Escalate to 4, then 6, only if a pair has no suitable path in the current results — not just because paths exist, but because none match the intended relation type.",
|
|
257
|
+
default: 2,
|
|
258
|
+
},
|
|
226
259
|
},
|
|
227
260
|
},
|
|
228
261
|
};
|
package/standard-prompt.js
CHANGED
package/user-copilot.js
CHANGED