@saltcorn/copilot 0.8.2 → 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(
@@ -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) =>
@@ -91,21 +157,9 @@ const requirementsList = async (req) => {
91
157
  return div(
92
158
  { class: "mt-2" },
93
159
  p(
160
+ { id: "reqs-generating-state" },
94
161
  i({ class: "fas fa-spinner fa-spin me-2" }),
95
162
  "Generating requirements, please wait..."
96
- ),
97
- script(
98
- domReady(`
99
- (function() {
100
- const poll = () => {
101
- view_post(${JSON.stringify(viewname)}, 'req_status', {}, (resp) => {
102
- if (resp && !resp.generating) location.reload();
103
- else setTimeout(poll, 3000);
104
- });
105
- };
106
- setTimeout(poll, 3000);
107
- })();
108
- `)
109
163
  )
110
164
  );
111
165
  }
@@ -116,27 +170,11 @@ const requirementsList = async (req) => {
116
170
  button(
117
171
  { class: "btn btn-primary", onclick: `copilotGenReqs()` },
118
172
  "Generate requirements"
119
- ),
120
- script(
121
- domReady(`
122
- window.copilotGenReqs = () => {
123
- document.getElementById('req-gen-area').innerHTML =
124
- '<p><i class="fas fa-spinner fa-spin me-2"></i>Generating requirements, please wait...</p>';
125
- view_post(${JSON.stringify(viewname)}, 'gen_reqs', {}, () => {});
126
- const poll = () => {
127
- view_post(${JSON.stringify(viewname)}, 'req_status', {}, (resp) => {
128
- if (resp && !resp.generating) location.reload();
129
- else setTimeout(poll, 3000);
130
- });
131
- };
132
- setTimeout(poll, 3000);
133
- };
134
- `)
135
173
  )
136
174
  );
137
175
  };
138
176
 
139
- const doGenReqs = async (spec, userId) => {
177
+ const doGenReqs = async (userId) => {
140
178
  const generatingMd = await MetaData.create({
141
179
  type: "CopilotConstructMgr",
142
180
  name: "generating_requirements",
@@ -144,22 +182,16 @@ const doGenReqs = async (spec, userId) => {
144
182
  user_id: userId,
145
183
  });
146
184
  try {
185
+ const generator = await PromptGenerator.createInstance();
186
+ if (!generator.spec) throw new Error("Specification not found");
147
187
  const answer = await getState().functions.llm_generate.run(
148
- `Generate the requirements for this application:
149
-
150
- Description: ${spec.body.description}
151
- Audience: ${spec.body.audience}
152
- Core features: ${spec.body.core_features}
153
- Out of scope: ${spec.body.out_of_scope}
154
- Visual style: ${spec.body.visual_style}
155
-
156
- Now use the make_requirements tool to list the requirements for this software application
157
- `,
188
+ generator.requirementsPlanPrompt(),
158
189
  {
159
190
  tools: [requirements_tool],
160
191
  ...tool_choice("make_requirements"),
161
192
  systemPrompt:
162
- "You are a project manager. The user wants to build an application, and you must analyse their application description",
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.",
163
195
  }
164
196
  );
165
197
  const tc = answer.getToolCalls()[0];
@@ -172,18 +204,17 @@ Now use the make_requirements tool to list the requirements for this software ap
172
204
  });
173
205
  } finally {
174
206
  await generatingMd.delete();
207
+ try {
208
+ getState().emitDynamicUpdate(db.getTenantSchema(), {
209
+ eval_js:
210
+ "if(typeof copilotRefreshReqs==='function')copilotRefreshReqs();",
211
+ });
212
+ } catch (_) {}
175
213
  }
176
214
  };
177
215
 
178
216
  const gen_reqs = async (table_id, viewname, config, body, { req, res }) => {
179
- const spec = await MetaData.findOne({
180
- type: "CopilotConstructMgr",
181
- name: "spec",
182
- });
183
- if (!spec) throw new Error("Specification not found");
184
- doGenReqs(spec, req.user?.id).catch((e) =>
185
- console.error("gen_reqs error", e)
186
- );
217
+ doGenReqs(req.user?.id).catch((e) => console.error("gen_reqs error", e));
187
218
  return { json: { success: true } };
188
219
  };
189
220
 
@@ -202,7 +233,12 @@ const del_req = async (table_id, viewname, config, body, { req, res }) => {
202
233
 
203
234
  if (!r) throw new Error("Requirement not found");
204
235
  await r.delete();
205
- return { json: { reload_page: true } };
236
+ return {
237
+ json: {
238
+ eval_js:
239
+ "if(typeof copilotRefreshReqs==='function')copilotRefreshReqs();",
240
+ },
241
+ };
206
242
  };
207
243
  const del_all_reqs = async (table_id, viewname, config, body, { req, res }) => {
208
244
  const rs = await MetaData.find({
@@ -210,9 +246,32 @@ const del_all_reqs = async (table_id, viewname, config, body, { req, res }) => {
210
246
  name: "requirement",
211
247
  });
212
248
  for (const r of rs) await r.delete();
213
- return { json: { reload_page: true } };
249
+ return {
250
+ json: {
251
+ eval_js:
252
+ "if(typeof copilotRefreshReqs==='function')copilotRefreshReqs();",
253
+ },
254
+ };
255
+ };
256
+
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 } };
214
267
  };
215
268
 
216
- const req_routes = { gen_reqs, req_status, del_req, del_all_reqs };
269
+ const req_routes = {
270
+ gen_reqs,
271
+ req_status,
272
+ del_req,
273
+ del_all_reqs,
274
+ req_list_html,
275
+ };
217
276
 
218
- 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
+ };