@alisio/plugin-semantic 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -351,13 +351,24 @@ textarea {
351
351
  .nb-card:focus-within {
352
352
  border-color: var(--border-strong);
353
353
  }
354
+ /* "New notebook": icon and label centered, same height as the notebook cards. */
354
355
  .nb-card.new {
356
+ display: flex;
355
357
  flex-direction: column;
358
+ align-items: center;
359
+ justify-content: center;
356
360
  gap: 12px;
357
361
  color: var(--accent);
358
362
  border-style: dashed;
359
363
  font-weight: 650;
360
364
  width: 100%;
365
+ height: 100%;
366
+ min-height: 170px;
367
+ padding: 18px;
368
+ text-align: center;
369
+ }
370
+ .nb-card.new .nb-glyph {
371
+ margin-bottom: 0;
361
372
  }
362
373
  .nb-open {
363
374
  display: flex;
@@ -600,19 +611,167 @@ textarea {
600
611
  gap: 10px;
601
612
  color: var(--muted);
602
613
  }
614
+ /* Citation chip: clearly a control, not a bare number. */
603
615
  .cite {
604
616
  min-height: 0;
605
617
  min-width: 22px;
606
- height: 22px;
618
+ height: 20px;
607
619
  padding: 0 6px;
608
- margin: 0 2px;
609
- border-radius: 6px;
610
- font-size: 12px;
611
- font-weight: 700;
612
- vertical-align: 1px;
620
+ margin: 0 1px 0 3px;
621
+ border-radius: 999px;
622
+ font-size: 11.5px;
623
+ font-weight: 750;
624
+ line-height: 1;
625
+ vertical-align: 2px;
613
626
  background: var(--amber-bg);
614
627
  color: var(--amber);
628
+ border: 1px solid var(--amber);
629
+ }
630
+ .cite:hover {
631
+ background: var(--amber);
632
+ color: var(--bg);
633
+ }
634
+ /* Rendered Markdown in answers, the guide and summaries. */
635
+ .md > :first-child {
636
+ margin-top: 0;
637
+ }
638
+ .md > :last-child {
639
+ margin-bottom: 0;
640
+ }
641
+ .md p,
642
+ .md ul,
643
+ .md ol,
644
+ .md blockquote,
645
+ .md pre,
646
+ .md .table-wrap {
647
+ margin: 0 0 10px;
648
+ }
649
+ .md ul,
650
+ .md ol {
651
+ padding-left: 22px;
652
+ }
653
+ .md li + li {
654
+ margin-top: 4px;
655
+ }
656
+ .md h3,
657
+ .md h4,
658
+ .md h5 {
659
+ margin: 14px 0 6px;
660
+ font-size: 15px;
661
+ text-transform: none;
662
+ letter-spacing: 0;
663
+ color: var(--text);
664
+ }
665
+ .md h3 {
666
+ font-size: 17px;
667
+ }
668
+ .md code {
669
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
670
+ font-size: 12.5px;
671
+ background: var(--raised);
672
+ border-radius: 5px;
673
+ padding: 1px 5px;
674
+ }
675
+ .md pre {
676
+ background: var(--raised);
677
+ border-radius: var(--radius-sm);
678
+ padding: 10px 12px;
679
+ overflow: auto;
680
+ }
681
+ .md pre code {
682
+ background: none;
683
+ padding: 0;
684
+ }
685
+ .md blockquote {
686
+ border-left: 3px solid var(--border-strong);
687
+ padding: 2px 0 2px 12px;
688
+ background: none;
689
+ border-radius: 0;
690
+ white-space: normal;
691
+ }
692
+ .md hr {
693
+ border: none;
694
+ border-top: 1px solid var(--border-strong);
695
+ margin: 12px 0;
696
+ }
697
+ .md .table-wrap {
698
+ overflow-x: auto;
699
+ }
700
+ .md table {
701
+ border-collapse: collapse;
702
+ font-size: 13px;
703
+ }
704
+ .md th,
705
+ .md td {
706
+ border: 1px solid var(--border-strong);
707
+ padding: 5px 9px;
708
+ text-align: left;
709
+ vertical-align: top;
710
+ }
711
+ .md th {
712
+ background: var(--raised);
713
+ }
714
+ .msg .who {
715
+ display: flex;
716
+ align-items: center;
717
+ justify-content: space-between;
718
+ gap: 8px;
719
+ }
720
+ .copy-controls {
721
+ display: inline-flex;
722
+ gap: 2px;
723
+ margin-left: auto;
724
+ }
725
+ .copy-controls .icon-btn {
726
+ width: 28px;
727
+ min-width: 28px;
728
+ height: 28px;
729
+ min-height: 28px;
730
+ color: var(--muted);
731
+ }
732
+ .block h3 {
733
+ display: flex;
734
+ align-items: center;
735
+ gap: 6px;
736
+ }
737
+ .chat-head:empty {
738
+ display: none;
739
+ }
740
+ .chat-head {
741
+ padding: 12px 18px 0;
742
+ }
743
+ .model-picker {
744
+ display: inline-flex;
745
+ align-items: center;
746
+ gap: 8px;
747
+ color: var(--muted);
748
+ font-size: 13px;
749
+ max-width: 100%;
750
+ }
751
+ .model-picker select {
752
+ min-height: 34px;
753
+ max-width: 340px;
754
+ border: 1px solid var(--border-strong);
755
+ border-radius: var(--radius-sm);
756
+ background: var(--surface);
757
+ color: var(--text);
758
+ padding: 4px 8px;
759
+ }
760
+ .block-state {
761
+ background: var(--amber-bg);
615
762
  border-color: transparent;
763
+ margin: 0;
764
+ }
765
+ textarea:disabled {
766
+ opacity: 0.55;
767
+ cursor: not-allowed;
768
+ }
769
+ .copy-area {
770
+ max-height: 50vh;
771
+ overflow: auto;
772
+ border: 1px solid var(--border-strong);
773
+ border-radius: var(--radius-sm);
774
+ padding: 10px;
616
775
  }
617
776
  .warn {
618
777
  display: flex;
@@ -649,16 +808,113 @@ textarea {
649
808
  justify-content: flex-start;
650
809
  border-radius: var(--radius-sm);
651
810
  }
811
+ /* Composer: one rounded container with an auto-growing textarea and a round send button inside. */
652
812
  .composer {
813
+ padding: 12px 18px 10px;
814
+ border-top: 1px solid var(--border);
815
+ }
816
+ .composer-box {
653
817
  display: flex;
654
- gap: 10px;
655
818
  align-items: flex-end;
656
- padding: 14px 18px;
657
- border-top: 1px solid var(--border);
819
+ gap: 8px;
820
+ border: 1px solid var(--border-strong);
821
+ border-radius: var(--radius);
822
+ background: var(--surface);
823
+ padding: 6px 6px 6px 14px;
824
+ transition:
825
+ border-color 0.15s,
826
+ box-shadow 0.15s;
827
+ }
828
+ .composer-box:focus-within {
829
+ border-color: var(--accent);
830
+ box-shadow: 0 0 0 3px var(--accent-soft);
831
+ }
832
+ .composer-box textarea {
833
+ flex: 1;
834
+ min-width: 0;
835
+ border: none;
836
+ background: transparent;
837
+ padding: 7px 0;
838
+ min-height: 22px;
839
+ line-height: 22px;
840
+ resize: none;
841
+ overflow-y: hidden;
842
+ }
843
+ .composer-box textarea:focus,
844
+ .composer-box textarea:focus-visible {
845
+ outline: none;
846
+ box-shadow: none;
658
847
  }
659
- #composer-actions {
848
+ .composer-actions {
660
849
  display: flex;
661
- gap: 6px;
850
+ flex: none;
851
+ }
852
+ .send-btn {
853
+ width: 36px;
854
+ min-width: 36px;
855
+ height: 36px;
856
+ min-height: 36px;
857
+ padding: 0;
858
+ border-radius: 999px;
859
+ background: var(--accent);
860
+ border-color: var(--accent);
861
+ color: var(--on-accent);
862
+ }
863
+ .send-btn:hover {
864
+ background: var(--accent-hover);
865
+ border-color: var(--accent-hover);
866
+ }
867
+ .send-btn:disabled {
868
+ background: var(--surface-2);
869
+ border-color: var(--border-strong);
870
+ color: var(--faint);
871
+ opacity: 1;
872
+ }
873
+ .send-btn.stop {
874
+ background: var(--text);
875
+ border-color: var(--text);
876
+ color: var(--bg);
877
+ }
878
+ .send-btn .icon {
879
+ width: 18px;
880
+ height: 18px;
881
+ }
882
+ .composer-hint,
883
+ .disclaimer {
884
+ margin: 6px 4px 0;
885
+ font-size: 12px;
886
+ line-height: 1.4;
887
+ color: var(--faint);
888
+ text-align: center;
889
+ }
890
+ .disclaimer {
891
+ margin-top: 2px;
892
+ }
893
+ /* Copy controls: top right of each generated block; shown on hover and keyboard focus, always
894
+ on touch screens and narrow layouts. */
895
+ .copyable .copy-controls {
896
+ opacity: 0;
897
+ transition: opacity 0.15s;
898
+ }
899
+ .copyable:hover .copy-controls,
900
+ .copyable:focus-within .copy-controls {
901
+ opacity: 1;
902
+ }
903
+ @media (hover: none), (max-width: 899px) {
904
+ .copyable .copy-controls {
905
+ opacity: 1;
906
+ }
907
+ }
908
+ .chat-head {
909
+ display: flex;
910
+ align-items: center;
911
+ gap: 10px;
912
+ }
913
+ .chat-head > .banner {
914
+ flex: 1;
915
+ }
916
+ .chat-head > .icon-btn {
917
+ margin-left: auto;
662
918
  }
663
919
  .spinner {
664
920
  width: 16px;
@@ -748,6 +1004,9 @@ dialog::backdrop {
748
1004
  margin: 15vh auto;
749
1005
  padding: 0 24px;
750
1006
  text-align: center;
1007
+ display: grid;
1008
+ justify-items: center;
1009
+ gap: 12px;
751
1010
  }
752
1011
 
753
1012
  /* ---------- Narrow layout: tabs under 900 px ---------- */
@@ -951,6 +1210,8 @@ const en = {
951
1210
  keywordAnswer: "Found with keyword search",
952
1211
  clearChat: "Clear chat",
953
1212
  clearChatConfirm: "Clear the whole chat of this notebook?",
1213
+ composerHint: "Enter to send, Shift+Enter for a new line",
1214
+ disclaimer: "The assistant can make mistakes, so check its answers against the cited sources.",
954
1215
  noticeTitle: "Before your first question",
955
1216
  noticeText:
956
1217
  "To answer, the passages that match your question are sent to the AI model selected in Alisio, which may run outside this computer. Reading and indexing your files stays on this computer.",
@@ -969,6 +1230,21 @@ const en = {
969
1230
  showGuide: "Show guide panel",
970
1231
  hideGuide: "Hide guide panel",
971
1232
  awaiting_approval: "Waiting for your approval in Alisio…",
1233
+ model: "Model",
1234
+ modelDefault: (name) => (name ? `Alisio's current model (${name})` : "Alisio's current model"),
1235
+ modelFallback:
1236
+ "The model chosen for this notebook is no longer available; Alisio's current model answered.",
1237
+ noModelTitle: "Connect a model to chat",
1238
+ noModel:
1239
+ "To chat with your sources, first connect a model in Alisio (/connect). Indexing and keyword search still work.",
1240
+ outdated: "Outdated: sources changed since it was written.",
1241
+ regenerate: "Regenerate",
1242
+ copyText: "Copy text",
1243
+ copyFormatted: "Copy formatted",
1244
+ copied: "Copied",
1245
+ copyManual: "Press Ctrl+C / ⌘C to copy",
1246
+ retry: "Try again",
1247
+ checking: "Checking…",
972
1248
  unavailableTitle: "Notebook is unavailable",
973
1249
  unavailable:
974
1250
  "This app is no longer available. The plugin may have been turned off or updated; reopen Notebook from the Apps list.",
@@ -990,6 +1266,7 @@ const en = {
990
1266
  download_failed: "The download failed. Check your connection and try again.",
991
1267
  cancelled: "Cancelled.",
992
1268
  answer_failed: "The AI model did not answer. Check the model selected in Alisio.",
1269
+ no_model: "Connect a model in Alisio (/connect) to chat with your sources.",
993
1270
  operation_failed: "Something went wrong. Please try again.",
994
1271
  not_permitted_write:
995
1272
  'Alisio did not allow Notebook to save changes. Set this session to Auto (or Full access), or approve the action in the chat with "Allow for session".',
@@ -1100,8 +1377,11 @@ const es = {
1100
1377
  assistant: "Cuaderno",
1101
1378
  notGrounded: "Esta respuesta no cita tus fuentes. Revísala antes de confiar en ella.",
1102
1379
  keywordAnswer: "Encontrado con palabras clave",
1103
- clearChat: "Borrar chat",
1104
- clearChatConfirm: "¿Borrar todo el chat de este cuaderno?",
1380
+ clearChat: "Limpiar chat",
1381
+ clearChatConfirm: "¿Limpiar todo el chat de este cuaderno?",
1382
+ composerHint: "Enter para enviar, Shift+Enter para nueva línea",
1383
+ disclaimer:
1384
+ "El asistente puede cometer errores; comprueba sus respuestas con las fuentes citadas.",
1105
1385
  noticeTitle: "Antes de tu primera pregunta",
1106
1386
  noticeText:
1107
1387
  "Para responder, los pasajes que coinciden con tu pregunta se envían al modelo de IA elegido en Alisio, que puede funcionar fuera de este equipo. La lectura y el indexado de tus archivos se quedan en este equipo.",
@@ -1119,6 +1399,21 @@ const es = {
1119
1399
  showGuide: "Mostrar panel de guía",
1120
1400
  hideGuide: "Ocultar panel de guía",
1121
1401
  awaiting_approval: "Esperando tu aprobación en Alisio…",
1402
+ model: "Modelo",
1403
+ modelDefault: (name) => (name ? `Modelo actual de Alisio (${name})` : "Modelo actual de Alisio"),
1404
+ modelFallback:
1405
+ "El modelo elegido para este cuaderno ya no está disponible; respondió el modelo actual de Alisio.",
1406
+ noModelTitle: "Conecta un modelo para chatear",
1407
+ noModel:
1408
+ "Para chatear con tus fuentes, primero conecta un modelo en Alisio (/connect). El indexado y la búsqueda por palabras clave siguen funcionando.",
1409
+ outdated: "Desactualizado: las fuentes cambiaron desde que se escribió.",
1410
+ regenerate: "Regenerar",
1411
+ copyText: "Copiar texto",
1412
+ copyFormatted: "Copiar con formato",
1413
+ copied: "Copiado",
1414
+ copyManual: "Pulsa Ctrl+C / ⌘C para copiar",
1415
+ retry: "Reintentar",
1416
+ checking: "Comprobando…",
1122
1417
  unavailableTitle: "Cuaderno no está disponible",
1123
1418
  unavailable:
1124
1419
  "Esta app ya no está disponible. Puede que el plugin se haya desactivado o actualizado; vuelve a abrir Cuaderno desde la lista de Aplicaciones.",
@@ -1141,6 +1436,7 @@ const es = {
1141
1436
  download_failed: "La descarga falló. Revisa tu conexión e inténtalo de nuevo.",
1142
1437
  cancelled: "Cancelado.",
1143
1438
  answer_failed: "El modelo de IA no respondió. Revisa el modelo elegido en Alisio.",
1439
+ no_model: "Conecta un modelo en Alisio (/connect) para chatear con tus fuentes.",
1144
1440
  operation_failed: "Algo salió mal. Inténtalo de nuevo.",
1145
1441
  not_permitted_write:
1146
1442
  'Alisio no permitió que Cuaderno guarde cambios. Pon esta sesión en Auto (o Acceso total), o aprueba la acción en el chat con "Permitir en la sesión".',
@@ -1332,7 +1628,7 @@ const PATHS = {
1332
1628
  folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
1333
1629
  file: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5",
1334
1630
  send: "M4 12l16-8-6 16-2-6z",
1335
- stop: "M7 7h10v10H7z",
1631
+ stop: "M8 8h8v8H8z",
1336
1632
  trash: "M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3",
1337
1633
  edit: "M4 20h4L19 9l-4-4L4 16zM14 6l4 4",
1338
1634
  sparkle:
@@ -1344,7 +1640,10 @@ const PATHS = {
1344
1640
  book: "M4 5a2 2 0 0 1 2-2h5v17H6a2 2 0 0 0-2 2zM20 5a2 2 0 0 0-2-2h-5v17h5a2 2 0 0 1 2 2z",
1345
1641
  check: "M5 12l5 5 9-10",
1346
1642
  up: "M12 19V5m0 0l-6 6m6-6l6 6",
1643
+ arrowUp: "M12 19V6m0 0l-6 6m6-6l6 6",
1347
1644
  close: "M6 6l12 12M18 6L6 18",
1645
+ copy: "M9 9h10v10H9zM5 15V5h10",
1646
+ copyRich: "M9 9h10v10H9zM5 15V5h10M12 13h4M12 16h3",
1348
1647
  quote: "M7 7h4v4c0 3-2 5-4 6M15 7h4v4c0 3-2 5-4 6",
1349
1648
  };
1350
1649
 
@@ -1352,6 +1651,324 @@ function icon(name, cls = "icon") {
1352
1651
  return `<svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${PATHS[name] ?? ""}"/></svg>`;
1353
1652
  }
1354
1653
 
1654
+ // Small, safe Markdown renderer for answers (0.1.1). Every piece of model text is HTML-escaped
1655
+ // before any formatting is applied, so the only tags in the output are the ones built here.
1656
+ // Supports paragraphs, line breaks, # to ### headings, bold, italic, inline code, fenced code,
1657
+ // nested ordered/unordered lists, blockquotes, simple GFM tables and horizontal rules. Links are
1658
+ // shown as plain text (the frame cannot navigate). `[n]` citations that match `citations` become
1659
+ // chips; other numbers stay text.
1660
+
1661
+ const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
1662
+ const escapeHtml = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ESCAPES[c]);
1663
+
1664
+ function chip(citation) {
1665
+ const label = `${citation.n}: ${citation.sourceName ?? ""}`;
1666
+ return `<button type="button" class="cite" data-action="cite" data-source="${escapeHtml(citation.sourceId)}" data-ordinal="${escapeHtml(citation.ordinal)}" aria-label="${escapeHtml(label)}" title="${escapeHtml(citation.sourceName ?? "")}">${escapeHtml(citation.n)}</button>`;
1667
+ }
1668
+
1669
+ /**
1670
+ * Inline formatting of one line of raw text. `options.chips === false` keeps citations as plain
1671
+ * `[n]` text (copied HTML must not contain buttons).
1672
+ */
1673
+ function renderInline(raw, citations = [], options = {}) {
1674
+ const byNumber = new Map(citations.map((c) => [Number(c.n), c]));
1675
+ const slots = [];
1676
+ const hold = (html) => `\uE000${slots.push(html) - 1}\uE000`;
1677
+ let s = escapeHtml(String(raw ?? "").replace(/\uE000/g, ""));
1678
+ // Code spans first: nothing inside them is formatted.
1679
+ s = s.replace(/`([^`]+)`/g, (_, code) => hold(`<code>${code}</code>`));
1680
+ // Links become their text plus the address, as plain text.
1681
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, text, url) =>
1682
+ text === url ? text : `${text} (${url})`,
1683
+ );
1684
+ s = s.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, "<strong>$1</strong>");
1685
+ s = s.replace(/__(?=\S)([\s\S]*?\S)__/g, "<strong>$1</strong>");
1686
+ s = s.replace(/(^|[^\w*])\*(?=[^\s*])([^*]*?[^\s*])\*(?!\*)/g, "$1<em>$2</em>");
1687
+ s = s.replace(/(^|[^\w])_(?=[^\s_])([^_]*?[^\s_])_(?![\w])/g, "$1<em>$2</em>");
1688
+ s = s.replace(/\[(\d{1,3}(?:\s*,\s*\d{1,3})*)\]/g, (match, list) => {
1689
+ const numbers = list.split(",").map((n) => Number(n.trim()));
1690
+ if (!numbers.every((n) => byNumber.has(n))) return match;
1691
+ if (options.chips === false) return `[${numbers.join(", ")}]`;
1692
+ return numbers.map((n) => hold(chip(byNumber.get(n)))).join("");
1693
+ });
1694
+ return s.replace(/\uE000(\d+)\uE000/g, (_, i) => slots[Number(i)]);
1695
+ }
1696
+
1697
+ const LIST = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
1698
+ const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
1699
+ const FENCE = /^\s{0,3}(```|~~~)/;
1700
+ const HEADING = /^\s{0,3}(#{1,3})\s+(.*?)\s*#*\s*$/;
1701
+ const QUOTE = /^\s{0,3}>\s?(.*)$/;
1702
+ const TABLE_RULE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/;
1703
+ const isTableRow = (line) => line.includes("|");
1704
+ const cells = (line) =>
1705
+ line
1706
+ .trim()
1707
+ .replace(/^\|/, "")
1708
+ .replace(/\|$/, "")
1709
+ .split("|")
1710
+ .map((c) => c.trim());
1711
+ const indentOf = (s) => s.replace(/\t/g, " ").length;
1712
+
1713
+ function renderList(lines, citations, options) {
1714
+ // Builds nested lists from indentation: deeper markers open a child list in the last item.
1715
+ const root = { indent: -1, items: [] };
1716
+ const stack = [];
1717
+ for (const line of lines) {
1718
+ const m = LIST.exec(line);
1719
+ if (!m) {
1720
+ const top = stack.at(-1);
1721
+ const item = top?.list.items.at(-1);
1722
+ if (item) item.text += `\n${line.trim()}`;
1723
+ continue;
1724
+ }
1725
+ const indent = indentOf(m[1]);
1726
+ const ordered = /\d/.test(m[2]);
1727
+ while (stack.length && indent < stack.at(-1).indent) stack.pop();
1728
+ let top = stack.at(-1);
1729
+ if (!top || indent > top.indent) {
1730
+ const parentItem = top?.list.items.at(-1);
1731
+ const list = { ordered, items: [] };
1732
+ if (parentItem) parentItem.children.push(list);
1733
+ else root.items.push(list);
1734
+ top = { indent, list };
1735
+ stack.push(top);
1736
+ }
1737
+ top.list.items.push({ text: m[3], children: [] });
1738
+ }
1739
+ const html = (list) =>
1740
+ `<${list.ordered ? "ol" : "ul"}>${list.items
1741
+ .map(
1742
+ (item) =>
1743
+ `<li>${item.text
1744
+ .split("\n")
1745
+ .map((t) => renderInline(t, citations, options))
1746
+ .join("<br>")}${item.children.map(html).join("")}</li>`,
1747
+ )
1748
+ .join("")}</${list.ordered ? "ol" : "ul"}>`;
1749
+ return root.items.map(html).join("");
1750
+ }
1751
+
1752
+ function renderTable(rows, citations, options) {
1753
+ const [head, , ...body] = rows.map(cells);
1754
+ const th = (head ?? []).map((c) => `<th>${renderInline(c, citations, options)}</th>`).join("");
1755
+ const tr = body
1756
+ .map(
1757
+ (r) => `<tr>${r.map((c) => `<td>${renderInline(c, citations, options)}</td>`).join("")}</tr>`,
1758
+ )
1759
+ .join("");
1760
+ return `<div class="table-wrap"><table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table></div>`;
1761
+ }
1762
+
1763
+ /** Block-level rendering; returns HTML built only from escaped text and fixed tags. */
1764
+ function renderMarkdown(text, citations = [], options = {}) {
1765
+ const lines = String(text ?? "")
1766
+ .replace(/\r\n?/g, "\n")
1767
+ .split("\n");
1768
+ const out = [];
1769
+ let i = 0;
1770
+ while (i < lines.length) {
1771
+ const line = lines[i];
1772
+ if (!line.trim()) {
1773
+ i++;
1774
+ continue;
1775
+ }
1776
+ const fence = FENCE.exec(line);
1777
+ if (fence) {
1778
+ const code = [];
1779
+ i++;
1780
+ while (i < lines.length && !lines[i].trimStart().startsWith(fence[1])) code.push(lines[i++]);
1781
+ i++;
1782
+ out.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
1783
+ continue;
1784
+ }
1785
+ const heading = HEADING.exec(line);
1786
+ if (heading) {
1787
+ const level = heading[1].length + 2; // # -> h3: answers live inside the chat
1788
+ out.push(`<h${level}>${renderInline(heading[2], citations, options)}</h${level}>`);
1789
+ i++;
1790
+ continue;
1791
+ }
1792
+ if (RULE.test(line)) {
1793
+ out.push("<hr>");
1794
+ i++;
1795
+ continue;
1796
+ }
1797
+ if (QUOTE.test(line)) {
1798
+ const inner = [];
1799
+ while (i < lines.length && QUOTE.test(lines[i])) inner.push(QUOTE.exec(lines[i++])[1]);
1800
+ out.push(`<blockquote>${renderMarkdown(inner.join("\n"), citations, options)}</blockquote>`);
1801
+ continue;
1802
+ }
1803
+ if (isTableRow(line) && i + 1 < lines.length && TABLE_RULE.test(lines[i + 1])) {
1804
+ const rows = [];
1805
+ while (i < lines.length && lines[i].trim() && isTableRow(lines[i])) rows.push(lines[i++]);
1806
+ out.push(renderTable(rows, citations, options));
1807
+ continue;
1808
+ }
1809
+ if (LIST.test(line)) {
1810
+ const block = [];
1811
+ while (
1812
+ i < lines.length &&
1813
+ lines[i].trim() &&
1814
+ (LIST.test(lines[i]) || /^\s+\S/.test(lines[i]))
1815
+ )
1816
+ block.push(lines[i++]);
1817
+ out.push(renderList(block, citations, options));
1818
+ continue;
1819
+ }
1820
+ const paragraph = [];
1821
+ while (
1822
+ i < lines.length &&
1823
+ lines[i].trim() &&
1824
+ !FENCE.test(lines[i]) &&
1825
+ !HEADING.test(lines[i]) &&
1826
+ !QUOTE.test(lines[i]) &&
1827
+ !LIST.test(lines[i]) &&
1828
+ !RULE.test(lines[i])
1829
+ )
1830
+ paragraph.push(lines[i++]);
1831
+ out.push(`<p>${paragraph.map((l) => renderInline(l, citations, options)).join("<br>")}</p>`);
1832
+ }
1833
+ return out.join("");
1834
+ }
1835
+
1836
+ // Copy an answer as text or with formatting (0.1.1, O14). The formatted HTML comes from the same
1837
+ // safe renderer (model text is always escaped), with citations as plain [n] and inline styles only.
1838
+
1839
+ /** Plain text to copy: the answer's Markdown source, citations kept as [n]. */
1840
+ const copyText = (markdown) => String(markdown ?? "");
1841
+
1842
+ const STYLES = [
1843
+ [/<table>/g, '<table style="border-collapse:collapse">'],
1844
+ [/<th>/g, '<th style="border:1px solid #999;padding:4px 8px;text-align:left">'],
1845
+ [/<td>/g, '<td style="border:1px solid #999;padding:4px 8px">'],
1846
+ [/<pre>/g, '<pre style="background:#f4f4f4;padding:8px;white-space:pre-wrap">'],
1847
+ [
1848
+ /<blockquote>/g,
1849
+ '<blockquote style="border-left:3px solid #999;margin:0 0 8px;padding-left:10px">',
1850
+ ],
1851
+ [/ class="table-wrap"/g, ""],
1852
+ ];
1853
+
1854
+ /** Rich HTML to copy: rendered Markdown without chips, buttons or classes. */
1855
+ function copyHtml(markdown, citations, render) {
1856
+ let html = render(String(markdown ?? ""), citations ?? [], { chips: false });
1857
+ for (const [pattern, replacement] of STYLES) html = html.replace(pattern, replacement);
1858
+ return `<div>${html}</div>`;
1859
+ }
1860
+
1861
+ /**
1862
+ * Tries the async Clipboard API, then a `copy` event fired by `execCommand` on a hidden
1863
+ * selection (sandboxed frames often lack the clipboard permission). Returns the path that worked:
1864
+ * "clipboard", "execCommand", or "manual" when the caller must show the content to copy by hand.
1865
+ */
1866
+ async function writeClipboard({ text, html }, env = globalThis) {
1867
+ const nav = env.navigator;
1868
+ try {
1869
+ if (html && nav?.clipboard?.write && typeof env.ClipboardItem === "function") {
1870
+ await nav.clipboard.write([
1871
+ new env.ClipboardItem({
1872
+ "text/html": new env.Blob([html], { type: "text/html" }),
1873
+ "text/plain": new env.Blob([text], { type: "text/plain" }),
1874
+ }),
1875
+ ]);
1876
+ return "clipboard";
1877
+ }
1878
+ if (!html && nav?.clipboard?.writeText) {
1879
+ await nav.clipboard.writeText(text);
1880
+ return "clipboard";
1881
+ }
1882
+ } catch {
1883
+ // Permission denied in the sandbox: fall back below.
1884
+ }
1885
+ const doc = env.document;
1886
+ try {
1887
+ const area = doc.createElement("textarea");
1888
+ area.value = text;
1889
+ area.setAttribute("readonly", "");
1890
+ area.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0";
1891
+ doc.body.append(area);
1892
+ area.select();
1893
+ let filled = false;
1894
+ const onCopy = (event) => {
1895
+ event.clipboardData?.setData("text/plain", text);
1896
+ if (html) event.clipboardData?.setData("text/html", html);
1897
+ if (event.clipboardData) {
1898
+ event.preventDefault();
1899
+ filled = true;
1900
+ }
1901
+ };
1902
+ doc.addEventListener("copy", onCopy);
1903
+ let ok = false;
1904
+ try {
1905
+ ok = !!doc.execCommand?.("copy");
1906
+ } finally {
1907
+ doc.removeEventListener("copy", onCopy);
1908
+ area.remove();
1909
+ }
1910
+ if (ok && (filled || !html)) return "execCommand";
1911
+ } catch {
1912
+ // execCommand is unavailable: manual copy.
1913
+ }
1914
+ return "manual";
1915
+ }
1916
+
1917
+ /**
1918
+ * What a copy control copies (one shared component for every generated block, O14):
1919
+ * `chat:<i>` an answer, `guide` the notebook summary, `questions` its suggested questions,
1920
+ * `summary` a source summary, `passage` the passage text plus a plain reference line.
1921
+ * `place` formats the passage's location ("Page 3", "Slide 2", "Sheet: Sales").
1922
+ */
1923
+ function copySourceFor(ref, state) {
1924
+ const { chat = [], guide, summary, passage, place } = state;
1925
+ if (ref === "guide")
1926
+ return guide?.summary
1927
+ ? { text: guide.summary, citations: guide.citations ?? [], markdown: true }
1928
+ : undefined;
1929
+ if (ref === "questions") {
1930
+ const questions = (guide?.questions ?? []).filter(Boolean);
1931
+ return questions.length
1932
+ ? { text: questions.map((q) => `- ${q}`).join("\n"), citations: [], markdown: true }
1933
+ : undefined;
1934
+ }
1935
+ if (ref === "summary")
1936
+ return summary?.summary
1937
+ ? { text: summary.summary, citations: summary.citations ?? [], markdown: true }
1938
+ : undefined;
1939
+ if (ref === "passage") {
1940
+ if (!passage) return undefined;
1941
+ const where = [passage.sourceName, place?.(passage)].filter(Boolean).join(", ");
1942
+ return { text: passage.text, reference: where, citations: [], markdown: false };
1943
+ }
1944
+ const message = chat[Number(String(ref).split(":")[1])];
1945
+ return message?.text
1946
+ ? { text: message.text, citations: message.citations ?? [], markdown: true }
1947
+ : undefined;
1948
+ }
1949
+
1950
+ const escapeText = (value) =>
1951
+ String(value ?? "").replace(
1952
+ /[&<>"']/g,
1953
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
1954
+ );
1955
+
1956
+ /** Plain text and (when `formatted`) rich HTML for a copy source. */
1957
+ function copyPayload(source, render, formatted) {
1958
+ const text = source.reference
1959
+ ? `${copyText(source.text)}\n\n— ${source.reference}`
1960
+ : copyText(source.text);
1961
+ if (!formatted) return { text };
1962
+ if (source.markdown) return { text, html: copyHtml(source.text, source.citations, render) };
1963
+ // Source text is not Markdown: quote it as is, with its reference line.
1964
+ const quoted = escapeText(source.text).split(/\n/).join("<br>");
1965
+ const reference = source.reference ? `<p>— ${escapeText(source.reference)}</p>` : "";
1966
+ return {
1967
+ text,
1968
+ html: `<div><blockquote style="border-left:3px solid #999;margin:0 0 8px;padding-left:10px">${quoted}</blockquote>${reference}</div>`,
1969
+ };
1970
+ }
1971
+
1355
1972
 
1356
1973
  const root = document.querySelector("#app");
1357
1974
  const dialog = document.querySelector("#dialog");
@@ -1370,6 +1987,8 @@ const WRITE_ACTIONS = new Set([
1370
1987
  "jobs.cancel",
1371
1988
  "notice.ack",
1372
1989
  "engine.setup",
1990
+ "notebooks.setModel",
1991
+ "notebooks.setScope",
1373
1992
  ]);
1374
1993
 
1375
1994
  // ---------- Transport ----------
@@ -1394,7 +2013,8 @@ const connected = new Promise((resolve) => {
1394
2013
  if (message?.type === "locale") changeLocale(message.locale);
1395
2014
  if (message?.type === "visibility") {
1396
2015
  visible = message.visible !== false;
1397
- if (visible) void poll();
2016
+ // Coming back: re-check the app (it may have been reopened by Alisio) and the models.
2017
+ if (visible) void (unavailable ? verifyAvailable() : refreshOnShow());
1398
2018
  }
1399
2019
  if (message?.type === "response") settle(message);
1400
2020
  };
@@ -1410,12 +2030,37 @@ function settle(message) {
1410
2030
  clearTimeout(request.timer);
1411
2031
  clearTimeout(request.approvalTimer);
1412
2032
  settleApprovalNotice();
1413
- if (message.error === "not_found") showUnavailable();
1414
2033
  // `message.error` is a host transport code; `result.error` a Notebook code. For not_permitted,
1415
2034
  // prefer the host's `effect`; older hosts omit it, so fall back to the action's own effect.
1416
2035
  const error = responseError(message, WRITE_ACTIONS.has(request.action) ? "write" : "read");
1417
- error ? request.reject(new Error(error)) : request.resolve(message.result?.value);
2036
+ if (!error) return request.resolve(message.result?.value);
2037
+ const failure = new Error(error);
2038
+ // Only the HOST's not_found can mean "app gone"; a plugin's not_found is an unknown item.
2039
+ failure.transport = !!message.error;
2040
+ if (message.error === "not_found") hostNotFound();
2041
+ request.reject(failure);
2042
+ }
2043
+
2044
+ // O13: one host not_found never latches. It is confirmed with a `status` request after a short
2045
+ // delay; only a second host not_found shows the unavailable screen, which keeps retrying.
2046
+ let verifying;
2047
+ function hostNotFound() {
2048
+ if (unavailable || verifying) return;
2049
+ verifying = new Promise((r) => setTimeout(r, VERIFY_DELAY_MS)).then(verifyAvailable);
2050
+ }
2051
+ async function verifyAvailable() {
2052
+ try {
2053
+ await send("status", {});
2054
+ const wasUnavailable = unavailable;
2055
+ unavailable = false;
2056
+ if (wasUnavailable) await reloadAll();
2057
+ } catch (error) {
2058
+ if (error?.transport && error.message === "not_found") showUnavailable();
2059
+ } finally {
2060
+ verifying = undefined;
2061
+ }
1418
2062
  }
2063
+ const VERIFY_DELAY_MS = 1500;
1419
2064
 
1420
2065
  // The host runs at most PLUGIN_APP_LIMITS.inFlight (8) requests per frame; stay below it.
1421
2066
  const MAX_IN_FLIGHT = 6;
@@ -1483,6 +2128,8 @@ const fail = (error) => {
1483
2128
  };
1484
2129
 
1485
2130
  // ---------- State ----------
2131
+ /** Configured models (O12): an empty list blocks asking, not indexing or keyword search. */
2132
+ let models = { models: [], active: null, available: true };
1486
2133
  let status = {};
1487
2134
  let notebooks = [];
1488
2135
  let current; // { ...notebook, sources }
@@ -1504,11 +2151,6 @@ let tab = "chat";
1504
2151
  let pollBusy = false;
1505
2152
  let draft = "";
1506
2153
 
1507
- const escapeHtml = (value) =>
1508
- String(value ?? "").replace(
1509
- /[&<>"']/g,
1510
- (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
1511
- );
1512
2154
  const tf = (key, ...args) => {
1513
2155
  const value = t(key);
1514
2156
  return typeof value === "function" ? value(...args) : value;
@@ -1524,7 +2166,7 @@ const announce = (text) => {
1524
2166
 
1525
2167
  function showUnavailable() {
1526
2168
  unavailable = true;
1527
- root.innerHTML = `<main class="unavailable"><span class="mark" aria-hidden="true"></span><h1>${escapeHtml(t("unavailableTitle"))}</h1><p>${escapeHtml(t("unavailable"))}</p></main>`;
2169
+ root.innerHTML = `<main class="unavailable"><span class="mark" aria-hidden="true"></span><h1>${escapeHtml(t("unavailableTitle"))}</h1><p>${escapeHtml(t("unavailable"))}</p>${button("retry", escapeHtml(t("retry")), "primary")}</main>`;
1528
2170
  }
1529
2171
 
1530
2172
  // ---------- Rendering ----------
@@ -1640,12 +2282,13 @@ function renderNotebook() {
1640
2282
  <div class="stack">${button("add-computer", `${icon("upload")}<span>${escapeHtml(t("addComputer"))}</span>`, "full")}${button("add-workspace", `${icon("folder")}<span>${escapeHtml(t("addWorkspace"))}</span>`, "full outline")}</div>
1641
2283
  <input id="file-input" type="file" multiple hidden accept="${SOURCE_EXTENSIONS.join(",")}">
1642
2284
  <div id="sources-list"></div></aside>
1643
- <section class="panel chat" aria-label="${escapeHtml(t("chatPanel"))}"><div id="banner"></div><div id="messages" class="messages"></div>
1644
- <div class="composer"><label class="sr-only" for="question">${escapeHtml(t("placeholder"))}</label><textarea id="question" rows="2" maxlength="4000" placeholder="${escapeHtml(t("placeholder"))}">${escapeHtml(draft)}</textarea><div id="composer-actions"></div></div>
2285
+ <section class="panel chat" aria-label="${escapeHtml(t("chatPanel"))}"><div id="chat-head" class="chat-head"></div><div id="banner"></div><div id="messages" class="messages"></div>
2286
+ <div class="composer"><div class="composer-box"><label class="sr-only" for="question">${escapeHtml(t("placeholder"))}</label><textarea id="question" rows="1" maxlength="4000" placeholder="${escapeHtml(t("placeholder"))}" aria-describedby="composer-hint">${escapeHtml(draft)}</textarea><div id="composer-actions" class="composer-actions"></div></div><p id="composer-hint" class="composer-hint">${escapeHtml(t("composerHint"))}</p><p class="disclaimer">${escapeHtml(t("disclaimer"))}</p></div>
1645
2287
  <p id="live" class="sr-only" aria-live="polite"></p></section>
1646
2288
  <aside class="panel guide" aria-label="${escapeHtml(t("guidePanel"))}" id="guide"></aside>
1647
2289
  </main>`;
1648
2290
  renderSources();
2291
+ renderChatHead();
1649
2292
  renderMessages();
1650
2293
  renderGuide();
1651
2294
  renderComposer();
@@ -1676,34 +2319,58 @@ function renderSources() {
1676
2319
  : t(`status_${view.key}`);
1677
2320
  return `<li class="source ${view.key}"><label class="check"><input type="checkbox" data-source="${s.id}" ${checked.has(s.id) ? "checked" : ""} ${usable ? "" : "disabled"} aria-label="${escapeHtml(tf("includeSource", s.name))}"></label>
1678
2321
  <span class="source-main">${icon("file")}<span class="source-name" title="${escapeHtml(s.name)}">${escapeHtml(s.name)}</span><span class="source-state small">${escapeHtml(label)}</span></span>
1679
- <span class="source-actions">${usable ? iconButton("summarize", "sparkle", t("summarize"), `data-id="${s.id}"`) : ""}${iconButton("delete-source", "trash", t("removeSource"), `data-id="${s.id}"`)}</span></li>`;
2322
+ <span class="source-actions">${usable ? iconButton("summarize", "sparkle", t("summarize"), `data-id="${s.id}" ${canAsk() || s.summary ? "" : "disabled"}`) : ""}${iconButton("delete-source", "trash", t("removeSource"), `data-id="${s.id}"`)}</span></li>`;
1680
2323
  })
1681
2324
  .join("")}</ul>`;
1682
2325
  const selectAll = document.querySelector("#select-all");
1683
2326
  if (selectAll) selectAll.indeterminate = all === "mixed";
1684
2327
  }
1685
2328
 
1686
- function citedHtml(text, citations) {
1687
- return citationParts(text, citations)
1688
- .map((part) =>
1689
- part.type === "text"
1690
- ? escapeHtml(part.text)
1691
- : `<button type="button" class="cite" data-action="cite" data-source="${part.citation.sourceId}" data-ordinal="${part.citation.ordinal}" aria-label="${escapeHtml(`${part.n}: ${part.citation.sourceName}`)}" title="${escapeHtml(part.citation.sourceName)}">${part.n}</button>`,
2329
+ const canAsk = () => models.available !== false;
2330
+
2331
+ /** Copy controls for an answer, the guide or a summary (O14). */
2332
+ const copyControls = (ref) =>
2333
+ `<span class="copy-controls">${iconButton("copy-text", "copy", t("copyText"), `data-copy="${ref}"`)}${iconButton("copy-formatted", "copyRich", t("copyFormatted"), `data-copy="${ref}"`)}</span>`;
2334
+
2335
+ /** Model selector and the no-model state above the chat (O12). */
2336
+ function renderChatHead() {
2337
+ const el = document.querySelector("#chat-head");
2338
+ if (!el || !current) return;
2339
+ if (!canAsk()) {
2340
+ el.innerHTML = `<div class="banner block-state" role="alert">${icon("warning")}<span><strong>${escapeHtml(t("noModelTitle"))}</strong> ${escapeHtml(t("noModel"))}</span></div>${clearChatButton()}`;
2341
+ return;
2342
+ }
2343
+ const chosen = current.model ?? "";
2344
+ const groups = new Map();
2345
+ for (const m of models.models) {
2346
+ if (!groups.has(m.provider)) groups.set(m.provider, []);
2347
+ groups.get(m.provider).push(m);
2348
+ }
2349
+ const options = [...groups.entries()]
2350
+ .map(
2351
+ ([provider, list]) =>
2352
+ `<optgroup label="${escapeHtml(provider)}">${list.map((m) => `<option value="${escapeHtml(m.reference)}" ${m.reference === chosen ? "selected" : ""}>${escapeHtml(m.name)}</option>`).join("")}</optgroup>`,
1692
2353
  )
1693
2354
  .join("");
2355
+ el.innerHTML = `<label class="model-picker"><span>${escapeHtml(t("model"))}</span><select id="model-select"><option value="" ${chosen ? "" : "selected"}>${escapeHtml(tf("modelDefault", models.active ?? ""))}</option>${options}</select></label>${clearChatButton()}`;
1694
2356
  }
2357
+ const clearChatButton = () =>
2358
+ chat.length && !askJob ? iconButton("clear-chat", "trash", t("clearChat")) : "";
1695
2359
 
1696
- function messageHtml(m) {
2360
+ function messageHtml(m, index) {
1697
2361
  if (m.role === "user")
1698
2362
  return `<div class="msg user"><span class="who">${escapeHtml(t("you"))}</span><p>${escapeHtml(m.text)}</p></div>`;
1699
- const body = citedHtml(m.text, m.citations);
2363
+ const body = renderMarkdown(m.text, m.citations);
1700
2364
  const warnings = [
2365
+ m.modelFallback
2366
+ ? `<p class="warn small">${icon("warning")}${escapeHtml(t("modelFallback"))}</p>`
2367
+ : "",
1701
2368
  !m.grounded && !m.notFound
1702
2369
  ? `<p class="warn small">${icon("warning")}${escapeHtml(t("notGrounded"))}</p>`
1703
2370
  : "",
1704
2371
  m.keywordOnly ? `<span class="badge small">${escapeHtml(t("keywordAnswer"))}</span>` : "",
1705
2372
  ].join("");
1706
- return `<div class="msg assistant"><span class="who">${escapeHtml(t("assistant"))}</span><p>${body.replaceAll("\n", "<br>")}</p>${warnings}</div>`;
2373
+ return `<div class="msg assistant copyable"><span class="who">${escapeHtml(t("assistant"))}${m.notFound ? "" : copyControls(`chat:${index}`)}</span><div class="md">${body}</div>${warnings}</div>`;
1707
2374
  }
1708
2375
 
1709
2376
  function renderMessages() {
@@ -1712,13 +2379,13 @@ function renderMessages() {
1712
2379
  if (chat.length === 0 && !askJob) {
1713
2380
  const questions = suggestions(guide);
1714
2381
  el.innerHTML = `<div class="empty-chat">${icon("quote", "icon big")}<h2>${escapeHtml(t("emptyChatTitle"))}</h2><p class="muted">${escapeHtml(t("emptyChatText"))}</p>
1715
- ${guide?.summary ? `<p class="guide-summary">${citedHtml(guide.summary, guide.citations)}</p>` : ""}
1716
- ${questions.length ? `<div class="chips">${questions.map((q, i) => button("suggest", escapeHtml(q), "chip", `data-index="${i}"`)).join("")}</div>` : current?.sources.length ? button("guide", `${icon("sparkle")}<span>${escapeHtml(guideJob ? t("guideWorking") : t("makeGuide"))}</span>`, "outline", guideJob ? "disabled" : "") : ""}
2382
+ ${guide?.summary ? `<div class="guide-summary md">${renderMarkdown(guide.summary, guide.citations)}</div>` : ""}
2383
+ ${questions.length ? `<div class="chips">${questions.map((q, i) => button("suggest", escapeHtml(q), "chip", `data-index="${i}" ${canAsk() ? "" : "disabled"}`)).join("")}</div>` : current?.sources.length ? button("guide", `${icon("sparkle")}<span>${escapeHtml(guideJob ? t("guideWorking") : t("makeGuide"))}</span>`, "outline", guideJob || !canAsk() ? "disabled" : "") : ""}
1717
2384
  </div>`;
1718
2385
  return;
1719
2386
  }
1720
2387
  el.innerHTML =
1721
- chat.map(messageHtml).join("") +
2388
+ chat.map((m, i) => messageHtml(m, i)).join("") +
1722
2389
  (askJob
1723
2390
  ? `<div class="msg assistant pending"><span class="spinner" aria-hidden="true"></span>${escapeHtml(t("thinking"))}</div>`
1724
2391
  : "");
@@ -1728,9 +2395,23 @@ function renderMessages() {
1728
2395
  function renderComposer() {
1729
2396
  const el = document.querySelector("#composer-actions");
1730
2397
  if (!el) return;
2398
+ // One compact round button inside the composer: Send, or Stop while an answer is running.
2399
+ const empty = !draft.trim();
1731
2400
  el.innerHTML = askJob
1732
- ? button("stop", `${icon("stop")}<span>${escapeHtml(t("stop"))}</span>`)
1733
- : `${chat.length ? iconButton("clear-chat", "trash", t("clearChat")) : ""}${button("send", `${icon("send")}<span>${escapeHtml(t("send"))}</span>`, "primary")}`;
2401
+ ? `<button type="button" class="send-btn stop" data-action="stop" aria-label="${escapeHtml(t("stop"))}" title="${escapeHtml(t("stop"))}">${icon("stop")}</button>`
2402
+ : `<button type="button" class="send-btn" data-action="send" aria-label="${escapeHtml(t("send"))}" title="${escapeHtml(t("send"))}" ${canAsk() && !empty ? "" : "disabled"}>${icon("arrowUp")}</button>`;
2403
+ const box = document.querySelector("#question");
2404
+ if (box) {
2405
+ box.disabled = !canAsk();
2406
+ autoGrow(box);
2407
+ }
2408
+ }
2409
+ /** 1 line minimum, about 6 lines maximum, then the textarea scrolls. */
2410
+ function autoGrow(box) {
2411
+ box.style.height = "auto";
2412
+ const max = 6 * 22 + 16;
2413
+ box.style.height = `${Math.min(box.scrollHeight || 0, max) || ""}px`;
2414
+ box.style.overflowY = box.scrollHeight > max ? "auto" : "hidden";
1734
2415
  }
1735
2416
 
1736
2417
  function renderBanner() {
@@ -1747,10 +2428,14 @@ function renderGuide() {
1747
2428
  if (!el) return;
1748
2429
  const questions = suggestions(guide);
1749
2430
  el.innerHTML = `<h2>${escapeHtml(t("guidePanel"))}</h2>
1750
- <section class="block"><h3>${escapeHtml(t("summaryTitle"))}</h3>${guide?.summary ? `<p>${citedHtml(guide.summary, guide.citations)}</p>` : `<p class="muted small">${escapeHtml(t("noGuide"))}</p>${current?.sources.length ? button("guide", `${icon("sparkle")}<span>${escapeHtml(guideJob ? t("guideWorking") : t("makeGuide"))}</span>`, "small", guideJob ? "disabled" : "") : ""}`}</section>
1751
- ${questions.length ? `<section class="block"><h3>${escapeHtml(t("questionsTitle"))}</h3><div class="chips column">${questions.map((q, i) => button("suggest", escapeHtml(q), "chip", `data-index="${i}"`)).join("")}</div></section>` : ""}
1752
- ${summary ? `<section class="block"><h3>${escapeHtml(summary.name)}</h3><p>${escapeHtml(summary.text)}</p></section>` : ""}
1753
- <section class="block passage" aria-live="polite"><h3>${escapeHtml(t("passageTitle"))}</h3>${
2431
+ <section class="block copyable"><h3>${escapeHtml(t("summaryTitle"))}${guide?.summary ? copyControls("guide") : ""}</h3>${
2432
+ guide?.summary
2433
+ ? `${guide.outdated ? `<p class="warn small">${icon("warning")}${escapeHtml(t("outdated"))} ${button("guide", escapeHtml(guideJob ? t("guideWorking") : t("regenerate")), "small", guideJob || !canAsk() ? "disabled" : "")}</p>` : ""}<div class="md">${renderMarkdown(guide.summary, guide.citations)}</div>`
2434
+ : `<p class="muted small">${escapeHtml(t("noGuide"))}</p>${current?.sources.length ? button("guide", `${icon("sparkle")}<span>${escapeHtml(guideJob ? t("guideWorking") : t("makeGuide"))}</span>`, "small", guideJob || !canAsk() ? "disabled" : "") : ""}`
2435
+ }</section>
2436
+ ${questions.length ? `<section class="block copyable"><h3>${escapeHtml(t("questionsTitle"))}${copyControls("questions")}</h3><div class="chips column">${questions.map((q, i) => button("suggest", escapeHtml(q), "chip", `data-index="${i}" ${canAsk() ? "" : "disabled"}`)).join("")}</div></section>` : ""}
2437
+ ${summary ? `<section class="block copyable"><h3>${escapeHtml(summary.name)}${copyControls("summary")}</h3><div class="md">${renderMarkdown(summary.summary, summary.citations)}</div><p>${button("summarize-again", escapeHtml(t("regenerate")), "small", `data-id="${summary.sourceId}" ${canAsk() ? "" : "disabled"}`)}</p></section>` : ""}
2438
+ <section class="block passage copyable" aria-live="polite"><h3>${escapeHtml(t("passageTitle"))}${passage ? copyControls("passage") : ""}</h3>${
1754
2439
  passage
1755
2440
  ? `<p class="muted small">${escapeHtml(passage.sourceName)}${placeLabel(passage) ? ` · ${escapeHtml(tf(...placeLabel(passage)))}` : ""}</p><blockquote>${escapeHtml(passage.text)}</blockquote>`
1756
2441
  : `<p class="muted small">${escapeHtml(t("passageEmpty"))}</p>`
@@ -1776,17 +2461,68 @@ async function refreshNotebook() {
1776
2461
  checked.add(s.id);
1777
2462
  current = fresh;
1778
2463
  }
2464
+ async function loadModels() {
2465
+ try {
2466
+ models = (await api("models.list")) ?? models;
2467
+ } catch {
2468
+ // Keep the last known list; a transport problem is handled by the not_found check.
2469
+ }
2470
+ }
1779
2471
  async function openNotebook(id) {
1780
2472
  current = await api("notebooks.get", { id });
2473
+ // The ticked scope, the guide and the chat persist in the notebook (O15).
1781
2474
  checked.clear();
1782
- for (const s of current.sources) if (s.status !== "failed") checked.add(s.id);
2475
+ const excluded = new Set(current.excluded ?? []);
2476
+ for (const s of current.sources)
2477
+ if (s.status !== "failed" && !excluded.has(s.id)) checked.add(s.id);
1783
2478
  chat = await api("chat.history", { notebookId: id });
1784
- guide = undefined;
2479
+ guide = current.guide ?? undefined;
1785
2480
  passage = undefined;
1786
2481
  summary = undefined;
1787
2482
  tab = "chat";
2483
+ await loadModels();
2484
+ render();
2485
+ }
2486
+ /** Visible again: refresh what may have changed while hidden (models, notebook, status). */
2487
+ async function refreshOnShow() {
2488
+ try {
2489
+ await loadStatus();
2490
+ if (current) {
2491
+ await loadModels();
2492
+ await refreshNotebook();
2493
+ render();
2494
+ } else {
2495
+ await loadHome();
2496
+ render();
2497
+ }
2498
+ } catch (error) {
2499
+ fail(error);
2500
+ }
2501
+ void poll();
2502
+ }
2503
+ /** After the app comes back from the unavailable state. */
2504
+ async function reloadAll() {
2505
+ await loadStatus();
2506
+ await loadHome();
2507
+ if (current) {
2508
+ try {
2509
+ await openNotebook(current.id);
2510
+ return;
2511
+ } catch {
2512
+ current = undefined;
2513
+ }
2514
+ }
1788
2515
  render();
1789
2516
  }
2517
+ function saveScope() {
2518
+ if (!current) return;
2519
+ const usable = current.sources.filter(
2520
+ (s) => s.status === "ready" || s.status === "ready_keyword",
2521
+ );
2522
+ const excluded = usable.filter((s) => !checked.has(s.id)).map((s) => s.id);
2523
+ current.excluded = excluded;
2524
+ api("notebooks.setScope", { id: current.id, excluded }).catch(fail);
2525
+ }
1790
2526
 
1791
2527
  function track(jobId, info) {
1792
2528
  if (!jobId) return;
@@ -1799,7 +2535,18 @@ async function poll() {
1799
2535
  pollBusy = true;
1800
2536
  try {
1801
2537
  if (setupJob && jobView(setupJob).running) {
1802
- setupJob = await api("jobs.get", { jobId: setupJob.id });
2538
+ try {
2539
+ setupJob = await api("jobs.get", { jobId: setupJob.id });
2540
+ } catch (error) {
2541
+ // The backend no longer knows this job (e.g. it was restarted): stop polling it.
2542
+ if (error?.message !== "not_found" || error.transport) throw error;
2543
+ setupJob = undefined;
2544
+ await loadStatus();
2545
+ }
2546
+ if (!setupJob) {
2547
+ render();
2548
+ return;
2549
+ }
1803
2550
  const slot = document.querySelector("#setup");
1804
2551
  if (slot) slot.innerHTML = setupCard();
1805
2552
  if (!jobView(setupJob).running) {
@@ -1810,7 +2557,13 @@ async function poll() {
1810
2557
  }
1811
2558
  let changed = false;
1812
2559
  for (const [jobId, info] of jobs) {
1813
- const job = await api("jobs.get", { jobId });
2560
+ let job;
2561
+ try {
2562
+ job = await api("jobs.get", { jobId });
2563
+ } catch (error) {
2564
+ if (error?.message !== "not_found" || error.transport) throw error;
2565
+ job = { state: "failed", error: "not_found" };
2566
+ }
1814
2567
  jobState.set(jobId, job);
1815
2568
  if (jobView(job).running) {
1816
2569
  if (info.kind === "index") renderSources();
@@ -1838,7 +2591,8 @@ async function poll() {
1838
2591
 
1839
2592
  async function finished(info, job) {
1840
2593
  const view = jobView(job);
1841
- if (view.failed && info.kind !== "index") fail(new Error(view.error));
2594
+ if (view.failed && info.kind !== "index" && view.error !== "not_found")
2595
+ fail(new Error(view.error));
1842
2596
  if (!current || info.notebookId !== current.id) return;
1843
2597
  if (info.kind === "index" || info.kind === "rebuild") {
1844
2598
  if (info.kind === "rebuild") rebuildJob = undefined;
@@ -1851,17 +2605,29 @@ async function finished(info, job) {
1851
2605
  chat = await api("chat.history", { notebookId: current.id });
1852
2606
  renderMessages();
1853
2607
  renderComposer();
2608
+ renderChatHead();
1854
2609
  announce(chat.at(-1)?.text ?? "");
1855
2610
  } else if (info.kind === "guide") {
1856
2611
  guideJob = undefined;
1857
2612
  if (view.done) guide = job.result;
1858
2613
  renderGuide();
1859
2614
  renderMessages();
1860
- } else if (info.kind === "summary" && view.done) {
1861
- const source = current.sources.find((s) => s.id === info.sourceId);
1862
- summary = { name: source?.name ?? "", text: job.result?.summary ?? "" };
2615
+ } else if (info.kind === "summary") {
2616
+ if (view.done) {
2617
+ const source = current.sources.find((s) => s.id === info.sourceId);
2618
+ summary = { name: source?.name ?? "", ...job.result };
2619
+ await refreshNotebook();
2620
+ }
1863
2621
  renderGuide();
1864
2622
  }
2623
+ if (view.failed && view.error === "not_found") {
2624
+ // A job the backend no longer knows: just refresh what it was about.
2625
+ if (info.kind === "ask") askJob = undefined;
2626
+ if (info.kind === "guide") guideJob = undefined;
2627
+ if (info.kind === "rebuild") rebuildJob = undefined;
2628
+ await refreshNotebook();
2629
+ render();
2630
+ }
1865
2631
  }
1866
2632
 
1867
2633
  // ---------- Actions ----------
@@ -2008,7 +2774,7 @@ async function workspaceBrowser(dir = "") {
2008
2774
 
2009
2775
  async function ask(question) {
2010
2776
  const text = question.trim();
2011
- if (!text || askJob || !current) return;
2777
+ if (!text || askJob || !current || !canAsk()) return;
2012
2778
  if (needsRemoteNotice(current)) {
2013
2779
  const ok = await confirmDialog("noticeText", "noticeOk", "noticeTitle");
2014
2780
  if (!ok) return;
@@ -2092,6 +2858,17 @@ async function handle(action, target) {
2092
2858
  await refreshNotebook();
2093
2859
  return renderSources();
2094
2860
  case "summarize": {
2861
+ // A stored summary is shown as is; it is regenerated only on request (O15).
2862
+ const source = current.sources.find((s) => s.id === target.dataset.id);
2863
+ if (source?.summary) {
2864
+ summary = { name: source.name, sourceId: source.id, ...source.summary };
2865
+ if (!panels.guide) panels.guide = true;
2866
+ tab = "guide";
2867
+ return renderNotebook();
2868
+ }
2869
+ return handle("summarize-again", target);
2870
+ }
2871
+ case "summarize-again": {
2095
2872
  const { jobId } = await api("sources.summary", { sourceId: target.dataset.id, locale });
2096
2873
  track(jobId, { kind: "summary", notebookId: current.id, sourceId: target.dataset.id });
2097
2874
  return;
@@ -2103,6 +2880,20 @@ async function handle(action, target) {
2103
2880
  renderGuide();
2104
2881
  return renderMessages();
2105
2882
  }
2883
+ case "retry":
2884
+ root.querySelector('[data-action="retry"]')?.setAttribute("disabled", "");
2885
+ return verifyAvailable();
2886
+ case "copy-text":
2887
+ case "copy-formatted": {
2888
+ const source = copySource(target.dataset.copy);
2889
+ if (!source) return;
2890
+ const { text, html } = copyPayload(source, renderMarkdown, action === "copy-formatted");
2891
+ const path = await writeClipboard({ text, ...(html ? { html } : {}) });
2892
+ lastCopyPath = path;
2893
+ if (path === "manual") return manualCopy(text, html);
2894
+ toast(t("copied"));
2895
+ return announce(t("copied"));
2896
+ }
2106
2897
  case "suggest":
2107
2898
  return ask(suggestions(guide)[Number(target.dataset.index)] ?? "");
2108
2899
  case "send":
@@ -2160,13 +2951,30 @@ root.addEventListener("change", (event) => {
2160
2951
  if (s.status === "ready" || s.status === "ready_keyword")
2161
2952
  target.checked ? checked.add(s.id) : checked.delete(s.id);
2162
2953
  renderSources();
2954
+ saveScope();
2163
2955
  } else if (target.dataset.source) {
2164
2956
  target.checked ? checked.add(target.dataset.source) : checked.delete(target.dataset.source);
2165
2957
  renderSources();
2958
+ saveScope();
2959
+ } else if (target.id === "model-select") {
2960
+ const model = target.value || null;
2961
+ api("notebooks.setModel", { id: current.id, model })
2962
+ .then(() => {
2963
+ current.model = model;
2964
+ })
2965
+ .catch((error) => {
2966
+ fail(error);
2967
+ renderChatHead();
2968
+ });
2166
2969
  }
2167
2970
  });
2168
2971
  root.addEventListener("input", (event) => {
2169
- if (event.target.id === "question") draft = event.target.value;
2972
+ if (event.target.id === "question") {
2973
+ const wasEmpty = !draft.trim();
2974
+ draft = event.target.value;
2975
+ autoGrow(event.target);
2976
+ if (wasEmpty !== !draft.trim()) renderComposer();
2977
+ }
2170
2978
  });
2171
2979
  root.addEventListener("keydown", (event) => {
2172
2980
  if (
@@ -2180,6 +2988,47 @@ root.addEventListener("keydown", (event) => {
2180
2988
  }
2181
2989
  });
2182
2990
 
2991
+ /** Text and citations behind a copy button (one shared component for every generated block). */
2992
+ const copySource = (ref) =>
2993
+ copySourceFor(ref, {
2994
+ chat,
2995
+ guide,
2996
+ summary,
2997
+ passage,
2998
+ place: (p) => (placeLabel(p) ? tf(...placeLabel(p)) : ""),
2999
+ });
3000
+
3001
+ /** Last resort when the frame may not write the clipboard: show the content preselected. */
3002
+ function manualCopy(text, html) {
3003
+ openDialog(
3004
+ () =>
3005
+ `<div class="dialog-body wide"><p class="lead">${escapeHtml(t("copyManual"))}</p>${
3006
+ html
3007
+ ? `<div id="copy-area" class="copy-area md" contenteditable="true">${html}</div>`
3008
+ : `<textarea id="copy-area" class="copy-area" readonly rows="10">${escapeHtml(text)}</textarea>`
3009
+ }<div class="row end">${button("dialog-cancel", escapeHtml(t("close")))}</div></div>`,
3010
+ () => {
3011
+ const area = dialog.querySelector("#copy-area");
3012
+ if (!area) return;
3013
+ if (area.select) area.select();
3014
+ else {
3015
+ const range = document.createRange();
3016
+ range.selectNodeContents(area);
3017
+ const selection = window.getSelection();
3018
+ selection?.removeAllRanges();
3019
+ selection?.addRange(range);
3020
+ }
3021
+ area.focus();
3022
+ },
3023
+ );
3024
+ dialog.onclick = (event) => {
3025
+ if (event.target.closest("[data-action]")?.dataset.action === "dialog-cancel") closeDialog();
3026
+ };
3027
+ }
3028
+ /** Which copy path worked last ("clipboard", "execCommand", "manual"); read by the visual check. */
3029
+ let lastCopyPath;
3030
+ globalThis.semanticLastCopyPath = () => lastCopyPath;
3031
+
2183
3032
  // ---------- Start ----------
2184
3033
  await connected;
2185
3034
  try {