@saltcorn/copilot 0.2.0 → 0.3.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,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
@@ -11,7 +11,9 @@ module.exports = {
11
11
  sc_plugin_api_version: 1,
12
12
  //configuration_workflow,
13
13
  dependencies: ["@saltcorn/large-language-model"],
14
- viewtemplates: [require("./action-builder"), require("./database-designer")],
14
+ viewtemplates: features.workflows
15
+ ? [require("./chat-copilot")]
16
+ : [require("./action-builder"), require("./database-designer")],
15
17
  functions: features.workflows
16
18
  ? { copilot_generate_workflow: require("./workflow-gen") }
17
19
  : {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/copilot",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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
+ }