loopengine 0.1.93 → 0.1.95
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 +6 -1
- package/dist/core/scheduler.d.ts +17 -1
- package/dist/core/scheduler.js +100 -19
- package/dist/core/system-agents/templates.d.ts +3 -3
- package/dist/core/system-agents/templates.js +6 -14
- package/dist/core/system-tools/schedule.js +16 -6
- package/dist/core/time-zone.d.ts +40 -0
- package/dist/core/time-zone.js +138 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/web/agents-config-page.js +37 -13
- package/dist/web/console-shell.js +4 -4
- package/dist/web/dev-ui-styles.js +1 -1
- package/dist/web/global-config-page.js +211 -77
- package/dist/web/global-config.d.ts +24 -0
- package/dist/web/global-config.js +30 -0
- package/dist/web/templates-admin.d.ts +1 -0
- package/dist/web/templates-admin.js +3 -2
- package/dist/web/templates-page.js +1 -1
- package/package.json +3 -1
|
@@ -533,7 +533,7 @@ ${consoleShellScript}
|
|
|
533
533
|
// has no spec to repopulate an edit form from, so it just gets a blank
|
|
534
534
|
// cell — not a button that would 404 the moment it's clicked.
|
|
535
535
|
function renderLocalTools(tools) {
|
|
536
|
-
if (!tools.length) return '<p class="muted">No tools yet.
|
|
536
|
+
if (!tools.length) return '<p class="muted">No tools yet. Install an ability, or add one below.</p>';
|
|
537
537
|
var rows = tools.map(function (t) {
|
|
538
538
|
var actions = t.httpToolEditable
|
|
539
539
|
? '<button type="button" class="edit-http-tool-btn" data-name="' + escapeHtml(t.name) + '">Edit</button>'
|
|
@@ -1484,10 +1484,22 @@ ${consoleShellScript}
|
|
|
1484
1484
|
scheduled: 'scheduled', running: 'running', awaiting_approval: 'waiting for approval', paused: 'paused', done: 'done', cancelled: 'cancelled'
|
|
1485
1485
|
};
|
|
1486
1486
|
|
|
1487
|
+
// The workspace's time zone, from GET /agents/:name/schedules (an
|
|
1488
|
+
// older project adapter doesn't send it: then the browser's own).
|
|
1489
|
+
var schedulesTimeZone = null;
|
|
1490
|
+
|
|
1491
|
+
function fmtScheduleTime(iso) {
|
|
1492
|
+
var d = new Date(iso);
|
|
1493
|
+
if (schedulesTimeZone) {
|
|
1494
|
+
try { return d.toLocaleString(undefined, { timeZone: schedulesTimeZone, timeZoneName: 'short' }); } catch (e) {}
|
|
1495
|
+
}
|
|
1496
|
+
return d.toLocaleString();
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1487
1499
|
function scheduleText(s) {
|
|
1488
1500
|
// A repeating task's anchor is its first run, so it says when the
|
|
1489
1501
|
// later ones fall (e.g. every 1d from 9:00).
|
|
1490
|
-
return s.run_at ? 'Once, ' +
|
|
1502
|
+
return s.run_at ? 'Once, ' + escapeHtml(fmtScheduleTime(s.run_at)) : 'Every ' + escapeHtml(s.every) + (s.anchor ? '<div class="hint">from ' + escapeHtml(fmtScheduleTime(s.anchor)) + '</div>' : '');
|
|
1491
1503
|
}
|
|
1492
1504
|
|
|
1493
1505
|
function renderScheduleRow(t) {
|
|
@@ -1499,17 +1511,17 @@ ${consoleShellScript}
|
|
|
1499
1511
|
if (t.status === 'cancelled' || t.status === 'done') actions.push(['delete', 'Delete']);
|
|
1500
1512
|
var last = t.last_run_at
|
|
1501
1513
|
? '<span class="le-pill ' + (t.last_status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(t.last_status) + '</span> ' +
|
|
1502
|
-
'<span class="hint">' + escapeHtml(
|
|
1514
|
+
'<span class="hint">' + escapeHtml(fmtScheduleTime(t.last_run_at)) + '</span>' +
|
|
1503
1515
|
'<div class="schedule-result">' + escapeHtml(t.last_status === 'ok' ? (t.last_result || '') : (t.last_error || '')) + '</div>'
|
|
1504
1516
|
: '<span class="hint">not run yet</span>';
|
|
1505
1517
|
var history = (t.history || []).slice().reverse().map(function (h) {
|
|
1506
|
-
return '<li><span class="le-pill ' + (h.status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(h.status) + '</span> <span class="hint">' + escapeHtml(
|
|
1518
|
+
return '<li><span class="le-pill ' + (h.status === 'ok' ? 'good' : 'bad') + '">' + escapeHtml(h.status) + '</span> <span class="hint">' + escapeHtml(fmtScheduleTime(h.ran_at)) + '</span>' +
|
|
1507
1519
|
'<div class="schedule-result">' + escapeHtml(h.status === 'ok' ? (h.result || '') : (h.error || '')) + '</div></li>';
|
|
1508
1520
|
}).join('');
|
|
1509
1521
|
return '<tr>' +
|
|
1510
1522
|
'<td style="min-width:180px"><strong>' + escapeHtml(t.task_name || 'Untitled') + '</strong>' + (t.notify ? ' <span class="le-pill accent" title="Each result goes through this agent's notifier">notifies</span>' : '') +
|
|
1511
1523
|
'<div class="schedule-result hint">' + escapeHtml(t.message) + '</div></td>' +
|
|
1512
|
-
'<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(
|
|
1524
|
+
'<td>' + scheduleText(t.schedule) + (t.next_run_at ? '<div class="hint">next ' + escapeHtml(fmtScheduleTime(t.next_run_at)) + '</div>' : '') + '</td>' +
|
|
1513
1525
|
'<td><span class="le-pill ' + (SCHEDULE_PILLS[t.status] || 'neutral') + '">' + escapeHtml(SCHEDULE_LABELS[t.status] || t.status) + '</span>' +
|
|
1514
1526
|
(t.status === 'awaiting_approval' ? '<div class="hint"><a href="#" class="schedule-to-approvals">Decide in Messaging</a></div>' : '') + '</td>' +
|
|
1515
1527
|
'<td style="min-width:200px">' + last + (history ? '<details><summary class="hint">History (' + (t.history || []).length + ')</summary><ul class="schedule-history">' + history + '</ul></details>' : '') + '</td>' +
|
|
@@ -1533,7 +1545,8 @@ ${consoleShellScript}
|
|
|
1533
1545
|
'<input type="number" name="everyN" value="1" min="1" aria-label="How many">' +
|
|
1534
1546
|
'<select name="everyUnit" aria-label="Unit"><option value="m">minutes</option><option value="h" selected>hours</option><option value="d">days</option><option value="w">weeks</option></select>' +
|
|
1535
1547
|
'<label for="everyStart">starting</label><input type="datetime-local" name="everyStart" id="everyStart" aria-label="First run"></div>' +
|
|
1536
|
-
'<p class="hint" style="margin:0 0 4px 22px">Optional. The first run, which sets the time of day (and the weekday, for weeks) every later run keeps, e.g. every 1 day starting tomorrow 09:00. Without it, the first run is one interval from now
|
|
1548
|
+
'<p class="hint" style="margin:0 0 4px 22px">Optional. The first run, which sets the time of day (and the weekday, for weeks) every later run keeps, e.g. every 1 day starting tomorrow 09:00. Without it, the first run is one interval from now.' +
|
|
1549
|
+
(schedulesTimeZone ? ' Times are in ' + escapeHtml(schedulesTimeZone.replace(/_/g, ' ')) + ' (<a href="/config?section=settings">change</a>).' : '') + '</p>' +
|
|
1537
1550
|
'<div class="schedule-when"><input type="radio" name="when" value="once" id="whenOnce"><label for="whenOnce">Once at</label>' +
|
|
1538
1551
|
'<input type="datetime-local" name="runAt" aria-label="Date and time"></div>' +
|
|
1539
1552
|
'</div>' +
|
|
@@ -1544,6 +1557,13 @@ ${consoleShellScript}
|
|
|
1544
1557
|
'</form></section>';
|
|
1545
1558
|
}
|
|
1546
1559
|
|
|
1560
|
+
// A date field's value ("2026-11-02T09:00") goes as it is, for the
|
|
1561
|
+
// server to read in the workspace's time zone; with no workspace time
|
|
1562
|
+
// zone known, it's the browser's.
|
|
1563
|
+
function scheduleFormTime(value) {
|
|
1564
|
+
return schedulesTimeZone ? value : new Date(value).toISOString();
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1547
1567
|
function wireSchedulesHandlers(name) {
|
|
1548
1568
|
var content = schedulesContentEl();
|
|
1549
1569
|
if (!content) return;
|
|
@@ -1576,14 +1596,13 @@ ${consoleShellScript}
|
|
|
1576
1596
|
var schedule;
|
|
1577
1597
|
if (data.get('when') === 'once') {
|
|
1578
1598
|
if (!data.get('runAt')) { result.textContent = 'Pick a date and time.'; return; }
|
|
1579
|
-
schedule = { run_at:
|
|
1599
|
+
schedule = { run_at: scheduleFormTime(data.get('runAt')) };
|
|
1580
1600
|
} else {
|
|
1581
|
-
// Weeks go to the scheduler as days
|
|
1582
|
-
// browser's own time zone.
|
|
1601
|
+
// Weeks go to the scheduler as days.
|
|
1583
1602
|
var unit = data.get('everyUnit');
|
|
1584
1603
|
var n = Number(data.get('everyN')) || 1;
|
|
1585
1604
|
schedule = { every: unit === 'w' ? String(n * 7) + 'd' : String(n) + unit };
|
|
1586
|
-
if (data.get('everyStart')) schedule.start_at =
|
|
1605
|
+
if (data.get('everyStart')) schedule.start_at = scheduleFormTime(data.get('everyStart'));
|
|
1587
1606
|
}
|
|
1588
1607
|
result.textContent = 'Scheduling' + String.fromCharCode(8230);
|
|
1589
1608
|
fetchJsonRetryOnce('/agents/' + encodeURIComponent(name) + '/schedules', {
|
|
@@ -1604,6 +1623,7 @@ ${consoleShellScript}
|
|
|
1604
1623
|
if (currentName !== name) return;
|
|
1605
1624
|
if (!r.ok) throw new Error(r.body.error || 'request failed');
|
|
1606
1625
|
var tasks = r.body.tasks || [];
|
|
1626
|
+
schedulesTimeZone = r.body.time_zone || null;
|
|
1607
1627
|
content.innerHTML = renderSchedulesHtml(tasks);
|
|
1608
1628
|
wireSchedulesHandlers(name);
|
|
1609
1629
|
leShell.setCount('schedules', tasks.filter(function (t) { return t.status !== 'cancelled' && t.status !== 'done'; }).length);
|
|
@@ -2034,7 +2054,7 @@ ${consoleShellScript}
|
|
|
2034
2054
|
'<input type="text" name="q" placeholder="Search by name or keyword (optional)">' +
|
|
2035
2055
|
'<button type="submit">Search</button>' +
|
|
2036
2056
|
'</form>' +
|
|
2037
|
-
'<div id="abilitySearchResults"></div>' +
|
|
2057
|
+
'<div id="abilitySearchResults" style="margin-bottom:32px"></div>' +
|
|
2038
2058
|
'<h3 style="margin-top:24px">Install from a spec</h3>' +
|
|
2039
2059
|
'<p class="hint">A git URL, a local <code>file:../path</code>, or a private npm package name/version — for anything not published publicly.</p>' +
|
|
2040
2060
|
'<form id="abilityInstallForm" class="add-source">' +
|
|
@@ -2517,8 +2537,10 @@ ${consoleShellScript}
|
|
|
2517
2537
|
'<section><h3>Your tools (' + cfg.localTools.length + ')</h3>' + renderLocalTools(cfg.localTools) +
|
|
2518
2538
|
'<div class="admin-subsection"><h4 id="httpToolFormHeading">Create an HTTP tool</h4>' + renderHttpToolFormHtml() + '</div>' +
|
|
2519
2539
|
'</section>' +
|
|
2520
|
-
|
|
2521
|
-
|
|
2540
|
+
// Shown only for an agent that already has Composio tools
|
|
2541
|
+
// (gateway-tools.yml): see applyGatewaySources.
|
|
2542
|
+
'<section id="gatewayToolsSection" style="display:none">' +
|
|
2543
|
+
'<h3>From Composio <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
|
|
2522
2544
|
'<div id="gatewayToolsContent"><p class="hint">Loading…</p></div>' +
|
|
2523
2545
|
'</section>' +
|
|
2524
2546
|
'<section><h3>Subagents (' + cfg.agentAsTools.length + ')</h3>' + renderTools(cfg.agentAsTools) + '</section>';
|
|
@@ -3062,6 +3084,8 @@ ${consoleShellScript}
|
|
|
3062
3084
|
function applyGatewaySources(name, sources) {
|
|
3063
3085
|
var content = gatewayContentEl();
|
|
3064
3086
|
if (!content) return;
|
|
3087
|
+
var section = detail.querySelector('#gatewayToolsSection');
|
|
3088
|
+
if (section) section.style.display = sources.length ? '' : 'none';
|
|
3065
3089
|
content.innerHTML = renderGatewayHtml(sources);
|
|
3066
3090
|
wireGatewayHandlers(name);
|
|
3067
3091
|
gatewayLoadedFor = name;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The app shell every console page renders inside: a sidebar listing the
|
|
2
|
-
// agents (plus Models and
|
|
2
|
+
// agents (plus Models, Abilities and Settings), and, for a page about one agent,
|
|
3
3
|
// a header with its name, prompt and tabs. The tabs span two pages —
|
|
4
4
|
// Chat is playground.ts, every other tab is agents-config-page.ts — so
|
|
5
5
|
// both render the header through this one script and read as one app.
|
|
@@ -22,11 +22,11 @@ export const consoleSidebarHtml = `<aside class="le-sidebar" aria-label="Console
|
|
|
22
22
|
<a class="le-brand" href="/agents"><span class="le-brand-mark">${BRAND_MARK}</span><span class="le-brand-name">LoopEngine</span></a>
|
|
23
23
|
<div class="le-nav-label"><span>Agents</span><a href="/agents?new=1">+ New</a></div>
|
|
24
24
|
<ul class="le-agent-list" id="leAgentList"></ul>
|
|
25
|
-
<a class="le-find" href="/templates" data-le-nav="templates">Start from a template</a>
|
|
26
25
|
<a class="le-find" href="/interview" data-le-nav="interview">Find your first agent</a>
|
|
26
|
+
<a class="le-find" href="/templates" data-le-nav="templates">Start from a template</a>
|
|
27
27
|
<nav class="le-foot" aria-label="Settings">
|
|
28
28
|
<a href="/config?section=models" data-le-nav="models">Models</a>
|
|
29
|
-
<a href="/config?section=
|
|
29
|
+
<a href="/config?section=abilities" data-le-nav="abilities">Abilities</a>
|
|
30
30
|
<a href="/config?section=settings" data-le-nav="settings">Settings</a>
|
|
31
31
|
<div class="le-who" id="leWho" hidden></div>
|
|
32
32
|
</nav>
|
|
@@ -161,7 +161,7 @@ export const consoleShellScript = `<script>
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
var section = params.get('section');
|
|
164
|
-
var foot = page === 'config' ? (section === 'gateways' ? '
|
|
164
|
+
var foot = page === 'config' ? (section === 'abilities' || section === 'gateways' ? 'abilities' : section === 'settings' ? 'settings' : 'models') : page === 'interview' || page === 'templates' ? page : '';
|
|
165
165
|
var footLinks = document.querySelectorAll('[data-le-nav]');
|
|
166
166
|
for (var i = 0; i < footLinks.length; i++) {
|
|
167
167
|
if (footLinks[i].getAttribute('data-le-nav') === foot) footLinks[i].setAttribute('aria-current', 'page');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Shared look for every browser page adapters/http.ts serves — the agent
|
|
2
2
|
// console (agents-config-page.ts), its Chat tab (playground.ts), the
|
|
3
|
-
// agents home (agents-list-page.ts) and the Models/
|
|
3
|
+
// agents home (agents-list-page.ts) and the Models/Abilities pages
|
|
4
4
|
// (global-config-page.ts). All four render inside the same app shell
|
|
5
5
|
// (web/console-shell.ts), so the shell's own classes (.le-*) live here
|
|
6
6
|
// too, alongside the shared base: color tokens, type, form controls,
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// Overview/Skills/Tools/ActAuth tabs (served separately at
|
|
5
5
|
// /agents/config, deliberately left untouched by this page). A left
|
|
6
6
|
// sidebar picks between Models (which providers this deployment can
|
|
7
|
-
// call, and which agents use which)
|
|
8
|
-
//
|
|
9
|
-
// that data
|
|
7
|
+
// call, and which agents use which), Abilities (what each agent has
|
|
8
|
+
// installed, and adding more) and Settings — see global-config.ts for
|
|
9
|
+
// where most of that data comes from. "Agents" is a plain link out to
|
|
10
10
|
// /agents/config, not a third client-rendered section here: that page
|
|
11
11
|
// already owns the full per-agent picture, duplicating or embedding it
|
|
12
12
|
// would just be two places for the same UI to drift apart.
|
|
@@ -78,10 +78,10 @@ ${consoleSidebarHtml}
|
|
|
78
78
|
<div><h1>Models</h1><p>API keys for each model provider, and which agents use each one. A saved key is stored on this server and never shown again.</p></div></div></div>
|
|
79
79
|
<div class="le-scroll"><div class="le-page" style="display:block"><div id="modelsContent"><p class="hint">Loading…</p></div></div></div>
|
|
80
80
|
</div>
|
|
81
|
-
<div class="section-panel" data-section-panel="
|
|
81
|
+
<div class="section-panel" data-section-panel="abilities" style="display:none">
|
|
82
82
|
<div class="le-head plain"><div class="le-title"><button type="button" class="le-menu-btn" data-le-menu aria-label="Open navigation">☰</button>
|
|
83
|
-
<div><h1>
|
|
84
|
-
<div class="le-scroll"><div class="le-page" style="display:block"><div id="
|
|
83
|
+
<div><h1>Abilities</h1><p>How agents reach your apps, such as Gmail, Shopify or GitHub. An ability installed into an agent adds its tools, skills and permission rules.</p></div></div></div>
|
|
84
|
+
<div class="le-scroll"><div class="le-page" style="display:block"><div id="abilitiesContent"><p class="hint">Loading…</p></div></div></div>
|
|
85
85
|
</div>
|
|
86
86
|
<div class="section-panel" data-section-panel="settings" style="display:none">
|
|
87
87
|
<div class="le-head plain"><div class="le-title"><button type="button" class="le-menu-btn" data-le-menu aria-label="Open navigation">☰</button>
|
|
@@ -202,83 +202,150 @@ ${consoleShellScript}
|
|
|
202
202
|
});
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
// ----
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
205
|
+
// ---- Abilities section ----
|
|
206
|
+
// How agents reach outside apps: an ability (lp-gmail, lp-shopify...)
|
|
207
|
+
// installed into an agent brings its tools, skills and permission
|
|
208
|
+
// rules. Abilities live in each agent, so this page gathers them from
|
|
209
|
+
// every agent's own Abilities routes and installs through the same
|
|
210
|
+
// route, which also keeps it working with any project template.
|
|
211
|
+
|
|
212
|
+
var abilityAgents = [];
|
|
213
|
+
var abilitiesByAgent = {};
|
|
214
|
+
|
|
215
|
+
function agentAbilitiesHref(agent) {
|
|
216
|
+
return '/agents/config?agent=' + encodeURIComponent(agent) + '&tab=abilities';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderInstalledAbilities() {
|
|
220
|
+
var byName = {};
|
|
221
|
+
abilityAgents.forEach(function (agent) {
|
|
222
|
+
(abilitiesByAgent[agent] || []).forEach(function (a) {
|
|
223
|
+
if (!byName[a.name]) byName[a.name] = { name: a.name, versions: [], agents: [] };
|
|
224
|
+
if (byName[a.name].versions.indexOf(a.version) === -1) byName[a.name].versions.push(a.version);
|
|
225
|
+
byName[a.name].agents.push(agent);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
var names = Object.keys(byName).sort();
|
|
229
|
+
if (!names.length) return '<p class="hint">No agent has an ability yet. Add one below, or start from a template, which installs the ones it needs.</p>';
|
|
230
|
+
return '<table><thead><tr><th>Ability</th><th>Version</th><th>Agents</th></tr></thead><tbody>' +
|
|
231
|
+
names.map(function (n) {
|
|
232
|
+
var a = byName[n];
|
|
233
|
+
return '<tr><td><code>' + escapeHtml(a.name) + '</code></td>' +
|
|
234
|
+
'<td>' + escapeHtml(a.versions.join(', ')) + '</td>' +
|
|
235
|
+
'<td>' + a.agents.map(function (agent) { return '<a href="' + agentAbilitiesHref(agent) + '">' + escapeHtml(agent) + '</a>'; }).join(', ') + '</td></tr>';
|
|
236
|
+
}).join('') + '</tbody></table>';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function renderAbilities() {
|
|
240
|
+
var content = document.getElementById('abilitiesContent');
|
|
241
|
+
var add = abilityAgents.length
|
|
242
|
+
? '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px">' +
|
|
243
|
+
'<label for="abilityAgent">Install into</label>' +
|
|
244
|
+
'<select id="abilityAgent">' + abilityAgents.map(function (a) { return '<option value="' + escapeHtml(a) + '">' + escapeHtml(a) + '</option>'; }).join('') + '</select>' +
|
|
245
|
+
'<form id="abilitySearchForm" style="display:flex;gap:6px;flex:1;min-width:220px;margin:0">' +
|
|
246
|
+
'<input type="search" name="q" placeholder="Search abilities, e.g. gmail" aria-label="Search abilities" style="flex:1">' +
|
|
247
|
+
'<button type="submit">Search</button>' +
|
|
248
|
+
'</form>' +
|
|
249
|
+
'</div>' +
|
|
250
|
+
'<p id="abilityInstallStatus" class="hint"></p>' +
|
|
251
|
+
'<div id="abilitySearchResults"><p class="hint">Loading…</p></div>'
|
|
252
|
+
: '<p>Create an agent first: abilities are installed into one. <a href="/templates">Start from a template</a> or <a href="/agents?new=1">create an agent</a>.</p>';
|
|
253
|
+
content.innerHTML =
|
|
254
|
+
'<section><h3>Installed</h3><div id="abilitiesInstalled">' + renderInstalledAbilities() + '</div></section>' +
|
|
255
|
+
'<section><h3>Add an ability</h3>' + add + '</section>';
|
|
256
|
+
if (!abilityAgents.length) return;
|
|
257
|
+
var form = document.getElementById('abilitySearchForm');
|
|
258
|
+
form.addEventListener('submit', function (ev) { ev.preventDefault(); searchAbilities(form.q.value.trim()); });
|
|
259
|
+
document.getElementById('abilityAgent').addEventListener('change', function () { searchAbilities(form.q.value.trim()); });
|
|
260
|
+
searchAbilities('');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
var lastSearch = { q: null, results: [] };
|
|
264
|
+
|
|
265
|
+
function renderSearchResults() {
|
|
266
|
+
var resultsEl = document.getElementById('abilitySearchResults');
|
|
267
|
+
var agent = document.getElementById('abilityAgent').value;
|
|
268
|
+
var installed = (abilitiesByAgent[agent] || []).map(function (a) { return a.name; });
|
|
269
|
+
var results = lastSearch.results;
|
|
270
|
+
resultsEl.innerHTML = results.length
|
|
271
|
+
? '<table><thead><tr><th>Ability</th><th>What it does</th><th></th></tr></thead><tbody>' + results.map(function (pkg) {
|
|
272
|
+
var action = installed.indexOf(pkg.name) !== -1
|
|
273
|
+
? '<span class="hint">In ' + escapeHtml(agent) + '</span>'
|
|
274
|
+
: '<button type="button" class="ability-install-btn" data-spec="' + escapeHtml(pkg.name) + '">Install</button>';
|
|
275
|
+
return '<tr><td><code>' + escapeHtml(pkg.name) + '</code> <span class="hint">' + escapeHtml(pkg.version) + '</span></td>' +
|
|
276
|
+
'<td>' + escapeHtml(pkg.description || '') + '</td><td>' + action + '</td></tr>';
|
|
277
|
+
}).join('') + '</tbody></table>'
|
|
278
|
+
: '<p class="hint">No published abilities match.</p>';
|
|
279
|
+
var buttons = resultsEl.querySelectorAll('.ability-install-btn');
|
|
280
|
+
for (var i = 0; i < buttons.length; i++) {
|
|
281
|
+
buttons[i].addEventListener('click', function (ev) { installAbility(agent, ev.currentTarget.dataset.spec, ev.currentTarget); });
|
|
228
282
|
}
|
|
229
|
-
return '<div class="source' + (g.supported ? '' : ' unsupported') + '">' +
|
|
230
|
-
'<div class="source-head">' +
|
|
231
|
-
'<h4>' + escapeHtml(g.provider) + '</h4>' +
|
|
232
|
-
statusHtml +
|
|
233
|
-
'</div>' +
|
|
234
|
-
bodyHtml +
|
|
235
|
-
'</div>';
|
|
236
283
|
}
|
|
237
284
|
|
|
238
|
-
function
|
|
239
|
-
var
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
285
|
+
function searchAbilities(q) {
|
|
286
|
+
var resultsEl = document.getElementById('abilitySearchResults');
|
|
287
|
+
if (lastSearch.q === q) { renderSearchResults(); return; }
|
|
288
|
+
resultsEl.innerHTML = '<p class="hint">Loading…</p>';
|
|
289
|
+
// Search doesn't depend on the agent; the route just lives under one.
|
|
290
|
+
fetch('/agents/' + encodeURIComponent(abilityAgents[0]) + '/abilities/search' + (q ? '?q=' + encodeURIComponent(q) : ''))
|
|
291
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
292
|
+
.then(function (r) {
|
|
293
|
+
if (!r.ok) throw new Error(r.body.error || 'search failed');
|
|
294
|
+
lastSearch = { q: q, results: r.body.results || [] };
|
|
295
|
+
renderSearchResults();
|
|
296
|
+
})
|
|
297
|
+
.catch(function (err) { resultsEl.innerHTML = '<p class="error">Could not search abilities: ' + escapeHtml(err.message) + '</p>'; });
|
|
243
298
|
}
|
|
244
299
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (!
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
300
|
+
function installAbility(agent, spec, btn) {
|
|
301
|
+
var status = document.getElementById('abilityInstallStatus');
|
|
302
|
+
btn.disabled = true;
|
|
303
|
+
status.className = 'hint';
|
|
304
|
+
status.textContent = 'Installing ' + spec + ' into ' + agent + String.fromCharCode(8230);
|
|
305
|
+
fetch('/agents/' + encodeURIComponent(agent) + '/abilities', {
|
|
306
|
+
method: 'POST',
|
|
307
|
+
headers: { 'content-type': 'application/json' },
|
|
308
|
+
body: JSON.stringify({ spec: spec }),
|
|
309
|
+
})
|
|
310
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
311
|
+
.then(function (r) {
|
|
312
|
+
if (!r.ok) throw new Error(r.body.error || 'request failed');
|
|
313
|
+
var deps = r.body.dependencies || [];
|
|
314
|
+
status.innerHTML = 'Installed ' + escapeHtml(spec) + ' into ' + escapeHtml(agent) + '. ' +
|
|
315
|
+
'<a href="' + agentAbilitiesHref(agent) + '">Add its keys in ' + escapeHtml(agent) + String.fromCharCode(8217) + 's Abilities tab</a>.' +
|
|
316
|
+
(deps.length ? ' <strong>It also needs <code>' + escapeHtml(deps.join(', ')) + '</code>:</strong> use Install deps there.' : '');
|
|
317
|
+
return loadAgentAbilities(agent).then(function () {
|
|
318
|
+
document.getElementById('abilitiesInstalled').innerHTML = renderInstalledAbilities();
|
|
319
|
+
renderSearchResults();
|
|
320
|
+
});
|
|
321
|
+
})
|
|
322
|
+
.catch(function (err) {
|
|
323
|
+
btn.disabled = false;
|
|
324
|
+
status.className = 'error';
|
|
325
|
+
status.textContent = 'Could not install: ' + err.message;
|
|
269
326
|
});
|
|
270
|
-
}
|
|
271
327
|
}
|
|
272
328
|
|
|
273
|
-
function
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
.then(function (
|
|
277
|
-
.
|
|
278
|
-
|
|
329
|
+
function loadAgentAbilities(agent) {
|
|
330
|
+
return fetch('/agents/' + encodeURIComponent(agent) + '/abilities')
|
|
331
|
+
.then(function (r) { return r.ok ? r.json() : { abilities: [] }; })
|
|
332
|
+
.then(function (d) { abilitiesByAgent[agent] = d.abilities || []; })
|
|
333
|
+
.catch(function () { abilitiesByAgent[agent] = []; });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function loadAbilities() {
|
|
337
|
+
var content = document.getElementById('abilitiesContent');
|
|
338
|
+
leShell.ready
|
|
339
|
+
.then(function (agents) {
|
|
340
|
+
abilityAgents = agents.map(function (a) { return a.name; });
|
|
341
|
+
return Promise.all(abilityAgents.map(loadAgentAbilities));
|
|
342
|
+
})
|
|
343
|
+
.then(function () {
|
|
344
|
+
renderAbilities();
|
|
345
|
+
loadedSections.abilities = true;
|
|
279
346
|
})
|
|
280
347
|
.catch(function (err) {
|
|
281
|
-
content.innerHTML = '<p class="error">Could not load
|
|
348
|
+
content.innerHTML = '<p class="error">Could not load abilities: ' + escapeHtml(err.message) + '</p>';
|
|
282
349
|
});
|
|
283
350
|
}
|
|
284
351
|
|
|
@@ -298,14 +365,78 @@ ${consoleShellScript}
|
|
|
298
365
|
['Environment name', '<code>' + escapeHtml(d.environment) + '</code> <span class="hint">(LOOPENGINE_ENV)</span>'],
|
|
299
366
|
['Public address', d.publicUrl ? '<code>' + escapeHtml(d.publicUrl) + '</code>' : '<span class="hint">Not set (LOOPENGINE_PUBLIC_URL); links in notifications use the address saved with each notifier</span>'],
|
|
300
367
|
['Scheduled tasks', d.scheduler === 'off' ? 'Off on this server (LOOPENGINE_SCHEDULER=off)' : 'Run by this server'],
|
|
368
|
+
['Time zone', d.timeZone ? '<code>' + escapeHtml(d.timeZone) + '</code>' : '<span class="hint">The server' + String.fromCharCode(8217) + 's own. Update the template below to choose one here.</span>'],
|
|
301
369
|
['Theme', 'Follows your system'],
|
|
302
370
|
];
|
|
303
371
|
return '<section><table><thead><tr><th>Setting</th><th>Value</th></tr></thead><tbody>' +
|
|
304
372
|
rows.map(function (r) { return '<tr><td>' + r[0] + '</td><td>' + r[1] + '</td></tr>'; }).join('') +
|
|
305
373
|
'</tbody></table></section>' +
|
|
374
|
+
(d.timeZone ? '<section><h3>Time zone</h3><div class="source" id="timeZoneBox"><p class="hint">Loading' + String.fromCharCode(8230) + '</p></div></section>' : '') +
|
|
306
375
|
'<section><h3>Updates</h3><div class="source" id="upgradeBox"><p class="hint">Checking for a newer LoopEngine' + String.fromCharCode(8230) + '</p></div></section>';
|
|
307
376
|
}
|
|
308
377
|
|
|
378
|
+
// ---- Time zone ----
|
|
379
|
+
|
|
380
|
+
function renderTimeZone(t) {
|
|
381
|
+
var box = document.getElementById('timeZoneBox');
|
|
382
|
+
if (!box) return;
|
|
383
|
+
var intro = '<p>Schedules and templates use this for times of day: “daily at 09:00” runs at 9:00 here, and daily and weekly schedules keep that time when the clocks change. ' +
|
|
384
|
+
(t.chosen ? '' : 'Not chosen yet, so it' + String.fromCharCode(8217) + 's the server' + String.fromCharCode(8217) + 's (<code>' + escapeHtml(t.server) + '</code>).') + '</p>';
|
|
385
|
+
if (!t.canSave) {
|
|
386
|
+
box.innerHTML = intro + '<p>In use: <code>' + escapeHtml(t.timeZone) + '</code></p><p class="hint">Set LOOPENGINE_ADMIN_AUTH to change it here, or put <code>LOOPENGINE_TIME_ZONE</code> in .env.</p>';
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
var browserZone = '';
|
|
390
|
+
try { browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
|
|
391
|
+
var options = t.zones.map(function (z) {
|
|
392
|
+
return '<option value="' + escapeHtml(z) + '"' + (z === t.timeZone ? ' selected' : '') + '>' + escapeHtml(z.replace(/_/g, ' ')) + '</option>';
|
|
393
|
+
}).join('');
|
|
394
|
+
box.innerHTML = intro +
|
|
395
|
+
'<form id="timeZoneForm" style="display:flex;flex-direction:column;gap:10px;max-width:480px">' +
|
|
396
|
+
'<label for="timeZoneSelect">Time zone</label>' +
|
|
397
|
+
'<select id="timeZoneSelect" name="timeZone">' + options + '</select>' +
|
|
398
|
+
(browserZone && browserZone !== t.timeZone && t.zones.indexOf(browserZone) >= 0
|
|
399
|
+
? '<p class="hint" style="margin:0"><a href="#" id="timeZoneUseBrowser">Use this browser' + String.fromCharCode(8217) + 's, ' + escapeHtml(browserZone) + '</a></p>' : '') +
|
|
400
|
+
'<label style="display:flex;gap:8px;align-items:center"><input type="checkbox" name="moveSchedules" checked> Move existing daily and weekly schedules too, keeping their time of day</label>' +
|
|
401
|
+
'<div><button type="submit" class="primary">Save</button></div>' +
|
|
402
|
+
'<div id="timeZoneMsg" class="hint"></div>' +
|
|
403
|
+
'</form>';
|
|
404
|
+
var form = document.getElementById('timeZoneForm');
|
|
405
|
+
var useBrowser = document.getElementById('timeZoneUseBrowser');
|
|
406
|
+
if (useBrowser) useBrowser.addEventListener('click', function (ev) { ev.preventDefault(); form.timeZone.value = browserZone; });
|
|
407
|
+
form.addEventListener('submit', function (ev) {
|
|
408
|
+
ev.preventDefault();
|
|
409
|
+
var msg = document.getElementById('timeZoneMsg');
|
|
410
|
+
var btn = form.querySelector('button[type="submit"]');
|
|
411
|
+
btn.disabled = true;
|
|
412
|
+
msg.textContent = 'Saving' + String.fromCharCode(8230);
|
|
413
|
+
fetch('/config/time-zone', {
|
|
414
|
+
method: 'PUT',
|
|
415
|
+
headers: { 'content-type': 'application/json' },
|
|
416
|
+
body: JSON.stringify({ timeZone: form.timeZone.value, moveSchedules: form.moveSchedules.checked }),
|
|
417
|
+
})
|
|
418
|
+
.then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
|
|
419
|
+
.then(function (r) {
|
|
420
|
+
btn.disabled = false;
|
|
421
|
+
if (!r.ok) { msg.innerHTML = '<span class="error">' + escapeHtml(r.body.error || 'Could not save') + '</span>'; return; }
|
|
422
|
+
msg.textContent = 'Saved: ' + r.body.timeZone.replace(/_/g, ' ') + '.' +
|
|
423
|
+
(r.body.moved ? ' Moved ' + r.body.moved + ' schedule' + (r.body.moved === 1 ? '' : 's') + '.' : '');
|
|
424
|
+
})
|
|
425
|
+
.catch(function (err) { btn.disabled = false; msg.innerHTML = '<span class="error">' + escapeHtml(err.message) + '</span>'; });
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function loadTimeZone() {
|
|
430
|
+
if (!document.getElementById('timeZoneBox')) return;
|
|
431
|
+
fetch('/config/time-zone', { cache: 'no-store' })
|
|
432
|
+
.then(function (r) { return r.json(); })
|
|
433
|
+
.then(renderTimeZone)
|
|
434
|
+
.catch(function (err) {
|
|
435
|
+
var box = document.getElementById('timeZoneBox');
|
|
436
|
+
if (box) box.innerHTML = '<p class="error">Could not load the time zone: ' + escapeHtml(err.message) + '</p>';
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
309
440
|
// ---- Upgrade ----
|
|
310
441
|
|
|
311
442
|
var UPGRADE_NOTE = 'Updates this server' + String.fromCharCode(8217) + 's LoopEngine code and restarts it, which takes a minute or two. ' +
|
|
@@ -436,6 +567,7 @@ ${consoleShellScript}
|
|
|
436
567
|
.then(function (d) {
|
|
437
568
|
content.innerHTML = renderSettings(d);
|
|
438
569
|
loadedSections.settings = true;
|
|
570
|
+
loadTimeZone();
|
|
439
571
|
loadUpgrade();
|
|
440
572
|
})
|
|
441
573
|
.catch(function (err) {
|
|
@@ -455,7 +587,7 @@ ${consoleShellScript}
|
|
|
455
587
|
panels[j].style.display = panels[j].dataset.sectionPanel === section ? 'contents' : 'none';
|
|
456
588
|
}
|
|
457
589
|
if (section === 'models' && !loadedSections.models) loadModels();
|
|
458
|
-
if (section === '
|
|
590
|
+
if (section === 'abilities' && !loadedSections.abilities) loadAbilities();
|
|
459
591
|
if (section === 'settings' && !loadedSections.settings) loadSettings();
|
|
460
592
|
}
|
|
461
593
|
|
|
@@ -463,12 +595,14 @@ ${consoleShellScript}
|
|
|
463
595
|
sectionButtons[i].addEventListener('click', function (ev) { switchSection(ev.currentTarget.dataset.section); });
|
|
464
596
|
}
|
|
465
597
|
|
|
466
|
-
// Deep-linked from
|
|
467
|
-
// links (/config?section=
|
|
598
|
+
// Deep-linked from the console sidebar's Models/Abilities/Settings
|
|
599
|
+
// links (/config?section=abilities, say) — preselect it if it names a
|
|
468
600
|
// real section, same "trust the query param only if it's valid"
|
|
469
601
|
// caution playground.ts's own ?agent= handling already uses.
|
|
470
602
|
var requestedSection = new URLSearchParams(location.search).get('section');
|
|
471
|
-
|
|
603
|
+
// ?section=gateways is the old Integrations page's link.
|
|
604
|
+
if (requestedSection === 'gateways') requestedSection = 'abilities';
|
|
605
|
+
var initialSection = requestedSection === 'abilities' || requestedSection === 'models' || requestedSection === 'settings' ? requestedSection : currentSection;
|
|
472
606
|
switchSection(initialSection);
|
|
473
607
|
})();
|
|
474
608
|
</script>
|
|
@@ -69,3 +69,27 @@ export declare function modelKeyEnvVar(provider: string): string | undefined;
|
|
|
69
69
|
*/
|
|
70
70
|
export declare function setModelProviderKey(provider: string, key: unknown): Promise<void>;
|
|
71
71
|
export declare function clearModelProviderKey(provider: string): Promise<void>;
|
|
72
|
+
export declare class TimeZoneError extends Error {
|
|
73
|
+
}
|
|
74
|
+
export interface TimeZoneView {
|
|
75
|
+
/** The one in use. */
|
|
76
|
+
timeZone: string;
|
|
77
|
+
/** Whether it was chosen (LOOPENGINE_TIME_ZONE), not the server's. */
|
|
78
|
+
chosen: boolean;
|
|
79
|
+
server: string;
|
|
80
|
+
zones: string[];
|
|
81
|
+
}
|
|
82
|
+
export declare function describeTimeZone(): TimeZoneView;
|
|
83
|
+
/**
|
|
84
|
+
* The console's Settings → Time zone: saved like a model key (the
|
|
85
|
+
* project's .env by default) and applied to this process at once. New
|
|
86
|
+
* schedules and template start times use it from then on; with
|
|
87
|
+
* `moveSchedules`, existing daily and weekly schedules move to it too,
|
|
88
|
+
* keeping their time of day. An empty value goes back to the server's.
|
|
89
|
+
*/
|
|
90
|
+
export declare function setWorkspaceTimeZone(value: unknown, options?: {
|
|
91
|
+
moveSchedules?: boolean;
|
|
92
|
+
}): Promise<{
|
|
93
|
+
timeZone: string;
|
|
94
|
+
moved: number;
|
|
95
|
+
}>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getComposioAuthStatus } from '../core/gateway-tools.js';
|
|
2
|
+
import { moveScheduledTasksToTimeZone } from '../core/scheduler.js';
|
|
3
|
+
import { TIME_ZONE_ENV, isValidTimeZone, listTimeZones, serverTimeZone, workspaceTimeZone } from '../core/time-zone.js';
|
|
2
4
|
import { setEnvVar, unsetEnvVar } from './env-admin.js';
|
|
3
5
|
export class ModelKeyError extends Error {
|
|
4
6
|
}
|
|
@@ -102,3 +104,31 @@ export async function clearModelProviderKey(provider) {
|
|
|
102
104
|
throw new ModelKeyError(`unknown provider '${provider}'`);
|
|
103
105
|
await unsetEnvVar(envVar);
|
|
104
106
|
}
|
|
107
|
+
export class TimeZoneError extends Error {
|
|
108
|
+
}
|
|
109
|
+
export function describeTimeZone() {
|
|
110
|
+
const timeZone = workspaceTimeZone();
|
|
111
|
+
const configured = process.env[TIME_ZONE_ENV]?.trim();
|
|
112
|
+
return { timeZone, chosen: Boolean(configured && isValidTimeZone(configured)), server: serverTimeZone(), zones: listTimeZones() };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The console's Settings → Time zone: saved like a model key (the
|
|
116
|
+
* project's .env by default) and applied to this process at once. New
|
|
117
|
+
* schedules and template start times use it from then on; with
|
|
118
|
+
* `moveSchedules`, existing daily and weekly schedules move to it too,
|
|
119
|
+
* keeping their time of day. An empty value goes back to the server's.
|
|
120
|
+
*/
|
|
121
|
+
export async function setWorkspaceTimeZone(value, options = {}) {
|
|
122
|
+
const requested = typeof value === 'string' ? value.trim() : '';
|
|
123
|
+
if (requested && !isValidTimeZone(requested))
|
|
124
|
+
throw new TimeZoneError(`"${requested}" isn't a time zone this server knows, such as America/New_York or Europe/London`);
|
|
125
|
+
const before = workspaceTimeZone();
|
|
126
|
+
// Saved by its canonical name ("america/new_york" → "America/New_York").
|
|
127
|
+
if (requested)
|
|
128
|
+
await setEnvVar(TIME_ZONE_ENV, new Intl.DateTimeFormat('en-US', { timeZone: requested }).resolvedOptions().timeZone);
|
|
129
|
+
else
|
|
130
|
+
await unsetEnvVar(TIME_ZONE_ENV);
|
|
131
|
+
const timeZone = workspaceTimeZone();
|
|
132
|
+
const moved = options.moveSchedules && timeZone !== before ? await moveScheduledTasksToTimeZone(before, timeZone) : 0;
|
|
133
|
+
return { timeZone, moved };
|
|
134
|
+
}
|