pi-interview 0.6.1 → 0.8.0
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.
- package/README.md +42 -25
- package/form/script.js +1417 -387
- package/form/styles.css +420 -17
- package/index.ts +412 -65
- package/package.json +1 -1
- package/schema.ts +81 -20
- package/server.ts +766 -90
package/form/script.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
const cwd = data.cwd || "";
|
|
7
7
|
const gitBranch = data.gitBranch || "";
|
|
8
8
|
const timeout = typeof data.timeout === "number" ? data.timeout : 0;
|
|
9
|
+
const askModels = Array.isArray(data.askModels)
|
|
10
|
+
? data.askModels.filter((model) => model && typeof model.value === "string" && typeof model.provider === "string")
|
|
11
|
+
: [];
|
|
12
|
+
const defaultAskModel = typeof data.defaultAskModel === "string" ? data.defaultAskModel : null;
|
|
9
13
|
|
|
10
14
|
const titleEl = document.getElementById("form-title");
|
|
11
15
|
const descriptionEl = document.getElementById("form-description");
|
|
@@ -37,6 +41,12 @@
|
|
|
37
41
|
const imagePathState = new Map();
|
|
38
42
|
const attachState = new Map();
|
|
39
43
|
const attachPathState = new Map();
|
|
44
|
+
const optionKeyState = new Map();
|
|
45
|
+
const choiceNoteState = new Map();
|
|
46
|
+
const optionInsightState = {
|
|
47
|
+
active: null,
|
|
48
|
+
pinned: new Map(),
|
|
49
|
+
};
|
|
40
50
|
const nav = {
|
|
41
51
|
questionIndex: 0,
|
|
42
52
|
optionIndex: 0,
|
|
@@ -76,6 +86,12 @@
|
|
|
76
86
|
dismissed: false,
|
|
77
87
|
knownIds: new Set(),
|
|
78
88
|
};
|
|
89
|
+
const ASK_PROMPT_CHIPS = [
|
|
90
|
+
{ key: "explain", label: "Explain this", prompt: "Explain this better." },
|
|
91
|
+
{ key: "why", label: "Why this option?", prompt: "Why is this option like that?" },
|
|
92
|
+
{ key: "tradeoffs", label: "Tradeoffs", prompt: "What are the tradeoffs of this option?" },
|
|
93
|
+
{ key: "fail", label: "When would this fail?", prompt: "When would this option fail or be the wrong choice?" },
|
|
94
|
+
];
|
|
79
95
|
|
|
80
96
|
function updateCountdownBadge(secondsLeft, totalSeconds) {
|
|
81
97
|
if (!countdownBadge || !countdownValue || !countdownRingProgress) return;
|
|
@@ -375,77 +391,1206 @@
|
|
|
375
391
|
return String(value).replace(/["\\]/g, "\\$&");
|
|
376
392
|
}
|
|
377
393
|
|
|
378
|
-
function setText(el, text) {
|
|
379
|
-
if (!el) return;
|
|
380
|
-
el.textContent = text || "";
|
|
381
|
-
}
|
|
394
|
+
function setText(el, text) {
|
|
395
|
+
if (!el) return;
|
|
396
|
+
el.textContent = text || "";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function escapeHtml(text) {
|
|
400
|
+
return String(text || "")
|
|
401
|
+
.replace(/&/g, "&")
|
|
402
|
+
.replace(/</g, "<")
|
|
403
|
+
.replace(/>/g, ">");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function renderLightMarkdown(text) {
|
|
407
|
+
if (!text) return "";
|
|
408
|
+
let html = escapeHtml(text);
|
|
409
|
+
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
410
|
+
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
411
|
+
html = html.replace(/\n/g, "<br>");
|
|
412
|
+
html = html.replace(/\s(\d+\.)\s/g, "<br>$1 ");
|
|
413
|
+
return html;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isMarkdownLang(lang) {
|
|
417
|
+
if (typeof lang !== "string") return false;
|
|
418
|
+
const normalized = lang.trim().toLowerCase();
|
|
419
|
+
return normalized === "md" || normalized === "markdown";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function renderMarkdownPreviewFallback(markdown) {
|
|
423
|
+
const lines = String(markdown || "").replace(/\r\n?/g, "\n").split("\n");
|
|
424
|
+
const html = [];
|
|
425
|
+
const paragraph = [];
|
|
426
|
+
let listType = null;
|
|
427
|
+
let inFence = false;
|
|
428
|
+
let fenceLang = "";
|
|
429
|
+
let fenceLines = [];
|
|
430
|
+
|
|
431
|
+
const flushParagraph = () => {
|
|
432
|
+
if (paragraph.length === 0) return;
|
|
433
|
+
html.push(`<p>${renderLightMarkdown(paragraph.join(" "))}</p>`);
|
|
434
|
+
paragraph.length = 0;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const closeList = () => {
|
|
438
|
+
if (!listType) return;
|
|
439
|
+
html.push(listType === "ol" ? "</ol>" : "</ul>");
|
|
440
|
+
listType = null;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
for (const rawLine of lines) {
|
|
444
|
+
const line = rawLine ?? "";
|
|
445
|
+
|
|
446
|
+
if (inFence) {
|
|
447
|
+
if (/^```/.test(line.trim())) {
|
|
448
|
+
html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
|
|
449
|
+
inFence = false;
|
|
450
|
+
fenceLang = "";
|
|
451
|
+
fenceLines = [];
|
|
452
|
+
} else {
|
|
453
|
+
fenceLines.push(line);
|
|
454
|
+
}
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const fenceStart = line.match(/^```\s*([^\s`]*)\s*$/);
|
|
459
|
+
if (fenceStart) {
|
|
460
|
+
flushParagraph();
|
|
461
|
+
closeList();
|
|
462
|
+
inFence = true;
|
|
463
|
+
fenceLang = fenceStart[1] || "";
|
|
464
|
+
fenceLines = [];
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!line.trim()) {
|
|
469
|
+
flushParagraph();
|
|
470
|
+
closeList();
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const headingMatch = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
|
|
475
|
+
if (headingMatch) {
|
|
476
|
+
flushParagraph();
|
|
477
|
+
closeList();
|
|
478
|
+
const level = headingMatch[1].length;
|
|
479
|
+
html.push(`<h${level}>${renderLightMarkdown(headingMatch[2].trim())}</h${level}>`);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const quoteMatch = line.match(/^>\s?(.*)$/);
|
|
484
|
+
if (quoteMatch) {
|
|
485
|
+
flushParagraph();
|
|
486
|
+
closeList();
|
|
487
|
+
html.push(`<blockquote><p>${renderLightMarkdown(quoteMatch[1])}</p></blockquote>`);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const orderedMatch = line.match(/^\s*\d+\.\s+(.+)$/);
|
|
492
|
+
if (orderedMatch) {
|
|
493
|
+
flushParagraph();
|
|
494
|
+
if (listType !== "ol") {
|
|
495
|
+
closeList();
|
|
496
|
+
html.push("<ol>");
|
|
497
|
+
listType = "ol";
|
|
498
|
+
}
|
|
499
|
+
html.push(`<li>${renderLightMarkdown(orderedMatch[1])}</li>`);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const unorderedMatch = line.match(/^\s*[-*]\s+(.+)$/);
|
|
504
|
+
if (unorderedMatch) {
|
|
505
|
+
flushParagraph();
|
|
506
|
+
if (listType !== "ul") {
|
|
507
|
+
closeList();
|
|
508
|
+
html.push("<ul>");
|
|
509
|
+
listType = "ul";
|
|
510
|
+
}
|
|
511
|
+
html.push(`<li>${renderLightMarkdown(unorderedMatch[1])}</li>`);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
closeList();
|
|
516
|
+
paragraph.push(line.trim());
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (inFence) {
|
|
520
|
+
html.push(`<pre class="markdown-fence"><code${fenceLang ? ` data-lang="${escapeHtml(fenceLang)}"` : ""}>${escapeHtml(fenceLines.join("\n"))}</code></pre>`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
flushParagraph();
|
|
524
|
+
closeList();
|
|
525
|
+
return html.join("\n");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function getOptionLabel(option) {
|
|
529
|
+
return typeof option === "string" ? option : option.label;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function questionCanClarifyOption(question) {
|
|
533
|
+
return (question.type === "single" || question.type === "multi")
|
|
534
|
+
&& Array.isArray(question.options)
|
|
535
|
+
&& question.options.length > 0
|
|
536
|
+
&& question.options.every((option) => typeof option === "string");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function isChoiceResponseValue(value) {
|
|
540
|
+
return value && typeof value === "object" && !Array.isArray(value) && typeof value.option === "string";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function normalizeChoiceResponseValue(value) {
|
|
544
|
+
if (!isChoiceResponseValue(value)) return null;
|
|
545
|
+
const option = value.option.trim();
|
|
546
|
+
if (!option) return null;
|
|
547
|
+
const note = typeof value.note === "string" ? value.note.trim() : "";
|
|
548
|
+
return note ? { option, note } : { option };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function renameChoiceAnswerValue(question, value, previousOption, nextOption) {
|
|
552
|
+
if (!nextOption || (question.type !== "single" && question.type !== "multi")) {
|
|
553
|
+
return value;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (question.type === "single") {
|
|
557
|
+
const choiceValue = normalizeChoiceResponseValue(value);
|
|
558
|
+
if (!choiceValue || choiceValue.option !== previousOption) return value;
|
|
559
|
+
return choiceValue.note ? { option: nextOption, note: choiceValue.note } : { option: nextOption };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (!Array.isArray(value)) return value;
|
|
563
|
+
return value.map((item) => {
|
|
564
|
+
const choiceValue = normalizeChoiceResponseValue(item);
|
|
565
|
+
if (!choiceValue || choiceValue.option !== previousOption) return item;
|
|
566
|
+
return choiceValue.note ? { option: nextOption, note: choiceValue.note } : { option: nextOption };
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function preserveChoiceAnswerValue(question, value, validLabels) {
|
|
571
|
+
if (question.type === "single") {
|
|
572
|
+
const choiceValue = normalizeChoiceResponseValue(value);
|
|
573
|
+
if (!choiceValue || !validLabels.has(choiceValue.option)) return "";
|
|
574
|
+
return choiceValue;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (question.type === "multi") {
|
|
578
|
+
if (!Array.isArray(value)) return [];
|
|
579
|
+
return value
|
|
580
|
+
.map((item) => normalizeChoiceResponseValue(item))
|
|
581
|
+
.filter((item) => item && validLabels.has(item.option));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return value;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function getChoiceNotes(questionId) {
|
|
588
|
+
return choiceNoteState.get(questionId) || new Map();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function getChoiceNote(questionId, optionLabel) {
|
|
592
|
+
return getChoiceNotes(questionId).get(optionLabel) || "";
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function setChoiceNote(questionId, optionLabel, note) {
|
|
596
|
+
const normalizedNote = typeof note === "string" ? note.trim() : "";
|
|
597
|
+
const existing = choiceNoteState.get(questionId) || new Map();
|
|
598
|
+
if (!normalizedNote) {
|
|
599
|
+
existing.delete(optionLabel);
|
|
600
|
+
if (existing.size === 0) {
|
|
601
|
+
choiceNoteState.delete(questionId);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
choiceNoteState.set(questionId, existing);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
existing.set(optionLabel, normalizedNote);
|
|
608
|
+
choiceNoteState.set(questionId, existing);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function clearChoiceNotes(questionId) {
|
|
612
|
+
choiceNoteState.delete(questionId);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function getSelectedOptionLabels(questionId) {
|
|
616
|
+
return Array.from(
|
|
617
|
+
formEl.querySelectorAll(`input[name="${escapeSelector(questionId)}"]:checked`)
|
|
618
|
+
)
|
|
619
|
+
.map((input) => input.value)
|
|
620
|
+
.filter((value) => value && value !== "__other__");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function syncChoiceNotesWithSelection(question) {
|
|
624
|
+
if (!questionCanClarifyOption(question)) return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function isRichOption(option) {
|
|
628
|
+
return typeof option === "object" && option !== null && "label" in option;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function syncRecommendations(question, options) {
|
|
632
|
+
const optionLabels = options.map((option) => getOptionLabel(option));
|
|
633
|
+
if (!question.recommended) return;
|
|
634
|
+
|
|
635
|
+
if (question.type === "single") {
|
|
636
|
+
if (typeof question.recommended === "string" && optionLabels.includes(question.recommended)) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
delete question.recommended;
|
|
640
|
+
delete question.conviction;
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (question.type !== "multi") {
|
|
645
|
+
delete question.recommended;
|
|
646
|
+
delete question.conviction;
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const nextRecommended = (Array.isArray(question.recommended)
|
|
651
|
+
? question.recommended
|
|
652
|
+
: [question.recommended]).filter((option) => optionLabels.includes(option));
|
|
653
|
+
if (nextRecommended.length === 0) {
|
|
654
|
+
delete question.recommended;
|
|
655
|
+
delete question.conviction;
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
question.recommended = nextRecommended;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function makeClientId(prefix = "id") {
|
|
662
|
+
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
|
663
|
+
return `${prefix}-${window.crypto.randomUUID()}`;
|
|
664
|
+
}
|
|
665
|
+
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function questionSupportsOptionInsights(question) {
|
|
669
|
+
return (question.type === "single" || question.type === "multi") &&
|
|
670
|
+
Array.isArray(question.options) &&
|
|
671
|
+
question.options.length > 0;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function questionCanAskAboutOption(question) {
|
|
675
|
+
return !!data.canGenerate && questionSupportsOptionInsights(question);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function normalizeOptionKeysFromData() {
|
|
679
|
+
const raw = data.optionKeysByQuestion && typeof data.optionKeysByQuestion === "object"
|
|
680
|
+
? data.optionKeysByQuestion
|
|
681
|
+
: {};
|
|
682
|
+
|
|
683
|
+
questions.forEach((question) => {
|
|
684
|
+
if (!questionSupportsOptionInsights(question)) return;
|
|
685
|
+
const rawKeys = Array.isArray(raw[question.id]) ? raw[question.id] : [];
|
|
686
|
+
const keys = rawKeys.length === question.options.length && rawKeys.every((key) => typeof key === "string" && key)
|
|
687
|
+
? [...rawKeys]
|
|
688
|
+
: question.options.map(() => makeClientId(`opt-${question.id}`));
|
|
689
|
+
optionKeyState.set(question.id, keys);
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function getOptionKeys(questionId) {
|
|
694
|
+
return optionKeyState.get(questionId) || [];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function setOptionKeys(questionId, keys) {
|
|
698
|
+
optionKeyState.set(questionId, Array.isArray(keys) ? [...keys] : []);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function getOptionIndexByKey(questionId, optionKey) {
|
|
702
|
+
return getOptionKeys(questionId).indexOf(optionKey);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function getOptionTextByKey(questionId, optionKey) {
|
|
706
|
+
const question = questions.find((q) => q.id === questionId);
|
|
707
|
+
if (!question || !Array.isArray(question.options)) return "";
|
|
708
|
+
const index = getOptionIndexByKey(questionId, optionKey);
|
|
709
|
+
if (index < 0 || index >= question.options.length) return "";
|
|
710
|
+
return getOptionLabel(question.options[index]);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function providerLabel(provider) {
|
|
714
|
+
if (!provider) return "";
|
|
715
|
+
if (provider === "openai") return "OpenAI";
|
|
716
|
+
if (provider === "google") return "Google";
|
|
717
|
+
if (provider === "anthropic") return "Anthropic";
|
|
718
|
+
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function parseModelValue(modelValue) {
|
|
722
|
+
if (typeof modelValue !== "string") return { provider: "", model: "" };
|
|
723
|
+
const slashIndex = modelValue.indexOf("/");
|
|
724
|
+
if (slashIndex <= 0 || slashIndex === modelValue.length - 1) {
|
|
725
|
+
return { provider: "", model: modelValue };
|
|
726
|
+
}
|
|
727
|
+
return {
|
|
728
|
+
provider: modelValue.slice(0, slashIndex),
|
|
729
|
+
model: modelValue.slice(slashIndex + 1),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function getModelsForProvider(provider) {
|
|
734
|
+
return askModels.filter((model) => model.provider === provider);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function getFirstProvider() {
|
|
738
|
+
return askModels[0]?.provider || "";
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function createDefaultActiveInsight(questionId, optionKey) {
|
|
742
|
+
const selectedModel = askModels.some((model) => model.value === defaultAskModel)
|
|
743
|
+
? defaultAskModel
|
|
744
|
+
: (askModels[0]?.value || null);
|
|
745
|
+
const parsed = parseModelValue(selectedModel);
|
|
746
|
+
return {
|
|
747
|
+
questionId,
|
|
748
|
+
optionKey,
|
|
749
|
+
prompt: "",
|
|
750
|
+
selectedChip: null,
|
|
751
|
+
loading: false,
|
|
752
|
+
error: "",
|
|
753
|
+
result: null,
|
|
754
|
+
advancedOpen: false,
|
|
755
|
+
selectedProvider: parsed.provider || getFirstProvider(),
|
|
756
|
+
selectedModel,
|
|
757
|
+
abortController: null,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function getActiveInsight(questionId, optionKey) {
|
|
762
|
+
const active = optionInsightState.active;
|
|
763
|
+
return active && active.questionId === questionId && active.optionKey === optionKey
|
|
764
|
+
? active
|
|
765
|
+
: null;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function getPinnedInsights(questionId, optionKey) {
|
|
769
|
+
const questionInsights = optionInsightState.pinned.get(questionId) || [];
|
|
770
|
+
return questionInsights.filter((insight) => insight.optionKey === optionKey);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function normalizeSavedOptionInsights(input) {
|
|
774
|
+
if (!Array.isArray(input)) return [];
|
|
775
|
+
return input
|
|
776
|
+
.filter((item) => item && typeof item === "object")
|
|
777
|
+
.map((item) => ({
|
|
778
|
+
id: typeof item.id === "string" ? item.id : makeClientId("insight"),
|
|
779
|
+
questionId: typeof item.questionId === "string" ? item.questionId : "",
|
|
780
|
+
optionKey: typeof item.optionKey === "string" ? item.optionKey : "",
|
|
781
|
+
optionText: typeof item.optionText === "string" ? item.optionText : "",
|
|
782
|
+
prompt: typeof item.prompt === "string" ? item.prompt : "",
|
|
783
|
+
summary: typeof item.summary === "string" ? item.summary : "",
|
|
784
|
+
bullets: Array.isArray(item.bullets)
|
|
785
|
+
? item.bullets.filter((bullet) => typeof bullet === "string" && bullet.trim()).map((bullet) => bullet.trim())
|
|
786
|
+
: [],
|
|
787
|
+
suggestedText: typeof item.suggestedText === "string" ? item.suggestedText : undefined,
|
|
788
|
+
modelUsed: typeof item.modelUsed === "string" ? item.modelUsed : item.modelUsed === null ? null : undefined,
|
|
789
|
+
createdAt: typeof item.createdAt === "string" ? item.createdAt : new Date().toISOString(),
|
|
790
|
+
}))
|
|
791
|
+
.filter((item) => item.questionId && item.optionKey && item.summary);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function restoreSavedOptionInsights(input) {
|
|
795
|
+
optionInsightState.pinned.clear();
|
|
796
|
+
normalizeSavedOptionInsights(input).forEach((insight) => {
|
|
797
|
+
const existing = optionInsightState.pinned.get(insight.questionId) || [];
|
|
798
|
+
existing.push(insight);
|
|
799
|
+
optionInsightState.pinned.set(insight.questionId, existing);
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function serializeSavedOptionInsights() {
|
|
804
|
+
return Array.from(optionInsightState.pinned.values())
|
|
805
|
+
.flat()
|
|
806
|
+
.map((insight) => ({
|
|
807
|
+
id: insight.id,
|
|
808
|
+
questionId: insight.questionId,
|
|
809
|
+
optionKey: insight.optionKey,
|
|
810
|
+
optionText: insight.optionText,
|
|
811
|
+
prompt: insight.prompt,
|
|
812
|
+
summary: insight.summary,
|
|
813
|
+
bullets: Array.isArray(insight.bullets) ? [...insight.bullets] : [],
|
|
814
|
+
suggestedText: insight.suggestedText,
|
|
815
|
+
modelUsed: insight.modelUsed ?? null,
|
|
816
|
+
createdAt: insight.createdAt,
|
|
817
|
+
}));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function removePinnedInsight(questionId, insightId) {
|
|
821
|
+
const existing = optionInsightState.pinned.get(questionId) || [];
|
|
822
|
+
const next = existing.filter((insight) => insight.id !== insightId);
|
|
823
|
+
if (next.length > 0) {
|
|
824
|
+
optionInsightState.pinned.set(questionId, next);
|
|
825
|
+
} else {
|
|
826
|
+
optionInsightState.pinned.delete(questionId);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function updatePinnedInsightOptionText(questionId, optionKey, optionText) {
|
|
831
|
+
const existing = optionInsightState.pinned.get(questionId) || [];
|
|
832
|
+
existing.forEach((insight) => {
|
|
833
|
+
if (insight.optionKey === optionKey) {
|
|
834
|
+
insight.optionText = optionText;
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function pruneQuestionOptionInsights(questionId) {
|
|
840
|
+
const validKeys = new Set(getOptionKeys(questionId));
|
|
841
|
+
const existing = optionInsightState.pinned.get(questionId) || [];
|
|
842
|
+
const next = existing.filter((insight) => validKeys.has(insight.optionKey));
|
|
843
|
+
if (next.length > 0) {
|
|
844
|
+
optionInsightState.pinned.set(questionId, next);
|
|
845
|
+
} else {
|
|
846
|
+
optionInsightState.pinned.delete(questionId);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const active = optionInsightState.active;
|
|
850
|
+
if (!active || active.questionId !== questionId || validKeys.has(active.optionKey)) {
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (active.abortController) {
|
|
854
|
+
active.abortController.abort();
|
|
855
|
+
}
|
|
856
|
+
optionInsightState.active = null;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function closeOptionInsightPanel(questionId, optionKey) {
|
|
860
|
+
const active = optionInsightState.active;
|
|
861
|
+
if (!active) return;
|
|
862
|
+
if (questionId && active.questionId !== questionId) return;
|
|
863
|
+
if (optionKey && active.optionKey !== optionKey) return;
|
|
864
|
+
if (active.abortController) {
|
|
865
|
+
active.abortController.abort();
|
|
866
|
+
}
|
|
867
|
+
optionInsightState.active = null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function openOptionInsightPanel(question, optionKey) {
|
|
871
|
+
if (!questionCanAskAboutOption(question)) return;
|
|
872
|
+
const currentValue = getQuestionValue(question);
|
|
873
|
+
const active = getActiveInsight(question.id, optionKey);
|
|
874
|
+
if (active) {
|
|
875
|
+
closeOptionInsightPanel(question.id, optionKey);
|
|
876
|
+
replaceQuestionOptionList(question, currentValue, optionKey);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const previousActive = optionInsightState.active;
|
|
881
|
+
if (previousActive?.abortController) {
|
|
882
|
+
previousActive.abortController.abort();
|
|
883
|
+
}
|
|
884
|
+
if (previousActive && (previousActive.questionId !== question.id || previousActive.optionKey !== optionKey)) {
|
|
885
|
+
const previousQuestion = questions.find((item) => item.id === previousActive.questionId);
|
|
886
|
+
if (previousQuestion) {
|
|
887
|
+
const previousValue = getQuestionValue(previousQuestion);
|
|
888
|
+
optionInsightState.active = null;
|
|
889
|
+
replaceQuestionOptionList(previousQuestion, previousValue, previousActive.optionKey);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
optionInsightState.active = createDefaultActiveInsight(question.id, optionKey);
|
|
894
|
+
replaceQuestionOptionList(question, currentValue, optionKey, { focusComposer: true });
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function getSelectedInsightModel(activeInsight) {
|
|
898
|
+
if (!activeInsight) return null;
|
|
899
|
+
return typeof activeInsight.selectedModel === "string" && activeInsight.selectedModel
|
|
900
|
+
? activeInsight.selectedModel
|
|
901
|
+
: defaultAskModel;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function getInsightModelLabel(activeInsight) {
|
|
905
|
+
const selectedModel = getSelectedInsightModel(activeInsight);
|
|
906
|
+
if (!selectedModel) return "No model selected";
|
|
907
|
+
const parsed = parseModelValue(selectedModel);
|
|
908
|
+
return `${providerLabel(parsed.provider)} / ${parsed.model}`;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function applyQuestionValue(question, value) {
|
|
912
|
+
populateForm({ [question.id]: value }, { preserveChoiceNotes: true });
|
|
913
|
+
if (question.type === "multi") {
|
|
914
|
+
updateDoneState(question.id);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function replaceQuestionOptionList(question, preserveValue, focusOptionKey, options = {}) {
|
|
919
|
+
const card = containerEl.querySelector(`.question-card[data-question-id="${escapeSelector(question.id)}"]`);
|
|
920
|
+
const currentList = card?.querySelector('.option-list');
|
|
921
|
+
const title = card?.querySelector('.question-title');
|
|
922
|
+
if (!card || !currentList || !title) return;
|
|
923
|
+
|
|
924
|
+
const nextList = createChoiceQuestionList(question, title, options);
|
|
925
|
+
currentList.replaceWith(nextList);
|
|
926
|
+
applyQuestionValue(question, preserveValue);
|
|
927
|
+
|
|
928
|
+
if (nav.cards[nav.questionIndex] === card && !nav.inSubmitArea && focusOptionKey) {
|
|
929
|
+
const optionIndex = getOptionIndexByKey(question.id, focusOptionKey);
|
|
930
|
+
if (optionIndex >= 0) {
|
|
931
|
+
nav.optionIndex = optionIndex;
|
|
932
|
+
highlightOption(card, optionIndex, false);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (options.focusComposer) {
|
|
937
|
+
requestAnimationFrame(() => {
|
|
938
|
+
const composer = card.querySelector(`.option-insight-input[data-question-id="${escapeSelector(question.id)}"][data-option-key="${escapeSelector(focusOptionKey || "")}"]`);
|
|
939
|
+
composer?.focus();
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async function runOptionAction(question, optionKey, action, text) {
|
|
945
|
+
const preservedValue = getQuestionValue(question);
|
|
946
|
+
const previousText = getOptionTextByKey(question.id, optionKey);
|
|
947
|
+
try {
|
|
948
|
+
const response = await fetch("/option-action", {
|
|
949
|
+
method: "POST",
|
|
950
|
+
headers: { "Content-Type": "application/json" },
|
|
951
|
+
body: JSON.stringify({ token: sessionToken, questionId: question.id, optionKey, action, text }),
|
|
952
|
+
});
|
|
953
|
+
const result = await response.json();
|
|
954
|
+
if (!result.ok) throw new Error(result.error || "Option action failed");
|
|
955
|
+
|
|
956
|
+
if (result.question && Array.isArray(result.question.options)) {
|
|
957
|
+
question.options = result.question.options;
|
|
958
|
+
question.recommended = result.question.recommended;
|
|
959
|
+
question.conviction = result.question.conviction;
|
|
960
|
+
}
|
|
961
|
+
if (Array.isArray(result.optionKeys)) {
|
|
962
|
+
setOptionKeys(question.id, result.optionKeys);
|
|
963
|
+
pruneQuestionOptionInsights(question.id);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if (action === "replace-text") {
|
|
967
|
+
const nextText = getOptionTextByKey(question.id, optionKey);
|
|
968
|
+
updatePinnedInsightOptionText(question.id, optionKey, nextText);
|
|
969
|
+
if (optionInsightState.active && optionInsightState.active.questionId === question.id && optionInsightState.active.optionKey === optionKey && optionInsightState.active.result) {
|
|
970
|
+
optionInsightState.active.result.suggestedText = nextText;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
let nextValue = preservedValue;
|
|
975
|
+
if (action === "replace-text" && text) {
|
|
976
|
+
nextValue = renameChoiceAnswerValue(question, preservedValue, previousText, text);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
replaceQuestionOptionList(question, nextValue, optionKey);
|
|
980
|
+
debounceSave();
|
|
981
|
+
refreshCountdown();
|
|
982
|
+
return true;
|
|
983
|
+
} catch (err) {
|
|
984
|
+
const active = getActiveInsight(question.id, optionKey);
|
|
985
|
+
if (active) {
|
|
986
|
+
active.error = err instanceof Error ? err.message : "Option action failed";
|
|
987
|
+
replaceQuestionOptionList(question, preservedValue, optionKey);
|
|
988
|
+
}
|
|
989
|
+
return false;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function submitOptionInsight(question, optionKey) {
|
|
994
|
+
const active = getActiveInsight(question.id, optionKey);
|
|
995
|
+
if (!active) return;
|
|
996
|
+
const prompt = active.prompt.trim();
|
|
997
|
+
if (!prompt) {
|
|
998
|
+
active.error = "Prompt is required";
|
|
999
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey, { focusComposer: true });
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (active.loading) {
|
|
1004
|
+
active.abortController?.abort();
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
active.loading = true;
|
|
1009
|
+
active.error = "";
|
|
1010
|
+
active.abortController = new AbortController();
|
|
1011
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1012
|
+
|
|
1013
|
+
try {
|
|
1014
|
+
const modelOverride = getSelectedInsightModel(active);
|
|
1015
|
+
const response = await fetch("/option-insight", {
|
|
1016
|
+
method: "POST",
|
|
1017
|
+
headers: { "Content-Type": "application/json" },
|
|
1018
|
+
body: JSON.stringify({
|
|
1019
|
+
token: sessionToken,
|
|
1020
|
+
questionId: question.id,
|
|
1021
|
+
optionKey,
|
|
1022
|
+
prompt,
|
|
1023
|
+
model: modelOverride && modelOverride !== defaultAskModel ? modelOverride : null,
|
|
1024
|
+
}),
|
|
1025
|
+
signal: active.abortController.signal,
|
|
1026
|
+
});
|
|
1027
|
+
const result = await response.json();
|
|
1028
|
+
if (!result.ok) throw new Error(result.error || "Option insight failed");
|
|
1029
|
+
active.result = {
|
|
1030
|
+
summary: result.summary,
|
|
1031
|
+
bullets: Array.isArray(result.bullets) ? result.bullets : [],
|
|
1032
|
+
suggestedText: typeof result.suggestedText === "string" ? result.suggestedText : undefined,
|
|
1033
|
+
modelUsed: typeof result.modelUsed === "string" ? result.modelUsed : null,
|
|
1034
|
+
};
|
|
1035
|
+
active.error = "";
|
|
1036
|
+
const optionText = typeof result.optionText === "string" ? result.optionText : getOptionTextByKey(question.id, optionKey);
|
|
1037
|
+
updatePinnedInsightOptionText(question.id, optionKey, optionText);
|
|
1038
|
+
refreshCountdown();
|
|
1039
|
+
} catch (err) {
|
|
1040
|
+
if (!(err instanceof Error && err.name === "AbortError")) {
|
|
1041
|
+
active.error = err instanceof Error ? err.message : "Option insight failed";
|
|
1042
|
+
}
|
|
1043
|
+
} finally {
|
|
1044
|
+
if (optionInsightState.active && optionInsightState.active.questionId === question.id && optionInsightState.active.optionKey === optionKey) {
|
|
1045
|
+
optionInsightState.active.loading = false;
|
|
1046
|
+
optionInsightState.active.abortController = null;
|
|
1047
|
+
}
|
|
1048
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function pinActiveInsight(question, optionKey) {
|
|
1053
|
+
const active = getActiveInsight(question.id, optionKey);
|
|
1054
|
+
if (!active || !active.result) return;
|
|
1055
|
+
const optionText = getOptionTextByKey(question.id, optionKey);
|
|
1056
|
+
const questionInsights = optionInsightState.pinned.get(question.id) || [];
|
|
1057
|
+
questionInsights.push({
|
|
1058
|
+
id: makeClientId("insight"),
|
|
1059
|
+
questionId: question.id,
|
|
1060
|
+
optionKey,
|
|
1061
|
+
optionText,
|
|
1062
|
+
prompt: active.prompt.trim(),
|
|
1063
|
+
summary: active.result.summary,
|
|
1064
|
+
bullets: Array.isArray(active.result.bullets) ? [...active.result.bullets] : [],
|
|
1065
|
+
suggestedText: active.result.suggestedText,
|
|
1066
|
+
modelUsed: active.result.modelUsed ?? null,
|
|
1067
|
+
createdAt: new Date().toISOString(),
|
|
1068
|
+
});
|
|
1069
|
+
optionInsightState.pinned.set(question.id, questionInsights);
|
|
1070
|
+
debounceSave();
|
|
1071
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function createPinnedInsightCard(question, optionKey, insight) {
|
|
1075
|
+
const card = document.createElement("div");
|
|
1076
|
+
card.className = "option-insight-pinned";
|
|
1077
|
+
|
|
1078
|
+
const head = document.createElement("div");
|
|
1079
|
+
head.className = "option-insight-pinned-head";
|
|
1080
|
+
|
|
1081
|
+
const prompt = document.createElement("div");
|
|
1082
|
+
prompt.className = "option-insight-pinned-prompt";
|
|
1083
|
+
prompt.textContent = insight.prompt;
|
|
1084
|
+
head.appendChild(prompt);
|
|
1085
|
+
|
|
1086
|
+
const unpin = document.createElement("button");
|
|
1087
|
+
unpin.type = "button";
|
|
1088
|
+
unpin.className = "option-insight-unpin";
|
|
1089
|
+
unpin.textContent = "Unpin";
|
|
1090
|
+
unpin.addEventListener("click", (event) => {
|
|
1091
|
+
event.preventDefault();
|
|
1092
|
+
event.stopPropagation();
|
|
1093
|
+
removePinnedInsight(question.id, insight.id);
|
|
1094
|
+
debounceSave();
|
|
1095
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1096
|
+
});
|
|
1097
|
+
head.appendChild(unpin);
|
|
1098
|
+
card.appendChild(head);
|
|
1099
|
+
|
|
1100
|
+
const summary = document.createElement("p");
|
|
1101
|
+
summary.className = "option-insight-summary pinned";
|
|
1102
|
+
summary.textContent = insight.summary;
|
|
1103
|
+
card.appendChild(summary);
|
|
1104
|
+
|
|
1105
|
+
if (Array.isArray(insight.bullets) && insight.bullets.length > 0) {
|
|
1106
|
+
const list = document.createElement("ul");
|
|
1107
|
+
list.className = "option-insight-bullets";
|
|
1108
|
+
insight.bullets.forEach((bullet) => {
|
|
1109
|
+
const item = document.createElement("li");
|
|
1110
|
+
item.textContent = bullet;
|
|
1111
|
+
list.appendChild(item);
|
|
1112
|
+
});
|
|
1113
|
+
card.appendChild(list);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (insight.suggestedText) {
|
|
1117
|
+
const suggestion = document.createElement("code");
|
|
1118
|
+
suggestion.className = "option-insight-suggested-text compact";
|
|
1119
|
+
suggestion.textContent = insight.suggestedText;
|
|
1120
|
+
card.appendChild(suggestion);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (insight.modelUsed) {
|
|
1124
|
+
const meta = document.createElement("div");
|
|
1125
|
+
meta.className = "option-insight-meta";
|
|
1126
|
+
meta.textContent = insight.modelUsed;
|
|
1127
|
+
card.appendChild(meta);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
return card;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function createOptionInsightPanel(question, optionKey) {
|
|
1134
|
+
const active = getActiveInsight(question.id, optionKey);
|
|
1135
|
+
if (!active) return null;
|
|
1136
|
+
|
|
1137
|
+
const panel = document.createElement("div");
|
|
1138
|
+
panel.className = "option-insight-panel";
|
|
1139
|
+
panel.dataset.optionInsightFor = optionKey;
|
|
1140
|
+
|
|
1141
|
+
const chips = document.createElement("div");
|
|
1142
|
+
chips.className = "option-insight-chips";
|
|
1143
|
+
ASK_PROMPT_CHIPS.forEach((chip) => {
|
|
1144
|
+
const btn = document.createElement("button");
|
|
1145
|
+
btn.type = "button";
|
|
1146
|
+
btn.className = "option-insight-chip" + (active.selectedChip === chip.key ? " active" : "");
|
|
1147
|
+
btn.textContent = chip.label;
|
|
1148
|
+
btn.addEventListener("click", (event) => {
|
|
1149
|
+
event.preventDefault();
|
|
1150
|
+
event.stopPropagation();
|
|
1151
|
+
active.selectedChip = chip.key;
|
|
1152
|
+
active.prompt = chip.prompt;
|
|
1153
|
+
active.error = "";
|
|
1154
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey, { focusComposer: true });
|
|
1155
|
+
});
|
|
1156
|
+
chips.appendChild(btn);
|
|
1157
|
+
});
|
|
1158
|
+
panel.appendChild(chips);
|
|
1159
|
+
|
|
1160
|
+
const input = document.createElement("textarea");
|
|
1161
|
+
input.className = "option-insight-input";
|
|
1162
|
+
input.rows = 2;
|
|
1163
|
+
input.placeholder = "Ask why it works, where it fails, or how to rewrite it...";
|
|
1164
|
+
input.dataset.questionId = question.id;
|
|
1165
|
+
input.dataset.optionKey = optionKey;
|
|
1166
|
+
input.value = active.prompt;
|
|
1167
|
+
input.addEventListener("input", () => {
|
|
1168
|
+
active.prompt = input.value;
|
|
1169
|
+
active.selectedChip = null;
|
|
1170
|
+
active.error = "";
|
|
1171
|
+
});
|
|
1172
|
+
input.addEventListener("keydown", (event) => {
|
|
1173
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
1174
|
+
event.preventDefault();
|
|
1175
|
+
submitOptionInsight(question, optionKey);
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
panel.appendChild(input);
|
|
1179
|
+
|
|
1180
|
+
const metaRow = document.createElement("div");
|
|
1181
|
+
metaRow.className = "option-insight-meta-row";
|
|
1182
|
+
|
|
1183
|
+
const model = document.createElement("div");
|
|
1184
|
+
model.className = "option-insight-model";
|
|
1185
|
+
model.textContent = getInsightModelLabel(active);
|
|
1186
|
+
metaRow.appendChild(model);
|
|
1187
|
+
|
|
1188
|
+
const advancedToggle = document.createElement("button");
|
|
1189
|
+
advancedToggle.type = "button";
|
|
1190
|
+
advancedToggle.className = "option-insight-advanced-toggle";
|
|
1191
|
+
advancedToggle.textContent = active.advancedOpen ? "Advanced model settings ▾" : "Advanced model settings ▸";
|
|
1192
|
+
advancedToggle.addEventListener("click", (event) => {
|
|
1193
|
+
event.preventDefault();
|
|
1194
|
+
event.stopPropagation();
|
|
1195
|
+
active.advancedOpen = !active.advancedOpen;
|
|
1196
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1197
|
+
});
|
|
1198
|
+
metaRow.appendChild(advancedToggle);
|
|
1199
|
+
|
|
1200
|
+
panel.appendChild(metaRow);
|
|
1201
|
+
|
|
1202
|
+
if (active.advancedOpen) {
|
|
1203
|
+
const advanced = document.createElement("div");
|
|
1204
|
+
advanced.className = "option-insight-advanced";
|
|
1205
|
+
|
|
1206
|
+
const providerSelect = document.createElement("select");
|
|
1207
|
+
providerSelect.className = "option-insight-select";
|
|
1208
|
+
const providers = [...new Set(askModels.map((model) => model.provider))];
|
|
1209
|
+
providers.forEach((provider) => {
|
|
1210
|
+
const option = document.createElement("option");
|
|
1211
|
+
option.value = provider;
|
|
1212
|
+
option.textContent = providerLabel(provider);
|
|
1213
|
+
providerSelect.appendChild(option);
|
|
1214
|
+
});
|
|
1215
|
+
providerSelect.value = active.selectedProvider || providers[0] || "";
|
|
1216
|
+
providerSelect.addEventListener("change", () => {
|
|
1217
|
+
active.selectedProvider = providerSelect.value;
|
|
1218
|
+
const providerModels = getModelsForProvider(active.selectedProvider);
|
|
1219
|
+
active.selectedModel = providerModels[0]?.value || null;
|
|
1220
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1221
|
+
});
|
|
1222
|
+
advanced.appendChild(providerSelect);
|
|
1223
|
+
|
|
1224
|
+
const modelSelect = document.createElement("select");
|
|
1225
|
+
modelSelect.className = "option-insight-select";
|
|
1226
|
+
const providerModels = getModelsForProvider(active.selectedProvider);
|
|
1227
|
+
providerModels.forEach((modelOption) => {
|
|
1228
|
+
const option = document.createElement("option");
|
|
1229
|
+
option.value = modelOption.value;
|
|
1230
|
+
option.textContent = modelOption.label;
|
|
1231
|
+
modelSelect.appendChild(option);
|
|
1232
|
+
});
|
|
1233
|
+
modelSelect.value = active.selectedModel || providerModels[0]?.value || "";
|
|
1234
|
+
modelSelect.addEventListener("change", () => {
|
|
1235
|
+
active.selectedModel = modelSelect.value;
|
|
1236
|
+
});
|
|
1237
|
+
advanced.appendChild(modelSelect);
|
|
1238
|
+
|
|
1239
|
+
panel.appendChild(advanced);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const actions = document.createElement("div");
|
|
1243
|
+
actions.className = "option-insight-actions";
|
|
1244
|
+
|
|
1245
|
+
const askButton = document.createElement("button");
|
|
1246
|
+
askButton.type = "button";
|
|
1247
|
+
askButton.className = "option-insight-submit" + (active.loading ? " loading" : "");
|
|
1248
|
+
askButton.textContent = active.loading ? "Cancel" : "Ask";
|
|
1249
|
+
askButton.addEventListener("click", (event) => {
|
|
1250
|
+
event.preventDefault();
|
|
1251
|
+
event.stopPropagation();
|
|
1252
|
+
submitOptionInsight(question, optionKey);
|
|
1253
|
+
});
|
|
1254
|
+
actions.appendChild(askButton);
|
|
1255
|
+
|
|
1256
|
+
panel.appendChild(actions);
|
|
1257
|
+
|
|
1258
|
+
if (active.error) {
|
|
1259
|
+
const error = document.createElement("div");
|
|
1260
|
+
error.className = "option-insight-error";
|
|
1261
|
+
error.textContent = active.error;
|
|
1262
|
+
panel.appendChild(error);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (active.result) {
|
|
1266
|
+
const result = document.createElement("div");
|
|
1267
|
+
result.className = "option-insight-result";
|
|
1268
|
+
|
|
1269
|
+
const summary = document.createElement("p");
|
|
1270
|
+
summary.className = "option-insight-summary";
|
|
1271
|
+
summary.textContent = active.result.summary;
|
|
1272
|
+
result.appendChild(summary);
|
|
1273
|
+
|
|
1274
|
+
if (Array.isArray(active.result.bullets) && active.result.bullets.length > 0) {
|
|
1275
|
+
const list = document.createElement("ul");
|
|
1276
|
+
list.className = "option-insight-bullets";
|
|
1277
|
+
active.result.bullets.forEach((bullet) => {
|
|
1278
|
+
const item = document.createElement("li");
|
|
1279
|
+
item.textContent = bullet;
|
|
1280
|
+
list.appendChild(item);
|
|
1281
|
+
});
|
|
1282
|
+
result.appendChild(list);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
if (active.result.suggestedText) {
|
|
1286
|
+
const suggestionLabel = document.createElement("div");
|
|
1287
|
+
suggestionLabel.className = "option-insight-suggestion-label";
|
|
1288
|
+
suggestionLabel.textContent = "Suggested rewrite";
|
|
1289
|
+
result.appendChild(suggestionLabel);
|
|
1290
|
+
|
|
1291
|
+
const suggestion = document.createElement("code");
|
|
1292
|
+
suggestion.className = "option-insight-suggested-text";
|
|
1293
|
+
suggestion.textContent = active.result.suggestedText;
|
|
1294
|
+
result.appendChild(suggestion);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
if (active.result.modelUsed) {
|
|
1298
|
+
const meta = document.createElement("div");
|
|
1299
|
+
meta.className = "option-insight-meta";
|
|
1300
|
+
meta.textContent = active.result.modelUsed;
|
|
1301
|
+
result.appendChild(meta);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const resultActions = document.createElement("div");
|
|
1305
|
+
resultActions.className = "option-insight-result-actions";
|
|
1306
|
+
|
|
1307
|
+
const pinBtn = document.createElement("button");
|
|
1308
|
+
pinBtn.type = "button";
|
|
1309
|
+
pinBtn.className = "option-insight-secondary";
|
|
1310
|
+
pinBtn.textContent = "Pin";
|
|
1311
|
+
pinBtn.addEventListener("click", (event) => {
|
|
1312
|
+
event.preventDefault();
|
|
1313
|
+
event.stopPropagation();
|
|
1314
|
+
pinActiveInsight(question, optionKey);
|
|
1315
|
+
});
|
|
1316
|
+
resultActions.appendChild(pinBtn);
|
|
1317
|
+
|
|
1318
|
+
const moveUpBtn = document.createElement("button");
|
|
1319
|
+
moveUpBtn.type = "button";
|
|
1320
|
+
moveUpBtn.className = "option-insight-secondary";
|
|
1321
|
+
moveUpBtn.textContent = "Move up";
|
|
1322
|
+
moveUpBtn.disabled = getOptionIndexByKey(question.id, optionKey) <= 0;
|
|
1323
|
+
moveUpBtn.addEventListener("click", (event) => {
|
|
1324
|
+
event.preventDefault();
|
|
1325
|
+
event.stopPropagation();
|
|
1326
|
+
runOptionAction(question, optionKey, "move-up");
|
|
1327
|
+
});
|
|
1328
|
+
resultActions.appendChild(moveUpBtn);
|
|
1329
|
+
|
|
1330
|
+
if (active.result.suggestedText) {
|
|
1331
|
+
const replaceBtn = document.createElement("button");
|
|
1332
|
+
replaceBtn.type = "button";
|
|
1333
|
+
replaceBtn.className = "option-insight-primary";
|
|
1334
|
+
replaceBtn.textContent = "Use rewrite";
|
|
1335
|
+
replaceBtn.addEventListener("click", (event) => {
|
|
1336
|
+
event.preventDefault();
|
|
1337
|
+
event.stopPropagation();
|
|
1338
|
+
runOptionAction(question, optionKey, "replace-text", active.result.suggestedText);
|
|
1339
|
+
});
|
|
1340
|
+
resultActions.appendChild(replaceBtn);
|
|
1341
|
+
|
|
1342
|
+
const addBtn = document.createElement("button");
|
|
1343
|
+
addBtn.type = "button";
|
|
1344
|
+
addBtn.className = "option-insight-secondary";
|
|
1345
|
+
addBtn.textContent = "Add rewrite as option";
|
|
1346
|
+
addBtn.addEventListener("click", (event) => {
|
|
1347
|
+
event.preventDefault();
|
|
1348
|
+
event.stopPropagation();
|
|
1349
|
+
runOptionAction(question, optionKey, "add-option", active.result.suggestedText);
|
|
1350
|
+
});
|
|
1351
|
+
resultActions.appendChild(addBtn);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
result.appendChild(resultActions);
|
|
1355
|
+
panel.appendChild(result);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
return panel;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function createOptionNoteInput(question, optionLabel, isSelected) {
|
|
1362
|
+
if (!questionCanClarifyOption(question) || !isSelected) return null;
|
|
1363
|
+
|
|
1364
|
+
const wrap = document.createElement("div");
|
|
1365
|
+
wrap.className = "option-note-wrap";
|
|
1366
|
+
|
|
1367
|
+
const input = document.createElement("input");
|
|
1368
|
+
input.type = "text";
|
|
1369
|
+
input.className = "option-note-input";
|
|
1370
|
+
input.placeholder = "Optional clarification...";
|
|
1371
|
+
input.dataset.questionId = question.id;
|
|
1372
|
+
input.dataset.optionLabel = optionLabel;
|
|
1373
|
+
input.value = getChoiceNote(question.id, optionLabel);
|
|
1374
|
+
input.addEventListener("input", () => {
|
|
1375
|
+
setChoiceNote(question.id, optionLabel, input.value);
|
|
1376
|
+
debounceSave();
|
|
1377
|
+
});
|
|
1378
|
+
setupEdgeNavigation(input);
|
|
1379
|
+
wrap.appendChild(input);
|
|
1380
|
+
|
|
1381
|
+
return wrap;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function createChoiceOptionRow(question, option, optionIndex, options = {}) {
|
|
1385
|
+
const optionLabel = getOptionLabel(option);
|
|
1386
|
+
const optionContent = isRichOption(option) ? option.content : null;
|
|
1387
|
+
const optionKey = getOptionKeys(question.id)[optionIndex] || null;
|
|
1388
|
+
const generatedSet = options.generatedKeys || new Set();
|
|
1389
|
+
const insightable = questionSupportsOptionInsights(question) && !!optionKey;
|
|
1390
|
+
const askable = questionCanAskAboutOption(question) && !!optionKey;
|
|
1391
|
+
const activeInsight = optionKey ? getActiveInsight(question.id, optionKey) : null;
|
|
1392
|
+
|
|
1393
|
+
const row = document.createElement("div");
|
|
1394
|
+
row.className = "option-row";
|
|
1395
|
+
if (generatedSet.has(optionKey)) {
|
|
1396
|
+
row.classList.add("generated");
|
|
1397
|
+
}
|
|
1398
|
+
if (activeInsight) {
|
|
1399
|
+
row.classList.add("ask-open");
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const main = document.createElement("div");
|
|
1403
|
+
main.className = "option-row-main";
|
|
1404
|
+
|
|
1405
|
+
const label = document.createElement("label");
|
|
1406
|
+
label.className = "option-item";
|
|
1407
|
+
if (optionContent) {
|
|
1408
|
+
label.classList.add("has-code");
|
|
1409
|
+
}
|
|
1410
|
+
const input = document.createElement("input");
|
|
1411
|
+
input.type = question.type === "single" ? "radio" : "checkbox";
|
|
1412
|
+
input.name = question.id;
|
|
1413
|
+
input.value = optionLabel;
|
|
1414
|
+
input.id = `q-${question.id}-${optionIndex}`;
|
|
1415
|
+
|
|
1416
|
+
input.addEventListener("change", () => {
|
|
1417
|
+
syncChoiceNotesWithSelection(question);
|
|
1418
|
+
debounceSave();
|
|
1419
|
+
if (question.type === "multi") {
|
|
1420
|
+
updateDoneState(question.id);
|
|
1421
|
+
}
|
|
1422
|
+
replaceQuestionOptionList(question, getQuestionValue(question), optionKey);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
const body = document.createElement("div");
|
|
1426
|
+
body.className = "option-item-body";
|
|
1427
|
+
|
|
1428
|
+
const text = document.createElement("span");
|
|
1429
|
+
text.className = "option-item-label";
|
|
1430
|
+
text.textContent = optionLabel;
|
|
1431
|
+
|
|
1432
|
+
const recommended = question.recommended;
|
|
1433
|
+
const recommendedList = Array.isArray(recommended)
|
|
1434
|
+
? recommended
|
|
1435
|
+
: recommended
|
|
1436
|
+
? [recommended]
|
|
1437
|
+
: [];
|
|
1438
|
+
const shouldPreselect = recommendedList.length > 0 && question.conviction !== "slight";
|
|
1439
|
+
|
|
1440
|
+
if (recommendedList.includes(optionLabel)) {
|
|
1441
|
+
const pill = document.createElement("span");
|
|
1442
|
+
pill.className = "recommended-pill";
|
|
1443
|
+
pill.textContent = "Recommended";
|
|
1444
|
+
text.appendChild(pill);
|
|
1445
|
+
if (shouldPreselect) {
|
|
1446
|
+
input.checked = true;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
body.appendChild(text);
|
|
1451
|
+
|
|
1452
|
+
if (optionContent) {
|
|
1453
|
+
const contentBlockEl = renderContentBlock(optionContent);
|
|
1454
|
+
if (contentBlockEl) {
|
|
1455
|
+
body.appendChild(contentBlockEl);
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
label.appendChild(input);
|
|
1460
|
+
label.appendChild(body);
|
|
1461
|
+
|
|
1462
|
+
main.appendChild(label);
|
|
1463
|
+
const selectedLabels = new Set(getSelectedOptionLabels(question.id));
|
|
1464
|
+
const noteInput = createOptionNoteInput(question, optionLabel, input.checked || selectedLabels.has(optionLabel));
|
|
1465
|
+
|
|
1466
|
+
if (insightable && optionKey) {
|
|
1467
|
+
if (askable) {
|
|
1468
|
+
const askButton = document.createElement("button");
|
|
1469
|
+
askButton.type = "button";
|
|
1470
|
+
askButton.className = "option-ask-btn";
|
|
1471
|
+
askButton.textContent = activeInsight ? "Hide" : "Ask";
|
|
1472
|
+
askButton.addEventListener("click", (event) => {
|
|
1473
|
+
event.preventDefault();
|
|
1474
|
+
event.stopPropagation();
|
|
1475
|
+
openOptionInsightPanel(question, optionKey);
|
|
1476
|
+
});
|
|
1477
|
+
main.appendChild(askButton);
|
|
1478
|
+
|
|
1479
|
+
const panel = createOptionInsightPanel(question, optionKey);
|
|
1480
|
+
row.appendChild(main);
|
|
1481
|
+
if (noteInput) row.appendChild(noteInput);
|
|
1482
|
+
if (panel) row.appendChild(panel);
|
|
1483
|
+
} else {
|
|
1484
|
+
row.appendChild(main);
|
|
1485
|
+
if (noteInput) row.appendChild(noteInput);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const pinnedInsights = getPinnedInsights(question.id, optionKey);
|
|
1489
|
+
if (pinnedInsights.length > 0) {
|
|
1490
|
+
const pinnedWrap = document.createElement("div");
|
|
1491
|
+
pinnedWrap.className = "option-insight-pinned-list";
|
|
1492
|
+
pinnedInsights.forEach((insight) => {
|
|
1493
|
+
pinnedWrap.appendChild(createPinnedInsightCard(question, optionKey, insight));
|
|
1494
|
+
});
|
|
1495
|
+
row.appendChild(pinnedWrap);
|
|
1496
|
+
}
|
|
382
1497
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
let html = text
|
|
386
|
-
.replace(/&/g, "&")
|
|
387
|
-
.replace(/</g, "<")
|
|
388
|
-
.replace(/>/g, ">");
|
|
389
|
-
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
390
|
-
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
391
|
-
html = html.replace(/\n/g, "<br>");
|
|
392
|
-
html = html.replace(/\s(\d+\.)\s/g, "<br>$1 ");
|
|
393
|
-
return html;
|
|
394
|
-
}
|
|
1498
|
+
return row;
|
|
1499
|
+
}
|
|
395
1500
|
|
|
396
|
-
|
|
397
|
-
|
|
1501
|
+
row.appendChild(main);
|
|
1502
|
+
if (noteInput) row.appendChild(noteInput);
|
|
1503
|
+
return row;
|
|
398
1504
|
}
|
|
399
1505
|
|
|
400
|
-
function
|
|
401
|
-
|
|
402
|
-
|
|
1506
|
+
function createChoiceQuestionList(question, title, options = {}) {
|
|
1507
|
+
const list = document.createElement("div");
|
|
1508
|
+
list.className = "option-list";
|
|
1509
|
+
list.setAttribute("role", question.type === "single" ? "radiogroup" : "group");
|
|
1510
|
+
list.setAttribute("aria-labelledby", title.id);
|
|
403
1511
|
|
|
404
|
-
|
|
405
|
-
if (!question.recommended) return;
|
|
1512
|
+
const generatedKeys = new Set(options.generatedKeys || []);
|
|
406
1513
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
1514
|
+
question.options.forEach((option, optionIndex) => {
|
|
1515
|
+
list.appendChild(createChoiceOptionRow(question, option, optionIndex, { generatedKeys }));
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
const generateMoreEl = createGenerateMoreUI(question, list);
|
|
1519
|
+
if (generateMoreEl) list.appendChild(generateMoreEl);
|
|
1520
|
+
|
|
1521
|
+
const otherLabel = document.createElement("label");
|
|
1522
|
+
otherLabel.className = "option-item option-other";
|
|
1523
|
+
const otherCheck = document.createElement("input");
|
|
1524
|
+
otherCheck.type = question.type === "single" ? "radio" : "checkbox";
|
|
1525
|
+
otherCheck.name = question.id;
|
|
1526
|
+
otherCheck.value = "__other__";
|
|
1527
|
+
otherCheck.id = `q-${question.id}-other`;
|
|
1528
|
+
const otherInput = document.createElement("textarea");
|
|
1529
|
+
otherInput.className = "other-input";
|
|
1530
|
+
otherInput.placeholder = "Other...";
|
|
1531
|
+
otherInput.rows = 1;
|
|
1532
|
+
otherInput.dataset.questionId = question.id;
|
|
1533
|
+
const autoResizeOther = () => {
|
|
1534
|
+
otherInput.style.height = "auto";
|
|
1535
|
+
otherInput.style.height = otherInput.scrollHeight + "px";
|
|
1536
|
+
};
|
|
1537
|
+
otherInput.addEventListener("input", () => {
|
|
1538
|
+
autoResizeOther();
|
|
1539
|
+
if (otherInput.value && !otherCheck.checked) {
|
|
1540
|
+
otherCheck.checked = true;
|
|
1541
|
+
if (question.type === "multi") updateDoneState(question.id);
|
|
410
1542
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
1543
|
+
debounceSave();
|
|
1544
|
+
});
|
|
1545
|
+
otherInput.addEventListener("focus", () => {
|
|
1546
|
+
if (!otherCheck.checked) {
|
|
1547
|
+
otherCheck.checked = true;
|
|
1548
|
+
if (question.type === "multi") updateDoneState(question.id);
|
|
1549
|
+
debounceSave();
|
|
1550
|
+
}
|
|
1551
|
+
});
|
|
1552
|
+
otherCheck.addEventListener("change", () => {
|
|
1553
|
+
debounceSave();
|
|
1554
|
+
if (question.type === "multi") updateDoneState(question.id);
|
|
1555
|
+
if (otherCheck.checked) otherInput.focus();
|
|
1556
|
+
});
|
|
1557
|
+
setupEdgeNavigation(otherInput);
|
|
1558
|
+
otherLabel.appendChild(otherCheck);
|
|
1559
|
+
otherLabel.appendChild(otherInput);
|
|
1560
|
+
list.appendChild(otherLabel);
|
|
415
1561
|
|
|
416
|
-
if (question.type
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
1562
|
+
if (question.type === "multi") {
|
|
1563
|
+
const doneItem = document.createElement("div");
|
|
1564
|
+
doneItem.className = "option-item done-item disabled";
|
|
1565
|
+
doneItem.setAttribute("tabindex", "0");
|
|
1566
|
+
doneItem.dataset.doneFor = question.id;
|
|
1567
|
+
doneItem.innerHTML = '<span class="done-check">✓</span><span>Done</span>';
|
|
1568
|
+
doneItem.addEventListener("click", () => {
|
|
1569
|
+
if (!doneItem.classList.contains("disabled")) {
|
|
1570
|
+
nextQuestion();
|
|
1571
|
+
}
|
|
1572
|
+
});
|
|
1573
|
+
doneItem.addEventListener("keydown", (e) => {
|
|
1574
|
+
if ((e.key === "Enter" || e.key === " ") && !doneItem.classList.contains("disabled")) {
|
|
1575
|
+
e.preventDefault();
|
|
1576
|
+
e.stopPropagation();
|
|
1577
|
+
nextQuestion();
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
list.appendChild(doneItem);
|
|
420
1581
|
}
|
|
421
1582
|
|
|
422
|
-
|
|
423
|
-
? question.recommended
|
|
424
|
-
: [question.recommended]).filter((option) => options.includes(option));
|
|
425
|
-
if (nextRecommended.length === 0) {
|
|
426
|
-
delete question.recommended;
|
|
427
|
-
delete question.conviction;
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
question.recommended = nextRecommended;
|
|
1583
|
+
return list;
|
|
431
1584
|
}
|
|
432
1585
|
|
|
433
|
-
function
|
|
434
|
-
if (!block || !block.
|
|
1586
|
+
function renderContentBlock(block) {
|
|
1587
|
+
if (!block || !block.source) return null;
|
|
435
1588
|
|
|
1589
|
+
const markdownPreview = isMarkdownLang(block.lang) && block.showSource !== true;
|
|
436
1590
|
const container = document.createElement("div");
|
|
437
1591
|
container.className = "code-block";
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const isDiff = block.lang === "diff";
|
|
441
|
-
const lines = block.code.split("\n");
|
|
442
|
-
const highlights = new Set(block.highlights || []);
|
|
443
|
-
|
|
444
|
-
// Parse starting line number from lines prop (e.g., "10-16" -> 10, "42" -> 42)
|
|
445
|
-
let startLineNum = 1;
|
|
446
|
-
if (block.lines) {
|
|
447
|
-
const match = block.lines.match(/^(\d+)/);
|
|
448
|
-
if (match) startLineNum = parseInt(match[1], 10);
|
|
1592
|
+
if (markdownPreview) {
|
|
1593
|
+
container.classList.add("markdown-content-block");
|
|
449
1594
|
}
|
|
450
1595
|
|
|
451
1596
|
if (block.file || block.lines || block.lang || block.title) {
|
|
@@ -483,6 +1628,25 @@
|
|
|
483
1628
|
container.appendChild(header);
|
|
484
1629
|
}
|
|
485
1630
|
|
|
1631
|
+
if (markdownPreview) {
|
|
1632
|
+
const preview = document.createElement("div");
|
|
1633
|
+
preview.className = "markdown-preview";
|
|
1634
|
+
preview.innerHTML = renderMarkdownPreviewFallback(block.source);
|
|
1635
|
+
container.appendChild(preview);
|
|
1636
|
+
return container;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const showLineNumbers = !!block.file || !!block.lines;
|
|
1640
|
+
const isDiff = block.lang === "diff";
|
|
1641
|
+
const lines = block.source.split("\n");
|
|
1642
|
+
const highlights = new Set(block.highlights || []);
|
|
1643
|
+
|
|
1644
|
+
let startLineNum = 1;
|
|
1645
|
+
if (block.lines) {
|
|
1646
|
+
const match = block.lines.match(/^(\d+)/);
|
|
1647
|
+
if (match) startLineNum = parseInt(match[1], 10);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
486
1650
|
const pre = document.createElement("pre");
|
|
487
1651
|
const code = document.createElement("code");
|
|
488
1652
|
|
|
@@ -526,7 +1690,7 @@
|
|
|
526
1690
|
|
|
527
1691
|
code.appendChild(linesContainer);
|
|
528
1692
|
} else {
|
|
529
|
-
code.textContent = block.
|
|
1693
|
+
code.textContent = block.source;
|
|
530
1694
|
}
|
|
531
1695
|
|
|
532
1696
|
pre.appendChild(code);
|
|
@@ -1076,8 +2240,14 @@
|
|
|
1076
2240
|
return items;
|
|
1077
2241
|
}
|
|
1078
2242
|
|
|
2243
|
+
function getTabStopsForCard(card) {
|
|
2244
|
+
return Array.from(
|
|
2245
|
+
card.querySelectorAll('input[type="radio"], input[type="checkbox"], .option-note-input, .option-ask-btn, .file-dropzone, .image-path-input, .done-item')
|
|
2246
|
+
);
|
|
2247
|
+
}
|
|
2248
|
+
|
|
1079
2249
|
function isPathInput(el) {
|
|
1080
|
-
return el && (el.classList.contains('image-path-input') || el.classList.contains('attach-inline-path') || el.classList.contains('other-input'));
|
|
2250
|
+
return el && (el.classList.contains('image-path-input') || el.classList.contains('attach-inline-path') || el.classList.contains('other-input') || el.classList.contains('option-note-input'));
|
|
1081
2251
|
}
|
|
1082
2252
|
|
|
1083
2253
|
function isDropzone(el) {
|
|
@@ -1133,7 +2303,7 @@
|
|
|
1133
2303
|
function highlightOption(card, optionIndex, isKeyboard = true) {
|
|
1134
2304
|
const options = getOptionsForCard(card);
|
|
1135
2305
|
options.forEach((opt, i) => {
|
|
1136
|
-
const item = isOptionInput(opt) ? opt.closest('.option-item') : opt;
|
|
2306
|
+
const item = isOptionInput(opt) ? (opt.closest('.option-row') || opt.closest('.option-item')) : opt;
|
|
1137
2307
|
item?.classList.toggle('focused', i === optionIndex);
|
|
1138
2308
|
});
|
|
1139
2309
|
const current = options[optionIndex];
|
|
@@ -1145,8 +2315,31 @@
|
|
|
1145
2315
|
}
|
|
1146
2316
|
}
|
|
1147
2317
|
|
|
2318
|
+
function focusCardTabStop(card, target, isKeyboard = true) {
|
|
2319
|
+
if (!target) return;
|
|
2320
|
+
|
|
2321
|
+
clearOptionHighlight(card);
|
|
2322
|
+
|
|
2323
|
+
const row = target.closest?.('.option-row');
|
|
2324
|
+
const highlightTarget = row || (isOptionInput(target) ? target.closest('.option-item') : target);
|
|
2325
|
+
highlightTarget?.classList.add('focused');
|
|
2326
|
+
|
|
2327
|
+
const rowInput = row?.querySelector('input[type="radio"], input[type="checkbox"]');
|
|
2328
|
+
const options = getOptionsForCard(card);
|
|
2329
|
+
const navTarget = rowInput || target;
|
|
2330
|
+
const nextIndex = options.indexOf(navTarget);
|
|
2331
|
+
if (nextIndex >= 0) {
|
|
2332
|
+
nav.optionIndex = nextIndex;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
target.focus();
|
|
2336
|
+
if (isKeyboard) {
|
|
2337
|
+
card.classList.add('keyboard-nav');
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
|
|
1148
2341
|
function clearOptionHighlight(card) {
|
|
1149
|
-
card.querySelectorAll('.option-item, .done-item, .file-dropzone, .image-path-input').forEach(item => {
|
|
2342
|
+
card.querySelectorAll('.option-row, .option-item, .done-item, .file-dropzone, .image-path-input').forEach(item => {
|
|
1150
2343
|
item.classList.remove('focused');
|
|
1151
2344
|
});
|
|
1152
2345
|
}
|
|
@@ -1258,25 +2451,33 @@
|
|
|
1258
2451
|
const options = getOptionsForCard(card);
|
|
1259
2452
|
const textarea = card.querySelector('textarea');
|
|
1260
2453
|
const isTextFocused = document.activeElement === textarea;
|
|
1261
|
-
|
|
2454
|
+
const inAskArea = document.activeElement?.closest('.option-insight-panel, .option-ask-btn, .option-insight-pinned');
|
|
2455
|
+
const inOptionNote = document.activeElement?.closest('.option-note-wrap');
|
|
2456
|
+
|
|
1262
2457
|
if (event.key === 'Tab') {
|
|
1263
2458
|
const inAttachArea = document.activeElement?.closest('.attach-inline');
|
|
1264
2459
|
const inGenerateArea = document.activeElement?.closest('.generate-more');
|
|
1265
|
-
if (inAttachArea || inGenerateArea) return;
|
|
1266
|
-
|
|
1267
|
-
event.preventDefault();
|
|
2460
|
+
if (inAttachArea || inGenerateArea || inAskArea || inOptionNote) return;
|
|
1268
2461
|
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
} else {
|
|
1273
|
-
nav.optionIndex = (nav.optionIndex + 1) % options.length;
|
|
1274
|
-
}
|
|
1275
|
-
highlightOption(card, nav.optionIndex);
|
|
2462
|
+
const tabStops = getTabStopsForCard(card);
|
|
2463
|
+
if (tabStops.length === 0) {
|
|
2464
|
+
return;
|
|
1276
2465
|
}
|
|
2466
|
+
|
|
2467
|
+
event.preventDefault();
|
|
2468
|
+
|
|
2469
|
+
const activeIndex = tabStops.indexOf(document.activeElement);
|
|
2470
|
+
const fallbackIndex = options[nav.optionIndex] ? tabStops.indexOf(options[nav.optionIndex]) : -1;
|
|
2471
|
+
const currentIndex = activeIndex >= 0 ? activeIndex : (fallbackIndex >= 0 ? fallbackIndex : 0);
|
|
2472
|
+
const nextIndex = event.shiftKey
|
|
2473
|
+
? (currentIndex - 1 + tabStops.length) % tabStops.length
|
|
2474
|
+
: (currentIndex + 1) % tabStops.length;
|
|
2475
|
+
focusCardTabStop(card, tabStops[nextIndex]);
|
|
1277
2476
|
return;
|
|
1278
2477
|
}
|
|
1279
|
-
|
|
2478
|
+
|
|
2479
|
+
if (inAskArea || inOptionNote) return;
|
|
2480
|
+
|
|
1280
2481
|
if (event.key === 'ArrowLeft') {
|
|
1281
2482
|
if (isTextFocused || isPathInput(document.activeElement)) {
|
|
1282
2483
|
return;
|
|
@@ -1311,16 +2512,19 @@
|
|
|
1311
2512
|
}
|
|
1312
2513
|
|
|
1313
2514
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
2515
|
+
if (isPathInput(document.activeElement)) {
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
if (document.activeElement?.closest('.attach-inline')) {
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
if (document.activeElement?.closest('.generate-more')) {
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
if (document.activeElement?.closest('.option-insight-panel, .option-ask-btn')) {
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
event.preventDefault();
|
|
1324
2528
|
const option = options[nav.optionIndex];
|
|
1325
2529
|
if (option) {
|
|
1326
2530
|
if (isDoneItem(option)) {
|
|
@@ -1355,6 +2559,17 @@
|
|
|
1355
2559
|
}
|
|
1356
2560
|
return;
|
|
1357
2561
|
}
|
|
2562
|
+
|
|
2563
|
+
if ((event.key === 'a' || event.key === 'A') && isOptionInput(document.activeElement)) {
|
|
2564
|
+
event.preventDefault();
|
|
2565
|
+
const focusedInput = options[nav.optionIndex];
|
|
2566
|
+
const row = focusedInput?.closest('.option-row');
|
|
2567
|
+
const askButton = row?.querySelector('.option-ask-btn');
|
|
2568
|
+
if (askButton) {
|
|
2569
|
+
askButton.click();
|
|
2570
|
+
}
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
1358
2573
|
}
|
|
1359
2574
|
|
|
1360
2575
|
if (textarea && !isTextFocused) {
|
|
@@ -1423,7 +2638,6 @@
|
|
|
1423
2638
|
|
|
1424
2639
|
function createGenerateMoreUI(question, list) {
|
|
1425
2640
|
if (!data.canGenerate) return null;
|
|
1426
|
-
if (question.options.some(isRichOption)) return null;
|
|
1427
2641
|
|
|
1428
2642
|
const container = document.createElement("div");
|
|
1429
2643
|
container.className = "generate-more";
|
|
@@ -1482,15 +2696,6 @@
|
|
|
1482
2696
|
}, timeoutMs);
|
|
1483
2697
|
}
|
|
1484
2698
|
|
|
1485
|
-
function getExistingOptions() {
|
|
1486
|
-
const inputs = list.querySelectorAll(
|
|
1487
|
-
'input[name="' + escapeSelector(question.id) + '"]'
|
|
1488
|
-
);
|
|
1489
|
-
return Array.from(inputs)
|
|
1490
|
-
.map((input) => input.value)
|
|
1491
|
-
.filter((v) => v && v !== "__other__");
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
2699
|
async function runGenerate(btn, mode) {
|
|
1495
2700
|
if (generating) {
|
|
1496
2701
|
if (abortController) abortController.abort();
|
|
@@ -1507,7 +2712,7 @@
|
|
|
1507
2712
|
clearStatus();
|
|
1508
2713
|
|
|
1509
2714
|
abortController = new AbortController();
|
|
1510
|
-
const
|
|
2715
|
+
const currentValue = getQuestionValue(question);
|
|
1511
2716
|
|
|
1512
2717
|
try {
|
|
1513
2718
|
const response = await fetch("/generate", {
|
|
@@ -1516,7 +2721,6 @@
|
|
|
1516
2721
|
body: JSON.stringify({
|
|
1517
2722
|
token: sessionToken,
|
|
1518
2723
|
questionId: question.id,
|
|
1519
|
-
existingOptions,
|
|
1520
2724
|
mode,
|
|
1521
2725
|
}),
|
|
1522
2726
|
signal: abortController.signal,
|
|
@@ -1524,25 +2728,23 @@
|
|
|
1524
2728
|
|
|
1525
2729
|
const result = await response.json();
|
|
1526
2730
|
if (!result.ok) throw new Error(result.error || "Generation failed");
|
|
1527
|
-
if (!Array.isArray(result.options)
|
|
2731
|
+
if (!Array.isArray(result.options)) {
|
|
2732
|
+
throw new Error("Generation returned invalid options");
|
|
2733
|
+
}
|
|
2734
|
+
if (mode === "review" && result.options.length === 0) {
|
|
1528
2735
|
throw new Error("No options generated");
|
|
1529
2736
|
}
|
|
2737
|
+
if (Array.isArray(result.optionKeys)) {
|
|
2738
|
+
setOptionKeys(question.id, result.optionKeys);
|
|
2739
|
+
pruneQuestionOptionInsights(question.id);
|
|
2740
|
+
}
|
|
1530
2741
|
|
|
1531
2742
|
if (mode === "review") {
|
|
1532
2743
|
if (typeof result.question !== "string" || !result.question.trim()) {
|
|
1533
2744
|
throw new Error("No revised question returned");
|
|
1534
2745
|
}
|
|
1535
2746
|
|
|
1536
|
-
const
|
|
1537
|
-
const revisedOptions = result.options.filter((option) => {
|
|
1538
|
-
const key = option.toLowerCase().trim();
|
|
1539
|
-
if (seen.has(key)) return false;
|
|
1540
|
-
seen.add(key);
|
|
1541
|
-
return true;
|
|
1542
|
-
});
|
|
1543
|
-
if (revisedOptions.length === 0) {
|
|
1544
|
-
throw new Error("No valid options returned for review");
|
|
1545
|
-
}
|
|
2747
|
+
const revisedOptions = result.options;
|
|
1546
2748
|
|
|
1547
2749
|
question.question = result.question.trim();
|
|
1548
2750
|
question.options = revisedOptions;
|
|
@@ -1551,38 +2753,27 @@
|
|
|
1551
2753
|
if (title) {
|
|
1552
2754
|
title.innerHTML = renderLightMarkdown(question.question);
|
|
1553
2755
|
}
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
.forEach((el) => el.remove());
|
|
1558
|
-
revisedOptions.forEach((optionText, i) => {
|
|
1559
|
-
const optionEl = createGeneratedOption(question, optionText, i);
|
|
1560
|
-
list.insertBefore(optionEl, container);
|
|
1561
|
-
});
|
|
1562
|
-
if (question.type === "multi") updateDoneState(question.id);
|
|
2756
|
+
const revisedLabels = new Set(revisedOptions.map((option) => getOptionLabel(option)));
|
|
2757
|
+
const nextValue = preserveChoiceAnswerValue(question, currentValue, revisedLabels);
|
|
2758
|
+
replaceQuestionOptionList(question, nextValue);
|
|
1563
2759
|
debounceSave();
|
|
1564
2760
|
showStatus(
|
|
1565
2761
|
"Question updated and " + revisedOptions.length + " option" + (revisedOptions.length > 1 ? "s" : "") + " revised",
|
|
1566
2762
|
2500,
|
|
1567
2763
|
);
|
|
1568
2764
|
} else {
|
|
1569
|
-
const
|
|
1570
|
-
const seen = new Set();
|
|
1571
|
-
const newOptions = result.options.filter((o) => {
|
|
1572
|
-
const key = o.toLowerCase().trim();
|
|
1573
|
-
if (existingSet.has(key) || seen.has(key)) return false;
|
|
1574
|
-
seen.add(key);
|
|
1575
|
-
return true;
|
|
1576
|
-
});
|
|
2765
|
+
const newOptions = result.options;
|
|
1577
2766
|
|
|
1578
2767
|
if (newOptions.length === 0) {
|
|
1579
2768
|
showStatus("All generated options already exist", 3000);
|
|
1580
2769
|
} else {
|
|
1581
2770
|
question.options = question.options.concat(newOptions);
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
2771
|
+
const optionKeys = getOptionKeys(question.id);
|
|
2772
|
+
const generatedKeys = optionKeys.slice(-newOptions.length);
|
|
2773
|
+
replaceQuestionOptionList(question, currentValue, generatedKeys[0] || null, {
|
|
2774
|
+
generatedKeys,
|
|
1585
2775
|
});
|
|
2776
|
+
debounceSave();
|
|
1586
2777
|
showStatus(
|
|
1587
2778
|
newOptions.length + " option" + (newOptions.length > 1 ? "s" : "") + " added",
|
|
1588
2779
|
2500,
|
|
@@ -1612,33 +2803,6 @@
|
|
|
1612
2803
|
return container;
|
|
1613
2804
|
}
|
|
1614
2805
|
|
|
1615
|
-
function createGeneratedOption(question, optionText, animIndex) {
|
|
1616
|
-
const label = document.createElement("label");
|
|
1617
|
-
label.className = "option-item generated";
|
|
1618
|
-
label.style.animationDelay = (animIndex * 0.08) + "s";
|
|
1619
|
-
|
|
1620
|
-
const input = document.createElement("input");
|
|
1621
|
-
input.type = question.type === "single" ? "radio" : "checkbox";
|
|
1622
|
-
input.name = question.id;
|
|
1623
|
-
input.value = optionText;
|
|
1624
|
-
input.setAttribute("tabindex", "-1");
|
|
1625
|
-
|
|
1626
|
-
input.addEventListener("change", () => {
|
|
1627
|
-
debounceSave();
|
|
1628
|
-
if (question.type === "multi") {
|
|
1629
|
-
updateDoneState(question.id);
|
|
1630
|
-
}
|
|
1631
|
-
});
|
|
1632
|
-
|
|
1633
|
-
const text = document.createElement("span");
|
|
1634
|
-
text.textContent = optionText;
|
|
1635
|
-
|
|
1636
|
-
label.appendChild(input);
|
|
1637
|
-
label.appendChild(text);
|
|
1638
|
-
|
|
1639
|
-
return label;
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
2806
|
function createQuestionCard(question, index, badgeNumber) {
|
|
1643
2807
|
const card = document.createElement("section");
|
|
1644
2808
|
card.className = "question-card";
|
|
@@ -1676,11 +2840,11 @@
|
|
|
1676
2840
|
card.appendChild(context);
|
|
1677
2841
|
}
|
|
1678
2842
|
|
|
1679
|
-
if (question.
|
|
1680
|
-
const
|
|
1681
|
-
if (
|
|
1682
|
-
|
|
1683
|
-
card.appendChild(
|
|
2843
|
+
if (question.content) {
|
|
2844
|
+
const contentBlockEl = renderContentBlock(question.content);
|
|
2845
|
+
if (contentBlockEl) {
|
|
2846
|
+
contentBlockEl.classList.add("question-code-block");
|
|
2847
|
+
card.appendChild(contentBlockEl);
|
|
1684
2848
|
}
|
|
1685
2849
|
}
|
|
1686
2850
|
|
|
@@ -1711,135 +2875,7 @@
|
|
|
1711
2875
|
}
|
|
1712
2876
|
|
|
1713
2877
|
if (question.type === "single" || question.type === "multi") {
|
|
1714
|
-
|
|
1715
|
-
list.className = "option-list";
|
|
1716
|
-
list.setAttribute("role", question.type === "single" ? "radiogroup" : "group");
|
|
1717
|
-
list.setAttribute("aria-labelledby", title.id);
|
|
1718
|
-
|
|
1719
|
-
const recommended = question.recommended;
|
|
1720
|
-
const recommendedList = Array.isArray(recommended)
|
|
1721
|
-
? recommended
|
|
1722
|
-
: recommended
|
|
1723
|
-
? [recommended]
|
|
1724
|
-
: [];
|
|
1725
|
-
const shouldPreselect = recommendedList.length > 0 && question.conviction !== "slight";
|
|
1726
|
-
|
|
1727
|
-
question.options.forEach((option, optionIndex) => {
|
|
1728
|
-
const optionLabel = getOptionLabel(option);
|
|
1729
|
-
const optionCode = isRichOption(option) ? option.code : null;
|
|
1730
|
-
|
|
1731
|
-
const label = document.createElement("label");
|
|
1732
|
-
label.className = "option-item";
|
|
1733
|
-
if (optionCode) {
|
|
1734
|
-
label.classList.add("has-code");
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
|
-
const input = document.createElement("input");
|
|
1738
|
-
input.type = question.type === "single" ? "radio" : "checkbox";
|
|
1739
|
-
input.name = question.id;
|
|
1740
|
-
input.value = optionLabel;
|
|
1741
|
-
input.id = `q-${question.id}-${optionIndex}`;
|
|
1742
|
-
|
|
1743
|
-
input.addEventListener("change", () => {
|
|
1744
|
-
debounceSave();
|
|
1745
|
-
if (question.type === "multi") {
|
|
1746
|
-
updateDoneState(question.id);
|
|
1747
|
-
}
|
|
1748
|
-
});
|
|
1749
|
-
|
|
1750
|
-
const text = document.createElement("span");
|
|
1751
|
-
text.textContent = optionLabel;
|
|
1752
|
-
|
|
1753
|
-
if (recommendedList.includes(optionLabel)) {
|
|
1754
|
-
const pill = document.createElement("span");
|
|
1755
|
-
pill.className = "recommended-pill";
|
|
1756
|
-
pill.textContent = "Recommended";
|
|
1757
|
-
text.appendChild(pill);
|
|
1758
|
-
|
|
1759
|
-
if (shouldPreselect) {
|
|
1760
|
-
input.checked = true;
|
|
1761
|
-
}
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
label.appendChild(input);
|
|
1765
|
-
label.appendChild(text);
|
|
1766
|
-
|
|
1767
|
-
if (optionCode) {
|
|
1768
|
-
const codeBlockEl = renderCodeBlock(optionCode);
|
|
1769
|
-
if (codeBlockEl) {
|
|
1770
|
-
label.appendChild(codeBlockEl);
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
list.appendChild(label);
|
|
1775
|
-
});
|
|
1776
|
-
|
|
1777
|
-
const generateMoreEl = createGenerateMoreUI(question, list);
|
|
1778
|
-
if (generateMoreEl) list.appendChild(generateMoreEl);
|
|
1779
|
-
|
|
1780
|
-
const otherLabel = document.createElement("label");
|
|
1781
|
-
otherLabel.className = "option-item option-other";
|
|
1782
|
-
const otherCheck = document.createElement("input");
|
|
1783
|
-
otherCheck.type = question.type === "single" ? "radio" : "checkbox";
|
|
1784
|
-
otherCheck.name = question.id;
|
|
1785
|
-
otherCheck.value = "__other__";
|
|
1786
|
-
otherCheck.id = `q-${question.id}-other`;
|
|
1787
|
-
const otherInput = document.createElement("textarea");
|
|
1788
|
-
otherInput.className = "other-input";
|
|
1789
|
-
otherInput.placeholder = "Other...";
|
|
1790
|
-
otherInput.rows = 1;
|
|
1791
|
-
otherInput.dataset.questionId = question.id;
|
|
1792
|
-
const autoResizeOther = () => {
|
|
1793
|
-
otherInput.style.height = "auto";
|
|
1794
|
-
otherInput.style.height = otherInput.scrollHeight + "px";
|
|
1795
|
-
};
|
|
1796
|
-
otherInput.addEventListener("input", () => {
|
|
1797
|
-
autoResizeOther();
|
|
1798
|
-
if (otherInput.value && !otherCheck.checked) {
|
|
1799
|
-
otherCheck.checked = true;
|
|
1800
|
-
if (question.type === "multi") updateDoneState(question.id);
|
|
1801
|
-
}
|
|
1802
|
-
debounceSave();
|
|
1803
|
-
});
|
|
1804
|
-
otherInput.addEventListener("focus", () => {
|
|
1805
|
-
if (!otherCheck.checked) {
|
|
1806
|
-
otherCheck.checked = true;
|
|
1807
|
-
if (question.type === "multi") updateDoneState(question.id);
|
|
1808
|
-
debounceSave();
|
|
1809
|
-
}
|
|
1810
|
-
});
|
|
1811
|
-
otherCheck.addEventListener("change", () => {
|
|
1812
|
-
debounceSave();
|
|
1813
|
-
if (question.type === "multi") updateDoneState(question.id);
|
|
1814
|
-
if (otherCheck.checked) otherInput.focus();
|
|
1815
|
-
});
|
|
1816
|
-
setupEdgeNavigation(otherInput);
|
|
1817
|
-
otherLabel.appendChild(otherCheck);
|
|
1818
|
-
otherLabel.appendChild(otherInput);
|
|
1819
|
-
list.appendChild(otherLabel);
|
|
1820
|
-
|
|
1821
|
-
if (question.type === "multi") {
|
|
1822
|
-
const doneItem = document.createElement("div");
|
|
1823
|
-
doneItem.className = "option-item done-item disabled";
|
|
1824
|
-
doneItem.setAttribute("tabindex", "0");
|
|
1825
|
-
doneItem.dataset.doneFor = question.id;
|
|
1826
|
-
doneItem.innerHTML = '<span class="done-check">✓</span><span>Done</span>';
|
|
1827
|
-
doneItem.addEventListener("click", () => {
|
|
1828
|
-
if (!doneItem.classList.contains("disabled")) {
|
|
1829
|
-
nextQuestion();
|
|
1830
|
-
}
|
|
1831
|
-
});
|
|
1832
|
-
doneItem.addEventListener("keydown", (e) => {
|
|
1833
|
-
if ((e.key === "Enter" || e.key === " ") && !doneItem.classList.contains("disabled")) {
|
|
1834
|
-
e.preventDefault();
|
|
1835
|
-
e.stopPropagation();
|
|
1836
|
-
nextQuestion();
|
|
1837
|
-
}
|
|
1838
|
-
});
|
|
1839
|
-
list.appendChild(doneItem);
|
|
1840
|
-
}
|
|
1841
|
-
|
|
1842
|
-
card.appendChild(list);
|
|
2878
|
+
card.appendChild(createChoiceQuestionList(question, title));
|
|
1843
2879
|
}
|
|
1844
2880
|
|
|
1845
2881
|
if (question.type === "text") {
|
|
@@ -2069,17 +3105,7 @@
|
|
|
2069
3105
|
if (files && files.length > 0) {
|
|
2070
3106
|
const file = files[0];
|
|
2071
3107
|
if (!file.type.startsWith("image/")) return;
|
|
2072
|
-
|
|
2073
|
-
const input = card.querySelector('input[type="file"]');
|
|
2074
|
-
if (input) {
|
|
2075
|
-
const dt = new DataTransfer();
|
|
2076
|
-
dt.items.add(file);
|
|
2077
|
-
input.files = dt.files;
|
|
2078
|
-
input.dispatchEvent(new Event("change"));
|
|
2079
|
-
}
|
|
2080
|
-
} else {
|
|
2081
|
-
void addPastedImage(question, file);
|
|
2082
|
-
}
|
|
3108
|
+
void addDroppedImage(question, file);
|
|
2083
3109
|
}
|
|
2084
3110
|
});
|
|
2085
3111
|
|
|
@@ -2179,8 +3205,9 @@
|
|
|
2179
3205
|
input.value = "";
|
|
2180
3206
|
return;
|
|
2181
3207
|
}
|
|
2182
|
-
} catch (
|
|
2183
|
-
|
|
3208
|
+
} catch (err) {
|
|
3209
|
+
const message = err instanceof Error ? err.message : "Failed to validate image.";
|
|
3210
|
+
setFieldError(questionId, message);
|
|
2184
3211
|
input.value = "";
|
|
2185
3212
|
return;
|
|
2186
3213
|
}
|
|
@@ -2188,37 +3215,15 @@
|
|
|
2188
3215
|
manager.addFile(questionId, file);
|
|
2189
3216
|
}
|
|
2190
3217
|
|
|
2191
|
-
function resolveQuestionContext(target) {
|
|
2192
|
-
const element = target && target.closest ? target : null;
|
|
2193
|
-
let card = element ? element.closest(".question-card") : null;
|
|
2194
|
-
|
|
2195
|
-
if (!card) {
|
|
2196
|
-
card = document.querySelector(".question-card.active");
|
|
2197
|
-
}
|
|
2198
|
-
|
|
2199
|
-
if (card?.dataset?.questionId) {
|
|
2200
|
-
const question = questions.find((q) => q.id === card.dataset.questionId);
|
|
2201
|
-
if (question) {
|
|
2202
|
-
return { question, card };
|
|
2203
|
-
}
|
|
2204
|
-
}
|
|
2205
|
-
|
|
2206
|
-
const question = questions[nav.questionIndex];
|
|
2207
|
-
const fallbackCard = nav.cards[nav.questionIndex];
|
|
2208
|
-
if (!question || !fallbackCard) return null;
|
|
2209
|
-
return { question, card: fallbackCard };
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
3218
|
function revealAttachmentArea(questionId) {
|
|
2213
3219
|
const attachInline = document.querySelector(
|
|
2214
3220
|
`[data-attach-inline-for="${escapeSelector(questionId)}"]`
|
|
2215
3221
|
);
|
|
2216
|
-
if (attachInline?.classList.contains("hidden"))
|
|
2217
|
-
|
|
2218
|
-
}
|
|
3222
|
+
if (!attachInline?.classList.contains("hidden")) return;
|
|
3223
|
+
attachInline.classList.remove("hidden");
|
|
2219
3224
|
}
|
|
2220
3225
|
|
|
2221
|
-
async function
|
|
3226
|
+
async function addDroppedImage(question, file) {
|
|
2222
3227
|
if (countUploadedFiles(question.id) + 1 > MAX_IMAGES) {
|
|
2223
3228
|
setFieldError(question.id, `Only ${MAX_IMAGES} images allowed.`);
|
|
2224
3229
|
return;
|
|
@@ -2230,59 +3235,20 @@
|
|
|
2230
3235
|
setFieldError(question.id, validation.error);
|
|
2231
3236
|
return;
|
|
2232
3237
|
}
|
|
2233
|
-
} catch (
|
|
2234
|
-
|
|
3238
|
+
} catch (err) {
|
|
3239
|
+
const message = err instanceof Error ? err.message : "Failed to validate image.";
|
|
3240
|
+
setFieldError(question.id, message);
|
|
2235
3241
|
return;
|
|
2236
3242
|
}
|
|
2237
3243
|
|
|
2238
3244
|
setFieldError(question.id, "");
|
|
2239
3245
|
if (question.type === "image") {
|
|
2240
3246
|
questionImages.addFile(question.id, file);
|
|
2241
|
-
} else {
|
|
2242
|
-
revealAttachmentArea(question.id);
|
|
2243
|
-
attachments.addFile(question.id, file);
|
|
2244
|
-
}
|
|
2245
|
-
}
|
|
2246
|
-
|
|
2247
|
-
function handlePaste(event) {
|
|
2248
|
-
if (nav.inSubmitArea || session.expired) return;
|
|
2249
|
-
const clipboard = event.clipboardData;
|
|
2250
|
-
if (!clipboard) return;
|
|
2251
|
-
|
|
2252
|
-
const active = document.activeElement;
|
|
2253
|
-
const isTextInput = active && (active.tagName === "TEXTAREA" || (active.tagName === "INPUT" && active.type === "text"));
|
|
2254
|
-
if (isTextInput && clipboard.getData("text/plain")) {
|
|
2255
|
-
return;
|
|
2256
|
-
}
|
|
2257
|
-
|
|
2258
|
-
const context = resolveQuestionContext(event.target);
|
|
2259
|
-
if (!context) return;
|
|
2260
|
-
|
|
2261
|
-
const items = Array.from(clipboard.items || []);
|
|
2262
|
-
const imageItem = items.find((item) => item.type && item.type.startsWith("image/"));
|
|
2263
|
-
|
|
2264
|
-
if (imageItem) {
|
|
2265
|
-
const file = imageItem.getAsFile();
|
|
2266
|
-
if (!file) return;
|
|
2267
|
-
event.preventDefault();
|
|
2268
|
-
void addPastedImage(context.question, file);
|
|
2269
3247
|
return;
|
|
2270
3248
|
}
|
|
2271
3249
|
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
const hasImageExtension = text && /\.(png|jpe?g|gif|webp)$/i.test(text);
|
|
2275
|
-
|
|
2276
|
-
if (isPathLike && hasImageExtension) {
|
|
2277
|
-
event.preventDefault();
|
|
2278
|
-
const normalizedPath = normalizePath(text);
|
|
2279
|
-
if (context.question.type === "image") {
|
|
2280
|
-
questionImages.addPath(context.question.id, normalizedPath);
|
|
2281
|
-
} else {
|
|
2282
|
-
revealAttachmentArea(context.question.id);
|
|
2283
|
-
attachments.addPath(context.question.id, normalizedPath);
|
|
2284
|
-
}
|
|
2285
|
-
}
|
|
3250
|
+
revealAttachmentArea(question.id);
|
|
3251
|
+
attachments.addFile(question.id, file);
|
|
2286
3252
|
}
|
|
2287
3253
|
|
|
2288
3254
|
function countUploadedFiles(excludingId) {
|
|
@@ -2306,13 +3272,24 @@
|
|
|
2306
3272
|
if (question.type === "single") {
|
|
2307
3273
|
const selected = formEl.querySelector(`input[name="${escapeSelector(id)}"]:checked`);
|
|
2308
3274
|
if (!selected) return "";
|
|
2309
|
-
if (selected.value === "__other__")
|
|
2310
|
-
|
|
3275
|
+
if (selected.value === "__other__") {
|
|
3276
|
+
const otherValue = getOtherValue(id).trim();
|
|
3277
|
+
return otherValue ? { option: otherValue } : "";
|
|
3278
|
+
}
|
|
3279
|
+
const note = questionCanClarifyOption(question) ? getChoiceNote(id, selected.value) : "";
|
|
3280
|
+
return note ? { option: selected.value, note } : { option: selected.value };
|
|
2311
3281
|
}
|
|
2312
3282
|
if (question.type === "multi") {
|
|
2313
3283
|
return Array.from(
|
|
2314
3284
|
formEl.querySelectorAll(`input[name="${escapeSelector(id)}"]:checked`)
|
|
2315
|
-
).map((input) =>
|
|
3285
|
+
).map((input) => {
|
|
3286
|
+
if (input.value === "__other__") {
|
|
3287
|
+
const otherValue = getOtherValue(id).trim();
|
|
3288
|
+
return otherValue ? { option: otherValue } : null;
|
|
3289
|
+
}
|
|
3290
|
+
const note = questionCanClarifyOption(question) ? getChoiceNote(id, input.value) : "";
|
|
3291
|
+
return note ? { option: input.value, note } : { option: input.value };
|
|
3292
|
+
}).filter((value) => value && value.option);
|
|
2316
3293
|
}
|
|
2317
3294
|
if (question.type === "text") {
|
|
2318
3295
|
const textarea = formEl.querySelector(`textarea[data-question-id="${escapeSelector(id)}"]`);
|
|
@@ -2338,31 +3315,54 @@
|
|
|
2338
3315
|
}
|
|
2339
3316
|
|
|
2340
3317
|
function collectPersistedData() {
|
|
2341
|
-
const
|
|
3318
|
+
const answers = {};
|
|
2342
3319
|
questions.forEach((question) => {
|
|
2343
3320
|
if (question.type === "info" || question.type === "image") return;
|
|
2344
|
-
|
|
3321
|
+
answers[question.id] = getQuestionValue(question);
|
|
2345
3322
|
});
|
|
2346
|
-
return
|
|
3323
|
+
return {
|
|
3324
|
+
answers,
|
|
3325
|
+
savedOptionInsights: serializeSavedOptionInsights(),
|
|
3326
|
+
};
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
function getSavedSingleChoiceValue(value) {
|
|
3330
|
+
return normalizeChoiceResponseValue(value);
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
function getSavedMultiChoiceValues(value) {
|
|
3334
|
+
if (!Array.isArray(value)) return [];
|
|
3335
|
+
return value.map((item) => normalizeChoiceResponseValue(item)).filter(Boolean);
|
|
2347
3336
|
}
|
|
2348
3337
|
|
|
2349
|
-
function populateForm(saved) {
|
|
3338
|
+
function populateForm(saved, options = {}) {
|
|
3339
|
+
const { preserveChoiceNotes = false } = options;
|
|
2350
3340
|
if (!saved) return;
|
|
2351
3341
|
questions.forEach((question) => {
|
|
3342
|
+
const hasSavedValue = Object.prototype.hasOwnProperty.call(saved, question.id);
|
|
2352
3343
|
const value = saved[question.id];
|
|
2353
|
-
if (question.type === "single"
|
|
3344
|
+
if (question.type === "single") {
|
|
2354
3345
|
const radios = formEl.querySelectorAll(
|
|
2355
3346
|
`input[name="${escapeSelector(question.id)}"]`
|
|
2356
3347
|
);
|
|
2357
3348
|
radios.forEach((radio) => {
|
|
2358
3349
|
radio.checked = false;
|
|
2359
3350
|
});
|
|
2360
|
-
if (
|
|
3351
|
+
if (!preserveChoiceNotes) {
|
|
3352
|
+
clearChoiceNotes(question.id);
|
|
3353
|
+
}
|
|
3354
|
+
if (!hasSavedValue) return;
|
|
3355
|
+
const choiceValue = getSavedSingleChoiceValue(value);
|
|
3356
|
+
if (!choiceValue) return;
|
|
3357
|
+
if (choiceValue.option !== "") {
|
|
2361
3358
|
const input = formEl.querySelector(
|
|
2362
|
-
`input[name="${escapeSelector(question.id)}"][value="${escapeSelector(
|
|
3359
|
+
`input[name="${escapeSelector(question.id)}"][value="${escapeSelector(choiceValue.option)}"]`
|
|
2363
3360
|
);
|
|
2364
3361
|
if (input) {
|
|
2365
3362
|
input.checked = true;
|
|
3363
|
+
if (questionCanClarifyOption(question) && choiceValue.note) {
|
|
3364
|
+
setChoiceNote(question.id, choiceValue.option, choiceValue.note);
|
|
3365
|
+
}
|
|
2366
3366
|
} else {
|
|
2367
3367
|
const otherCheck = formEl.querySelector(
|
|
2368
3368
|
`input[name="${escapeSelector(question.id)}"][value="__other__"]`
|
|
@@ -2372,28 +3372,36 @@
|
|
|
2372
3372
|
);
|
|
2373
3373
|
if (otherCheck && otherInput) {
|
|
2374
3374
|
otherCheck.checked = true;
|
|
2375
|
-
otherInput.value =
|
|
3375
|
+
otherInput.value = choiceValue.option;
|
|
2376
3376
|
otherInput.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2377
3377
|
}
|
|
2378
3378
|
}
|
|
2379
3379
|
}
|
|
2380
3380
|
}
|
|
2381
|
-
if (question.type === "multi"
|
|
3381
|
+
if (question.type === "multi") {
|
|
2382
3382
|
const checkboxes = formEl.querySelectorAll(
|
|
2383
3383
|
`input[name="${escapeSelector(question.id)}"]`
|
|
2384
3384
|
);
|
|
2385
3385
|
checkboxes.forEach((checkbox) => {
|
|
2386
3386
|
checkbox.checked = false;
|
|
2387
3387
|
});
|
|
3388
|
+
if (!preserveChoiceNotes) {
|
|
3389
|
+
clearChoiceNotes(question.id);
|
|
3390
|
+
}
|
|
3391
|
+
if (!hasSavedValue) return;
|
|
3392
|
+
const choiceValues = getSavedMultiChoiceValues(value);
|
|
2388
3393
|
let otherValue = "";
|
|
2389
|
-
|
|
3394
|
+
choiceValues.forEach((choiceValue) => {
|
|
2390
3395
|
const input = formEl.querySelector(
|
|
2391
|
-
`input[name="${escapeSelector(question.id)}"][value="${escapeSelector(
|
|
3396
|
+
`input[name="${escapeSelector(question.id)}"][value="${escapeSelector(choiceValue.option)}"]`
|
|
2392
3397
|
);
|
|
2393
3398
|
if (input) {
|
|
2394
3399
|
input.checked = true;
|
|
2395
|
-
|
|
2396
|
-
|
|
3400
|
+
if (questionCanClarifyOption(question) && choiceValue.note) {
|
|
3401
|
+
setChoiceNote(question.id, choiceValue.option, choiceValue.note);
|
|
3402
|
+
}
|
|
3403
|
+
} else if (choiceValue.option) {
|
|
3404
|
+
otherValue = choiceValue.option;
|
|
2397
3405
|
}
|
|
2398
3406
|
});
|
|
2399
3407
|
if (otherValue) {
|
|
@@ -2429,16 +3437,29 @@
|
|
|
2429
3437
|
}
|
|
2430
3438
|
}
|
|
2431
3439
|
|
|
3440
|
+
function rerenderChoiceQuestions() {
|
|
3441
|
+
questions.forEach((question) => {
|
|
3442
|
+
if (question.type !== "single" && question.type !== "multi") return;
|
|
3443
|
+
replaceQuestionOptionList(question, getQuestionValue(question));
|
|
3444
|
+
});
|
|
3445
|
+
}
|
|
3446
|
+
|
|
2432
3447
|
function loadProgress() {
|
|
2433
3448
|
if (!session.storageKey) return;
|
|
2434
3449
|
let loaded = false;
|
|
2435
3450
|
try {
|
|
2436
3451
|
const saved = localStorage.getItem(session.storageKey);
|
|
2437
3452
|
if (saved) {
|
|
2438
|
-
|
|
3453
|
+
const parsed = JSON.parse(saved);
|
|
3454
|
+
const answers = parsed && typeof parsed === "object" && parsed.answers && typeof parsed.answers === "object"
|
|
3455
|
+
? parsed.answers
|
|
3456
|
+
: parsed;
|
|
3457
|
+
populateForm(answers);
|
|
3458
|
+
restoreSavedOptionInsights(parsed?.savedOptionInsights);
|
|
2439
3459
|
questions.forEach((q) => {
|
|
2440
3460
|
if (q.type === "multi") updateDoneState(q.id);
|
|
2441
3461
|
});
|
|
3462
|
+
rerenderChoiceQuestions();
|
|
2442
3463
|
loaded = true;
|
|
2443
3464
|
}
|
|
2444
3465
|
} catch (_err) {
|
|
@@ -2541,6 +3562,12 @@
|
|
|
2541
3562
|
updateDoneState(q.id);
|
|
2542
3563
|
}
|
|
2543
3564
|
});
|
|
3565
|
+
rerenderChoiceQuestions();
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
function populateFromSavedOptionInsights(savedOptionInsights) {
|
|
3569
|
+
restoreSavedOptionInsights(savedOptionInsights);
|
|
3570
|
+
rerenderChoiceQuestions();
|
|
2544
3571
|
}
|
|
2545
3572
|
|
|
2546
3573
|
function readFileBase64(file) {
|
|
@@ -2608,6 +3635,7 @@
|
|
|
2608
3635
|
token: sessionToken,
|
|
2609
3636
|
responses: payload.responses,
|
|
2610
3637
|
images: payload.images,
|
|
3638
|
+
savedOptionInsights: serializeSavedOptionInsights(),
|
|
2611
3639
|
submitted,
|
|
2612
3640
|
}),
|
|
2613
3641
|
});
|
|
@@ -2713,6 +3741,7 @@
|
|
|
2713
3741
|
function init() {
|
|
2714
3742
|
initTheme();
|
|
2715
3743
|
clearReloadIntent();
|
|
3744
|
+
normalizeOptionKeysFromData();
|
|
2716
3745
|
|
|
2717
3746
|
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
2718
3747
|
const modKey = document.querySelector(".mod-key");
|
|
@@ -2747,6 +3776,9 @@
|
|
|
2747
3776
|
// Pre-populate: savedAnswers takes precedence over localStorage
|
|
2748
3777
|
if (data.savedAnswers && Array.isArray(data.savedAnswers)) {
|
|
2749
3778
|
populateFromSavedAnswers(data.savedAnswers);
|
|
3779
|
+
if (Array.isArray(data.savedOptionInsights)) {
|
|
3780
|
+
populateFromSavedOptionInsights(data.savedOptionInsights);
|
|
3781
|
+
}
|
|
2750
3782
|
initStorageKeyOnly();
|
|
2751
3783
|
} else {
|
|
2752
3784
|
initStorage();
|
|
@@ -2851,8 +3883,6 @@
|
|
|
2851
3883
|
}
|
|
2852
3884
|
}
|
|
2853
3885
|
}, true);
|
|
2854
|
-
document.addEventListener("paste", handlePaste);
|
|
2855
|
-
|
|
2856
3886
|
if (timeout > 0) {
|
|
2857
3887
|
startCountdownDisplay();
|
|
2858
3888
|
timers.expiration = setTimeout(() => {
|