@saltcorn/agents 0.8.7 → 0.8.8

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/agent-view.js CHANGED
@@ -50,6 +50,7 @@ const {
50
50
  get_skill_instances,
51
51
  saveInteractions,
52
52
  extractText,
53
+ stripMarkdownImages,
53
54
  } = require("./common");
54
55
  const MarkdownIt = require("markdown-it"),
55
56
  md = new MarkdownIt({ html: true, breaks: true, linkify: true });
@@ -103,8 +104,13 @@ const configuration_workflow = (req) =>
103
104
  {
104
105
  name: "prev_runs_closed",
105
106
  label: "Initially closed",
107
+ sublabel:
108
+ "Only available for Standard / No card layouts. Modern chat uses a hamburger drawer.",
106
109
  type: "Bool",
107
- showIf: { show_prev_runs: true },
110
+ showIf: {
111
+ show_prev_runs: true,
112
+ layout: ["Standard", "No card"],
113
+ },
108
114
  },
109
115
  {
110
116
  name: "stream",
@@ -155,6 +161,20 @@ const configuration_workflow = (req) =>
155
161
  ],
156
162
  },
157
163
  },
164
+ {
165
+ name: "input_mode",
166
+ label: "Input position",
167
+ sublabel:
168
+ "Where the message input appears within the chat panel.",
169
+ type: "String",
170
+ attributes: {
171
+ options: ["Inline", "Footer (sticky)"],
172
+ },
173
+ default: "Inline",
174
+ showIf: {
175
+ layout: ["Modern chat", "Modern chat - no card"],
176
+ },
177
+ },
158
178
  {
159
179
  name: "image_upload",
160
180
  label: "Upload images",
@@ -281,6 +301,7 @@ const run = async (
281
301
  audio_recorder,
282
302
  layout,
283
303
  shared,
304
+ input_mode,
284
305
  },
285
306
  state,
286
307
  { res, req },
@@ -416,9 +437,9 @@ const run = async (
416
437
  interactMarkups.push(
417
438
  wrapSegment(
418
439
  typeof interact.content === "string"
419
- ? md.render(interact.content)
440
+ ? md.render(stripMarkdownImages(interact.content))
420
441
  : typeof interact.content?.content === "string"
421
- ? md.render(interact.content.content)
442
+ ? md.render(stripMarkdownImages(interact.content.content))
422
443
  : interact.content,
423
444
  action.name,
424
445
  false,
@@ -462,7 +483,8 @@ const run = async (
462
483
  runInteractions = interactMarkups.join("");
463
484
  }
464
485
  const skill_form_widgets = [];
465
- for (const skill of get_skill_instances(action.configuration)) {
486
+ const _skill_instances = get_skill_instances(action.configuration);
487
+ for (const skill of _skill_instances) {
466
488
  if (skill.formWidget)
467
489
  skill_form_widgets.push(
468
490
  await skill.formWidget({
@@ -472,15 +494,24 @@ const run = async (
472
494
  }),
473
495
  );
474
496
  }
497
+ const hasTTS = _skill_instances.some(
498
+ (s) => s.constructor && s.constructor.skill_name === "Text to speech",
499
+ );
475
500
 
476
501
  const debugMode = is_debug_mode(action.configuration, req.user);
477
502
  const dyn_updates = getState().getConfig("enable_dynamic_updates", true);
478
503
 
479
504
  const rndid = Math.floor(Math.random() * 16777215).toString(16);
505
+ const footerInputMode = input_mode === "Footer (sticky)";
480
506
  const input_form = form(
481
507
  {
482
508
  onsubmit: `event.preventDefault();const _fd=new FormData(this);spin_send_button();view_post('${viewname}', 'interact', _fd, ${dyn_updates ? "null" : "processCopilotResponse"});return false;`,
483
- class: ["form-namespace copilot mt-2 agent-view"],
509
+ class: [
510
+ "form-namespace copilot agent-view",
511
+ footerInputMode
512
+ ? "mt-auto sticky-bottom bg-body py-1"
513
+ : "mt-2",
514
+ ],
484
515
  method: "post",
485
516
  },
486
517
  input({
@@ -557,17 +588,13 @@ const run = async (
557
588
  { class: "modern-sessions-header" },
558
589
  div(
559
590
  { class: "d-flex align-items-center" },
560
- i({
561
- class: "fas fa-caret-down me-2 session-open-sessions",
562
- onclick: "close_session_list()",
563
- }),
564
591
  i({ class: "fas fa-comments me-2 text-primary" }),
565
592
  span({ class: "fw-semibold" }, req.__("Sessions")),
566
593
  ),
567
594
  button(
568
595
  {
569
596
  type: "button",
570
- class: "btn btn-primary btn-sm rounded-pill px-3",
597
+ class: "btn btn-primary btn-sm px-3",
571
598
  onclick: "unset_state_field('run_id', this)",
572
599
  title: "New chat",
573
600
  },
@@ -653,6 +680,10 @@ const run = async (
653
680
  : "";
654
681
 
655
682
  const main_inner = div(
683
+ {
684
+ class: "d-flex flex-column flex-grow-1",
685
+ style: "min-height:0",
686
+ },
656
687
  div(
657
688
  {
658
689
  class: "open-prev-runs",
@@ -908,21 +939,311 @@ const run = async (
908
939
  `$('form.agent-view input[name=page_load_tag]').val(window._sc_pageloadtag)`,
909
940
  ),
910
941
  initial_q && domReady("$('form.copilot').submit()"),
942
+ domReady(`
943
+ (function() {
944
+ var VIEWNAME = ${JSON.stringify(viewname)};
945
+
946
+ /* Container-responsive: ResizeObserver toggles .chat-wide based on
947
+ the shell's own width (independent of viewport / theme sidebars).
948
+ Also sets a precise min-height in footer mode so the input form
949
+ reaches the visible viewport bottom regardless of any navbar above. */
950
+ function applyShellFooterHeight(shell) {
951
+ if (!shell.classList.contains('input-footer')) return;
952
+ var rect = shell.getBoundingClientRect();
953
+ var vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
954
+ var h = Math.max(280, Math.floor(vh - rect.top - 8));
955
+ shell.style.minHeight = h + 'px';
956
+ }
957
+ document.querySelectorAll('.modern-chat-shell').forEach(function(shell) {
958
+ if (shell._scChatResizeBound) return;
959
+ shell._scChatResizeBound = true;
960
+ var apply = function(w) { shell.classList.toggle('chat-wide', w >= 720); };
961
+ apply(shell.getBoundingClientRect().width);
962
+ applyShellFooterHeight(shell);
963
+ if ('ResizeObserver' in window) {
964
+ var ro = new ResizeObserver(function(entries) {
965
+ apply(entries[0].contentRect.width);
966
+ applyShellFooterHeight(shell);
967
+ });
968
+ ro.observe(shell);
969
+ } else {
970
+ window.addEventListener('resize', function() {
971
+ apply(shell.getBoundingClientRect().width);
972
+ applyShellFooterHeight(shell);
973
+ });
974
+ }
975
+ window.addEventListener('resize', function() { applyShellFooterHeight(shell); });
976
+ if (window.visualViewport) {
977
+ window.visualViewport.addEventListener('resize', function() {
978
+ applyShellFooterHeight(shell);
979
+ });
980
+ }
981
+ });
982
+
983
+ /* Clean Bootstrap-leftover backdrop/styles after pjax view re-render */
984
+ function cleanupOffcanvasState() {
985
+ document.querySelectorAll('.offcanvas-backdrop').forEach(function(el) { el.remove(); });
986
+ document.body.style.removeProperty('overflow');
987
+ document.body.style.removeProperty('padding-right');
988
+ document.body.classList.remove('modal-open');
989
+ }
990
+ if (!window._scAgentOffcanvasCleanupBound) {
991
+ $(document).on('pjaxlinks_loaded', cleanupOffcanvasState);
992
+ window._scAgentOffcanvasCleanupBound = true;
993
+ }
994
+ /* Hide offcanvas when user picks a session from inside the drawer */
995
+ if (!window._scAgentOffcanvasClickBound) {
996
+ document.addEventListener('click', function(e) {
997
+ var trigger = e.target.closest(
998
+ '.modern-sessions-offcanvas .prevcopilotrun, ' +
999
+ '.modern-sessions-offcanvas .modern-sessions-header button.btn-primary'
1000
+ );
1001
+ if (!trigger) return;
1002
+ var ofc = trigger.closest('.offcanvas');
1003
+ if (!ofc || !window.bootstrap) return;
1004
+ var inst = bootstrap.Offcanvas.getInstance(ofc);
1005
+ if (inst) inst.hide();
1006
+ });
1007
+ window._scAgentOffcanvasClickBound = true;
1008
+ }
1009
+
1010
+ /* === TTS === */
1011
+ function getSharedAudio() {
1012
+ if (!window._ttsSharedAudio) {
1013
+ var a = new Audio();
1014
+ a.preload = 'auto';
1015
+ a.className = 'agent-tts-audio d-none';
1016
+ document.body.appendChild(a);
1017
+ window._ttsSharedAudio = a;
1018
+ }
1019
+ return window._ttsSharedAudio;
1020
+ }
1021
+ function primeTtsUnlock() {
1022
+ if (window._ttsUnlocked) return;
1023
+ try {
1024
+ var a = getSharedAudio();
1025
+ a.src = 'data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQxAADB8AhSmxhIIEVCSiJrDCQBTcu3UrAIwUdkRgQbFAZC1CQEwTJ9mjRvBA4UOLD8nKVOWfh+UlK3z/177OXrfOdKl7pyn3Xf//FJ';
1026
+ var p = a.play();
1027
+ if (p && p.catch) p.catch(function() {});
1028
+ a.pause();
1029
+ a.currentTime = 0;
1030
+ window._ttsUnlocked = true;
1031
+ } catch(e) {}
1032
+ }
1033
+ window.toggle_agent_tts = function(btn, viewname) {
1034
+ var key = 'agent-tts-' + (viewname || VIEWNAME);
1035
+ var newOn = btn.getAttribute('data-tts-state') !== 'on';
1036
+ btn.setAttribute('data-tts-state', newOn ? 'on' : 'off');
1037
+ btn.classList.toggle('bg-primary-subtle', newOn);
1038
+ btn.classList.toggle('text-primary', newOn);
1039
+ try { localStorage.setItem(key, newOn ? '1' : '0'); } catch(e){}
1040
+ if (newOn) {
1041
+ primeTtsUnlock();
1042
+ } else {
1043
+ var a = window._ttsSharedAudio;
1044
+ if (a) { try { a.pause(); } catch(e){} a.src = ''; }
1045
+ }
1046
+ };
1047
+ /* Restore toggle state on load */
1048
+ (function() {
1049
+ try {
1050
+ var btn = document.querySelector('.modern-tts-toggle');
1051
+ if (!btn) return;
1052
+ if (localStorage.getItem('agent-tts-' + VIEWNAME) === '1') {
1053
+ btn.setAttribute('data-tts-state', 'on');
1054
+ btn.classList.add('bg-primary-subtle');
1055
+ btn.classList.add('text-primary');
1056
+ }
1057
+ } catch(e) {}
1058
+ })();
1059
+
1060
+ function ttsForBubble(bubble) {
1061
+ if (!bubble || bubble.dataset.ttsDispatched) return;
1062
+ var btn = document.querySelector('.modern-tts-toggle[data-tts-state="on"]');
1063
+ if (!btn) return;
1064
+ // Skip bubbles that are just tool-rendered output (image card etc.) —
1065
+ // they have a Bootstrap card inside and no meaningful narrative text.
1066
+ if (bubble.querySelector('.card.bg-secondary-subtle')) return;
1067
+ bubble.dataset.ttsDispatched = '1';
1068
+ var text = (bubble.innerText || bubble.textContent || '').trim();
1069
+ if (!text || text.length < 2) return;
1070
+ var fd = new FormData();
1071
+ fd.append('text', text);
1072
+ var csrf = ($('input[name=_csrf]').first().val()) || '';
1073
+ fd.append('_csrf', csrf);
1074
+ fetch('/view/' + VIEWNAME + '/tts', {
1075
+ method: 'POST',
1076
+ body: fd,
1077
+ credentials: 'same-origin',
1078
+ }).then(function(res) {
1079
+ if (!res.ok) { res.text().then(function(t){ console.warn('tts http ' + res.status + ': ' + t); }); return null; }
1080
+ var ct = res.headers.get('content-type') || '';
1081
+ if (ct.indexOf('audio') !== 0) { res.text().then(function(t){ console.warn('tts non-audio:', t); }); return null; }
1082
+ return res.blob();
1083
+ }).then(function(blob) {
1084
+ if (!blob) return;
1085
+ var url = URL.createObjectURL(blob);
1086
+ var a = getSharedAudio();
1087
+ a.src = url;
1088
+ var p = a.play();
1089
+ if (p && p.catch) p.catch(function(err) { console.warn('tts play blocked:', err); });
1090
+ var clean = function() { URL.revokeObjectURL(url); a.removeEventListener('ended', clean); };
1091
+ a.addEventListener('ended', clean);
1092
+ }).catch(function(err) { console.warn('tts fetch err:', err); });
1093
+ }
1094
+
1095
+ var interactionsRoot = document.getElementById('copilotinteractions');
1096
+ if (interactionsRoot && !interactionsRoot._scTtsObserver) {
1097
+ var mo = new MutationObserver(function(muts) {
1098
+ muts.forEach(function(m) {
1099
+ m.addedNodes && m.addedNodes.forEach(function(n) {
1100
+ if (!n || n.nodeType !== 1) return;
1101
+ if (n.matches && n.matches('.chat-message.chat-assistant .chat-bubble')) {
1102
+ ttsForBubble(n);
1103
+ }
1104
+ if (n.matches && n.matches('.chat-message.chat-assistant')) {
1105
+ var b = n.querySelector('.chat-bubble');
1106
+ if (b) ttsForBubble(b);
1107
+ }
1108
+ if (n.querySelectorAll) {
1109
+ n.querySelectorAll('.chat-message.chat-assistant .chat-bubble').forEach(ttsForBubble);
1110
+ }
1111
+ });
1112
+ });
1113
+ });
1114
+ mo.observe(interactionsRoot, { childList: true, subtree: true });
1115
+ interactionsRoot._scTtsObserver = mo;
1116
+ }
1117
+
1118
+ /* PWA-standalone image download via Web Share API */
1119
+ function isPwaStandalone() {
1120
+ return (window.navigator.standalone === true) ||
1121
+ (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
1122
+ }
1123
+ if (!window._scAgentImageShareBound) {
1124
+ document.addEventListener('click', function(e) {
1125
+ var link = e.target.closest('.agent-image-download');
1126
+ if (!link) return;
1127
+ if (!isPwaStandalone()) return;
1128
+ if (!navigator.share || typeof navigator.canShare !== 'function') return;
1129
+ e.preventDefault();
1130
+ e.stopPropagation();
1131
+ var url = link.getAttribute('href');
1132
+ var dlname = link.getAttribute('download') || ('image-' + Date.now() + '.png');
1133
+ fetch(url).then(function(r) { return r.blob(); }).then(function(blob) {
1134
+ var file = new File([blob], dlname, { type: blob.type });
1135
+ if (!navigator.canShare({ files: [file] })) { window.open(url, '_blank'); return; }
1136
+ return navigator.share({ files: [file], title: dlname });
1137
+ }).catch(function(err) { console.warn('share err', err); });
1138
+ }, true);
1139
+ window._scAgentImageShareBound = true;
1140
+ }
1141
+ })();
1142
+ `),
911
1143
  ),
912
1144
  );
913
1145
  const isModern = layout && layout.startsWith("Modern chat");
1146
+ const viewObj = View.findOne({ name: viewname });
1147
+ const headerTitle = viewObj?.description?.trim() || action.name || "";
1148
+ const modern_chat_header =
1149
+ isModern && (show_prev_runs || hasTTS)
1150
+ ? div(
1151
+ {
1152
+ class:
1153
+ "modern-chat-header d-flex align-items-center gap-2 px-3 py-2 border-bottom flex-shrink-0",
1154
+ },
1155
+ show_prev_runs
1156
+ ? button(
1157
+ {
1158
+ type: "button",
1159
+ class:
1160
+ "btn btn-sm btn-outline-secondary modern-chat-hamburger",
1161
+ "data-bs-toggle": "offcanvas",
1162
+ "data-bs-target": "#agent-sessions-" + rndid,
1163
+ "aria-controls": "agent-sessions-" + rndid,
1164
+ title: req.__("Sessions"),
1165
+ },
1166
+ i({ class: "fas fa-bars" }),
1167
+ )
1168
+ : "",
1169
+ span(
1170
+ { class: "flex-grow-1 text-truncate fw-semibold" },
1171
+ headerTitle,
1172
+ ),
1173
+ hasTTS
1174
+ ? button(
1175
+ {
1176
+ type: "button",
1177
+ class:
1178
+ "btn btn-sm btn-outline-secondary modern-tts-toggle",
1179
+ onclick: `toggle_agent_tts(this, '${viewname}')`,
1180
+ title: req.__("Read responses aloud"),
1181
+ "data-tts-state": "off",
1182
+ },
1183
+ i({ class: "fas fa-volume-up" }),
1184
+ )
1185
+ : "",
1186
+ )
1187
+ : "";
914
1188
  const main_chat =
915
1189
  layout === "Modern chat"
916
1190
  ? div(
917
- { class: "card" },
918
- div({ class: "card-body modern-chat-layout" }, main_inner),
1191
+ { class: "card modern-chat-card d-flex flex-column" },
1192
+ modern_chat_header,
1193
+ div(
1194
+ { class: "card-body modern-chat-layout p-0 d-flex flex-column" },
1195
+ main_inner,
1196
+ ),
919
1197
  )
920
1198
  : layout === "Modern chat - no card"
921
- ? div({ class: "modern-chat-layout" }, main_inner)
1199
+ ? div(
1200
+ { class: "modern-chat-noncard d-flex flex-column" },
1201
+ modern_chat_header,
1202
+ div(
1203
+ { class: "modern-chat-layout d-flex flex-column" },
1204
+ main_inner,
1205
+ ),
1206
+ )
922
1207
  : layout === "No card"
923
1208
  ? div({ class: "mx-1" }, main_inner)
924
1209
  : div({ class: "card" }, div({ class: "card-body" }, main_inner));
925
1210
 
1211
+ if (isModern) {
1212
+ const shellExtraClass =
1213
+ input_mode === "Footer (sticky)" ? " input-footer" : "";
1214
+ const mainColumnAttrs = {
1215
+ class: "modern-chat-main flex-grow-1 d-flex flex-column",
1216
+ style: "min-width:0",
1217
+ };
1218
+ return show_prev_runs
1219
+ ? div(
1220
+ { class: "modern-chat-shell d-flex gap-4" + shellExtraClass },
1221
+ div(
1222
+ {
1223
+ class:
1224
+ "offcanvas offcanvas-start modern-sessions-offcanvas border-end pe-3" +
1225
+ (prev_runs_closed ? " sessions-initially-closed" : ""),
1226
+ id: "agent-sessions-" + rndid,
1227
+ tabindex: "-1",
1228
+ "aria-labelledby": "agent-sessions-label-" + rndid,
1229
+ },
1230
+ div(
1231
+ { class: "offcanvas-body p-2" },
1232
+ div({ class: "prev-runs-list" }, prev_runs_side_bar),
1233
+ ),
1234
+ ),
1235
+ div(mainColumnAttrs, main_chat),
1236
+ )
1237
+ : div(
1238
+ {
1239
+ class:
1240
+ "modern-chat-shell modern-chat-shell-no-sidebar d-flex" +
1241
+ shellExtraClass,
1242
+ },
1243
+ div(mainColumnAttrs, main_chat),
1244
+ );
1245
+ }
1246
+
926
1247
  return show_prev_runs
927
1248
  ? div(
928
1249
  { class: "row gx-3" },
@@ -1335,6 +1656,60 @@ const execute_user_action = async (
1335
1656
  };
1336
1657
  };
1337
1658
 
1659
+ // Text-to-speech route. Streams audio directly to the browser via
1660
+ // llm_text_to_speech with stream:true so playback can start as soon as the
1661
+ // first chunk arrives. The TextToSpeech skill must be configured on the
1662
+ // referenced agent action.
1663
+ const tts = async (table_id, viewname, config, body, { req, res }) => {
1664
+ const { text } = body;
1665
+ if (!text || !text.trim()) return { json: { error: "no text" } };
1666
+ const stream_fn = getState().functions.llm_text_to_speech;
1667
+ if (!stream_fn)
1668
+ return {
1669
+ json: {
1670
+ error:
1671
+ "llm_text_to_speech not registered — update @saltcorn/large-language-model to >= 1.1.0",
1672
+ },
1673
+ };
1674
+ const action =
1675
+ config.agent_action || (await Trigger.findOne({ id: config.action_id }));
1676
+ const skills = get_skill_instances(action.configuration);
1677
+ const tts_skill = skills.find(
1678
+ (s) => s.constructor && s.constructor.skill_name === "Text to speech",
1679
+ );
1680
+ if (!tts_skill) return { json: { error: "tts skill not configured" } };
1681
+ const ttsOpts = {
1682
+ voice: tts_skill.voice,
1683
+ speed: tts_skill.speed,
1684
+ response_format: tts_skill.format,
1685
+ instructions: tts_skill.instructions,
1686
+ stream: true,
1687
+ };
1688
+ try {
1689
+ const result = await stream_fn.run(text, ttsOpts);
1690
+ const ext = result?.output_format || tts_skill.format || "mp3";
1691
+ const mime = ext === "mp3" ? "audio/mpeg" : `audio/${ext}`;
1692
+ res.setHeader("Content-Type", mime);
1693
+ res.setHeader("Cache-Control", "no-store");
1694
+ res.setHeader("X-Accel-Buffering", "no");
1695
+ const reader = result.stream.getReader();
1696
+ while (true) {
1697
+ const { done, value } = await reader.read();
1698
+ if (done) break;
1699
+ if (!res.write(Buffer.from(value))) {
1700
+ await new Promise((r) => res.once("drain", r));
1701
+ }
1702
+ }
1703
+ res.end();
1704
+ } catch (e) {
1705
+ getState().log(2, "tts stream pump error: " + (e?.message || e));
1706
+ try {
1707
+ res.end();
1708
+ } catch (_) {}
1709
+ }
1710
+ return;
1711
+ };
1712
+
1338
1713
  module.exports = {
1339
1714
  name: "Agent Chat",
1340
1715
  configuration_workflow,
@@ -1350,6 +1725,7 @@ module.exports = {
1350
1725
  skillroute,
1351
1726
  execute_user_action,
1352
1727
  cancel,
1728
+ tts,
1353
1729
  },
1354
1730
  mobile_render_server_side: true,
1355
1731
  };
package/agents.css CHANGED
@@ -190,7 +190,7 @@ p.prevrun_content {
190
190
  }
191
191
  .modern-chat-layout .chat-bubble {
192
192
  padding: 0.6rem 1rem;
193
- border-radius: 1rem;
193
+ border-radius: 0.5rem;
194
194
  line-height: 1.5;
195
195
  word-wrap: break-word;
196
196
  overflow-wrap: break-word;
@@ -198,7 +198,7 @@ p.prevrun_content {
198
198
  .modern-chat-layout .chat-user .chat-bubble {
199
199
  background: #0d6efd;
200
200
  color: #fff;
201
- border-bottom-right-radius: 0.25rem;
201
+ border-bottom-right-radius: 0.15rem;
202
202
  }
203
203
  .modern-chat-layout .chat-assistant .chat-bubble {
204
204
  background: var(
@@ -206,7 +206,7 @@ p.prevrun_content {
206
206
  var(--bs-secondary-bg-subtle, #f0f2f5)
207
207
  );
208
208
  color: var(--tblr-body-color, var(--bs-body-color, #212529));
209
- border-bottom-left-radius: 0.25rem;
209
+ border-bottom-left-radius: 0.15rem;
210
210
  }
211
211
  /* Markdown content inside bubbles */
212
212
  .modern-chat-layout .chat-bubble h1,
@@ -305,7 +305,7 @@ p.prevrun_content {
305
305
  margin-top: 0.5rem;
306
306
  }
307
307
  .modern-chat-layout .copilot-entry textarea {
308
- border-radius: 1.5rem;
308
+ border-radius: 0.5rem;
309
309
  padding: 0.6rem 1rem;
310
310
  resize: none;
311
311
  }
@@ -325,23 +325,24 @@ p.prevrun_content {
325
325
  display: flex;
326
326
  align-items: center;
327
327
  justify-content: space-between;
328
- padding: 0.6rem 0.75rem;
329
- margin-bottom: 0.75rem;
328
+ padding: 0.5rem 0.6rem;
329
+ margin-bottom: 0.5rem;
330
330
  background: var(
331
331
  --tblr-secondary-bg-subtle,
332
332
  var(--bs-secondary-bg-subtle, #f8f9fa)
333
333
  );
334
- border-radius: 0.75rem;
334
+ border-radius: 0.3rem;
335
335
  border-bottom: 1px solid
336
336
  var(--tblr-border-color, var(--bs-border-color, #dee2e6));
337
337
  position: sticky;
338
338
  top: 0;
339
339
  z-index: 1;
340
340
  }
341
+ .modern-sessions-header .btn { border-radius: 0.3rem; }
341
342
  .modern-sessions .modern-session-item {
342
- border-radius: 0.75rem;
343
- padding: 0.65rem 0.75rem;
344
- margin-bottom: 0.4rem;
343
+ border-radius: 0.3rem;
344
+ padding: 0.55rem 0.65rem;
345
+ margin-bottom: 0.35rem;
345
346
  border: 1px solid var(--tblr-border-color, var(--bs-border-color, #dee2e6));
346
347
  cursor: pointer;
347
348
  transition: all 0.15s ease;
@@ -398,4 +399,83 @@ p.prevrun_content {
398
399
  .copy-to-clipboard-elem.copy-success::before {
399
400
  content: "✓";
400
401
  color: green;
401
- }
402
+ }
403
+
404
+ /* === Modern-chat container-responsive shell ===
405
+ Bootstrap offcanvas is viewport-driven; we want a container-driven
406
+ version so themes with their own sidebars don't break the chat
407
+ layout. A ResizeObserver toggles .chat-wide on .modern-chat-shell at
408
+ >= 720px container width and the rules below override Bootstrap's
409
+ offcanvas hiding so it renders inline as a column.
410
+
411
+ Everything else uses Bootstrap utility classes in the markup; only
412
+ the rules below are custom and cannot be expressed via utilities. */
413
+ .modern-chat-shell.chat-wide .modern-sessions-offcanvas {
414
+ position: static !important;
415
+ transform: none !important;
416
+ visibility: visible !important;
417
+ z-index: auto !important;
418
+ width: 260px;
419
+ max-width: 260px;
420
+ flex: 0 0 260px;
421
+ background: transparent;
422
+ height: auto;
423
+ box-shadow: none;
424
+ }
425
+ .modern-chat-shell.chat-wide .modern-chat-hamburger { display: none; }
426
+
427
+ /* Bootstrap has no `:empty` utility — collapse the interaction list when
428
+ it has no content so the input form sits directly under the header. */
429
+ .modern-chat-layout #copilotinteractions:empty { display: none; }
430
+
431
+ /* Footer mode: the shell's min-height is set dynamically by JS to fill
432
+ the visible viewport from its top edge down to the bottom (so a navbar
433
+ above doesn't push the footer off-screen and an embed without navbar
434
+ doesn't leave a giant gap). The fallback calc() is used until JS runs.
435
+ Bootstrap has no min-h-0 utility and no descendant-targeted flex-grow,
436
+ so the four rules below are required to propagate the shell's height
437
+ down through the card and card-body to the chat-layout. */
438
+ .modern-chat-shell.input-footer { min-height: calc(100dvh - 4rem); }
439
+ .modern-chat-shell.input-footer .modern-chat-card,
440
+ .modern-chat-shell.input-footer .modern-chat-noncard {
441
+ flex: 1 1 auto;
442
+ min-height: 0;
443
+ }
444
+ .modern-chat-shell.input-footer .modern-chat-card .card-body {
445
+ flex: 1 1 auto;
446
+ min-height: 0;
447
+ }
448
+ .modern-chat-shell.input-footer #copilotinteractions:empty { display: block; }
449
+
450
+ /* Narrow-mode bleed: when the chat container is mobile-narrow, eat the
451
+ parent's gutter padding so the chat uses (nearly) the full screen
452
+ width — Bootstrap has no automatic mobile-only negative-margin utility. */
453
+ .modern-chat-shell:not(.chat-wide) {
454
+ margin-left: -0.75rem;
455
+ margin-right: -0.75rem;
456
+ }
457
+
458
+ /* Generated-image hover-reveal download/share overlay (Bootstrap has no
459
+ hover-fade utility). */
460
+ .agent-generated-image { position: relative; display: inline-block; }
461
+ .agent-image-download {
462
+ position: absolute;
463
+ top: 6px;
464
+ right: 6px;
465
+ background: rgba(0, 0, 0, 0.55);
466
+ color: #fff;
467
+ width: 28px;
468
+ height: 28px;
469
+ border-radius: 50%;
470
+ display: flex;
471
+ align-items: center;
472
+ justify-content: center;
473
+ text-decoration: none;
474
+ opacity: 0;
475
+ transition: opacity 0.15s ease-in-out;
476
+ z-index: 5;
477
+ }
478
+ .agent-image-download:hover { color: #fff; background: rgba(0, 0, 0, 0.75); }
479
+ .agent-generated-image:hover .agent-image-download,
480
+ .agent-image-download:focus { opacity: 1; }
481
+ @media (hover: none) { .agent-image-download { opacity: 1; } }
package/common.js CHANGED
@@ -33,6 +33,7 @@ const get_skills = () => {
33
33
  require("./skills/Table"),
34
34
  require("./skills/PreloadData"),
35
35
  require("./skills/GenerateImage"),
36
+ require("./skills/TextToSpeech"),
36
37
  require("./skills/ModelContextProtocol"),
37
38
  require("./skills/PromptPicker"),
38
39
  require("./skills/ModelPicker"),
@@ -244,6 +245,14 @@ function extractText(html) {
244
245
  return html.replace(/<[^>]*>/g, '');
245
246
  }
246
247
 
248
+ // Strip markdown image syntax ![alt](url) from assistant text so that the LLM
249
+ // can't leak a broken/duplicate image reference next to a tool-rendered image
250
+ // bubble. Plain links are preserved.
251
+ function stripMarkdownImages(s) {
252
+ if (typeof s !== "string") return s;
253
+ return s.replace(/!\[[^\]]*\]\([^)]*\)/g, "").trim();
254
+ }
255
+
247
256
  const process_interaction = async (
248
257
  run,
249
258
  config,
@@ -359,7 +368,7 @@ const process_interaction = async (
359
368
  add_response(
360
369
  req?.disable_markdown_render
361
370
  ? answer
362
- : wrapSegment(md.render(answer.content), agent_label, false, layout),
371
+ : wrapSegment(md.render(stripMarkdownImages(answer.content)), agent_label, false, layout),
363
372
  );
364
373
  }
365
374
 
@@ -372,7 +381,7 @@ const process_interaction = async (
372
381
  add_response(
373
382
  req?.disable_markdown_render
374
383
  ? answer
375
- : wrapSegment(md.render(answer.content), agent_label, false, layout),
384
+ : wrapSegment(md.render(stripMarkdownImages(answer.content)), agent_label, false, layout),
376
385
  );
377
386
  //const actions = [];
378
387
  let hasResult = false;
@@ -708,7 +717,7 @@ const process_interaction = async (
708
717
  add_response(
709
718
  req?.disable_markdown_render
710
719
  ? answer
711
- : wrapSegment(md.render(answer), agent_label, false, layout),
720
+ : wrapSegment(md.render(stripMarkdownImages(answer)), agent_label, false, layout),
712
721
  );
713
722
  if (dyn_updates && !is_sub_agent)
714
723
  getState().emitDynamicUpdate(
@@ -752,5 +761,6 @@ module.exports = {
752
761
  is_debug_mode,
753
762
  get_initial_interactions,
754
763
  nubBy,
755
- extractText
764
+ extractText,
765
+ stripMarkdownImages,
756
766
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/agents",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "AI agents for Saltcorn",
5
5
  "main": "index.js",
6
6
  "dependencies": {
@@ -0,0 +1,93 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+ const File = require("@saltcorn/data/models/file");
3
+
4
+ class TextToSpeech {
5
+ static skill_name = "Text to speech";
6
+
7
+ get skill_label() {
8
+ return "Text to speech";
9
+ }
10
+
11
+ constructor(cfg) {
12
+ Object.assign(this, cfg);
13
+ }
14
+
15
+ static async configFields() {
16
+ return [
17
+ {
18
+ name: "voice",
19
+ label: "Voice",
20
+ type: "String",
21
+ required: true,
22
+ attributes: {
23
+ options: [
24
+ "alloy",
25
+ "ash",
26
+ "ballad",
27
+ "coral",
28
+ "echo",
29
+ "fable",
30
+ "nova",
31
+ "onyx",
32
+ "sage",
33
+ "shimmer",
34
+ "verse",
35
+ ],
36
+ },
37
+ default: "nova",
38
+ },
39
+ {
40
+ name: "speed",
41
+ label: "Speed",
42
+ type: "Float",
43
+ attributes: { min: 0.25, max: 4, decimal_places: 2 },
44
+ default: 1.0,
45
+ },
46
+ {
47
+ name: "format",
48
+ label: "Audio format",
49
+ type: "String",
50
+ attributes: { options: ["mp3", "opus", "aac", "flac", "wav"] },
51
+ default: "mp3",
52
+ },
53
+ {
54
+ name: "instructions",
55
+ label: "Voice instructions",
56
+ type: "String",
57
+ fieldview: "textarea",
58
+ sublabel:
59
+ "Optional. Only used with gpt-4o-mini-tts. E.g. 'Speak slowly and friendly.'",
60
+ },
61
+ ];
62
+ }
63
+
64
+ // Server-side synthesis helper, called from agent-view.js tts route
65
+ // (not exposed as an LLM tool — the agent UI generates audio for the
66
+ // verbatim final assistant text after each response).
67
+ async synthesize(text, req) {
68
+ const fn = getState().functions.llm_text_to_speech;
69
+ if (!fn)
70
+ throw new Error(
71
+ "LLM plugin does not provide llm_text_to_speech (please update @saltcorn/large-language-model to >= 1.1.0)",
72
+ );
73
+ if (!text || !text.trim()) throw new Error("No text to speak.");
74
+ const result = await fn.run(text, {
75
+ voice: this.voice,
76
+ speed: this.speed,
77
+ response_format: this.format,
78
+ instructions: this.instructions,
79
+ });
80
+ const ext = result?.output_format || this.format || "mp3";
81
+ const mime = ext === "mp3" ? "audio/mpeg" : `audio/${ext}`;
82
+ const file = await File.from_contents(
83
+ `tts.${ext}`,
84
+ mime,
85
+ result.buffer,
86
+ req?.user?.id,
87
+ 100,
88
+ );
89
+ return { filename: file.path_to_serve };
90
+ }
91
+ }
92
+
93
+ module.exports = TextToSpeech;