@saltcorn/agents 0.1.2 → 0.2.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/agent-view.js CHANGED
@@ -2,6 +2,7 @@ const Field = require("@saltcorn/data/models/field");
2
2
  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
+ const File = require("@saltcorn/data/models/file");
5
6
  const Trigger = require("@saltcorn/data/models/trigger");
6
7
  const FieldRepeat = require("@saltcorn/data/models/fieldrepeat");
7
8
  const db = require("@saltcorn/data/db");
@@ -26,6 +27,7 @@ const {
26
27
  small,
27
28
  form,
28
29
  textarea,
30
+ label,
29
31
  a,
30
32
  } = require("@saltcorn/markup/tags");
31
33
  const { getState } = require("@saltcorn/data/db/state");
@@ -45,7 +47,7 @@ const configuration_workflow = (req) =>
45
47
  new Workflow({
46
48
  steps: [
47
49
  {
48
- name: req.__("Agent action"),
50
+ name: "Agent action",
49
51
  form: async (context) => {
50
52
  const agent_actions = await Trigger.find({ action: "Agent" });
51
53
  return new Form({
@@ -68,7 +70,7 @@ const configuration_workflow = (req) =>
68
70
  "data-dyn-href": `\`/actions/configure/\${action_id}\``,
69
71
  target: "_blank",
70
72
  },
71
- req.__("Configure")
73
+ "Configure"
72
74
  ),
73
75
  },
74
76
  {
@@ -80,7 +82,27 @@ const configuration_workflow = (req) =>
80
82
  name: "placeholder",
81
83
  label: "Placeholder",
82
84
  type: "String",
83
- default: "How can I help you?"
85
+ default: "How can I help you?",
86
+ },
87
+ {
88
+ name: "explainer",
89
+ label: "Explainer",
90
+ type: "String",
91
+ sublabel:
92
+ "Appears below the input box. Use for additional instructions.",
93
+ },
94
+ {
95
+ name: "image_upload",
96
+ label: "Upload images",
97
+ sublabel: "Allow the user to upload images",
98
+ type: "Bool",
99
+ },
100
+ {
101
+ name: "image_base64",
102
+ label: "base64 encode",
103
+ sublabel: "Use base64 encoding in the OpenAI API",
104
+ type: "Bool",
105
+ showIf: { image_upload: true },
84
106
  },
85
107
  ],
86
108
  });
@@ -91,10 +113,30 @@ const configuration_workflow = (req) =>
91
113
 
92
114
  const get_state_fields = () => [];
93
115
 
116
+ const uploadForm = (viewname, req) =>
117
+ span(
118
+ {
119
+ class: "attach_agent_image_wrap",
120
+ },
121
+ label(
122
+ { class: "btn-link", for: "attach_agent_image" },
123
+ i({ class: "fas fa-paperclip" })
124
+ ),
125
+ input({
126
+ id: "attach_agent_image",
127
+ name: "file",
128
+ type: "file",
129
+ class: "d-none",
130
+ accept: "image/*",
131
+ onchange: `agent_file_attach(event)`,
132
+ }),
133
+ span({ class: "ms-2 filename-label" })
134
+ );
135
+
94
136
  const run = async (
95
137
  table_id,
96
138
  viewname,
97
- { action_id, show_prev_runs, placeholder },
139
+ { action_id, show_prev_runs, placeholder, explainer, image_upload },
98
140
  state,
99
141
  { res, req }
100
142
  ) => {
@@ -115,13 +157,33 @@ const run = async (
115
157
  for (const interact of run.context.interactions) {
116
158
  switch (interact.role) {
117
159
  case "user":
118
- interactMarkups.push(
119
- div(
120
- { class: "interaction-segment" },
121
- span({ class: "badge bg-secondary" }, "You"),
122
- md.render(interact.content)
123
- )
124
- );
160
+ console.log(interact.content);
161
+ if (interact.content?.[0]?.type === "image_url") {
162
+ const image_url = interact.content[0].image_url.url;
163
+ if (image_url.startsWith("data"))
164
+ interactMarkups.push(
165
+ div(
166
+ { class: "interaction-segment" },
167
+ span({ class: "badge bg-secondary" }, "You"),
168
+ "File"
169
+ )
170
+ );
171
+ else
172
+ interactMarkups.push(
173
+ div(
174
+ { class: "interaction-segment" },
175
+ span({ class: "badge bg-secondary" }, "You"),
176
+ a({ href: image_url, target: "_blank" }, "File")
177
+ )
178
+ );
179
+ } else
180
+ interactMarkups.push(
181
+ div(
182
+ { class: "interaction-segment" },
183
+ span({ class: "badge bg-secondary" }, "You"),
184
+ md.render(interact.content)
185
+ )
186
+ );
125
187
  break;
126
188
  case "assistant":
127
189
  case "system":
@@ -209,7 +271,7 @@ const run = async (
209
271
  }
210
272
  const input_form = form(
211
273
  {
212
- onsubmit: `event.preventDefault();spin_send_button();view_post('${viewname}', 'interact', $(this).serialize(), processCopilotResponse);return false;`,
274
+ onsubmit: `event.preventDefault();spin_send_button();view_post('${viewname}', 'interact', new FormData(this), processCopilotResponse);return false;`,
213
275
  class: "form-namespace copilot mt-2",
214
276
  method: "post",
215
277
  },
@@ -238,7 +300,9 @@ const run = async (
238
300
  span(
239
301
  { class: "submit-button p-2", onclick: "$('form.copilot').submit()" },
240
302
  i({ id: "sendbuttonicon", class: "far fa-paper-plane" })
241
- )
303
+ ),
304
+ image_upload && uploadForm(viewname, req),
305
+ explainer && small({ class: "explainer" }, i(explainer))
242
306
  )
243
307
  );
244
308
 
@@ -283,17 +347,6 @@ const run = async (
283
347
  { class: "card" },
284
348
  div(
285
349
  { class: "card-body" },
286
- script({
287
- src: `/static_assets/${db.connectObj.version_tag}/mermaid.min.js`,
288
- }),
289
- script(
290
- { type: "module" },
291
- `mermaid.initialize({securityLevel: 'loose'${
292
- getState().getLightDarkMode(req.user) === "dark"
293
- ? ",theme: 'dark',"
294
- : ""
295
- }});`
296
- ),
297
350
  div({ id: "copilotinteractions" }, runInteractions),
298
351
  input_form,
299
352
  style(
@@ -305,11 +358,22 @@ const run = async (
305
358
  div.prevcopilotrun i.fa-trash-alt {display: none;}
306
359
  div.prevcopilotrun:hover i.fa-trash-alt {display: block;}
307
360
  .copilot-entry .submit-button:hover { cursor: pointer}
361
+ .copilot-entry span.attach_agent_image_wrap i:hover { cursor: pointer}
308
362
 
309
363
  .copilot-entry .submit-button {
310
364
  position: relative;
311
365
  top: -1.8rem;
312
366
  left: 0.1rem;
367
+ }
368
+ .copilot-entry span.attach_agent_image_wrap {
369
+ position: relative;
370
+ top: -1.8rem;
371
+ left: 0.2rem;
372
+ }
373
+ .copilot-entry .explainer {
374
+ position: relative;
375
+ top: -1.2rem;
376
+ display: block;
313
377
  }
314
378
  .copilot-entry {margin-bottom: -1.25rem; margin-top: 1rem;}
315
379
  p.prevrun_content {
@@ -320,17 +384,25 @@ const run = async (
320
384
  text-overflow: ellipsis;}`
321
385
  ),
322
386
  script(`function processCopilotResponse(res) {
387
+ const hadFile = $("input#attach_agent_image").val();
388
+ $("span.filename-label").text("");
389
+ $("input#attach_agent_image").val(null);
323
390
  $("#sendbuttonicon").attr("class","far fa-paper-plane");
324
391
  const $runidin= $("input[name=run_id")
325
392
  if(res.run_id && (!$runidin.val() || $runidin.val()=="undefined"))
326
393
  $runidin.val(res.run_id);
327
394
  const wrapSegment = (html, who) => '<div class="interaction-segment"><span class="badge bg-secondary">'+who+'</span>'+html+'</div>'
328
395
  $("#copilotinteractions").append(wrapSegment('<p>'+$("textarea[name=userinput]").val()+'</p>', "You"))
329
- $("textarea[name=userinput]").val("")
396
+ if(hadFile)
397
+ $("#copilotinteractions").append(wrapSegment('File', "You"))
398
+ $("textarea[name=userinput]").val("")
330
399
 
331
400
  if(res.response)
332
401
  $("#copilotinteractions").append(res.response)
333
402
  }
403
+ function agent_file_attach(e) {
404
+ $(".attach_agent_image_wrap span.filename-label").text(e.target.files[0].name)
405
+ }
334
406
  function restore_old_button_elem(btn) {
335
407
  const oldText = $(btn).data("old-text");
336
408
  btn.html(oldText);
@@ -390,12 +462,6 @@ const run = async (
390
462
  : main_chat;
391
463
  };
392
464
 
393
- /*
394
-
395
- build a workflow that asks the user for their name and age
396
-
397
- */
398
-
399
465
  const interact = async (table_id, viewname, config, body, { req, res }) => {
400
466
  const { userinput, run_id } = body;
401
467
  let run;
@@ -416,6 +482,41 @@ const interact = async (table_id, viewname, config, body, { req, res }) => {
416
482
  interactions: [{ role: "user", content: userinput }],
417
483
  });
418
484
  }
485
+ if (config.image_upload && req.files?.file) {
486
+ const file = await File.from_req_files(
487
+ req.files.file,
488
+ req.user ? req.user.id : null,
489
+ 100
490
+ // file_field?.attributes?.folder
491
+ );
492
+ const baseUrl = getState().getConfig("base_url").replace(/\/$/, "");
493
+ let imageurl;
494
+ if (
495
+ !config.image_base64 &&
496
+ baseUrl &&
497
+ !baseUrl.includes("http://localhost:")
498
+ ) {
499
+ imageurl = `${baseUrl}/files/serve/${file.path_to_serve}`;
500
+ } else {
501
+ const b64 = await file.get_contents("base64");
502
+ imageurl = `data:${file.mimetype};base64,${b64}`;
503
+ }
504
+ await addToContext(run, {
505
+ interactions: [
506
+ {
507
+ role: "user",
508
+ content: [
509
+ {
510
+ type: "image_url",
511
+ image_url: {
512
+ url: imageurl,
513
+ },
514
+ },
515
+ ],
516
+ },
517
+ ],
518
+ });
519
+ }
419
520
  const action = await Trigger.findOne({ id: config.action_id });
420
521
 
421
522
  return await process_interaction(run, action.configuration, req, action.name);
package/common.js CHANGED
@@ -9,6 +9,8 @@ const get_skills = () => {
9
9
  return [
10
10
  require("./skills/FTSRetrieval"),
11
11
  require("./skills/EmbeddingRetrieval"),
12
+ require("./skills/Trigger"),
13
+ require("./skills/Table"),
12
14
  //require("./skills/AdaptiveFeedback"),
13
15
  ];
14
16
  };
@@ -181,7 +183,8 @@ const process_interaction = async (
181
183
  }
182
184
  hasResult = true;
183
185
  const result = await tool.tool.process(
184
- JSON.parse(tool_call.function.arguments)
186
+ JSON.parse(tool_call.function.arguments),
187
+ { req }
185
188
  );
186
189
  if (
187
190
  (typeof result === "object" && Object.keys(result || {}).length) ||
package/index.js CHANGED
@@ -93,8 +93,9 @@ module.exports = {
93
93
  /*
94
94
  TODO
95
95
 
96
- -embedding retrieval test
97
- -promote triggers to skills (optional user confirm)
96
+ -embedding retrieval list view
97
+ -optional user confirm: action, insert
98
+ -Preload data
98
99
  -sql access
99
100
  -memory
100
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/agents",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "AI agents for Saltcorn",
5
5
  "main": "index.js",
6
6
  "dependencies": {
@@ -37,17 +37,26 @@ class RetrievalByEmbedding {
37
37
  const allTables = await Table.find();
38
38
  const tableOpts = [];
39
39
  const relation_opts = {};
40
+ const list_view_opts = {};
40
41
  for (const table of allTables) {
41
- table.fields
42
- .filter((f) => f.type?.name === "PGVector")
43
- .forEach((f) => {
44
- const relNm = `${table.name}.${f.name}`;
45
- tableOpts.push(relNm);
46
- const fkeys = table.fields
47
- .filter((f) => f.is_fkey)
48
- .map((f) => f.name);
49
- relation_opts[relNm] = ["", ...fkeys];
50
- });
42
+ for (const f of table.fields) {
43
+ if (f.type?.name !== "PGVector") continue;
44
+ const relNm = `${table.name}.${f.name}`;
45
+ tableOpts.push(relNm);
46
+ list_view_opts[relNm] = [""];
47
+ const fkeys = table.fields.filter((f) => f.is_fkey).map((f) => f.name);
48
+ relation_opts[relNm] = ["", ...fkeys];
49
+ for (const fkeyField of table.fields.filter((f) => f.is_fkey)) {
50
+ const t = Table.findOne(fkeyField.reftable_name);
51
+ const lviews = await View.find_table_views_where(
52
+ t.id,
53
+ ({ state_fields, viewrow }) =>
54
+ viewrow.viewtemplate !== "Edit" &&
55
+ state_fields.every((sf) => !sf.required)
56
+ );
57
+ list_view_opts[relNm].push(...lviews.map((v) => v.name));
58
+ }
59
+ }
51
60
  }
52
61
  return [
53
62
  {
@@ -74,6 +83,14 @@ class RetrievalByEmbedding {
74
83
  required: true,
75
84
  attributes: { calcOptions: ["vec_field", relation_opts] },
76
85
  },
86
+ {
87
+ name: "list_view",
88
+ label: "List view",
89
+ type: "String",
90
+ attributes: {
91
+ calcOptions: ["table_name", list_view_opts],
92
+ },
93
+ },
77
94
  {
78
95
  name: "hidden_fields",
79
96
  label: "Hide fields",
@@ -90,6 +107,7 @@ class RetrievalByEmbedding {
90
107
  name: "add_sys_prompt",
91
108
  label: "Additional prompt",
92
109
  type: "String",
110
+ fieldview: "textarea",
93
111
  },
94
112
  ];
95
113
  }
@@ -152,6 +170,24 @@ class RetrievalByEmbedding {
152
170
  return { rows: docs };
153
171
  }
154
172
  },
173
+ renderToolResponse: async ({ response, rows }, { req }) => {
174
+ if (rows) {
175
+ const view = View.findOne({ name: this.list_view });
176
+
177
+ if (view) {
178
+ const viewRes = await view.run(
179
+ {
180
+ [table_docs.pk_name]: {
181
+ in: rows.map((r) => r[table_docs.pk_name]),
182
+ },
183
+ },
184
+ { req }
185
+ );
186
+ return viewRes;
187
+ } else return "";
188
+ }
189
+ return div({ class: "border border-success p-2 m-2" }, response);
190
+ },
155
191
  function: {
156
192
  name: this.toolName,
157
193
  description: `Search the ${table_docs.name``} archive${
@@ -26,6 +26,10 @@ class RetrievalByFullTextSearch {
26
26
  return `Use the ${this.toolName} tool to search the ${
27
27
  this.table_name
28
28
  } database by a search phrase which will locate rows where any field match that query.${
29
+ this.list_view
30
+ ? ` When the tool call returns rows, do not describe them or repeat the information to the user. The results are already displayed to the user automatically.`
31
+ : ""
32
+ }${
29
33
  this.add_sys_prompt
30
34
  ? ` Additional information for the ${this.toolName} tool: ${this.add_sys_prompt}`
31
35
  : ""
@@ -78,6 +82,7 @@ class RetrievalByFullTextSearch {
78
82
  name: "add_sys_prompt",
79
83
  label: "Additional prompt",
80
84
  type: "String",
85
+ fieldview: "textarea",
81
86
  },
82
87
  /*{
83
88
  name: "contents_expr",
@@ -0,0 +1,241 @@
1
+ const { div, pre } = require("@saltcorn/markup/tags");
2
+ const Workflow = require("@saltcorn/data/models/workflow");
3
+ const Form = require("@saltcorn/data/models/form");
4
+ const Table = require("@saltcorn/data/models/table");
5
+ const View = require("@saltcorn/data/models/view");
6
+ const { getState } = require("@saltcorn/data/db/state");
7
+ const db = require("@saltcorn/data/db");
8
+ const { fieldProperties } = require("./helpers");
9
+
10
+ class TableToSkill {
11
+ static skill_name = "Table";
12
+
13
+ get skill_label() {
14
+ return `${this.table_name} table`;
15
+ }
16
+
17
+ constructor(cfg) {
18
+ Object.assign(this, cfg);
19
+ }
20
+
21
+ systemPrompt() {
22
+ return `Use the query_${this.table_name} tool to search the ${
23
+ this.table_name
24
+ } database by a search phrase which will locate rows where any field match that query.${
25
+ this.add_sys_prompt ? ` ${this.add_sys_prompt}` : ""
26
+ }`;
27
+ }
28
+
29
+ static async configFields() {
30
+ const allTables = await Table.find();
31
+ const list_view_opts = {};
32
+ for (const t of allTables) {
33
+ const lviews = await View.find_table_views_where(
34
+ t.id,
35
+ ({ state_fields, viewrow }) =>
36
+ viewrow.viewtemplate !== "Edit" &&
37
+ state_fields.every((sf) => !sf.required)
38
+ );
39
+ list_view_opts[t.name] = ["", ...lviews.map((v) => v.name)];
40
+ }
41
+ return [
42
+ {
43
+ name: "table_name",
44
+ label: "Table",
45
+ sublabel: "Which table to link to the agent",
46
+ type: "String",
47
+ required: true,
48
+ attributes: { options: allTables.map((t) => t.name) },
49
+ },
50
+ {
51
+ name: "query",
52
+ label: "Query",
53
+ type: "Bool",
54
+ sublabel: "Allow the agent to query from this table",
55
+ },
56
+ {
57
+ name: "insert",
58
+ label: "Insert",
59
+ type: "Bool",
60
+ sublabel: "Allow the agent to insert new rows into this table",
61
+ },
62
+ {
63
+ name: "list_view",
64
+ label: "List view",
65
+ type: "String",
66
+ attributes: {
67
+ calcOptions: ["table_name", list_view_opts],
68
+ },
69
+ },
70
+ {
71
+ name: "hidden_fields",
72
+ label: "Hide fields",
73
+ type: "String",
74
+ sublabel: "Comma-separated list of fields to hide from the prompt",
75
+ showIf: { query: true },
76
+ },
77
+ {
78
+ name: "add_sys_prompt",
79
+ label: "Additional system prompt",
80
+ type: "String",
81
+ fieldview: "textarea"
82
+ },
83
+ ];
84
+ }
85
+
86
+ provideTools() {
87
+ const table = Table.findOne(this.table_name);
88
+ const tools = [];
89
+ let queryProperties = {};
90
+ let required = [];
91
+
92
+ table.fields
93
+ .filter((f) => !f.primary_key)
94
+ .forEach((field) => {
95
+ if (field.required && !field.primary_key) required.push(field.name);
96
+ queryProperties[field.name] = {
97
+ description: field.label + " " + field.description || "",
98
+ ...fieldProperties(field),
99
+ };
100
+ });
101
+
102
+ if (this.query)
103
+ tools.push({
104
+ type: "function",
105
+ process: async (q) => {
106
+ console.log("Table search", q);
107
+
108
+ let rows = [];
109
+ if (q.query?.full_text_search || q.full_text_search) {
110
+ const scState = getState();
111
+ const language = scState.pg_ts_config;
112
+ const use_websearch = scState.getConfig(
113
+ "search_use_websearch",
114
+ false
115
+ );
116
+ rows = await table.getRows({
117
+ _fts: {
118
+ fields: table.fields,
119
+ searchTerm: q.query?.full_text_search || q.full_text_search,
120
+ language,
121
+ use_websearch,
122
+ table: table.name,
123
+ schema: db.isSQLite ? undefined : db.getTenantSchema(),
124
+ },
125
+ });
126
+ } else {
127
+ rows = await table.getRows(q.query || q);
128
+ }
129
+ if (this.hidden_fields) {
130
+ const hidden_fields = this.hidden_fields
131
+ .split(",")
132
+ .map((s) => s.trim());
133
+ rows.forEach((r) => {
134
+ hidden_fields.forEach((k) => {
135
+ delete r[k];
136
+ });
137
+ });
138
+ }
139
+
140
+ if (rows.length) return { rows };
141
+ else
142
+ return {
143
+ response: "No rows found",
144
+ };
145
+ },
146
+ /*renderToolCall({ phrase }, { req }) {
147
+ return div({ class: "border border-primary p-2 m-2" }, phrase);
148
+ },*/
149
+ renderToolResponse: async ({ response, rows }, { req }) => {
150
+ if (rows) {
151
+ const view = View.findOne({ name: this.list_view });
152
+
153
+ if (view) {
154
+ const viewRes = await view.run(
155
+ { [table.pk_name]: { in: rows.map((r) => r[table.pk_name]) } },
156
+ { req }
157
+ );
158
+ return viewRes;
159
+ } else return "";
160
+ }
161
+ return div({ class: "border border-success p-2 m-2" }, response);
162
+ },
163
+ function: {
164
+ name: `query_${this.table_name}`,
165
+ description: `Search the ${this.table_name} database table${
166
+ table.description ? ` (${table.description})` : ""
167
+ } by a search phrase matched against all fields in the table with full text search or a more specific query. The retrieved rows will be returned`,
168
+ parameters: {
169
+ type: "object",
170
+ properties: {
171
+ query: {
172
+ anyOf: [
173
+ {
174
+ type: "object",
175
+ description:
176
+ "Search the table by a phrase matched against any string field",
177
+ properties: {
178
+ full_text_search: {
179
+ type: "string",
180
+ description: "A phrase to search the table with",
181
+ },
182
+ },
183
+ },
184
+ {
185
+ type: "object",
186
+ description: "Search the table by individual fields",
187
+ properties: queryProperties,
188
+ },
189
+ ],
190
+ },
191
+ },
192
+ },
193
+ },
194
+ });
195
+
196
+ if (this.insert)
197
+ tools.push({
198
+ type: "function",
199
+ process: async (rows, { req }) => {
200
+ const ids = [];
201
+ if (Array.isArray(rows)) {
202
+ for (const row of rows)
203
+ ids.push(await table.insertRow(row, req?.user));
204
+ } else ids.push(await table.insertRow(rows, req?.user));
205
+ return { created_ids: ids };
206
+ },
207
+ /*renderToolCall({ phrase }, { req }) {
208
+ return div({ class: "border border-primary p-2 m-2" }, phrase);
209
+ },*/
210
+ renderToolResponse: async ({ created_ids }, { req }) => {
211
+ if (created_ids) {
212
+ const view = View.findOne({ name: this.list_view });
213
+
214
+ if (view) {
215
+ const viewRes = await view.run(
216
+ { [table.pk_name]: { in: created_ids } },
217
+ { req }
218
+ );
219
+ return viewRes;
220
+ } else return "";
221
+ }
222
+ },
223
+ function: {
224
+ name: `insert_${this.table_name}`,
225
+ description: `Insert rows into the ${
226
+ this.table_name
227
+ } database table${
228
+ table.description ? ` (${table.description})` : ""
229
+ }`,
230
+ parameters: {
231
+ type: "object",
232
+ required,
233
+ properties: queryProperties,
234
+ },
235
+ },
236
+ });
237
+ return tools;
238
+ }
239
+ }
240
+
241
+ module.exports = TableToSkill;
@@ -0,0 +1,101 @@
1
+ const { div, pre, a } = require("@saltcorn/markup/tags");
2
+ const Workflow = require("@saltcorn/data/models/workflow");
3
+ const Form = require("@saltcorn/data/models/form");
4
+ const Table = require("@saltcorn/data/models/table");
5
+ const View = require("@saltcorn/data/models/view");
6
+ const Trigger = require("@saltcorn/data/models/trigger");
7
+ const { getState } = require("@saltcorn/data/db/state");
8
+ const db = require("@saltcorn/data/db");
9
+ const { fieldProperties } = require("./helpers");
10
+
11
+ class TriggerToSkill {
12
+ static skill_name = "Trigger";
13
+
14
+ get skill_label() {
15
+ return this.trigger_name;
16
+ }
17
+
18
+ constructor(cfg) {
19
+ Object.assign(this, cfg);
20
+ }
21
+
22
+ systemPrompt() {
23
+ const trigger = Trigger.findOne({ name: this.trigger_name });
24
+
25
+ return `${this.trigger_name} tool: ${trigger.description}`;
26
+ }
27
+
28
+ static async configFields() {
29
+ const actions = (await Trigger.find({})).filter(
30
+ (action) => action.description
31
+ );
32
+ const hasTable = actions.filter((a) => a.table_id).map((a) => a.name);
33
+ const confirm_view_opts = {};
34
+ for (const a of actions) {
35
+ if (!a.table_id) continue;
36
+ const views = await View.find({ table_id: a.table_id });
37
+ confirm_view_opts[a.name] = views.map((v) => v.name);
38
+ }
39
+ return [
40
+ {
41
+ name: "trigger_name",
42
+ label: "Action",
43
+ sublabel:
44
+ "Only actions with a description can be enabled. " +
45
+ a(
46
+ {
47
+ "data-dyn-href": `\`/actions/configure/\${trigger_name}\``,
48
+ target: "_blank",
49
+ },
50
+ "Configure"
51
+ ),
52
+ type: "String",
53
+ required: true,
54
+ attributes: { options: actions.map((a) => a.name) },
55
+ },
56
+ // TODO: confirm, show response, show argument
57
+ ];
58
+ }
59
+
60
+ provideTools = () => {
61
+ let properties = {};
62
+
63
+ const trigger = Trigger.findOne({ name: this.trigger_name });
64
+ if (trigger.table_id) {
65
+ const table = Table.findOne({ id: trigger.table_id });
66
+
67
+ table.fields
68
+ .filter((f) => !f.primary_key)
69
+ .forEach((field) => {
70
+ properties[field.name] = {
71
+ description: field.label + " " + field.description || "",
72
+ ...fieldProperties(field),
73
+ };
74
+ });
75
+ }
76
+ return {
77
+ type: "function",
78
+ process: async (row, { req }) => {
79
+ const result = await trigger.runWithoutRow({ user: req?.user, row });
80
+ return result;
81
+ },
82
+ /*renderToolCall({ phrase }, { req }) {
83
+ return div({ class: "border border-primary p-2 m-2" }, phrase);
84
+ },*/
85
+ renderToolResponse: async (response, { req }) => {
86
+ return div({ class: "border border-success p-2 m-2" }, response);
87
+ },
88
+ function: {
89
+ name: trigger.name,
90
+ description: trigger.description,
91
+ parameters: {
92
+ type: "object",
93
+ //required: ["action_javascript_code", "action_name"],
94
+ properties,
95
+ },
96
+ },
97
+ };
98
+ };
99
+ }
100
+
101
+ module.exports = TriggerToSkill;
@@ -0,0 +1,54 @@
1
+ const toArrayOfStrings = (opts) => {
2
+ if (typeof opts === "string") return opts.split(",").map((s) => s.trim());
3
+ if (Array.isArray(opts))
4
+ return opts.map((o) => (typeof o === "string" ? o : o.value || o.name));
5
+ };
6
+
7
+ const fieldProperties = (field) => {
8
+ const props = {};
9
+ const typeName = field.type?.name || field.type || field.input_type;
10
+ if (field.isRepeat) {
11
+ props.type = "array";
12
+ const properties = {};
13
+ field.fields.map((f) => {
14
+ properties[f.name] = {
15
+ description: f.sublabel || f.label,
16
+ ...fieldProperties(f),
17
+ };
18
+ });
19
+ props.items = {
20
+ type: "object",
21
+ properties,
22
+ };
23
+ }
24
+ switch (typeName) {
25
+ case "String":
26
+ props.type = "string";
27
+ if (field.attributes?.options)
28
+ props.enum = toArrayOfStrings(field.attributes.options);
29
+ break;
30
+ case "Bool":
31
+ props.type = "boolean";
32
+ break;
33
+ case "Integer":
34
+ props.type = "integer";
35
+ break;
36
+ case "Float":
37
+ props.type = "number";
38
+ break;
39
+ case "select":
40
+ props.type = "string";
41
+ if (field.options) props.enum = toArrayOfStrings(field.options);
42
+ break;
43
+ }
44
+ if (!props.type) {
45
+ switch (field.input_type) {
46
+ case "code":
47
+ props.type = "string";
48
+ break;
49
+ }
50
+ }
51
+ return props;
52
+ };
53
+
54
+ module.exports = { fieldProperties };