@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 +432 -15
- package/agents.css +91 -11
- package/common.js +14 -4
- package/package.json +1 -1
- package/skills/TextToSpeech.js +93 -0
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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.
|
|
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.
|
|
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:
|
|
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.
|
|
329
|
-
margin-bottom: 0.
|
|
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.
|
|
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.
|
|
343
|
-
padding: 0.
|
|
344
|
-
margin-bottom: 0.
|
|
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  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
|
@@ -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;
|