@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.
@@ -1,112 +1,1339 @@
1
- const Field = require("@saltcorn/data/models/field");
2
- const Table = require("@saltcorn/data/models/table");
3
- const Form = require("@saltcorn/data/models/form");
1
+ const feedbackAction = require("./feedback-action.js");
4
2
  const MetaData = require("@saltcorn/data/models/metadata");
5
- const View = require("@saltcorn/data/models/view");
6
- const Trigger = require("@saltcorn/data/models/trigger");
7
- const { findType } = require("@saltcorn/data/models/discovery");
8
3
  const { save_menu_items } = require("@saltcorn/data/models/config");
9
- const db = require("@saltcorn/data/db");
10
- const WorkflowRun = require("@saltcorn/data/models/workflow_run");
11
- const {
12
- localeDateTime,
13
- renderForm,
14
- mkTable,
15
- post_delete_btn,
16
- } = require("@saltcorn/markup");
4
+ const { mkTable } = require("@saltcorn/markup");
17
5
  const {
18
6
  div,
19
7
  script,
20
8
  domReady,
21
- pre,
22
- code,
23
- input,
24
- h4,
25
- style,
26
9
  h5,
27
10
  button,
28
- text_attr,
29
11
  i,
30
12
  p,
31
- span,
32
- small,
33
- form,
13
+ a,
14
+ hr,
15
+ input,
16
+ label,
34
17
  textarea,
18
+ small,
19
+ span,
35
20
  } = require("@saltcorn/markup/tags");
36
- const { getState } = require("@saltcorn/data/db/state");
37
- const renderLayout = require("@saltcorn/markup/layout");
21
+ const { getState, features } = require("@saltcorn/data/db/state");
38
22
  const { viewname } = require("./common");
23
+ const { questions_tool } = require("./research");
24
+ const { PromptGenerator } = require("./prompt-generator");
39
25
 
