@saltcorn/copilot 0.8.0 → 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.
@@ -0,0 +1,61 @@
1
+ const Trigger = require("@saltcorn/data/models/trigger");
2
+ const Table = require("@saltcorn/data/models/table");
3
+ const { a, div, pre } = require("@saltcorn/markup/tags");
4
+
5
+ class GenerateTrigger {
6
+ static title = "Generate Trigger";
7
+ static function_name = "generate_trigger";
8
+ static description =
9
+ "Generate a Saltcorn trigger with any available action type";
10
+
11
+ static render_html({
12
+ action_name,
13
+ action_type,
14
+ when_trigger,
15
+ trigger_table,
16
+ action_config,
17
+ }) {
18
+ const summary =
19
+ `<strong>${action_name}</strong> — ${action_type}` +
20
+ (when_trigger ? `: ${when_trigger}` : "") +
21
+ (trigger_table ? ` on ${trigger_table}` : "");
22
+ const configSection =
23
+ action_config && Object.keys(action_config).length
24
+ ? pre({ class: "mt-2" }, JSON.stringify(action_config, null, 2))
25
+ : "";
26
+ return div({ class: "mb-3" }, summary) + configSection;
27
+ }
28
+
29
+ static async execute(
30
+ { action_name, action_type, when_trigger, trigger_table, action_config },
31
+ req,
32
+ ) {
33
+ let table_id;
34
+ if (trigger_table) {
35
+ const table = Table.findOne({ name: trigger_table });
36
+ if (!table) return { postExec: `Table not found: ${trigger_table}` };
37
+ table_id = table.id;
38
+ }
39
+ const trigger = await Trigger.create({
40
+ name: action_name,
41
+ when_trigger: when_trigger || "Never",
42
+ table_id,
43
+ action: action_type,
44
+ configuration: action_config || {},
45
+ });
46
+ Trigger.emitEvent("AppChange", `Trigger ${trigger.name}`, req?.user, {
47
+ entity_type: "Trigger",
48
+ entity_name: trigger.name,
49
+ });
50
+ return {
51
+ postExec:
52
+ "Trigger created. " +
53
+ a(
54
+ { target: "_blank", href: `/actions/configure/${trigger.id}` },
55
+ "Configure trigger.",
56
+ ),
57
+ };
58
+ }
59
+ }
60
+
61
+ module.exports = GenerateTrigger;
@@ -215,7 +215,8 @@ class GenerateWorkflow {
215
215
  workflow_steps: await steps(),
216
216
  workflow_name: {
217
217
  description:
218
- "The name of the workflow. Can include spaces and mixed case, should be 1-5 words.",
218
+ "The name of the workflow. Can include spaces and mixed case, should be 1-5 words. " +
219
+ "Must be unique across all workflows. When creating variants for different events on the same table (e.g. insert, update, delete), include the event in each name — e.g. 'Recalc trip packing insert', 'Recalc trip packing update'.",
219
220
  type: "string",
220
221
  },
221
222
  when_trigger: {
@@ -254,6 +255,10 @@ class GenerateWorkflow {
254
255
  return `Use the generate_workflow tool to construct computational workflows according to specifications. You must create
255
256
  the workflow by calling the generate_workflow tool, with the step required to implement the specification.
256
257
 
258
+ **Trigger vs workflow:** If the task can be completed in a single step (e.g. updating one field with modify_row, sending a single notification), use the generate_trigger tool instead — a workflow is only appropriate when multiple steps, branching, or looping are required. If the task requires several independent single-step actions (e.g. "mark complete" and "mark incomplete"), create a separate trigger for each — do NOT bundle them into one workflow.
259
+
260
+ **Naming:** Workflow names must be unique. When creating variants for different events on the same table (e.g. insert, update, delete), include the event in each name — e.g. "Recalc trip packing insert", "Recalc trip packing update", "Recalc trip packing delete".
261
+
257
262
  ${contextBlocks}
258
263
 
259
264
  The steps are specified as JSON objects. Each step has a name, specified in the step_name key in the JSON object.
@@ -57,7 +57,7 @@ class GeneratePageSkill {
57
57
  `${name}.html`,
58
58
  "text/html",
59
59
  html,
60
- user.id,
60
+ user?.id,
61
61
  100,
62
62
  );
63
63
 
@@ -96,7 +96,7 @@ class GeneratePageSkill {
96
96
  );
97
97
  } else return "Metadata recieved";
98
98
  },
99
- postProcess: async ({ tool_call, generate }) => {
99
+ postProcess: async ({ tool_call, generate, req }) => {
100
100
  const str = await generate(
101
101
  `Now generate the contents of the ${tool_call.input.name} HTML page. If I asked you to embed a view,
102
102
  use the <embed-view> self-closing tag to do so, setting the view name in the viewname attribute. For example,
@@ -120,6 +120,19 @@ class GeneratePageSkill {
120
120
  ? str.split("```html")[1].split("```")[0]
121
121
  : str;
122
122
 
123
+ if (this.yoloMode) {
124
+ await this.userActions.build_copilot_page_gen({
125
+ user: req?.user,
126
+ name: tool_call.input.name,
127
+ title: tool_call.input.title,
128
+ description: tool_call.input.description,
129
+ html,
130
+ });
131
+ return {
132
+ stop: true,
133
+ add_response: `Page ${tool_call.input.name} created.`,
134
+ };
135
+ }
123
136
  return {
124
137
  stop: true,
125
138
  add_response: iframe({
@@ -127,10 +140,10 @@ class GeneratePageSkill {
127
140
  width: 500,
128
141
  height: 800,
129
142
  }),
130
- add_system_prompt: `If the user asks you to regenerate the page,
131
- you must run the generate_page tool again. After running this tool
132
- you will be prompted to generate the html again. You should repeat
133
- the html from the previous answer except for the changes the user
143
+ add_system_prompt: `If the user asks you to regenerate the page,
144
+ you must run the generate_page tool again. After running this tool
145
+ you will be prompted to generate the html again. You should repeat
146
+ the html from the previous answer except for the changes the user
134
147
  is requesting.`,
135
148
  add_user_action: {
136
149
  name: "build_copilot_page_gen",
@@ -0,0 +1,263 @@
1
+ const Trigger = require("@saltcorn/data/models/trigger");
2
+ const Table = require("@saltcorn/data/models/table");
3
+ const { getState } = require("@saltcorn/data/db/state");
4
+ const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
5
+ const { fieldProperties } = require("../common");
6
+ const GenerateAnyAction = require("../actions/generate-trigger");
7
+
8
+ const flattenOptionGroups = (options = []) =>
9
+ options.flatMap((opt) =>
10
+ opt?.optgroup && Array.isArray(opt.options) ? opt.options : [opt],
11
+ );
12
+
13
+ class AnyActionSkill {
14
+ static skill_name = "Generate trigger";
15
+
16
+ get skill_label() {
17
+ return "Generate trigger";
18
+ }
19
+
20
+ constructor(cfg) {
21
+ Object.assign(this, cfg);
22
+ }
23
+
24
+ async systemPrompt() {
25
+ return (
26
+ `If the user asks to create an action or trigger, use the generate_trigger tool. ` +
27
+ `Pick the most appropriate action_type from the available options. ` +
28
+ `Only set when_trigger and trigger_table if the user has specified them. ` +
29
+ `Trigger names must be unique — when creating variants for different events on the same table, include the event in each name (e.g. "Recalc trip packing insert", "Recalc trip packing update", "Recalc trip packing delete").\n\n` +
30
+ `**Trigger vs workflow:** Use a trigger (this tool) when the action is a single step — ` +
31
+ `for example, setting a field value with modify_row, sending a notification, or calling an API. ` +
32
+ `Only use a workflow when the logic requires multiple steps, branching, or looping. ` +
33
+ `If the task requires several independent single-step actions (e.g. "mark complete" and "mark incomplete"), call this tool once per action — do NOT bundle them into one workflow.\n\n` +
34
+ `**Navigation is a view concern:** If a task description says "return the user to X" or "navigate back", ` +
35
+ `do NOT add a navigation step inside the trigger. Triggers only handle data operations. ` +
36
+ `Navigation (GoBack) is configured on the button in the list view, not inside the trigger itself.\n\n` +
37
+ `**Verification/confirmation emails after user registration:** Create a trigger with event "Insert" on the "users" table, ` +
38
+ `action_type "run_js_code". Do NOT use the send_email action — it will not work for verification. ` +
39
+ `Set the action_config "code" field to exactly:\n` +
40
+ `const { send_verification_email } = require("@saltcorn/data/models/email");\nawait send_verification_email(row, req);`
41
+ );
42
+ }
43
+
44
+ get userActions() {
45
+ return {
46
+ async build_copilot_any_action(input) {
47
+ const {
48
+ name,
49
+ action_type,
50
+ when_trigger,
51
+ trigger_table,
52
+ action_config,
53
+ user,
54
+ } = input;
55
+ if (!name || !action_type) {
56
+ return { notify: "Action name and type are required." };
57
+ }
58
+ const result = await GenerateAnyAction.execute(
59
+ {
60
+ action_name: name,
61
+ action_type,
62
+ when_trigger,
63
+ trigger_table,
64
+ action_config,
65
+ },
66
+ { user },
67
+ );
68
+ return { notify: result?.postExec || `Action saved: ${name}` };
69
+ },
70
+ };
71
+ }
72
+
73
+ provideTools = () => {
74
+ const state = getState();
75
+ const allActionOptions = (() => {
76
+ try {
77
+ return (
78
+ Trigger.action_options({ notRequireRow: false, workflow: false }) ||
79
+ []
80
+ );
81
+ } catch (_) {
82
+ return [];
83
+ }
84
+ })();
85
+ const stateActions = state?.actions || {};
86
+ const stateActionNames = Object.keys(stateActions);
87
+ const catalogNames = flattenOptionGroups(allActionOptions);
88
+ const actionEnum = Array.from(
89
+ new Set([...catalogNames, ...stateActionNames]),
90
+ ).sort();
91
+
92
+ const tables = (state.tables || []).map((t) => t.name);
93
+
94
+ return {
95
+ type: "function",
96
+ function: {
97
+ name: GenerateAnyAction.function_name,
98
+ description: GenerateAnyAction.description,
99
+ parameters: {
100
+ type: "object",
101
+ required: ["name", "action_type"],
102
+ properties: {
103
+ name: {
104
+ type: "string",
105
+ description:
106
+ "A human-readable name for the trigger/action (1–5 words). " +
107
+ "Must be unique across all triggers. When creating multiple triggers for different events on the same table (e.g. insert, update, delete), include the event in the name — e.g. 'Recalc trip packing insert', 'Recalc trip packing update'.",
108
+ },
109
+ action_type: {
110
+ type: "string",
111
+ enum: actionEnum.length ? actionEnum : undefined,
112
+ description: "The action to run when the trigger fires.",
113
+ },
114
+ when_trigger: {
115
+ type: "string",
116
+ enum: Trigger.when_options,
117
+ description:
118
+ "When to fire this trigger. Only set if the user has specified. Leave unset for manual/API-call triggers.",
119
+ },
120
+ trigger_table: {
121
+ type: "string",
122
+ enum: tables,
123
+ description:
124
+ "Table for row-level triggers (Insert/Update/Delete/Validate). Only set when when_trigger requires a table.",
125
+ },
126
+ },
127
+ },
128
+ },
129
+ process: async (input) => {
130
+ const { name, action_type, when_trigger, trigger_table } = input || {};
131
+ return [
132
+ `Generating ${action_type} action: ${name}.`,
133
+ when_trigger ? `Trigger: ${when_trigger}` : null,
134
+ trigger_table ? `Table: ${trigger_table}` : null,
135
+ ]
136
+ .filter(Boolean)
137
+ .join("\n");
138
+ },
139
+ postProcess: async ({ tool_call, generate }) => {
140
+ const { name, action_type, when_trigger, trigger_table } =
141
+ tool_call.input || {};
142
+ if (!name || !action_type) {
143
+ return {
144
+ stop: true,
145
+ add_response: "Action name and type are required.",
146
+ };
147
+ }
148
+
149
+ const stateAction = getState()?.actions?.[action_type];
150
+ let action_config = {};
151
+
152
+ if (stateAction) {
153
+ const table = trigger_table
154
+ ? Table.findOne({ name: trigger_table })
155
+ : null;
156
+ let cfgFields = [];
157
+ try {
158
+ cfgFields = await getActionConfigFields(stateAction, table, {
159
+ copilot: true,
160
+ });
161
+ } catch (_) {}
162
+
163
+ const configurable = cfgFields.filter(
164
+ (f) => f.input_type !== "section_header",
165
+ );
166
+ if (configurable.length > 0) {
167
+ const properties = {};
168
+ for (const f of configurable) {
169
+ properties[f.name] = {
170
+ description: f.sublabel || f.label || f.name,
171
+ ...fieldProperties(f),
172
+ };
173
+ if (!properties[f.name].type) properties[f.name].type = "string";
174
+ }
175
+
176
+ let actionPrompt = "";
177
+ if (stateAction.copilot_generate_trigger_prompt) {
178
+ if (
179
+ typeof stateAction.copilot_generate_trigger_prompt === "string"
180
+ )
181
+ actionPrompt = stateAction.copilot_generate_trigger_prompt;
182
+ else if (
183
+ typeof stateAction.copilot_generate_trigger_prompt ===
184
+ "function"
185
+ )
186
+ actionPrompt =
187
+ await stateAction.copilot_generate_trigger_prompt(
188
+ tool_call.input,
189
+ );
190
+ }
191
+
192
+ const llm = getState().functions.llm_generate;
193
+ const answer = await llm.run(
194
+ `${actionPrompt ? actionPrompt + "\n\n" : ""}Configure the "${action_type}" action named "${name}". ` +
195
+ `Fill in the configuration by calling the generate_action_config tool.`,
196
+ {
197
+ tools: [
198
+ {
199
+ type: "function",
200
+ function: {
201
+ name: "generate_action_config",
202
+ description: `Provide configuration fields for the ${action_type} action`,
203
+ parameters: { type: "object", properties },
204
+ },
205
+ },
206
+ ],
207
+ tool_choice: {
208
+ type: "function",
209
+ function: { name: "generate_action_config" },
210
+ },
211
+ },
212
+ );
213
+
214
+ const tc = answer.getToolCalls()[0];
215
+ if (tc?.input) action_config = tc.input;
216
+ }
217
+ }
218
+
219
+ if (this.yoloMode) {
220
+ const result = await GenerateAnyAction.execute(
221
+ {
222
+ action_name: name,
223
+ action_type,
224
+ when_trigger,
225
+ trigger_table,
226
+ action_config,
227
+ },
228
+ {},
229
+ );
230
+ return {
231
+ stop: true,
232
+ add_response: result?.postExec || `Action ${name} created.`,
233
+ };
234
+ }
235
+
236
+ return {
237
+ stop: true,
238
+ add_response: GenerateAnyAction.render_html({
239
+ action_name: name,
240
+ action_type,
241
+ when_trigger,
242
+ trigger_table,
243
+ action_config,
244
+ }),
245
+ add_user_action: {
246
+ name: "build_copilot_any_action",
247
+ type: "button",
248
+ label: `Save action (${name})`,
249
+ input: {
250
+ name,
251
+ action_type,
252
+ when_trigger,
253
+ trigger_table,
254
+ action_config,
255
+ },
256
+ },
257
+ };
258
+ },
259
+ };
260
+ };
261
+ }
262
+
263
+ module.exports = AnyActionSkill;