@saltcorn/copilot 0.8.1 → 0.8.3

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.
@@ -37,6 +37,58 @@ const { getState } = require("@saltcorn/data/db/state");
37
37
  const renderLayout = require("@saltcorn/markup/layout");
38
38
  const { viewname, tool_choice } = require("./common");
39
39
  const { requirements_tool } = require("./tools");
40
+ const { PromptGenerator } = require("./prompt-generator");
41
+
42
+ const requirementsStaticScript = `<script>
43
+ const _reqsVn = ${JSON.stringify(viewname)};
44
+
45
+ window.copilotRefreshReqs = () => {
46
+ view_post(_reqsVn, 'req_list_html', {}, (r) => {
47
+ const a = document.getElementById('req-list-area');
48
+ if (r && r.html && a) {
49
+ a.innerHTML = r.html;
50
+ if (typeof copilotInitReqsState === 'function') copilotInitReqsState();
51
+ }
52
+ });
53
+ };
54
+
55
+ window.copilotGenReqs = function() {
56
+ const area = document.getElementById('req-gen-area');
57
+ if (area) area.innerHTML =
58
+ '<p><i class="fas fa-spinner fa-spin me-2"></i>Generating requirements, please wait...</p>';
59
+ view_post(_reqsVn, 'gen_reqs', {}, () => {});
60
+ if (!window.dynamic_updates_cfg?.enabled) {
61
+ const poll = () => {
62
+ view_post(_reqsVn, 'req_status', {}, (resp) => {
63
+ if (resp && !resp.generating) {
64
+ if (typeof copilotRefreshReqs === 'function') copilotRefreshReqs();
65
+ } else setTimeout(poll, 3000);
66
+ });
67
+ };
68
+ setTimeout(poll, 3000);
69
+ }
70
+ };
71
+
72
+ function copilotInitReqsState() {
73
+ const isGenerating = !!document.getElementById('reqs-generating-state');
74
+ if (isGenerating) {
75
+ const poll = () => {
76
+ view_post(_reqsVn, 'req_status', {}, (resp) => {
77
+ if (resp && !resp.generating) {
78
+ if (typeof copilotRefreshReqs === 'function') copilotRefreshReqs();
79
+ } else setTimeout(poll, 3000);
80
+ });
81
+ };
82
+ if (!window.dynamic_updates_cfg?.enabled) setTimeout(poll, 3000);
83
+ }
84
+ }
85
+ window.copilotInitReqsState = copilotInitReqsState;
86
+
87
+ (function () {
88
+ if (document.readyState !== 'loading') copilotInitReqsState();
89
+ else document.addEventListener('DOMContentLoaded', copilotInitReqsState);
90
+ })();
91
+ </script>`;
40
92
 
41
93
  const requirementsList = async (req) => {
42
94
  const rs = await MetaData.find(
@@ -44,7 +96,7 @@ const requirementsList = async (req) => {
44
96
  type: "CopilotConstructMgr",
45
97
  name: "requirement",
46
98
  },
47
- { orderBy: "written_at" },
99
+ { orderBy: "written_at" }
48
100
  );
49
101
  const starFieldview = getState().types.Integer.fieldviews.show_star_rating;
50
102
 
@@ -53,7 +105,21 @@ const requirementsList = async (req) => {
53
105
  { class: "mt-2" },
54
106
  mkTable(
55
107
  [
56
- { label: "Requirement", key: (m) => m.body.requirement },
108
+ {
109
+ label: "Requirement",
110
+ key: (m) =>
111
+ m.body.requirement +
112
+ (m.body.source === "feedback"
113
+ ? span(
114
+ {
115
+ class: "badge bg-warning text-dark ms-2 fw-normal",
116
+ title: `From feedback: ${m.body.feedback_title || ""}`,
117
+ },
118
+ i({ class: "fas fa-comment-alt me-1" }),
119
+ "feedback"
120
+ )
121
+ : ""),
122
+ },
57
123
  {
58
124
  label: "Priority",
59
125
  key: (m) =>
@@ -67,70 +133,97 @@ const requirementsList = async (req) => {
67
133
  class: "btn btn-outline-danger btn-sm",
68
134
  onclick: `view_post("${viewname}", "del_req", {id:${r.id}})`,
69
135
  },
70
- i({ class: "fas fa-trash-alt" }),
136
+ i({ class: "fas fa-trash-alt" })
71
137
  ),
72
138
  },
73
139
  ],
74
- rs,
140
+ rs
75
141
  ),
76
142
  button(
77
143
  {
78
144
  class: "btn btn-outline-danger mb-4",
79
145
  onclick: `view_post("${viewname}", "del_all_reqs")`,
80
146
  },
81
- "Delete all",
82
- ),
147
+ "Delete all"
148
+ )
83
149
  );