40
- const feedbackList = async (req) => {
41
- const errs = await MetaData.find({
26
+ /**
27
+ * Returns the Bootstrap modal HTML that prompts the user to analyse or skip.
28
+ */
29
+ const feedbackClarifyModal = () =>
30
+ `<div class="modal fade" id="fb-clarify-modal" tabindex="-1" aria-hidden="true">
31
+ <div class="modal-dialog modal-dialog-centered">
32
+ <div class="modal-content">
33
+ <div class="modal-header border-0 pb-0">
34
+ <h5 class="modal-title fw-semibold">Analyse feedback?</h5>
35
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
36
+ </div>
37
+ <div class="modal-body pt-2">
38
+ <p class="text-muted small mb-0">Before saving, I can analyse your feedback and ask a few short questions to help clarify the requirements. Answering them produces more accurate tasks — but you can also save right away.</p>
39
+ </div>
40
+ <div class="modal-footer">
41
+ <button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" onclick="fbSubmit()">Save without questions</button>
42
+ <button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="fbAnalyseFeedback()">Analyse</button>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ </div>`;
47
+
48
+ const scopeLabel = (scope, phases) => {
49
+ if (!scope || scope === "overall") return "Overall";
50
+ const m = scope.match(/^phase_(\d+)$/);
51
+ if (!m) return scope;
52
+ const idx = parseInt(m[1]);
53
+ const ph = phases[idx];
54
+ return ph ? `Phase ${idx + 1}: ${ph.name}` : `Phase ${idx + 1}`;
55
+ };
56
+
57
+ /** Inner content of #feedback-views-area — data only, no JS. Swapped in on ajax refresh. */
58
+ const feedbackViewsContent = async () => {
59
+ const safeViewName = JSON.stringify(viewname);
60
+ const phasesMd = await MetaData.findOne({
42
61
  type: "CopilotConstructMgr",
43
- name: "feedback",
62
+ name: "phases",
63
+ });
64
+ const phases = phasesMd?.body?.phases || [];
65
+
66
+ const pendingMds = await MetaData.find(
67
+ { type: "CopilotConstructMgr", name: "feedback_pending" },
68
+ { orderBy: "id" }
69
+ );
70
+
71
+ const approvingIds = new Set(
72
+ (
73
+ await Promise.all(
74
+ pendingMds.map(async (r) => {
75
+ const md = await MetaData.findOne({
76
+ type: "CopilotConstructMgr",
77
+ name: `approving_feedback_${r.id}`,
78
+ });
79
+ return md ? r.id : null;
80
+ })
81
+ )
82
+ ).filter(Boolean)
83
+ );
84
+
85
+ const addButton = features.view_route_modal
86
+ ? button(
87
+ {
88
+ class: "btn btn-outline-primary btn-sm mt-1",
89
+ title: "Submit feedback",
90
+ onclick: `ajax_modal('/view/${encodeURIComponent(
91
+ viewname
92
+ )}/get_feedback_form', {method:'POST'})`,
93
+ },
94
+ i({ class: "fas fa-plus me-1" }),
95
+ "Add feedback"
96
+ )
97
+ : small(
98
+ { class: "text-muted mt-1 d-block" },
99
+ i({ class: "fas fa-info-circle me-1" }),
100
+ "Submitting feedback requires a newer version of Saltcorn."
101
+ );
102
+
103
+ let pendingSection;
104
+ if (!pendingMds.length) {
105
+ pendingSection =
106
+ h5({ class: "mb-2" }, "Pending feedback") +
107
+ p({ class: "text-muted mt-2" }, "No pending feedback submissions.") +
108
+ addButton;
109
+ } else {
110
+ const tableHtml = mkTable(
111
+ [
112
+ { label: "Scope", key: (r) => scopeLabel(r.body.scope, phases) },
113
+ { label: "Title", key: (r) => r.body.title },
114
+ { label: "Description", key: (r) => r.body.description || "" },
115
+ { label: "Status", key: (r) => r.body.status || "" },
116
+ {
117
+ label: "Actions",
118
+ key: (r) =>
119
+ a(
120
+ {
121
+ href: "#",
122
+ class: "btn btn-outline-primary btn-sm me-1",
123
+ onclick: `copilotOpenFeedbackEdit(${r.id});return false;`,
124
+ },
125
+ "Edit"
126
+ ) +
127
+ (approvingIds.has(r.id)
128
+ ? button(
129
+ {
130
+ class: "btn btn-success btn-sm me-1",
131
+ disabled: true,
132
+ "data-approving-id": r.id,
133
+ },
134
+ i({ class: "fas fa-spinner fa-spin" })
135
+ )
136
+ : button(
137
+ {
138
+ class: "btn btn-success btn-sm me-1",
139
+ id: `approve-btn-${r.id}`,
140
+ onclick: `copilotApprove(${r.id})`,
141
+ },
142
+ "Approve"
143
+ )) +
144
+ button(
145
+ {
146
+ class: "btn btn-outline-danger btn-sm",
147
+ onclick: `copilotDeleteFeedback(${r.id})`,
148
+ },
149
+ i({ class: "fas fa-trash-alt" })
150
+ ),
151
+ },
152
+ ],
153
+ pendingMds
154
+ );
155
+ pendingSection =
156
+ h5({ class: "mb-2" }, "Pending feedback") + tableHtml + addButton;
157
+ }
158
+
159
+ const processed = await MetaData.find(
160
+ { type: "CopilotConstructMgr", name: "feedback" },
161
+ { orderBy: "written_at" }
162
+ );
163
+
164
+ const allTasks = await MetaData.find({
165
+ type: "CopilotConstructMgr",
166
+ name: "task",
44
167
  });
45
- if (errs.length) {
46
- return div(
47
- { class: "mt-2" },
48
- mkTable(
49
- [
168
+ const feedbackTasks = allTasks.filter((t) => t.body?.source === "feedback");
169
+ const tasksByTitle = {};
170
+ for (const t of feedbackTasks) {
171
+ const key = t.body.feedback_title || "";
172
+ if (!tasksByTitle[key]) tasksByTitle[key] = [];
173
+ tasksByTitle[key].push(t);
174
+ }
175
+
176
+ const taskBadge = (t) => {
177
+ const status = t.body.status;
178
+ if (!status || status === "Pending")
179
+ return button(
180
+ {
181
+ class: "btn btn-outline-primary btn-sm",
182
+ id: `fb-task-run-btn-${t.id}`,
183
+ onclick: `copilotRunFeedbackTask(${t.id})`,
184
+ title: t.body.name,
185
+ },
186
+ i({ class: "fas fa-play me-1" }),
187
+ span({ class: "text-truncate d-inline-block", style: "max-width:120px;vertical-align:middle" }, t.body.name)
188
+ );
189
+ if (status === "Running")
190
+ return span(
191
+ { class: "badge bg-warning text-dark", title: t.body.name },
192
+ i({ class: "fas fa-spinner fa-spin me-1" }),
193
+ "Running"
194
+ );
195
+ if (status === "Done")
196
+ return span(
197
+ { class: "badge bg-success", title: t.body.name },
198
+ i({ class: "fas fa-check me-1" }),
199
+ "Done"
200
+ );
201
+ return span(
202
+ { class: "badge bg-danger", title: t.body.name },
203
+ status
204
+ );
205
+ };
206
+
207
+ const processedSection = div(
208
+ { class: "mt-4" },
209
+ h5("Approved feedback"),
210
+ processed.length
211
+ ? div(
212
+ mkTable(
213
+ [
214
+ { label: "Scope", key: (m) => scopeLabel(m.body.scope, phases) },
215
+ { label: "Title", key: (m) => m.body.title },
216
+ { label: "Description", key: (m) => m.body.description },
217
+ {
218
+ label: "Tasks",
219
+ key: (m) => {
220
+ const tasks = tasksByTitle[m.body.title] || [];
221
+ if (!tasks.length) return span({ class: "text-muted small" }, "—");
222
+ return div(
223
+ { class: "d-flex flex-column gap-1" },
224
+ ...tasks.map(taskBadge)
225
+ );
226
+ },
227
+ },
228
+ {
229
+ label: "",
230
+ key: (r) =>
231
+ button(
232
+ {
233
+ class: "btn btn-outline-secondary btn-sm me-1",
234
+ onclick: `copilotShowProcessedFeedback(${r.id})`,
235
+ },
236
+ i({ class: "fas fa-eye" })
237
+ ) +
238
+ button(
239
+ {
240
+ class: "btn btn-outline-danger btn-sm",
241
+ onclick: `view_post(${safeViewName}, "del_feedback", {id:${r.id}}, refreshFeedbackViews)`,
242
+ },
243
+ i({ class: "fas fa-trash-alt" })
244
+ ),
245
+ },
246
+ ],
247
+ processed
248
+ ),
249
+ button(
250
+ {
251
+ class: "btn btn-outline-danger btn-sm",
252
+ onclick: `view_post(${safeViewName}, "del_all_feedback", {}, refreshFeedbackViews)`,
253
+ },
254
+ "Delete all"
255
+ )
256
+ )
257
+ : p({ class: "text-muted" }, "No processed feedback yet.")
258
+ );
259
+
260
+ return pendingSection + processedSection;
261
+ };
262
+
263
+ /** Shared form body — phases baked into the scope select. Used by both the inline modal and the POST route popup. */
264
+ const feedbackFormInner = (phases, preselectedScope = "") => {
265
+ const sel = (val) => (preselectedScope === val ? " selected" : "");
266
+ const scopeOptions = [
267
+ `<option value="overall"${sel("overall")}>Overall</option>`,
268
+ ...phases.map(
269
+ (ph, idx) =>
270
+ `<option value="phase_${idx}"${sel(`phase_${idx}`)}>Phase ${idx + 1}: ${
271
+ ph.name
272
+ }</option>`
273
+ ),
274
+ ].join("");
275
+
276
+ return (
277
+ div(
278
+ { id: "fb-step1" },
279
+ small(
280
+ { class: "text-muted d-block mb-3" },
281
+ "Describe a feature request, bug, or improvement."
282
+ ),
283
+ div(
284
+ { id: "fb-fields" },
285
+ div(
286
+ { class: "mb-3" },
287
+ label({ class: "form-label", for: "fb-title" }, "Title"),
288
+ input({ type: "text", class: "form-control", id: "fb-title" })
289
+ ),
290
+ div(
291
+ { class: "mb-3" },
292
+ label({ class: "form-label", for: "fb-desc" }, "Description"),
293
+ textarea({ class: "form-control", id: "fb-desc", rows: 3 }, "")
294
+ ),
295
+ div(
296
+ { class: "mb-3" },
297
+ label({ class: "form-label", for: "fb-scope" }, "Phase"),
298
+ `<select class="form-select" id="fb-scope">${scopeOptions}</select>`,
299
+ small(
300
+ { class: "form-text text-muted" },
301
+ "Select a phase if this feedback applies to a specific part of the build, or leave as Overall."
302
+ )
303
+ ),
304
+ div(
305
+ { class: "mb-3" },
306
+ label({ class: "form-label", for: "fb-url" }, "Page URL"),
307
+ input({
308
+ type: "text",
309
+ class: "form-control",
310
+ id: "fb-url",
311
+ placeholder: "Optional — the page this feedback relates to",
312
+ })
313
+ )
314
+ ),
315
+ div(
316
+ { id: "fb-analyse-area", class: "mb-2" },
317
+ button(
50
318
  {
51
- label: "Title",
52
- key: (m) => m.body.title,
319
+ type: "button",
320
+ class: "btn btn-outline-secondary btn-sm",
321
+ onclick: "fbAnalyseFeedback()",
53
322
  },
323
+ i({ class: "fas fa-search me-1" }),
324
+ "Analyse feedback"
325
+ ),
326
+ small(
327
+ { class: "text-muted d-block mt-1" },
328
+ "Checks if anything needs clarifying before saving."
329
+ )
330
+ ),
331
+ div(
332
+ { id: "fb-regen-area", class: "mb-2", style: "display:none" },
333
+ button(
54
334
  {
55
- label: "Description",
56
- key: (m) => m.body.description,
335
+ type: "button",
336
+ class: "btn btn-outline-secondary btn-sm me-2",
337
+ onclick: "fbAnalyseFeedback()",
57
338
  },
58
-
339
+ i({ class: "fas fa-sync me-1" }),
340
+ "Regenerate"
341
+ ),
342
+ button(
59
343
  {
60
- label: "Delete",
61
- key: (r) =>
62
- button(
63
- {
64
- class: "btn btn-outline-danger btn-sm",
65
- onclick: `view_post("${viewname}", "del_feedback", {id:${r.id}})`,
66
- },
67
- i({ class: "fas fa-trash-alt" }),
68
- ),
344
+ type: "button",
345
+ class: "btn btn-outline-danger btn-sm",
346
+ onclick: "fbDismissQuestions()",
69
347
  },
70
- ],
71
- errs,
348
+ "Dismiss"
349
+ )
72
350
  ),
73
- button(
351
+ div(
74
352
  {
75
- class: "btn btn-outline-danger",
76
- onclick: `view_post("${viewname}", "del_all_feedback")`,
353
+ id: "fb-spinner",
354
+ class: "my-2 text-muted small",
355
+ style: "display:none",
77
356
  },
78
- "Delete all",
357
+ i({ class: "fas fa-spinner fa-spin me-2" }),
358
+ "Analysing feedback..."
359
+ ),
360
+ div({ id: "fb-questions-area" }),
361
+ div(
362
+ {
363
+ id: "fb-clear-area",
364
+ class: "alert alert-success py-2 mt-2",
365
+ style: "display:none",
366
+ },
367
+ i({ class: "fas fa-check-circle me-2" }),
368
+ "Feedback is clear — no questions needed."
369
+ ),
370
+ div(
371
+ { class: "mt-3" },
372
+ button(
373
+ {
374
+ type: "button",
375
+ class: "btn btn-primary",
376
+ onclick: "fbSaveOrPrompt()",
377
+ },
378
+ "Save feedback"
379
+ )
380
+ )
381
+ ) +
382
+ div(
383
+ { id: "fb-success", class: "text-success mt-3", style: "display:none" },
384
+ i({ class: "fas fa-check-circle me-2" }),
385
+ "Thank you! Your feedback has been submitted."
386
+ ) +
387
+ feedbackClarifyModal()
388
+ );
389
+ };
390
+
391
+ /** Outer shell of the feedback tab — renders once, includes modals, JS, and #feedback-views-area. */
392
+ const feedbackList = async () => {
393
+ const phasesMd = await MetaData.findOne({
394
+ type: "CopilotConstructMgr",
395
+ name: "phases",
396
+ });
397
+ const phases = phasesMd?.body?.phases || [];
398
+ const safeViewName = JSON.stringify(viewname);
399
+
400
+ const topSection = div(
401
+ { id: "feedback-views-area" },
402
+ await feedbackViewsContent()
403
+ );
404
+
405
+ const clientScript = script(
406
+ domReady(`
407
+ const safeViewName = ${safeViewName};
408
+ const _pollingIds = new Set();
409
+ function startApprovalPolling() {
410
+ document.querySelectorAll('[data-approving-id]').forEach(el => {
411
+ const id = parseInt(el.dataset.approvingId);
412
+ if (_pollingIds.has(id)) return;
413
+ _pollingIds.add(id);
414
+ const poll = () => {
415
+ view_post(safeViewName, 'approval_status', { id }, (resp) => {
416
+ if (resp && !resp.approving) {
417
+ _pollingIds.delete(id);
418
+ refreshFeedbackViews();
419
+ refreshReqTaskAreas();
420
+ }
421
+ else setTimeout(poll, 3000);
422
+ });
423
+ };
424
+ setTimeout(poll, 3000);
425
+ });
426
+ }
427
+ function refreshReqTaskAreas() {
428
+ view_post(safeViewName, 'req_list_html', {}, (r) => {
429
+ const el = document.getElementById('req-list-area');
430
+ if (r && r.html && el) el.innerHTML = r.html;
431
+ });
432
+ }
433
+ window.refreshFeedbackViews = () => {
434
+ view_post(safeViewName, 'feedback_views_html', {}, (r) => {
435
+ if (r && r.html) {
436
+ document.getElementById('feedback-views-area').innerHTML = r.html;
437
+ startApprovalPolling();
438
+ }
439
+ });
440
+ };
441
+ window.copilotAddFeedbackToMenu = () => {
442
+ view_post(safeViewName, 'add_feedback_to_menu', {}, (resp) => {
443
+ if (resp && !resp.error) location.reload();
444
+ });
445
+ };
446
+ window.copilotApprove = (id) => {
447
+ const btn = document.getElementById('approve-btn-' + id);
448
+ if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; }
449
+ _pollingIds.add(id);
450
+ view_post(safeViewName, 'start_approve_feedback', { id }, () => {
451
+ const poll = () => {
452
+ view_post(safeViewName, 'approval_status', { id }, (resp) => {
453
+ if (resp && !resp.approving) {
454
+ _pollingIds.delete(id);
455
+ refreshFeedbackViews();
456
+ refreshReqTaskAreas();
457
+ }
458
+ else setTimeout(poll, 3000);
459
+ });
460
+ };
461
+ setTimeout(poll, 3000);
462
+ });
463
+ };
464
+ window.copilotDeleteFeedback = (id) => {
465
+ view_post(safeViewName, 'delete_feedback_row', { id }, (resp) => {
466
+ if (resp && !resp.error) refreshFeedbackViews();
467
+ });
468
+ };
469
+ window.copilotOpenFeedbackEdit = (id) => {
470
+ view_post(safeViewName, 'get_feedback_edit_html', { id }, (resp) => {
471
+ if (!resp || !resp.html) return;
472
+ document.getElementById('fb-edit-modal-body').innerHTML = resp.html;
473
+ document.getElementById('fb-edit-modal').dataset.feedbackId = id;
474
+ new bootstrap.Modal(document.getElementById('fb-edit-modal')).show();
475
+ });
476
+ };
477
+ window.copilotSaveFeedbackEdit = () => {
478
+ const id = document.getElementById('fb-edit-modal').dataset.feedbackId;
479
+ const payload = {
480
+ id,
481
+ title: document.getElementById('fbed-title').value,
482
+ description: document.getElementById('fbed-desc').value,
483
+ url: document.getElementById('fbed-url').value,
484
+ };
485
+ document.querySelectorAll('.fbed-answer').forEach(el => {
486
+ payload[el.dataset.q] = el.value;
487
+ });
488
+ view_post(safeViewName, 'save_feedback_edit', payload, (resp) => {
489
+ if (resp && !resp.error) {
490
+ bootstrap.Modal.getInstance(document.getElementById('fb-edit-modal')).hide();
491
+ refreshFeedbackViews();
492
+ }
493
+ });
494
+ };
495
+ window.copilotRunFeedbackTask = (taskId) => {
496
+ const btn = document.getElementById('fb-task-run-btn-' + taskId);
497
+ if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; }
498
+ view_post(safeViewName, 'run_task', { id: taskId }, () => {
499
+ const poll = () => {
500
+ view_post(safeViewName, 'task_status', { ids: [String(taskId)] }, (resp) => {
501
+ if (resp && resp.any_done) refreshFeedbackViews();
502
+ else setTimeout(poll, 3000);
503
+ });
504
+ };
505
+ setTimeout(poll, 3000);
506
+ });
507
+ };
508
+ window.copilotShowProcessedFeedback = (id) => {
509
+ view_post(safeViewName, 'show_processed_feedback', { id }, (resp) => {
510
+ if (!resp || !resp.html) return;
511
+ document.getElementById('fb-details-modal-body').innerHTML = resp.html;
512
+ new bootstrap.Modal(document.getElementById('fb-details-modal')).show();
513
+ });
514
+ };
515
+ document.addEventListener('shown.bs.tab', () => startApprovalPolling());
516
+ startApprovalPolling();
517
+ `)
518
+ );
519
+
520
+ const editModal = div(
521
+ {
522
+ class: "modal fade",
523
+ id: "fb-edit-modal",
524
+ tabindex: "-1",
525
+ "aria-hidden": "true",
526
+ },
527
+ div(
528
+ { class: "modal-dialog modal-lg" },
529
+ div(
530
+ { class: "modal-content" },
531
+ div(
532
+ { class: "modal-header" },
533
+ h5({ class: "modal-title" }, "Edit feedback"),
534
+ button({
535
+ type: "button",
536
+ class: "btn-close",
537
+ "data-bs-dismiss": "modal",
538
+ "aria-label": "Close",
539
+ })
540
+ ),
541
+ div({ class: "modal-body", id: "fb-edit-modal-body" }),
542
+ div(
543
+ { class: "modal-footer" },
544
+ button(
545
+ {
546
+ type: "button",
547
+ class: "btn btn-outline-secondary",
548
+ "data-bs-dismiss": "modal",
549
+ },
550
+ "Cancel"
551
+ ),
552
+ button(
553
+ {
554
+ type: "button",
555
+ class: "btn btn-primary",
556
+ onclick: "copilotSaveFeedbackEdit()",
557
+ },
558
+ "Save"
559
+ )
560
+ )
561
+ )
562
+ )
563
+ );
564
+
565
+ const detailsModal = div(
566
+ {
567
+ class: "modal fade",
568
+ id: "fb-details-modal",
569
+ tabindex: "-1",
570
+ "aria-hidden": "true",
571
+ },
572
+ div(
573
+ { class: "modal-dialog modal-lg" },
574
+ div(
575
+ { class: "modal-content" },
576
+ div(
577
+ { class: "modal-header" },
578
+ h5({ class: "modal-title" }, "Feedback details"),
579
+ button({
580
+ type: "button",
581
+ class: "btn-close",
582
+ "data-bs-dismiss": "modal",
583
+ "aria-label": "Close",
584
+ })
585
+ ),
586
+ div({ class: "modal-body", id: "fb-details-modal-body" }),
587
+ div(
588
+ { class: "modal-footer" },
589
+ button(
590
+ {
591
+ type: "button",
592
+ class: "btn btn-secondary",
593
+ "data-bs-dismiss": "modal",
594
+ },
595
+ "Close"
596
+ )
597
+ )
598
+ )
599
+ )
600
+ );
601
+
602
+ const menuItems = getState().getConfig("menu_items", []);
603
+ const feedbackInMenu = menuItems.some(
604
+ (mi) => mi.type === "Link" && mi.url?.includes("get_feedback_form")
605
+ );
606
+ const navbarBtn = !features.view_route_modal
607
+ ? div(
608
+ { class: "mt-3" },
609
+ small(
610
+ { class: "text-muted" },
611
+ i({ class: "fas fa-info-circle me-1" }),
612
+ "Adding a feedback button to the navbar requires a newer version of Saltcorn."
613
+ )
614
+ )
615
+ : feedbackInMenu
616
+ ? ""
617
+ : div(
618
+ { class: "mt-3" },
619
+ button(
620
+ {
621
+ class: "btn btn-outline-secondary btn-sm",
622
+ onclick: "copilotAddFeedbackToMenu()",
623
+ title: "Add a Feedback button to the site navigation bar",
624
+ },
625
+ i({ class: "fas fa-bars me-1" }),
626
+ "Add feedback button to navbar"
627
+ )
628
+ );
629
+
630
+ return div(
631
+ { class: "mt-2" },
632
+ topSection,
633
+ navbarBtn,
634
+ editModal,
635
+ detailsModal,
636
+ clientScript
637
+ );
638
+ };
639
+
640
+ /**
641
+ * Route: returns the rendered feedbackViewsContent HTML for in-place ajax refresh.
642
+ */
643
+ const feedback_views_html = async (
644
+ table_id,
645
+ vn,
646
+ config,
647
+ body,
648
+ { req, res }
649
+ ) => {
650
+ const html = await feedbackViewsContent();
651
+ return { json: { html } };
652
+ };
653
+
654
+ /**
655
+ * Route: starts async approval of a pending feedback row.
656
+ * Runs feedbackAction in the background, then deletes the MetaData record and its research metadata.
657
+ */
658
+ const start_approve_feedback = async (
659
+ table_id,
660
+ vn,
661
+ config,
662
+ body,
663
+ { req, res }
664
+ ) => {
665
+ const id = parseInt(body.id);
666
+ const mdRow = await MetaData.findOne({
667
+ id,
668
+ type: "CopilotConstructMgr",
669
+ name: "feedback_pending",
670
+ });
671
+ if (!mdRow) return { json: { error: "Not found" } };
672
+ const row = mdRow.body;
673
+
674
+ const mdName = `approving_feedback_${id}`;
675
+ await MetaData.create({
676
+ type: "CopilotConstructMgr",
677
+ name: mdName,
678
+ body: { id },
679
+ });
680
+
681
+ const researchMd = await MetaData.findOne({
682
+ type: "CopilotConstructMgr",
683
+ name: `feedback_research_${id}`,
684
+ });
685
+ let research_context = null;
686
+ if (researchMd) {
687
+ const questions = researchMd.body.questions || [];
688
+ const answers = researchMd.body.answers || {};
689
+ const pairs = questions.map((q, idx) => {
690
+ const a = answers[`q${idx + 1}`];
691
+ return `Q: ${q}\nA: ${a && a.trim() ? a.trim() : "(no answer)"}`;
692
+ });
693
+ if (pairs.length) research_context = pairs.join("\n\n");
694
+ }
695
+
696
+ if (row.phase_idx != null) {
697
+ {
698
+ const phIdx = row.phase_idx;
699
+ const phasesMd = await MetaData.findOne({
700
+ type: "CopilotConstructMgr",
701
+ name: "phases",
702
+ });
703
+ const ph = phasesMd?.body?.phases?.[phIdx];
704
+ if (ph) {
705
+ const allPhaseRecords = await MetaData.find({
706
+ type: "CopilotConstructMgr",
707
+ });
708
+ const forPhase = (name) =>
709
+ allPhaseRecords.filter(
710
+ (r) => r.name === name && r.body?.phase_idx === phIdx
711
+ );
712
+
713
+ const tableLines = forPhase("table_phase").map(
714
+ (r) => `- ${r.body.table_name}`
715
+ );
716
+ const pluginLines = forPhase("plugin_phase").map(
717
+ (r) => `- ${r.body.plugin_name}`
718
+ );
719
+ const viewLines = forPhase("view_phase").map((r) =>
720
+ r.body.viewtemplate === "page"
721
+ ? `- page: ${r.body.view_name}`
722
+ : `- view: ${r.body.view_name} (${r.body.viewtemplate})`
723
+ );
724
+
725
+ const sections = [
726
+ pluginLines.length
727
+ ? `Plugins installed in this phase:\n${pluginLines.join("\n")}`
728
+ : "",
729
+ tableLines.length
730
+ ? `Tables created in this phase:\n${tableLines.join("\n")}`
731
+ : "",
732
+ viewLines.length
733
+ ? `Views and pages created in this phase:\n${viewLines.join("\n")}`
734
+ : "",
735
+ ].filter(Boolean);
736
+
737
+ const phaseNote = `This feedback is scoped to Phase ${phIdx + 1}: ${
738
+ ph.name
739
+ }. ${ph.description}${
740
+ sections.length ? "\n\n" + sections.join("\n\n") : ""
741
+ }`;
742
+ research_context = research_context
743
+ ? phaseNote + "\n\n" + research_context
744
+ : phaseNote;
745
+ }
746
+ }
747
+ }
748
+
749
+ const existingTaskIds = new Set(
750
+ (await MetaData.find({ type: "CopilotConstructMgr", name: "task" })).map(
751
+ (t) => t.id
752
+ )
753
+ );
754
+
755
+ feedbackAction
756
+ .run({
757
+ row,
758
+ user: req.user,
759
+ mode: "table",
760
+ req,
761
+ configuration: {
762
+ title_field: "title",
763
+ description_field: "description",
764
+ url_field: "url",
765
+ research_context,
766
+ },
767
+ })
768
+ .then(async () => {
769
+ if (row.phase_idx != null) {
770
+ const phIdx = row.phase_idx;
771
+ const phasesMd = await MetaData.findOne({
772
+ type: "CopilotConstructMgr",
773
+ name: "phases",
774
+ });
775
+ const phaseName = phasesMd?.body?.phases?.[phIdx]?.name;
776
+ const allTasks = await MetaData.find({
777
+ type: "CopilotConstructMgr",
778
+ name: "task",
779
+ });
780
+ for (const t of allTasks.filter((t) => !existingTaskIds.has(t.id)))
781
+ await t.update({
782
+ body: { ...t.body, phase_idx: phIdx, phase_name: phaseName },
783
+ });
784
+ }
785
+ const md = await MetaData.findOne({
786
+ type: "CopilotConstructMgr",
787
+ name: mdName,
788
+ });
789
+ if (md) await md.delete();
790
+ await mdRow.delete();
791
+ const rmd = await MetaData.findOne({
792
+ type: "CopilotConstructMgr",
793
+ name: `feedback_research_${id}`,
794
+ });
795
+ if (rmd) await rmd.delete();
796
+ })
797
+ .catch(async (e) => {
798
+ console.error("approve_feedback error", e);
799
+ const md = await MetaData.findOne({
800
+ type: "CopilotConstructMgr",
801
+ name: mdName,
802
+ });
803
+ if (md) await md.delete();
804
+ });
805
+
806
+ return { json: { success: true } };
807
+ };
808
+
809
+ /**
810
+ * Route: polls whether a given feedback row is still being approved.
811
+ */
812
+ const approval_status = async (table_id, vn, config, body, { req, res }) => {
813
+ const id = parseInt(body.id);
814
+ const md = await MetaData.findOne({
815
+ type: "CopilotConstructMgr",
816
+ name: `approving_feedback_${id}`,
817
+ });
818
+ return { json: { approving: !!md } };
819
+ };
820
+
821
+ /**
822
+ * Route: deletes a pending feedback MetaData record and its associated research metadata.
823
+ */
824
+ const delete_feedback_row = async (
825
+ table_id,
826
+ vn,
827
+ config,
828
+ body,
829
+ { req, res }
830
+ ) => {
831
+ const id = parseInt(body.id);
832
+ const mdRow = await MetaData.findOne({
833
+ id,
834
+ type: "CopilotConstructMgr",
835
+ name: "feedback_pending",
836
+ });
837
+ if (mdRow) await mdRow.delete();
838
+ const rmd = await MetaData.findOne({
839
+ type: "CopilotConstructMgr",
840
+ name: `feedback_research_${id}`,
841
+ });
842
+ if (rmd) await rmd.delete();
843
+ return { json: { success: true } };
844
+ };
845
+
846
+ /** Route: returns the show HTML for a processed feedback entry, displayed in the details modal. */
847
+ const show_processed_feedback = async (
848
+ table_id,
849
+ vn,
850
+ config,
851
+ body,
852
+ { req, res }
853
+ ) => {
854
+ const id = parseInt(body.id);
855
+ const md = await MetaData.findOne({ id });
856
+ if (!md) return { json: { error: "Not found" } };
857
+
858
+ const { title, description, url, research_context } = md.body;
859
+
860
+ const field = (lbl, val) =>
861
+ val
862
+ ? div(
863
+ { class: "mb-3" },
864
+ small({ class: "text-muted fw-semibold d-block" }, lbl),
865
+ p({ class: "mb-0" }, val)
866
+ )
867
+ : "";
868
+
869
+ const qaHtml = research_context
870
+ ? hr() +
871
+ p({ class: "fw-semibold mb-3" }, "Clarifying questions") +
872
+ research_context
873
+ .split("\n\n")
874
+ .map((pair) => {
875
+ const [qLine, aLine] = pair.split("\n");
876
+ const q = qLine?.replace(/^Q:\s*/, "") || "";
877
+ const a = aLine?.replace(/^A:\s*/, "") || "";
878
+ return div(
879
+ { class: "mb-3" },
880
+ small({ class: "text-muted fw-semibold d-block" }, q),
881
+ p({ class: "mb-0" }, a || "—")
882
+ );
883
+ })
884
+ .join("")
885
+ : "";
886
+
887
+ return {
888
+ json: {
889
+ html:
890
+ field("Title", title) +
891
+ field("Description", description) +
892
+ field("URL", url) +
893
+ qaHtml,
894
+ },
895
+ };
896
+ };
897
+
898
+ /**
899
+ * Route: returns editable HTML for a pending feedback record including all fields
900
+ * and any associated Q&A answers, shown in the edit modal.
901
+ */
902
+ const get_feedback_edit_html = async (
903
+ table_id,
904
+ vn,
905
+ config,
906
+ body,
907
+ { req, res }
908
+ ) => {
909
+ const id = parseInt(body.id);
910
+ const mdRow = await MetaData.findOne({
911
+ id,
912
+ type: "CopilotConstructMgr",
913
+ name: "feedback_pending",
914
+ });
915
+ if (!mdRow) return { json: { error: "Not found" } };
916
+ const row = mdRow.body;
917
+
918
+ const rmd = await MetaData.findOne({
919
+ type: "CopilotConstructMgr",
920
+ name: `feedback_research_${id}`,
921
+ });
922
+ const rmdValid = parseInt(rmd?.body?.feedback_id) === id;
923
+ const questions = rmdValid ? rmd.body.questions || [] : [];
924
+ const answers = rmdValid ? rmd.body.answers || {} : {};
925
+
926
+ const fieldsHtml =
927
+ div(
928
+ { class: "mb-3" },
929
+ label({ class: "form-label fw-semibold", for: "fbed-title" }, "Title"),
930
+ input({
931
+ type: "text",
932
+ class: "form-control",
933
+ id: "fbed-title",
934
+ value: row.title || "",
935
+ })
936
+ ) +
937
+ div(
938
+ { class: "mb-3" },
939
+ label(
940
+ { class: "form-label fw-semibold", for: "fbed-desc" },
941
+ "Description"
79
942
  ),
943
+ textarea(
944
+ { class: "form-control", id: "fbed-desc", rows: 3 },
945
+ row.description || ""
946
+ )
947
+ ) +
948
+ div(
949
+ { class: "mb-3" },
950
+ label({ class: "form-label fw-semibold", for: "fbed-url" }, "URL"),
951
+ input({
952
+ type: "text",
953
+ class: "form-control",
954
+ id: "fbed-url",
955
+ value: row.url || "",
956
+ })
80
957
  );
81
- } else {
82
- return div({ class: "mt-2" }, p("No feedback"));
958
+
959
+ const questionsHtml = questions.length
960
+ ? hr() +
961
+ p({ class: "fw-semibold mb-3" }, "Clarifying questions") +
962
+ questions
963
+ .map((q, idx) => {
964
+ const key = `q${idx + 1}`;
965
+ return div(
966
+ { class: "mb-3" },
967
+ label({ class: "form-label small fw-semibold" }, q),
968
+ textarea(
969
+ {
970
+ class: "form-control form-control-sm fbed-answer",
971
+ "data-q": key,
972
+ rows: 2,
973
+ },
974
+ answers[key] || ""
975
+ )
976
+ );
977
+ })
978
+ .join("")
979
+ : "";
980
+
981
+ return { json: { html: fieldsHtml + questionsHtml } };
982
+ };
983
+
984
+ /**
985
+ * Route: saves edits to a pending feedback MetaData record and updates its Q&A answers metadata.
986
+ */
987
+ const save_feedback_edit = async (table_id, vn, config, body, { req, res }) => {
988
+ const { _csrf, id: rawId, title, description, url, ...rest } = body;
989
+ const id = parseInt(rawId);
990
+ const mdRow = await MetaData.findOne({
991
+ id,
992
+ type: "CopilotConstructMgr",
993
+ name: "feedback_pending",
994
+ });
995
+ if (mdRow) {
996
+ await mdRow.update({ body: { ...mdRow.body, title, description, url } });
997
+ }
998
+
999
+ const rmd = await MetaData.findOne({
1000
+ type: "CopilotConstructMgr",
1001
+ name: `feedback_research_${id}`,
1002
+ });
1003
+ if (rmd) {
1004
+ const answers = { ...rmd.body.answers };
1005
+ for (const [k, v] of Object.entries(rest)) {
1006
+ if (/^q\d+$/.test(k)) answers[k] = v;
1007
+ }
1008
+ await rmd.update({ body: { ...rmd.body, answers } });
83
1009
  }
1010
+
1011
+ return { json: { success: true, notify_success: "Feedback saved" } };
84
1012
  };
85
1013
 
86
- const del_feedback = async (table_id, viewname, config, body, { req, res }) => {
87
- const r = await MetaData.findOne({
88
- id: body.id,
1014
+ /**
1015
+ * Route: adds a Feedback link to the navigation menu if not already present.
1016
+ */
1017
+ const get_feedback_form = async (table_id, vn, config, body, { req, res }) => {
1018
+ const phasesMd = await MetaData.findOne({
1019
+ type: "CopilotConstructMgr",
1020
+ name: "phases",
89
1021
  });
1022
+ const phases = phasesMd?.body?.phases || [];
1023
+ const preselectedScope = body.scope || req?.query?.scope || "";
1024
+ const safeViewNameJson = JSON.stringify(viewname);
90
1025
 
91
- if (!r) throw new Error("Feedback not found");
92
- await r.delete();
93
- return { json: { reload_page: true } };
1026
+ const standaloneScript = `<script>
1027
+ (function(){
1028
+ const safeViewName = ${safeViewNameJson};
1029
+ let _fbQuestions;
1030
+ const _urlField = document.getElementById('fb-url');
1031
+ if (_urlField) {
1032
+ try {
1033
+ const _href = window.location.href;
1034
+ if (!decodeURIComponent(_href).includes('/view/' + safeViewName)) _urlField.value = _href;
1035
+ } catch (_e) { _urlField.value = window.location.href; }
1036
+ }
1037
+ function fbGetUrl() {
1038
+ const url = document.getElementById('fb-url')?.value?.trim() || '';
1039
+ if (!url) return '';
1040
+ try { if (decodeURIComponent(url).includes('/view/' + safeViewName)) return ''; } catch (_e) {}
1041
+ return url;
1042
+ }
1043
+ function fbPopulateQuestions(questions) {
1044
+ const area = document.getElementById('fb-questions-area');
1045
+ area.innerHTML = '';
1046
+ for (const [idx, q] of questions.entries()) {
1047
+ const wrapper = document.createElement('div');
1048
+ wrapper.className = 'mb-3';
1049
+ const lbl = document.createElement('label');
1050
+ lbl.className = 'form-label';
1051
+ lbl.textContent = q;
1052
+ lbl.htmlFor = 'fb-answer-' + idx;
1053
+ const ta = document.createElement('textarea');
1054
+ ta.className = 'form-control';
1055
+ ta.id = 'fb-answer-' + idx;
1056
+ ta.rows = 2;
1057
+ wrapper.appendChild(lbl);
1058
+ wrapper.appendChild(ta);
1059
+ area.appendChild(wrapper);
1060
+ }
1061
+ }
1062
+ window.fbAnalyseFeedback = () => {
1063
+ const title = document.getElementById('fb-title').value.trim();
1064
+ if (!title) { document.getElementById('fb-title').classList.add('is-invalid'); return; }
1065
+ document.getElementById('fb-title').classList.remove('is-invalid');
1066
+ document.getElementById('fb-spinner').style.display = '';
1067
+ document.getElementById('fb-analyse-area').style.display = 'none';
1068
+ document.getElementById('fb-regen-area').style.display = 'none';
1069
+ view_post(safeViewName, 'analyse_feedback', { title, description: document.getElementById('fb-desc').value.trim(), url: fbGetUrl() }, (resp) => {
1070
+ document.getElementById('fb-spinner').style.display = 'none';
1071
+ if (!resp || resp.error) { document.getElementById('fb-analyse-area').style.display = ''; return; }
1072
+ const questions = resp.questions || [];
1073
+ _fbQuestions = questions;
1074
+ if (questions.length === 0) {
1075
+ document.getElementById('fb-clear-area').style.display = '';
1076
+ document.getElementById('fb-regen-area').style.display = '';
1077
+ return;
1078
+ }
1079
+ fbPopulateQuestions(questions);
1080
+ document.getElementById('fb-fields').style.display = 'none';
1081
+ document.getElementById('fb-regen-area').style.display = '';
1082
+ });
1083
+ };
1084
+ window.fbDismissQuestions = () => {
1085
+ _fbQuestions = undefined;
1086
+ document.getElementById('fb-questions-area').innerHTML = '';
1087
+ document.getElementById('fb-clear-area').style.display = 'none';
1088
+ document.getElementById('fb-fields').style.display = '';
1089
+ document.getElementById('fb-regen-area').style.display = 'none';
1090
+ document.getElementById('fb-analyse-area').style.display = '';
1091
+ };
1092
+ window.fbSaveOrPrompt = () => {
1093
+ const title = document.getElementById('fb-title').value.trim();
1094
+ if (!title) { document.getElementById('fb-title').classList.add('is-invalid'); return; }
1095
+ document.getElementById('fb-title').classList.remove('is-invalid');
1096
+ if (_fbQuestions !== undefined) { fbSubmit(); return; }
1097
+ new bootstrap.Modal(document.getElementById('fb-clarify-modal')).show();
1098
+ };
1099
+ window.fbSubmit = () => {
1100
+ const title = document.getElementById('fb-title').value.trim();
1101
+ if (!title) { document.getElementById('fb-title').classList.add('is-invalid'); return; }
1102
+ document.getElementById('fb-title').classList.remove('is-invalid');
1103
+ const scope = document.getElementById('fb-scope')?.value || 'overall';
1104
+ const payload = { title, description: document.getElementById('fb-desc').value.trim(), url: fbGetUrl(), scope };
1105
+ for (const [idx, q] of (_fbQuestions || []).entries()) {
1106
+ payload['question_' + (idx + 1)] = q;
1107
+ const ta = document.getElementById('fb-answer-' + idx);
1108
+ payload['a' + (idx + 1)] = ta ? ta.value : '';
1109
+ }
1110
+ view_post(safeViewName, 'submit_feedback_with_answers', payload, (resp) => {
1111
+ if (resp && !resp.error) {
1112
+ document.getElementById('fb-step1').style.display = 'none';
1113
+ document.getElementById('fb-success').style.display = '';
1114
+ if (typeof refreshFeedbackViews === 'function') refreshFeedbackViews();
1115
+ }
1116
+ });
1117
+ };
1118
+ })();
1119
+ </script>`;
1120
+
1121
+ return {
1122
+ html: feedbackFormInner(phases, preselectedScope) + standaloneScript,
1123
+ title: "Submit feedback",
1124
+ };
94
1125
  };
95
- const del_all_feedback = async (
1126
+
1127
+ const add_feedback_to_menu = async (
96
1128
  table_id,
97
- viewname,
1129
+ vn,
98
1130
  config,
99
1131
  body,
100
- { req, res },
1132
+ { req, res }
101
1133
  ) => {
1134
+ if (!features.view_route_modal) {
1135
+ return {
1136
+ json: {
1137
+ error: "requires_newer_saltcorn",
1138
+ notify_error:
1139
+ "Adding a navbar feedback button requires a newer version of Saltcorn.",
1140
+ },
1141
+ };
1142
+ }
1143
+
1144
+ const menuUrl = `javascript:ajax_modal('/view/${encodeURIComponent(
1145
+ viewname
1146
+ )}/get_feedback_form', {method:'POST'})`;
1147
+ const current = getState().getConfig("menu_items", []);
1148
+ const alreadyAdded = current.some(
1149
+ (mi) => mi.type === "Link" && mi.url?.includes("get_feedback_form")
1150
+ );
1151
+ if (!alreadyAdded) {
1152
+ await save_menu_items([
1153
+ ...current,
1154
+ {
1155
+ type: "Link",
1156
+ label: "Feedback",
1157
+ text: "Feedback",
1158
+ icon: "fas fa-comment-alt",
1159
+ url: menuUrl,
1160
+ min_role: 80,
1161
+ },
1162
+ ]);
1163
+ }
1164
+ return { json: { success: true } };
1165
+ };
1166
+
1167
+ /**
1168
+ * Route: deletes a single processed feedback metadata entry.
1169
+ */
1170
+ const del_feedback = async (table_id, vn, config, body, { req, res }) => {
1171
+ const r = await MetaData.findOne({ id: body.id });
1172
+ if (!r) throw new Error("Feedback not found");
1173
+ await r.delete();
1174
+
1175
+ // just to be sure
1176
+ // feedback_research_${body.id} should already be cleaned up
1177
+ const stale = await MetaData.findOne({
1178
+ type: "CopilotConstructMgr",
1179
+ name: `feedback_research_${body.id}`,
1180
+ });
1181
+ if (stale) {
1182
+ getState().log(
1183
+ 5,
1184
+ `del_feedback: found stale feedback_research_${body.id}, deleting`
1185
+ );
1186
+ await stale.delete();
1187
+ }
1188
+ return { json: { success: true } };
1189
+ };
1190
+
1191
+ /**
1192
+ * Route: deletes all processed feedback metadata entries.
1193
+ */
1194
+ const del_all_feedback = async (table_id, vn, config, body, { req, res }) => {
102
1195
  const rs = await MetaData.find({
103
1196
  type: "CopilotConstructMgr",
104
1197
  name: "feedback",
105
1198
  });
106
- for (const r of rs) await r.delete();
107
- return { json: { reload_page: true } };
1199
+ for (const r of rs) {
1200
+ await r.delete();
1201
+ const stale = await MetaData.findOne({
1202
+ type: "CopilotConstructMgr",
1203
+ name: `feedback_research_${r.id}`,
1204
+ });
1205
+ if (stale) {
1206
+ getState().log(
1207
+ 5,
1208
+ `del_all_feedback: found stale feedback_research_${r.id}, deleting`
1209
+ );
1210
+ await stale.delete();
1211
+ }
1212
+ }
1213
+ return { json: { success: true } };
108
1214
  };
109
1215
 
110
- const feedback_routes = { del_feedback, del_all_feedback };
1216
+ /**
1217
+ * Route: asks the LLM whether the feedback needs clarification.
1218
+ * Returns an array of questions, or an empty array if the feedback is clear.
1219
+ */
1220
+ const analyse_feedback = async (table_id, vn, config, body, { req, res }) => {
1221
+ const { title, description, url = "" } = body;
1222
+
1223
+ let knownContext = null;
1224
+ if (url) {
1225
+ const mView = url.match(/\/view\/([^/?#]+)/);
1226
+ const mPage = url.match(/\/page\/([^/?#]+)/);
1227
+ const entityType = mView ? "view" : mPage ? "page" : null;
1228
+ const entityName = mView?.[1] ?? mPage?.[1] ?? null;
1229
+ if (entityType) {
1230
+ knownContext = {
1231
+ section:
1232
+ "Known context (do NOT ask about these — they are already established facts):\n" +
1233
+ `- The feedback targets the Saltcorn ${entityType} named "${entityName}"\n` +
1234
+ `- URL: ${url}\n`,
1235
+ doNotAsk:
1236
+ `- Which ${entityType}, screen, or part of the application this concerns` +
1237
+ ` — it is the ${entityType} "${entityName}" as stated above`,
1238
+ };
1239
+ } else {
1240
+ knownContext = {
1241
+ section: `Known context:\n- URL: ${url}\n`,
1242
+ doNotAsk: null,
1243
+ };
1244
+ }
1245
+ }
1246
+
1247
+ const generator = await PromptGenerator.createInstance();
1248
+
1249
+ const answer = await getState().functions.llm_generate.run(
1250
+ generator.feedbackAnalysePrompt({ title, description, knownContext }),
1251
+ {
1252
+ tools: [questions_tool],
1253
+ systemPrompt:
1254
+ "You are a requirements analyst reviewing user feedback. " +
1255
+ "Your default is to ask NO questions — only use the tool when something\n" +
1256
+ "is genuinely too ambiguous to act on without clarification.\n" +
1257
+ "Never fish for detail that a competent developer could infer or decide themselves.",
1258
+ }
1259
+ );
1260
+ const tc =
1261
+ typeof answer.getToolCalls === "function"
1262
+ ? answer.getToolCalls()[0]
1263
+ : undefined;
1264
+ return { json: { questions: tc?.input?.questions ?? [] } };
1265
+ };
1266
+
1267
+ /**
1268
+ * Route: creates a new pending feedback MetaData record and stores any Q&A answers
1269
+ * as a separate metadata record keyed by the record id.
1270
+ */
1271
+ const submit_feedback_with_answers = async (
1272
+ table_id,
1273
+ vn,
1274
+ config,
1275
+ body,
1276
+ { req, res }
1277
+ ) => {
1278
+ const { _csrf, title, description, url, scope, ...rest } = body;
1279
+
1280
+ const phaseMatch = (scope || "").match(/^phase_(\d+)$/);
1281
+ const phase_idx = phaseMatch ? parseInt(phaseMatch[1]) : null;
1282
+
1283
+ const newMd = await MetaData.create({
1284
+ type: "CopilotConstructMgr",
1285
+ name: "feedback_pending",
1286
+ body: {
1287
+ title,
1288
+ description: description || null,
1289
+ url: url || null,
1290
+ scope: scope || "overall",
1291
+ phase_idx,
1292
+ status: "Pending",
1293
+ },
1294
+ user_id: req?.user?.id,
1295
+ });
1296
+ const rowId = newMd.id;
1297
+
1298
+ const questions = [];
1299
+ const answers = {};
1300
+ let idx = 1;
1301
+ while (rest[`question_${idx}`] !== undefined) {
1302
+ questions.push(rest[`question_${idx}`]);
1303
+ answers[`q${idx}`] = rest[`a${idx}`] || "";
1304
+ idx++;
1305
+ }
1306
+
1307
+ if (questions.length) {
1308
+ await MetaData.create({
1309
+ type: "CopilotConstructMgr",
1310
+ name: `feedback_research_${rowId}`,
1311
+ body: {
1312
+ feedback_id: rowId,
1313
+ title,
1314
+ questions,
1315
+ answers,
1316
+ },
1317
+ });
1318
+ }
1319
+
1320
+ return { json: { success: true } };
1321
+ };
1322
+
1323
+ const feedback_routes = {
1324
+ del_feedback,
1325
+ del_all_feedback,
1326
+ feedback_views_html,
1327
+ get_feedback_form,
1328
+ start_approve_feedback,
1329
+ approval_status,
1330
+ delete_feedback_row,
1331
+ analyse_feedback,
1332
+ submit_feedback_with_answers,
1333
+ get_feedback_edit_html,
1334
+ save_feedback_edit,
1335
+ show_processed_feedback,
1336
+ add_feedback_to_menu,
1337
+ };
111
1338
 
112
1339
  module.exports = { feedbackList, feedback_routes };