loopengine 0.1.94 → 0.1.97
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/README.md +21 -2
- package/dist/bin/ability-manager.d.ts +4 -0
- package/dist/bin/ability-manager.js +34 -2
- package/dist/bin/cli.d.ts +1 -1
- package/dist/bin/cli.js +3 -0
- package/dist/core/ability-details.d.ts +31 -0
- package/dist/core/ability-details.js +140 -0
- package/dist/core/ability-sources.d.ts +59 -0
- package/dist/core/ability-sources.js +312 -0
- package/dist/core/agent-config.d.ts +21 -1
- package/dist/core/discover-agents.js +11 -1
- package/dist/core/http-notifier.d.ts +7 -1
- package/dist/core/http-notifier.js +68 -9
- package/dist/core/http-notify-triggers/email.d.ts +2 -2
- package/dist/core/http-notify-triggers/email.js +2 -4
- package/dist/core/http-notify-triggers/lark.d.ts +2 -2
- package/dist/core/http-notify-triggers/lark.js +2 -4
- package/dist/core/http-notify-triggers/slack.d.ts +2 -2
- package/dist/core/http-notify-triggers/slack.js +2 -4
- package/dist/core/http-notify-triggers/telegram.d.ts +28 -0
- package/dist/core/http-notify-triggers/telegram.js +71 -0
- package/dist/core/http-notify-triggers/webhook.d.ts +2 -2
- package/dist/core/http-notify-triggers/webhook.js +2 -4
- package/dist/core/model-calls/meta-model-call.d.ts +15 -0
- package/dist/core/model-calls/meta-model-call.js +48 -0
- package/dist/core/model-calls/openai-model-call.d.ts +5 -0
- package/dist/core/model-calls/openai-model-call.js +47 -1
- package/dist/core/model-calls/xai-model-call.d.ts +15 -0
- package/dist/core/model-calls/xai-model-call.js +45 -0
- package/dist/core/notifier-file.d.ts +40 -6
- package/dist/core/notifier-file.js +119 -25
- package/dist/core/system-agents/workflow-planner.d.ts +100 -0
- package/dist/core/system-agents/workflow-planner.js +278 -0
- package/dist/core/workflows.d.ts +26 -0
- package/dist/core/workflows.js +39 -0
- package/dist/core/workspace-abilities.d.ts +31 -0
- package/dist/core/workspace-abilities.js +110 -0
- package/dist/index.d.ts +18 -4
- package/dist/index.js +17 -3
- package/dist/web/ability-details-admin.d.ts +17 -0
- package/dist/web/ability-details-admin.js +45 -0
- package/dist/web/ability-sources-admin.d.ts +33 -0
- package/dist/web/ability-sources-admin.js +65 -0
- package/dist/web/agent-file-admin.d.ts +1 -1
- package/dist/web/agent-file-admin.js +3 -0
- package/dist/web/agents-config-page.js +608 -224
- package/dist/web/agents-list-page.js +49 -20
- package/dist/web/console-shell.js +248 -7
- package/dist/web/dev-ui-styles.js +13 -1
- package/dist/web/global-config-page.js +403 -81
- package/dist/web/global-config.d.ts +44 -1
- package/dist/web/global-config.js +162 -2
- package/dist/web/interview-admin.js +2 -2
- package/dist/web/messaging-icons.d.ts +2 -0
- package/dist/web/messaging-icons.js +7 -0
- package/dist/web/notifier-admin.d.ts +33 -15
- package/dist/web/notifier-admin.js +109 -65
- package/dist/web/provider-icons.d.ts +2 -0
- package/dist/web/provider-icons.js +10 -0
- package/dist/web/templates-admin.d.ts +3 -0
- package/dist/web/templates-admin.js +5 -1
- package/dist/web/workflow-admin.d.ts +124 -0
- package/dist/web/workflow-admin.js +308 -0
- package/dist/web/workflow-page.d.ts +1 -0
- package/dist/web/workflow-page.js +226 -0
- package/dist/web/workspace-abilities-admin.d.ts +24 -0
- package/dist/web/workspace-abilities-admin.js +118 -0
- package/package.json +13 -1
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// concatenation, not a nested template literal, for the same
|
|
27
27
|
// backtick/${}-escaping reason playground.ts's own header comment explains.
|
|
28
28
|
import { devUiCss } from './dev-ui-styles.js';
|
|
29
|
+
import { MESSAGING_ICONS } from './messaging-icons.js';
|
|
29
30
|
import { consoleShellScript, consoleSidebarHtml } from './console-shell.js';
|
|
30
31
|
export const agentsConfigPageHtml = `<!doctype html>
|
|
31
32
|
<html lang="en">
|
|
@@ -46,7 +47,34 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
46
47
|
form.add-source .schedule-when input[type="radio"] { width: auto; margin: 0; }
|
|
47
48
|
form.add-source .schedule-when input[type="number"] { width: 80px; }
|
|
48
49
|
form.add-source .schedule-when select, form.add-source .schedule-when input[type="datetime-local"] { width: auto; }
|
|
50
|
+
.workflow-card { margin-bottom: 10px; }
|
|
51
|
+
.workflow-steps { margin: 0; padding-left: 20px; font-size: 13px; }
|
|
52
|
+
.workflow-steps li { margin: 3px 0; }
|
|
53
|
+
.advanced-part { font-size: 17px; font-weight: 600; margin: 28px 0 12px; padding-top: 16px; border-top: 1px solid var(--line); }
|
|
54
|
+
.advanced-part#advanced-tools { margin-top: 0; padding-top: 0; border-top: 0; }
|
|
49
55
|
.approvals-nudge { border: 1px solid var(--warn); background: var(--warn-soft); color: var(--ink); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 24px; font-size: 13px; }
|
|
56
|
+
/* Permissions: Allow / Ask me / Never, switched in place and saved at once. */
|
|
57
|
+
.decision-switch { display: inline-flex; gap: 4px; flex-wrap: nowrap; }
|
|
58
|
+
.actauth-rules td:nth-child(3) { white-space: nowrap; width: 1%; }
|
|
59
|
+
.actauth-rules-full td:nth-child(3) { white-space: normal; width: auto; }
|
|
60
|
+
.actauth-rules-full td:nth-child(5), .actauth-rules-full td:nth-child(6) { white-space: nowrap; width: 1%; }
|
|
61
|
+
.actauth-rules td code { overflow-wrap: anywhere; }
|
|
62
|
+
.decision-switch button { min-width: 64px; padding: 5px 10px; white-space: nowrap; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); font-size: 12.5px; font-weight: 600; cursor: pointer; }
|
|
63
|
+
.decision-switch button:hover { border-color: var(--ink-faint); }
|
|
64
|
+
.decision-switch button[aria-pressed="true"] { color: var(--on-accent); }
|
|
65
|
+
.decision-switch button[aria-pressed="true"][data-decision="allow"] { background: var(--accent); border-color: var(--accent); }
|
|
66
|
+
.decision-switch button[aria-pressed="true"][data-decision="ask"] { background: var(--warn); border-color: var(--warn); }
|
|
67
|
+
.decision-switch button[aria-pressed="true"][data-decision="deny"] { background: var(--ink-muted); border-color: var(--ink-muted); }
|
|
68
|
+
.decision-switch.is-saving button { cursor: progress; opacity: 0.7; }
|
|
69
|
+
/* Messaging: one section per app; the primary one is outlined. */
|
|
70
|
+
.msg-conns { display: grid; gap: 10px; max-width: 720px; }
|
|
71
|
+
.msg-conn { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 0 14px; }
|
|
72
|
+
.msg-conn.is-primary { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
|
73
|
+
.msg-conn > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 0; cursor: pointer; }
|
|
74
|
+
.msg-conn-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 8px; }
|
|
75
|
+
.le-channel-icon { width: 18px; height: 18px; flex: none; }
|
|
76
|
+
.msg-conn-blurb { flex-basis: 100%; margin: 0; }
|
|
77
|
+
.msg-conn > form.add-source { border: 0; background: none; box-shadow: none; padding: 0 0 14px; margin: 0; }
|
|
50
78
|
.env-desc { max-width: 340px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
51
79
|
form.add-source.env-inline { border: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; max-width: 320px; }
|
|
52
80
|
form.add-source.env-inline input, form.add-source.env-inline select { flex: 1 1 140px; width: auto; min-width: 0; }
|
|
@@ -533,7 +561,7 @@ ${consoleShellScript}
|
|
|
533
561
|
// has no spec to repopulate an edit form from, so it just gets a blank
|
|
534
562
|
// cell — not a button that would 404 the moment it's clicked.
|
|
535
563
|
function renderLocalTools(tools) {
|
|
536
|
-
if (!tools.length) return '<p class="muted">No tools yet.
|
|
564
|
+
if (!tools.length) return '<p class="muted">No tools yet. Install an ability, or add one below.</p>';
|
|
537
565
|
var rows = tools.map(function (t) {
|
|
538
566
|
var actions = t.httpToolEditable
|
|
539
567
|
? '<button type="button" class="edit-http-tool-btn" data-name="' + escapeHtml(t.name) + '">Edit</button>'
|
|
@@ -626,6 +654,76 @@ ${consoleShellScript}
|
|
|
626
654
|
// Messaging and Schedules come from their own endpoints, so their
|
|
627
655
|
// cards are filled in once those answer. Called after every render of
|
|
628
656
|
// Overview, which is also re-rendered after edits.
|
|
657
|
+
// ---- Overview: the agent's workflows (web/workflow-admin.ts), added
|
|
658
|
+
// from "Describe a workflow": each with its trigger, its schedule's
|
|
659
|
+
// state, its steps (checkpoints marked) and Run now. ----
|
|
660
|
+
|
|
661
|
+
function renderWorkflowsHtml(name, workflows) {
|
|
662
|
+
if (!workflows.length) {
|
|
663
|
+
return '<p class="hint" style="margin:0">No workflows yet. <a href="/workflows/new">Describe a workflow</a>: say the steps and where you want to decide, and it' + String.fromCharCode(8217) + 's set up for you.</p>';
|
|
664
|
+
}
|
|
665
|
+
return workflows.map(function (w) {
|
|
666
|
+
var s = w.schedule;
|
|
667
|
+
var state = !s
|
|
668
|
+
? (w.trigger.kind === 'schedule' ? '<span class="hint">no schedule found</span>' : '<span class="hint">started when asked</span>')
|
|
669
|
+
: s.waiting
|
|
670
|
+
? '<span class="le-pill ask">Waiting for a decision</span>'
|
|
671
|
+
: s.status === 'paused'
|
|
672
|
+
? '<span class="le-pill">Schedule paused</span>'
|
|
673
|
+
: s.status === 'running'
|
|
674
|
+
? '<span class="le-pill accent">Running</span>'
|
|
675
|
+
: '<span class="le-pill good">On</span>' + (s.nextRunAt ? ' <span class="hint">next ' + escapeHtml(new Date(s.nextRunAt).toLocaleString()) + '</span>' : '');
|
|
676
|
+
var steps = (w.steps || []).map(function (st) {
|
|
677
|
+
var mark = st.kind === 'ask' ? ' <span class="le-pill accent">Asks you</span>' : st.kind === 'approval' ? ' <span class="le-pill ask">You approve</span>' : '';
|
|
678
|
+
return '<li>' + escapeHtml(st.title) + (st.tool && st.tool !== 'system_ask_user' ? ' <code>' + escapeHtml(st.tool) + '</code>' : '') + mark + '</li>';
|
|
679
|
+
}).join('');
|
|
680
|
+
return '<div class="source workflow-card" data-workflow="' + escapeHtml(w.id) + '">' +
|
|
681
|
+
'<div style="display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;align-items:baseline">' +
|
|
682
|
+
'<strong>' + escapeHtml(w.name) + '</strong><span>' + state + '</span></div>' +
|
|
683
|
+
'<p class="hint" style="margin:4px 0 6px">' + escapeHtml(w.summary) + ' ' + String.fromCharCode(183) + ' ' + escapeHtml(w.trigger.description) + '</p>' +
|
|
684
|
+
'<ol class="workflow-steps">' + steps + '</ol>' +
|
|
685
|
+
'<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">' +
|
|
686
|
+
'<button type="button" class="workflow-run">Run now</button>' +
|
|
687
|
+
(s ? '<a href="?tab=schedules" data-tab="schedules" class="hint" style="align-self:center">Schedule</a>' : '') +
|
|
688
|
+
'<button type="button" class="delete-btn workflow-forget">Forget</button>' +
|
|
689
|
+
'</div><div class="hint workflow-result"></div></div>';
|
|
690
|
+
}).join('') + '<p class="hint" style="margin:6px 0 0"><a href="/workflows/new">Describe another workflow</a></p>';
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function loadWorkflowsSection(name) {
|
|
694
|
+
var el = detail.querySelector('#workflowsContent');
|
|
695
|
+
if (!el) return;
|
|
696
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/workflows').then(function (r) {
|
|
697
|
+
if (currentName !== name) return;
|
|
698
|
+
if (!r.ok) { el.innerHTML = '<p class="error">Could not load workflows: ' + escapeHtml(r.body.error || 'unknown error') + '</p>'; return; }
|
|
699
|
+
el.innerHTML = renderWorkflowsHtml(name, r.body.workflows || []);
|
|
700
|
+
var cards = el.querySelectorAll('.workflow-card');
|
|
701
|
+
for (var i = 0; i < cards.length; i++) wireWorkflowCard(name, cards[i]);
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function wireWorkflowCard(name, card) {
|
|
706
|
+
var id = card.dataset.workflow;
|
|
707
|
+
var result = card.querySelector('.workflow-result');
|
|
708
|
+
var base = '/agents/' + encodeURIComponent(name) + '/workflows/' + encodeURIComponent(id);
|
|
709
|
+
card.querySelector('.workflow-run').addEventListener('click', function (ev) {
|
|
710
|
+
var btn = ev.currentTarget;
|
|
711
|
+
btn.disabled = true;
|
|
712
|
+
result.textContent = 'Starting' + String.fromCharCode(8230);
|
|
713
|
+
fetchJsonRetryOnce(base + '/run', { method: 'POST' }).then(function (r) {
|
|
714
|
+
btn.disabled = false;
|
|
715
|
+
result.textContent = r.ok
|
|
716
|
+
? 'Started. Questions and approvals go to this agent' + String.fromCharCode(8217) + 's messaging app; its result shows in the Schedules tab.'
|
|
717
|
+
: (r.body.error || 'Could not start it.');
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
card.querySelector('.workflow-forget').addEventListener('click', function (ev) {
|
|
721
|
+
var btn = ev.currentTarget;
|
|
722
|
+
if (btn.dataset.confirming !== '1') { btn.dataset.confirming = '1'; btn.textContent = 'Click again (its skill, schedule and rules stay)'; return; }
|
|
723
|
+
fetchJsonRetryOnce(base, { method: 'DELETE' }).then(function () { loadWorkflowsSection(name); });
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
629
727
|
function fillOverviewCards(name) {
|
|
630
728
|
Promise.all([
|
|
631
729
|
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier'),
|
|
@@ -641,7 +739,7 @@ ${consoleShellScript}
|
|
|
641
739
|
var messagingDetail = [];
|
|
642
740
|
if (pending.length) messagingDetail.push(pending.length + ' waiting for a decision');
|
|
643
741
|
if (n && connected && n.channel === 'slack' && n.plan !== 'free' && (!n.file || n.file.conversations !== false)) messagingDetail.push('answers replies in threads');
|
|
644
|
-
if (n && !connected) messagingDetail.push(n.missingSecret ? 'Saved, but its secret isn' + String.fromCharCode(8217) + 't set' : 'Send approvals and questions to Slack, Lark, email or a webhook');
|
|
742
|
+
if (n && !connected) messagingDetail.push(n.missingSecret ? 'Saved, but its secret isn' + String.fromCharCode(8217) + 't set' : 'Send approvals and questions to Telegram, Slack, Lark, email or a webhook');
|
|
645
743
|
if (!messagingDetail.length && n) messagingDetail.push('Sends ' + escapeHtml((n.events || []).join(', ')));
|
|
646
744
|
set('ovMessagingDetail', messagingDetail.join(' \u00b7 '));
|
|
647
745
|
showPendingCount(pending.length);
|
|
@@ -689,12 +787,12 @@ ${consoleShellScript}
|
|
|
689
787
|
'<form class="add-source" id="modelForm" style="display:none">' +
|
|
690
788
|
'<label>Provider' +
|
|
691
789
|
'<select name="provider">' +
|
|
692
|
-
['anthropic', '
|
|
790
|
+
['openai', 'anthropic', 'gemini', 'meta', 'xai', 'deepseek', 'kimi', 'glm'].map(function (p) {
|
|
693
791
|
return '<option value="' + p + '"' + (p === cfg.model.provider ? ' selected' : '') + '>' + p + '</option>';
|
|
694
792
|
}).join('') +
|
|
695
793
|
'</select>' +
|
|
696
794
|
'</label>' +
|
|
697
|
-
'<label>Model name <span class="hint">(
|
|
795
|
+
'<label>Model name <span class="hint">(choose Other… to type a model not in the list)</span>' +
|
|
698
796
|
'<input type="text" name="modelName" value="' + escapeHtml(cfg.model.model || '') + '">' +
|
|
699
797
|
'</label>' +
|
|
700
798
|
'<label>Max tokens <span class="hint">(blank for provider default)</span>' +
|
|
@@ -714,6 +812,7 @@ ${consoleShellScript}
|
|
|
714
812
|
}
|
|
715
813
|
|
|
716
814
|
return renderOverviewCards(cfg) +
|
|
815
|
+
'<section id="workflowsSection"><h3>Workflows</h3><div id="workflowsContent"><p class="hint">Loading…</p></div></section>' +
|
|
717
816
|
'<section>' + systemPromptSection + '</section>' +
|
|
718
817
|
'<section>' + modelSection + '</section>' +
|
|
719
818
|
'<section><h3>Hooks</h3><dl class="kv">' +
|
|
@@ -813,6 +912,13 @@ ${consoleShellScript}
|
|
|
813
912
|
modelDisplay.style.display = 'none';
|
|
814
913
|
editModelBtn.style.display = 'none';
|
|
815
914
|
modelForm.style.display = '';
|
|
915
|
+
// Only providers with a key saved can be chosen; the agent's
|
|
916
|
+
// current one stays selectable either way. Then the model dropdown.
|
|
917
|
+
var currentProvider = modelForm.querySelector('select[name="provider"] option[selected]');
|
|
918
|
+
leShell.loadProviderKeys().then(function (keys) {
|
|
919
|
+
leShell.applyProviderKeys(modelForm, keys, currentProvider && currentProvider.value);
|
|
920
|
+
leShell.wireModelPicker(modelForm);
|
|
921
|
+
});
|
|
816
922
|
});
|
|
817
923
|
overviewPanel.querySelector('#cancelModelBtn').addEventListener('click', function () {
|
|
818
924
|
modelForm.style.display = 'none';
|
|
@@ -975,6 +1081,19 @@ ${consoleShellScript}
|
|
|
975
1081
|
});
|
|
976
1082
|
}
|
|
977
1083
|
|
|
1084
|
+
// A decision in words, as on the homepage: Allow, Ask me (wait for a
|
|
1085
|
+
// person) or Never.
|
|
1086
|
+
var DECISION_LABELS = { allow: 'Allow', ask: 'Ask me', deny: 'Never' };
|
|
1087
|
+
|
|
1088
|
+
// Three buttons for a decision; the pressed one is current. attrs
|
|
1089
|
+
// says what the switch sets (a rule, or the default decision).
|
|
1090
|
+
function decisionSwitch(current, label, attrs) {
|
|
1091
|
+
return '<div class="decision-switch" role="group" aria-label="' + escapeHtml(label) + '" ' + attrs + '>' +
|
|
1092
|
+
['allow', 'ask', 'deny'].map(function (d) {
|
|
1093
|
+
return '<button type="button" data-decision="' + d + '" aria-pressed="' + (current === d ? 'true' : 'false') + '">' + DECISION_LABELS[d] + '</button>';
|
|
1094
|
+
}).join('') + '</div>';
|
|
1095
|
+
}
|
|
1096
|
+
|
|
978
1097
|
function renderActauthRuleRow(r) {
|
|
979
1098
|
var actionsHtml = r.name
|
|
980
1099
|
? '<button type="button" class="edit-rule-btn" data-name="' + escapeHtml(r.name) + '">Edit</button> ' +
|
|
@@ -983,36 +1102,58 @@ ${consoleShellScript}
|
|
|
983
1102
|
// update/removeActauthRule (both keyed by name) can address — left
|
|
984
1103
|
// visible, but not editable here.
|
|
985
1104
|
: '<span class="hint">unnamed — edit actauth.yml directly</span>';
|
|
1105
|
+
// Advanced's rule editor: every rule in full, name and scope included.
|
|
986
1106
|
return '<tr>' +
|
|
987
1107
|
'<td><code>' + escapeHtml(r.name || '') + '</code></td>' +
|
|
988
1108
|
'<td><code>' + escapeHtml(r.scope) + '</code></td>' +
|
|
1109
|
+
'<td>' + (r.ability ? '<code>' + escapeHtml(r.ability) + '</code>' : '') + '</td>' +
|
|
989
1110
|
'<td><code>' + escapeHtml(r.tool) + '</code></td>' +
|
|
990
|
-
'<td
|
|
1111
|
+
'<td>' + (r.name
|
|
1112
|
+
? decisionSwitch(r.decision, 'Decision for ' + r.name, 'data-rule="' + escapeHtml(r.name) + '"')
|
|
1113
|
+
: '<span class="le-pill ' + escapeHtml(r.decision) + '">' + escapeHtml(DECISION_LABELS[r.decision] || r.decision) + '</span>') + '</td>' +
|
|
991
1114
|
'<td>' + actionsHtml + '</td>' +
|
|
992
1115
|
'</tr>';
|
|
993
1116
|
}
|
|
994
1117
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1118
|
+
// The Permissions tab: every tool the agent has, each with its ability
|
|
1119
|
+
// and Allow / Ask me / Never (saved at once: its rule for everyone is
|
|
1120
|
+
// updated, or added), then the decision when no rule matches. Rules in
|
|
1121
|
+
// full (scopes, names, adding and removing) are in Advanced.
|
|
1122
|
+
function renderPermissionsHtml(cfg) {
|
|
1123
|
+
var tools = cfg.tools || [];
|
|
1124
|
+
var rows = tools.map(function (t) {
|
|
1125
|
+
var decision = t.editable
|
|
1126
|
+
? decisionSwitch(t.decision, 'Decision for ' + t.tool, 'data-tool="' + escapeHtml(t.tool) + '"')
|
|
1127
|
+
: '<span class="le-pill ' + escapeHtml(t.decision) + '">' + escapeHtml(DECISION_LABELS[t.decision] || t.decision) + '</span>';
|
|
1128
|
+
var note = t.isDefault ? 'default' : t.scopeNote;
|
|
1129
|
+
return '<tr>' +
|
|
1130
|
+
'<td>' + (t.ability ? '<code>' + escapeHtml(t.ability) + '</code>' : '') + '</td>' +
|
|
1131
|
+
'<td><code>' + escapeHtml(t.tool) + '</code></td>' +
|
|
1132
|
+
'<td>' + decision + (note ? ' <span class="hint">' + escapeHtml(note) + '</span>' : '') + '</td>' +
|
|
1133
|
+
'</tr>';
|
|
1134
|
+
}).join('');
|
|
1135
|
+
return '<p class="muted" style="margin:0 0 20px">Decide which tool calls run on their own (Allow), which wait for a person (Ask me), and which never run (Never). Click to change; it's saved at once. Scoped rules, adding and removing rules are in <a href="?tab=advanced" data-tab="advanced">Advanced</a>.</p>' +
|
|
1136
|
+
'<section><h3>Tools (' + tools.length + ')</h3>' +
|
|
1137
|
+
(tools.length
|
|
1138
|
+
? '<div class="le-table-wrap"><table class="actauth-rules"><thead><tr><th>Ability</th><th>Tool</th><th>Decision</th></tr></thead><tbody>' + rows + '</tbody></table></div>'
|
|
1139
|
+
: '<p class="hint">This agent has no tools yet. Install an ability, or add a tool in Advanced.</p>') +
|
|
1140
|
+
'<p class="error" id="toolsDecisionError" style="margin:6px 0 0"></p></section>' +
|
|
1003
1141
|
'<section><h3>When no rule matches</h3>' +
|
|
1004
|
-
'
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
'</
|
|
1015
|
-
'<
|
|
1142
|
+
decisionSwitch(cfg.defaultDecision, 'Decision when no rule matches', 'id="defaultDecisionSwitch"') +
|
|
1143
|
+
'<p class="hint" style="margin:6px 0 0">For a tool with no rule (shown as default above), and any call no rule covers.</p>' +
|
|
1144
|
+
'<p class="error" id="defaultDecisionError" style="margin:4px 0 0"></p></section>';
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Advanced's Permission rules: every rule in actauth.yml, with its name
|
|
1148
|
+
// and scope, to edit, remove or add.
|
|
1149
|
+
function renderRulesEditorHtml(cfg) {
|
|
1150
|
+
var rulesHtml = cfg.rules.length
|
|
1151
|
+
? '<div class="le-table-wrap"><table class="actauth-rules actauth-rules-full"><thead><tr><th>Name</th><th>Who (tenant / environment)</th><th>Ability</th><th>Tool</th><th>Decision</th><th></th></tr></thead><tbody>' +
|
|
1152
|
+
cfg.rules.map(renderActauthRuleRow).join('') + '</tbody></table></div>'
|
|
1153
|
+
: '<p class="hint">No rules yet: every tool call gets the decision for when no rule matches.</p>';
|
|
1154
|
+
return '<p class="muted" style="margin:0 0 12px">Every rule in <code>actauth.yml</code>. The first rule that matches a call decides it; Who narrows a rule to a tenant and environment (<code>*/*</code> is everyone).</p>' +
|
|
1155
|
+
rulesHtml + '<p class="error" id="rulesError" style="margin:6px 0 0"></p>' +
|
|
1156
|
+
'<h3 id="ruleFormHeading" style="margin-top:20px">Add a rule</h3>' +
|
|
1016
1157
|
'<form class="add-source" id="ruleForm">' +
|
|
1017
1158
|
'<label>Name' +
|
|
1018
1159
|
'<input name="name" id="ruleNameInput" required placeholder="my-rule-name">' +
|
|
@@ -1025,9 +1166,9 @@ ${consoleShellScript}
|
|
|
1025
1166
|
'</label>' +
|
|
1026
1167
|
'<label>Decision' +
|
|
1027
1168
|
'<select name="decision" id="ruleDecisionInput">' +
|
|
1028
|
-
'<option value="allow">
|
|
1029
|
-
'<option value="ask" selected>
|
|
1030
|
-
'<option value="deny">
|
|
1169
|
+
'<option value="allow">Allow</option>' +
|
|
1170
|
+
'<option value="ask" selected>Ask me</option>' +
|
|
1171
|
+
'<option value="deny">Never</option>' +
|
|
1031
1172
|
'</select>' +
|
|
1032
1173
|
'</label>' +
|
|
1033
1174
|
'<button type="submit" id="ruleSubmitBtn">Add</button>' +
|
|
@@ -1071,12 +1212,44 @@ ${consoleShellScript}
|
|
|
1071
1212
|
}).join('') + '</tbody></table></div>';
|
|
1072
1213
|
}
|
|
1073
1214
|
|
|
1215
|
+
// The Messaging tab lists one section per app; each is set up and
|
|
1216
|
+
// saved on its own. A webhook always sends once set up; of the
|
|
1217
|
+
// messaging apps, only the primary one does.
|
|
1218
|
+
var CHANNEL_TITLES = { webhook: 'Webhook', telegram: 'Telegram', slack: 'Slack', lark: 'Lark / Feishu', email: 'Email' };
|
|
1219
|
+
// Each app's icon (web/messaging-icons.ts).
|
|
1220
|
+
var CHANNEL_ICONS = ${JSON.stringify(MESSAGING_ICONS)};
|
|
1221
|
+
var CHANNEL_BLURBS = {
|
|
1222
|
+
webhook: 'Your own app receives each request as a signed POST.',
|
|
1223
|
+
telegram: 'A Telegram bot posts requests to a group, channel or person.',
|
|
1224
|
+
slack: 'A Slack app posts requests to a channel, with buttons and replies in threads.',
|
|
1225
|
+
lark: 'A Lark or Feishu app posts requests to a chat.',
|
|
1226
|
+
email: 'Requests are emailed through Resend, SendGrid or Postmark.'
|
|
1227
|
+
};
|
|
1228
|
+
|
|
1229
|
+
function connectionFor(n, channel) {
|
|
1230
|
+
var list = n.connections || [];
|
|
1231
|
+
for (var i = 0; i < list.length; i++) if (list[i].channel === channel) return list[i];
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1074
1235
|
function renderNotifierStatus(n) {
|
|
1075
|
-
if (n.source === 'code') return 'Set in this agent's <code>index.ts</code> (<code>httpNotifier</code>, ' + escapeHtml(n.channel) + '). Edit it there; code always wins over the
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1236
|
+
if (n.source === 'code') return 'Set in this agent's <code>index.ts</code> (<code>httpNotifier</code>, ' + escapeHtml(n.channel) + '). Edit it there; code always wins over the connections below.';
|
|
1237
|
+
var rule = ' A webhook always sends once set up; of Telegram, Slack, Lark and email, only the primary one does.';
|
|
1238
|
+
var active = n.active || [];
|
|
1239
|
+
var sending = active.filter(function (a) { return !a.missingSecret; }).map(function (a) {
|
|
1240
|
+
return a.channel === 'webhook' ? 'your <strong>webhook</strong>' : '<strong>' + escapeHtml(CHANNEL_TITLES[a.channel] || a.channel) + '</strong> (primary)';
|
|
1241
|
+
});
|
|
1242
|
+
var waiting = active.filter(function (a) { return a.missingSecret; }).map(function (a) {
|
|
1243
|
+
return escapeHtml(CHANNEL_TITLES[a.channel] || a.channel) + ' is waiting for its secret (<code>' + escapeHtml(a.missingSecret) + '</code>)';
|
|
1244
|
+
});
|
|
1245
|
+
var appsSaved = (n.connections || []).some(function (c) { return c.channel !== 'webhook'; });
|
|
1246
|
+
var noPrimary = appsSaved && !n.primary ? ' No messaging app is primary: make one primary below.' : '';
|
|
1247
|
+
var html = sending.length ? 'Sending to ' + sending.join(' and ') + '.' : '';
|
|
1248
|
+
if (waiting.length) html += (html ? ' ' : '') + waiting.join('; ') + '.';
|
|
1249
|
+
if (html) return html + noPrimary + rule;
|
|
1250
|
+
var fallback = n.source === 'default' ? ' Until then the project-wide default webhook (<code>LOOPENGINE_DEFAULT_WEBHOOK_URL</code>, ' + escapeHtml(n.defaultWebhookUrl) + ') is used.' : ' Until then nobody is notified.';
|
|
1251
|
+
if (appsSaved) return '<strong>Nothing is sending.</strong>' + noPrimary + fallback + rule;
|
|
1252
|
+
return '<strong>Nowhere yet.</strong> Set up an app below; the first messaging app you save becomes primary.' + fallback + rule;
|
|
1080
1253
|
}
|
|
1081
1254
|
|
|
1082
1255
|
// Slack only: whether replies in a request's thread are answered. On
|
|
@@ -1092,43 +1265,52 @@ ${consoleShellScript}
|
|
|
1092
1265
|
: 'People can ask the agent about a request in its thread, and it answers there. Approvers (below) can also decide it there.') + '</p></div>';
|
|
1093
1266
|
}
|
|
1094
1267
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1268
|
+
// A secret's state at a glance: a pill by the label, and a placeholder
|
|
1269
|
+
// saying whether typing replaces a saved one. Its value is never shown.
|
|
1270
|
+
function secretPill(isSet) {
|
|
1271
|
+
return isSet ? '<span class="le-pill good">Saved</span>' : '<span class="le-pill warn">Not set</span>';
|
|
1272
|
+
}
|
|
1273
|
+
// The field's name and its pill on one line (form labels stack their
|
|
1274
|
+
// children, which would stretch the pill across the row).
|
|
1275
|
+
function secretHead(name, isSet) {
|
|
1276
|
+
return '<span style="display:flex;align-items:center;gap:8px">' + name + secretPill(isSet) + '</span>';
|
|
1277
|
+
}
|
|
1278
|
+
function secretPlaceholder(isSet, missing) {
|
|
1279
|
+
return isSet ? 'Saved ' + String.fromCharCode(8212) + ' type a new one only to replace it' : missing;
|
|
1280
|
+
}
|
|
1281
|
+
var SECRET_HINTS = {
|
|
1282
|
+
slack: 'xoxb-' + String.fromCharCode(8230) + ' from OAuth & Permissions',
|
|
1283
|
+
webhook: 'A long random string, or Generate',
|
|
1284
|
+
telegram: 'The token @BotFather gave you, like 123456:ABC' + String.fromCharCode(8230),
|
|
1285
|
+
lark: 'App Secret from the Lark developer console',
|
|
1286
|
+
email: 'An API key from your email provider'
|
|
1287
|
+
};
|
|
1288
|
+
function secretField(n, channel, label) {
|
|
1289
|
+
var isSet = n.secretsSet && n.secretsSet[channel];
|
|
1290
|
+
return '<label>' + secretHead(label, isSet) + '<span class="hint">(stored in this agent's env as ' + escapeHtml(n.secretVars[channel]) + ')</span>' +
|
|
1291
|
+
'<span style="display:flex;gap:6px"><input type="password" name="secret" autocomplete="off" style="flex:1;min-width:0" placeholder="' + escapeHtml(secretPlaceholder(isSet, SECRET_HINTS[channel] || '')) + '">' +
|
|
1292
|
+
(channel === 'webhook' ? '<button type="button" class="notifier-generate">Generate</button>' : '') + '</span></label>';
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// The settings only this app has.
|
|
1296
|
+
function renderChannelFields(n, channel, f) {
|
|
1297
|
+
if (channel === 'webhook') {
|
|
1298
|
+
return '<label>Webhook URL<input type="url" name="url" placeholder="https://your-app.example.com/hooks/loopengine" value="' + escapeHtml(f.url || '') + '"></label>' + secretField(n, 'webhook', 'Signing secret') +
|
|
1299
|
+
'<p class="hint" style="margin:0">Your app checks the <code>X-Actauth-Signature</code> header (HMAC-SHA256 of the body with the signing secret). Each payload carries a <code>decideUrl</code> it can simply forward to a person, or it can decide itself by sending <code>{"decision":"approve"}</code> or <code>{"decision":"deny"}</code> to <code>resolveUrl</code>.</p>';
|
|
1123
1300
|
}
|
|
1124
|
-
|
|
1125
|
-
'<
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1301
|
+
if (channel === 'telegram') {
|
|
1302
|
+
return '<details class="slack-step"' + (n.secretsSet && n.secretsSet.telegram ? '' : ' open') + '><summary>Create the Telegram bot (once)</summary>' +
|
|
1303
|
+
'<ol class="hint" style="margin:6px 0;padding-left:18px">' +
|
|
1304
|
+
'<li>Message <a href="https://t.me/BotFather" target="_blank" rel="noopener">@BotFather</a>, send /newbot and follow the steps. Paste the token it gives you below.</li>' +
|
|
1305
|
+
'<li>Add the bot to the group or channel that should get requests (as an admin, for a channel), or start a chat with it yourself.</li>' +
|
|
1306
|
+
'<li>Find the chat id: send a message there, then open https://api.telegram.org/bot<token>/getUpdates and copy <code>chat.id</code>. A public channel can use @its-name instead.</li>' +
|
|
1307
|
+
'</ol></details>' +
|
|
1308
|
+
secretField(n, 'telegram', 'Bot token') +
|
|
1309
|
+
'<label>Chat ID <span class="hint">(a group or channel id like -1001234567890, or @channelname)</span><input name="chatId" placeholder="-1001234567890" value="' + escapeHtml(f.chatId || '') + '"></label>' +
|
|
1310
|
+
'<p class="hint" style="margin:0">Each message links to a page where the person approves, denies or answers; there are no buttons in Telegram yet.</p>';
|
|
1311
|
+
}
|
|
1312
|
+
if (channel === 'slack') {
|
|
1313
|
+
return '<details class="slack-step"' + (n.secretsSet && n.secretsSet.slack ? '' : ' open') + '><summary>Create the Slack app (once)</summary>' +
|
|
1132
1314
|
'<ol class="hint" style="margin:6px 0;padding-left:18px">' +
|
|
1133
1315
|
'<li>Open <a href="https://api.slack.com/apps?new_app=1" target="_blank" rel="noopener">Slack apps</a> → Create New App → From a manifest, and pick your workspace.</li>' +
|
|
1134
1316
|
'<li>Paste the manifest below, create the app, then Install to Workspace.</li>' +
|
|
@@ -1137,7 +1319,7 @@ ${consoleShellScript}
|
|
|
1137
1319
|
'<textarea id="slackManifest" readonly rows="8" style="font-family:var(--font-mono);font-size:11.5px"></textarea>' +
|
|
1138
1320
|
'<button type="button" id="slackManifestCopy" style="margin-top:6px">Copy manifest</button>' +
|
|
1139
1321
|
'</details>' +
|
|
1140
|
-
secretField('slack', 'Bot token') +
|
|
1322
|
+
secretField(n, 'slack', 'Bot token') +
|
|
1141
1323
|
'<label>Channel <span class="hint">(invite the bot to a private channel first: /invite @its-name)</span>' +
|
|
1142
1324
|
'<span style="display:flex;gap:6px"><select id="slackChannelSelect" style="flex:1;min-width:0"><option value="">' + (f.channelId ? 'Saved: ' + escapeHtml(f.channelId) : 'Load channels to pick one') + '</option></select>' +
|
|
1143
1325
|
'<button type="button" id="slackLoadChannels">Load channels</button></span></label>' +
|
|
@@ -1147,29 +1329,74 @@ ${consoleShellScript}
|
|
|
1147
1329
|
'<input type="password" name="signingSecret" autocomplete="off" placeholder="' + escapeHtml(secretPlaceholder(n.slack && n.slack.signingSecretSet, 'Paste the Signing Secret')) + '"></label>' +
|
|
1148
1330
|
'<p class="hint" style="margin:0">Buttons send their clicks to <code id="slackInteractivityUrl"></code> (the manifest already points there).</p>' +
|
|
1149
1331
|
renderConversationsField(n, f) +
|
|
1150
|
-
'<p class="hint" style="margin:0" id="slackEventsNote"></p>'
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
'
|
|
1332
|
+
'<p class="hint" style="margin:0" id="slackEventsNote"></p>';
|
|
1333
|
+
}
|
|
1334
|
+
if (channel === 'lark') {
|
|
1335
|
+
return '<label>App ID<input name="appId" value="' + escapeHtml(f.appId || '') + '"></label><label>Chat ID<input name="chatId" value="' + escapeHtml(f.chatId || '') + '"></label>' + secretField(n, 'lark', 'App secret');
|
|
1336
|
+
}
|
|
1337
|
+
return '<label>Email addresses <span class="hint">(who gets the requests; separate several with commas)</span><input name="emailTo" placeholder="ops@example.com, lead@example.com" value="' + escapeHtml(f.to || '') + '"></label>' +
|
|
1338
|
+
'<label>Sent by <span class="hint">(your email provider must allow sending from this address)</span><input name="emailFrom" placeholder="LoopEngine <approvals@example.com>" value="' + escapeHtml(f.from || '') + '"></label>' +
|
|
1339
|
+
'<label>Email provider<select name="emailProvider">' +
|
|
1340
|
+
[['resend', 'Resend'], ['sendgrid', 'SendGrid'], ['postmark', 'Postmark']].map(function (p) { return '<option value="' + p[0] + '"' + (f.provider === p[0] ? ' selected' : '') + '>' + p[1] + '</option>'; }).join('') +
|
|
1341
|
+
'</select></label>' +
|
|
1342
|
+
secretField(n, 'email', 'API key') +
|
|
1343
|
+
'<p class="hint" style="margin:0">Each email links to a page where the person approves, denies, edits or answers, with no login. The link address below must be one they can open.</p>';
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// A messaging app's form says whether it's the primary one, or offers
|
|
1347
|
+
// to make it primary on Save (ticked already when none is). The webhook
|
|
1348
|
+
// has neither: it always sends.
|
|
1349
|
+
function renderPrimaryField(n, channel, saved, isPrimary) {
|
|
1350
|
+
if (channel === 'webhook') return '';
|
|
1351
|
+
if (isPrimary) return '<p class="hint" style="margin:0"><span class="le-pill accent">Primary</span> This is the primary messaging app. To switch, tick “Make this the primary messaging app” in another app's section and save it.</p>';
|
|
1352
|
+
return '<div class="body-field" style="margin:0">' +
|
|
1353
|
+
'<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="makePrimary"' + (n.primary ? '' : ' checked') + '> Make this the primary messaging app</label>' +
|
|
1354
|
+
'<p class="hint" style="margin:2px 0 0">' + (n.primary
|
|
1355
|
+
? 'Only the primary messaging app sends; now that's ' + escapeHtml(CHANNEL_TITLES[n.primary] || n.primary) + '. ' + (saved ? 'Send test works either way.' : 'Leave this off to save ' + escapeHtml(CHANNEL_TITLES[channel] || channel) + ' for later.')
|
|
1356
|
+
: 'No messaging app is primary yet, so saving this one makes it primary.') + '</p></div>';
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// One app's section: its status in the summary, then its own form.
|
|
1360
|
+
function renderConnectionSection(n, channel) {
|
|
1361
|
+
var saved = connectionFor(n, channel);
|
|
1362
|
+
var f = saved || { channel: channel, events: ['approval', 'question'] };
|
|
1363
|
+
var isPrimary = n.primary === channel;
|
|
1364
|
+
var isWebhook = channel === 'webhook';
|
|
1365
|
+
var secretSet = n.secretsSet && n.secretsSet[channel];
|
|
1366
|
+
var pills = isPrimary ? '<span class="le-pill accent">Primary</span>' : isWebhook && saved ? '<span class="le-pill accent">Always on</span>' : '';
|
|
1367
|
+
pills += saved ? (secretSet ? ' <span class="le-pill good">Saved</span>' : ' <span class="le-pill warn">Secret not set</span>') : ' <span class="le-pill">Not set up</span>';
|
|
1368
|
+
var events = f.events || [];
|
|
1369
|
+
function eventBox(e) {
|
|
1370
|
+
return '<label class="tool-picker-item" style="border:0;padding:0;justify-content:flex-start"><input type="checkbox" name="events" value="' + e[0] + '"' + (events.indexOf(e[0]) !== -1 ? ' checked' : '') + '> ' + e[1] + '</label>';
|
|
1371
|
+
}
|
|
1372
|
+
var locked = n.canSave === false;
|
|
1373
|
+
// Every section starts closed; loadApprovalsSection reopens the ones
|
|
1374
|
+
// that were open before a save redrew the tab.
|
|
1375
|
+
var inUse = isPrimary || (isWebhook && saved);
|
|
1376
|
+
return '<details class="msg-conn' + (inUse ? ' is-primary' : '') + '" data-channel="' + channel + '">' +
|
|
1377
|
+
'<summary><span class="msg-conn-title">' + (CHANNEL_ICONS[channel] || '') + escapeHtml(CHANNEL_TITLES[channel] || channel) + '</span>' + pills +
|
|
1378
|
+
'<span class="hint msg-conn-blurb">' + escapeHtml(CHANNEL_BLURBS[channel] || '') + '</span></summary>' +
|
|
1379
|
+
'<form class="add-source notifier-form" data-channel="' + channel + '" style="max-width:560px">' +
|
|
1380
|
+
renderChannelFields(n, channel, f) +
|
|
1381
|
+
'<label>Link address <span class="hint">(messages link here so people can approve without logging in; use an address they can reach)</span>' +
|
|
1382
|
+
'<input type="url" name="publicUrl" class="notifier-public-url" value="' + escapeHtml(n.publicUrl || f.publicUrl || location.origin) + '"' + (n.publicUrl ? ' disabled title="Set by LOOPENGINE_PUBLIC_URL"' : '') + '></label>' +
|
|
1383
|
+
'<div class="body-field">Send' +
|
|
1384
|
+
'<div style="display:flex;gap:14px;flex-wrap:wrap">' + NOTIFIER_EVENTS.slice(0, 2).map(eventBox).join('') + '</div>' +
|
|
1385
|
+
'<details' + (events.indexOf('run_finish') !== -1 || events.indexOf('run_start') !== -1 ? ' open' : '') + '><summary>More events</summary>' +
|
|
1386
|
+
'<div style="display:flex;gap:14px;flex-wrap:wrap;margin-top:6px">' + NOTIFIER_EVENTS.slice(2).map(eventBox).join('') + '</div>' +
|
|
1387
|
+
'<p class="hint" style="margin:4px 0 0">Run finished tells your app the agent's final reply once a decision lets it finish.</p></details></div>' +
|
|
1388
|
+
renderPrimaryField(n, channel, saved, isPrimary) +
|
|
1389
|
+
'<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit"' + (locked ? ' disabled' : '') + '>' + (saved ? 'Save' : 'Save ' + escapeHtml(CHANNEL_TITLES[channel] || channel)) + '</button>' +
|
|
1390
|
+
(saved ? '<button type="button" class="notifier-test">Send test</button>' : '') +
|
|
1391
|
+
(saved ? '<button type="button" class="delete-btn notifier-remove"' + (locked ? ' disabled' : '') + '>Remove</button>' : '') + '</div>' +
|
|
1392
|
+
(isWebhook ? '<p class="hint" style="margin:0">A webhook always sends once saved, alongside the primary messaging app.</p>' : '') +
|
|
1393
|
+
'<div class="hint notifier-result"></div>' +
|
|
1394
|
+
'</form></details>';
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function renderConnectionsHtml(n) {
|
|
1398
|
+
return (n.canSave === false ? '<p class="error" style="margin:0 0 12px">Saving needs <code>LOOPENGINE_ADMIN_AUTH</code> set on the server, because this stores a secret. Add <code>LOOPENGINE_ADMIN_AUTH=user:pass</code> to <code>.env</code> and restart.</p>' : '') +
|
|
1399
|
+
'<div class="msg-conns">' + (n.channels || ['slack', 'telegram', 'lark', 'email', 'webhook']).map(function (c) { return renderConnectionSection(n, c); }).join('') + '</div>';
|
|
1173
1400
|
}
|
|
1174
1401
|
|
|
1175
1402
|
function renderDeliveries(list) {
|
|
@@ -1207,15 +1434,18 @@ ${consoleShellScript}
|
|
|
1207
1434
|
}
|
|
1208
1435
|
|
|
1209
1436
|
// Slack only: the people who can decide by replying in a request's
|
|
1210
|
-
// thread, and whose button clicks count once any are listed.
|
|
1437
|
+
// thread, and whose button clicks count once any are listed. Shown once
|
|
1438
|
+
// a Slack connection is saved; they only act while Slack is primary.
|
|
1211
1439
|
function renderApproversHtml(n) {
|
|
1212
|
-
|
|
1213
|
-
|
|
1440
|
+
var slack = connectionFor(n, 'slack');
|
|
1441
|
+
if (n.source === 'code' || !slack) return '';
|
|
1442
|
+
var approvers = slack.approvers || [];
|
|
1214
1443
|
var rows = approvers.map(function (a) {
|
|
1215
1444
|
return '<tr><td>' + escapeHtml(a.name || a.user) + '</td><td><code>' + escapeHtml(a.user) + '</code></td>' +
|
|
1216
1445
|
'<td><button type="button" class="delete-btn approver-remove" data-user="' + escapeHtml(a.user) + '"' + (n.canSave === false ? ' disabled' : '') + '>Remove</button></td></tr>';
|
|
1217
1446
|
}).join('');
|
|
1218
|
-
return '<section id="approversSection"><h3>
|
|
1447
|
+
return '<section id="approversSection"><h3>Slack approvers</h3>' +
|
|
1448
|
+
(n.primary !== 'slack' ? '<p class="hint" style="margin:0 0 8px">Slack isn't primary, so these take effect once it is.</p>' : '') +
|
|
1219
1449
|
'<p class="muted" style="margin:0 0 10px">People who can approve, deny, change or answer a request by replying in its thread, like “yes, go ahead” or “make it $40”. A plain “yes” runs it; a reply that's less clear (“sure, send it”, “yes but check the amount”) shows exactly what will run with a Confirm button first, and a change is shown to approve first. Once anyone is listed, only approvers' button clicks count too. Anyone in the channel can still ask about a request.</p>' +
|
|
1220
1450
|
(approvers.length
|
|
1221
1451
|
? '<div class="le-table-wrap" style="max-width:560px"><table><thead><tr><th>Name</th><th>Slack ID</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>'
|
|
@@ -1231,7 +1461,8 @@ ${consoleShellScript}
|
|
|
1231
1461
|
var section = detail.querySelector('#approversSection');
|
|
1232
1462
|
if (!section) return;
|
|
1233
1463
|
var result = section.querySelector('#approversResult');
|
|
1234
|
-
var
|
|
1464
|
+
var slack = connectionFor(n, 'slack');
|
|
1465
|
+
var current = (slack && slack.approvers) || [];
|
|
1235
1466
|
function save(list, message) {
|
|
1236
1467
|
result.textContent = 'Saving' + String.fromCharCode(8230);
|
|
1237
1468
|
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/approvers', {
|
|
@@ -1278,10 +1509,10 @@ ${consoleShellScript}
|
|
|
1278
1509
|
|
|
1279
1510
|
function renderApprovalsHtml(n) {
|
|
1280
1511
|
return '<p class="muted" style="margin:0 0 20px">Connect a messaging app, so this agent can reach people when nobody is in Chat. When an app calls it over HTTP or a scheduled task runs, and a tool call needs approval or the agent asks a question, the request is sent here. In Chat it shows up in the conversation instead.</p>' +
|
|
1281
|
-
'<section id="approvalsSetup"><h3>
|
|
1282
|
-
'<p class="muted" style="margin:0 0 12px">
|
|
1512
|
+
'<section id="approvalsSetup"><h3>Connections</h3>' +
|
|
1513
|
+
'<p class="muted" style="margin:0 0 12px">' + renderNotifierStatus(n) + '</p>' +
|
|
1283
1514
|
(n.error ? '<p class="error">' + escapeHtml(n.error) + '</p>' : '') +
|
|
1284
|
-
(n.source === 'code' ? '' :
|
|
1515
|
+
(n.source === 'code' ? '' : renderConnectionsHtml(n)) + '</section>' +
|
|
1285
1516
|
renderApproversHtml(n) +
|
|
1286
1517
|
'<section><h3>Recent deliveries</h3>' + renderDeliveries(n.deliveries || []) + '</section>';
|
|
1287
1518
|
}
|
|
@@ -1324,28 +1555,91 @@ ${consoleShellScript}
|
|
|
1324
1555
|
function wireApprovalsHandlers(name) {
|
|
1325
1556
|
var content = approvalsContentEl();
|
|
1326
1557
|
if (!content) return;
|
|
1327
|
-
var
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1558
|
+
var forms = content.querySelectorAll('.notifier-form');
|
|
1559
|
+
for (var i = 0; i < forms.length; i++) wireConnectionForm(name, forms[i]);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// One app's section: Save (with its "make primary" box), Send test and Remove act on
|
|
1563
|
+
// that app's connection only.
|
|
1564
|
+
function wireConnectionForm(name, form) {
|
|
1565
|
+
var channel = form.dataset.channel;
|
|
1566
|
+
var result = form.querySelector('.notifier-result');
|
|
1567
|
+
var base = '/agents/' + encodeURIComponent(name) + '/notifier';
|
|
1334
1568
|
var generate = form.querySelector('.notifier-generate');
|
|
1335
1569
|
if (generate) {
|
|
1336
1570
|
generate.addEventListener('click', function () {
|
|
1337
1571
|
var bytes = new Uint8Array(24);
|
|
1338
1572
|
crypto.getRandomValues(bytes);
|
|
1339
|
-
var input = form.querySelector('input[name="
|
|
1573
|
+
var input = form.querySelector('input[name="secret"]');
|
|
1340
1574
|
input.type = 'text';
|
|
1341
1575
|
input.value = Array.prototype.map.call(bytes, function (b) { return ('0' + b.toString(16)).slice(-2); }).join('');
|
|
1342
1576
|
result.textContent = 'Copy this secret into your app before saving; it is not shown again.';
|
|
1343
1577
|
});
|
|
1344
1578
|
}
|
|
1345
|
-
|
|
1579
|
+
if (channel === 'slack') wireSlackFields(name, form, result);
|
|
1580
|
+
|
|
1581
|
+
form.addEventListener('submit', function (ev) {
|
|
1582
|
+
ev.preventDefault();
|
|
1583
|
+
var data = new FormData(form);
|
|
1584
|
+
var body = { channel: channel, events: data.getAll('events'), publicUrl: data.get('publicUrl') };
|
|
1585
|
+
if (channel === 'webhook') body.url = data.get('url');
|
|
1586
|
+
if (channel === 'telegram') body.chatId = data.get('chatId');
|
|
1587
|
+
if (channel === 'slack') body.channelId = data.get('channelId');
|
|
1588
|
+
if (channel === 'lark') { body.appId = data.get('appId'); body.chatId = data.get('chatId'); }
|
|
1589
|
+
if (channel === 'email') { body.to = data.get('emailTo'); body.from = data.get('emailFrom'); body.provider = data.get('emailProvider'); }
|
|
1590
|
+
if (data.get('secret')) body.secret = data.get('secret');
|
|
1591
|
+
if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
|
|
1592
|
+
if (channel === 'slack') {
|
|
1593
|
+
var conversationsBox = form.querySelector('#slackConversations');
|
|
1594
|
+
var savedSlack = lastNotifier && connectionFor(lastNotifier, 'slack');
|
|
1595
|
+
// A locked switch (free plan) keeps whatever was saved before.
|
|
1596
|
+
if (conversationsBox && !conversationsBox.disabled) body.conversations = conversationsBox.checked;
|
|
1597
|
+
else if (savedSlack && savedSlack.conversations !== undefined) body.conversations = savedSlack.conversations;
|
|
1598
|
+
}
|
|
1599
|
+
// Ticked "Make this the primary messaging app" (or none is primary yet,
|
|
1600
|
+
// which the server treats the same) makes this one primary.
|
|
1601
|
+
var makePrimaryBox = form.querySelector('input[name="makePrimary"]');
|
|
1602
|
+
if (makePrimaryBox && makePrimaryBox.checked) body.primary = true;
|
|
1603
|
+
var first = Boolean(makePrimaryBox && makePrimaryBox.checked) || (channel !== 'webhook' && lastNotifier && !lastNotifier.primary);
|
|
1604
|
+
result.textContent = 'Saving' + String.fromCharCode(8230);
|
|
1605
|
+
fetchJsonRetryOnce(base, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(function (r) {
|
|
1606
|
+
if (!r.ok) { result.textContent = r.body.error || 'Could not save'; return; }
|
|
1607
|
+
loadApprovalsSection(name, first ? CHANNEL_TITLES[channel] + ' saved and made primary.' : CHANNEL_TITLES[channel] + ' saved.');
|
|
1608
|
+
});
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
var testBtn = form.querySelector('.notifier-test');
|
|
1612
|
+
if (testBtn) {
|
|
1613
|
+
testBtn.addEventListener('click', function () {
|
|
1614
|
+
result.textContent = 'Sending' + String.fromCharCode(8230);
|
|
1615
|
+
fetchJsonRetryOnce(base + '/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ channel: channel }) }).then(function (r) {
|
|
1616
|
+
var t = r.body;
|
|
1617
|
+
result.textContent = t.ok ? 'Test delivered (' + (t.status || 'ok') + ').' : 'Test failed: ' + (t.error || 'unknown error');
|
|
1618
|
+
});
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
var removeBtn = form.querySelector('.notifier-remove');
|
|
1623
|
+
if (removeBtn) {
|
|
1624
|
+
removeBtn.addEventListener('click', function () {
|
|
1625
|
+
if (removeBtn.dataset.confirming !== '1') {
|
|
1626
|
+
removeBtn.dataset.confirming = '1';
|
|
1627
|
+
removeBtn.textContent = lastNotifier && lastNotifier.primary === channel ? 'Click again: no messaging app will be primary' : 'Click again to remove';
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
fetchJsonRetryOnce(base + '?channel=' + encodeURIComponent(channel), { method: 'DELETE' }).then(function () {
|
|
1631
|
+
loadApprovalsSection(name, CHANNEL_TITLES[channel] + ' removed.');
|
|
1632
|
+
});
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// Slack's section: the manifest follows the link address; Load channels
|
|
1638
|
+
// fills the picker.
|
|
1639
|
+
function wireSlackFields(name, form, result) {
|
|
1346
1640
|
var n0 = lastNotifier;
|
|
1347
1641
|
var manifestEl = form.querySelector('#slackManifest');
|
|
1348
|
-
var linkInput = form.querySelector('
|
|
1642
|
+
var linkInput = form.querySelector('.notifier-public-url');
|
|
1349
1643
|
function refreshSlackManifest() {
|
|
1350
1644
|
if (!manifestEl || !n0 || !n0.slack) return;
|
|
1351
1645
|
var url = (linkInput.value || location.origin).replace(/[/]+$/, '') + n0.slack.interactivityPath;
|
|
@@ -1373,63 +1667,26 @@ ${consoleShellScript}
|
|
|
1373
1667
|
}
|
|
1374
1668
|
var loadChannels = form.querySelector('#slackLoadChannels');
|
|
1375
1669
|
var channelSelect = form.querySelector('#slackChannelSelect');
|
|
1376
|
-
if (loadChannels)
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
}).then(function (r) {
|
|
1384
|
-
loadChannels.disabled = false;
|
|
1385
|
-
if (!r.ok) { result.textContent = r.body.error || 'Could not load channels'; return; }
|
|
1386
|
-
var current = form.querySelector('input[name="channelId"]').value;
|
|
1387
|
-
channelSelect.innerHTML = '<option value="">Pick a channel</option>' + r.body.channels.map(function (c) {
|
|
1388
|
-
return '<option value="' + escapeHtml(c.id) + '"' + (c.id === current ? ' selected' : '') + '>' + (c.isPrivate ? '🔒 ' : '#') + escapeHtml(c.name) + (c.isMember ? '' : ' (bot not in channel yet)') + '</option>';
|
|
1389
|
-
}).join('');
|
|
1390
|
-
result.textContent = r.body.channels.length + ' channels found.';
|
|
1391
|
-
});
|
|
1392
|
-
});
|
|
1393
|
-
channelSelect.addEventListener('change', function () {
|
|
1394
|
-
if (channelSelect.value) form.querySelector('input[name="channelId"]').value = channelSelect.value;
|
|
1395
|
-
});
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
form.addEventListener('submit', function (ev) {
|
|
1399
|
-
ev.preventDefault();
|
|
1400
|
-
var data = new FormData(form);
|
|
1401
|
-
var channel = data.get('channel');
|
|
1402
|
-
var body = { channel: channel, events: data.getAll('events'), url: data.get('url'), channelId: data.get('channelId'), appId: data.get('appId'), chatId: data.get('chatId'), publicUrl: data.get('publicUrl') };
|
|
1403
|
-
if (channel === 'email') { body.to = data.get('emailTo'); body.from = data.get('emailFrom'); body.provider = data.get('emailProvider'); }
|
|
1404
|
-
var secret = data.get('secret_' + channel);
|
|
1405
|
-
if (secret) body.secret = secret;
|
|
1406
|
-
if (channel === 'slack' && data.get('signingSecret')) body.signingSecret = data.get('signingSecret');
|
|
1407
|
-
var conversationsBox = form.querySelector('#slackConversations');
|
|
1408
|
-
// A locked switch (free plan) keeps whatever was saved before.
|
|
1409
|
-
if (channel === 'slack' && conversationsBox && !conversationsBox.disabled) body.conversations = conversationsBox.checked;
|
|
1410
|
-
else if (channel === 'slack' && lastNotifier && lastNotifier.file && lastNotifier.file.conversations !== undefined) body.conversations = lastNotifier.file.conversations;
|
|
1411
|
-
result.textContent = 'Saving…';
|
|
1412
|
-
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', {
|
|
1413
|
-
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
|
|
1670
|
+
if (!loadChannels) return;
|
|
1671
|
+
loadChannels.addEventListener('click', function () {
|
|
1672
|
+
loadChannels.disabled = true;
|
|
1673
|
+
result.textContent = 'Loading channels' + String.fromCharCode(8230);
|
|
1674
|
+
var token = form.querySelector('input[name="secret"]').value;
|
|
1675
|
+
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/slack-channels', {
|
|
1676
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ botToken: token }),
|
|
1414
1677
|
}).then(function (r) {
|
|
1415
|
-
|
|
1416
|
-
|
|
1678
|
+
loadChannels.disabled = false;
|
|
1679
|
+
if (!r.ok) { result.textContent = r.body.error || 'Could not load channels'; return; }
|
|
1680
|
+
var current = form.querySelector('input[name="channelId"]').value;
|
|
1681
|
+
channelSelect.innerHTML = '<option value="">Pick a channel</option>' + r.body.channels.map(function (c) {
|
|
1682
|
+
return '<option value="' + escapeHtml(c.id) + '"' + (c.id === current ? ' selected' : '') + '>' + (c.isPrivate ? '🔒 ' : '#') + escapeHtml(c.name) + (c.isMember ? '' : ' (bot not in channel yet)') + '</option>';
|
|
1683
|
+
}).join('');
|
|
1684
|
+
result.textContent = r.body.channels.length + ' channels found.';
|
|
1417
1685
|
});
|
|
1418
1686
|
});
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier/test', { method: 'POST' }).then(function (r) {
|
|
1422
|
-
var t = r.body;
|
|
1423
|
-
loadApprovalsSection(name, t.ok ? 'Test delivered (' + (t.status || 'ok') + ').' : 'Test failed: ' + (t.error || 'unknown error'));
|
|
1424
|
-
});
|
|
1687
|
+
channelSelect.addEventListener('change', function () {
|
|
1688
|
+
if (channelSelect.value) form.querySelector('input[name="channelId"]').value = channelSelect.value;
|
|
1425
1689
|
});
|
|
1426
|
-
var removeBtn = content.querySelector('#notifierRemoveBtn');
|
|
1427
|
-
if (removeBtn) {
|
|
1428
|
-
removeBtn.addEventListener('click', function () {
|
|
1429
|
-
if (removeBtn.dataset.confirming !== '1') { removeBtn.dataset.confirming = '1'; removeBtn.textContent = 'Click again to remove'; return; }
|
|
1430
|
-
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/notifier', { method: 'DELETE' }).then(function () { loadApprovalsSection(name, 'Removed.'); });
|
|
1431
|
-
});
|
|
1432
|
-
}
|
|
1433
1690
|
}
|
|
1434
1691
|
|
|
1435
1692
|
function loadApprovalsSection(name, message) {
|
|
@@ -1442,7 +1699,13 @@ ${consoleShellScript}
|
|
|
1442
1699
|
if (currentName !== name) return;
|
|
1443
1700
|
if (!results[0].ok) throw new Error(results[0].body.error || 'request failed');
|
|
1444
1701
|
lastNotifier = results[0].body;
|
|
1702
|
+
// Keep open the app sections that were open (a save redraws the tab).
|
|
1703
|
+
var wasOpen = Array.prototype.map.call(content.querySelectorAll('details.msg-conn[open]'), function (d) { return d.dataset.channel; });
|
|
1445
1704
|
content.innerHTML = renderApprovalsHtml(results[0].body);
|
|
1705
|
+
wasOpen.forEach(function (channel) {
|
|
1706
|
+
var section = content.querySelector('details.msg-conn[data-channel="' + channel + '"]');
|
|
1707
|
+
if (section) section.open = true;
|
|
1708
|
+
});
|
|
1446
1709
|
wireApprovalsHandlers(name);
|
|
1447
1710
|
wireApproversHandlers(name, results[0].body);
|
|
1448
1711
|
var pendingEl = detail.querySelector('#pendingContent');
|
|
@@ -1640,9 +1903,16 @@ ${consoleShellScript}
|
|
|
1640
1903
|
return detail.querySelector('#actauthContent');
|
|
1641
1904
|
}
|
|
1642
1905
|
|
|
1906
|
+
function advancedRulesEl() {
|
|
1907
|
+
return detail.querySelector('#advancedRulesContent');
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// Permissions (one switch per tool, and the default) and Advanced's rule
|
|
1911
|
+
// editor (per-rule switches, Edit, Delete, Add a rule).
|
|
1643
1912
|
function wireActauthHandlers(name, cfg) {
|
|
1644
|
-
var
|
|
1645
|
-
|
|
1913
|
+
var perm = actauthContentEl();
|
|
1914
|
+
var content = advancedRulesEl();
|
|
1915
|
+
if (!content || !perm) return;
|
|
1646
1916
|
var editingRuleName = null;
|
|
1647
1917
|
|
|
1648
1918
|
function ruleByName(ruleName) {
|
|
@@ -1743,33 +2013,79 @@ ${consoleShellScript}
|
|
|
1743
2013
|
});
|
|
1744
2014
|
});
|
|
1745
2015
|
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
2016
|
+
// Allow / Ask me / Never: a click saves at once. The pressed button
|
|
2017
|
+
// moves straight away and moves back if the save fails.
|
|
2018
|
+
function wireDecisionSwitch(group, errorEl, save) {
|
|
2019
|
+
group.addEventListener('click', function (ev) {
|
|
2020
|
+
var btn = ev.target.closest('button[data-decision]');
|
|
2021
|
+
if (!btn || btn.getAttribute('aria-pressed') === 'true' || group.classList.contains('is-saving')) return;
|
|
2022
|
+
var buttons = group.querySelectorAll('button[data-decision]');
|
|
2023
|
+
var before = group.querySelector('button[aria-pressed="true"]');
|
|
2024
|
+
function press(target) {
|
|
2025
|
+
for (var k = 0; k < buttons.length; k++) buttons[k].setAttribute('aria-pressed', buttons[k] === target ? 'true' : 'false');
|
|
2026
|
+
}
|
|
2027
|
+
press(btn);
|
|
2028
|
+
group.classList.add('is-saving');
|
|
2029
|
+
errorEl.textContent = '';
|
|
2030
|
+
save(btn.getAttribute('data-decision'))
|
|
2031
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
2032
|
+
.then(function (result) {
|
|
2033
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
2034
|
+
applyActauthConfig(name, result.body);
|
|
2035
|
+
refreshOverviewPanel(name);
|
|
2036
|
+
})
|
|
2037
|
+
.catch(function (err) {
|
|
2038
|
+
press(before);
|
|
2039
|
+
group.classList.remove('is-saving');
|
|
2040
|
+
errorEl.textContent = 'Could not save: ' + err.message;
|
|
2041
|
+
});
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
var ruleSwitches = content.querySelectorAll('.decision-switch[data-rule]');
|
|
2046
|
+
for (var s = 0; s < ruleSwitches.length; s++) {
|
|
2047
|
+
(function (group) {
|
|
2048
|
+
var rule = ruleByName(group.getAttribute('data-rule'));
|
|
2049
|
+
if (!rule) return;
|
|
2050
|
+
wireDecisionSwitch(group, content.querySelector('#rulesError'), function (decision) {
|
|
2051
|
+
return fetch('/agents/' + encodeURIComponent(name) + '/actauth/rules/' + encodeURIComponent(rule.name), {
|
|
2052
|
+
method: 'PUT',
|
|
2053
|
+
headers: { 'content-type': 'application/json' },
|
|
2054
|
+
body: JSON.stringify({ scope: rule.scope, tool: rule.tool, decision: decision }),
|
|
2055
|
+
});
|
|
2056
|
+
});
|
|
2057
|
+
})(ruleSwitches[s]);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
var toolSwitches = perm.querySelectorAll('.decision-switch[data-tool]');
|
|
2061
|
+
for (var ts = 0; ts < toolSwitches.length; ts++) {
|
|
2062
|
+
(function (group) {
|
|
2063
|
+
var tool = group.getAttribute('data-tool');
|
|
2064
|
+
wireDecisionSwitch(group, perm.querySelector('#toolsDecisionError'), function (decision) {
|
|
2065
|
+
return fetch('/agents/' + encodeURIComponent(name) + '/actauth/tools/' + encodeURIComponent(tool), {
|
|
2066
|
+
method: 'PUT',
|
|
2067
|
+
headers: { 'content-type': 'application/json' },
|
|
2068
|
+
body: JSON.stringify({ decision: decision }),
|
|
2069
|
+
});
|
|
2070
|
+
});
|
|
2071
|
+
})(toolSwitches[ts]);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
wireDecisionSwitch(perm.querySelector('#defaultDecisionSwitch'), perm.querySelector('#defaultDecisionError'), function (decision) {
|
|
2075
|
+
return fetch('/agents/' + encodeURIComponent(name) + '/actauth/default-decision', {
|
|
1753
2076
|
method: 'PUT',
|
|
1754
2077
|
headers: { 'content-type': 'application/json' },
|
|
1755
2078
|
body: JSON.stringify({ decision: decision }),
|
|
1756
|
-
})
|
|
1757
|
-
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1758
|
-
.then(function (result) {
|
|
1759
|
-
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1760
|
-
applyActauthConfig(name, result.body);
|
|
1761
|
-
refreshOverviewPanel(name);
|
|
1762
|
-
})
|
|
1763
|
-
.catch(function (err) {
|
|
1764
|
-
errorEl.textContent = err.message;
|
|
1765
|
-
});
|
|
2079
|
+
});
|
|
1766
2080
|
});
|
|
1767
2081
|
}
|
|
1768
2082
|
|
|
1769
2083
|
function applyActauthConfig(name, cfg) {
|
|
1770
2084
|
var content = actauthContentEl();
|
|
1771
|
-
|
|
1772
|
-
content
|
|
2085
|
+
var advanced = advancedRulesEl();
|
|
2086
|
+
if (!content || !advanced) return;
|
|
2087
|
+
content.innerHTML = renderPermissionsHtml(cfg);
|
|
2088
|
+
advanced.innerHTML = renderRulesEditorHtml(cfg);
|
|
1773
2089
|
wireActauthHandlers(name, cfg);
|
|
1774
2090
|
actauthLoadedFor = name;
|
|
1775
2091
|
}
|
|
@@ -1778,6 +2094,8 @@ ${consoleShellScript}
|
|
|
1778
2094
|
var content = actauthContentEl();
|
|
1779
2095
|
if (!content) return;
|
|
1780
2096
|
content.innerHTML = '<p class="hint">Loading…</p>';
|
|
2097
|
+
var advanced = advancedRulesEl();
|
|
2098
|
+
if (advanced) advanced.innerHTML = '<p class="hint">Loading…</p>';
|
|
1781
2099
|
fetch('/agents/' + encodeURIComponent(name) + '/actauth')
|
|
1782
2100
|
.then(function (r) { return r.json(); })
|
|
1783
2101
|
.then(function (cfg) {
|
|
@@ -2022,46 +2340,53 @@ ${consoleShellScript}
|
|
|
2022
2340
|
actionHtml += ' <button type="button" class="ability-install-deps-btn" data-name="' + escapeHtml(a.name) + '" title="npm install ' + escapeHtml(deps.join(' ')) + '">Install deps</button>';
|
|
2023
2341
|
}
|
|
2024
2342
|
actionHtml += ' <button type="button" class="ability-uninstall-btn" data-name="' + escapeHtml(a.name) + '">Uninstall</button>';
|
|
2343
|
+
// Details: what it can do, with this agent's own permissions. Fetched
|
|
2344
|
+
// by its install spec, pinned to the installed version for an npm name.
|
|
2345
|
+
var spec = a.spec || a.name;
|
|
2346
|
+
if (!/^(git[+:]|github:|https?:|file:|[.]|[/])/.test(spec) && spec.lastIndexOf('@') <= 0 && a.version) spec += '@' + a.version;
|
|
2347
|
+
var detailsHtml = '<button type="button" class="ability-details-btn" data-spec="' + escapeHtml(spec) + '" data-agent="' + escapeHtml(currentName) + '">Details</button>';
|
|
2025
2348
|
return '<tr>' +
|
|
2026
2349
|
'<td><code>' + escapeHtml(a.name) + '</code></td>' +
|
|
2027
2350
|
'<td>' + escapeHtml(a.version) + '</td>' +
|
|
2351
|
+
'<td>' + leShell.abilityOriginBadge(a.origin) + '</td>' +
|
|
2028
2352
|
'<td class="hint">' + escapeHtml((a.tools || []).join(', ')) + '</td>' +
|
|
2029
|
-
'<td>' + actionHtml + '</td>' +
|
|
2353
|
+
'<td style="white-space:nowrap">' + detailsHtml + ' ' + actionHtml + '</td>' +
|
|
2030
2354
|
'</tr>';
|
|
2031
2355
|
}
|
|
2032
2356
|
|
|
2033
|
-
|
|
2357
|
+
// A public (npm) or private ability not installed here yet: its
|
|
2358
|
+
// details show the permissions it ships with.
|
|
2359
|
+
function renderAbilitySearchResultRow(pkg, installedNames, spec) {
|
|
2360
|
+
var installSpec = spec || pkg.name;
|
|
2034
2361
|
var actionHtml = installedNames.indexOf(pkg.name) !== -1
|
|
2035
2362
|
? '<span class="hint">Installed</span>'
|
|
2036
|
-
: '<button type="button" class="ability-install-btn" data-spec="' + escapeHtml(
|
|
2363
|
+
: '<button type="button" class="ability-install-btn" data-spec="' + escapeHtml(installSpec) + '">Install</button>';
|
|
2364
|
+
var detailsSpec = spec || (pkg.version ? pkg.name + '@' + pkg.version : pkg.name);
|
|
2037
2365
|
return '<tr>' +
|
|
2038
|
-
'<td><code>' + escapeHtml(pkg.name) + '</code> <span class="hint">' + escapeHtml(pkg.version) + '</span
|
|
2366
|
+
'<td><code>' + escapeHtml(pkg.name) + '</code>' + (pkg.version ? ' <span class="hint">' + escapeHtml(pkg.version) + '</span>' : '') + '</td>' +
|
|
2039
2367
|
'<td>' + escapeHtml(pkg.description || '') + '</td>' +
|
|
2040
|
-
'<td>' + actionHtml + '</td>' +
|
|
2368
|
+
'<td style="white-space:nowrap"><button type="button" class="ability-details-btn" data-spec="' + escapeHtml(detailsSpec) + '">Details</button> ' + actionHtml + '</td>' +
|
|
2041
2369
|
'</tr>';
|
|
2042
2370
|
}
|
|
2043
2371
|
|
|
2044
2372
|
function renderAbilitiesConfigHtml(data) {
|
|
2045
2373
|
var installed = data.abilities || [];
|
|
2046
2374
|
var installedHtml = installed.length
|
|
2047
|
-
? '<table><thead><tr><th>Name</th><th>Version</th><th>Tools</th><th></th></tr></thead><tbody>' + installed.map(renderInstalledAbilityRow).join('') + '</tbody></table>' +
|
|
2375
|
+
? '<table><thead><tr><th>Name</th><th>Version</th><th>Source</th><th>Tools</th><th></th></tr></thead><tbody>' + installed.map(renderInstalledAbilityRow).join('') + '</tbody></table>' +
|
|
2048
2376
|
'<p id="abilityUpgradeStatus" class="hint"></p>'
|
|
2049
2377
|
: '<p class="hint">No abilities installed for this agent yet.</p>';
|
|
2050
2378
|
|
|
2051
2379
|
return '<h3>Installed</h3>' + installedHtml +
|
|
2052
|
-
|
|
2053
|
-
'<
|
|
2054
|
-
|
|
2380
|
+
// Installing by name or spec is on the console's Abilities page.
|
|
2381
|
+
'<p id="abilityInstallStatus" class="hint"></p>' +
|
|
2382
|
+
'<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Public abilities <span class="hint" style="font-weight:400">npm packages tagged loopengine-ability</span></h3>' +
|
|
2383
|
+
'<form id="abilitySearchForm" style="display:flex;gap:6px;margin:0 0 10px;max-width:560px">' +
|
|
2384
|
+
'<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search public abilities" style="flex:1">' +
|
|
2055
2385
|
'<button type="submit">Search</button>' +
|
|
2056
2386
|
'</form>' +
|
|
2057
|
-
'<div id="abilitySearchResults"></div>' +
|
|
2058
|
-
'<h3 style="margin-top:24px">
|
|
2059
|
-
'<p class="hint">
|
|
2060
|
-
'<form id="abilityInstallForm" class="add-source">' +
|
|
2061
|
-
'<input type="text" name="spec" placeholder="e.g. git+https://github.com/org/repo.git or file:../my-ability" required>' +
|
|
2062
|
-
'<button type="submit">Install</button>' +
|
|
2063
|
-
'</form>' +
|
|
2064
|
-
'<p id="abilityInstallStatus" class="hint"></p>';
|
|
2387
|
+
'<div id="abilitySearchResults" style="margin-bottom:32px"></div>' +
|
|
2388
|
+
'<h3 style="margin-top:24px;justify-content:flex-start;align-items:baseline;gap:8px">Private abilities <span class="hint" style="font-weight:400">your own, installed in this workspace</span></h3>' +
|
|
2389
|
+
'<div id="abilityPrivateResults" style="margin-bottom:32px"><p class="hint">Loading…</p></div>';
|
|
2065
2390
|
}
|
|
2066
2391
|
|
|
2067
2392
|
function abilitiesContentEl() {
|
|
@@ -2230,6 +2555,34 @@ ${consoleShellScript}
|
|
|
2230
2555
|
});
|
|
2231
2556
|
}
|
|
2232
2557
|
|
|
2558
|
+
// The workspace's private abilities (installed from the console's
|
|
2559
|
+
// Abilities page, core/workspace-abilities.ts), each with Details and
|
|
2560
|
+
// Install into this agent. With none, a link to install some there.
|
|
2561
|
+
function loadPrivateAbilitiesFor(name, content, installedNames) {
|
|
2562
|
+
var el = content.querySelector('#abilityPrivateResults');
|
|
2563
|
+
if (!el) return;
|
|
2564
|
+
fetch('/abilities/workspace')
|
|
2565
|
+
.then(function (r) { return r.json(); })
|
|
2566
|
+
.then(function (d) {
|
|
2567
|
+
if (currentName !== name) return;
|
|
2568
|
+
var abilities = d.abilities || [];
|
|
2569
|
+
if (!abilities.length) {
|
|
2570
|
+
el.innerHTML = '<p><a href="/config?section=abilities#private-abilities">Install private abilities</a></p>';
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
el.innerHTML = '<table><thead><tr><th>Ability</th><th>What it does</th><th></th></tr></thead><tbody>' +
|
|
2574
|
+
abilities.map(function (a) { return renderAbilitySearchResultRow(a, installedNames, a.spec); }).join('') + '</tbody></table>' +
|
|
2575
|
+
'<p class="hint" style="margin:6px 0 0"><a href="/config?section=abilities#private-abilities">Install more private abilities</a></p>';
|
|
2576
|
+
var installBtns = el.querySelectorAll('.ability-install-btn');
|
|
2577
|
+
for (var i = 0; i < installBtns.length; i++) {
|
|
2578
|
+
installBtns[i].addEventListener('click', function (ev) {
|
|
2579
|
+
installAbilitySpec(name, ev.currentTarget.dataset.spec, content.querySelector('#abilityInstallStatus'), ev.currentTarget);
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
})
|
|
2583
|
+
.catch(function (err) { el.innerHTML = '<p class="error">Could not list private abilities: ' + escapeHtml(err.message) + '</p>'; });
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2233
2586
|
// Shared by the initial (empty-query) load right after the tab opens
|
|
2234
2587
|
// and the search form's own submit — the empty-query case is just
|
|
2235
2588
|
// "list every published ability" (the keyword filter alone, no extra
|
|
@@ -2262,6 +2615,16 @@ ${consoleShellScript}
|
|
|
2262
2615
|
var content = abilitiesContentEl();
|
|
2263
2616
|
if (!content) return;
|
|
2264
2617
|
|
|
2618
|
+
// Details buttons anywhere in the tab, including search results that
|
|
2619
|
+
// arrive later. The tab's container outlives each redraw, so wired once.
|
|
2620
|
+
if (!content.dataset.detailsWired) {
|
|
2621
|
+
content.dataset.detailsWired = '1';
|
|
2622
|
+
content.addEventListener('click', function (ev) {
|
|
2623
|
+
var btn = ev.target.closest && ev.target.closest('.ability-details-btn');
|
|
2624
|
+
if (btn) leShell.toggleAbilityDetails(btn);
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2265
2628
|
var upgradeStatusEl = content.querySelector('#abilityUpgradeStatus');
|
|
2266
2629
|
var upgradeBtns = content.querySelectorAll('.ability-upgrade-btn');
|
|
2267
2630
|
for (var u = 0; u < upgradeBtns.length; u++) {
|
|
@@ -2301,17 +2664,7 @@ ${consoleShellScript}
|
|
|
2301
2664
|
// operator explicitly searches for something.
|
|
2302
2665
|
runAbilitySearch(name, '', content, resultsEl, installedNames);
|
|
2303
2666
|
}
|
|
2304
|
-
|
|
2305
|
-
var installForm = content.querySelector('#abilityInstallForm');
|
|
2306
|
-
if (installForm) {
|
|
2307
|
-
installForm.addEventListener('submit', function (ev) {
|
|
2308
|
-
ev.preventDefault();
|
|
2309
|
-
var spec = new FormData(installForm).get('spec');
|
|
2310
|
-
var statusEl = content.querySelector('#abilityInstallStatus');
|
|
2311
|
-
var btn = installForm.querySelector('button[type="submit"]');
|
|
2312
|
-
installAbilitySpec(name, spec, statusEl, btn);
|
|
2313
|
-
});
|
|
2314
|
-
}
|
|
2667
|
+
loadPrivateAbilitiesFor(name, content, installedNames);
|
|
2315
2668
|
}
|
|
2316
2669
|
|
|
2317
2670
|
function applyAbilitiesConfig(name, data) {
|
|
@@ -2537,8 +2890,10 @@ ${consoleShellScript}
|
|
|
2537
2890
|
'<section><h3>Your tools (' + cfg.localTools.length + ')</h3>' + renderLocalTools(cfg.localTools) +
|
|
2538
2891
|
'<div class="admin-subsection"><h4 id="httpToolFormHeading">Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
|
|
2539
2892
|
'</section>' +
|
|
2540
|
-
|
|
2541
|
-
|
|
2893
|
+
// Shown only for an agent that already has Composio tools
|
|
2894
|
+
// (gateway-tools.yml): see applyGatewaySources.
|
|
2895
|
+
'<section id="gatewayToolsSection" style="display:none">' +
|
|
2896
|
+
'<h3>From Composio <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
|
|
2542
2897
|
'<div id="gatewayToolsContent"><p class="hint">Loading…</p></div>' +
|
|
2543
2898
|
'</section>' +
|
|
2544
2899
|
'<section><h3>Subagents (' + cfg.agentAsTools.length + ')</h3>' + renderTools(cfg.agentAsTools) + '</section>';
|
|
@@ -3082,6 +3437,8 @@ ${consoleShellScript}
|
|
|
3082
3437
|
function applyGatewaySources(name, sources) {
|
|
3083
3438
|
var content = gatewayContentEl();
|
|
3084
3439
|
if (!content) return;
|
|
3440
|
+
var section = detail.querySelector('#gatewayToolsSection');
|
|
3441
|
+
if (section) section.style.display = sources.length ? '' : 'none';
|
|
3085
3442
|
content.innerHTML = renderGatewayHtml(sources);
|
|
3086
3443
|
wireGatewayHandlers(name);
|
|
3087
3444
|
gatewayLoadedFor = name;
|
|
@@ -3110,10 +3467,18 @@ ${consoleShellScript}
|
|
|
3110
3467
|
|
|
3111
3468
|
// ---- Tab switching + agent selection ----
|
|
3112
3469
|
|
|
3113
|
-
var CONFIG_TABS = ['overview', '
|
|
3470
|
+
var CONFIG_TABS = ['overview', 'abilities', 'actauth', 'messaging', 'schedules', 'env', 'advanced'];
|
|
3471
|
+
// The old Tools and Skills tabs: now parts of Advanced. A link to one
|
|
3472
|
+
// opens Advanced at that part.
|
|
3473
|
+
var TAB_ALIASES = { tools: 'advanced', skills: 'advanced' };
|
|
3474
|
+
|
|
3475
|
+
function resolveTab(tab) {
|
|
3476
|
+
tab = TAB_ALIASES[tab] || tab;
|
|
3477
|
+
return CONFIG_TABS.indexOf(tab) === -1 ? 'overview' : tab;
|
|
3478
|
+
}
|
|
3114
3479
|
|
|
3115
|
-
function switchTab(
|
|
3116
|
-
|
|
3480
|
+
function switchTab(requested) {
|
|
3481
|
+
var tab = resolveTab(requested);
|
|
3117
3482
|
currentTab = tab;
|
|
3118
3483
|
leShell.setTab(tab);
|
|
3119
3484
|
// Same URL a fresh load of this tab would have, so reload, back/forward
|
|
@@ -3125,10 +3490,15 @@ ${consoleShellScript}
|
|
|
3125
3490
|
for (var i = 0; i < panels.length; i++) {
|
|
3126
3491
|
panels[i].style.display = panels[i].dataset.tabPanel === tab ? 'block' : 'none';
|
|
3127
3492
|
}
|
|
3128
|
-
if (tab === '
|
|
3493
|
+
if (tab === 'advanced' && gatewayLoadedFor !== currentName) {
|
|
3129
3494
|
loadGatewayTab(currentName);
|
|
3130
3495
|
}
|
|
3131
|
-
|
|
3496
|
+
// Opened through an old Tools or Skills link: scroll to that part.
|
|
3497
|
+
if (TAB_ALIASES[requested]) {
|
|
3498
|
+
var part = detail.querySelector('#advanced-' + requested);
|
|
3499
|
+
if (part) part.scrollIntoView({ block: 'start' });
|
|
3500
|
+
}
|
|
3501
|
+
if ((tab === 'actauth' || tab === 'advanced') && actauthLoadedFor !== currentName) {
|
|
3132
3502
|
loadActauthTab(currentName);
|
|
3133
3503
|
}
|
|
3134
3504
|
if (tab === 'messaging') loadApprovalsSection(currentName);
|
|
@@ -3233,8 +3603,18 @@ ${consoleShellScript}
|
|
|
3233
3603
|
}) +
|
|
3234
3604
|
'<div class="le-scroll"><div class="le-page" style="display:block">' +
|
|
3235
3605
|
'<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
|
|
3236
|
-
|
|
3237
|
-
|
|
3606
|
+
// Advanced: the agent's own tools (and Composio, subagents), then its
|
|
3607
|
+
// own skills. Each part keeps its old data-tab-panel name, which the
|
|
3608
|
+
// code that redraws it looks for; only .tab-panel is shown or hidden.
|
|
3609
|
+
'<div class="tab-panel" data-tab-panel="advanced">' +
|
|
3610
|
+
'<p class="muted" style="margin:0 0 20px">For building your own: tools and skills that don't come from an ability, Composio tools, subagents and permission rules in full. What each ability adds is in its Details on the Abilities tab.</p>' +
|
|
3611
|
+
'<h2 class="advanced-part" id="advanced-tools">Tools</h2>' +
|
|
3612
|
+
'<div data-tab-panel="tools">' + renderToolsTabHtml(cfg) + '</div>' +
|
|
3613
|
+
'<h2 class="advanced-part" id="advanced-skills">Skills</h2>' +
|
|
3614
|
+
'<div data-tab-panel="skills">' + renderSkillsTabHtml(cfg) + '</div>' +
|
|
3615
|
+
'<h2 class="advanced-part" id="advanced-rules">Permission rules</h2>' +
|
|
3616
|
+
'<div id="advancedRulesContent"><p class="hint">Loading…</p></div>' +
|
|
3617
|
+
'</div>' +
|
|
3238
3618
|
'<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>' +
|
|
3239
3619
|
'<div class="tab-panel" data-tab-panel="messaging">' + renderMessagingTabPlaceholder() + '</div>' +
|
|
3240
3620
|
'<div class="tab-panel" data-tab-panel="env">' + renderEnvTabPlaceholder() + '</div>' +
|
|
@@ -3252,8 +3632,10 @@ ${consoleShellScript}
|
|
|
3252
3632
|
|
|
3253
3633
|
empty.style.display = 'none';
|
|
3254
3634
|
detail.style.display = 'flex';
|
|
3255
|
-
switchTab(currentTab);
|
|
3635
|
+
switchTab(initialAdvancedPart || currentTab);
|
|
3636
|
+
initialAdvancedPart = null;
|
|
3256
3637
|
fillOverviewCards(cfg.name);
|
|
3638
|
+
loadWorkflowsSection(cfg.name);
|
|
3257
3639
|
}
|
|
3258
3640
|
|
|
3259
3641
|
function selectAgent(name) {
|
|
@@ -3297,7 +3679,9 @@ ${consoleShellScript}
|
|
|
3297
3679
|
});
|
|
3298
3680
|
|
|
3299
3681
|
var initialParams = new URLSearchParams(location.search);
|
|
3300
|
-
currentTab =
|
|
3682
|
+
currentTab = resolveTab(initialParams.get('tab'));
|
|
3683
|
+
// An old ?tab=tools or ?tab=skills link: scrolled to that part once loaded.
|
|
3684
|
+
var initialAdvancedPart = TAB_ALIASES[initialParams.get('tab')] ? initialParams.get('tab') : null;
|
|
3301
3685
|
leShell.ready.then(function (agents) {
|
|
3302
3686
|
if (!agents.length) {
|
|
3303
3687
|
empty.innerHTML = leShell.loadError()
|