@saltcorn/copilot 0.1.1 → 0.3.0

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/action-builder.js CHANGED
@@ -128,10 +128,14 @@ const runPost = async (
128
128
  form.hasErrors = false;
129
129
  form.errors = {};
130
130
 
131
- const fullPrompt = await getPromptFromTemplate(
131
+ const partPrompt = await getPromptFromTemplate(
132
132
  "action-builder.txt",
133
133
  form.values.prompt
134
134
  );
135
+ const fullPrompt =
136
+ partPrompt +
137
+ "\n\nWrite the JavaScript code to implement the following functionality:\n\n" +
138
+ form.values.prompt;
135
139
 
136
140
  const completion = await getCompletion("JavaScript", fullPrompt);
137
141
 
@@ -0,0 +1,108 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+ const WorkflowStep = require("@saltcorn/data/models/workflow_step");
3
+ const Trigger = require("@saltcorn/data/models/trigger");
4
+ const Table = require("@saltcorn/data/models/table");
5
+ const Field = require("@saltcorn/data/models/field");
6
+ const { apply, removeAllWhiteSpace } = require("@saltcorn/data/utils");
7
+ const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
8
+ const { a, pre, script, div, code } = require("@saltcorn/markup/tags");
9
+ const { fieldProperties, getPromptFromTemplate } = require("../common");
10
+
11
+ class GenerateJsAction {
12
+ static title = "Generate JavaScript Action";
13
+ static function_name = "generate_js_action";
14
+ static description = "Generate Javascript Action";
15
+
16
+ static async json_schema() {
17
+ return {
18
+ type: "object",
19
+ required: ["action_javascript_code", "action_name"],
20
+ properties: {
21
+ action_javascript_code: {
22
+ description: "JavaScript code that constitutes the action",
23
+ type: "string",
24
+ },
25
+ action_name: {
26
+ description:
27
+ "A human-readable label for the action. Can include spaces and mixed case, should be 1-5 words.",
28
+ type: "string",
29
+ },
30
+ action_description: {
31
+ description: "A description of the purpose of the action.",
32
+ type: "string",
33
+ },
34
+ when_trigger: {
35
+ description:
36
+ "When the action should trigger. Optional, leave blank if unspecified or workflow will be run on button click",
37
+ type: "string",
38
+ enum: ["Insert", "Delete", "Update", "Daily", "Hourly", "Weekly"],
39
+ },
40
+ trigger_table: {
41
+ description:
42
+ "If the action trigger is Insert, Delete or Update, the name of the table that triggers the workflow",
43
+ type: "string",
44
+ },
45
+ },
46
+ };
47
+ }
48
+ static async system_prompt() {
49
+ const partPrompt = await getPromptFromTemplate("action-builder.txt", "");
50
+ return (
51
+ `Use the generate_js_action to generate actions based on JavaScript code. ` +
52
+ partPrompt
53
+ );
54
+ }
55
+ static render_html({
56
+ action_javascript_code,
57
+ action_name,
58
+ action_description,
59
+ when_trigger,
60
+ trigger_table,
61
+ }) {
62
+ return (
63
+ div({class: "mb-3"},
64
+ `${action_name}${when_trigger ? `: ${when_trigger}` : ""}${
65
+ trigger_table ? ` on ${trigger_table}` : ""
66
+ }`
67
+ ) + pre(code(action_javascript_code))
68
+ );
69
+ }
70
+ static async execute(
71
+ {
72
+ action_javascript_code,
73
+ action_name,
74
+ action_description,
75
+ when_trigger,
76
+ trigger_table,
77
+ },
78
+ req
79
+ ) {
80
+ let table_id;
81
+ if (trigger_table) {
82
+ const table = Table.findOne({ name: trigger_table });
83
+ if (!table) return { postExec: `Table not found: ${trigger_table}` };
84
+ table_id = table.id;
85
+ }
86
+ const trigger = await Trigger.create({
87
+ name: action_name,
88
+ when_trigger: when_trigger || "Never",
89
+ table_id,
90
+ action: "run_js_code",
91
+ configuration: {code: action_javascript_code},
92
+ });
93
+ Trigger.emitEvent("AppChange", `Trigger ${trigger.name}`, req?.user, {
94
+ entity_type: "Trigger",
95
+ entity_name: trigger.name,
96
+ });
97
+ return {
98
+ postExec:
99
+ "Action created. " +
100
+ a(
101
+ { target: "_blank", href: `/actions/configure/${trigger.id}` },
102
+ "Configure action."
103
+ ),
104
+ };
105
+ }
106
+ }
107
+
108
+ module.exports = GenerateJsAction;
@@ -0,0 +1,259 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+ const WorkflowStep = require("@saltcorn/data/models/workflow_step");
3
+ const Trigger = require("@saltcorn/data/models/trigger");
4
+ const Table = require("@saltcorn/data/models/table");
5
+ const Field = require("@saltcorn/data/models/field");
6
+ const { apply, removeAllWhiteSpace } = require("@saltcorn/data/utils");
7
+ const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
8
+ const { a, pre, script, div } = require("@saltcorn/markup/tags");
9
+ const { fieldProperties } = require("../common");
10
+
11
+ class GenerateTables {
12
+ static title = "Generate Tables";
13
+ static function_name = "generate_tables";
14
+ static description = "Generate database tables";
15
+
16
+ static async json_schema() {
17
+ const types = Object.values(getState().types);
18
+ const fieldTypeCfg = types.map((ty) => {
19
+ const properties = {
20
+ data_type: { const: ty.name },
21
+ };
22
+ const attrs = apply(ty.attributes, {}) || [];
23
+ attrs.forEach((a) => {
24
+ properties[a.name] = {
25
+ description:
26
+ a.copilot_description ||
27
+ `${a.label}.${a.sublabel ? ` ${a.sublabel}` : ""}`,
28
+ ...fieldProperties(a),
29
+ };
30
+ });
31
+ return {
32
+ type: "object",
33
+ description: ty.copilot_description || ty.description,
34
+ properties,
35
+ };
36
+ });
37
+ fieldTypeCfg.push({
38
+ type: "object",
39
+ description:
40
+ "A foreign key to a different table. This will reference the primary key on another table.",
41
+ properties: {
42
+ data_type: { const: "ForeignKey" },
43
+ reference_table: {
44
+ type: "string",
45
+ description: "Name of the table being referenced",
46
+ },
47
+ },
48
+ });
49
+ fieldTypeCfg.push({
50
+ type: "object",
51
+ description:
52
+ "A reference (file path) to a file on disk. This can be used for example to hold images or documents",
53
+ properties: {
54
+ data_type: { const: "File" },
55
+ },
56
+ });
57
+ return {
58
+ type: "object",
59
+ required: ["tables"],
60
+ properties: {
61
+ tables: {
62
+ type: "array",
63
+ items: {
64
+ type: "object",
65
+ required: ["table_name", "fields"],
66
+ properties: {
67
+ table_name: {
68
+ type: "string",
69
+ description: "The name of the table",
70
+ },
71
+ fields: {
72
+ type: "array",
73
+ items: {
74
+ type: "object",
75
+ required: [
76
+ "name",
77
+ "label",
78
+ "type_and_configuration",
79
+ "importance",
80
+ ],
81
+ properties: {
82
+ name: {
83
+ type: "string",
84
+ description:
85
+ "The field name. Must be a valid identifier in both SQL and JavaScript, all lower case, snake_case (underscore instead of spaces)",
86
+ },
87
+ label: {
88
+ type: "string",
89
+ description:
90
+ "A human-readable label for the field. Should be short, 1-4 words, can have spaces and mixed case.",
91
+ },
92
+ not_null: {
93
+ type: "boolean",
94
+ description:
95
+ "A value is required and the field will be NOT NULL in the database",
96
+ },
97
+ unique: {
98
+ type: "boolean",
99
+ description:
100
+ "The value is unique - different rows must have different values for this field",
101
+ },
102
+ type_and_configuration: { anyOf: fieldTypeCfg },
103
+ importance: {
104
+ type: "number",
105
+ description:
106
+ "How important is this field if only some fields can be displayed to the user. From 1 (least important) to 10 (most important).",
107
+ },
108
+ },
109
+ },
110
+ },
111
+ },
112
+ },
113
+ },
114
+ },
115
+ };
116
+ }
117
+
118
+ static async system_prompt() {
119
+ const tableLines = [];
120
+ const tables = await Table.find({});
121
+ tables.forEach((table) => {
122
+ const fieldLines = table.fields.map(
123
+ (f) =>
124
+ ` * ${f.name} with type: ${f.pretty_type.replace(
125
+ "Key to",
126
+ "ForeignKey referencing"
127
+ )}.${f.description ? ` ${f.description}` : ""}`
128
+ );
129
+ tableLines.push(
130
+ `${table.name}${
131
+ table.description ? `: ${table.description}.` : "."
132
+ } Contains the following fields:\n${fieldLines.join("\n")}`
133
+ );
134
+ });
135
+ return `Use the generate_tables tool to construct one or more database tables.
136
+
137
+ Do not call this tool more than once. It should only be called once. If you are
138
+ building more than one table, use one call to the generate_tables tool to build all the
139
+ tables.
140
+
141
+ The argument to generate_tables is an array of tables, each with an array of fields. You do not
142
+ need to specify a primary key, a primary key called id with autoincrementing integers is
143
+ autmatically generated.
144
+
145
+ The database already contains the following tables:
146
+
147
+ ${tableLines.join("\n\n")}
148
+
149
+ `;
150
+ }
151
+ static render_html({ tables }) {
152
+ const sctables = this.process_tables(tables);
153
+ const mmdia = buildMermaidMarkup(sctables);
154
+ return (
155
+ pre({ class: "mermaid" }, mmdia) +
156
+ script(`mermaid.run({querySelector: 'pre.mermaid'});`)
157
+ );
158
+ }
159
+
160
+ static async execute({ tables }, req) {
161
+ const sctables = this.process_tables(tables);
162
+ for (const table of sctables) await Table.create(table.name);
163
+ for (const table of sctables) {
164
+ for (const field of table.fields) {
165
+ field.table = Table.findOne({ name: table.name });
166
+ await Field.create(field);
167
+ }
168
+ }
169
+ Trigger.emitEvent("AppChange", `Tables created`, req?.user, {
170
+ entity_type: "Table",
171
+ entity_names: sctables.map((t) => t.name),
172
+ });
173
+ }
174
+
175
+ static process_tables(tables) {
176
+ return tables.map((table) => {
177
+ return new Table({
178
+ name: table.table_name,
179
+ fields: table.fields.map((f) => {
180
+ const { data_type, reference_table, ...attributes } =
181
+ f.type_and_configuration;
182
+ let type = data_type;
183
+ const scattributes = { ...attributes, importance: f.importance };
184
+ if (data_type === "ForeignKey") {
185
+ type = `Key to ${reference_table}`;
186
+ let refTableHere = tables.find(
187
+ (t) => t.table_name === reference_table
188
+ );
189
+ if (refTableHere) {
190
+ const strFields = refTableHere.fields.filter(
191
+ (f) => f.type_and_configuration.data_type === "String"
192
+ );
193
+ if (strFields.length) {
194
+ const maxImp = strFields.reduce(function (prev, current) {
195
+ return prev && prev.importance > current.importance
196
+ ? prev
197
+ : current;
198
+ });
199
+ if (maxImp) scattributes.summary_field = maxImp.name;
200
+ }
201
+ } else if (reference_table === "users") {
202
+ scattributes.summary_field = "email";
203
+ }
204
+ }
205
+
206
+ return {
207
+ ...f,
208
+ type,
209
+ required: f.not_null,
210
+ attributes: scattributes,
211
+ };
212
+ }),
213
+ });
214
+ });
215
+ }
216
+ }
217
+
218
+ const EOL = "\n";
219
+ const indentString = (str, indent) => `${" ".repeat(indent)}${str}`;
220
+
221
+ const srcCardinality = (field) => (field.required ? "||" : "|o");
222
+
223
+ const buildTableMarkup = (table) => {
224
+ const fields = table.getFields();
225
+ const members = fields
226
+ // .filter((f) => !f.reftable_name)
227
+ .map((f) =>
228
+ indentString(`${removeAllWhiteSpace(f.type_name)} ${f.name}`, 6)
229
+ )
230
+ .join(EOL);
231
+ const keys = table
232
+ .getForeignKeys()
233
+ .map((f) =>
234
+ indentString(
235
+ `"${table.name}"${srcCardinality(f)}--|| "${f.reftable_name}" : "${
236
+ f.name
237
+ }"`,
238
+ 2
239
+ )
240
+ )
241
+ .join(EOL);
242
+ return `${keys}
243
+ "${table.name}" {${EOL}${members}${EOL} }`;
244
+ };
245
+
246
+ const buildMermaidMarkup = (tables) => {
247
+ const lines = tables.map((table) => buildTableMarkup(table)).join(EOL);
248
+ return `${indentString("erDiagram", 2)}${EOL}${lines}`;
249
+ };
250
+
251
+ module.exports = GenerateTables;
252
+
253
+ /* todo
254
+
255
+ - tag
256
+ - generate descriptions
257
+ - generate views
258
+
259
+ */
@@ -0,0 +1,320 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+ const WorkflowStep = require("@saltcorn/data/models/workflow_step");
3
+ const Trigger = require("@saltcorn/data/models/trigger");
4
+ const Table = require("@saltcorn/data/models/table");
5
+ const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
6
+ const { a, pre, script, div } = require("@saltcorn/markup/tags");
7
+ const { fieldProperties } = require("../common");
8
+
9
+ const steps = async () => {
10
+ const actionExplainers = WorkflowStep.builtInActionExplainers();
11
+ const actionFields = await WorkflowStep.builtInActionConfigFields();
12
+
13
+ let stateActions = getState().actions;
14
+ const stateActionList = Object.entries(stateActions).filter(
15
+ ([k, v]) => !v.disableInWorkflow
16
+ );
17
+
18
+ const stepTypeAndCfg = Object.keys(actionExplainers).map((actionName) => {
19
+ const properties = { step_type: { const: actionName } };
20
+ const myFields = actionFields.filter(
21
+ (f) => f.showIf?.wf_action_name === actionName
22
+ );
23
+ const required = ["step_type"];
24
+ myFields.forEach((f) => {
25
+ if (f.required) required.push(f.name);
26
+ properties[f.name] = {
27
+ description: f.sublabel || f.label,
28
+ ...fieldProperties(f),
29
+ };
30
+ });
31
+ return {
32
+ type: "object",
33
+ description: actionExplainers[actionName],
34
+ properties,
35
+ required,
36
+ };
37
+ });
38
+ for (const [actionName, action] of stateActionList) {
39
+ try {
40
+ const properties = { step_type: { const: actionName } };
41
+ const cfgFields = await getActionConfigFields(action, null, {
42
+ mode: "workflow",
43
+ copilot: true,
44
+ });
45
+ const required = ["step_type"];
46
+ cfgFields.forEach((f) => {
47
+ if (f.input_type === "section_header") return;
48
+ if (f.required) required.push(f.name);
49
+ properties[f.name] = {
50
+ description: f.sublabel || f.label,
51
+ ...fieldProperties(f),
52
+ };
53
+ });
54
+ stepTypeAndCfg.push({
55
+ type: "object",
56
+ description:
57
+ actionExplainers[actionName] ||
58
+ `${actionName}.${action.description ? ` ${action.description}` : ""}`,
59
+ properties,
60
+ required,
61
+ });
62
+ } catch (e) {
63
+ //ignore
64
+ }
65
+ }
66
+ const triggers = Trigger.find({
67
+ when_trigger: { or: ["API call", "Never"] },
68
+ }).filter((tr) => tr.description && tr.name && tr !== "Workflow");
69
+ //TODO workflows
70
+ for (const trigger of triggers) {
71
+ const properties = {
72
+ step_type: { const: trigger.name },
73
+ };
74
+ if (trigger.table_id) {
75
+ const table = Table.findOne({ id: trigger.table_id });
76
+ const fieldSpecs = [];
77
+ table.fields.forEach((f) => {
78
+ // TODO fkeys dereferenced.
79
+ fieldSpecs.push(`${f.name} with ${f.pretty_type} type`);
80
+ });
81
+ properties.row_expr = {
82
+ type: "string",
83
+ description: `JavaScript expression for the input to the action. This should be an expression for an object, with the following field name and types: ${fieldSpecs.join(
84
+ "; "
85
+ )}.`,
86
+ };
87
+ }
88
+ const required = ["step_type"];
89
+ stepTypeAndCfg.push({
90
+ type: "object",
91
+ description: `${trigger.name}: ${trigger.description}`,
92
+ properties,
93
+ required,
94
+ });
95
+ }
96
+ const properties = {
97
+ step_name: {
98
+ description: "The name of this step as a valid Javascript identifier",
99
+ type: "string",
100
+ },
101
+ only_if: {
102
+ description:
103
+ "Optional JavaScript expression based on the context. If given, the chosen action will only be executed if evaluates to true",
104
+ type: "string",
105
+ },
106
+ /*step_type: {
107
+ description: "The type of workflow step",
108
+ type: "string",
109
+ enum: Object.keys(actionExplainers),
110
+ },*/
111
+ next_step: {
112
+ description:
113
+ "The next step in the workflow, as a JavaScript expression based on the context.",
114
+ type: "string",
115
+ },
116
+ step_configuration: { anyOf: stepTypeAndCfg },
117
+ };
118
+ return {
119
+ type: "array",
120
+ items: {
121
+ type: "object",
122
+ properties,
123
+ },
124
+ };
125
+ };
126
+
127
+ class GenerateWorkflow {
128
+ static title = "Generate Workflow";
129
+ static function_name = "generate_workflow";
130
+ static description = "Generate the steps in a workflow";
131
+
132
+ static async json_schema() {
133
+ return {
134
+ type: "object",
135
+ properties: {
136
+ workflow_steps: await steps(),
137
+ workflow_name: {
138
+ description:
139
+ "The name of the workflow. Can include spaces and mixed case, should be 1-5 words.",
140
+ type: "string",
141
+ },
142
+ when_trigger: {
143
+ description:
144
+ "When the workflow should trigger. Optional, leave blank if unspecified or workflow will be run on button click",
145
+ type: "string",
146
+ enum: ["Insert", "Delete", "Update", "Daily", "Hourly", "Weekly"],
147
+ },
148
+ trigger_table: {
149
+ description:
150
+ "If the workflow trigger is Insert, Delete or Update, the name of the table that triggers the workflow",
151
+ type: "string",
152
+ },
153
+ },
154
+ };
155
+ }
156
+
157
+ static async system_prompt() {
158
+ const actionExplainers = WorkflowStep.builtInActionExplainers();
159
+ let stateActions = getState().actions;
160
+ const stateActionList = Object.entries(stateActions).filter(
161
+ ([k, v]) => !v.disableInWorkflow
162
+ );
163
+
164
+ return `Use the generate_workflow tool to construct computational workflows according to specifications. You must create
165
+ the workflow by calling the generate_workflow tool, with the step required to implement the specification.
166
+
167
+ The steps are specified as JSON objects. Each step has a name, specified in the step_name key in the JSON object.
168
+ The step name should be a valid JavaScript identifier.
169
+
170
+ Each run of the workflow is executed in the presence of a context, which is a JavaScript object that individual
171
+ steps can read values from and write values to. This context is a state that is persisted on disk for each workflow
172
+ run.
173
+
174
+ Each step can have a next_step key which is the name of the next step, or a JavaScript expression which evaluates
175
+ to the name of the next step based on the context. In the evaluation of the next step, each value in the context is
176
+ in scope and can be addressed directly. Identifiers for the step names are also in scope, the name of the next step
177
+ can be used directly without enclosing it in quotes to form a string.
178
+
179
+ For example, if the context contains a value x which is an integer and you have steps named "too_low" and "too_high",
180
+ and you would like the next step to be too_low if x is less than 10 and too_high otherwise,
181
+ use this as the next_step expression: x<10 ? too_low : too_high
182
+
183
+ If the next_step is omitted then the workflow terminates.
184
+
185
+ Each step has a step_configuration object which contains the step type and the specific parameters of
186
+ that step type. You should specify the step type in the step_type subfield of the step_configuration
187
+ field. The available step types are:
188
+
189
+ ${Object.entries(actionExplainers)
190
+ .map(([k, v]) => `* ${k}: ${v}`)
191
+ .join("\n")}
192
+ ${stateActionList
193
+ .map(([k, v]) => `* ${k}: ${v.description || ""}`)
194
+ .join("\n")}
195
+
196
+ Most of them are are explained by their parameter descriptions. Here are some additional information for some
197
+ step types:
198
+
199
+ run_js_code: if the step_type is "run_js_code" then the step object should include the JavaScript code to be executed in the "code"
200
+ key. You can use await in the code if you need to run asynchronous code. The values in the context are directly in scope and can be accessed using their name. In addition, the variable
201
+ "context" is also in scope and can be used to address the context as a whole. To write values to the context, return an
202
+ object. The values in this object will be written into the current context. If a value already exists in the context
203
+ it will be overwritten. For example, If the context contains values x and y which are numbers and you would like to push
204
+ the value "sum" which is the sum of x and y, then use this as the code: return {sum: x+y}. You cannot set the next step in the
205
+ return object or by returning a string from a run_js_code step, this will not work. To set the next step from a code action, always use the next_step property of the step object.
206
+ This expression for the next step can depend on value pushed to the context (by the return object in the code) as these values are in scope.
207
+
208
+ ForLoop: ForLoop steps loop over an array which is specified by the array_expression JavaScript expression. Execution of the workflow steps is temporarily diverted to another set
209
+ of steps, starting from the step specified by the loop_body_inital_step value, and runs until it encounters a
210
+ step with nothing specified for next_step at which point the next iteration (over the next item in the array) is started. When all items have
211
+ been iterated over, the for loop is complete and execution continues with the next_step of the ForLoop step. During each iteration
212
+ of the loop, the current array item is temporarily set to a variable in the context specified by the item_variable variable. The steps between
213
+ in the loop body can access this current array items in the context by the context item_variable name.
214
+ When all items have been iterated, the for loop will continue from the step indicated by its next_step.
215
+
216
+ llm_generate: use a llm_generate step to consult an artificial intelligence language processor to ask a question in natural language in which the answer is given in natural language. The answer is based on a
217
+ question, specified as a string in the step conmfiguration "prompt_template" key in which you can user interpolation ({{ }}) to access context variables. Running the step will provide an answer by a
218
+ highly capable artificial intelligence processor who however does not have in-depth knowledge of the subject matter or any case specifics at hand - you
219
+ must provide all these details in the question string, which should concatenate multiple background documents before asking the
220
+ actual question. You must also provide a variable name (in the answer_field key in the step definition) where the answer
221
+ will be pushed to the context as a string. If you specificy a variable name in chat_history_field, the invocation of subsequent llm_generate
222
+ steps in the same workflow will contain the interaction history of previous invocations, so you don't have to repeat information in the prompt and can
223
+ maintain a conversational interaction.
224
+
225
+ llm_generate_json: use llm_generate_json steps to extract structured information from text. llm_generate_json uses natural language processing to read a document,
226
+ and to generate JSON objects with specified fields. A llm_generate_json step requires four settings in the step object: gen_description,
227
+ a general description of what it is that should be extracted; fields, which is an array of the fields in each object that is
228
+ extracted from the text, each with a name, a type and a description; multiple a boolean and that indicates whether exactly one object
229
+ or an array with any number of objects should be extracted; and answer_field, the name of the variable should be written to in the
230
+ context (as an object if multiple is false and as an array if multiple is true).`;
231
+ }
232
+
233
+ static async execute(
234
+ { workflow_steps, workflow_name, when_trigger, trigger_table },
235
+ req
236
+ ) {
237
+ const steps = this.process_all_steps(workflow_steps);
238
+ let table_id;
239
+ if (trigger_table) {
240
+ const table = Table.findOne({ name: trigger_table });
241
+ if (!table) return { postExec: `Table not found: ${trigger_table}` };
242
+ table_id = table.id;
243
+ }
244
+ const trigger = await Trigger.create({
245
+ name: workflow_name,
246
+ when_trigger: when_trigger || "Never",
247
+ table_id,
248
+ action: "Workflow",
249
+ configuration: {},
250
+ });
251
+ for (const step of steps) {
252
+ step.trigger_id = trigger.id;
253
+ await WorkflowStep.create(step);
254
+ }
255
+ Trigger.emitEvent("AppChange", `Trigger ${trigger.name}`, req?.user, {
256
+ entity_type: "Trigger",
257
+ entity_name: trigger.name,
258
+ });
259
+ return {
260
+ postExec:
261
+ "Workflow created. " +
262
+ a(
263
+ { target: "_blank", href: `/actions/configure/${trigger.id}` },
264
+ "Configure workflow."
265
+ ),
266
+ };
267
+ }
268
+
269
+ static render_html({
270
+ workflow_steps,
271
+ workflow_name,
272
+ when_trigger,
273
+ trigger_table,
274
+ }) {
275
+ const steps = this.process_all_steps(workflow_steps);
276
+
277
+ if (WorkflowStep.generate_diagram) {
278
+ steps.forEach((step, ix) => {
279
+ step.id = ix + 1;
280
+ });
281
+ const mmdia = WorkflowStep.generate_diagram(
282
+ steps.map((s) => new WorkflowStep(s))
283
+ );
284
+ return (
285
+ div(
286
+ `${workflow_name}${when_trigger ? `: ${when_trigger}` : ""}${
287
+ trigger_table ? ` on ${trigger_table}` : ""
288
+ }`
289
+ ) +
290
+ pre({ class: "mermaid" }, mmdia) +
291
+ script(`mermaid.run({querySelector: 'pre.mermaid'});`)
292
+ );
293
+ }
294
+
295
+ return `A workflow! Step names: ${workflow_steps.map(
296
+ (s) => s.step_name
297
+ )}. Upgrade Saltcorn to see diagrams in copilot`;
298
+ }
299
+
300
+ //specific methods
301
+
302
+ static process_all_steps(steps) {
303
+ const scsteps = steps.map((s) => this.to_saltcorn_step(s));
304
+ if (scsteps.length) scsteps[0].initial_step = true;
305
+ return scsteps;
306
+ }
307
+
308
+ static to_saltcorn_step(llm_step) {
309
+ const { step_type, ...configuration } = llm_step.step_configuration;
310
+ return {
311
+ name: llm_step.step_name,
312
+ action_name: step_type,
313
+ next_step: llm_step.next_step,
314
+ only_if: llm_step.only_if,
315
+ configuration,
316
+ };
317
+ }
318
+ }
319
+
320
+ module.exports = GenerateWorkflow;
@@ -0,0 +1,471 @@
1
+ const Field = require("@saltcorn/data/models/field");
2
+ const Table = require("@saltcorn/data/models/table");
3
+ const Form = require("@saltcorn/data/models/form");
4
+ const View = require("@saltcorn/data/models/view");
5
+ const Trigger = require("@saltcorn/data/models/trigger");
6
+ const { findType } = require("@saltcorn/data/models/discovery");
7
+ const { save_menu_items } = require("@saltcorn/data/models/config");
8
+ const db = require("@saltcorn/data/db");
9
+ const WorkflowRun = require("@saltcorn/data/models/workflow_run");
10
+ const { localeDateTime } = require("@saltcorn/markup");
11
+ const {
12
+ div,
13
+ script,
14
+ domReady,
15
+ pre,
16
+ code,
17
+ input,
18
+ h4,
19
+ style,
20
+ h5,
21
+ button,
22
+ text_attr,
23
+ i,
24
+ p,
25
+ span,
26
+ small,
27
+ form,
28
+ textarea,
29
+ } = require("@saltcorn/markup/tags");
30
+ const { getState } = require("@saltcorn/data/db/state");
31
+ const {
32
+ getCompletion,
33
+ getPromptFromTemplate,
34
+ incompleteCfgMsg,
35
+ } = require("./common");
36
+
37
+ const get_state_fields = () => [];
38
+
39
+ const run = async (table_id, viewname, cfg, state, { res, req }) => {
40
+ const prevRuns = (
41
+ await WorkflowRun.find(
42
+ { trigger_id: null },
43
+ { orderBy: "started_at", orderDesc: true, limit: 30 }
44
+ )
45
+ ).filter((r) => r.context.interactions);
46
+ const cfgMsg = incompleteCfgMsg();
47
+ if (cfgMsg) return cfgMsg;
48
+ let runInteractions = "";
49
+ if (state.run_id) {
50
+ const run = prevRuns.find((r) => r.id == state.run_id);
51
+ const interactMarkups = [];
52
+ for (const interact of run.context.interactions) {
53
+ switch (interact.role) {
54
+ case "user":
55
+ interactMarkups.push(
56
+ div(
57
+ { class: "interaction-segment" },
58
+ span({ class: "badge bg-secondary" }, "You"),
59
+ p(interact.content)
60
+ )
61
+ );
62
+ break;
63
+ case "assistant":
64
+ case "system":
65
+ if (interact.tool_calls) {
66
+ for (const tool_call of interact.tool_calls) {
67
+ const markup = await renderToolcall(
68
+ tool_call,
69
+ viewname,
70
+ (run.context.implemented_fcall_ids || []).includes(
71
+ tool_call.id
72
+ ),
73
+ run
74
+ );
75
+ interactMarkups.push(
76
+ div(
77
+ { class: "interaction-segment" },
78
+ span({ class: "badge bg-secondary" }, "Copilot"),
79
+ markup
80
+ )
81
+ );
82
+ }
83
+ } else
84
+ interactMarkups.push(
85
+ div(
86
+ { class: "interaction-segment" },
87
+ span({ class: "badge bg-secondary" }, "Copilot"),
88
+ p(interact.content)
89
+ )
90
+ );
91
+ break;
92
+ case "tool":
93
+ //ignore
94
+ break;
95
+ }
96
+ }
97
+ runInteractions = interactMarkups.join("");
98
+ }
99
+ const input_form = form(
100
+ {
101
+ onsubmit:
102
+ "event.preventDefault();spin_send_button();view_post('Saltcorn Copilot', 'interact', $(this).serialize(), processCopilotResponse);return false;",
103
+ class: "form-namespace copilot mt-2",
104
+ method: "post",
105
+ },
106
+ input({
107
+ type: "hidden",
108
+ name: "_csrf",
109
+ value: req.csrfToken(),
110
+ }),
111
+ input({
112
+ type: "hidden",
113
+ class: "form-control ",
114
+ name: "run_id",
115
+ value: state.run_id ? +state.run_id : undefined,
116
+ }),
117
+ div(
118
+ { class: "copilot-entry" },
119
+ textarea({
120
+ class: "form-control",
121
+ name: "userinput",
122
+ "data-fieldname": "userinput",
123
+ placeholder: "How can I help you?",
124
+ id: "inputuserinput",
125
+ rows: "3",
126
+ autofocus: true,
127
+ }),
128
+ span(
129
+ { class: "submit-button p-2", onclick: "$('form.copilot').submit()" },
130
+ i({ id: "sendbuttonicon", class: "far fa-paper-plane" })
131
+ )
132
+ ),
133
+
134
+ i(
135
+ small(
136
+ "Skills you can request: " +
137
+ actionClasses.map((ac) => ac.title).join(", ")
138
+ )
139
+ )
140
+ );
141
+ return {
142
+ widths: [3, 9],
143
+ gx: 3,
144
+ besides: [
145
+ {
146
+ type: "container",
147
+ contents: div(
148
+ div(
149
+ {
150
+ class: "d-flex justify-content-between align-middle mb-2",
151
+ },
152
+ h5("Sessions"),
153
+
154
+ button(
155
+ {
156
+ type: "button",
157
+ class: "btn btn-secondary btn-sm",
158
+ onclick: "unset_state_field('run_id')",
159
+ title: "New session",
160
+ },
161
+ i({ class: "fas fa-redo" })
162
+ )
163
+ ),
164
+ prevRuns.map((run) =>
165
+ div(
166
+ {
167
+ onclick: `set_state_field('run_id',${run.id})`,
168
+ class: "prevcopilotrun",
169
+ },
170
+ localeDateTime(run.started_at),
171
+
172
+ p(
173
+ { class: "prevrun_content" },
174
+ run.context.interactions[0]?.content
175
+ )
176
+ )
177
+ )
178
+ ),
179
+ },
180
+ {
181
+ type: "container",
182
+ contents: div(
183
+ { class: "card" },
184
+ div(
185
+ { class: "card-body" },
186
+ script({
187
+ src: `/static_assets/${db.connectObj.version_tag}/mermaid.min.js`,
188
+ }),
189
+ script(
190
+ { type: "module" },
191
+ `mermaid.initialize({securityLevel: 'loose'${
192
+ getState().getLightDarkMode(req.user) === "dark"
193
+ ? ",theme: 'dark',"
194
+ : ""
195
+ }});`
196
+ ),
197
+ div({ id: "copilotinteractions" }, runInteractions),
198
+ input_form,
199
+ style(
200
+ `div.interaction-segment:not(:first-child) {border-top: 1px solid #e7e7e7; }
201
+ div.interaction-segment {padding-top: 5px;padding-bottom: 5px;}
202
+ div.interaction-segment p {margin-bottom: 0px;}
203
+ div.interaction-segment div.card {margin-top: 0.5rem;}
204
+ div.prevcopilotrun {border-top: 1px solid #e7e7e7;border-right: 1px solid #e7e7e7;padding-top:3px; padding-bottom:3px; padding-right: 1rem;}
205
+ div.prevcopilotrun:hover {cursor: pointer}
206
+ .copilot-entry .submit-button:hover { cursor: pointer}
207
+
208
+ .copilot-entry .submit-button {
209
+ position: relative;
210
+ top: -1.8rem;
211
+ left: 0.1rem;
212
+ }
213
+ .copilot-entry {margin-bottom: -1.25rem; margin-top: 1rem;}
214
+ p.prevrun_content {
215
+ white-space: nowrap;
216
+ overflow: hidden;
217
+ margin-bottom: 0px;
218
+ display: block;
219
+ text-overflow: ellipsis;}`
220
+ ),
221
+ script(`function processCopilotResponse(res) {
222
+ $("#sendbuttonicon").attr("class","far fa-paper-plane");
223
+ const $runidin= $("input[name=run_id")
224
+ if(res.run_id && (!$runidin.val() || $runidin.val()=="undefined"))
225
+ $runidin.val(res.run_id);
226
+ const wrapSegment = (html, who) => '<div class="interaction-segment"><span class="badge bg-secondary">'+who+'</span>'+html+'</div>'
227
+ $("#copilotinteractions").append(wrapSegment('<p>'+$("textarea[name=userinput]").val()+'</p>', "You"))
228
+ $("textarea[name=userinput]").val("")
229
+
230
+ for(const action of res.actions||[]) {
231
+ $("#copilotinteractions").append(wrapSegment(action, "Copilot"))
232
+
233
+ }
234
+
235
+ if(res.response)
236
+ $("#copilotinteractions").append(wrapSegment('<p>'+res.response+'</p>', "Copilot"))
237
+ }
238
+ function restore_old_button_elem(btn) {
239
+ const oldText = $(btn).data("old-text");
240
+ btn.html(oldText);
241
+ btn.css({ width: "" });
242
+ btn.removeData("old-text");
243
+ }
244
+ function processExecuteResponse(res) {
245
+ const btn = $("#exec-"+res.fcall_id)
246
+ restore_old_button_elem($("#exec-"+res.fcall_id))
247
+ btn.prop('disabled', true);
248
+ btn.html('<i class="fas fa-check me-1"></i>Applied')
249
+ btn.removeClass("btn-primary")
250
+ btn.addClass("btn-secondary")
251
+ if(res.postExec) {
252
+ $('#postexec-'+res.fcall_id).html(res.postExec)
253
+ }
254
+ }
255
+ function submitOnEnter(event) {
256
+ if (event.which === 13) {
257
+ if (!event.repeat) {
258
+ const newEvent = new Event("submit", {cancelable: true});
259
+ event.target.form.dispatchEvent(newEvent);
260
+ }
261
+
262
+ event.preventDefault(); // Prevents the addition of a new line in the text field
263
+ }
264
+ }
265
+ document.getElementById("inputuserinput").addEventListener("keydown", submitOnEnter);
266
+ function spin_send_button() {
267
+ $("#sendbuttonicon").attr("class","fas fa-spinner fa-spin");
268
+ }
269
+ `)
270
+ )
271
+ ),
272
+ },
273
+ ],
274
+ };
275
+ };
276
+
277
+ const ellipsize = (s, nchars) => {
278
+ if (!s || !s.length) return "";
279
+ if (s.length <= (nchars || 20)) return text_attr(s);
280
+ return text_attr(s.substr(0, (nchars || 20) - 3)) + "...";
281
+ };
282
+
283
+ const actionClasses = [
284
+ require("./actions/generate-workflow"),
285
+ require("./actions/generate-tables"),
286
+ require("./actions/generate-js-action"),
287
+ ];
288
+
289
+ const getCompletionArguments = async () => {
290
+ const tools = [];
291
+ const sysPrompts = [];
292
+ for (const actionClass of actionClasses) {
293
+ tools.push({
294
+ type: "function",
295
+ function: {
296
+ name: actionClass.function_name,
297
+ description: actionClass.description,
298
+ parameters: await actionClass.json_schema(),
299
+ },
300
+ });
301
+ sysPrompts.push(await actionClass.system_prompt());
302
+ }
303
+ const systemPrompt =
304
+ "You are building application components in a database application builder called Saltcorn.\n\n" +
305
+ sysPrompts.join("\n\n");
306
+ return { tools, systemPrompt };
307
+ };
308
+
309
+ /*
310
+
311
+ build a workflow that asks the user for their name and age
312
+
313
+ */
314
+
315
+ const execute = async (table_id, viewname, config, body, { req }) => {
316
+ const { fcall_id, run_id } = body;
317
+
318
+ const run = await WorkflowRun.findOne({ id: +run_id });
319
+
320
+ const fcall = run.context.funcalls[fcall_id];
321
+ const actionClass = actionClasses.find(
322
+ (ac) => ac.function_name === fcall.name
323
+ );
324
+ const result = await actionClass.execute(JSON.parse(fcall.arguments), req);
325
+ await addToContext(run, { implemented_fcall_ids: [fcall_id] });
326
+ return { json: { success: "ok", fcall_id, ...(result || {}) } };
327
+ };
328
+
329
+ const interact = async (table_id, viewname, config, body, { req }) => {
330
+ const { userinput, run_id } = body;
331
+ let run;
332
+ if (!run_id || run_id === "undefined")
333
+ run = await WorkflowRun.create({
334
+ status: "Running",
335
+ context: {
336
+ implemented_fcall_ids: [],
337
+ interactions: [{ role: "user", content: userinput }],
338
+ funcalls: {},
339
+ },
340
+ });
341
+ else {
342
+ run = await WorkflowRun.findOne({ id: +run_id });
343
+ await addToContext(run, {
344
+ interactions: [{ role: "user", content: userinput }],
345
+ });
346
+ }
347
+ const complArgs = await getCompletionArguments();
348
+ complArgs.chat = run.context.interactions;
349
+ //console.log(complArgs);
350
+
351
+ //build a database for a bicycle rental company
352
+ //add a boolean field called "paid" to the payments table
353
+
354
+ const answer = await getState().functions.llm_generate.run(
355
+ userinput,
356
+ complArgs
357
+ );
358
+ await addToContext(run, {
359
+ interactions:
360
+ typeof answer === "object" && answer.tool_calls
361
+ ? [
362
+ { role: "assistant", tool_calls: answer.tool_calls },
363
+ ...answer.tool_calls.map((tc) => ({
364
+ role: "tool",
365
+ tool_call_id: tc.id,
366
+ name: tc.function.name,
367
+ content: "Action suggested to user.",
368
+ })),
369
+ ]
370
+ : [{ role: "assistant", content: answer }],
371
+ });
372
+ console.log("answer", answer);
373
+
374
+ if (typeof answer === "object" && answer.tool_calls) {
375
+ const actions = [];
376
+ for (const tool_call of answer.tool_calls) {
377
+ await addToContext(run, {
378
+ funcalls: { [tool_call.id]: tool_call.function },
379
+ });
380
+ const markup = await renderToolcall(tool_call, viewname, false, run);
381
+
382
+ actions.push(markup);
383
+ }
384
+ return { json: { success: "ok", actions, run_id: run.id } };
385
+ } else return { json: { success: "ok", response: answer, run_id: run.id } };
386
+ };
387
+
388
+ const renderToolcall = async (tool_call, viewname, implemented, run) => {
389
+ const fname = tool_call.function.name;
390
+ const actionClass = actionClasses.find((ac) => ac.function_name === fname);
391
+ const args = JSON.parse(tool_call.function.arguments);
392
+
393
+ const inner_markup = await actionClass.render_html(args);
394
+ return wrapAction(
395
+ inner_markup,
396
+ viewname,
397
+ tool_call,
398
+ actionClass,
399
+ implemented,
400
+ run
401
+ );
402
+ };
403
+
404
+ const wrapAction = (
405
+ inner_markup,
406
+ viewname,
407
+ tool_call,
408
+ actionClass,
409
+ implemented,
410
+ run
411
+ ) =>
412
+ div(
413
+ { class: "card mb-3" },
414
+ div({ class: "card-header" }, h5(actionClass.title)),
415
+ div(
416
+ { class: "card-body" },
417
+ inner_markup,
418
+ implemented
419
+ ? button(
420
+ {
421
+ type: "button",
422
+ class: "btn btn-secondary d-block mt-3 float-end",
423
+ disabled: true,
424
+ },
425
+ i({ class: "fas fa-check me-1" }),
426
+ "Applied"
427
+ )
428
+ : button(
429
+ {
430
+ type: "button",
431
+ id: "exec-" + tool_call.id,
432
+ class: "btn btn-primary d-block mt-3 float-end",
433
+ onclick: `press_store_button(this, true);view_post('${viewname}', 'execute', {fcall_id: '${tool_call.id}', run_id: ${run.id}}, processExecuteResponse)`,
434
+ },
435
+ "Apply"
436
+ ),
437
+ div({ id: "postexec-" + tool_call.id })
438
+ )
439
+ );
440
+
441
+ const addToContext = async (run, newCtx) => {
442
+ if (run.addToContext) return await run.addToContext(newCtx);
443
+ let changed = true;
444
+ Object.keys(newCtx).forEach((k) => {
445
+ if (Array.isArray(run.context[k])) {
446
+ if (!Array.isArray(newCtx[k]))
447
+ throw new Error("Must be array to append to array");
448
+ run.context[k].push(...newCtx[k]);
449
+ changed = true;
450
+ } else if (typeof run.context[k] === "object") {
451
+ if (typeof newCtx[k] !== "object")
452
+ throw new Error("Must be object to append to object");
453
+ Object.assign(run.context[k], newCtx[k]);
454
+ changed = true;
455
+ } else {
456
+ run.context[k] = newCtx[k];
457
+ changed = true;
458
+ }
459
+ });
460
+ if (changed) await run.update({ context: run.context });
461
+ };
462
+
463
+ module.exports = {
464
+ name: "Saltcorn Copilot",
465
+ display_state_form: false,
466
+ get_state_fields,
467
+ tableless: true,
468
+ singleton: true,
469
+ run,
470
+ routes: { interact, execute },
471
+ };
package/common.js CHANGED
@@ -57,4 +57,50 @@ const incompleteCfgMsg = () => {
57
57
  }
58
58
  };
59
59
 
60
- module.exports = { getCompletion, getPromptFromTemplate, incompleteCfgMsg };
60
+ const toArrayOfStrings = (opts) => {
61
+ if (typeof opts === "string") return opts.split(",").map((s) => s.trim());
62
+ if (Array.isArray(opts))
63
+ return opts.map((o) => (typeof o === "string" ? o : o.value || o.name));
64
+ };
65
+
66
+ const fieldProperties = (field) => {
67
+ const props = {};
68
+ const typeName = field.type?.name || field.type || field.input_type;
69
+ if (field.isRepeat) {
70
+ props.type = "array";
71
+ const properties = {};
72
+ field.fields.map((f) => {
73
+ properties[f.name] = {
74
+ description: f.sublabel || f.label,
75
+ ...fieldProperties(f),
76
+ };
77
+ });
78
+ props.items = {
79
+ type: "object",
80
+ properties,
81
+ };
82
+ }
83
+ switch (typeName) {
84
+ case "String":
85
+ props.type = "string";
86
+ if (field.attributes?.options)
87
+ props.enum = toArrayOfStrings(field.attributes.options);
88
+ break;
89
+ case "Bool":
90
+ props.type = "boolean";
91
+ break;
92
+ case "Integer":
93
+ props.type = "integer";
94
+ break;
95
+ case "Float":
96
+ props.type = "number";
97
+ break;
98
+ case "select":
99
+ props.type = "string";
100
+ if (field.options) props.enum = toArrayOfStrings(field.options);
101
+ break;
102
+ }
103
+ return props;
104
+ };
105
+
106
+ module.exports = { getCompletion, getPromptFromTemplate, incompleteCfgMsg, fieldProperties };
package/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const Workflow = require("@saltcorn/data/models/workflow");
2
2
  const Form = require("@saltcorn/data/models/form");
3
+ const { features } = require("@saltcorn/data/db/state");
3
4
 
4
5
  const configuration_workflow = () =>
5
6
  new Workflow({
@@ -10,5 +11,10 @@ module.exports = {
10
11
  sc_plugin_api_version: 1,
11
12
  //configuration_workflow,
12
13
  dependencies: ["@saltcorn/large-language-model"],
13
- viewtemplates: [require("./action-builder"), require("./database-designer")],
14
+ viewtemplates: features.workflows
15
+ ? [require("./chat-copilot")]
16
+ : [require("./action-builder"), require("./database-designer")],
17
+ functions: features.workflows
18
+ ? { copilot_generate_workflow: require("./workflow-gen") }
19
+ : {},
14
20
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/copilot",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "AI assistant for building Saltcorn applications",
5
5
  "main": "index.js",
6
6
  "dependencies": {
@@ -15,7 +15,7 @@
15
15
  "eslintConfig": {
16
16
  "extends": "eslint:recommended",
17
17
  "parserOptions": {
18
- "ecmaVersion": 2020
18
+ "ecmaVersion": 2022
19
19
  },
20
20
  "env": {
21
21
  "node": true,
@@ -300,8 +300,4 @@ value should be an object with keys that are field variable names. Example:
300
300
  return { set_fields: {
301
301
  zidentifier: `${name.toUpperCase()}-${id}`
302
302
  }
303
- }
304
-
305
- Write the JavaScript code to implement the following functionality:
306
-
307
- {{ userPrompt }}
303
+ }
@@ -0,0 +1,45 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+ const WorkflowStep = require("@saltcorn/data/models/workflow_step");
3
+ const Trigger = require("@saltcorn/data/models/trigger");
4
+ const { getActionConfigFields } = require("@saltcorn/data/plugin-helper");
5
+ const GenerateWorkflow = require("./actions/generate-workflow");
6
+
7
+ const workflow_function = async () => ({
8
+ type: "function",
9
+ function: {
10
+ name: "generate_workflow",
11
+ description: "Generate the steps in a workflow",
12
+ parameters: await GenerateWorkflow.json_schema(),
13
+ },
14
+ });
15
+
16
+ module.exports = {
17
+ run: async (description) => {
18
+ const rnd = Math.round(100 * Math.random());
19
+ const systemPrompt = await GenerateWorkflow.system_prompt();
20
+
21
+ const toolargs = {
22
+ tools: [await workflow_function()],
23
+ tool_choice: {
24
+ type: "function",
25
+ function: { name: "generate_workflow" },
26
+ },
27
+ systemPrompt,
28
+ };
29
+ const prompt = `Design a workflow to implement a workflow accorfing to the following specification: ${description}`;
30
+ console.log(prompt);
31
+ console.log(JSON.stringify(toolargs, null, 2));
32
+
33
+ const answer = await getState().functions.llm_generate.run(
34
+ prompt,
35
+ toolargs
36
+ );
37
+ const resp = JSON.parse(answer.tool_calls[0].function.arguments);
38
+ const scsteps = resp.workflow_steps.map(GenerateWorkflow.to_saltcorn_step);
39
+
40
+ return scsteps;
41
+ },
42
+ isAsync: true,
43
+ description: "Generate a workflow",
44
+ arguments: [{ name: "description", type: "String" }],
45
+ };