@saltcorn/agents 0.8.7 → 0.8.9

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 },
@@ -316,14 +337,25 @@ const run = async (
316
337
  let hasInputForm = true;
317
338
 
318
339
  const initial_q = state.run_id ? undefined : state._q;
340
+ let run;
319
341
  if (state.run_id) {
320
- let run = prevRuns ? prevRuns.find((r) => r.id == state.run_id) : null;
342
+ run = prevRuns ? prevRuns.find((r) => r.id == state.run_id) : null;
321
343
  if (!run)
322
344
  run = await WorkflowRun.findOne({
323
345
  trigger_id: action.id,
324
- ...(shared ? {} : { started_by: req.user?.id }),
346
+ //...(shared ? {} : { started_by: req.user?.id }),
325
347
  id: state.run_id,
326
348
  });
349
+
350
+ if (
351
+ run &&
352
+ !shared &&
353
+ run.started_by != req.user?.id &&
354
+ run.context.share_token !== (state.share_token || "none")
355
+ )
356
+ run = null;
357
+ }
358
+ if (run) {
327
359
  const interactMarkups = [];
328
360
  if (run.context.html_interactions) {
329
361
  interactMarkups.push(...run.context.html_interactions);
@@ -416,9 +448,9 @@ const run = async (
416
448
  interactMarkups.push(
417
449
  wrapSegment(
418
450
  typeof interact.content === "string"
419
- ? md.render(interact.content)
451
+ ? md.render(stripMarkdownImages(interact.content))
420
452
  : typeof interact.content?.content === "string"
421
- ? md.render(interact.content.content)
453
+ ? md.render(stripMarkdownImages(interact.content.content))
422
454
  : interact.content,
423
455
  action.name,
424
456
  false,
@@ -462,7 +494,8 @@ const run = async (
462
494
  runInteractions = interactMarkups.join("");
463
495
  }
464
496
  const skill_form_widgets = [];
465
- for (const skill of get_skill_instances(action.configuration)) {
497
+ const _skill_instances = get_skill_instances(action.configuration);
498
+ for (const skill of _skill_instances) {
466
499
  if (skill.formWidget)
467
500
  skill_form_widgets.push(
468
501
  await skill.formWidget({
@@ -472,15 +505,22 @@ const run = async (
472
505
  }),
473
506
  );
474
507
  }
508
+ const hasTTS = _skill_instances.some(
509
+ (s) => s.constructor && s.constructor.skill_name === "Text to speech",
510
+ );
475
511
 
476
512
  const debugMode = is_debug_mode(action.configuration, req.user);
477
513
  const dyn_updates = getState().getConfig("enable_dynamic_updates", true);
478
514
 
479
515
  const rndid = Math.floor(Math.random() * 16777215).toString(16);
516
+ const footerInputMode = input_mode === "Footer (sticky)";
480
517
  const input_form = form(
481
518
  {
482
519
  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"],
520
+ class: [
521
+ "form-namespace copilot agent-view",
522
+ footerInputMode ? "mt-auto sticky-bottom bg-body py-1" : "mt-2",
523
+ ],
484
524
  method: "post",
485
525
  },
486
526
  input({
@@ -557,17 +597,13 @@ const run = async (
557
597
  { class: "modern-sessions-header" },
558
598
  div(
559
599
  { 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
600
  i({ class: "fas fa-comments me-2 text-primary" }),
565
601
  span({ class: "fw-semibold" }, req.__("Sessions")),
566
602
  ),
567
603
  button(
568
604
  {
569
605
  type: "button",
570
- class: "btn btn-primary btn-sm rounded-pill px-3",
606
+ class: "btn btn-primary btn-sm px-3",
571
607
  onclick: "unset_state_field('run_id', this)",
572
608
  title: "New chat",
573
609
  },
@@ -653,6 +689,10 @@ const run = async (
653
689
  : "";
654
690
 
655
691
  const main_inner = div(
692
+ {
693
+ class: "d-flex flex-column flex-grow-1",
694
+ style: "min-height:0",
695
+ },
656
696
  div(
657
697
  {
658
698
  class: "open-prev-runs",
@@ -721,6 +761,7 @@ const run = async (
721
761
  $("textarea[name=userinput]").val("")
722
762
  $('div.next_response_scratch').html("")
723
763
  window['stream scratch ${viewname} ${rndid}'] = []
764
+ $("button.modern-share").show()
724
765
  if(res.response) {
725
766
  $(".agent-waiting-indicator").remove();
726
767
  $("#copilotinteractions").append(res.response);
@@ -908,21 +949,328 @@ const run = async (
908
949
  `$('form.agent-view input[name=page_load_tag]').val(window._sc_pageloadtag)`,
909
950
  ),
910
951
  initial_q && domReady("$('form.copilot').submit()"),
952
+ domReady(`
953
+ (function() {
954
+ var VIEWNAME = ${JSON.stringify(viewname)};
955
+
956
+ /* Container-responsive: ResizeObserver toggles .chat-wide based on
957
+ the shell's own width (independent of viewport / theme sidebars).
958
+ Also sets a precise min-height in footer mode so the input form
959
+ reaches the visible viewport bottom regardless of any navbar above. */
960
+ function applyShellFooterHeight(shell) {
961
+ if (!shell.classList.contains('input-footer')) return;
962
+ var rect = shell.getBoundingClientRect();
963
+ var vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
964
+ var h = Math.max(280, Math.floor(vh - rect.top - 8));
965
+ shell.style.minHeight = h + 'px';
966
+ }
967
+ document.querySelectorAll('.modern-chat-shell').forEach(function(shell) {
968
+ if (shell._scChatResizeBound) return;
969
+ shell._scChatResizeBound = true;
970
+ var apply = function(w) { shell.classList.toggle('chat-wide', w >= 720); };
971
+ apply(shell.getBoundingClientRect().width);
972
+ applyShellFooterHeight(shell);
973
+ if ('ResizeObserver' in window) {
974
+ var ro = new ResizeObserver(function(entries) {
975
+ apply(entries[0].contentRect.width);
976
+ applyShellFooterHeight(shell);
977
+ });
978
+ ro.observe(shell);
979
+ } else {
980
+ window.addEventListener('resize', function() {
981
+ apply(shell.getBoundingClientRect().width);
982
+ applyShellFooterHeight(shell);
983
+ });
984
+ }
985
+ window.addEventListener('resize', function() { applyShellFooterHeight(shell); });
986
+ if (window.visualViewport) {
987
+ window.visualViewport.addEventListener('resize', function() {
988
+ applyShellFooterHeight(shell);
989
+ });
990
+ }
991
+ });
992
+
993
+ /* Clean Bootstrap-leftover backdrop/styles after pjax view re-render */
994
+ function cleanupOffcanvasState() {
995
+ document.querySelectorAll('.offcanvas-backdrop').forEach(function(el) { el.remove(); });
996
+ document.body.style.removeProperty('overflow');
997
+ document.body.style.removeProperty('padding-right');
998
+ document.body.classList.remove('modal-open');
999
+ }
1000
+ if (!window._scAgentOffcanvasCleanupBound) {
1001
+ $(document).on('pjaxlinks_loaded', cleanupOffcanvasState);
1002
+ window._scAgentOffcanvasCleanupBound = true;
1003
+ }
1004
+ /* Hide offcanvas when user picks a session from inside the drawer */
1005
+ if (!window._scAgentOffcanvasClickBound) {
1006
+ document.addEventListener('click', function(e) {
1007
+ var trigger = e.target.closest(
1008
+ '.modern-sessions-offcanvas .prevcopilotrun, ' +
1009
+ '.modern-sessions-offcanvas .modern-sessions-header button.btn-primary'
1010
+ );
1011
+ if (!trigger) return;
1012
+ var ofc = trigger.closest('.offcanvas');
1013
+ if (!ofc || !window.bootstrap) return;
1014
+ var inst = bootstrap.Offcanvas.getInstance(ofc);
1015
+ if (inst) inst.hide();
1016
+ });
1017
+ window._scAgentOffcanvasClickBound = true;
1018
+ }
1019
+
1020
+ /* === TTS === */
1021
+ function getSharedAudio() {
1022
+ if (!window._ttsSharedAudio) {
1023
+ var a = new Audio();
1024
+ a.preload = 'auto';
1025
+ a.className = 'agent-tts-audio d-none';
1026
+ document.body.appendChild(a);
1027
+ window._ttsSharedAudio = a;
1028
+ }
1029
+ return window._ttsSharedAudio;
1030
+ }
1031
+ function primeTtsUnlock() {
1032
+ if (window._ttsUnlocked) return;
1033
+ try {
1034
+ var a = getSharedAudio();
1035
+ a.src = 'data:audio/mpeg;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQxAADB8AhSmxhIIEVCSiJrDCQBTcu3UrAIwUdkRgQbFAZC1CQEwTJ9mjRvBA4UOLD8nKVOWfh+UlK3z/177OXrfOdKl7pyn3Xf//FJ';
1036
+ var p = a.play();
1037
+ if (p && p.catch) p.catch(function() {});
1038
+ a.pause();
1039
+ a.currentTime = 0;
1040
+ window._ttsUnlocked = true;
1041
+ } catch(e) {}
1042
+ }
1043
+ window.toggle_agent_tts = function(btn, viewname) {
1044
+ var key = 'agent-tts-' + (viewname || VIEWNAME);
1045
+ var newOn = btn.getAttribute('data-tts-state') !== 'on';
1046
+ btn.setAttribute('data-tts-state', newOn ? 'on' : 'off');
1047
+ btn.classList.toggle('bg-primary-subtle', newOn);
1048
+ btn.classList.toggle('text-primary', newOn);
1049
+ try { localStorage.setItem(key, newOn ? '1' : '0'); } catch(e){}
1050
+ if (newOn) {
1051
+ primeTtsUnlock();
1052
+ } else {
1053
+ var a = window._ttsSharedAudio;
1054
+ if (a) { try { a.pause(); } catch(e){} a.src = ''; }
1055
+ }
1056
+ };
1057
+
1058
+ window.share_agent_chat = function(btn, viewname) {
1059
+ const runid = $("input[name=run_id").val()
1060
+ view_post(viewname, 'share_chat', {run_id:runid}, async (data)=>{
1061
+ console.log(data)
1062
+ const clipboardItemData = {
1063
+ ["text/plain"]: window.location.origin+'/view/'+viewname+'?run_id='+runid+(data.share_token ? "&share_token="+data.share_token:"")
1064
+ };
1065
+ const clipboardItem = new ClipboardItem(clipboardItemData);
1066
+ await navigator.clipboard.write([clipboardItem]);
1067
+ common_done({notify: "Share link copied to clipboard", remove_delay: 1})
1068
+
1069
+ })
1070
+ };
1071
+ /* Restore toggle state on load */
1072
+ (function() {
1073
+ try {
1074
+ var btn = document.querySelector('.modern-tts-toggle');
1075
+ if (!btn) return;
1076
+ if (localStorage.getItem('agent-tts-' + VIEWNAME) === '1') {
1077
+ btn.setAttribute('data-tts-state', 'on');
1078
+ btn.classList.add('bg-primary-subtle');
1079
+ btn.classList.add('text-primary');
1080
+ }
1081
+ } catch(e) {}
1082
+ })();
1083
+
1084
+ function ttsForBubble(bubble) {
1085
+ if (!bubble || bubble.dataset.ttsDispatched) return;
1086
+ var btn = document.querySelector('.modern-tts-toggle[data-tts-state="on"]');
1087
+ if (!btn) return;
1088
+ // Skip bubbles that are just tool-rendered output (image card etc.) —
1089
+ // they have a Bootstrap card inside and no meaningful narrative text.
1090
+ if (bubble.querySelector('.card.bg-secondary-subtle')) return;
1091
+ bubble.dataset.ttsDispatched = '1';
1092
+ var text = (bubble.innerText || bubble.textContent || '').trim();
1093
+ if (!text || text.length < 2) return;
1094
+ var fd = new FormData();
1095
+ fd.append('text', text);
1096
+ var csrf = ($('input[name=_csrf]').first().val()) || '';
1097
+ fd.append('_csrf', csrf);
1098
+ fetch('/view/' + VIEWNAME + '/tts', {
1099
+ method: 'POST',
1100
+ body: fd,
1101
+ credentials: 'same-origin',
1102
+ }).then(function(res) {
1103
+ if (!res.ok) { res.text().then(function(t){ console.warn('tts http ' + res.status + ': ' + t); }); return null; }
1104
+ var ct = res.headers.get('content-type') || '';
1105
+ if (ct.indexOf('audio') !== 0) { res.text().then(function(t){ console.warn('tts non-audio:', t); }); return null; }
1106
+ return res.blob();
1107
+ }).then(function(blob) {
1108
+ if (!blob) return;
1109
+ var url = URL.createObjectURL(blob);
1110
+ var a = getSharedAudio();
1111
+ a.src = url;
1112
+ var p = a.play();
1113
+ if (p && p.catch) p.catch(function(err) { console.warn('tts play blocked:', err); });
1114
+ var clean = function() { URL.revokeObjectURL(url); a.removeEventListener('ended', clean); };
1115
+ a.addEventListener('ended', clean);
1116
+ }).catch(function(err) { console.warn('tts fetch err:', err); });
1117
+ }
1118
+
1119
+ var interactionsRoot = document.getElementById('copilotinteractions');
1120
+ if (interactionsRoot && !interactionsRoot._scTtsObserver) {
1121
+ var mo = new MutationObserver(function(muts) {
1122
+ muts.forEach(function(m) {
1123
+ m.addedNodes && m.addedNodes.forEach(function(n) {
1124
+ if (!n || n.nodeType !== 1) return;
1125
+ if (n.matches && n.matches('.chat-message.chat-assistant .chat-bubble')) {
1126
+ ttsForBubble(n);
1127
+ }
1128
+ if (n.matches && n.matches('.chat-message.chat-assistant')) {
1129
+ var b = n.querySelector('.chat-bubble');
1130
+ if (b) ttsForBubble(b);
1131
+ }
1132
+ if (n.querySelectorAll) {
1133
+ n.querySelectorAll('.chat-message.chat-assistant .chat-bubble').forEach(ttsForBubble);
1134
+ }
1135
+ });
1136
+ });
1137
+ });
1138
+ mo.observe(interactionsRoot, { childList: true, subtree: true });
1139
+ interactionsRoot._scTtsObserver = mo;
1140
+ }
1141
+
1142
+ /* PWA-standalone image download via Web Share API */
1143
+ function isPwaStandalone() {
1144
+ return (window.navigator.standalone === true) ||
1145
+ (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches);
1146
+ }
1147
+ if (!window._scAgentImageShareBound) {
1148
+ document.addEventListener('click', function(e) {
1149
+ var link = e.target.closest('.agent-image-download');
1150
+ if (!link) return;
1151
+ if (!isPwaStandalone()) return;
1152
+ if (!navigator.share || typeof navigator.canShare !== 'function') return;
1153
+ e.preventDefault();
1154
+ e.stopPropagation();
1155
+ var url = link.getAttribute('href');
1156
+ var dlname = link.getAttribute('download') || ('image-' + Date.now() + '.png');
1157
+ fetch(url).then(function(r) { return r.blob(); }).then(function(blob) {
1158
+ var file = new File([blob], dlname, { type: blob.type });
1159
+ if (!navigator.canShare({ files: [file] })) { window.open(url, '_blank'); return; }
1160
+ return navigator.share({ files: [file], title: dlname });
1161
+ }).catch(function(err) { console.warn('share err', err); });
1162
+ }, true);
1163
+ window._scAgentImageShareBound = true;
1164
+ }
1165
+ })();
1166
+ `),
911
1167
  ),
912
1168
  );
913
1169
  const isModern = layout && layout.startsWith("Modern chat");
1170
+ const viewObj = View.findOne({ name: viewname });
1171
+ const headerTitle = viewObj?.description?.trim() || action.name || "";
1172
+ const modern_chat_header =
1173
+ isModern && (show_prev_runs || hasTTS)
1174
+ ? div(
1175
+ {
1176
+ class:
1177
+ "modern-chat-header d-flex align-items-center gap-2 px-3 py-2 border-bottom flex-shrink-0",
1178
+ },
1179
+ show_prev_runs
1180
+ ? button(
1181
+ {
1182
+ type: "button",
1183
+ class:
1184
+ "btn btn-sm btn-outline-secondary modern-chat-hamburger",
1185
+ "data-bs-toggle": "offcanvas",
1186
+ "data-bs-target": "#agent-sessions-" + rndid,
1187
+ "aria-controls": "agent-sessions-" + rndid,
1188
+ title: req.__("Sessions"),
1189
+ },
1190
+ i({ class: "fas fa-bars" }),
1191
+ )
1192
+ : "",
1193
+ span({ class: "flex-grow-1 text-truncate fw-semibold" }, headerTitle),
1194
+ button(
1195
+ {
1196
+ type: "button",
1197
+ style: run ? undefined : { display: "none" },
1198
+ class: "btn btn-sm btn-outline-secondary modern-share",
1199
+ onclick: `share_agent_chat(this, '${viewname}')`,
1200
+ title: req.__("Share chat"),
1201
+ },
1202
+ i({ class: "fas fa-share-alt" }),
1203
+ ),
1204
+ hasTTS
1205
+ ? button(
1206
+ {
1207
+ type: "button",
1208
+ class: "btn btn-sm btn-outline-secondary modern-tts-toggle",
1209
+ onclick: `toggle_agent_tts(this, '${viewname}')`,
1210
+ title: req.__("Read responses aloud"),
1211
+ "data-tts-state": "off",
1212
+ },
1213
+ i({ class: "fas fa-volume-up" }),
1214
+ )
1215
+ : "",
1216
+ )
1217
+ : "";
914
1218
  const main_chat =
915
1219
  layout === "Modern chat"
916
1220
  ? div(
917
- { class: "card" },
918
- div({ class: "card-body modern-chat-layout" }, main_inner),
1221
+ { class: "card modern-chat-card d-flex flex-column" },
1222
+ modern_chat_header,
1223
+ div(
1224
+ { class: "card-body modern-chat-layout p-0 d-flex flex-column" },
1225
+ main_inner,
1226
+ ),
919
1227
  )
920
1228
  : layout === "Modern chat - no card"
921
- ? div({ class: "modern-chat-layout" }, main_inner)
1229
+ ? div(
1230
+ { class: "modern-chat-noncard d-flex flex-column" },
1231
+ modern_chat_header,
1232
+ div({ class: "modern-chat-layout d-flex flex-column" }, main_inner),
1233
+ )
922
1234
  : layout === "No card"
923
1235
  ? div({ class: "mx-1" }, main_inner)
924
1236
  : div({ class: "card" }, div({ class: "card-body" }, main_inner));
925
1237
 
1238
+ if (isModern) {
1239
+ const shellExtraClass =
1240
+ input_mode === "Footer (sticky)" ? " input-footer" : "";
1241
+ const mainColumnAttrs = {
1242
+ class: "modern-chat-main flex-grow-1 d-flex flex-column",
1243
+ style: "min-width:0",
1244
+ };
1245
+ return show_prev_runs
1246
+ ? div(
1247
+ { class: "modern-chat-shell d-flex gap-4" + shellExtraClass },
1248
+ div(
1249
+ {
1250
+ class:
1251
+ "offcanvas offcanvas-start modern-sessions-offcanvas border-end pe-3" +
1252
+ (prev_runs_closed ? " sessions-initially-closed" : ""),
1253
+ id: "agent-sessions-" + rndid,
1254
+ tabindex: "-1",
1255
+ "aria-labelledby": "agent-sessions-label-" + rndid,
1256
+ },
1257
+ div(
1258
+ { class: "offcanvas-body p-2" },
1259
+ div({ class: "prev-runs-list" }, prev_runs_side_bar),
1260
+ ),
1261
+ ),
1262
+ div(mainColumnAttrs, main_chat),
1263
+ )
1264
+ : div(
1265
+ {
1266
+ class:
1267
+ "modern-chat-shell modern-chat-shell-no-sidebar d-flex" +
1268
+ shellExtraClass,
1269
+ },
1270
+ div(mainColumnAttrs, main_chat),
1271
+ );
1272
+ }
1273
+
926
1274
  return show_prev_runs
927
1275
  ? div(
928
1276
  { class: "row gx-3" },
@@ -1088,6 +1436,19 @@ const cancel = async (table_id, viewname, config, body, { req, res }) => {
1088
1436
  return;
1089
1437
  };
1090
1438
 
1439
+ const share_chat = async (table_id, viewname, config, body, { req, res }) => {
1440
+ const { run_id } = body;
1441
+ const run = await WorkflowRun.findOne({ id: +run_id });
1442
+ if (run.context.share_token)
1443
+ return { json: { share_token: run.context.share_token } };
1444
+ else {
1445
+ if (run.started_by != req.user?.id && !config.shared) return;
1446
+ const rndid = Math.floor(Math.random() * 16777215).toString(16);
1447
+ await run.update({ context: { ...run.context, share_token: rndid } });
1448
+ return { json: { share_token: rndid } };
1449
+ }
1450
+ };
1451
+
1091
1452
  const debug_info = async (table_id, viewname, config, body, { req, res }) => {
1092
1453
  const { run_id, triggering_row_id } = body;
1093
1454
  const action =
@@ -1335,6 +1696,60 @@ const execute_user_action = async (
1335
1696
  };
1336
1697
  };
1337
1698
 
1699
+ // Text-to-speech route. Streams audio directly to the browser via
1700
+ // llm_text_to_speech with stream:true so playback can start as soon as the
1701
+ // first chunk arrives. The TextToSpeech skill must be configured on the
1702
+ // referenced agent action.
1703
+ const tts = async (table_id, viewname, config, body, { req, res }) => {
1704
+ const { text } = body;
1705
+ if (!text || !text.trim()) return { json: { error: "no text" } };
1706
+ const stream_fn = getState().functions.llm_text_to_speech;
1707
+ if (!stream_fn)
1708
+ return {
1709
+ json: {
1710
+ error:
1711
+ "llm_text_to_speech not registered — update @saltcorn/large-language-model to >= 1.1.0",
1712
+ },
1713
+ };
1714
+ const action =
1715
+ config.agent_action || (await Trigger.findOne({ id: config.action_id }));
1716
+ const skills = get_skill_instances(action.configuration);
1717
+ const tts_skill = skills.find(
1718
+ (s) => s.constructor && s.constructor.skill_name === "Text to speech",
1719
+ );
1720
+ if (!tts_skill) return { json: { error: "tts skill not configured" } };
1721
+ const ttsOpts = {
1722
+ voice: tts_skill.voice,
1723
+ speed: tts_skill.speed,
1724
+ response_format: tts_skill.format,
1725
+ instructions: tts_skill.instructions,
1726
+ stream: true,
1727
+ };
1728
+ try {
1729
+ const result = await stream_fn.run(text, ttsOpts);
1730
+ const ext = result?.output_format || tts_skill.format || "mp3";
1731
+ const mime = ext === "mp3" ? "audio/mpeg" : `audio/${ext}`;
1732
+ res.setHeader("Content-Type", mime);
1733
+ res.setHeader("Cache-Control", "no-store");
1734
+ res.setHeader("X-Accel-Buffering", "no");
1735
+ const reader = result.stream.getReader();
1736
+ while (true) {
1737
+ const { done, value } = await reader.read();
1738
+ if (done) break;
1739
+ if (!res.write(Buffer.from(value))) {
1740
+ await new Promise((r) => res.once("drain", r));
1741
+ }
1742
+ }
1743
+ res.end();
1744
+ } catch (e) {
1745
+ getState().log(2, "tts stream pump error: " + (e?.message || e));
1746
+ try {
1747
+ res.end();
1748
+ } catch (_) {}
1749
+ }
1750
+ return;
1751
+ };
1752
+
1338
1753
  module.exports = {
1339
1754
  name: "Agent Chat",
1340
1755
  configuration_workflow,
@@ -1350,6 +1765,8 @@ module.exports = {
1350
1765
  skillroute,
1351
1766
  execute_user_action,
1352
1767
  cancel,
1768
+ tts,
1769
+ share_chat,
1353
1770
  },
1354
1771
  mobile_render_server_side: true,
1355
1772
  };
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.9",
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;