84
- } else {
150
+ }
151
+
152
+ const generating = await MetaData.findOne({
153
+ type: "CopilotConstructMgr",
154
+ name: "generating_requirements",
155
+ });
156
+ if (generating) {
85
157
  return div(
86
158
  { class: "mt-2" },
87
- p("No requirements found"),
88
- button(
89
- {
90
- class: "btn btn-primary",
91
- onclick: `press_store_button(this);view_post("${viewname}", "gen_reqs")`,
92
- },
93
- "Generate requirements",
94
- ),
159
+ p(
160
+ { id: "reqs-generating-state" },
161
+ i({ class: "fas fa-spinner fa-spin me-2" }),
162
+ "Generating requirements, please wait..."
163
+ )
95
164
  );
96
165
  }
166
+
167
+ return div(
168
+ { class: "mt-2", id: "req-gen-area" },
169
+ p("No requirements found"),
170
+ button(
171
+ { class: "btn btn-primary", onclick: `copilotGenReqs()` },
172
+ "Generate requirements"
173
+ )
174
+ );
97
175
  };
98
176
 
99
- const gen_reqs = async (table_id, viewname, config, body, { req, res }) => {
100
- const spec = await MetaData.findOne({
177
+ const doGenReqs = async (userId) => {
178
+ const generatingMd = await MetaData.create({
101
179
  type: "CopilotConstructMgr",
102
- name: "spec",
180
+ name: "generating_requirements",
181
+ body: {},
182
+ user_id: userId,
103
183
  });
104
- if (!spec) throw new Error("Specification not found");
105
- const answer = await getState().functions.llm_generate.run(
106
- `Generate the requirements for this application:
107
-
108
- Description: ${spec.body.description}
109
- Audience: ${spec.body.audience}
110
- Core features: ${spec.body.core_features}
111
- Out of scope: ${spec.body.out_of_scope}
112
- Visual style: ${spec.body.visual_style}
113
-
114
- Now use the make_requirements tool to list the requirements for this software application
115
- `,
116
- {
117
- tools: [requirements_tool],
118
- ...tool_choice("make_requirements"),
119
- systemPrompt:
120
- "You are a project manager. The user wants to build an application, and you must analyse their application description",
121
- },
122
- );
184
+ try {
185
+ const generator = await PromptGenerator.createInstance();
186
+ if (!generator.spec) throw new Error("Specification not found");
187
+ const answer = await getState().functions.llm_generate.run(
188
+ generator.requirementsPlanPrompt(),
189
+ {
190
+ tools: [requirements_tool],
191
+ ...tool_choice("make_requirements"),
192
+ systemPrompt:
193
+ "You are a project manager extracting requirements from a written specification.\n" +
194
+ "Only include what is explicitly stated do not infer or add plausible extras.",
195
+ }
196
+ );
197
+ const tc = answer.getToolCalls()[0];
198
+ for (const reqm of tc.input.requirements)
199
+ await MetaData.create({
200
+ type: "CopilotConstructMgr",
201
+ name: "requirement",
202
+ body: reqm,
203
+ user_id: userId,
204
+ });
205
+ } finally {
206
+ await generatingMd.delete();
207
+ try {
208
+ getState().emitDynamicUpdate(db.getTenantSchema(), {
209
+ eval_js:
210
+ "if(typeof copilotRefreshReqs==='function')copilotRefreshReqs();",
211
+ });
212
+ } catch (_) {}
213
+ }
214
+ };
123
215
 
124
- const tc = answer.getToolCalls()[0];
216
+ const gen_reqs = async (table_id, viewname, config, body, { req, res }) => {
217
+ doGenReqs(req.user?.id).catch((e) => console.error("gen_reqs error", e));
218
+ return { json: { success: true } };
219
+ };
125
220
 
126
- for (const reqm of tc.input.requirements)
127
- await MetaData.create({
128
- type: "CopilotConstructMgr",
129
- name: "requirement",
130
- body: reqm,
131
- user_id: req.user?.id,
132
- });
133
- return { json: { reload_page: true } };
221
+ const req_status = async (table_id, viewname, config, body, { req, res }) => {
222
+ const generating = await MetaData.findOne({
223
+ type: "CopilotConstructMgr",
224
+ name: "generating_requirements",
225
+ });
226
+ return { json: { generating: !!generating } };
134
227
  };
135
228
 
136
229
  const del_req = async (table_id, viewname, config, body, { req, res }) => {
@@ -140,7 +233,12 @@ const del_req = async (table_id, viewname, config, body, { req, res }) => {
140
233
 
141
234
  if (!r) throw new Error("Requirement not found");
142
235
  await r.delete();
143
- return { json: { reload_page: true } };
236
+ return {
237
+ json: {
238
+ eval_js:
239
+ "if(typeof copilotRefreshReqs==='function')copilotRefreshReqs();",
240
+ },
241
+ };
144
242
  };
145
243
  const del_all_reqs = async (table_id, viewname, config, body, { req, res }) => {
146
244
  const rs = await MetaData.find({
@@ -148,9 +246,32 @@ const del_all_reqs = async (table_id, viewname, config, body, { req, res }) => {
148
246
  name: "requirement",
149
247
  });
150
248
  for (const r of rs) await r.delete();
151
- return { json: { reload_page: true } };
249
+ return {
250
+ json: {
251
+ eval_js:
252
+ "if(typeof copilotRefreshReqs==='function')copilotRefreshReqs();",
253
+ },
254
+ };
152
255
  };
153
256
 
154
- const req_routes = { gen_reqs, del_req, del_all_reqs };
257
+ /** Route: returns the rendered requirements list HTML for AJAX refresh. */
258
+ const req_list_html = async (
259
+ table_id,
260
+ viewname,
261
+ config,
262
+ body,
263
+ { req, res }
264
+ ) => {
265
+ const html = await requirementsList(req);
266
+ return { json: { html } };
267
+ };
268
+
269
+ const req_routes = {
270
+ gen_reqs,
271
+ req_status,
272
+ del_req,
273
+ del_all_reqs,
274
+ req_list_html,
275
+ };
155
276
 
156
- module.exports = { requirementsList, req_routes };
277
+ module.exports = { requirementsList, requirementsStaticScript, req_routes };
@@ -0,0 +1,350 @@
1
+ const MetaData = require("@saltcorn/data/models/metadata");
2
+ const {
3
+ div,
4
+ script,
5
+ domReady,
6
+ button,
7
+ i,
8
+ p,
9
+ label,
10
+ textarea,
11
+ form,
12
+ h5,
13
+ small,
14
+ } = require("@saltcorn/markup/tags");
15
+ const { getState } = require("@saltcorn/data/db/state");
16
+ const db = require("@saltcorn/data/db");
17
+ const { viewname, tool_choice } = require("./common");
18
+ const { PromptGenerator } = require("./prompt-generator");
19
+
20
+ const questions_tool = {
21
+ type: "function",
22
+ function: {
23
+ name: "ask_questions",
24
+ description: "Ask the user clarifying questions about the application",
25
+ parameters: {
26
+ type: "object",
27
+ required: ["questions"],
28
+ additionalProperties: false,
29
+ properties: {
30
+ questions: {
31
+ type: "array",
32
+ maxItems: 10,
33
+ description: "List of clarifying questions, maximum 10",
34
+ items: { type: "string" },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ const spinnerHtml =
42
+ "<p>" +
43
+ i({ class: "fas fa-spinner fa-spin me-2" }) +
44
+ "Generating questions, please wait...</p>";
45
+
46
+ // Pure HTML for each state — no embedded scripts
47
+ const researchPanelHtml = async (req) => {
48
+ const generating = await MetaData.findOne({
49
+ type: "CopilotConstructMgr",
50
+ name: "generating_research",
51
+ });
52
+ if (generating) return spinnerHtml;
53
+
54
+ const questions_md = await MetaData.findOne({
55
+ type: "CopilotConstructMgr",
56
+ name: "research_questions",
57
+ });
58
+
59
+ if (questions_md) {
60
+ const answers_md = await MetaData.findOne({
61
+ type: "CopilotConstructMgr",
62
+ name: "research_answers",
63
+ });
64
+ const questions = questions_md.body.questions || [];
65
+ const saved = answers_md?.body || {};
66
+
67
+ const fieldRows = questions
68
+ .map((q, idx) => {
69
+ const fname = `question${idx + 1}`;
70
+ return div(
71
+ { class: "mb-3" },
72
+ label({ class: "form-label fw-semibold", for: fname }, q),
73
+ textarea(
74
+ { class: "form-control", id: fname, name: fname, rows: 3 },
75
+ saved[fname] || ""
76
+ )
77
+ );
78
+ })
79
+ .join("");
80
+
81
+ return (
82
+ h5({ class: "mb-2" }, "Specification questions") +
83
+ small(
84
+ { class: "text-muted d-block mb-3" },
85
+ "Answer these questions to help generate more accurate requirements and tasks. " +
86
+ "You can skip any question."
87
+ ) +
88
+ form(
89
+ { id: "research-form" },
90
+ fieldRows,
91
+ button(
92
+ {
93
+ type: "button",
94
+ class: "btn btn-primary me-2",
95
+ onclick: "copilotSubmitResearch()",
96
+ },
97
+ "Save answers"
98
+ ),
99
+ button(
100
+ {
101
+ type: "button",
102
+ class: "btn btn-outline-secondary",
103
+ onclick: "copilotRegenResearch()",
104
+ },
105
+ "Regenerate questions"
106
+ ),
107
+ button(
108
+ {
109
+ type: "button",
110
+ class: "btn btn-outline-danger ms-2",
111
+ onclick: "copilotDelAllResearch()",
112
+ },
113
+ "Delete all"
114
+ )
115
+ )
116
+ );
117
+ }
118
+
119
+ return (
120
+ p("Generate clarifying questions based on your specification.") +
121
+ button(
122
+ { class: "btn btn-primary", onclick: "copilotGenResearch()" },
123
+ "Generate questions"
124
+ )
125
+ );
126
+ };
127
+
128
+ // Outer wrapper rendered once on page load — includes the single script block
129
+ const researchPanel = async (req) => {
130
+ const generating = await MetaData.findOne({
131
+ type: "CopilotConstructMgr",
132
+ name: "generating_research",
133
+ });
134
+ const innerHtml = await researchPanelHtml(req);
135
+
136
+ return div(
137
+ { class: "mt-2" },
138
+ div({ id: "research-panel" }, innerHtml),
139
+ script(
140
+ domReady(`
141
+ const _vn = ${JSON.stringify(viewname)};
142
+ function researchStartPoll() {
143
+ const poll = () => {
144
+ view_post(_vn, 'research_status', {}, (resp) => {
145
+ if (resp && !resp.generating) {
146
+ view_post(_vn, 'research_html', {}, (r) => {
147
+ if (r && r.html) document.getElementById('research-panel').innerHTML = r.html;
148
+ });
149
+ } else setTimeout(poll, 3000);
150
+ });
151
+ };
152
+ setTimeout(poll, 3000);
153
+ }
154
+ window.copilotRefreshResearch = () => {
155
+ view_post(_vn, 'research_html', {}, (r) => {
156
+ const a = document.getElementById('research-panel');
157
+ if (r && r.html && a) a.innerHTML = r.html;
158
+ });
159
+ };
160
+ window.copilotGenResearch = window.copilotRegenResearch = () => {
161
+ document.getElementById('research-panel').innerHTML = ${JSON.stringify(
162
+ spinnerHtml
163
+ )};
164
+ view_post(_vn, 'gen_research', {}, () => {});
165
+ if (!window.dynamic_updates_cfg?.enabled) researchStartPoll();
166
+ };
167
+ window.copilotDelAllResearch = () => {
168
+ view_post(_vn, 'del_all_research', {}, () => {
169
+ view_post(_vn, 'research_html', {}, (r) => {
170
+ if (r && r.html) document.getElementById('research-panel').innerHTML = r.html;
171
+ });
172
+ });
173
+ };
174
+ window.copilotSubmitResearch = () => {
175
+ const data = {};
176
+ const f = document.getElementById('research-form');
177
+ for (const el of f.querySelectorAll('textarea')) data[el.name] = el.value;
178
+ view_post(_vn, 'submit_research', data);
179
+ };
180
+ ${
181
+ generating
182
+ ? "if (!window.dynamic_updates_cfg?.enabled) researchStartPoll();"
183
+ : ""
184
+ }
185
+ `)
186
+ )
187
+ );
188
+ };
189
+
190
+ const doGenResearch = async (userId) => {
191
+ const generatingMd = await MetaData.create({
192
+ type: "CopilotConstructMgr",
193
+ name: "generating_research",
194
+ body: {},
195
+ user_id: userId,
196
+ });
197
+ try {
198
+ const generator = await PromptGenerator.createInstance();
199
+ if (!generator.spec) throw new Error("Specification not found");
200
+ const answer = await getState().functions.llm_generate.run(
201
+ generator.researchQuestionsPrompt(),
202
+ {
203
+ tools: [questions_tool],
204
+ ...tool_choice("ask_questions"),
205
+ systemPrompt:
206
+ "You are a requirements analyst. Ask only the clarifying questions that are\n" +
207
+ "genuinely needed — fewer is better. 10 is a hard maximum, not a target.\n" +
208
+ "Each question must be short, clear, and easy to understand —\n" +
209
+ "avoid technical jargon where possible and keep sentences simple.",
210
+ }
211
+ );
212
+ const tc = answer.getToolCalls()[0];
213
+ const existing = await MetaData.findOne({
214
+ type: "CopilotConstructMgr",
215
+ name: "research_questions",
216
+ });
217
+ if (existing) {
218
+ await existing.update({ body: { questions: tc.input.questions } });
219
+ } else {
220
+ await MetaData.create({
221
+ type: "CopilotConstructMgr",
222
+ name: "research_questions",
223
+ body: { questions: tc.input.questions },
224
+ user_id: userId,
225
+ });
226
+ }
227
+ const oldAnswers = await MetaData.findOne({
228
+ type: "CopilotConstructMgr",
229
+ name: "research_answers",
230
+ });
231
+ if (oldAnswers) await oldAnswers.delete();
232
+ } finally {
233
+ await generatingMd.delete();
234
+ try {
235
+ getState().emitDynamicUpdate(db.getTenantSchema(), {
236
+ eval_js:
237
+ "if(typeof copilotRefreshResearch==='function')copilotRefreshResearch();",
238
+ });
239
+ } catch (_) {}
240
+ }
241
+ };
242
+
243
+ const gen_research = async (table_id, viewname, config, body, { req, res }) => {
244
+ doGenResearch(req.user?.id).catch((e) =>
245
+ console.error("gen_research error", e)
246
+ );
247
+ return { json: { success: true } };
248
+ };
249
+
250
+ const research_status = async (
251
+ table_id,
252
+ viewname,
253
+ config,
254
+ body,
255
+ { req, res }
256
+ ) => {
257
+ const generating = await MetaData.findOne({
258
+ type: "CopilotConstructMgr",
259
+ name: "generating_research",
260
+ });
261
+ return { json: { generating: !!generating } };
262
+ };
263
+
264
+ const research_html = async (
265
+ table_id,
266
+ viewname,
267
+ config,
268
+ body,
269
+ { req, res }
270
+ ) => {
271
+ const html = await researchPanelHtml(req);
272
+ return { json: { html } };
273
+ };
274
+
275
+ const submit_research = async (
276
+ table_id,
277
+ viewname,
278
+ config,
279
+ body,
280
+ { req, res }
281
+ ) => {
282
+ const { _csrf, ...answers } = body;
283
+ const existing = await MetaData.findOne({
284
+ type: "CopilotConstructMgr",
285
+ name: "research_answers",
286
+ });
287
+ if (existing) {
288
+ await existing.update({ body: answers });
289
+ } else {
290
+ await MetaData.create({
291
+ type: "CopilotConstructMgr",
292
+ name: "research_answers",
293
+ body: answers,
294
+ user_id: req.user?.id,
295
+ });
296
+ }
297
+ return { json: { success: true, notify_success: "Answers saved" } };
298
+ };
299
+
300
+ const del_all_research = async (
301
+ table_id,
302
+ viewname,
303
+ config,
304
+ body,
305
+ { req, res }
306
+ ) => {
307
+ for (const name of ["research_questions", "research_answers"]) {
308
+ const md = await MetaData.findOne({ type: "CopilotConstructMgr", name });
309
+ if (md) await md.delete();
310
+ }
311
+ return { json: { success: true } };
312
+ };
313
+
314
+ const getResearchAnswersText = async () => {
315
+ const questions_md = await MetaData.findOne({
316
+ type: "CopilotConstructMgr",
317
+ name: "research_questions",
318
+ });
319
+ const answers_md = await MetaData.findOne({
320
+ type: "CopilotConstructMgr",
321
+ name: "research_answers",
322
+ });
323
+ if (!questions_md || !answers_md) return null;
324
+ const questions = questions_md.body.questions || [];
325
+ const answers = answers_md.body || {};
326
+ const pairs = questions
327
+ .map((q, idx) => {
328
+ const a = answers[`question${idx + 1}`];
329
+ if (!a || !a.trim()) return null;
330
+ return `Question: ${q}\nAnswer: ${a.trim()}`;
331
+ })
332
+ .filter(Boolean);
333
+ if (!pairs.length) return null;
334
+ return pairs.join("\n\n");
335
+ };
336
+
337
+ const research_routes = {
338
+ gen_research,
339
+ research_status,
340
+ research_html,
341
+ submit_research,
342
+ del_all_research,
343
+ };
344
+
345
+ module.exports = {
346
+ researchPanel,
347
+ research_routes,
348
+ getResearchAnswersText,
349
+ questions_tool,
350
+ };