@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-schema.js
CHANGED
|
@@ -41,6 +41,23 @@ const TOOLBOX_BY_MODE = {
|
|
|
41
41
|
],
|
|
42
42
|
list: [
|
|
43
43
|
"blank",
|
|
44
|
+
"list_columns",
|
|
45
|
+
"list_column",
|
|
46
|
+
"field",
|
|
47
|
+
"join_field",
|
|
48
|
+
"view_link",
|
|
49
|
+
"action",
|
|
50
|
+
"link",
|
|
51
|
+
"aggregation",
|
|
52
|
+
"view",
|
|
53
|
+
"container",
|
|
54
|
+
"dropdown_menu",
|
|
55
|
+
"line_break",
|
|
56
|
+
],
|
|
57
|
+
listcolumns: [
|
|
58
|
+
"blank",
|
|
59
|
+
"list_columns",
|
|
60
|
+
"list_column",
|
|
44
61
|
"field",
|
|
45
62
|
"join_field",
|
|
46
63
|
"view_link",
|
|
@@ -131,7 +148,6 @@ const buildSegmentDef = ({
|
|
|
131
148
|
});
|
|
132
149
|
|
|
133
150
|
const buildBuilderSchema = ({ mode, ctx }) => {
|
|
134
|
-
console.log({ ctx });
|
|
135
151
|
const normalizedMode = (mode || ctx?.mode || "show").toLowerCase();
|
|
136
152
|
const fields = ctx?.fields || [];
|
|
137
153
|
const actions = ctx?.actions || [];
|
|
@@ -141,9 +157,13 @@ const buildBuilderSchema = ({ mode, ctx }) => {
|
|
|
141
157
|
Array.isArray(rawIcons) ? rawIcons : Object.keys(rawIcons)
|
|
142
158
|
).slice(0, 15);
|
|
143
159
|
|
|
144
|
-
const
|
|
160
|
+
const isEditMode = normalizedMode === "edit" || normalizedMode === "filter";
|
|
161
|
+
const visibleFields = isEditMode
|
|
162
|
+
? fields.filter((f) => !f.calculated)
|
|
163
|
+
: fields;
|
|
164
|
+
const fieldNames = visibleFields.map((f) => f.name).filter(Boolean);
|
|
145
165
|
const fieldviewOptions = Array.from(
|
|
146
|
-
new Set(
|
|
166
|
+
new Set(visibleFields.flatMap((f) => f.fieldviews || []).filter(Boolean))
|
|
147
167
|
);
|
|
148
168
|
|
|
149
169
|
const defs = {
|
|
@@ -240,11 +260,11 @@ const buildBuilderSchema = ({ mode, ctx }) => {
|
|
|
240
260
|
},
|
|
241
261
|
imageLocation: makeEnum(
|
|
242
262
|
IMAGE_LOCATIONS,
|
|
243
|
-
"Image location for card/container backgrounds."
|
|
263
|
+
"Image location for card/container backgrounds."
|
|
244
264
|
),
|
|
245
265
|
imageSize: makeEnum(
|
|
246
266
|
IMAGE_SIZES,
|
|
247
|
-
"Image sizing for Card or Body locations."
|
|
267
|
+
"Image sizing for Card or Body locations."
|
|
248
268
|
),
|
|
249
269
|
gradStartColor: {
|
|
250
270
|
type: "string",
|
|
@@ -633,7 +653,7 @@ const buildBuilderSchema = ({ mode, ctx }) => {
|
|
|
633
653
|
{ $ref: "#/$defs/join_field" },
|
|
634
654
|
{ $ref: "#/$defs/list_column" },
|
|
635
655
|
{ $ref: "#/$defs/list_columns" },
|
|
636
|
-
{ $ref: "#/$defs/page" }
|
|
656
|
+
{ $ref: "#/$defs/page" }
|
|
637
657
|
);
|
|
638
658
|
|
|
639
659
|
Object.assign(defs, {
|
package/chat-copilot.js
CHANGED
|
@@ -382,6 +382,7 @@ const actionClasses = [
|
|
|
382
382
|
require("./actions/generate-workflow"),
|
|
383
383
|
require("./actions/generate-tables"),
|
|
384
384
|
require("./actions/generate-js-action"),
|
|
385
|
+
require("./actions/generate-trigger"),
|
|
385
386
|
require("./actions/generate-page"),
|
|
386
387
|
require("./actions/generate-view"),
|
|
387
388
|
];
|
package/common.js
CHANGED
|
@@ -14,6 +14,24 @@ const MarkdownIt = require("markdown-it"),
|
|
|
14
14
|
md = new MarkdownIt();
|
|
15
15
|
const HTMLParser = require("node-html-parser");
|
|
16
16
|
|
|
17
|
+
const getLlmConfigurationSafe = async () => {
|
|
18
|
+
const fn = getState().functions?.llm_get_configuration;
|
|
19
|
+
if (!fn?.run) return null;
|
|
20
|
+
try {
|
|
21
|
+
return await fn.run();
|
|
22
|
+
} catch (err) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const canUseResponseFormat = (llmConfig) => {
|
|
28
|
+
const backend = llmConfig?.backend;
|
|
29
|
+
if (!backend) return false;
|
|
30
|
+
if (backend === "AI SDK") return true;
|
|
31
|
+
if (backend === "OpenAI") return !!llmConfig?.responses_api;
|
|
32
|
+
return false;
|
|
33
|
+
};
|
|
34
|
+
|
|
17
35
|
const boxHandledStyles = new Set([
|
|
18
36
|
"margin",
|
|
19
37
|
"margin-top",
|
|
@@ -364,4 +382,6 @@ module.exports = {
|
|
|
364
382
|
splitContainerStyle,
|
|
365
383
|
walk_response,
|
|
366
384
|
parseHTML,
|
|
385
|
+
getLlmConfigurationSafe,
|
|
386
|
+
canUseResponseFormat,
|
|
367
387
|
};
|
package/copilot-as-agent.js
CHANGED
|
@@ -3,6 +3,7 @@ const Table = require("@saltcorn/data/models/table");
|
|
|
3
3
|
const Form = require("@saltcorn/data/models/form");
|
|
4
4
|
const View = require("@saltcorn/data/models/view");
|
|
5
5
|
const Trigger = require("@saltcorn/data/models/trigger");
|
|
6
|
+
const Plugin = require("@saltcorn/data/models/plugin");
|
|
6
7
|
const { findType } = require("@saltcorn/data/models/discovery");
|
|
7
8
|
const { save_menu_items } = require("@saltcorn/data/models/config");
|
|
8
9
|
const db = require("@saltcorn/data/db");
|
|
@@ -46,6 +47,12 @@ const get_agent_view = () => {
|
|
|
46
47
|
{ skill_type: "Database design" },
|
|
47
48
|
{ skill_type: "Generate Workflow" },
|
|
48
49
|
{ skill_type: "Generate View" },
|
|
50
|
+
{ skill_type: "Registry editor" },
|
|
51
|
+
{ skill_type: "Javascript Action" },
|
|
52
|
+
{ skill_type: "Generate trigger" },
|
|
53
|
+
...(typeof Plugin.loadAndSaveNewPlugin === "function"
|
|
54
|
+
? [{ skill_type: "Install Plugin" }]
|
|
55
|
+
: []),
|
|
49
56
|
],
|
|
50
57
|
},
|
|
51
58
|
});
|
|
@@ -64,7 +71,6 @@ const run = async (table_id, viewname, cfg, state, reqres) => {
|
|
|
64
71
|
};
|
|
65
72
|
|
|
66
73
|
const interact = async (table_id, viewname, config, body, reqres) => {
|
|
67
|
-
console.log("copilot interact with body", body);
|
|
68
74
|
const view = get_agent_view();
|
|
69
75
|
return await view.runRoute("interact", body, reqres.res, reqres);
|
|
70
76
|
};
|
package/index.js
CHANGED
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
const Workflow = require("@saltcorn/data/models/workflow");
|
|
2
2
|
const Form = require("@saltcorn/data/models/form");
|
|
3
3
|
const { features } = require("@saltcorn/data/db/state");
|
|
4
|
+
const db = require("@saltcorn/data/db");
|
|
5
|
+
const Plugin = require("@saltcorn/data/models/plugin");
|
|
6
|
+
const { viewname } = require("./app-constructor/common.js");
|
|
7
|
+
|
|
8
|
+
const headers = [
|
|
9
|
+
{
|
|
10
|
+
script: `/static_assets/${db.connectObj.version_tag}/mermaid.min.js`,
|
|
11
|
+
onlyViews: [viewname],
|
|
12
|
+
},
|
|
13
|
+
];
|
|
4
14
|
|
|
5
15
|
module.exports = {
|
|
6
16
|
sc_plugin_api_version: 1,
|
|
17
|
+
headers,
|
|
7
18
|
dependencies: ["@saltcorn/large-language-model", "@saltcorn/agents"],
|
|
8
19
|
viewtemplates: features.workflows
|
|
9
20
|
? [
|
|
10
21
|
require("./chat-copilot"),
|
|
11
22
|
require("./user-copilot"),
|
|
12
23
|
require("./copilot-as-agent"),
|
|
24
|
+
require("./app-constructor/view.js"),
|
|
13
25
|
]
|
|
14
26
|
: [require("./action-builder"), require("./database-designer")],
|
|
15
27
|
functions: features.workflows
|
|
@@ -17,15 +29,25 @@ module.exports = {
|
|
|
17
29
|
copilot_standard_prompt: require("./standard-prompt.js"),
|
|
18
30
|
copilot_generate_layout: require("./builder-gen.js"),
|
|
19
31
|
copilot_generate_workflow: require("./workflow-gen"),
|
|
32
|
+
copilot_generate_javascript: require("./js-code-gen.js"),
|
|
20
33
|
}
|
|
21
34
|
: {},
|
|
22
|
-
actions: {
|
|
35
|
+
actions: {
|
|
36
|
+
copilot_generate_page: require("./page-gen-action"),
|
|
37
|
+
app_constructor_feedback: require("./app-constructor/feedback-action.js"),
|
|
38
|
+
},
|
|
23
39
|
exchange: {
|
|
24
40
|
agent_skills: [
|
|
25
41
|
require("./agent-skills/pagegen.js"),
|
|
26
42
|
require("./agent-skills/database-design.js"),
|
|
27
43
|
require("./agent-skills/workflow.js"),
|
|
28
44
|
require("./agent-skills/viewgen.js"),
|
|
45
|
+
require("./agent-skills/registry-editor.js"),
|
|
46
|
+
require("./agent-skills/js-action.js"),
|
|
47
|
+
require("./agent-skills/triggergen.js"),
|
|
48
|
+
...(typeof Plugin.loadAndSaveNewPlugin === "function"
|
|
49
|
+
? [require("./agent-skills/install-plugin.js")]
|
|
50
|
+
: []),
|
|
29
51
|
],
|
|
30
52
|
},
|
|
31
53
|
};
|
package/js-code-gen.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const { getState } = require("@saltcorn/data/db/state");
|
|
2
|
+
const { getPromptFromTemplate } = require("./common");
|
|
3
|
+
const Table = require("@saltcorn/data/models/table");
|
|
4
|
+
|
|
5
|
+
const stripCodeFences = (text) => {
|
|
6
|
+
let s = String(text || "").trim();
|
|
7
|
+
s = s.replace(/^```(?:javascript|js|ts|typescript)?\s*\n?/i, "");
|
|
8
|
+
s = s.replace(/\n?```\s*$/i, "");
|
|
9
|
+
return s.trim();
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const getTableContext = (table_name) => {
|
|
13
|
+
if (!table_name) return null;
|
|
14
|
+
const table = Table.findOne({ name: table_name });
|
|
15
|
+
if (!table) return null;
|
|
16
|
+
const fields = table.getFields ? table.getFields() : table.fields || [];
|
|
17
|
+
return { table, fieldNames: fields.map((f) => f.name) };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
module.exports = {
|
|
21
|
+
run: async (description, existing_code, table_name) => {
|
|
22
|
+
const systemPrompt = await getPromptFromTemplate(
|
|
23
|
+
"action-builder.txt",
|
|
24
|
+
description
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const tableCtx = getTableContext(table_name);
|
|
28
|
+
let contextInfo;
|
|
29
|
+
if (tableCtx) {
|
|
30
|
+
contextInfo =
|
|
31
|
+
`\n\nThis action runs in the context of the "${table_name}" table. ` +
|
|
32
|
+
`Available variables: row (with fields: ${tableCtx.fieldNames.join(", ")}), ` +
|
|
33
|
+
`user, table, console, Actions, Table, File, User.`;
|
|
34
|
+
} else {
|
|
35
|
+
contextInfo =
|
|
36
|
+
"\n\nAvailable variables: user, console, Actions, Table, File, User.";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let prompt;
|
|
40
|
+
if (existing_code && existing_code.trim()) {
|
|
41
|
+
prompt =
|
|
42
|
+
`Modify the following JavaScript code based on this instruction: ${description}\n\n` +
|
|
43
|
+
`Existing code:\n${existing_code}` +
|
|
44
|
+
`${contextInfo}\n\n` +
|
|
45
|
+
`Return only the modified JavaScript code. Do not include any explanation or markdown code fences.`;
|
|
46
|
+
} else {
|
|
47
|
+
prompt =
|
|
48
|
+
`Generate JavaScript code for the following task: ${description}` +
|
|
49
|
+
`${contextInfo}\n\n` +
|
|
50
|
+
`Only return the JavaScript code. Do not include any explanation or markdown code fences.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = await getState().functions.llm_generate.run(prompt, {
|
|
54
|
+
systemPrompt,
|
|
55
|
+
});
|
|
56
|
+
return stripCodeFences(result);
|
|
57
|
+
},
|
|
58
|
+
isAsync: true,
|
|
59
|
+
description: "Generate JavaScript code for an action",
|
|
60
|
+
arguments: [
|
|
61
|
+
{ name: "description", type: "String" },
|
|
62
|
+
{ name: "existing_code", type: "String" },
|
|
63
|
+
{ name: "table_name", type: "String" },
|
|
64
|
+
],
|
|
65
|
+
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
const { RelationsFinder, RelationType } = require("@saltcorn/common-code");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Relation path documentation included in LLM system prompts (viewgen, builder-gen).
|
|
5
|
+
*
|
|
6
|
+
* TWO FORMATS EXIST:
|
|
7
|
+
*
|
|
8
|
+
* New format (always generate this):
|
|
9
|
+
* view: "viewname" + relation: ".sourcetable.segment1.segment2..."
|
|
10
|
+
*
|
|
11
|
+
* Segment types:
|
|
12
|
+
* Outbound FK (to parent): FK field name alone, e.g. trip_id
|
|
13
|
+
* Inbound FK (child rows): childtable$fkfield, e.g. packing_items$trip_id
|
|
14
|
+
*
|
|
15
|
+
* Examples:
|
|
16
|
+
* .trips.packing_items$trip_id ChildList: all packing_items for a trip
|
|
17
|
+
* .packing_items.trip_id ParentShow: the trip that owns a packing item
|
|
18
|
+
* .artists.artist_plays_on_album$artist.album ChildList through a join table
|
|
19
|
+
* .users.orders$user_id.order_lines$order_id RelationPath: multi-level
|
|
20
|
+
*
|
|
21
|
+
* Legacy format (may appear in existing configs — do not generate, understand only):
|
|
22
|
+
* The type and path are encoded together in the view field, no separate relation field.
|
|
23
|
+
* "Own:viewname" → same table, no relation
|
|
24
|
+
* "ParentShow:viewname.table.fkfield" → outbound FK to parent
|
|
25
|
+
* "ChildList:viewname.table.inbkey" → inbound FK, one-to-many
|
|
26
|
+
* "OneToOneShow:viewname.table.inbkey" → inbound FK, unique
|
|
27
|
+
* "Independent:viewname" → no FK relationship
|
|
28
|
+
*
|
|
29
|
+
* Relation types by new-format path structure:
|
|
30
|
+
* Own – zero segments, source and target are the same table
|
|
31
|
+
* ParentShow – single outbound-FK segment
|
|
32
|
+
* OneToOneShow – single inbound-FK segment on a unique field
|
|
33
|
+
* ChildList – one or more inbound-FK segments (may mix outbound for join tables)
|
|
34
|
+
* RelationPath – complex multi-level path mixing both segment types
|
|
35
|
+
*/
|
|
36
|
+
const RELATION_PATH_DOC = `
|
|
37
|
+
## Relation paths
|
|
38
|
+
|
|
39
|
+
Relation paths connect a view_link column or embedded view (type "view") segment to its
|
|
40
|
+
target view. There are two formats — **new** (preferred) and **legacy** (read-only).
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
### New format (always use this when generating or updating)
|
|
45
|
+
|
|
46
|
+
Two separate fields:
|
|
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"\`
|
|
49
|
+
|
|
50
|
+
**Path string format:** \`.sourcetable.segment1.segment2...\`
|
|
51
|
+
|
|
52
|
+
Segment types:
|
|
53
|
+
- **Outbound FK** (navigate to a parent): FK field name alone — e.g. \`trip_id\`
|
|
54
|
+
- **Inbound FK** (collect child rows): \`childtable$fkfield\` — e.g. \`packing_items$trip_id\`
|
|
55
|
+
|
|
56
|
+
Examples:
|
|
57
|
+
| relation string | type | meaning |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| \`.trips.packing_items$trip_id\` | ChildList | all packing_items for a trip |
|
|
60
|
+
| \`.packing_items.trip_id\` | ParentShow | the trip that owns a packing_item |
|
|
61
|
+
| \`.artists.artist_plays_on_album$artist.album\` | ChildList | albums via join table |
|
|
62
|
+
| \`.users.orders$user_id.order_lines$order_id\` | RelationPath | multi-level |
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
### Legacy format (you may encounter this in existing configs — do not generate it)
|
|
67
|
+
|
|
68
|
+
The type and path are encoded together inside the \`view\` field as a colon-prefixed string.
|
|
69
|
+
There is no separate \`relation\` field.
|
|
70
|
+
|
|
71
|
+
| legacy view field | equivalent new relation |
|
|
72
|
+
|---|---|
|
|
73
|
+
| \`"Own:viewname"\` | \`view: "viewname"\`, no relation |
|
|
74
|
+
| \`"ParentShow:viewname.table.fkfield"\` | \`relation: ".sourcetable.fkfield"\` |
|
|
75
|
+
| \`"ChildList:viewname.table.inbkey"\` | \`relation: ".sourcetable.childtable$inbkey"\` |
|
|
76
|
+
| \`"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
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### Using get_relation_paths
|
|
84
|
+
|
|
85
|
+
Call it **once** with all source_table/target_view pairs you need — do not make separate calls per pair. The tool returns paths in the new RelationPath format; always write back in the new format, even if you read legacy strings from an existing config.
|
|
86
|
+
|
|
87
|
+
When multiple paths are returned for one pair, pick by matching the relation type to what the target view is meant to show:
|
|
88
|
+
- **ChildList** — target view shows multiple rows belonging to the current row (e.g. packing items for a trip).
|
|
89
|
+
- **ParentShow** — target view shows the single parent the current row belongs to (e.g. the trip for a packing item).
|
|
90
|
+
- **OneToOneShow** — exactly one related child row via a unique FK.
|
|
91
|
+
- **Own** — target view is on the same table (no FK traversal needed).
|
|
92
|
+
- If multiple paths of the same type exist (e.g. a table has two FKs pointing to the same target), pick the one whose FK field name best matches the semantic relationship in the task.
|
|
93
|
+
- Prefer shorter paths (fewer segments) unless a longer one is clearly more appropriate.
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
const typeToLabel = (type) => {
|
|
97
|
+
if (type === RelationType.OWN)
|
|
98
|
+
return "Own – source and target are the same table (no relation needed)";
|
|
99
|
+
if (type === RelationType.INDEPENDENT)
|
|
100
|
+
return "Independent – no FK relationship exists";
|
|
101
|
+
if (type === RelationType.PARENT_SHOW)
|
|
102
|
+
return "ParentShow – outbound FK to a parent record (many-to-one)";
|
|
103
|
+
if (type === RelationType.ONE_TO_ONE_SHOW)
|
|
104
|
+
return "OneToOneShow – unique inbound FK (one-to-one)";
|
|
105
|
+
if (type === RelationType.CHILD_LIST)
|
|
106
|
+
return "ChildList – inbound FK, one parent → many child rows";
|
|
107
|
+
return "RelationPath – complex multi-level path";
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} sourceTableName
|
|
112
|
+
* @param {string} targetViewName
|
|
113
|
+
* @param {{ tables, views }} schemaData pre-fetched via build_schema_data()
|
|
114
|
+
* @returns {Array<Relation>} raw Relation objects from RelationsFinder
|
|
115
|
+
*/
|
|
116
|
+
function getRelationPaths(sourceTableName, targetViewName, schemaData) {
|
|
117
|
+
if (!schemaData) return [];
|
|
118
|
+
try {
|
|
119
|
+
const finder = new RelationsFinder(schemaData.tables, schemaData.views, 6);
|
|
120
|
+
return finder.findRelations(sourceTableName, targetViewName, []);
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve multiple source_table/target_view pairs against pre-fetched schema data.
|
|
128
|
+
* All per-pair work is synchronous — call build_schema_data() once before invoking this.
|
|
129
|
+
* @param {Array<{source_table: string, target_view: string}>} pairs
|
|
130
|
+
* @param {{ tables, views }} schemaData
|
|
131
|
+
* @returns {Array<string>} one formatted result string per pair
|
|
132
|
+
*/
|
|
133
|
+
function getRelationPathsForPairs(pairs, schemaData) {
|
|
134
|
+
if (!schemaData) return pairs.map(({ source_table, target_view }) =>
|
|
135
|
+
formatRelationPathResult(source_table, target_view, { error: "Schema data unavailable" })
|
|
136
|
+
);
|
|
137
|
+
const finder = new RelationsFinder(schemaData.tables, schemaData.views, 6);
|
|
138
|
+
return pairs.map(({ source_table, target_view }) => {
|
|
139
|
+
const targetView = (schemaData.views || []).find((v) => v.name === target_view);
|
|
140
|
+
if (!targetView)
|
|
141
|
+
return formatRelationPathResult(source_table, target_view, {
|
|
142
|
+
error: `View "${target_view}" not found in current schema`,
|
|
143
|
+
});
|
|
144
|
+
let relations;
|
|
145
|
+
try {
|
|
146
|
+
relations = finder.findRelations(source_table, target_view, []);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
return formatRelationPathResult(source_table, target_view, {
|
|
149
|
+
error: `Failed to find relations: ${e.message}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return formatRelationPathResult(source_table, target_view, {
|
|
153
|
+
paths: relations.map((r) => ({
|
|
154
|
+
relation_string: r.relationString,
|
|
155
|
+
type: String(r.type),
|
|
156
|
+
label: typeToLabel(r.type),
|
|
157
|
+
})),
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Pick the most useful relation from a list: Own > Parent > Child > first.
|
|
164
|
+
* Used as a fallback in builder-gen when the model doesn't specify a relation.
|
|
165
|
+
*/
|
|
166
|
+
function pickBestRelation(relations) {
|
|
167
|
+
if (!relations.length) return null;
|
|
168
|
+
let own = null,
|
|
169
|
+
parent = null,
|
|
170
|
+
child = null;
|
|
171
|
+
for (const r of relations) {
|
|
172
|
+
if (r.type === RelationType.OWN) own = r;
|
|
173
|
+
else if (r.type === RelationType.PARENT_SHOW) parent = r;
|
|
174
|
+
else if (
|
|
175
|
+
r.type === RelationType.CHILD_LIST ||
|
|
176
|
+
r.type === RelationType.ONE_TO_ONE_SHOW
|
|
177
|
+
)
|
|
178
|
+
child = r;
|
|
179
|
+
}
|
|
180
|
+
return own || parent || child || relations[0];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Format the result of getRelationPaths into a human-readable string for the model.
|
|
185
|
+
* Handles both found and not-found cases for one source_table/target_view pair.
|
|
186
|
+
*/
|
|
187
|
+
function formatRelationPathResult(source_table, target_view, result) {
|
|
188
|
+
if (result.error) return `${source_table} → ${target_view}: ${result.error}`;
|
|
189
|
+
if (!result.paths.length)
|
|
190
|
+
return `${source_table} → ${target_view}: no relation paths found (no FK relationship)`;
|
|
191
|
+
const lines = result.paths
|
|
192
|
+
.map((p) => ` "${p.relation_string}" — ${p.label}`)
|
|
193
|
+
.join("\n");
|
|
194
|
+
return `${source_table} → ${target_view}:\n${lines}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const GET_RELATION_PATHS_FUNCTION = {
|
|
198
|
+
name: "get_relation_paths",
|
|
199
|
+
description:
|
|
200
|
+
"Get all valid relation path strings for one or more source_table/target_view pairs. " +
|
|
201
|
+
"Call this ONCE with all pairs you need before setting any 'relation' property on " +
|
|
202
|
+
"view_link columns or embedded view (type 'view') segments.",
|
|
203
|
+
parameters: {
|
|
204
|
+
type: "object",
|
|
205
|
+
required: ["pairs"],
|
|
206
|
+
properties: {
|
|
207
|
+
pairs: {
|
|
208
|
+
type: "array",
|
|
209
|
+
description:
|
|
210
|
+
"All source_table/target_view pairs you need relation paths for. Include every pair in one call.",
|
|
211
|
+
items: {
|
|
212
|
+
type: "object",
|
|
213
|
+
required: ["source_table", "target_view"],
|
|
214
|
+
properties: {
|
|
215
|
+
source_table: {
|
|
216
|
+
type: "string",
|
|
217
|
+
description: "The table of the view being built or updated.",
|
|
218
|
+
},
|
|
219
|
+
target_view: {
|
|
220
|
+
type: "string",
|
|
221
|
+
description: "The view to link to or embed.",
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
RELATION_PATH_DOC,
|
|
232
|
+
GET_RELATION_PATHS_FUNCTION,
|
|
233
|
+
getRelationPaths,
|
|
234
|
+
getRelationPathsForPairs,
|
|
235
|
+
pickBestRelation,
|
|
236
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const { getState } = require("@saltcorn/data/db/state");
|
|
2
|
+
const View = require("@saltcorn/data/models/view");
|
|
3
|
+
const Table = require("@saltcorn/data/models/table");
|
|
4
|
+
const Plugin = require("@saltcorn/data/models/plugin");
|
|
5
|
+
const Trigger = require("@saltcorn/data/models/trigger");
|
|
6
|
+
const WorkflowStep = require("@saltcorn/data/models/workflow_step");
|
|
7
|
+
const WorkflowRun = require("@saltcorn/data/models/workflow_run");
|
|
8
|
+
|
|
9
|
+
const { mockReqRes } = require("@saltcorn/data/tests/mocks");
|
|
10
|
+
const { afterAll, beforeAll, describe, it, expect } = require("@jest/globals");
|
|
11
|
+
|
|
12
|
+
afterAll(require("@saltcorn/data/db").close);
|
|
13
|
+
beforeAll(async () => {
|
|
14
|
+
await require("@saltcorn/data/db/reset_schema")();
|
|
15
|
+
await require("@saltcorn/data/db/fixtures")();
|
|
16
|
+
|
|
17
|
+
getState().registerPlugin("base", require("@saltcorn/data/base-plugin"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
|
|
22
|
+
RUN WITH:
|
|
23
|
+
saltcorn dev:plugin-test -d ~/copilot -o ~/large-language-model/
|
|
24
|
+
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
jest.setTimeout(60000);
|
|
28
|
+
|
|
29
|
+
const configs = require("./configs.js");
|
|
30
|
+
|
|
31
|
+
for (const nameconfig of configs) {
|
|
32
|
+
const { name, ...config } = nameconfig;
|
|
33
|
+
describe("copilot_generate_layout with " + name, () => {
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
getState().registerPlugin(
|
|
36
|
+
"@saltcorn/large-language-model",
|
|
37
|
+
require("@saltcorn/large-language-model"),
|
|
38
|
+
config,
|
|
39
|
+
);
|
|
40
|
+
getState().registerPlugin("@saltcorn/copilot", require(".."));
|
|
41
|
+
});
|
|
42
|
+
for (const mode of ["page", "show", "edit", "filter"])
|
|
43
|
+
it("generates simple layout in mode " + mode, async () => {
|
|
44
|
+
const genres = await getState().functions.copilot_generate_layout.run(
|
|
45
|
+
"A container with a text element that says Hello World in H3",
|
|
46
|
+
mode,
|
|
47
|
+
mode === "page" ? null : "books",
|
|
48
|
+
);
|
|
49
|
+
const innerRes = genres.above ? genres.above[0] : genres;
|
|
50
|
+
expect(innerRes.type).toBe("container");
|
|
51
|
+
expect(innerRes.contents.type).toBe("blank");
|
|
52
|
+
expect(innerRes.contents.contents).toBe("Hello World");
|
|
53
|
+
expect(innerRes.contents.textStyle).toBe("h3");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|