loopengine 0.1.2 → 0.1.4
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/dist/core/agent-config.d.ts +9 -5
- package/dist/core/durable-approvals.d.ts +106 -0
- package/dist/core/durable-approvals.js +158 -0
- package/dist/core/gateway-tools.js +10 -9
- package/dist/core/run-agent.js +20 -3
- package/dist/index.d.ts +24 -1
- package/dist/index.js +26 -1
- package/dist/web/actauth-admin.d.ts +36 -0
- package/dist/web/actauth-admin.js +108 -0
- package/dist/web/agent-file-admin.d.ts +30 -0
- package/dist/web/agent-file-admin.js +165 -0
- package/dist/web/agents-config-page.d.ts +1 -0
- package/dist/web/agents-config-page.js +1628 -0
- package/dist/web/agents-list-page.d.ts +1 -0
- package/dist/web/agents-list-page.js +246 -0
- package/dist/web/dev-ui-styles.d.ts +1 -0
- package/dist/web/dev-ui-styles.js +63 -0
- package/dist/web/global-config-page.d.ts +1 -0
- package/dist/web/global-config-page.js +303 -0
- package/dist/web/global-config.d.ts +57 -0
- package/dist/web/global-config.js +67 -0
- package/dist/web/playground.d.ts +1 -0
- package/dist/web/playground.js +1271 -0
- package/dist/web/skills-admin.d.ts +23 -0
- package/dist/web/skills-admin.js +67 -0
- package/dist/web/web-approver.d.ts +35 -0
- package/dist/web/web-approver.js +88 -0
- package/package.json +5 -2
|
@@ -0,0 +1,1628 @@
|
|
|
1
|
+
// A local dev page: a single self-contained HTML page (no build step, no
|
|
2
|
+
// framework, no CDN) served by adapters/http.ts at GET /agents/config. It's
|
|
3
|
+
// a browser client on top of the existing GET /agents, GET
|
|
4
|
+
// /agents/:name/config, and GET/POST/DELETE /agents/:name/gateway-tools
|
|
5
|
+
// routes — nothing here talks to runAgent, SessionStore, or ActAuth
|
|
6
|
+
// directly; it only renders/writes through what those routes already
|
|
7
|
+
// resolve, so what's shown here is always what a real request would
|
|
8
|
+
// actually get (see describeAgent in adapters/http.ts).
|
|
9
|
+
//
|
|
10
|
+
// Once an agent is picked, its detail pane is a submenu — Overview /
|
|
11
|
+
// Actauth / Gateway tools — rather than one long scrolling page. Gateway
|
|
12
|
+
// tools used to be its own top-level page (adapters/gateway-tools-page.ts,
|
|
13
|
+
// now removed) with its own agent-picker sidebar; folded in here instead
|
|
14
|
+
// so there's one place to pick an agent and one place to see everything
|
|
15
|
+
// about it, not two parallel agent pickers for two halves of the same
|
|
16
|
+
// picture. The Gateway tools tab's own content is fetched lazily, only
|
|
17
|
+
// when that tab is actually opened — unlike Overview/Actauth (both free,
|
|
18
|
+
// pulled from the same /config fetch already made to open the agent at
|
|
19
|
+
// all), resolving gateway tools means actually connecting to each one
|
|
20
|
+
// (see gateway-tools.ts's describeGatewayTools), a real cost not worth
|
|
21
|
+
// paying for an agent whose gateway tools nobody looked at this visit.
|
|
22
|
+
//
|
|
23
|
+
// Embedded as a TS string (not a separate .html file), same reasoning as
|
|
24
|
+
// web/playground.ts: tsc's ordinary compile emits it straight into
|
|
25
|
+
// dist/adapters/ alongside http.js, no asset-copy step needed. Plain string
|
|
26
|
+
// concatenation, not a nested template literal, for the same
|
|
27
|
+
// backtick/${}-escaping reason playground.ts's own header comment explains.
|
|
28
|
+
import { devUiCss } from './dev-ui-styles.js';
|
|
29
|
+
export const agentsConfigPageHtml = `<!doctype html>
|
|
30
|
+
<html lang="en">
|
|
31
|
+
<head>
|
|
32
|
+
<meta charset="utf-8">
|
|
33
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
34
|
+
<title>LoopEngine Agents</title>
|
|
35
|
+
<style>${devUiCss}
|
|
36
|
+
body {
|
|
37
|
+
display: flex;
|
|
38
|
+
flex-direction: column;
|
|
39
|
+
height: 100vh;
|
|
40
|
+
}
|
|
41
|
+
/* Outer Agents/Models/Gateways section sidebar — matches
|
|
42
|
+
web/global-config-page.ts's own left nav, so this page (with
|
|
43
|
+
Agents pre-selected) and /config (with Models pre-selected) read as
|
|
44
|
+
two views of the same shell rather than unrelated pages. Models and
|
|
45
|
+
Gateways are real links out to /config?section=..., not client-side
|
|
46
|
+
panels rendered here too — that data/logic already lives in
|
|
47
|
+
global-config-page.ts, duplicating it here would just be two places
|
|
48
|
+
it could drift apart. Named .section-sidebar (not .sidebar) to avoid
|
|
49
|
+
colliding with nav.sidebar right below, which is the agent picker,
|
|
50
|
+
not this new outer one. */
|
|
51
|
+
.page-body { flex: 1; display: flex; min-height: 0; }
|
|
52
|
+
nav.section-sidebar {
|
|
53
|
+
width: 150px;
|
|
54
|
+
flex-shrink: 0;
|
|
55
|
+
border-right: 1px solid light-dark(#ddd, #333);
|
|
56
|
+
padding: 6px;
|
|
57
|
+
}
|
|
58
|
+
nav.section-sidebar a {
|
|
59
|
+
display: block;
|
|
60
|
+
border-radius: 6px;
|
|
61
|
+
padding: 8px 10px;
|
|
62
|
+
font-size: 13px;
|
|
63
|
+
text-decoration: none;
|
|
64
|
+
color: inherit;
|
|
65
|
+
}
|
|
66
|
+
nav.section-sidebar a:hover { background: light-dark(#eee, #26262b); }
|
|
67
|
+
nav.section-sidebar a.active { background: light-dark(#dbeafe, #1e3a5f); font-weight: 600; }
|
|
68
|
+
.layout { flex: 1; display: flex; min-height: 0; }
|
|
69
|
+
nav.sidebar {
|
|
70
|
+
width: 220px;
|
|
71
|
+
flex-shrink: 0;
|
|
72
|
+
border-right: 1px solid light-dark(#ddd, #333);
|
|
73
|
+
overflow-y: auto;
|
|
74
|
+
display: flex;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
}
|
|
77
|
+
#agentList { list-style: none; margin: 0; padding: 6px; flex: 1; }
|
|
78
|
+
#agentList li {
|
|
79
|
+
padding: 8px 10px;
|
|
80
|
+
border-radius: 6px;
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
font-size: 13px;
|
|
83
|
+
font-family: ui-monospace, monospace;
|
|
84
|
+
}
|
|
85
|
+
#agentList li:hover { background: light-dark(#eee, #26262b); }
|
|
86
|
+
#agentList li.active { background: light-dark(#dbeafe, #1e3a5f); font-weight: 600; }
|
|
87
|
+
main {
|
|
88
|
+
flex: 1;
|
|
89
|
+
overflow-y: auto;
|
|
90
|
+
padding: 20px 28px;
|
|
91
|
+
}
|
|
92
|
+
#empty { color: light-dark(#666, #999); font-size: 13px; }
|
|
93
|
+
h2 { font-size: 20px; margin: 0 0 4px; font-family: ui-monospace, monospace; }
|
|
94
|
+
.tabs {
|
|
95
|
+
display: flex;
|
|
96
|
+
gap: 18px;
|
|
97
|
+
margin: 14px 0 20px;
|
|
98
|
+
border-bottom: 1px solid light-dark(#ddd, #333);
|
|
99
|
+
}
|
|
100
|
+
.tabs button {
|
|
101
|
+
background: none;
|
|
102
|
+
border: none;
|
|
103
|
+
border-bottom: 2px solid transparent;
|
|
104
|
+
padding: 8px 2px;
|
|
105
|
+
font-size: 13px;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
color: light-dark(#666, #999);
|
|
108
|
+
}
|
|
109
|
+
.tabs button:hover { color: light-dark(#1a1a1e, #e8e8ea); }
|
|
110
|
+
.tabs button.active {
|
|
111
|
+
color: light-dark(#1a1a1e, #e8e8ea);
|
|
112
|
+
border-bottom-color: light-dark(#1d4ed8, #60a5fa);
|
|
113
|
+
font-weight: 600;
|
|
114
|
+
}
|
|
115
|
+
section { margin-bottom: 22px; }
|
|
116
|
+
section h3 {
|
|
117
|
+
font-size: 12px;
|
|
118
|
+
text-transform: uppercase;
|
|
119
|
+
letter-spacing: 0.04em;
|
|
120
|
+
color: light-dark(#666, #999);
|
|
121
|
+
margin: 0 0 8px;
|
|
122
|
+
border-bottom: 1px solid light-dark(#eee, #2a2a2e);
|
|
123
|
+
padding-bottom: 4px;
|
|
124
|
+
display: flex;
|
|
125
|
+
align-items: center;
|
|
126
|
+
justify-content: space-between;
|
|
127
|
+
}
|
|
128
|
+
.hint-btn {
|
|
129
|
+
font-size: 11px;
|
|
130
|
+
text-transform: none;
|
|
131
|
+
letter-spacing: normal;
|
|
132
|
+
padding: 2px 8px;
|
|
133
|
+
}
|
|
134
|
+
pre {
|
|
135
|
+
background: light-dark(#fff, #26262b);
|
|
136
|
+
border: 1px solid light-dark(#ddd, #3a3a3e);
|
|
137
|
+
border-radius: 6px;
|
|
138
|
+
padding: 10px 12px;
|
|
139
|
+
font-size: 12px;
|
|
140
|
+
white-space: pre-wrap;
|
|
141
|
+
word-break: break-word;
|
|
142
|
+
margin: 0;
|
|
143
|
+
}
|
|
144
|
+
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
145
|
+
th, td {
|
|
146
|
+
text-align: left;
|
|
147
|
+
padding: 6px 8px;
|
|
148
|
+
border-bottom: 1px solid light-dark(#eee, #2a2a2e);
|
|
149
|
+
vertical-align: top;
|
|
150
|
+
}
|
|
151
|
+
th { color: light-dark(#666, #999); font-weight: 600; }
|
|
152
|
+
.decision-allow { color: light-dark(#166534, #86efac); }
|
|
153
|
+
.decision-ask { color: light-dark(#92400e, #fcd34d); }
|
|
154
|
+
.decision-deny { color: light-dark(#991b1b, #f87171); }
|
|
155
|
+
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; font-size: 13px; }
|
|
156
|
+
.kv dt { color: light-dark(#666, #999); }
|
|
157
|
+
.kv dd { margin: 0; font-family: ui-monospace, monospace; }
|
|
158
|
+
.error { color: light-dark(#991b1b, #f87171); font-size: 13px; }
|
|
159
|
+
.hint { font-size: 11px; color: light-dark(#666, #999); }
|
|
160
|
+
#playgroundLink { font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-weight: normal; text-decoration: none; margin-left: 8px; }
|
|
161
|
+
.source {
|
|
162
|
+
border: 1px solid light-dark(#ddd, #3a3a3e);
|
|
163
|
+
border-radius: 8px;
|
|
164
|
+
padding: 12px 14px;
|
|
165
|
+
margin-bottom: 10px;
|
|
166
|
+
}
|
|
167
|
+
.source-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
|
168
|
+
.source-head h4 { margin: 0; font-size: 14px; font-family: ui-monospace, monospace; }
|
|
169
|
+
.status-ok { color: light-dark(#166534, #86efac); }
|
|
170
|
+
.status-error { color: light-dark(#991b1b, #f87171); }
|
|
171
|
+
.tool-list { margin: 8px 0 0; padding-left: 0; font-size: 12px; }
|
|
172
|
+
.tool-list li {
|
|
173
|
+
margin-bottom: 2px;
|
|
174
|
+
display: flex;
|
|
175
|
+
align-items: baseline;
|
|
176
|
+
gap: 8px;
|
|
177
|
+
}
|
|
178
|
+
.delete-btn {
|
|
179
|
+
flex-shrink: 0;
|
|
180
|
+
font-size: 12px;
|
|
181
|
+
color: light-dark(#991b1b, #f87171);
|
|
182
|
+
background: light-dark(#fee2e2, #3a1f1f);
|
|
183
|
+
border-color: light-dark(#f3b4b4, #6b3232);
|
|
184
|
+
}
|
|
185
|
+
.delete-btn:hover { background: light-dark(#fecaca, #4a1f1f); }
|
|
186
|
+
/* Explicit rather than relying on inherited font-size: .edit-rule-btn
|
|
187
|
+
sits inside a 12px table, but .edit-skill-btn sits in .source-head
|
|
188
|
+
(no font-size of its own, so it'd otherwise inherit the page's
|
|
189
|
+
larger default) — without this the two Edit buttons render at
|
|
190
|
+
visibly different sizes despite being the same component. */
|
|
191
|
+
.edit-rule-btn, .edit-skill-btn { font-size: 12px; }
|
|
192
|
+
/* Sits inside a section h3, which is uppercase/letter-spaced for its
|
|
193
|
+
own heading text — without resetting both here, the button's own
|
|
194
|
+
label ("Edit") would inherit that and render as "EDIT" with the
|
|
195
|
+
heading's wide letter-spacing, not a normal-looking button. */
|
|
196
|
+
.edit-btn { font-size: 11px; text-transform: none; letter-spacing: normal; margin-left: 6px; }
|
|
197
|
+
/* Flex items default to min-width: auto, which means a long,
|
|
198
|
+
unbreakable token (a slug like GITHUB_LIST_REPOSITORIES_FOR_THE_
|
|
199
|
+
AUTHENTICATED_USER has no spaces for the browser to wrap at) simply
|
|
200
|
+
doesn't shrink to fit — it overflows the row, and the row's
|
|
201
|
+
ancestor forms, instead of wrapping. min-width: 0 lets it actually
|
|
202
|
+
shrink to the available space; overflow-wrap lets it break mid-word
|
|
203
|
+
once there's nowhere left to wrap normally. */
|
|
204
|
+
.tool-picker-label { flex: 1; min-width: 0; overflow-wrap: break-word; }
|
|
205
|
+
form.add-source {
|
|
206
|
+
border: 1px solid light-dark(#ddd, #3a3a3e);
|
|
207
|
+
border-radius: 8px;
|
|
208
|
+
padding: 14px;
|
|
209
|
+
display: grid;
|
|
210
|
+
gap: 10px;
|
|
211
|
+
max-width: 480px;
|
|
212
|
+
}
|
|
213
|
+
form.add-source label { font-size: 12px; display: flex; flex-direction: column; gap: 4px; }
|
|
214
|
+
form.add-source input, form.add-source select, form.add-source textarea { width: 100%; }
|
|
215
|
+
form.add-source textarea { font-family: ui-monospace, monospace; min-height: 60px; }
|
|
216
|
+
/* Overrides form.add-source's shared max-width/textarea defaults for
|
|
217
|
+
the skill editor specifically — id selectors beat the class-level
|
|
218
|
+
rules above without needing to touch what every other add-source
|
|
219
|
+
form (gateway tools, actauth rules) looks like. A skill's
|
|
220
|
+
description/body are real prose/markdown, not the short one-liners
|
|
221
|
+
the shared 480px/60px defaults were sized for. */
|
|
222
|
+
#skillForm { max-width: 720px; }
|
|
223
|
+
#skillDescInput { font-family: inherit; min-height: 48px; resize: vertical; }
|
|
224
|
+
#skillBodyInput { min-height: 280px; resize: vertical; }
|
|
225
|
+
/* Same "field label" look form.add-source label gets, reproduced on a
|
|
226
|
+
<div> instead of a <label> — a <label> wrapping the Write/Preview
|
|
227
|
+
toggle buttons would make clicking them also fire the label's own
|
|
228
|
+
default click-forwarding behavior, aimed at whatever form control
|
|
229
|
+
the label wraps first (the textarea), which isn't what a toggle
|
|
230
|
+
button click should do. */
|
|
231
|
+
.body-field { font-size: 12px; display: flex; flex-direction: column; gap: 4px; }
|
|
232
|
+
.body-field-head { display: flex; align-items: center; justify-content: space-between; }
|
|
233
|
+
.md-toggle { display: flex; gap: 4px; }
|
|
234
|
+
.md-toggle-btn { padding: 2px 8px; font-size: 11px; background: transparent; }
|
|
235
|
+
.md-toggle-btn.active { background: light-dark(#e8e8ea, #333338); font-weight: 600; }
|
|
236
|
+
.markdown-body {
|
|
237
|
+
border: 1px solid light-dark(#ccc, #444);
|
|
238
|
+
border-radius: 6px;
|
|
239
|
+
padding: 10px 14px;
|
|
240
|
+
min-height: 280px;
|
|
241
|
+
max-height: 480px;
|
|
242
|
+
overflow-y: auto;
|
|
243
|
+
}
|
|
244
|
+
.markdown-body h1, .markdown-body h2 {
|
|
245
|
+
border-bottom: 1px solid light-dark(#eee, #333338);
|
|
246
|
+
padding-bottom: 4px;
|
|
247
|
+
}
|
|
248
|
+
.markdown-body pre {
|
|
249
|
+
background: light-dark(#f6f8fa, #26262b);
|
|
250
|
+
padding: 10px 12px;
|
|
251
|
+
border-radius: 6px;
|
|
252
|
+
overflow-x: auto;
|
|
253
|
+
}
|
|
254
|
+
.markdown-body code {
|
|
255
|
+
background: light-dark(#eee, #2a2a2e);
|
|
256
|
+
padding: 1px 5px;
|
|
257
|
+
border-radius: 4px;
|
|
258
|
+
font-family: ui-monospace, monospace;
|
|
259
|
+
font-size: 12px;
|
|
260
|
+
}
|
|
261
|
+
.markdown-body pre code { background: none; padding: 0; }
|
|
262
|
+
.tool-picker-controls {
|
|
263
|
+
display: flex;
|
|
264
|
+
align-items: center;
|
|
265
|
+
gap: 8px;
|
|
266
|
+
margin: 4px 0;
|
|
267
|
+
}
|
|
268
|
+
.tool-picker-controls button { font-size: 11px; padding: 3px 8px; }
|
|
269
|
+
.tool-picker-list {
|
|
270
|
+
max-height: 240px;
|
|
271
|
+
overflow-y: auto;
|
|
272
|
+
border: 1px solid light-dark(#ddd, #3a3a3e);
|
|
273
|
+
border-radius: 6px;
|
|
274
|
+
padding: 6px 8px;
|
|
275
|
+
}
|
|
276
|
+
/* "form.add-source label { flex-direction: column; }" above is meant
|
|
277
|
+
for the form's own top-level fields (Provider/App/Name/...), each a
|
|
278
|
+
<label> wrapping a heading over its input — but .tool-picker-item is
|
|
279
|
+
also a <label> inside that same form, so that rule matches it too,
|
|
280
|
+
and (higher specificity: form+class+label vs. just one class here)
|
|
281
|
+
wins over flex-direction on this rule alone, stacking the checkbox
|
|
282
|
+
under the text instead of beside it. "label.tool-picker-item" adds
|
|
283
|
+
the element back in to match that specificity and override it. */
|
|
284
|
+
form.add-source label.tool-picker-item {
|
|
285
|
+
display: flex;
|
|
286
|
+
flex-direction: row;
|
|
287
|
+
align-items: flex-start;
|
|
288
|
+
justify-content: space-between;
|
|
289
|
+
gap: 8px;
|
|
290
|
+
padding: 5px 2px;
|
|
291
|
+
font-size: 12px;
|
|
292
|
+
border-bottom: 1px solid light-dark(#eee, #2a2a2e);
|
|
293
|
+
}
|
|
294
|
+
.tool-picker-item:last-child { border-bottom: none; }
|
|
295
|
+
/* Higher specificity than "form.add-source input { width: 100%; }" —
|
|
296
|
+
without this, that rule wins (one more element in its selector) and
|
|
297
|
+
stretches the checkbox to the full row width, which visually
|
|
298
|
+
overlaps the description text under it instead of sitting beside it. */
|
|
299
|
+
form.add-source input[type="checkbox"] { width: auto; flex-shrink: 0; margin-top: 2px; }
|
|
300
|
+
</style>
|
|
301
|
+
</head>
|
|
302
|
+
<body>
|
|
303
|
+
<nav class="topnav">
|
|
304
|
+
<a href="/agents">Agents</a>
|
|
305
|
+
<a href="/agents/config" class="active">Config</a>
|
|
306
|
+
<a href="/playground">Playground</a>
|
|
307
|
+
</nav>
|
|
308
|
+
<div class="page-body">
|
|
309
|
+
<nav class="section-sidebar">
|
|
310
|
+
<a href="/agents/config" class="active">Agents</a>
|
|
311
|
+
<a href="/config?section=models">Models</a>
|
|
312
|
+
<a href="/config?section=gateways">Gateways</a>
|
|
313
|
+
</nav>
|
|
314
|
+
<div class="layout">
|
|
315
|
+
<nav class="sidebar">
|
|
316
|
+
<ul id="agentList"></ul>
|
|
317
|
+
</nav>
|
|
318
|
+
<main>
|
|
319
|
+
<div id="empty">Pick an agent to see its config.</div>
|
|
320
|
+
<div id="detail" style="display:none"></div>
|
|
321
|
+
</main>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
<script>
|
|
325
|
+
(function () {
|
|
326
|
+
var agentList = document.getElementById('agentList');
|
|
327
|
+
var empty = document.getElementById('empty');
|
|
328
|
+
var detail = document.getElementById('detail');
|
|
329
|
+
var currentName = null;
|
|
330
|
+
var currentTab = 'overview';
|
|
331
|
+
// Which agent's Gateway tools tab has already been fetched — reset on
|
|
332
|
+
// every agent switch so a stale agent's sources are never shown under
|
|
333
|
+
// a new one, and re-fetched the next time that tab is opened.
|
|
334
|
+
var gatewayLoadedFor = null;
|
|
335
|
+
// Same idea, for the Actauth tab's own separately-fetched GET
|
|
336
|
+
// .../actauth (see renderActauthTabPlaceholder's own doc comment).
|
|
337
|
+
var actauthLoadedFor = null;
|
|
338
|
+
// The full /agents/:name/config response the currently-open agent was
|
|
339
|
+
// last rendered from — kept around so refreshActauthDependentPanels/
|
|
340
|
+
// refreshSkillsDependentPanels below can re-fetch and re-render just
|
|
341
|
+
// the panels that read cfg (Overview, Skills) after a change, without
|
|
342
|
+
// touching the Tools/Actauth tabs' own already-current state.
|
|
343
|
+
var currentCfg = null;
|
|
344
|
+
|
|
345
|
+
function decisionClass(decision) {
|
|
346
|
+
return 'decision-' + decision;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function escapeHtml(s) {
|
|
350
|
+
return String(s).replace(/[&<>"']/g, function (c) {
|
|
351
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function badge(label) {
|
|
356
|
+
return '<span class="badge ' + (label === 'custom' ? 'badge-custom' : 'badge-default') + '">' + escapeHtml(label) + '</span>';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// A small, deliberately non-CommonMark markdown-to-HTML renderer for
|
|
360
|
+
// the skill Body field's Preview toggle (see wireSkillsHandlers) — this
|
|
361
|
+
// page stays a self-contained string, no build step, no CDN (same
|
|
362
|
+
// reasoning as devUiCss's own header comment), so pulling in a real
|
|
363
|
+
// markdown library isn't an option without breaking that. Covers what
|
|
364
|
+
// an actual SKILL.md body realistically uses (headings, bold/italic,
|
|
365
|
+
// inline code, fenced code blocks, links, lists, rules, paragraphs) —
|
|
366
|
+
// good enough for "does this read the way it will on GitHub", not a
|
|
367
|
+
// spec-complete parser. Escapes first, so no markdown source can inject
|
|
368
|
+
// raw HTML into the preview.
|
|
369
|
+
function renderMarkdownPreview(md) {
|
|
370
|
+
var lines = escapeHtml(md).split('\\n');
|
|
371
|
+
var html = '';
|
|
372
|
+
var inCode = false;
|
|
373
|
+
var codeBuffer = '';
|
|
374
|
+
var listType = null;
|
|
375
|
+
var paragraph = [];
|
|
376
|
+
|
|
377
|
+
// This whole page is one outer TS template literal AND its output is
|
|
378
|
+
// itself served as JS source text a browser then parses again — two
|
|
379
|
+
// layers of "escape sequences get processed", not one. The unicode
|
|
380
|
+
// escape used for the backtick patterns below survives both layers fine, since
|
|
381
|
+
// it's a real, recognized escape at both of them. A star/bracket/
|
|
382
|
+
// paren/s/d/dot escape (\*, \[, \s, \d, \.) is *not* a recognized
|
|
383
|
+
// escape at either layer, so each layer that touches it silently
|
|
384
|
+
// drops the backslash and keeps just the character — confirmed by
|
|
385
|
+
// extracting and executing the actually-served page during
|
|
386
|
+
// development, where every regex built the "obvious" way here ended
|
|
387
|
+
// up with its escapes stripped entirely, sometimes still parsing
|
|
388
|
+
// (silently wrong) and sometimes throwing ("nothing to repeat").
|
|
389
|
+
// Building these particular patterns from a runtime backslash
|
|
390
|
+
// character instead sidesteps the whole problem: there is no
|
|
391
|
+
// backslash-followed-by-letter sequence anywhere in this source for
|
|
392
|
+
// either layer to misinterpret.
|
|
393
|
+
var bs = String.fromCharCode(92);
|
|
394
|
+
var inlineCodePattern = new RegExp('\\u0060([^\\u0060]+)\\u0060', 'g');
|
|
395
|
+
var fencePattern = new RegExp('^\\u0060\\u0060\\u0060');
|
|
396
|
+
var boldPattern = new RegExp(bs + '*' + bs + '*([^*]+)' + bs + '*' + bs + '*', 'g');
|
|
397
|
+
var italicPattern = new RegExp(bs + '*([^*]+)' + bs + '*', 'g');
|
|
398
|
+
var linkPattern = new RegExp(bs + '[([^' + bs + ']]+)' + bs + ']' + bs + '(([^)]+)' + bs + ')', 'g');
|
|
399
|
+
var headingPattern = new RegExp('^(#{1,6})' + bs + 's+(.*)$');
|
|
400
|
+
var hrPattern = new RegExp('^(---|' + bs + '*' + bs + '*' + bs + '*)' + bs + 's*$');
|
|
401
|
+
var ulPattern = new RegExp('^[-*]' + bs + 's+(.*)$');
|
|
402
|
+
var olPattern = new RegExp('^' + bs + 'd+' + bs + '.' + bs + 's+(.*)$');
|
|
403
|
+
function inlineFormat(text) {
|
|
404
|
+
return text
|
|
405
|
+
.replace(inlineCodePattern, '<code>$1</code>')
|
|
406
|
+
.replace(boldPattern, '<strong>$1</strong>')
|
|
407
|
+
.replace(italicPattern, '<em>$1</em>')
|
|
408
|
+
.replace(linkPattern, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
409
|
+
}
|
|
410
|
+
function flushParagraph() {
|
|
411
|
+
if (paragraph.length) {
|
|
412
|
+
html += '<p>' + inlineFormat(paragraph.join(' ')) + '</p>';
|
|
413
|
+
paragraph = [];
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function closeList() {
|
|
417
|
+
if (listType) {
|
|
418
|
+
html += '</' + listType + '>';
|
|
419
|
+
listType = null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
for (var i = 0; i < lines.length; i++) {
|
|
424
|
+
var line = lines[i];
|
|
425
|
+
if (fencePattern.test(line)) {
|
|
426
|
+
if (!inCode) {
|
|
427
|
+
flushParagraph();
|
|
428
|
+
closeList();
|
|
429
|
+
inCode = true;
|
|
430
|
+
codeBuffer = '';
|
|
431
|
+
} else {
|
|
432
|
+
html += '<pre><code>' + codeBuffer + '</code></pre>';
|
|
433
|
+
inCode = false;
|
|
434
|
+
}
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (inCode) {
|
|
438
|
+
codeBuffer += line + '\\n';
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
var heading = line.match(headingPattern);
|
|
443
|
+
if (heading) {
|
|
444
|
+
flushParagraph();
|
|
445
|
+
closeList();
|
|
446
|
+
var level = heading[1].length;
|
|
447
|
+
html += '<h' + level + '>' + inlineFormat(heading[2]) + '</h' + level + '>';
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (hrPattern.test(line)) {
|
|
451
|
+
flushParagraph();
|
|
452
|
+
closeList();
|
|
453
|
+
html += '<hr>';
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
var ul = line.match(ulPattern);
|
|
457
|
+
var ol = line.match(olPattern);
|
|
458
|
+
if (ul || ol) {
|
|
459
|
+
flushParagraph();
|
|
460
|
+
var wantType = ul ? 'ul' : 'ol';
|
|
461
|
+
if (listType !== wantType) {
|
|
462
|
+
closeList();
|
|
463
|
+
html += '<' + wantType + '>';
|
|
464
|
+
listType = wantType;
|
|
465
|
+
}
|
|
466
|
+
html += '<li>' + inlineFormat((ul || ol)[1]) + '</li>';
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
closeList();
|
|
470
|
+
|
|
471
|
+
if (line.trim() === '') {
|
|
472
|
+
flushParagraph();
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
paragraph.push(line.trim());
|
|
476
|
+
}
|
|
477
|
+
flushParagraph();
|
|
478
|
+
closeList();
|
|
479
|
+
return html || '<p class="hint">Nothing to preview yet.</p>';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---- Overview tab ----
|
|
483
|
+
|
|
484
|
+
function renderTools(tools) {
|
|
485
|
+
if (!tools.length) return '<p class="muted">No tools.</p>';
|
|
486
|
+
var rows = tools.map(function (t) {
|
|
487
|
+
return '<tr><td><code>' + escapeHtml(t.name) + '</code></td>' +
|
|
488
|
+
'<td>' + escapeHtml(t.description) + '</td>' +
|
|
489
|
+
'<td>' + (t.safe ? 'yes' : 'no') + '</td></tr>';
|
|
490
|
+
}).join('');
|
|
491
|
+
return '<table><thead><tr><th>Name</th><th>Description</th><th>Parallel-safe</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Overview's own tools table, unlike the Tools tab's three separately-
|
|
495
|
+
// headed sections (Local tools / Gateway Tools / Agent as Tools), is
|
|
496
|
+
// one flat list (cfg.tools) — without a Type column there's no way to
|
|
497
|
+
// tell, at a glance, where a given tool actually comes from. Built
|
|
498
|
+
// from cfg.localTools/cfg.gatewayTools/cfg.agentAsTools directly
|
|
499
|
+
// (same three arrays the Tools tab renders) rather than cfg.tools, so
|
|
500
|
+
// each row can be tagged with which one it came from — same order as
|
|
501
|
+
// the Tools tab's own sections, for consistency. Deliberately excludes
|
|
502
|
+
// cfg.systemTools/cfg.systemSkills — infrastructure every agent gets
|
|
503
|
+
// automatically (see run-agent.ts's systemTools/systemSkillsDir), not
|
|
504
|
+
// something an operator configured, so this page hides them rather
|
|
505
|
+
// than clutter the per-agent view with tools/skills that aren't
|
|
506
|
+
// actually specific to this agent.
|
|
507
|
+
function renderToolsWithType(cfg) {
|
|
508
|
+
var rows = [];
|
|
509
|
+
function addRows(tools, type) {
|
|
510
|
+
for (var i = 0; i < tools.length; i++) {
|
|
511
|
+
var t = tools[i];
|
|
512
|
+
rows.push(
|
|
513
|
+
'<tr><td><code>' + escapeHtml(t.name) + '</code></td>' +
|
|
514
|
+
'<td>' + escapeHtml(t.description) + '</td>' +
|
|
515
|
+
'<td>' + escapeHtml(type) + '</td>' +
|
|
516
|
+
'<td>' + (t.safe ? 'yes' : 'no') + '</td></tr>',
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
addRows(cfg.localTools, 'Local');
|
|
521
|
+
addRows(cfg.gatewayTools, 'Gateway');
|
|
522
|
+
addRows(cfg.agentAsTools, 'Agent as Tool');
|
|
523
|
+
if (!rows.length) return '<p class="muted">No tools.</p>';
|
|
524
|
+
return '<table><thead><tr><th>Name</th><th>Description</th><th>Type</th><th>Parallel-safe</th></tr></thead><tbody>' + rows.join('') + '</tbody></table>';
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function renderSkills(skills) {
|
|
528
|
+
if (!skills.length) return '<p class="muted">No skills.</p>';
|
|
529
|
+
var rows = skills.map(function (s) {
|
|
530
|
+
return '<tr><td><code>' + escapeHtml(s.name) + '</code></td>' +
|
|
531
|
+
'<td>' + escapeHtml(s.description) + '</td></tr>';
|
|
532
|
+
}).join('');
|
|
533
|
+
return '<table><thead><tr><th>Name</th><th>Description</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function renderOverviewHtml(cfg) {
|
|
537
|
+
var systemPromptSection =
|
|
538
|
+
'<h3>System prompt <button type="button" class="edit-btn" id="editSystemPromptBtn">Edit</button></h3>' +
|
|
539
|
+
'<pre id="systemPromptDisplay">' + escapeHtml(cfg.systemPrompt) + '</pre>' +
|
|
540
|
+
'<form class="add-source" id="systemPromptForm" style="display:none">' +
|
|
541
|
+
'<textarea name="systemPrompt">' + escapeHtml(cfg.systemPrompt) + '</textarea>' +
|
|
542
|
+
'<button type="submit">Save</button>' +
|
|
543
|
+
'<button type="button" id="cancelSystemPromptBtn">Cancel</button>' +
|
|
544
|
+
'<div class="error" id="systemPromptError"></div>' +
|
|
545
|
+
'</form>';
|
|
546
|
+
|
|
547
|
+
// Only editable when cfg.model is the resolved {provider, model,
|
|
548
|
+
// maxTokens} object — a custom createModelCall (the string case) has
|
|
549
|
+
// no config.model at all for agent-file-admin.ts's editAgentFile to
|
|
550
|
+
// find and edit; see its own doc comment for why it refuses rather
|
|
551
|
+
// than guessing at that case.
|
|
552
|
+
var modelSection;
|
|
553
|
+
if (typeof cfg.model === 'string') {
|
|
554
|
+
modelSection = '<h3>Model</h3><p class="muted">' + escapeHtml(cfg.model) + '</p>';
|
|
555
|
+
} else {
|
|
556
|
+
modelSection =
|
|
557
|
+
'<h3>Model <button type="button" class="edit-btn" id="editModelBtn">Edit</button></h3>' +
|
|
558
|
+
'<dl class="kv" id="modelDisplay">' +
|
|
559
|
+
'<dt>Provider</dt><dd>' + escapeHtml(cfg.model.provider) + '</dd>' +
|
|
560
|
+
'<dt>Model</dt><dd>' + escapeHtml(cfg.model.model || '(provider default)') + '</dd>' +
|
|
561
|
+
'<dt>Max tokens</dt><dd>' + escapeHtml(cfg.model.maxTokens != null ? cfg.model.maxTokens : '(default)') + '</dd>' +
|
|
562
|
+
'</dl>' +
|
|
563
|
+
'<form class="add-source" id="modelForm" style="display:none">' +
|
|
564
|
+
'<label>Provider' +
|
|
565
|
+
'<select name="provider">' +
|
|
566
|
+
['anthropic', 'openai', 'deepseek'].map(function (p) {
|
|
567
|
+
return '<option value="' + p + '"' + (p === cfg.model.provider ? ' selected' : '') + '>' + p + '</option>';
|
|
568
|
+
}).join('') +
|
|
569
|
+
'</select>' +
|
|
570
|
+
'</label>' +
|
|
571
|
+
'<label>Model name <span class="hint">(required for openai/deepseek; defaults to claude-sonnet-5 for anthropic)</span>' +
|
|
572
|
+
'<input type="text" name="modelName" value="' + escapeHtml(cfg.model.model || '') + '">' +
|
|
573
|
+
'</label>' +
|
|
574
|
+
'<button type="submit">Save</button>' +
|
|
575
|
+
'<button type="button" id="cancelModelBtn">Cancel</button>' +
|
|
576
|
+
'<div class="error" id="modelError"></div>' +
|
|
577
|
+
'</form>';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return '<section>' + systemPromptSection + '</section>' +
|
|
581
|
+
'<section>' + modelSection + '</section>' +
|
|
582
|
+
'<section><h3>Skills (' + cfg.skills.length + ')</h3>' + renderSkills(cfg.skills) + '</section>' +
|
|
583
|
+
'<section><h3>Tools (' + cfg.tools.length + ')</h3>' + renderToolsWithType(cfg) + '</section>' +
|
|
584
|
+
'<section><h3>ActAuth</h3>' + renderRules(cfg.permissions) + '</section>' +
|
|
585
|
+
'<section><h3>Hooks</h3><dl class="kv">' +
|
|
586
|
+
'<dt>sessionIdFor</dt><dd>' + badge(cfg.sessionIdFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.sessionIdFor) + '</span></dd>' +
|
|
587
|
+
'<dt>tenantFor</dt><dd>' + badge(cfg.tenantFor.indexOf('custom') === 0 ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.tenantFor) + '</span></dd>' +
|
|
588
|
+
'<dt>isSafeTool</dt><dd>' + badge(cfg.isSafeTool === 'custom' ? 'custom' : 'default') + ' <span class="muted">' + escapeHtml(cfg.isSafeTool) + '</span></dd>' +
|
|
589
|
+
'</dl></section>' +
|
|
590
|
+
'<section><h3>Limits & budgets</h3><dl class="kv">' +
|
|
591
|
+
'<dt>maxTurns</dt><dd>' + escapeHtml(cfg.maxTurns) + '</dd>' +
|
|
592
|
+
'<dt>contextBudgetTokens</dt><dd>' + escapeHtml(cfg.contextBudgetTokens) + '</dd>' +
|
|
593
|
+
'<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
|
|
594
|
+
'<dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd>' +
|
|
595
|
+
'</dl></section>';
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Wires the System prompt / Model Edit buttons rendered by
|
|
599
|
+
// renderOverviewHtml above — called every time that HTML gets (re)set
|
|
600
|
+
// (renderDetail, refreshOverviewPanel, refreshSkillsDependentPanels),
|
|
601
|
+
// same pattern every other tab's own wireXHandlers already follows.
|
|
602
|
+
// PUT /agents/:name persists via agent-file-admin.ts's editAgentFile
|
|
603
|
+
// (see its own doc comment for why some agents' index.ts files can't
|
|
604
|
+
// be safely edited this way at all — no editModelBtn is rendered for
|
|
605
|
+
// those in the first place) and applies the change live; the response
|
|
606
|
+
// is the freshly-resolved config, so this re-renders the whole
|
|
607
|
+
// Overview panel from it directly instead of a second GET.
|
|
608
|
+
function wireOverviewHandlers(name) {
|
|
609
|
+
var overviewPanel = detail.querySelector('[data-tab-panel="overview"]');
|
|
610
|
+
if (!overviewPanel) return;
|
|
611
|
+
|
|
612
|
+
var editSystemPromptBtn = overviewPanel.querySelector('#editSystemPromptBtn');
|
|
613
|
+
if (editSystemPromptBtn) {
|
|
614
|
+
var systemPromptDisplay = overviewPanel.querySelector('#systemPromptDisplay');
|
|
615
|
+
var systemPromptForm = overviewPanel.querySelector('#systemPromptForm');
|
|
616
|
+
var systemPromptError = overviewPanel.querySelector('#systemPromptError');
|
|
617
|
+
|
|
618
|
+
editSystemPromptBtn.addEventListener('click', function () {
|
|
619
|
+
systemPromptDisplay.style.display = 'none';
|
|
620
|
+
editSystemPromptBtn.style.display = 'none';
|
|
621
|
+
systemPromptForm.style.display = '';
|
|
622
|
+
systemPromptForm.querySelector('textarea').focus();
|
|
623
|
+
});
|
|
624
|
+
overviewPanel.querySelector('#cancelSystemPromptBtn').addEventListener('click', function () {
|
|
625
|
+
systemPromptForm.style.display = 'none';
|
|
626
|
+
systemPromptDisplay.style.display = '';
|
|
627
|
+
editSystemPromptBtn.style.display = '';
|
|
628
|
+
systemPromptError.textContent = '';
|
|
629
|
+
});
|
|
630
|
+
systemPromptForm.addEventListener('submit', function (ev) {
|
|
631
|
+
ev.preventDefault();
|
|
632
|
+
systemPromptError.textContent = '';
|
|
633
|
+
var value = systemPromptForm.querySelector('textarea[name="systemPrompt"]').value;
|
|
634
|
+
var submitBtn = systemPromptForm.querySelector('button[type="submit"]');
|
|
635
|
+
submitBtn.disabled = true;
|
|
636
|
+
fetch('/agents/' + encodeURIComponent(name), {
|
|
637
|
+
method: 'PUT',
|
|
638
|
+
headers: { 'content-type': 'application/json' },
|
|
639
|
+
body: JSON.stringify({ systemPrompt: value }),
|
|
640
|
+
})
|
|
641
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
642
|
+
.then(function (result) {
|
|
643
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
644
|
+
currentCfg = result.body;
|
|
645
|
+
overviewPanel.innerHTML = renderOverviewHtml(result.body);
|
|
646
|
+
wireOverviewHandlers(name);
|
|
647
|
+
})
|
|
648
|
+
.catch(function (err) {
|
|
649
|
+
systemPromptError.textContent = err.message;
|
|
650
|
+
submitBtn.disabled = false;
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
var editModelBtn = overviewPanel.querySelector('#editModelBtn');
|
|
656
|
+
if (editModelBtn) {
|
|
657
|
+
var modelDisplay = overviewPanel.querySelector('#modelDisplay');
|
|
658
|
+
var modelForm = overviewPanel.querySelector('#modelForm');
|
|
659
|
+
var modelError = overviewPanel.querySelector('#modelError');
|
|
660
|
+
|
|
661
|
+
editModelBtn.addEventListener('click', function () {
|
|
662
|
+
modelDisplay.style.display = 'none';
|
|
663
|
+
editModelBtn.style.display = 'none';
|
|
664
|
+
modelForm.style.display = '';
|
|
665
|
+
});
|
|
666
|
+
overviewPanel.querySelector('#cancelModelBtn').addEventListener('click', function () {
|
|
667
|
+
modelForm.style.display = 'none';
|
|
668
|
+
modelDisplay.style.display = '';
|
|
669
|
+
editModelBtn.style.display = '';
|
|
670
|
+
modelError.textContent = '';
|
|
671
|
+
});
|
|
672
|
+
modelForm.addEventListener('submit', function (ev) {
|
|
673
|
+
ev.preventDefault();
|
|
674
|
+
modelError.textContent = '';
|
|
675
|
+
var data = new FormData(modelForm);
|
|
676
|
+
var submitBtn = modelForm.querySelector('button[type="submit"]');
|
|
677
|
+
submitBtn.disabled = true;
|
|
678
|
+
var body = { model: { provider: data.get('provider') } };
|
|
679
|
+
var modelName = data.get('modelName');
|
|
680
|
+
if (modelName && modelName.trim()) body.model.model = modelName;
|
|
681
|
+
fetch('/agents/' + encodeURIComponent(name), {
|
|
682
|
+
method: 'PUT',
|
|
683
|
+
headers: { 'content-type': 'application/json' },
|
|
684
|
+
body: JSON.stringify(body),
|
|
685
|
+
})
|
|
686
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
687
|
+
.then(function (result) {
|
|
688
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
689
|
+
currentCfg = result.body;
|
|
690
|
+
overviewPanel.innerHTML = renderOverviewHtml(result.body);
|
|
691
|
+
wireOverviewHandlers(name);
|
|
692
|
+
})
|
|
693
|
+
.catch(function (err) {
|
|
694
|
+
modelError.textContent = err.message;
|
|
695
|
+
submitBtn.disabled = false;
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ---- Overview's own Actauth section: read-only, from cfg.permissions
|
|
702
|
+
// (already resolved by run-agent.ts's own loadRules — 3-segment
|
|
703
|
+
// scopePattern, "when" conditions included). The dedicated Actauth tab
|
|
704
|
+
// below is a *different* view: editable, and reading/writing the raw
|
|
705
|
+
// 2-segment scope actauth.yml itself has, via a separately-fetched
|
|
706
|
+
// GET /agents/:name/actauth — see renderActauthTabPlaceholder. Keeping
|
|
707
|
+
// these two independent avoids a scope-format mismatch: round-tripping
|
|
708
|
+
// cfg.permissions' own expanded 3-segment scopePattern back through an
|
|
709
|
+
// edit would permanently "expand" a hand-authored 2-segment rule the
|
|
710
|
+
// moment anyone touched it. ----
|
|
711
|
+
|
|
712
|
+
function renderRules(permissions) {
|
|
713
|
+
var header = '<dl class="kv">' +
|
|
714
|
+
'<dt>Source</dt><dd>' + escapeHtml(permissions.source) + '</dd>' +
|
|
715
|
+
'<dt>Default decision</dt><dd class="' + decisionClass(permissions.defaultDecision) + '">' + escapeHtml(permissions.defaultDecision) + '</dd>' +
|
|
716
|
+
'</dl>';
|
|
717
|
+
if (!permissions.rules.length) return header + '<p class="muted">No explicit rules — every tool call falls through to the default decision above.</p>';
|
|
718
|
+
var rows = permissions.rules.map(function (r) {
|
|
719
|
+
return '<tr>' +
|
|
720
|
+
'<td><code>' + escapeHtml(r.scopePattern) + '</code></td>' +
|
|
721
|
+
'<td><code>' + escapeHtml(r.tool) + '</code></td>' +
|
|
722
|
+
'<td class="' + decisionClass(r.decision) + '">' + escapeHtml(r.decision) + '</td>' +
|
|
723
|
+
'<td>' + (r.when ? '<pre>' + escapeHtml(JSON.stringify(r.when, null, 2)) + '</pre>' : '') + '</td>' +
|
|
724
|
+
'</tr>';
|
|
725
|
+
}).join('');
|
|
726
|
+
return header + '<table style="margin-top:10px"><thead><tr><th>Scope</th><th>Tool</th><th>Decision</th><th>When</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ---- Actauth tab: editable — add/edit/delete rules and set
|
|
730
|
+
// default_decision via actauth-admin.ts, working directly against the
|
|
731
|
+
// file's own raw scope strings (see the comment above). Lazily loaded
|
|
732
|
+
// the same way Gateway Tools is (see loadActauthTab/actauthLoadedFor):
|
|
733
|
+
// not because reading it is expensive here (it's a plain file read,
|
|
734
|
+
// unlike describeGatewayTools' live reconnects), just to avoid a
|
|
735
|
+
// redundant fetch on every agent switch for a tab that isn't always
|
|
736
|
+
// opened. ----
|
|
737
|
+
|
|
738
|
+
function renderActauthTabPlaceholder() {
|
|
739
|
+
return '<section id="actauthSection"><div id="actauthContent"><p class="hint">Loading…</p></div></section>';
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function renderActauthRuleRow(r) {
|
|
743
|
+
var actionsHtml = r.name
|
|
744
|
+
? '<button type="button" class="edit-rule-btn" data-name="' + escapeHtml(r.name) + '">Edit</button> ' +
|
|
745
|
+
'<button type="button" class="delete-btn delete-rule-btn" data-name="' + escapeHtml(r.name) + '" title="Remove this rule" aria-label="Remove this rule">Delete</button>'
|
|
746
|
+
// A rule with no "name" in the YAML has nothing this tab's
|
|
747
|
+
// update/removeActauthRule (both keyed by name) can address — left
|
|
748
|
+
// visible, but not editable here.
|
|
749
|
+
: '<span class="hint">unnamed — edit actauth.yml directly</span>';
|
|
750
|
+
return '<tr>' +
|
|
751
|
+
'<td><code>' + escapeHtml(r.name || '') + '</code></td>' +
|
|
752
|
+
'<td><code>' + escapeHtml(r.scope) + '</code></td>' +
|
|
753
|
+
'<td><code>' + escapeHtml(r.tool) + '</code></td>' +
|
|
754
|
+
'<td class="' + decisionClass(r.decision) + '">' + escapeHtml(r.decision) + '</td>' +
|
|
755
|
+
'<td>' + actionsHtml + '</td>' +
|
|
756
|
+
'</tr>';
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function renderActauthConfigHtml(cfg) {
|
|
760
|
+
var rulesHtml = cfg.rules.length
|
|
761
|
+
? '<table><thead><tr><th>Name</th><th>Scope</th><th>Tool</th><th>Decision</th><th></th></tr></thead><tbody>' +
|
|
762
|
+
cfg.rules.map(renderActauthRuleRow).join('') + '</tbody></table>'
|
|
763
|
+
: '<p class="hint">No explicit rules — every tool call falls through to the default decision.</p>';
|
|
764
|
+
|
|
765
|
+
return '<section><h3>Default decision</h3>' +
|
|
766
|
+
'<form class="add-source" id="defaultDecisionForm" style="max-width:220px">' +
|
|
767
|
+
'<label>Applies when no rule matches' +
|
|
768
|
+
'<select name="defaultDecision" id="defaultDecisionSelect">' +
|
|
769
|
+
'<option value="allow"' + (cfg.defaultDecision === 'allow' ? ' selected' : '') + '>allow</option>' +
|
|
770
|
+
'<option value="ask"' + (cfg.defaultDecision === 'ask' ? ' selected' : '') + '>ask</option>' +
|
|
771
|
+
'<option value="deny"' + (cfg.defaultDecision === 'deny' ? ' selected' : '') + '>deny</option>' +
|
|
772
|
+
'</select>' +
|
|
773
|
+
'</label>' +
|
|
774
|
+
'<button type="submit">Save</button>' +
|
|
775
|
+
'<div id="defaultDecisionError" class="error"></div>' +
|
|
776
|
+
'</form></section>' +
|
|
777
|
+
'<section><h3>Rules (' + cfg.rules.length + ')</h3>' + rulesHtml + '</section>' +
|
|
778
|
+
'<section><h3 id="ruleFormHeading">Add a rule</h3>' +
|
|
779
|
+
'<form class="add-source" id="ruleForm">' +
|
|
780
|
+
'<label>Name' +
|
|
781
|
+
'<input name="name" id="ruleNameInput" required placeholder="my-rule-name">' +
|
|
782
|
+
'</label>' +
|
|
783
|
+
'<label>Scope <span class="hint">(tenant/environment — e.g. "default/production" or "*/*"; the agent segment is appended automatically)</span>' +
|
|
784
|
+
'<input name="scope" id="ruleScopeInput" required placeholder="default/production">' +
|
|
785
|
+
'</label>' +
|
|
786
|
+
'<label>Tool' +
|
|
787
|
+
'<input name="tool" id="ruleToolInput" required placeholder="write_file">' +
|
|
788
|
+
'</label>' +
|
|
789
|
+
'<label>Decision' +
|
|
790
|
+
'<select name="decision" id="ruleDecisionInput">' +
|
|
791
|
+
'<option value="allow">allow</option>' +
|
|
792
|
+
'<option value="ask" selected>ask</option>' +
|
|
793
|
+
'<option value="deny">deny</option>' +
|
|
794
|
+
'</select>' +
|
|
795
|
+
'</label>' +
|
|
796
|
+
'<button type="submit" id="ruleSubmitBtn">Add</button>' +
|
|
797
|
+
'<button type="button" id="ruleCancelBtn" style="display:none">Cancel</button>' +
|
|
798
|
+
'<div id="ruleFormError" class="error"></div>' +
|
|
799
|
+
'</form></section>';
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function actauthContentEl() {
|
|
803
|
+
return detail.querySelector('#actauthContent');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function wireActauthHandlers(name, cfg) {
|
|
807
|
+
var content = actauthContentEl();
|
|
808
|
+
if (!content) return;
|
|
809
|
+
var editingRuleName = null;
|
|
810
|
+
|
|
811
|
+
function ruleByName(ruleName) {
|
|
812
|
+
for (var i = 0; i < cfg.rules.length; i++) {
|
|
813
|
+
if (cfg.rules[i].name === ruleName) return cfg.rules[i];
|
|
814
|
+
}
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function resetRuleForm() {
|
|
819
|
+
var form = content.querySelector('#ruleForm');
|
|
820
|
+
form.reset();
|
|
821
|
+
content.querySelector('#ruleNameInput').disabled = false;
|
|
822
|
+
content.querySelector('#ruleFormHeading').textContent = 'Add a rule';
|
|
823
|
+
content.querySelector('#ruleSubmitBtn').textContent = 'Add';
|
|
824
|
+
content.querySelector('#ruleCancelBtn').style.display = 'none';
|
|
825
|
+
content.querySelector('#ruleFormError').textContent = '';
|
|
826
|
+
editingRuleName = null;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
var deleteButtons = content.querySelectorAll('.delete-rule-btn');
|
|
830
|
+
for (var i = 0; i < deleteButtons.length; i++) {
|
|
831
|
+
deleteButtons[i].addEventListener('click', function (ev) {
|
|
832
|
+
var btn = ev.currentTarget;
|
|
833
|
+
var ruleName = btn.getAttribute('data-name');
|
|
834
|
+
if (!confirm('Remove rule "' + ruleName + '"?')) return;
|
|
835
|
+
btn.disabled = true;
|
|
836
|
+
fetch('/agents/' + encodeURIComponent(name) + '/actauth/rules/' + encodeURIComponent(ruleName), { method: 'DELETE' })
|
|
837
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
838
|
+
.then(function (result) {
|
|
839
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
840
|
+
applyActauthConfig(name, result.body);
|
|
841
|
+
refreshOverviewPanel(name);
|
|
842
|
+
})
|
|
843
|
+
.catch(function (err) {
|
|
844
|
+
alert('Could not remove: ' + err.message);
|
|
845
|
+
btn.disabled = false;
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
var editButtons = content.querySelectorAll('.edit-rule-btn');
|
|
851
|
+
for (var i = 0; i < editButtons.length; i++) {
|
|
852
|
+
editButtons[i].addEventListener('click', function (ev) {
|
|
853
|
+
var ruleName = ev.currentTarget.getAttribute('data-name');
|
|
854
|
+
var rule = ruleByName(ruleName);
|
|
855
|
+
if (!rule) return;
|
|
856
|
+
content.querySelector('#ruleNameInput').value = rule.name;
|
|
857
|
+
content.querySelector('#ruleNameInput').disabled = true;
|
|
858
|
+
content.querySelector('#ruleScopeInput').value = rule.scope;
|
|
859
|
+
content.querySelector('#ruleToolInput').value = rule.tool;
|
|
860
|
+
content.querySelector('#ruleDecisionInput').value = rule.decision;
|
|
861
|
+
content.querySelector('#ruleFormHeading').textContent = 'Edit rule "' + rule.name + '"';
|
|
862
|
+
content.querySelector('#ruleSubmitBtn').textContent = 'Save';
|
|
863
|
+
content.querySelector('#ruleCancelBtn').style.display = '';
|
|
864
|
+
content.querySelector('#ruleFormError').textContent = '';
|
|
865
|
+
editingRuleName = rule.name;
|
|
866
|
+
content.querySelector('#ruleForm').scrollIntoView({ block: 'nearest' });
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
content.querySelector('#ruleCancelBtn').addEventListener('click', resetRuleForm);
|
|
871
|
+
|
|
872
|
+
var ruleForm = content.querySelector('#ruleForm');
|
|
873
|
+
ruleForm.addEventListener('submit', function (ev) {
|
|
874
|
+
ev.preventDefault();
|
|
875
|
+
var errorEl = content.querySelector('#ruleFormError');
|
|
876
|
+
errorEl.textContent = '';
|
|
877
|
+
var data = new FormData(ruleForm);
|
|
878
|
+
var scope = data.get('scope');
|
|
879
|
+
var tool = data.get('tool');
|
|
880
|
+
var decision = data.get('decision');
|
|
881
|
+
var submitBtn = content.querySelector('#ruleSubmitBtn');
|
|
882
|
+
submitBtn.disabled = true;
|
|
883
|
+
|
|
884
|
+
var request = editingRuleName
|
|
885
|
+
? fetch('/agents/' + encodeURIComponent(name) + '/actauth/rules/' + encodeURIComponent(editingRuleName), {
|
|
886
|
+
method: 'PUT',
|
|
887
|
+
headers: { 'content-type': 'application/json' },
|
|
888
|
+
body: JSON.stringify({ scope: scope, tool: tool, decision: decision }),
|
|
889
|
+
})
|
|
890
|
+
: fetch('/agents/' + encodeURIComponent(name) + '/actauth/rules', {
|
|
891
|
+
method: 'POST',
|
|
892
|
+
headers: { 'content-type': 'application/json' },
|
|
893
|
+
body: JSON.stringify({ name: data.get('name'), scope: scope, tool: tool, decision: decision }),
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
request
|
|
897
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
898
|
+
.then(function (result) {
|
|
899
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
900
|
+
applyActauthConfig(name, result.body);
|
|
901
|
+
refreshOverviewPanel(name);
|
|
902
|
+
})
|
|
903
|
+
.catch(function (err) {
|
|
904
|
+
errorEl.textContent = err.message;
|
|
905
|
+
submitBtn.disabled = false;
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
var defaultForm = content.querySelector('#defaultDecisionForm');
|
|
910
|
+
defaultForm.addEventListener('submit', function (ev) {
|
|
911
|
+
ev.preventDefault();
|
|
912
|
+
var errorEl = content.querySelector('#defaultDecisionError');
|
|
913
|
+
errorEl.textContent = '';
|
|
914
|
+
var decision = content.querySelector('#defaultDecisionSelect').value;
|
|
915
|
+
fetch('/agents/' + encodeURIComponent(name) + '/actauth/default-decision', {
|
|
916
|
+
method: 'PUT',
|
|
917
|
+
headers: { 'content-type': 'application/json' },
|
|
918
|
+
body: JSON.stringify({ decision: decision }),
|
|
919
|
+
})
|
|
920
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
921
|
+
.then(function (result) {
|
|
922
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
923
|
+
applyActauthConfig(name, result.body);
|
|
924
|
+
refreshOverviewPanel(name);
|
|
925
|
+
})
|
|
926
|
+
.catch(function (err) {
|
|
927
|
+
errorEl.textContent = err.message;
|
|
928
|
+
});
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function applyActauthConfig(name, cfg) {
|
|
933
|
+
var content = actauthContentEl();
|
|
934
|
+
if (!content) return;
|
|
935
|
+
content.innerHTML = renderActauthConfigHtml(cfg);
|
|
936
|
+
wireActauthHandlers(name, cfg);
|
|
937
|
+
actauthLoadedFor = name;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function loadActauthTab(name) {
|
|
941
|
+
var content = actauthContentEl();
|
|
942
|
+
if (!content) return;
|
|
943
|
+
content.innerHTML = '<p class="hint">Loading…</p>';
|
|
944
|
+
fetch('/agents/' + encodeURIComponent(name) + '/actauth')
|
|
945
|
+
.then(function (r) { return r.json(); })
|
|
946
|
+
.then(function (cfg) {
|
|
947
|
+
if (currentName !== name) return;
|
|
948
|
+
applyActauthConfig(name, cfg);
|
|
949
|
+
})
|
|
950
|
+
.catch(function (err) {
|
|
951
|
+
if (currentName !== name) return;
|
|
952
|
+
content.innerHTML = '<p class="error">Could not load actauth rules: ' + escapeHtml(err.message) + '</p>';
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// ---- Skills tab: editable — add/edit/delete SKILL.md files via
|
|
957
|
+
// skills-admin.ts, restricted (see that file's own doc comment) to
|
|
958
|
+
// flat, non-nested skill ids. Unlike the Tools tab's Gateway Tools
|
|
959
|
+
// section, this doesn't need its own lazy-loaded endpoint — cfg.skills
|
|
960
|
+
// (SkillGarden's own index: name + description, no body) is already
|
|
961
|
+
// free from the same /config fetch that opened the agent; only Edit
|
|
962
|
+
// needs a further per-skill GET, to pull the body that index doesn't
|
|
963
|
+
// carry. ----
|
|
964
|
+
|
|
965
|
+
function renderSkillCard(s) {
|
|
966
|
+
return '<div class="source">' +
|
|
967
|
+
'<div class="source-head">' +
|
|
968
|
+
'<h4>' + escapeHtml(s.name) + '</h4>' +
|
|
969
|
+
'<span>' +
|
|
970
|
+
'<button type="button" class="edit-skill-btn" data-id="' + escapeHtml(s.name) + '">Edit</button> ' +
|
|
971
|
+
'<button type="button" class="delete-btn delete-skill-btn" data-id="' + escapeHtml(s.name) + '" title="Delete this skill" aria-label="Delete this skill">Delete</button>' +
|
|
972
|
+
'</span>' +
|
|
973
|
+
'</div>' +
|
|
974
|
+
'<p class="hint">' + escapeHtml(s.description) + '</p>' +
|
|
975
|
+
'</div>';
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function renderSkillsTabHtml(cfg) {
|
|
979
|
+
var listHtml = cfg.skills.length ? cfg.skills.map(renderSkillCard).join('') : '<p class="hint">No skills yet.</p>';
|
|
980
|
+
return '<section><h3>Skills dirs</h3><dl class="kv">' +
|
|
981
|
+
'<dt>skillsDirs</dt><dd>' + escapeHtml(cfg.skillsDirs.join(', ') || '(none)') + '</dd>' +
|
|
982
|
+
'<dt>skillIndexBudgetTokens</dt><dd>' + escapeHtml(cfg.skillIndexBudgetTokens) + '</dd>' +
|
|
983
|
+
'</dl></section>' +
|
|
984
|
+
'<section><h3>Skills (' + cfg.skills.length + ')</h3><div id="skillList">' + listHtml + '</div></section>' +
|
|
985
|
+
'<section><h3 id="skillFormHeading">Add a skill</h3>' +
|
|
986
|
+
'<form class="add-source" id="skillForm">' +
|
|
987
|
+
'<label>Id <span class="hint">(lowercase, hyphen-separated — becomes the SKILL.md folder name; nested skills aren\\'t editable here)</span>' +
|
|
988
|
+
'<input name="id" id="skillIdInput" required pattern="[a-z0-9]+(-[a-z0-9]+)*" placeholder="summarize-files">' +
|
|
989
|
+
'</label>' +
|
|
990
|
+
'<label>Description <span class="hint">(what should make the agent reach for this? newlines are collapsed to spaces on save)</span>' +
|
|
991
|
+
'<textarea name="description" id="skillDescInput" required></textarea>' +
|
|
992
|
+
'</label>' +
|
|
993
|
+
'<div class="body-field">' +
|
|
994
|
+
'<div class="body-field-head">' +
|
|
995
|
+
'<span>Body <span class="hint">(markdown)</span></span>' +
|
|
996
|
+
'<span class="md-toggle">' +
|
|
997
|
+
'<button type="button" class="md-toggle-btn active" id="skillBodyWriteBtn">Write</button>' +
|
|
998
|
+
'<button type="button" class="md-toggle-btn" id="skillBodyPreviewBtn">Preview</button>' +
|
|
999
|
+
'</span>' +
|
|
1000
|
+
'</div>' +
|
|
1001
|
+
'<textarea name="body" id="skillBodyInput" required></textarea>' +
|
|
1002
|
+
'<div class="markdown-body" id="skillBodyPreview" style="display:none"></div>' +
|
|
1003
|
+
'</div>' +
|
|
1004
|
+
'<button type="submit" id="skillSubmitBtn">Add</button>' +
|
|
1005
|
+
'<button type="button" id="skillCancelBtn" style="display:none">Cancel</button>' +
|
|
1006
|
+
'<div id="skillFormError" class="error"></div>' +
|
|
1007
|
+
'</form></section>';
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function skillsPanelEl() {
|
|
1011
|
+
return detail.querySelector('[data-tab-panel="skills"]');
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function wireSkillsHandlers(name) {
|
|
1015
|
+
var panel = skillsPanelEl();
|
|
1016
|
+
if (!panel) return;
|
|
1017
|
+
var editingSkillId = null;
|
|
1018
|
+
|
|
1019
|
+
// Write/Preview toggle for the Body field — same idea as GitHub's own
|
|
1020
|
+
// issue/PR editor: the textarea and the rendered preview show/hide
|
|
1021
|
+
// each other, the textarea's value is the only source of truth (the
|
|
1022
|
+
// preview is just a render of it on demand, not a second place edits
|
|
1023
|
+
// could get made), so switching back to Write can never lose anything
|
|
1024
|
+
// typed before switching to Preview.
|
|
1025
|
+
var bodyTextarea = panel.querySelector('#skillBodyInput');
|
|
1026
|
+
var bodyPreview = panel.querySelector('#skillBodyPreview');
|
|
1027
|
+
var bodyWriteBtn = panel.querySelector('#skillBodyWriteBtn');
|
|
1028
|
+
var bodyPreviewBtn = panel.querySelector('#skillBodyPreviewBtn');
|
|
1029
|
+
|
|
1030
|
+
function showBodyWrite() {
|
|
1031
|
+
bodyTextarea.style.display = '';
|
|
1032
|
+
bodyPreview.style.display = 'none';
|
|
1033
|
+
bodyWriteBtn.classList.add('active');
|
|
1034
|
+
bodyPreviewBtn.classList.remove('active');
|
|
1035
|
+
}
|
|
1036
|
+
function showBodyPreview() {
|
|
1037
|
+
bodyPreview.innerHTML = renderMarkdownPreview(bodyTextarea.value);
|
|
1038
|
+
bodyTextarea.style.display = 'none';
|
|
1039
|
+
bodyPreview.style.display = 'block';
|
|
1040
|
+
bodyPreviewBtn.classList.add('active');
|
|
1041
|
+
bodyWriteBtn.classList.remove('active');
|
|
1042
|
+
}
|
|
1043
|
+
bodyWriteBtn.addEventListener('click', showBodyWrite);
|
|
1044
|
+
bodyPreviewBtn.addEventListener('click', showBodyPreview);
|
|
1045
|
+
|
|
1046
|
+
function resetSkillForm() {
|
|
1047
|
+
var form = panel.querySelector('#skillForm');
|
|
1048
|
+
form.reset();
|
|
1049
|
+
panel.querySelector('#skillIdInput').disabled = false;
|
|
1050
|
+
panel.querySelector('#skillFormHeading').textContent = 'Add a skill';
|
|
1051
|
+
panel.querySelector('#skillSubmitBtn').textContent = 'Add';
|
|
1052
|
+
panel.querySelector('#skillCancelBtn').style.display = 'none';
|
|
1053
|
+
panel.querySelector('#skillFormError').textContent = '';
|
|
1054
|
+
editingSkillId = null;
|
|
1055
|
+
showBodyWrite();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
var deleteButtons = panel.querySelectorAll('.delete-skill-btn');
|
|
1059
|
+
for (var i = 0; i < deleteButtons.length; i++) {
|
|
1060
|
+
deleteButtons[i].addEventListener('click', function (ev) {
|
|
1061
|
+
var btn = ev.currentTarget;
|
|
1062
|
+
var id = btn.getAttribute('data-id');
|
|
1063
|
+
if (!confirm('Delete skill "' + id + '"? This removes its SKILL.md.')) return;
|
|
1064
|
+
btn.disabled = true;
|
|
1065
|
+
fetch('/agents/' + encodeURIComponent(name) + '/skills/' + encodeURIComponent(id), { method: 'DELETE' })
|
|
1066
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1067
|
+
.then(function (result) {
|
|
1068
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1069
|
+
refreshSkillsDependentPanels(name);
|
|
1070
|
+
})
|
|
1071
|
+
.catch(function (err) {
|
|
1072
|
+
alert('Could not delete: ' + err.message);
|
|
1073
|
+
btn.disabled = false;
|
|
1074
|
+
});
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
var editButtons = panel.querySelectorAll('.edit-skill-btn');
|
|
1079
|
+
for (var i = 0; i < editButtons.length; i++) {
|
|
1080
|
+
editButtons[i].addEventListener('click', function (ev) {
|
|
1081
|
+
var id = ev.currentTarget.getAttribute('data-id');
|
|
1082
|
+
var errorEl = panel.querySelector('#skillFormError');
|
|
1083
|
+
errorEl.textContent = '';
|
|
1084
|
+
fetch('/agents/' + encodeURIComponent(name) + '/skills/' + encodeURIComponent(id))
|
|
1085
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1086
|
+
.then(function (result) {
|
|
1087
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1088
|
+
panel.querySelector('#skillIdInput').value = result.body.id;
|
|
1089
|
+
panel.querySelector('#skillIdInput').disabled = true;
|
|
1090
|
+
panel.querySelector('#skillDescInput').value = result.body.description;
|
|
1091
|
+
panel.querySelector('#skillBodyInput').value = result.body.body;
|
|
1092
|
+
panel.querySelector('#skillFormHeading').textContent = 'Edit skill "' + result.body.id + '"';
|
|
1093
|
+
panel.querySelector('#skillSubmitBtn').textContent = 'Save';
|
|
1094
|
+
panel.querySelector('#skillCancelBtn').style.display = '';
|
|
1095
|
+
editingSkillId = result.body.id;
|
|
1096
|
+
showBodyWrite();
|
|
1097
|
+
panel.querySelector('#skillForm').scrollIntoView({ block: 'nearest' });
|
|
1098
|
+
})
|
|
1099
|
+
.catch(function (err) {
|
|
1100
|
+
errorEl.textContent = 'Could not load skill: ' + err.message;
|
|
1101
|
+
});
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
panel.querySelector('#skillCancelBtn').addEventListener('click', resetSkillForm);
|
|
1106
|
+
|
|
1107
|
+
var form = panel.querySelector('#skillForm');
|
|
1108
|
+
form.addEventListener('submit', function (ev) {
|
|
1109
|
+
ev.preventDefault();
|
|
1110
|
+
var errorEl = panel.querySelector('#skillFormError');
|
|
1111
|
+
errorEl.textContent = '';
|
|
1112
|
+
var data = new FormData(form);
|
|
1113
|
+
// writeSkill (both routes go through the same PUT) creates-or-
|
|
1114
|
+
// replaces — so "Add" with an id that already exists just
|
|
1115
|
+
// overwrites it, same as editing it directly would.
|
|
1116
|
+
var id = editingSkillId || data.get('id');
|
|
1117
|
+
var submitBtn = panel.querySelector('#skillSubmitBtn');
|
|
1118
|
+
submitBtn.disabled = true;
|
|
1119
|
+
fetch('/agents/' + encodeURIComponent(name) + '/skills/' + encodeURIComponent(id), {
|
|
1120
|
+
method: 'PUT',
|
|
1121
|
+
headers: { 'content-type': 'application/json' },
|
|
1122
|
+
body: JSON.stringify({ description: data.get('description'), body: data.get('body') }),
|
|
1123
|
+
})
|
|
1124
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1125
|
+
.then(function (result) {
|
|
1126
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1127
|
+
refreshSkillsDependentPanels(name);
|
|
1128
|
+
})
|
|
1129
|
+
.catch(function (err) {
|
|
1130
|
+
errorEl.textContent = err.message;
|
|
1131
|
+
submitBtn.disabled = false;
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// ---- Tools tab: three sections, one per where a tool actually comes
|
|
1137
|
+
// from -- Local tools (agents/name/tools/), Agent as Tools (subagents,
|
|
1138
|
+
// via agentAsTool), and Gateway Tools (gateway-tools.ts's registry,
|
|
1139
|
+
// this section ported from the old standalone /agents/gateway-tools
|
|
1140
|
+
// page). Local tools/Agent as Tools render immediately from cfg --
|
|
1141
|
+
// already fetched to open the agent at all, no extra cost. Gateway
|
|
1142
|
+
// Tools alone loads lazily, only once this tab is opened (see
|
|
1143
|
+
// loadGatewayTab): unlike the other two, resolving it means actually
|
|
1144
|
+
// connecting to each registered source (see gateway-tools.ts's
|
|
1145
|
+
// describeGatewayTools), a real cost not worth paying up front. ----
|
|
1146
|
+
|
|
1147
|
+
function renderToolsTabHtml(cfg) {
|
|
1148
|
+
return '<section><h3>Local tools (' + cfg.localTools.length + ')</h3>' + renderTools(cfg.localTools) + '</section>' +
|
|
1149
|
+
'<section id="gatewayToolsSection">' +
|
|
1150
|
+
'<h3>Gateway Tools <button type="button" id="gatewayToolsRefreshBtn" class="hint-btn">Refresh</button></h3>' +
|
|
1151
|
+
'<div id="gatewayToolsContent"><p class="hint">Loading…</p></div>' +
|
|
1152
|
+
'</section>' +
|
|
1153
|
+
'<section><h3>Agent as Tools (' + cfg.agentAsTools.length + ')</h3>' + renderTools(cfg.agentAsTools) + '</section>';
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// Removing a source used to be one "Remove" button per source, at the
|
|
1157
|
+
// bottom of the card — dropping every tool it produces at once, with
|
|
1158
|
+
// no way to remove just one. Each slug now gets its own remove icon
|
|
1159
|
+
// instead, right where it's listed — a source disappears on its own,
|
|
1160
|
+
// via removeGatewayToolSlug, once its last slug is gone (see that
|
|
1161
|
+
// function's own doc comment).
|
|
1162
|
+
function renderGatewaySource(s) {
|
|
1163
|
+
var errorHtml = s.status === 'error' ? '<p class="error">' + escapeHtml(s.error) + '</p>' : '';
|
|
1164
|
+
var rowsHtml = !s.entry.slugs.length
|
|
1165
|
+
? '<p class="hint">No tools registered.</p>'
|
|
1166
|
+
: '<ul class="tool-list">' + s.entry.slugs.map(function (slug, i) {
|
|
1167
|
+
var tool = s.status === 'ok' ? s.tools[i] : null;
|
|
1168
|
+
var label = tool
|
|
1169
|
+
? '<code>' + escapeHtml(tool.name) + '</code> — ' + escapeHtml(tool.description)
|
|
1170
|
+
: '<code>' + escapeHtml(s.entry.name + '_' + slug) + '</code>';
|
|
1171
|
+
return '<li><span class="tool-picker-label">' + label + '</span>' +
|
|
1172
|
+
' <button type="button" class="delete-btn" data-source="' + escapeHtml(s.entry.name) + '" data-slug="' + escapeHtml(slug) + '" title="Remove this tool" aria-label="Remove this tool">Delete</button>' +
|
|
1173
|
+
'</li>';
|
|
1174
|
+
}).join('') + '</ul>';
|
|
1175
|
+
|
|
1176
|
+
return '<div class="source">' +
|
|
1177
|
+
'<div class="source-head">' +
|
|
1178
|
+
'<h4>' + escapeHtml(s.entry.name) + ' <span class="hint">(' + escapeHtml(s.entry.provider) + ')</span></h4>' +
|
|
1179
|
+
'<span class="' + (s.status === 'ok' ? 'status-ok' : 'status-error') + '">' + escapeHtml(s.status) + '</span>' +
|
|
1180
|
+
'</div>' +
|
|
1181
|
+
errorHtml +
|
|
1182
|
+
rowsHtml +
|
|
1183
|
+
'</div>';
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function renderGatewayHtml(sources) {
|
|
1187
|
+
var listHtml = sources.length
|
|
1188
|
+
? sources.map(renderGatewaySource).join('')
|
|
1189
|
+
: '<p class="hint">No gateway tools registered yet.</p>';
|
|
1190
|
+
|
|
1191
|
+
// App + tool picker instead of freeform slug entry — pulled from
|
|
1192
|
+
// GET /composio/connections and GET /composio/tools?toolkit=X (see
|
|
1193
|
+
// wireGatewayHandlers below), so adding a source means checking boxes
|
|
1194
|
+
// for what's already connected and available, not needing to already
|
|
1195
|
+
// know a toolkit's exact slug strings by heart.
|
|
1196
|
+
return '<section><div id="sourceList">' + listHtml + '</div></section>' +
|
|
1197
|
+
'<section><h3>Add a gateway tool</h3>' +
|
|
1198
|
+
'<form class="add-source" id="addForm">' +
|
|
1199
|
+
'<label>Provider' +
|
|
1200
|
+
'<select name="provider"><option value="composio">Composio</option></select>' +
|
|
1201
|
+
'</label>' +
|
|
1202
|
+
'<label>App <span class="hint">(already connected via composio link)</span>' +
|
|
1203
|
+
'<select name="toolkit" id="toolkitSelect" required><option value="">Loading apps…</option></select>' +
|
|
1204
|
+
'</label>' +
|
|
1205
|
+
'<label>Name <span class="hint">(namespaces every tool it produces, e.g. "gh")</span>' +
|
|
1206
|
+
'<input name="name" required pattern="[a-z0-9_]+" placeholder="gh">' +
|
|
1207
|
+
'</label>' +
|
|
1208
|
+
'<label>Tools' +
|
|
1209
|
+
'<input type="text" id="toolFilter" placeholder="Filter by name or slug…">' +
|
|
1210
|
+
'<div class="tool-picker-controls">' +
|
|
1211
|
+
'<button type="button" id="selectAllToolsBtn" disabled>Select all</button>' +
|
|
1212
|
+
'<span class="hint" id="selectedCount"></span>' +
|
|
1213
|
+
'</div>' +
|
|
1214
|
+
'<div class="tool-picker-list" id="toolPickerList"><p class="hint">Pick an app above first.</p></div>' +
|
|
1215
|
+
'</label>' +
|
|
1216
|
+
'<label>Grant permission <span class="hint">(optional — leave blank to leave every new tool at actauth\\'s own default, typically deny)</span>' +
|
|
1217
|
+
'<select name="decision">' +
|
|
1218
|
+
'<option value="">(leave unset)</option>' +
|
|
1219
|
+
'<option value="auto" selected>auto — allow read-only tools, ask for the rest</option>' +
|
|
1220
|
+
'<option value="allow">allow</option>' +
|
|
1221
|
+
'<option value="ask">ask</option>' +
|
|
1222
|
+
'<option value="deny">deny</option>' +
|
|
1223
|
+
'</select>' +
|
|
1224
|
+
'</label>' +
|
|
1225
|
+
'<button type="submit" id="addSubmitBtn" disabled>Add</button>' +
|
|
1226
|
+
'<div id="addError" class="error"></div>' +
|
|
1227
|
+
'</form></section>';
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function gatewayContentEl() {
|
|
1231
|
+
return detail.querySelector('#gatewayToolsContent');
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function wireGatewayHandlers(name) {
|
|
1235
|
+
var content = gatewayContentEl();
|
|
1236
|
+
var removeToolButtons = content.querySelectorAll('.delete-btn');
|
|
1237
|
+
for (var i = 0; i < removeToolButtons.length; i++) {
|
|
1238
|
+
removeToolButtons[i].addEventListener('click', function (ev) {
|
|
1239
|
+
var btn = ev.currentTarget;
|
|
1240
|
+
var sourceName = btn.getAttribute('data-source');
|
|
1241
|
+
var slug = btn.getAttribute('data-slug');
|
|
1242
|
+
btn.disabled = true;
|
|
1243
|
+
fetch('/agents/' + encodeURIComponent(name) + '/gateway-tools/' + encodeURIComponent(sourceName) + '/' + encodeURIComponent(slug), { method: 'DELETE' })
|
|
1244
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1245
|
+
.then(function (result) {
|
|
1246
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1247
|
+
// Same shortcut as the add-form's own success handler — the
|
|
1248
|
+
// DELETE response already carries the freshly-resolved
|
|
1249
|
+
// sources, so applying it directly skips a second,
|
|
1250
|
+
// redundant live-reconnect round trip for the same data.
|
|
1251
|
+
applyGatewaySources(name, result.body.sources || []);
|
|
1252
|
+
// Removing a tool also drops its auto-seeded actauth rule
|
|
1253
|
+
// (see removeGatewayToolSlug) — keep Overview/Actauth in
|
|
1254
|
+
// sync with that.
|
|
1255
|
+
refreshActauthDependentPanels(name);
|
|
1256
|
+
})
|
|
1257
|
+
.catch(function (err) {
|
|
1258
|
+
alert('Could not remove: ' + err.message);
|
|
1259
|
+
btn.disabled = false;
|
|
1260
|
+
});
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
var form = content.querySelector('#addForm');
|
|
1265
|
+
var toolkitSelect = content.querySelector('#toolkitSelect');
|
|
1266
|
+
var nameInput = form.querySelector('input[name="name"]');
|
|
1267
|
+
var toolFilter = content.querySelector('#toolFilter');
|
|
1268
|
+
var toolPickerList = content.querySelector('#toolPickerList');
|
|
1269
|
+
var selectAllBtn = content.querySelector('#selectAllToolsBtn');
|
|
1270
|
+
var selectedCountEl = content.querySelector('#selectedCount');
|
|
1271
|
+
var submitBtn = content.querySelector('#addSubmitBtn');
|
|
1272
|
+
var currentTools = [];
|
|
1273
|
+
// Tracks whichever toolkit slug the Name field was last auto-filled
|
|
1274
|
+
// with, so switching apps updates an untouched Name along with it —
|
|
1275
|
+
// but the moment a person types their own value in, it stops
|
|
1276
|
+
// clobbering that on the next app switch.
|
|
1277
|
+
var nameAutoFilledAs = '';
|
|
1278
|
+
|
|
1279
|
+
function updateSelectedCount() {
|
|
1280
|
+
var boxes = toolPickerList.querySelectorAll('input[name="slugs"]');
|
|
1281
|
+
var checked = toolPickerList.querySelectorAll('input[name="slugs"]:checked').length;
|
|
1282
|
+
selectedCountEl.textContent = checked ? checked + ' of ' + boxes.length + ' selected' : '';
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function renderToolPicker(tools, filterText) {
|
|
1286
|
+
var q = (filterText || '').toLowerCase();
|
|
1287
|
+
var filtered = !q
|
|
1288
|
+
? tools
|
|
1289
|
+
: tools.filter(function (t) {
|
|
1290
|
+
return t.slug.toLowerCase().indexOf(q) !== -1 || t.name.toLowerCase().indexOf(q) !== -1 || t.description.toLowerCase().indexOf(q) !== -1;
|
|
1291
|
+
});
|
|
1292
|
+
if (!filtered.length) {
|
|
1293
|
+
toolPickerList.innerHTML = '<p class="hint">No matching tools.</p>';
|
|
1294
|
+
updateSelectedCount();
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
toolPickerList.innerHTML = filtered.map(function (t) {
|
|
1298
|
+
return '<label class="tool-picker-item">' +
|
|
1299
|
+
'<span class="tool-picker-label"><strong>' + escapeHtml(t.name) + '</strong> <span class="hint">' + escapeHtml(t.slug) + '</span>' +
|
|
1300
|
+
'<br><span class="hint">' + escapeHtml(t.description) + '</span></span>' +
|
|
1301
|
+
'<input type="checkbox" name="slugs" value="' + escapeHtml(t.slug) + '">' +
|
|
1302
|
+
'</label>';
|
|
1303
|
+
}).join('');
|
|
1304
|
+
var boxes = toolPickerList.querySelectorAll('input[name="slugs"]');
|
|
1305
|
+
for (var i = 0; i < boxes.length; i++) {
|
|
1306
|
+
boxes[i].addEventListener('change', updateSelectedCount);
|
|
1307
|
+
}
|
|
1308
|
+
updateSelectedCount();
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Acts only on the currently-filtered/visible tools, not every tool
|
|
1312
|
+
// the toolkit has — filtering to "issue" and hitting Select all is
|
|
1313
|
+
// meant to select the issue-related tools shown, not silently pull
|
|
1314
|
+
// in everything else the toolkit offers too. Toggles: if everything
|
|
1315
|
+
// visible is already checked, this unchecks it instead.
|
|
1316
|
+
selectAllBtn.addEventListener('click', function () {
|
|
1317
|
+
var boxes = toolPickerList.querySelectorAll('input[name="slugs"]');
|
|
1318
|
+
if (!boxes.length) return;
|
|
1319
|
+
var allChecked = Array.prototype.every.call(boxes, function (b) { return b.checked; });
|
|
1320
|
+
for (var i = 0; i < boxes.length; i++) boxes[i].checked = !allChecked;
|
|
1321
|
+
updateSelectedCount();
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
function loadToolsForToolkit(toolkit) {
|
|
1325
|
+
toolPickerList.innerHTML = '<p class="hint">Loading tools…</p>';
|
|
1326
|
+
submitBtn.disabled = true;
|
|
1327
|
+
selectAllBtn.disabled = true;
|
|
1328
|
+
fetch('/composio/tools?toolkit=' + encodeURIComponent(toolkit))
|
|
1329
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1330
|
+
.then(function (result) {
|
|
1331
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1332
|
+
currentTools = result.body.tools || [];
|
|
1333
|
+
renderToolPicker(currentTools, toolFilter.value);
|
|
1334
|
+
submitBtn.disabled = false;
|
|
1335
|
+
selectAllBtn.disabled = false;
|
|
1336
|
+
})
|
|
1337
|
+
.catch(function (err) {
|
|
1338
|
+
toolPickerList.innerHTML = '<p class="error">Could not load tools: ' + escapeHtml(err.message) + '</p>';
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
fetch('/composio/connections')
|
|
1343
|
+
.then(function (r) { return r.json(); })
|
|
1344
|
+
.then(function (data) {
|
|
1345
|
+
var active = (data.connections || []).filter(function (c) { return c.status === 'ACTIVE'; });
|
|
1346
|
+
var toolkits = [];
|
|
1347
|
+
for (var i = 0; i < active.length; i++) {
|
|
1348
|
+
if (toolkits.indexOf(active[i].toolkit) === -1) toolkits.push(active[i].toolkit);
|
|
1349
|
+
}
|
|
1350
|
+
if (!toolkits.length) {
|
|
1351
|
+
toolkitSelect.innerHTML = '<option value="">No connected apps</option>';
|
|
1352
|
+
toolPickerList.innerHTML = '<p class="hint">Run "composio link <toolkit>" on the machine running this server, then reopen this tab.</p>';
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
toolkitSelect.innerHTML = '<option value="">Choose an app…</option>' +
|
|
1356
|
+
toolkits.map(function (t) { return '<option value="' + escapeHtml(t) + '">' + escapeHtml(t) + '</option>'; }).join('');
|
|
1357
|
+
})
|
|
1358
|
+
.catch(function (err) {
|
|
1359
|
+
toolkitSelect.innerHTML = '<option value="">Could not load apps</option>';
|
|
1360
|
+
toolPickerList.innerHTML = '<p class="error">Could not load connected apps: ' + escapeHtml(err.message) + '</p>';
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
toolkitSelect.addEventListener('change', function () {
|
|
1364
|
+
var toolkit = toolkitSelect.value;
|
|
1365
|
+
if (!toolkit) {
|
|
1366
|
+
toolPickerList.innerHTML = '<p class="hint">Pick an app above first.</p>';
|
|
1367
|
+
submitBtn.disabled = true;
|
|
1368
|
+
selectAllBtn.disabled = true;
|
|
1369
|
+
selectedCountEl.textContent = '';
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
if (!nameInput.value || nameInput.value === nameAutoFilledAs) {
|
|
1373
|
+
nameInput.value = toolkit;
|
|
1374
|
+
nameAutoFilledAs = toolkit;
|
|
1375
|
+
}
|
|
1376
|
+
loadToolsForToolkit(toolkit);
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
toolFilter.addEventListener('input', function () {
|
|
1380
|
+
renderToolPicker(currentTools, toolFilter.value);
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
form.addEventListener('submit', function (ev) {
|
|
1384
|
+
ev.preventDefault();
|
|
1385
|
+
var addError = content.querySelector('#addError');
|
|
1386
|
+
addError.textContent = '';
|
|
1387
|
+
var checked = form.querySelectorAll('input[name="slugs"]:checked');
|
|
1388
|
+
var slugs = Array.prototype.map.call(checked, function (cb) { return cb.value; });
|
|
1389
|
+
if (!slugs.length) {
|
|
1390
|
+
addError.textContent = 'Pick at least one tool.';
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
var data = new FormData(form);
|
|
1394
|
+
var body = { provider: data.get('provider'), name: data.get('name'), slugs: slugs };
|
|
1395
|
+
var decision = data.get('decision');
|
|
1396
|
+
if (decision) body.decision = decision;
|
|
1397
|
+
|
|
1398
|
+
// Immediate feedback the instant Add is clicked — without this,
|
|
1399
|
+
// the button just sits there through the whole POST (which itself
|
|
1400
|
+
// does a live connect to every registered source, not a quick
|
|
1401
|
+
// write — see gateway-tools.ts's describeGatewayTools), so nothing
|
|
1402
|
+
// visibly happens until it's already done.
|
|
1403
|
+
submitBtn.disabled = true;
|
|
1404
|
+
submitBtn.textContent = 'Adding…';
|
|
1405
|
+
|
|
1406
|
+
fetch('/agents/' + encodeURIComponent(name) + '/gateway-tools', {
|
|
1407
|
+
method: 'POST',
|
|
1408
|
+
headers: { 'content-type': 'application/json' },
|
|
1409
|
+
body: JSON.stringify(body),
|
|
1410
|
+
})
|
|
1411
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1412
|
+
.then(function (result) {
|
|
1413
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1414
|
+
// The POST response already carries the freshly-resolved
|
|
1415
|
+
// sources (handleToolSourcesPost returns exactly what
|
|
1416
|
+
// describeGatewayTools would) — applying it directly instead
|
|
1417
|
+
// of calling loadGatewayTab skips a second, fully-redundant
|
|
1418
|
+
// live-reconnect round trip for the exact same data.
|
|
1419
|
+
applyGatewaySources(name, result.body.sources || []);
|
|
1420
|
+
// A decision (if given) just seeded a new actauth rule — keep
|
|
1421
|
+
// Overview/Actauth in sync with that instead of showing it
|
|
1422
|
+
// only after the agent is re-selected.
|
|
1423
|
+
refreshActauthDependentPanels(name);
|
|
1424
|
+
})
|
|
1425
|
+
.catch(function (err) {
|
|
1426
|
+
addError.textContent = err.message;
|
|
1427
|
+
submitBtn.disabled = false;
|
|
1428
|
+
submitBtn.textContent = 'Add';
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function applyGatewaySources(name, sources) {
|
|
1434
|
+
var content = gatewayContentEl();
|
|
1435
|
+
if (!content) return;
|
|
1436
|
+
content.innerHTML = renderGatewayHtml(sources);
|
|
1437
|
+
wireGatewayHandlers(name);
|
|
1438
|
+
gatewayLoadedFor = name;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// force: true bypasses describeGatewayTools' own cache (see its own
|
|
1442
|
+
// doc comment) — the "Refresh" button's case, for when an operator
|
|
1443
|
+
// actually wants a live re-check (e.g. after reconnecting an account),
|
|
1444
|
+
// not the default tab-open case, which is happy with whatever a prior
|
|
1445
|
+
// load in this same gateway-tools.yml's lifetime already found.
|
|
1446
|
+
function loadGatewayTab(name, force) {
|
|
1447
|
+
var content = gatewayContentEl();
|
|
1448
|
+
if (!content) return;
|
|
1449
|
+
content.innerHTML = '<p class="hint">Loading…</p>';
|
|
1450
|
+
fetch('/agents/' + encodeURIComponent(name) + '/gateway-tools' + (force ? '?refresh=1' : ''))
|
|
1451
|
+
.then(function (r) { return r.json(); })
|
|
1452
|
+
.then(function (data) {
|
|
1453
|
+
if (currentName !== name) return;
|
|
1454
|
+
applyGatewaySources(name, data.sources || []);
|
|
1455
|
+
})
|
|
1456
|
+
.catch(function (err) {
|
|
1457
|
+
if (currentName !== name) return;
|
|
1458
|
+
content.innerHTML = '<p class="error">Could not load gateway tools: ' + escapeHtml(err.message) + '</p>';
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
// ---- Tab switching + agent selection ----
|
|
1463
|
+
|
|
1464
|
+
function switchTab(tab) {
|
|
1465
|
+
currentTab = tab;
|
|
1466
|
+
var buttons = detail.querySelectorAll('.tabs button');
|
|
1467
|
+
for (var i = 0; i < buttons.length; i++) {
|
|
1468
|
+
buttons[i].classList.toggle('active', buttons[i].dataset.tab === tab);
|
|
1469
|
+
}
|
|
1470
|
+
var panels = detail.querySelectorAll('.tab-panel');
|
|
1471
|
+
for (var i = 0; i < panels.length; i++) {
|
|
1472
|
+
panels[i].style.display = panels[i].dataset.tabPanel === tab ? 'block' : 'none';
|
|
1473
|
+
}
|
|
1474
|
+
if (tab === 'tools' && gatewayLoadedFor !== currentName) {
|
|
1475
|
+
loadGatewayTab(currentName);
|
|
1476
|
+
}
|
|
1477
|
+
if (tab === 'actauth' && actauthLoadedFor !== currentName) {
|
|
1478
|
+
loadActauthTab(currentName);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// Refreshes just Overview's own summary — used after anything that
|
|
1483
|
+
// changes tools/skills/permissions but already has its own tab
|
|
1484
|
+
// reflecting the change directly from the response (applyGatewaySources,
|
|
1485
|
+
// applyActauthConfig, refreshSkillsDependentPanels's own Skills half),
|
|
1486
|
+
// so only Overview is left needing a re-fetch.
|
|
1487
|
+
function refreshOverviewPanel(name) {
|
|
1488
|
+
fetch('/agents/' + encodeURIComponent(name) + '/config')
|
|
1489
|
+
.then(function (r) { return r.json(); })
|
|
1490
|
+
.then(function (cfg) {
|
|
1491
|
+
if (currentName !== name) return;
|
|
1492
|
+
currentCfg = cfg;
|
|
1493
|
+
var overviewPanel = detail.querySelector('[data-tab-panel="overview"]');
|
|
1494
|
+
if (overviewPanel) {
|
|
1495
|
+
overviewPanel.innerHTML = renderOverviewHtml(cfg);
|
|
1496
|
+
wireOverviewHandlers(name);
|
|
1497
|
+
}
|
|
1498
|
+
})
|
|
1499
|
+
.catch(function () {
|
|
1500
|
+
// Best-effort — the tab that triggered this already reflects the
|
|
1501
|
+
// change either way, this is only about keeping Overview in sync.
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
// Adding or removing a gateway tool changes actauth.yml (a decision
|
|
1506
|
+
// seeds/drops a rule for it — see gateway-tools.ts's addGatewayTool/
|
|
1507
|
+
// removeGatewayToolSlug) but Overview's own Actauth section and the
|
|
1508
|
+
// dedicated Actauth tab were both rendered once, from whatever cfg
|
|
1509
|
+
// /agents/:name/config (and, for the Actauth tab, GET .../actauth)
|
|
1510
|
+
// returned when the agent/tab was first opened — without this, a rule
|
|
1511
|
+
// added or removed from the Tools tab wouldn't show up there until the
|
|
1512
|
+
// agent was re-selected or the page reloaded. Only re-loads the
|
|
1513
|
+
// Actauth tab if it's actually been opened already (actauthLoadedFor)
|
|
1514
|
+
// — otherwise it'll load fresh, gateway-tool-seeded rules included,
|
|
1515
|
+
// the first time it is.
|
|
1516
|
+
function refreshActauthDependentPanels(name) {
|
|
1517
|
+
refreshOverviewPanel(name);
|
|
1518
|
+
if (actauthLoadedFor === name) loadActauthTab(name);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// Skills tab isn't lazily loaded like Gateway Tools/Actauth (see its
|
|
1522
|
+
// own header comment — it's already free from cfg, no live connection
|
|
1523
|
+
// needed) — so refreshing it after an add/edit/delete just means
|
|
1524
|
+
// re-rendering both it and Overview from a fresh /config fetch.
|
|
1525
|
+
function refreshSkillsDependentPanels(name) {
|
|
1526
|
+
fetch('/agents/' + encodeURIComponent(name) + '/config')
|
|
1527
|
+
.then(function (r) { return r.json(); })
|
|
1528
|
+
.then(function (cfg) {
|
|
1529
|
+
if (currentName !== name) return;
|
|
1530
|
+
currentCfg = cfg;
|
|
1531
|
+
var overviewPanel = detail.querySelector('[data-tab-panel="overview"]');
|
|
1532
|
+
var skillsPanel = skillsPanelEl();
|
|
1533
|
+
if (overviewPanel) {
|
|
1534
|
+
overviewPanel.innerHTML = renderOverviewHtml(cfg);
|
|
1535
|
+
wireOverviewHandlers(name);
|
|
1536
|
+
}
|
|
1537
|
+
if (skillsPanel) {
|
|
1538
|
+
skillsPanel.innerHTML = renderSkillsTabHtml(cfg);
|
|
1539
|
+
wireSkillsHandlers(name);
|
|
1540
|
+
}
|
|
1541
|
+
})
|
|
1542
|
+
.catch(function () {});
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function renderDetail(cfg) {
|
|
1546
|
+
currentCfg = cfg;
|
|
1547
|
+
detail.innerHTML =
|
|
1548
|
+
'<h2>' + escapeHtml(cfg.name) +
|
|
1549
|
+
' <a id="playgroundLink" href="/playground?agent=' + encodeURIComponent(cfg.name) + '">Open in playground →</a></h2>' +
|
|
1550
|
+
'<div class="tabs">' +
|
|
1551
|
+
'<button class="tab" data-tab="overview">Overview</button>' +
|
|
1552
|
+
'<button class="tab" data-tab="skills">Skills</button>' +
|
|
1553
|
+
'<button class="tab" data-tab="tools">Tools</button>' +
|
|
1554
|
+
'<button class="tab" data-tab="actauth">ActAuth</button>' +
|
|
1555
|
+
'</div>' +
|
|
1556
|
+
'<div class="tab-panel" data-tab-panel="overview">' + renderOverviewHtml(cfg) + '</div>' +
|
|
1557
|
+
'<div class="tab-panel" data-tab-panel="skills">' + renderSkillsTabHtml(cfg) + '</div>' +
|
|
1558
|
+
'<div class="tab-panel" data-tab-panel="tools">' + renderToolsTabHtml(cfg) + '</div>' +
|
|
1559
|
+
'<div class="tab-panel" data-tab-panel="actauth">' + renderActauthTabPlaceholder() + '</div>';
|
|
1560
|
+
|
|
1561
|
+
var buttons = detail.querySelectorAll('.tabs button');
|
|
1562
|
+
for (var i = 0; i < buttons.length; i++) {
|
|
1563
|
+
buttons[i].addEventListener('click', function (ev) { switchTab(ev.currentTarget.dataset.tab); });
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
wireSkillsHandlers(cfg.name);
|
|
1567
|
+
wireOverviewHandlers(cfg.name);
|
|
1568
|
+
var gatewayRefreshBtn = detail.querySelector('#gatewayToolsRefreshBtn');
|
|
1569
|
+
if (gatewayRefreshBtn) {
|
|
1570
|
+
gatewayRefreshBtn.addEventListener('click', function () { loadGatewayTab(cfg.name, true); });
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
empty.style.display = 'none';
|
|
1574
|
+
detail.style.display = 'block';
|
|
1575
|
+
switchTab(currentTab);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function selectAgent(name) {
|
|
1579
|
+
currentName = name;
|
|
1580
|
+
gatewayLoadedFor = null;
|
|
1581
|
+
actauthLoadedFor = null;
|
|
1582
|
+
var items = agentList.querySelectorAll('li');
|
|
1583
|
+
for (var i = 0; i < items.length; i++) {
|
|
1584
|
+
items[i].classList.toggle('active', items[i].dataset.name === name);
|
|
1585
|
+
}
|
|
1586
|
+
detail.innerHTML = '<p class="muted">Loading…</p>';
|
|
1587
|
+
empty.style.display = 'none';
|
|
1588
|
+
detail.style.display = 'block';
|
|
1589
|
+
fetch('/agents/' + encodeURIComponent(name) + '/config')
|
|
1590
|
+
.then(function (r) { return r.json(); })
|
|
1591
|
+
.then(function (cfg) {
|
|
1592
|
+
if (currentName !== name) return;
|
|
1593
|
+
renderDetail(cfg);
|
|
1594
|
+
})
|
|
1595
|
+
.catch(function (err) {
|
|
1596
|
+
if (currentName !== name) return;
|
|
1597
|
+
detail.innerHTML = '<p class="error">Could not load config: ' + escapeHtml(err.message) + '</p>';
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
fetch('/agents')
|
|
1602
|
+
.then(function (r) { return r.json(); })
|
|
1603
|
+
.then(function (data) {
|
|
1604
|
+
var agents = data.agents || [];
|
|
1605
|
+
agentList.textContent = '';
|
|
1606
|
+
for (var i = 0; i < agents.length; i++) {
|
|
1607
|
+
(function (name) {
|
|
1608
|
+
var li = document.createElement('li');
|
|
1609
|
+
li.textContent = name;
|
|
1610
|
+
li.dataset.name = name;
|
|
1611
|
+
li.addEventListener('click', function () { selectAgent(name); });
|
|
1612
|
+
agentList.appendChild(li);
|
|
1613
|
+
})(agents[i].name);
|
|
1614
|
+
}
|
|
1615
|
+
if (agents.length) {
|
|
1616
|
+
var requested = new URLSearchParams(location.search).get('agent');
|
|
1617
|
+
var initial = agents.some(function (a) { return a.name === requested; }) ? requested : agents[0].name;
|
|
1618
|
+
selectAgent(initial);
|
|
1619
|
+
}
|
|
1620
|
+
})
|
|
1621
|
+
.catch(function (err) {
|
|
1622
|
+
empty.textContent = 'Could not load agents: ' + err.message;
|
|
1623
|
+
});
|
|
1624
|
+
})();
|
|
1625
|
+
</script>
|
|
1626
|
+
</body>
|
|
1627
|
+
</html>
|
|
1628
|
+
`;
|