@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 +389 -13
- 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 },
|
|
@@ -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
|
-
|
|
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: [
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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;
|