@saltcorn/copilot 0.4.2 → 0.4.4

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/user-copilot.js +168 -134
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/copilot",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "AI assistant for building Saltcorn applications",
5
5
  "main": "index.js",
6
6
  "dependencies": {
package/user-copilot.js CHANGED
@@ -107,6 +107,13 @@ const configuration_workflow = (req) =>
107
107
  calcOptions: ["table_name", list_view_opts],
108
108
  },
109
109
  },
110
+ {
111
+ name: "exclude_fields",
112
+ label: "Exclude fields",
113
+ sublabel:
114
+ "Exclude fields from the chat context. Comma-separated list.",
115
+ type: "String",
116
+ },
110
117
  ],
111
118
  }),
112
119
  ],
@@ -189,6 +196,17 @@ const run = async (table_id, viewname, config, state, { res, req }) => {
189
196
  case "assistant":
190
197
  case "system":
191
198
  if (interact.tool_calls) {
199
+ if (interact.content) {
200
+ interactMarkups.push(
201
+ div(
202
+ { class: "interaction-segment" },
203
+ span({ class: "badge bg-secondary" }, "Copilot"),
204
+ typeof interact.content === "string"
205
+ ? md.render(interact.content)
206
+ : interact.content
207
+ )
208
+ );
209
+ }
192
210
  for (const tool_call of interact.tool_calls) {
193
211
  const action = config.actions.find(
194
212
  (a) => a.trigger_name === tool_call.function.name
@@ -205,17 +223,14 @@ const run = async (table_id, viewname, config, state, { res, req }) => {
205
223
  "Copilot"
206
224
  )
207
225
  );
208
- } else if (tool_call.function.name.startsWith("Query")) {
226
+ } else if (tool_call.function.name === "TableQuery") {
209
227
  const query = JSON.parse(tool_call.function.arguments);
210
228
  const queryText = query.sql_id_query
211
229
  ? query.sql_id_query
212
230
  : JSON.stringify(query, null, 2);
213
231
  interactMarkups.push(
214
232
  wrapSegment(
215
- wrapCard(
216
- "Query " + tool_call.function.name.replace("Query", ""),
217
- pre(queryText)
218
- ),
233
+ wrapCard("Query " + query.table_name, pre(queryText)),
219
234
  "Copilot"
220
235
  )
221
236
  );
@@ -233,25 +248,40 @@ const run = async (table_id, viewname, config, state, { res, req }) => {
233
248
  );
234
249
  break;
235
250
  case "tool":
236
- if (interact.name.startsWith("Query")) {
237
- const table = Table.findOne({
238
- name: interact.name.replace("Query", ""),
239
- });
240
- interactMarkups.push(
241
- await renderQueryInteraction(
242
- table,
243
- JSON.parse(interact.content),
244
- config,
245
- req
251
+ if (interact.name === "TableQuery") {
252
+ const tool_call = run.context.interactions
253
+ .map(
254
+ (i) =>
255
+ i.tool_calls &&
256
+ i.tool_calls.find((tc) => tc.id === interact.tool_call_id)
246
257
  )
247
- );
258
+ .filter(Boolean)[0];
259
+ if (tool_call) {
260
+ const args = JSON.parse(tool_call.function.arguments);
261
+ const table = Table.findOne(args.table_name);
262
+ interactMarkups.push(
263
+ await renderQueryInteraction(
264
+ table,
265
+ JSON.parse(interact.content),
266
+ config,
267
+ req
268
+ )
269
+ );
270
+ }
248
271
  } else if (interact.content !== "Action run") {
272
+ let markupContent;
273
+ try {
274
+ markupContent = JSON.stringify(
275
+ JSON.parse(interact.content),
276
+ null,
277
+ 2
278
+ );
279
+ } catch {
280
+ markupContent = interact.content;
281
+ }
249
282
  interactMarkups.push(
250
283
  wrapSegment(
251
- wrapCard(
252
- interact.name,
253
- pre(JSON.stringify(JSON.parse(interact.content), null, 2))
254
- ),
284
+ wrapCard(interact.name, pre(markupContent)),
255
285
  "Copilot"
256
286
  )
257
287
  );
@@ -436,84 +466,40 @@ const run = async (table_id, viewname, config, state, { res, req }) => {
436
466
  const getCompletionArguments = async (config) => {
437
467
  let tools = [];
438
468
  const sysPrompts = [];
439
- for (const tableCfg of config?.tables || []) {
440
- let properties = {};
469
+ let properties = {};
441
470
 
442
- const table = Table.findOne({ name: tableCfg.table_name });
471
+ const tableNames = (config?.tables || []).map((t) => t.table_name);
472
+ properties.table_name = {
473
+ type: "string",
474
+ enum: tableNames,
475
+ description: `Which table is this query from. Every query has to select rows from one table, even if it is based on joins from different tables`,
476
+ };
477
+ properties.sql_id_query = {
478
+ type: "string",
479
+ description: `An SQL query for this table's primary keys. This must select only the primary keys (even if the user wants a count), for example SELECT ${
480
+ tableNames[0][0]
481
+ }."${Table.findOne(tableNames[0]).pk_name}" from "${tableNames[0]}" ${
482
+ tableNames[0][0]
483
+ } JOIN ... where... Use this to join other tables in the database.`,
484
+ };
485
+ properties.is_count = {
486
+ type: "boolean",
487
+ description: `Is the only desired output a count? Make this true if the user wants a count of rows`,
488
+ };
443
489
 
444
- table.fields
445
- .filter((f) => !f.primary_key)
446
- .forEach((field) => {
447
- properties[field.name] = {
448
- description: field.label + " " + field.description || "",
449
- };
450
- if (field.type.name === "String") {
451
- properties[field.name].anyOf = [
452
- { type: "string", description: "Match this value exactly" },
453
- {
454
- type: "object",
455
- properties: {
456
- ilike: {
457
- type: "string",
458
- description: "Case insensitive substring match.",
459
- },
460
- in: {
461
- type: "array",
462
- description: "Match any one of these values",
463
- items: {
464
- type: "string",
465
- },
466
- },
467
- },
468
- },
469
- ];
470
- }
471
- if (field.type.name === "Integer" || field.type.name === "Float") {
472
- const basetype = field.type.name === "Integer" ? "integer" : "number";
473
- properties[field.name].anyOf = [
474
- { type: basetype, description: "Match this value exactly" },
475
- {
476
- type: "object",
477
- properties: {
478
- gt: {
479
- type: basetype,
480
- description: "Greater than this value",
481
- },
482
- lt: {
483
- type: basetype,
484
- description: "Less than this value",
485
- },
486
- in: {
487
- type: "array",
488
- description: "Match any one of these values",
489
- items: {
490
- type: basetype,
491
- },
492
- },
493
- },
494
- },
495
- ];
496
- } else Object.assign(fieldProperties(field), properties[field.name]);
497
- });
498
- properties.sql_id_query = {
499
- type: "string",
500
- description: `An SQL query for this table's primary keys (${table.pk_name}). This must select only the primary keys, for example SELECT ${table.name[0]}."${table.pk_name}" from "${table.name}" ${table.name[0]} JOIN ... where... Use this to join other tables in the database. If you use sql_id_query, use it alone, do not combine with other field queries. Any other fields you want to filter on must be accessed in the WHERE clause`,
501
- };
502
- tools.push({
503
- type: "function",
504
- function: {
505
- name: "Query" + table.name,
506
- description: `Query the ${table.name} table. ${
507
- table.description || ""
508
- }`,
509
- parameters: {
510
- type: "object",
511
- //required: ["action_javascript_code", "action_name"],
512
- properties,
513
- },
490
+ tools.push({
491
+ type: "function",
492
+ function: {
493
+ name: "TableQuery",
494
+ description: `Query a table and show the results to the user in a grid format`,
495
+ parameters: {
496
+ type: "object",
497
+ required: ["table_name", "sql_id_query", "is_count"],
498
+ properties,
514
499
  },
515
- });
516
- }
500
+ },
501
+ });
502
+
517
503
  for (const action of config?.actions || []) {
518
504
  let properties = {};
519
505
 
@@ -574,7 +560,14 @@ ${t.fields
574
560
  .join(",\n")}
575
561
  )`
576
562
  )
577
- .join(";\n\n");
563
+ .join(";\n\n") +
564
+ `
565
+
566
+ Use the TableQuery tool if the user asks to see, find or count or otherwise access rows from a table that matches what the user is looking for, or if
567
+ the user is asking for a summary or inference from such rows. The TableQuery query is parametrised by a SQL SELECT query which
568
+ selects primary key values from the specified table. You can join other tables or use complex logic in the WHERE clause, but you must
569
+ always return porimary key values from the specified table.
570
+ `;
578
571
  //console.log("sysprompt", systemPrompt);
579
572
 
580
573
  if (tools.length === 0) tools = undefined;
@@ -611,7 +604,24 @@ const interact = async (table_id, viewname, config, body, { req, res }) => {
611
604
  };
612
605
 
613
606
  const renderQueryInteraction = async (table, result, config, req) => {
614
- if (result.length === 0) return "No rows found";
607
+ if (typeof result === "number")
608
+ return wrapSegment(
609
+ wrapCard(
610
+ "Query " + table?.name || "",
611
+ //div("Query: ", code(JSON.stringify(query))),
612
+ `${result}`
613
+ ),
614
+ "Copilot"
615
+ );
616
+ if (result.length === 0)
617
+ return wrapSegment(
618
+ wrapCard(
619
+ "Query " + table?.name || "",
620
+ //div("Query: ", code(JSON.stringify(query))),
621
+ "No rows found"
622
+ ),
623
+ "Copilot"
624
+ );
615
625
 
616
626
  const tableCfg = config.tables.find((t) => t.table_name === table.name);
617
627
  let viewRes = "";
@@ -652,6 +662,7 @@ const process_interaction = async (
652
662
  ) => {
653
663
  const complArgs = await getCompletionArguments(config);
654
664
  complArgs.chat = run.context.interactions;
665
+ //complArgs.debugResult = true;
655
666
  //console.log(complArgs);
656
667
  console.log("complArgs", JSON.stringify(complArgs, null, 2));
657
668
 
@@ -660,12 +671,20 @@ const process_interaction = async (
660
671
  await addToContext(run, {
661
672
  interactions:
662
673
  typeof answer === "object" && answer.tool_calls
663
- ? [{ role: "assistant", tool_calls: answer.tool_calls }]
674
+ ? [
675
+ {
676
+ role: "assistant",
677
+ tool_calls: answer.tool_calls,
678
+ content: answer.content,
679
+ },
680
+ ]
664
681
  : [{ role: "assistant", content: answer }],
665
682
  });
666
683
  const responses = [];
667
684
 
668
685
  if (typeof answer === "object" && answer.tool_calls) {
686
+ if (answer.content)
687
+ responses.push(wrapSegment(md.render(answer.content), "Copilot"));
669
688
  //const actions = [];
670
689
  let hasResult = false;
671
690
  for (const tool_call of answer.tool_calls) {
@@ -724,51 +743,66 @@ const process_interaction = async (
724
743
  },
725
744
  ],
726
745
  });
727
- } else if (tool_call.function.name.startsWith("Query")) {
746
+ } else if (tool_call.function.name == "TableQuery") {
747
+ const query = JSON.parse(tool_call.function.arguments);
728
748
  const table = Table.findOne({
729
- name: tool_call.function.name.replace("Query", ""),
749
+ name: query.table_name,
730
750
  });
731
- const query = JSON.parse(tool_call.function.arguments);
732
- let result;
733
- if (query.sql_id_query) {
734
- const is_sqlite = db.isSQLite;
735
-
736
- const client = is_sqlite ? db : await db.getClient();
737
- await client.query(`BEGIN;`);
738
- if (!is_sqlite) {
739
- await client.query(
740
- `SET LOCAL search_path TO "${db.getTenantSchema()}";`
741
- );
742
- await client.query(
743
- `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;`
744
- );
745
- }
751
+ const tableCfg = config.tables.find((t) => t.table_name === table.name);
746
752
 
747
- const { rows } = await client.query(query.sql_id_query);
748
- await client.query(`ROLLBACK;`);
753
+ const is_sqlite = db.isSQLite;
749
754
 
750
- if (!is_sqlite) client.release(true);
751
- result = await table.getRows(
752
- { [table.pk_name]: { in: rows.map((r) => r[table.pk_name]) } },
753
- {
754
- forUser: req.user,
755
- forPublic: !req.user,
756
- }
755
+ const client = is_sqlite ? db : await db.getClient();
756
+ await client.query(`BEGIN;`);
757
+ if (!is_sqlite) {
758
+ await client.query(
759
+ `SET LOCAL search_path TO "${db.getTenantSchema()}";`
757
760
  );
758
- responses.push(
759
- wrapSegment(
760
- wrapCard(
761
- "Query " + tool_call.function.name.replace("Query", ""),
762
- pre(query.sql_id_query)
763
- ),
764
- "Copilot"
765
- )
761
+ await client.query(
762
+ `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;`
766
763
  );
767
- } else
768
- result = await table.getRows(query, {
764
+ }
765
+
766
+ const { rows } = await client.query(query.sql_id_query);
767
+ await client.query(`ROLLBACK;`);
768
+
769
+ if (!is_sqlite) client.release(true);
770
+ let result;
771
+ const id_query = {
772
+ [table.pk_name]: { in: rows.map((r) => r[table.pk_name]) },
773
+ };
774
+
775
+ if (query.is_count) {
776
+ const role = req.user?.role_id || 100;
777
+ if (role <= table.min_role_read) {
778
+ result = await table.countRows(id_query);
779
+ } else result = "Not authorized";
780
+ } else {
781
+ result = await table.getRows(id_query, {
769
782
  forUser: req.user,
770
783
  forPublic: !req.user,
771
784
  });
785
+ if (tableCfg.exclude_fields) {
786
+ const fields = tableCfg.exclude_fields
787
+ .split(",")
788
+ .map((s) => s.trim());
789
+ fields.forEach((f) => {
790
+ result.forEach((r) => {
791
+ delete r[f];
792
+ });
793
+ });
794
+ }
795
+ }
796
+ responses.push(
797
+ wrapSegment(
798
+ wrapCard(
799
+ "Query " + tool_call.function.name.replace("Query", ""),
800
+ pre(query.sql_id_query)
801
+ ),
802
+ "Copilot"
803
+ )
804
+ );
805
+
772
806
  await addToContext(run, {
773
807
  interactions: [
774
808
  {