ScuttleBot

scuttlebot / internal / api / ui / index.html
1
<!DOCTYPE html>
2
<html lang="en">
3
<head>
4
<meta charset="UTF-8">
5
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
<title>scuttlebot</title>
7
<style>
8
* { box-sizing: border-box; margin: 0; padding: 0; }
9
body { font-family: ui-monospace,'Cascadia Code','Source Code Pro',monospace; background:#0d1117; color:#e6edf3; height:100vh; height:100dvh; display:flex; flex-direction:column; overflow:hidden; }
10
11
/* header */
12
header { background:#161b22; border-bottom:1px solid #30363d; padding:0 20px; display:flex; align-items:stretch; flex-shrink:0; height:48px; }
13
.brand { display:flex; align-items:center; gap:8px; padding-right:20px; border-right:1px solid #30363d; margin-right:4px; }
14
.brand h1 { font-size:14px; color:#58a6ff; letter-spacing:.05em; }
15
.brand span { font-size:11px; color:#8b949e; }
16
nav { display:flex; align-items:stretch; flex:1; }
17
.nav-tab { display:flex; align-items:center; gap:6px; padding:0 14px; font-size:13px; color:#8b949e; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .1s; white-space:nowrap; }
18
.nav-tab:hover { color:#c9d1d9; }
19
.nav-tab.active { color:#e6edf3; border-bottom-color:#58a6ff; }
20
.header-right { display:flex; align-items:center; gap:8px; margin-left:auto; font-size:12px; color:#8b949e; }
21
.header-right code { background:#21262d; border:1px solid #30363d; border-radius:4px; padding:2px 7px; color:#a5d6ff; max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
22
23
/* tab panes */
24
.tab-pane { display:none; flex:1; min-height:0; overflow-y:auto; }
25
.tab-pane.active { display:flex; flex-direction:column; }
26
.pane-scroll { flex:1; overflow-y:auto; }
27
.pane-inner { max-width:1000px; margin:0 auto; padding:24px; display:flex; flex-direction:column; gap:20px; }
28
29
/* cards */
30
.card { background:#161b22; border:1px solid #30363d; border-radius:8px; overflow:hidden; }
31
.card-header { padding:12px 16px; border-bottom:1px solid #30363d; display:flex; align-items:center; gap:8px; cursor:pointer; user-select:none; }
32
.card-header:hover { background:#1c2128; }
33
.card-header h2 { font-size:14px; font-weight:600; }
34
.card-header .card-desc { font-size:11px; color:#6e7681; font-weight:400; }
35
.card-header .collapse-icon { font-size:11px; color:#8b949e; margin-left:2px; transition:transform .15s; }
36
.card.collapsed .card-header { border-bottom:none; }
37
.card.collapsed .card-body { display:none; }
38
.card.collapsed .collapse-icon { transform:rotate(-90deg); }
39
.card-body { padding:16px; }
40
/* behavior config panel */
41
.beh-config { background:#0d1117; border-top:1px solid #21262d; padding:14px 16px 14px 42px; display:none; }
42
.beh-config.open { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
43
.beh-field label { display:block; font-size:11px; color:#8b949e; margin-bottom:3px; }
44
.beh-field input[type=text],.beh-field input[type=number],.beh-field select { width:100%; }
45
.beh-field .hint { font-size:10px; color:#6e7681; margin-top:2px; }
46
.spacer { flex:1; }
47
.badge { background:#1f6feb22; border:1px solid #1f6feb44; color:#58a6ff; border-radius:999px; padding:1px 8px; font-size:12px; white-space:nowrap; }
48
49
/* status */
50
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; }
51
.stat { background:#0d1117; border:1px solid #21262d; border-radius:6px; padding:12px 16px; }
52
.stat .lbl { font-size:11px; color:#8b949e; text-transform:uppercase; letter-spacing:.06em; margin-bottom:4px; }
53
.stat .val { font-size:20px; color:#58a6ff; font-weight:600; }
54
.stat .sub { font-size:11px; color:#8b949e; margin-top:2px; }
55
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:5px; }
56
.dot.green { background:#3fb950; box-shadow:0 0 6px #3fb950aa; }
57
.dot.red { background:#f85149; }
58
59
/* table */
60
table { width:100%; border-collapse:collapse; font-size:13px; }
61
th { text-align:left; padding:8px 12px; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; border-bottom:1px solid #21262d; white-space:nowrap; }
62
td { padding:9px 12px; border-bottom:1px solid #21262d; color:#e6edf3; vertical-align:middle; }
63
tr:last-child td { border-bottom:none; }
64
tr:hover td { background:#1c2128; }
65
66
/* tags */
67
.tag { display:inline-block; border-radius:4px; padding:1px 6px; font-size:11px; margin:1px; border:1px solid; }
68
.tag.ch { background:#1f6feb22; border-color:#1f6feb44; color:#79c0ff; }
69
.tag.perm{ background:#21262d; border-color:#30363d; color:#8b949e; }
70
.tag.type-operator { background:#db613622; border-color:#db613644; color:#ffa657; }
71
.tag.type-orchestrator { background:#8957e522; border-color:#8957e544; color:#d2a8ff; }
72
.tag.type-worker { background:#1f6feb22; border-color:#1f6feb44; color:#79c0ff; }
73
.tag.type-observer { background:#21262d; border-color:#30363d; color:#8b949e; }
74
.tag.revoked { background:#f8514922; border-color:#f8514944; color:#ff7b72; }
75
76
/* buttons */
77
button { cursor:pointer; border:1px solid #30363d; border-radius:6px; padding:6px 12px; font-size:13px; font-family:inherit; background:#21262d; color:#e6edf3; transition:background .1s; }
78
button:hover:not(:disabled) { background:#30363d; }
79
button:disabled { opacity:.5; cursor:default; }
80
button.primary { background:#1f6feb; border-color:#1f6feb; color:#fff; }
81
button.primary:hover:not(:disabled) { background:#388bfd; }
82
button.danger { border-color:#f85149; color:#f85149; }
83
button.danger:hover:not(:disabled) { background:#3d1f1e; }
84
button.sm { padding:3px 8px; font-size:12px; }
85
.actions { display:flex; gap:6px; }
86
87
/* forms */
88
form { display:flex; flex-direction:column; gap:14px; }
89
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
90
label { display:block; font-size:12px; color:#8b949e; margin-bottom:4px; }
91
input,select,textarea { width:100%; background:#0d1117; border:1px solid #30363d; border-radius:6px; padding:8px 10px; font-size:13px; font-family:inherit; color:#e6edf3; outline:none; transition:border-color .1s; }
92
input:focus,select:focus,textarea:focus { border-color:#58a6ff; }
93
select option { background:#161b22; }
94
.hint { font-size:11px; color:#8b949e; margin-top:3px; }
95
96
/* alerts */
97
.alert { border-radius:6px; padding:12px 14px; font-size:13px; display:flex; gap:10px; align-items:flex-start; }
98
.alert.info { background:#1f6feb1a; border:1px solid #1f6feb44; color:#79c0ff; }
99
.alert.error { background:#f851491a; border:1px solid #f8514944; color:#ff7b72; }
100
.alert.success { background:#3fb9501a; border:1px solid #3fb95044; color:#7ee787; }
101
.alert .icon { flex-shrink:0; font-size:15px; line-height:1.4; }
102
.cred-box { background:#0d1117; border:1px solid #30363d; border-radius:6px; padding:12px; font-size:12px; margin-top:10px; }
103
.cred-row { display:flex; align-items:baseline; gap:8px; margin-bottom:6px; }
104
.cred-row:last-child { margin-bottom:0; }
105
.cred-key { color:#8b949e; min-width:90px; }
106
.cred-val { color:#a5d6ff; word-break:break-all; flex:1; }
107
108
/* search/filter bar */
109
.filter-bar { display:flex; gap:8px; align-items:center; padding:10px 16px; border-bottom:1px solid #30363d; }
110
.filter-bar input { max-width:280px; padding:5px 10px; }
111
112
/* empty */
113
.empty { color:#8b949e; font-size:13px; text-align:center; padding:28px; }
114
115
/* drawer */
116
.drawer-overlay { display:none; position:fixed; inset:0; background:#0d111760; z-index:50; }
117
.drawer-overlay.open { display:block; }
118
.drawer { position:fixed; top:48px; right:0; bottom:0; width:480px; max-width:95vw; background:#161b22; border-left:1px solid #30363d; transform:translateX(100%); transition:transform .2s; z-index:51; display:flex; flex-direction:column; }
119
.drawer.open { transform:translateX(0); }
120
.drawer-header { padding:16px 20px; border-bottom:1px solid #30363d; display:flex; align-items:center; gap:8px; flex-shrink:0; }
121
.drawer-header h3 { font-size:14px; font-weight:600; flex:1; }
122
.drawer-body { flex:1; overflow-y:auto; padding:20px; }
123
124
/* chat */
125
#pane-chat { flex-direction:row; overflow:hidden; }
126
.chat-sidebar { width:180px; min-width:0; flex-shrink:0; border-right:1px solid #30363d; display:flex; flex-direction:column; background:#161b22; overflow:hidden; transition:width .15s; }
127
.chat-sidebar.collapsed { width:28px; }
128
.chat-sidebar.collapsed .chan-join,.chat-sidebar.collapsed .chan-list { display:none; }
129
.sidebar-head { padding:8px 12px; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; border-bottom:1px solid #30363d; flex-shrink:0; display:flex; align-items:center; gap:4px; }
130
.sidebar-toggle { margin-left:auto; background:none; border:none; color:#8b949e; cursor:pointer; font-size:14px; padding:0 2px; line-height:1; }
131
.sidebar-toggle:hover { color:#e6edf3; }
132
.sidebar-resize { width:4px; flex-shrink:0; cursor:col-resize; background:transparent; transition:background .1s; z-index:10; }
133
.sidebar-resize:hover,.sidebar-resize.dragging { background:#58a6ff55; }
134
.chan-join { display:flex; gap:5px; padding:7px 9px; border-bottom:1px solid #21262d; flex-shrink:0; }
135
.chan-join input { flex:1; padding:4px 7px; font-size:12px; }
136
.chan-list { flex:1; overflow-y:auto; }
137
.chan-item { padding:7px 14px; font-size:13px; cursor:pointer; color:#8b949e; border-bottom:1px solid #21262d; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
138
.chan-item:hover { background:#1c2128; color:#e6edf3; }
139
.chan-item.active { background:#1f6feb22; color:#58a6ff; border-left:2px solid #58a6ff; padding-left:12px; }
140
.chat-main { flex:1; display:flex; flex-direction:column; min-width:0; }
141
.chat-topbar { padding:9px 16px; border-bottom:1px solid #30363d; display:flex; align-items:center; gap:10px; flex-shrink:0; background:#161b22; font-size:13px; }
142
.chat-ch-name { font-weight:600; color:#58a6ff; }
143
.stream-badge { font-size:11px; color:#8b949e; margin-left:auto; }
144
.chat-msgs { flex:1; overflow-y:auto; padding:4px 8px; display:flex; flex-direction:column; gap:0; }
145
.msg-row { font-size:13px; line-height:1.4; padding:1px 0; }
146
.msg-time { color:#8b949e; font-size:11px; margin-right:6px; }
147
.chat-msgs.hide-timestamps .msg-time { visibility:hidden; width:0; min-width:0; margin:0; overflow:hidden; }
148
.chat-msgs.columnar.hide-timestamps .msg-time { visibility:hidden; min-width:36px; margin:0; }
149
.msg-nick { font-weight:600; margin-right:6px; }
150
.msg-grouped .msg-nick { display:none; }
151
.msg-grouped .msg-time { display:none; }
152
/* columnar layout mode */
153
.chat-msgs.columnar .msg-row { display:flex; gap:6px; }
154
.chat-msgs.columnar .msg-time { flex-shrink:0; min-width:36px; padding-top:2px; margin-right:0; }
155
.chat-msgs.columnar .msg-nick { flex-shrink:0; min-width:80px; text-align:right; margin-right:0; }
156
.chat-msgs.columnar .msg-text { word-break:break-word; }
157
.chat-msgs.columnar .msg-grouped .msg-nick { display:inline; visibility:hidden; }
158
.chat-msgs.columnar .msg-grouped .msg-time { display:inline; color:transparent; }
159
.chat-msgs.columnar .msg-grouped:hover .msg-time { color:#8b949e; }
160
.chat-nicklist { width:148px; min-width:0; flex-shrink:0; border-left:1px solid #30363d; display:flex; flex-direction:column; background:#161b22; overflow-y:auto; overflow-x:hidden; transition:width .15s; }
161
.chat-nicklist.collapsed { width:28px; overflow:hidden; }
162
.chat-nicklist.collapsed #nicklist-users { display:none; }
163
.nicklist-head { padding:8px 12px; font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; border-bottom:1px solid #30363d; flex-shrink:0; display:flex; align-items:center; gap:4px; }
164
.nicklist-nick { padding:5px 12px; font-size:12px; color:#8b949e; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
165
.nicklist-nick.is-bot { color:#58a6ff; }
166
.nicklist-nick.is-op { color:#3fb950; font-weight:600; }
167
.nicklist-nick::before { content:""; }
168
.chat-new-banner { align-self:center; margin:4px auto 0; background:#1f6feb; color:#fff; border-radius:20px; padding:3px 14px; font-size:12px; cursor:pointer; display:inline-block; white-space:nowrap; }
169
/* login screen */
170
.login-screen { position:fixed; inset:0; background:#0d1117; z-index:200; display:flex; align-items:center; justify-content:center; flex-direction:column; }
171
.login-box { width:340px; }
172
.login-brand { text-align:center; margin-bottom:24px; }
173
.login-brand h1 { font-size:22px; color:#58a6ff; letter-spacing:.05em; }
174
.login-brand p { color:#8b949e; font-size:13px; margin-top:6px; }
175
/* unread badge on chat tab */
176
.nav-tab[data-unread]::after { content:attr(data-unread); background:#f85149; color:#fff; border-radius:999px; padding:1px 5px; font-size:10px; margin-left:5px; vertical-align:middle; }
177
.msg-text { color:#e6edf3; word-break:break-word; }
178
.msg-row.hl-mention { background:#1f6feb18; border-left:2px solid #58a6ff; padding-left:6px; }
179
.msg-row.hl-danger { background:#f8514918; border-left:2px solid #f85149; padding-left:6px; }
180
.msg-row.hl-system { opacity:0.6; font-style:italic; }
181
.msg-text .hl-word { background:#f0883e33; border-radius:2px; padding:0 2px; }
182
/* meta blocks */
183
.msg-meta { display:none; margin:2px 0 4px 0; padding:8px 10px; background:#0d1117; border:1px solid #21262d; border-radius:6px; font-size:12px; line-height:1.5; cursor:default; }
184
.msg-meta.open { display:block; }
185
.msg-meta-toggle { display:inline-block; margin-left:6px; font-size:10px; color:#8b949e; cursor:pointer; padding:0 4px; border:1px solid #30363d; border-radius:3px; vertical-align:middle; }
186
.msg-meta-toggle:hover { color:#e6edf3; border-color:#58a6ff; }
187
.msg-meta .meta-type { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:#8b949e; margin-bottom:4px; }
188
.msg-meta .meta-tool { color:#d2a8ff; font-weight:600; }
189
.msg-meta .meta-file { color:#79c0ff; }
190
.msg-meta .meta-cmd { color:#a5d6ff; font-family:inherit; }
191
.msg-meta .meta-error { color:#ff7b72; }
192
.msg-meta .meta-status { display:inline-block; padding:1px 6px; border-radius:3px; font-size:11px; }
193
.msg-meta .meta-status.ok { background:#3fb95022; color:#3fb950; border:1px solid #3fb95044; }
194
.msg-meta .meta-status.error { background:#f8514922; color:#f85149; border:1px solid #f8514944; }
195
.msg-meta .meta-status.running { background:#1f6feb22; color:#58a6ff; border:1px solid #1f6feb44; }
196
.msg-meta .meta-kv { display:grid; grid-template-columns:auto 1fr; gap:2px 10px; }
197
.msg-meta .meta-kv dt { color:#8b949e; }
198
.msg-meta .meta-kv dd { color:#e6edf3; word-break:break-all; }
199
.msg-meta pre { margin:4px 0 0; padding:6px 8px; background:#161b22; border:1px solid #21262d; border-radius:4px; overflow-x:auto; white-space:pre-wrap; word-break:break-all; color:#e6edf3; font-size:12px; }
200
.msg-meta img { max-width:100%; max-height:300px; border-radius:4px; margin-top:4px; }
201
/* rich rendering v2 */
202
.rich-card { border-radius:6px; overflow:hidden; margin-top:4px; font-size:12px; }
203
.rich-card-header { display:flex; align-items:center; gap:8px; padding:6px 10px; font-size:11px; }
204
.rich-card-body { padding:0; }
205
.rich-card-body pre { margin:0; border:0; border-radius:0; padding:8px 10px; font-size:12px; line-height:1.5; }
206
/* terminal block */
207
.rich-terminal { border:1px solid #30363d; background:#0d1117; }
208
.rich-terminal .rich-card-header { background:#161b22; color:#8b949e; border-bottom:1px solid #21262d; }
209
.rich-terminal .rich-card-header .cmd { color:#a5d6ff; font-family:monospace; font-weight:600; }
210
.rich-terminal .exit-ok { color:#3fb950; } .rich-terminal .exit-fail { color:#f85149; }
211
/* file card */
212
.rich-file { border:1px solid #30363d; background:#0d1117; }
213
.rich-file .rich-card-header { background:#161b22; border-bottom:1px solid #21262d; }
214
.rich-file .rich-card-header .path { color:#79c0ff; font-family:monospace; font-weight:600; }
215
.rich-file .rich-card-header .lang { background:#1f6feb33; color:#58a6ff; padding:1px 6px; border-radius:3px; font-size:10px; }
216
/* diff rendering */
217
.rich-diff .line-add { background:#3fb95015; color:#3fb950; }
218
.rich-diff .line-del { background:#f8514915; color:#f85149; }
219
.rich-diff .line-ctx { color:#8b949e; }
220
.rich-diff .line-hdr { color:#d2a8ff; font-weight:600; }
221
/* error card */
222
.rich-error { border:1px solid #f8514944; background:#f8514910; }
223
.rich-error .rich-card-header { background:#f8514918; color:#f85149; border-bottom:1px solid #f8514944; }
224
/* thinking block */
225
.rich-thinking { border:1px solid #30363d; background:#0d1117; font-style:italic; color:#8b949e; padding:6px 10px; border-radius:6px; margin-top:4px; font-size:12px; }
226
/* search results */
227
.rich-search { border:1px solid #30363d; background:#0d1117; }
228
.rich-search .rich-card-header { background:#161b22; border-bottom:1px solid #21262d; }
229
.rich-search .url { color:#58a6ff; word-break:break-all; }
230
.chat-input { padding:9px 13px; padding-bottom:calc(9px + env(safe-area-inset-bottom, 0px)); border-top:1px solid #30363d; display:flex; gap:7px; flex-shrink:0; background:#161b22; }
231
232
/* channels tab */
233
.chan-card { display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid #21262d; }
234
.chan-card:last-child { border-bottom:none; }
235
.chan-card:hover { background:#1c2128; }
236
.chan-name { font-size:14px; font-weight:600; color:#58a6ff; }
237
.chan-meta { font-size:12px; color:#8b949e; }
238
239
/* settings */
240
.setting-row { display:flex; align-items:center; gap:12px; padding:14px 0; border-bottom:1px solid #21262d; }
241
.setting-row:last-child { border-bottom:none; }
242
.setting-label { min-width:160px; font-size:13px; color:#c9d1d9; }
243
.setting-desc { font-size:12px; color:#8b949e; flex:1; }
244
.setting-val { font-size:12px; font-family:inherit; color:#a5d6ff; background:#0d1117; border:1px solid #30363d; border-radius:4px; padding:4px 10px; }
245
246
/* modal */
247
.modal-overlay { display:none; position:fixed; inset:0; background:#0d111788; z-index:100; align-items:center; justify-content:center; }
248
.modal-overlay.open { display:flex; }
249
.modal { background:#161b22; border:1px solid #30363d; border-radius:10px; padding:24px; width:480px; max-width:95vw; }
250
.modal h3 { font-size:15px; margin-bottom:16px; }
251
.modal .btn-row { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; }
252
/* charts */
253
.charts-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; }
254
.chart-card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:14px 16px; }
255
.chart-label { font-size:11px; color:#8b949e; text-transform:uppercase; letter-spacing:.06em; margin-bottom:10px; display:flex; align-items:center; gap:6px; }
256
.chart-label .val { margin-left:auto; font-size:13px; color:#e6edf3; font-weight:600; letter-spacing:0; text-transform:none; }
257
canvas { display:block; width:100% !important; }
258
.bridge-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
259
260
/* mobile */
261
@media (max-width: 600px) {
262
/* header: shorter, compact brand, scrollable nav */
263
header { padding:0 8px; height:44px; }
264
.brand { padding-right:8px; margin-right:0; border-right:none; }
265
.brand span { display:none; }
266
nav { overflow-x:auto; -webkit-overflow-scrolling:touch; scrollbar-width:none; position:relative; }
267
nav::-webkit-scrollbar { display:none; }
268
.nav-tab { padding:0 10px; font-size:12px; }
269
.header-right { display:none; }
270
271
272
/* content: reduce padding, stack grids */
273
.pane-inner { padding:12px; gap:12px; }
274
.stat-grid { grid-template-columns:1fr !important; }
275
.card-body { padding:12px !important; }
276
277
/* charts and bridge grids: single column */
278
.charts-grid { grid-template-columns:1fr !important; }
279
.bridge-grid { grid-template-columns:1fr !important; }
280
281
/* forms: stack 2-column rows */
282
.form-row { grid-template-columns:1fr !important; }
283
284
/* inline 2-column grids inside drawers/forms */
285
[style*="grid-template-columns:1fr 1fr"] { grid-template-columns:1fr !important; }
286
287
/* chat: hide both panels by default, show as overlay when toggled open */
288
.chat-sidebar { width:0 !important; min-width:0 !important; border-right:none !important; overflow:hidden !important; }
289
.chat-sidebar.mobile-open { position:fixed; top:44px; left:0; bottom:0; width:220px !important; z-index:50; border-right:1px solid #30363d !important; overflow:visible !important; }
290
.chat-nicklist { width:0 !important; min-width:0 !important; border-left:none !important; overflow:hidden !important; }
291
.chat-nicklist.mobile-open { position:fixed; top:44px; right:0; bottom:0; width:160px !important; z-index:50; border-left:1px solid #30363d !important; overflow-y:auto !important; }
292
.sidebar-resize { display:none; }
293
294
/* settings: stack label above input */
295
.setting-row { flex-direction:column !important; align-items:flex-start !important; gap:6px !important; }
296
.setting-label { min-width:unset !important; }
297
298
/* drawer: full-width on mobile */
299
.drawer { width:100vw; max-width:100vw; top:44px; }
300
.drawer-header { padding:12px 14px; }
301
.drawer-body { padding:14px; }
302
303
/* modal */
304
.modal { width:95vw; padding:16px; }
305
306
/* filter bar */
307
.filter-bar { flex-wrap:wrap; }
308
.filter-bar input { max-width:100% !important; flex:1; min-width:0; }
309
310
/* login */
311
.login-box { width:90vw; max-width:340px; }
312
313
/* chat: tighter on mobile */
314
.chat-msgs { padding:2px 4px; }
315
.msg-row { font-size:12px; line-height:1.3; }
316
.msg-time { font-size:10px; }
317
.chat-input { padding:6px 8px; }
318
.chat-topbar { padding:6px 10px; gap:6px; font-size:12px; }
319
}
320
</style>
321
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
322
</head>
323
<body>
324
325
<!-- login screen — shown when unauthenticated -->
326
<div class="login-screen" id="login-screen" style="display:none">
327
<div class="login-box">
328
<div class="login-brand">
329
<h1>⬡ scuttlebot</h1>
330
<p>agent coordination backplane</p>
331
</div>
332
<div class="card">
333
<div class="card-body">
334
<form id="login-form" onsubmit="handleLogin(event)" style="gap:12px">
335
<div>
336
<label>username</label>
337
<input type="text" id="login-username" autocomplete="username">
338
</div>
339
<div>
340
<label>password</label>
341
<input type="password" id="login-password" autocomplete="current-password">
342
</div>
343
<div id="login-error" style="display:none"></div>
344
<button type="submit" class="primary" style="width:100%;margin-top:4px" id="login-btn">sign in</button>
345
</form>
346
<details style="margin-top:16px;border-top:1px solid #21262d;padding-top:14px">
347
<summary style="font-size:12px;color:#8b949e;cursor:pointer;user-select:none">use API token instead</summary>
348
<div style="display:flex;gap:8px;margin-top:10px">
349
<input type="text" id="token-login-input" placeholder="paste API token" style="flex:1;font-size:12px" autocomplete="off" spellcheck="false">
350
<button class="sm primary" onclick="saveTokenLogin()">apply</button>
351
</div>
352
<div class="hint" style="margin-top:4px">Token is printed to stderr at startup.</div>
353
</details>
354
</div>
355
</div>
356
<p style="text-align:center;font-size:11px;color:#6e7681;margin-top:14px">
357
<a href="https://scuttlebot.dev" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">ScuttleBot</a> · Powered by <a href="https://weareconflict.com" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">CONFLICT</a>
358
</p>
359
</div>
360
</div>
361
362
<header>
363
<div class="brand">
364
<h1>⬡ scuttlebot</h1>
365
<span>agent coordination backplane</span>
366
</div>
367
<nav>
368
<div class="nav-tab active" id="tab-status" onclick="switchTab('status')">◈ status</div>
369
<div class="nav-tab" id="tab-users" onclick="switchTab('users')">◉ users</div>
370
<div class="nav-tab" id="tab-agents" onclick="switchTab('agents')">◎ agents</div>
371
<div class="nav-tab" id="tab-channels" onclick="switchTab('channels')">◎ channels</div>
372
<div class="nav-tab" id="tab-chat" onclick="switchTab('chat')">◌ chat</div>
373
<div class="nav-tab" id="tab-ai" onclick="switchTab('ai')">✦ ai</div>
374
<div class="nav-tab" id="tab-settings" onclick="switchTab('settings')">⚙ settings</div>
375
</nav>
376
<div class="header-right">
377
<span id="header-user-display" style="font-size:12px;color:#8b949e"></span>
378
<button class="sm" onclick="logout()">sign out</button>
379
</div>
380
</header>
381
382
<!-- STATUS -->
383
<div class="tab-pane active" id="pane-status">
384
<div class="pane-inner">
385
<div id="no-token-banner" class="alert info" style="display:none">
386
<span class="icon">ℹ</span>
387
<span>Paste your API token to continue — printed to stderr at startup: <code style="color:#a5d6ff">level=INFO msg="api token" token=…</code></span>
388
</div>
389
390
<!-- server status card -->
391
<div class="card" id="card-status">
392
<div class="card-header" onclick="toggleCard('card-status',event)">
393
<span class="dot green" id="status-dot"></span><h2>server status</h2>
394
<span class="collapse-icon">▾</span>
395
<div class="spacer"></div>
396
<span style="font-size:11px;color:#8b949e" id="metrics-updated"></span>
397
<button class="sm" onclick="loadStatus()" title="refresh">↻</button>
398
</div>
399
<div class="card-body">
400
<div class="stat-grid">
401
<div class="stat"><div class="lbl">state</div><div class="val" id="stat-status">—</div></div>
402
<div class="stat"><div class="lbl">uptime</div><div class="val" id="stat-uptime">—</div></div>
403
<div class="stat"><div class="lbl">agents</div><div class="val" id="stat-agents">—</div></div>
404
<div class="stat"><div class="lbl">started</div><div class="val" style="font-size:13px" id="stat-started">—</div><div class="sub" id="stat-started-rel"></div></div>
405
</div>
406
<div id="status-error" style="margin-top:12px;display:none"></div>
407
</div>
408
</div>
409
410
<!-- runtime -->
411
<div class="card" id="card-runtime">
412
<div class="card-header" onclick="toggleCard('card-runtime',event)"><h2>runtime</h2><span class="collapse-icon">▾</span></div>
413
<div class="card-body" style="display:flex;flex-direction:column;gap:16px">
414
<div class="stat-grid">
415
<div class="stat"><div class="lbl">goroutines</div><div class="val" id="stat-goroutines">—</div></div>
416
<div class="stat"><div class="lbl">heap alloc</div><div class="val" id="stat-heap">—</div></div>
417
<div class="stat"><div class="lbl">heap sys</div><div class="val" id="stat-heapsys">—</div></div>
418
<div class="stat"><div class="lbl">GC runs</div><div class="val" id="stat-gc">—</div></div>
419
</div>
420
<div class="charts-grid">
421
<div class="chart-card">
422
<div class="chart-label">heap alloc <span class="val" id="chart-heap-val">—</span></div>
423
<canvas id="chart-heap" height="80"></canvas>
424
</div>
425
<div class="chart-card">
426
<div class="chart-label">goroutines <span class="val" id="chart-goroutines-val">—</span></div>
427
<canvas id="chart-goroutines" height="80"></canvas>
428
</div>
429
<div class="chart-card">
430
<div class="chart-label">messages total <span class="val" id="chart-messages-val">—</span></div>
431
<canvas id="chart-messages" height="80"></canvas>
432
</div>
433
</div>
434
</div>
435
</div>
436
437
<!-- bridge -->
438
<div class="card" id="bridge-card" style="display:none">
439
<div class="card-header" onclick="toggleCard('bridge-card',event)"><h2>bridge</h2><span class="collapse-icon">▾</span></div>
440
<div class="card-body">
441
<div class="bridge-grid">
442
<div class="stat"><div class="lbl">channels</div><div class="val" id="stat-bridge-channels">—</div></div>
443
<div class="stat"><div class="lbl">messages total</div><div class="val" id="stat-bridge-msgs">—</div></div>
444
<div class="stat"><div class="lbl">active streams</div><div class="val" id="stat-bridge-subs">—</div></div>
445
</div>
446
</div>
447
</div>
448
449
<!-- registry -->
450
<div class="card" id="card-registry">
451
<div class="card-header" onclick="toggleCard('card-registry',event)"><h2>registry</h2><span class="collapse-icon">▾</span></div>
452
<div class="card-body">
453
<div class="stat-grid">
454
<div class="stat"><div class="lbl">total</div><div class="val" id="stat-reg-total">—</div></div>
455
<div class="stat"><div class="lbl">active</div><div class="val" id="stat-reg-active">—</div></div>
456
<div class="stat"><div class="lbl">revoked</div><div class="val" id="stat-reg-revoked">—</div></div>
457
</div>
458
</div>
459
</div>
460
461
</div>
462
</div>
463
464
<!-- USERS -->
465
<div class="tab-pane" id="pane-users">
466
<div class="filter-bar">
467
<input type="text" id="user-search" placeholder="search by nick or channel…" oninput="renderUsersTable()" style="max-width:320px">
468
<div class="spacer"></div>
469
<span class="badge" id="user-count" style="margin-right:4px">0</span>
470
<button class="sm" onclick="loadAgents()">↻ refresh</button>
471
<button class="sm" onclick="openAdoptDrawer()">adopt existing user</button>
472
<button class="sm primary" onclick="openRegisterUserDrawer()">+ register user</button>
473
</div>
474
<div style="flex:1;overflow-y:auto">
475
<div id="users-container"></div>
476
</div>
477
</div>
478
479
<!-- AGENTS -->
480
<div class="tab-pane" id="pane-agents">
481
<div class="filter-bar">
482
<input type="text" id="agent-search" placeholder="search by nick, type, channel…" oninput="agentPage=0;renderAgentTable()" style="max-width:280px">
483
<select id="agent-status-filter" onchange="agentPage=0;renderAgentTable()" style="padding:5px 8px;font-size:12px;width:90px">
484
<option value="all">all</option>
485
<option value="online">online</option>
486
<option value="offline">offline</option>
487
<option value="revoked">revoked</option>
488
</select>
489
<div class="spacer"></div>
490
<span class="badge" id="agent-count" style="margin-right:4px">0</span>
491
<button class="sm" onclick="loadAgents()">↻ refresh</button>
492
<button class="sm danger" id="bulk-delete-btn" style="display:none" onclick="bulkDeleteAgents()">delete selected</button>
493
<button class="sm primary" onclick="openDrawer()">+ register agent</button>
494
</div>
495
<div id="agent-pagination" style="display:none;padding:4px 16px;font-size:12px;color:#8b949e;display:flex;align-items:center;gap:8px">
496
<button class="sm" id="agent-prev" onclick="agentPage--;renderAgentTable()">← prev</button>
497
<span id="agent-page-info"></span>
498
<button class="sm" id="agent-next" onclick="agentPage++;renderAgentTable()">next →</button>
499
</div>
500
<div style="flex:1;overflow-y:auto">
501
<div id="agents-container"></div>
502
</div>
503
</div>
504
505
<!-- CHANNELS -->
506
<div class="tab-pane" id="pane-channels">
507
<div class="pane-inner">
508
<div class="card">
509
<div class="card-header">
510
<h2>channels</h2>
511
<span class="badge" id="chan-count">0</span>
512
<div class="spacer"></div>
513
<input type="text" id="chan-search" placeholder="filter…" oninput="renderChanList()" style="width:120px;padding:5px 8px;font-size:12px">
514
<div style="display:flex;gap:6px;align-items:center">
515
<input type="text" id="quick-join-input" placeholder="#channel" style="width:140px;padding:5px 8px;font-size:12px" autocomplete="off">
516
<button class="sm primary" onclick="quickJoin()">join</button>
517
</div>
518
</div>
519
<div id="channels-list"><div class="empty">no channels joined yet — type a channel name above</div></div>
520
</div>
521
522
<!-- topology panel -->
523
<div class="card" id="card-topology">
524
<div class="card-header" onclick="toggleCard('card-topology',event)">
525
<h2>topology</h2><span class="card-desc">channel types, provisioning rules, active task channels</span><span class="collapse-icon">▾</span>
526
<div class="spacer"></div>
527
<div style="display:flex;gap:6px;align-items:center">
528
<input type="text" id="provision-channel-input" placeholder="#project.name" style="width:160px;padding:5px 8px;font-size:12px" autocomplete="off">
529
<button class="sm primary" onclick="provisionChannel()">provision</button>
530
</div>
531
</div>
532
<div class="card-body" style="padding:0">
533
<div id="topology-types"></div>
534
<div id="topology-active" style="padding:12px 16px"><div class="empty">loading topology…</div></div>
535
</div>
536
</div>
537
538
<!-- ROE templates -->
539
<div class="card" id="card-roe">
540
<div class="card-header" onclick="toggleCard('card-roe',event)">
541
<h2>ROE templates</h2><span class="card-desc">rules-of-engagement presets for agent registration</span><span class="collapse-icon">▾</span>
542
<div class="spacer"></div>
543
<button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
544
</div>
545
<div class="card-body">
546
<p style="font-size:12px;color:#8b949e;margin-bottom:12px">Define ROE templates applied to agents at registration. Includes channels, permissions, and rate limits.</p>
547
<div id="roe-list"></div>
548
<button class="sm" onclick="addROETemplate()" style="margin-top:10px">+ add template</button>
549
</div>
550
</div>
551
</div>
552
</div>
553
554
<!-- CHAT -->
555
<div class="tab-pane" id="pane-chat">
556
<div class="chat-sidebar" id="chat-sidebar-left">
557
<div class="sidebar-head">
558
<span id="sidebar-left-label">channels</span>
559
<button class="sidebar-toggle" id="sidebar-left-toggle" title="collapse" onclick="toggleSidebar('left')">‹</button>
560
</div>
561
<div class="chan-join">
562
<input type="text" id="join-channel-input" placeholder="#general" autocomplete="off">
563
<button class="sm" onclick="joinChannel()">+</button>
564
</div>
565
<div class="chan-list" id="chan-list"></div>
566
</div>
567
<div class="sidebar-resize" id="resize-left" title="drag to resize"></div>
568
<div class="chat-main">
569
<div class="chat-topbar">
570
<span class="chat-ch-name" id="chat-ch-name">select a channel</span><span id="chat-channel-modes" style="color:#8b949e;font-size:11px;margin-left:6px"></span>
571
<div class="spacer"></div>
572
<span style="font-size:11px;color:#8b949e;margin-right:6px">chatting as</span>
573
<select id="chat-identity" style="width:140px;padding:3px 6px;font-size:12px" onchange="saveChatIdentity()">
574
<option value="">— pick a user —</option>
575
</select>
576
<button class="sm" id="chat-layout-toggle" onclick="toggleChatLayout()" title="toggle compact/columnar" style="font-size:11px;padding:2px 6px">☰</button>
577
<button class="sm" id="chat-ts-toggle" onclick="toggleTimestamps()" title="toggle timestamps" style="font-size:11px;padding:2px 6px">🕐</button>
578
<button class="sm" id="chat-rich-toggle" onclick="toggleRichMode()" title="toggle rich/text mode" style="font-size:11px;padding:2px 6px">✨</button>
579
<button class="sm" onclick="promptHighlightWords()" title="configure highlight keywords" style="font-size:11px;padding:2px 6px">✦</button>
580
<span class="stream-badge" id="chat-stream-status" style="margin-left:8px"></span>
581
</div>
582
<div class="chat-msgs" id="chat-msgs">
583
<div class="empty" id="chat-placeholder">join a channel to start chatting</div>
584
</div>
585
<div class="chat-input">
586
<input type="text" id="chat-text-input" placeholder="type a message…" style="flex:1" autocomplete="off">
587
<button class="primary sm" id="chat-send-btn" onclick="sendMsg()">send</button>
588
</div>
589
</div>
590
<div class="sidebar-resize" id="resize-right" title="drag to resize"></div>
591
<div class="chat-nicklist" id="chat-nicklist">
592
<div class="nicklist-head">
593
<button class="sidebar-toggle" id="sidebar-right-toggle" title="collapse" onclick="toggleSidebar('right')">›</button>
594
<span id="sidebar-right-label">users</span>
595
</div>
596
<div id="nicklist-users"></div>
597
</div>
598
</div>
599
600
<!-- SETTINGS -->
601
<div class="tab-pane" id="pane-settings">
602
<div class="pane-inner">
603
604
<!-- connection -->
605
<div class="card" id="card-connection">
606
<div class="card-header" style="cursor:default"><h2>connection</h2><span class="card-desc">current session and server endpoints</span></div>
607
<div class="card-body">
608
<div class="setting-row">
609
<div class="setting-label">signed in as</div>
610
<div class="setting-desc">Current admin session.</div>
611
<code class="setting-val" id="settings-username-display">—</code>
612
<button class="sm danger" onclick="logout()">sign out</button>
613
</div>
614
<div class="setting-row">
615
<div class="setting-label">API endpoint</div>
616
<div class="setting-desc">REST API base URL.</div>
617
<code class="setting-val" id="settings-api-url"></code>
618
</div>
619
<div class="setting-row">
620
<div class="setting-label">IRC network</div>
621
<div class="setting-desc">Ergo IRC server address.</div>
622
<code class="setting-val">localhost:6667</code>
623
</div>
624
<div class="setting-row">
625
<div class="setting-label">MCP server</div>
626
<div class="setting-desc">Model Context Protocol endpoint.</div>
627
<code class="setting-val">localhost:8081</code>
628
</div>
629
</div>
630
</div>
631
632
<!-- admin accounts -->
633
<div class="card" id="card-admins">
634
<div class="card-header" onclick="toggleCard('card-admins',event)"><h2>admin accounts</h2><span class="card-desc">who can sign in to this UI</span><span class="collapse-icon">▾</span></div>
635
<div id="admins-list-container"></div>
636
<div class="card-body" style="border-top:1px solid #21262d">
637
<p style="font-size:12px;color:#8b949e;margin-bottom:12px">Add an admin account. Admins sign in at the login screen with username + password.</p>
638
<form id="add-admin-form" onsubmit="addAdmin(event)" style="flex-direction:row;align-items:flex-end;gap:10px;flex-wrap:wrap">
639
<div style="flex:1;min-width:130px"><label>username</label><input type="text" id="new-admin-username" autocomplete="off"></div>
640
<div style="flex:1;min-width:130px"><label>password</label><input type="password" id="new-admin-password" autocomplete="new-password"></div>
641
<button type="submit" class="primary sm" style="margin-bottom:1px">add admin</button>
642
</form>
643
<div id="add-admin-result" style="margin-top:10px"></div>
644
</div>
645
</div>
646
647
<!-- api keys -->
648
<div class="card" id="card-apikeys">
649
<div class="card-header" onclick="toggleCard('card-apikeys',event)"><h2>API keys</h2><span class="card-desc">per-consumer tokens with scoped permissions</span><span class="collapse-icon">▾</span></div>
650
<div id="apikeys-list-container"></div>
651
<div class="card-body" style="border-top:1px solid #21262d">
652
<p style="font-size:12px;color:#8b949e;margin-bottom:12px">Create an API key with a name and scopes. The token is shown only once.</p>
653
<form id="add-apikey-form" onsubmit="createAPIKey(event)" style="display:flex;flex-direction:column;gap:10px">
654
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
655
<div style="flex:1;min-width:160px"><label>name</label><input type="text" id="new-apikey-name" placeholder="e.g. kohakku-controller" autocomplete="off"></div>
656
<div style="flex:1;min-width:160px"><label>expires in</label><input type="text" id="new-apikey-expires" placeholder="e.g. 720h (empty=never)" autocomplete="off"></div>
657
</div>
658
<div>
659
<label style="margin-bottom:6px;display:block">scopes</label>
660
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px">
661
<label><input type="checkbox" value="admin" class="apikey-scope"> admin</label>
662
<label><input type="checkbox" value="agents" class="apikey-scope"> agents</label>
663
<label><input type="checkbox" value="channels" class="apikey-scope"> channels</label>
664
<label><input type="checkbox" value="chat" class="apikey-scope"> chat</label>
665
<label><input type="checkbox" value="topology" class="apikey-scope"> topology</label>
666
<label><input type="checkbox" value="bots" class="apikey-scope"> bots</label>
667
<label><input type="checkbox" value="config" class="apikey-scope"> config</label>
668
<label><input type="checkbox" value="read" class="apikey-scope"> read</label>
669
</div>
670
</div>
671
<button type="submit" class="primary sm" style="align-self:flex-start">create key</button>
672
</form>
673
<div id="add-apikey-result" style="margin-top:10px"></div>
674
</div>
675
</div>
676
677
<!-- tls -->
678
<div class="card" id="card-tls">
679
<div class="card-header" onclick="toggleCard('card-tls',event)"><h2>TLS / SSL</h2><span class="card-desc">certificate status</span><span class="collapse-icon">▾</span><div class="spacer"></div><span id="tls-badge" class="badge">loading…</span></div>
680
<div class="card-body">
681
<div id="tls-status-rows"></div>
682
<div class="alert info" style="margin-top:12px;font-size:12px">
683
<span class="icon">ℹ</span>
684
<span>TLS is configured in <code style="color:#a5d6ff">scuttlebot.yaml</code> under <code style="color:#a5d6ff">tls:</code>.
685
Set <code style="color:#a5d6ff">domain:</code> to enable Let's Encrypt. <code style="color:#a5d6ff">allow_insecure: true</code> keeps HTTP running alongside HTTPS.</span>
686
</div>
687
</div>
688
</div>
689
690
<!-- system behaviors -->
691
<div class="card" id="card-behaviors">
692
<div class="card-header" onclick="toggleCard('card-behaviors',event)">
693
<h2>system behaviors</h2><span class="card-desc">bot toggles, rate limits, and default channel</span><span class="collapse-icon">▾</span>
694
<div class="spacer"></div>
695
<button class="sm primary" onclick="savePolicies()">save</button>
696
</div>
697
<div class="card-body" style="padding:0">
698
<div id="behaviors-list"></div>
699
</div>
700
</div>
701
702
<!-- on-join instructions -->
703
<div class="card" id="card-onjoin">
704
<div class="card-header" onclick="toggleCard('card-onjoin',event)">
705
<h2>on-join instructions</h2><span class="card-desc">messages sent to agents when they join a channel</span><span class="collapse-icon">▾</span>
706
<div class="spacer"></div>
707
<button class="sm primary" onclick="event.stopPropagation();savePolicies()">save</button>
708
</div>
709
<div class="card-body">
710
<p style="font-size:12px;color:#8b949e;margin-bottom:12px">Per-channel instructions delivered to agents on join. Supports <code>{nick}</code> and <code>{channel}</code> template variables.</p>
711
<div id="onjoin-list"></div>
712
<div style="display:flex;gap:8px;margin-top:12px;align-items:flex-end">
713
<div style="flex:0 0 160px"><label>channel</label><input type="text" id="onjoin-new-channel" placeholder="#channel" style="width:100%"></div>
714
<div style="flex:1"><label>message</label><input type="text" id="onjoin-new-message" placeholder="Welcome to {channel}, {nick}!" style="width:100%"></div>
715
<button class="sm primary" onclick="addOnJoinMessage()">add</button>
716
</div>
717
</div>
718
</div>
719
720
<!-- agent policy -->
721
<div class="card" id="card-agentpolicy">
722
<div class="card-header" onclick="toggleCard('card-agentpolicy',event)"><h2>agent policy</h2><span class="card-desc">autojoin and check-in rules for all agents</span><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="event.stopPropagation();saveAgentPolicy()">save</button></div>
723
<div class="card-body">
724
<div class="setting-row">
725
<div class="setting-label">require check-in</div>
726
<div class="setting-desc">Agents must join a coordination channel before others.</div>
727
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
728
<input type="checkbox" id="policy-checkin-enabled" onchange="toggleCheckinChannel()">
729
<span style="font-size:12px">enabled</span>
730
</label>
731
</div>
732
<div class="setting-row" id="policy-checkin-row" style="display:none">
733
<div class="setting-label">check-in channel</div>
734
<div class="setting-desc">Channel all agents must join first.</div>
735
<input type="text" id="policy-checkin-channel" placeholder="#coordination" style="width:180px;padding:4px 8px;font-size:12px">
736
</div>
737
<div class="setting-row">
738
<div class="setting-label">required channels</div>
739
<div class="setting-desc">Channels every agent is added to automatically.</div>
740
<input type="text" id="policy-required-channels" placeholder="#fleet, #alerts" style="width:220px;padding:4px 8px;font-size:12px">
741
</div>
742
<div class="setting-row">
743
<div class="setting-label">online timeout</div>
744
<div class="setting-desc">Seconds since last heartbeat before an agent is considered offline. Default: 120.</div>
745
<input type="number" id="policy-online-timeout" placeholder="120" min="10" max="3600" style="width:100px;padding:4px 8px;font-size:12px">
746
</div>
747
<div class="setting-row">
748
<div class="setting-label">reap after days</div>
749
<div class="setting-desc">Remove stale agents not seen in this many days. 0 = never reap.</div>
750
<input type="number" id="policy-reap-days" placeholder="0" min="0" max="365" style="width:100px;padding:4px 8px;font-size:12px">
751
</div>
752
</div>
753
<div id="agentpolicy-save-result" style="display:none;margin:0 16px 12px"></div>
754
</div>
755
756
<!-- bridge -->
757
<div class="card" id="card-bridgepolicy">
758
<div class="card-header" onclick="toggleCard('card-bridgepolicy',event)"><h2>web bridge</h2><span class="card-desc">IRC bot that powers the web chat UI</span><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="event.stopPropagation();saveBridgeConfig()">save</button></div>
759
<div class="card-body">
760
<div class="setting-row">
761
<div class="setting-label">enabled</div>
762
<div class="setting-desc">Start the bridge bot that powers the web chat UI.</div>
763
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
764
<input type="checkbox" id="bridge-enabled">
765
<span style="font-size:12px">enabled</span>
766
</label>
767
</div>
768
<div class="setting-row">
769
<div class="setting-label">nick</div>
770
<div class="setting-desc">IRC nick for the bridge bot. Requires restart.</div>
771
<input type="text" id="bridge-nick" placeholder="bridge" style="width:160px;padding:4px 8px;font-size:12px">
772
</div>
773
<div class="setting-row">
774
<div class="setting-label">channels</div>
775
<div class="setting-desc">Channels the bridge joins at startup.</div>
776
<input type="text" id="bridge-channels" placeholder="#general, #fleet" style="width:280px;padding:4px 8px;font-size:12px">
777
</div>
778
<div class="setting-row">
779
<div class="setting-label">message buffer</div>
780
<div class="setting-desc">Messages to keep per channel in memory.</div>
781
<div style="display:flex;align-items:center;gap:6px">
782
<input type="number" id="bridge-buffer-size" placeholder="200" min="1" style="width:80px;padding:4px 8px;font-size:12px">
783
<span style="font-size:12px;color:#8b949e">messages</span>
784
</div>
785
</div>
786
<div class="setting-row">
787
<div class="setting-label">web user TTL</div>
788
<div class="setting-desc">How long HTTP-posted nicks stay visible in the channel user list after their last message.</div>
789
<div style="display:flex;align-items:center;gap:6px">
790
<input type="number" id="policy-bridge-web-user-ttl" placeholder="5" min="1" style="width:80px;padding:4px 8px;font-size:12px">
791
<span style="font-size:12px;color:#8b949e">minutes</span>
792
</div>
793
</div>
794
</div>
795
<div id="bridge-save-result" style="display:none;margin:0 16px 12px"></div>
796
</div>
797
798
<!-- logging -->
799
<div class="card" id="card-logging">
800
<div class="card-header" onclick="toggleCard('card-logging',event)"><h2>message logging</h2><span class="card-desc">write channel traffic to disk</span><span class="collapse-icon">▾</span><div class="spacer"></div><button class="sm primary" onclick="event.stopPropagation();saveLogging()">save</button></div>
801
<div class="card-body">
802
<div class="setting-row">
803
<div class="setting-label">enabled</div>
804
<div class="setting-desc">Write every channel message to disk.</div>
805
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
806
<input type="checkbox" id="policy-logging-enabled" onchange="toggleLogOptions()">
807
<span style="font-size:12px">enabled</span>
808
</label>
809
</div>
810
<div id="policy-log-options" style="display:none">
811
<div class="setting-row">
812
<div class="setting-label">log directory</div>
813
<div class="setting-desc">Directory to write log files into.</div>
814
<input type="text" id="policy-log-dir" placeholder="./data/logs" style="width:280px;padding:4px 8px;font-size:12px">
815
</div>
816
<div class="setting-row">
817
<div class="setting-label">format</div>
818
<div class="setting-desc">Output format for log lines.</div>
819
<select id="policy-log-format" style="width:160px;padding:4px 8px;font-size:12px">
820
<option value="jsonl">JSON Lines (.jsonl)</option>
821
<option value="csv">CSV (.csv)</option>
822
<option value="text">Plain text (.log)</option>
823
</select>
824
</div>
825
<div class="setting-row">
826
<div class="setting-label">rotation</div>
827
<div class="setting-desc">When to start a new log file.</div>
828
<select id="policy-log-rotation" style="width:160px;padding:4px 8px;font-size:12px" onchange="toggleRotationOptions()">
829
<option value="none">None</option>
830
<option value="daily">Daily</option>
831
<option value="weekly">Weekly</option>
832
<option value="monthly">Monthly</option>
833
<option value="yearly">Yearly</option>
834
<option value="size">By size</option>
835
</select>
836
</div>
837
<div class="setting-row" id="policy-log-size-row" style="display:none">
838
<div class="setting-label">max file size</div>
839
<div class="setting-desc">Rotate when file reaches this size.</div>
840
<div style="display:flex;align-items:center;gap:6px">
841
<input type="number" id="policy-log-max-size" placeholder="100" min="1" style="width:80px;padding:4px 8px;font-size:12px">
842
<span style="font-size:12px;color:#8b949e">MiB</span>
843
</div>
844
</div>
845
<div class="setting-row">
846
<div class="setting-label">per-channel files</div>
847
<div class="setting-desc">Write a separate file for each channel.</div>
848
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
849
<input type="checkbox" id="policy-log-per-channel">
850
<span style="font-size:12px">enabled</span>
851
</label>
852
</div>
853
<div class="setting-row">
854
<div class="setting-label">max age</div>
855
<div class="setting-desc">Delete rotated files older than N days. 0 = keep forever.</div>
856
<div style="display:flex;align-items:center;gap:6px">
857
<input type="number" id="policy-log-max-age" placeholder="0" min="0" style="width:80px;padding:4px 8px;font-size:12px">
858
<span style="font-size:12px;color:#8b949e">days</span>
859
</div>
860
</div>
861
</div>
862
</div>
863
<div id="logging-save-result" style="display:none;margin:0 16px 12px"></div>
864
</div>
865
866
<div id="policies-save-result" style="display:none"></div>
867
868
<!-- general -->
869
<div class="card" id="card-general">
870
<div class="card-header" onclick="toggleCard('card-general',event)">
871
<h2>general</h2><span class="card-desc">API and MCP server addresses</span><span class="collapse-icon">▾</span>
872
<div class="spacer"></div>
873
<button class="sm primary" onclick="event.stopPropagation();saveGeneralConfig()">save</button>
874
</div>
875
<div class="card-body">
876
<div class="setting-row">
877
<div class="setting-label">API address</div>
878
<div class="setting-desc">Address scuttlebot listens on for HTTP API requests. Requires restart.</div>
879
<input type="text" id="general-api-addr" placeholder=":8080" style="width:160px;padding:4px 8px;font-size:12px">
880
</div>
881
<div class="setting-row">
882
<div class="setting-label">MCP address</div>
883
<div class="setting-desc">Address for the Model Context Protocol server. Requires restart.</div>
884
<input type="text" id="general-mcp-addr" placeholder=":8081" style="width:160px;padding:4px 8px;font-size:12px">
885
</div>
886
</div>
887
<div id="general-save-result" style="display:none;margin:0 16px 12px"></div>
888
</div>
889
890
<!-- ergo -->
891
<div class="card" id="card-ergo">
892
<div class="card-header" onclick="toggleCard('card-ergo',event)">
893
<h2>IRC server (ergo)</h2><span class="card-desc">embedded IRC server settings</span><span class="collapse-icon">▾</span>
894
<div class="spacer"></div>
895
<button class="sm primary" onclick="event.stopPropagation();saveErgoConfig()">save</button>
896
</div>
897
<div class="card-body">
898
<div class="setting-row">
899
<div class="setting-label">network name</div>
900
<div class="setting-desc">Human-readable IRC network name. Requires restart.</div>
901
<input type="text" id="ergo-network-name" placeholder="scuttlebot" style="width:220px;padding:4px 8px;font-size:12px">
902
</div>
903
<div class="setting-row">
904
<div class="setting-label">server name</div>
905
<div class="setting-desc">IRC server hostname (e.g. irc.example.com). Requires restart.</div>
906
<input type="text" id="ergo-server-name" placeholder="irc.scuttlebot.local" style="width:220px;padding:4px 8px;font-size:12px">
907
</div>
908
<div class="setting-row">
909
<div class="setting-label">IRC address</div>
910
<div class="setting-desc">Address Ergo listens on for IRC connections. Requires restart.</div>
911
<input type="text" id="ergo-irc-addr" placeholder="127.0.0.1:6667" style="width:180px;padding:4px 8px;font-size:12px">
912
</div>
913
<div class="setting-row">
914
<div class="setting-label">require SASL</div>
915
<div class="setting-desc">Enforce SASL authentication for all IRC connections. Only registered accounts can connect. Hot-reloads.</div>
916
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
917
<input type="checkbox" id="ergo-require-sasl">
918
<span style="font-size:12px">enforce SASL</span>
919
</label>
920
</div>
921
<div class="setting-row">
922
<div class="setting-label">default channel modes</div>
923
<div class="setting-desc">Modes applied to new channels (e.g. "+n", "+Rn"). Hot-reloads.</div>
924
<input type="text" id="ergo-default-modes" placeholder="+n" style="width:120px;padding:4px 8px;font-size:12px">
925
</div>
926
<div class="setting-row">
927
<div class="setting-label">message history</div>
928
<div class="setting-desc">Enable persistent message history (CHATHISTORY). Hot-reloads.</div>
929
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
930
<input type="checkbox" id="ergo-history-enabled">
931
<span style="font-size:12px">enabled</span>
932
</label>
933
</div>
934
<div class="setting-row">
935
<div class="setting-label">external mode</div>
936
<div class="setting-desc">Disable subprocess management — scuttlebot expects Ergo to already be running. Requires restart.</div>
937
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
938
<input type="checkbox" id="ergo-external">
939
<span style="font-size:12px">external</span>
940
</label>
941
</div>
942
</div>
943
<div id="ergo-save-result" style="display:none;margin:0 16px 12px"></div>
944
</div>
945
946
<!-- TLS -->
947
<div class="card" id="card-tls-config">
948
<div class="card-header" onclick="toggleCard('card-tls-config',event)">
949
<h2>TLS / HTTPS</h2><span class="card-desc">HTTPS and Let's Encrypt configuration</span><span class="collapse-icon">▾</span>
950
<div class="spacer"></div>
951
<button class="sm primary" onclick="event.stopPropagation();saveTLSConfig()">save</button>
952
</div>
953
<div class="card-body">
954
<div class="setting-row">
955
<div class="setting-label">domain</div>
956
<div class="setting-desc">Domain for Let's Encrypt certificate. Leave blank for HTTP only. Requires restart.</div>
957
<input type="text" id="tls-domain" placeholder="scuttlebot.example.com" style="width:240px;padding:4px 8px;font-size:12px">
958
</div>
959
<div class="setting-row">
960
<div class="setting-label">email</div>
961
<div class="setting-desc">Sent to Let's Encrypt for expiry notifications.</div>
962
<input type="email" id="tls-email" placeholder="[email protected]" style="width:240px;padding:4px 8px;font-size:12px">
963
</div>
964
<div class="setting-row">
965
<div class="setting-label">allow insecure</div>
966
<div class="setting-desc">Keep HTTP running on :80 alongside HTTPS.</div>
967
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
968
<input type="checkbox" id="tls-allow-insecure">
969
<span style="font-size:12px">enabled</span>
970
</label>
971
</div>
972
</div>
973
<div id="tls-config-save-result" style="display:none;margin:0 16px 12px"></div>
974
</div>
975
976
<!-- topology -->
977
<div class="card" id="card-topology">
978
<div class="card-header" onclick="toggleCard('card-topology',event)">
979
<h2>topology</h2><span class="card-desc">static channels and prefix-based channel rules</span><span class="collapse-icon">▾</span>
980
<div class="spacer"></div>
981
<button class="sm" onclick="event.stopPropagation();loadConfigCards()">↺ refresh</button>
982
<button class="sm primary" onclick="event.stopPropagation();saveTopologyConfig()">save</button>
983
</div>
984
<div class="card-body">
985
<div class="setting-row">
986
<div class="setting-label">manager nick</div>
987
<div class="setting-desc">IRC nick used by the topology manager to register channels via ChanServ.</div>
988
<input type="text" id="topo-nick" placeholder="topology" style="width:160px;padding:4px 8px;font-size:12px">
989
</div>
990
<div class="setting-row">
991
<div class="setting-label">config history</div>
992
<div class="setting-desc">Number of scuttlebot.yaml snapshots to keep before pruning.</div>
993
<div style="display:flex;align-items:center;gap:6px">
994
<input type="number" id="topo-history-keep" placeholder="20" min="0" style="width:80px;padding:4px 8px;font-size:12px">
995
<span style="font-size:12px;color:#8b949e">snapshots</span>
996
</div>
997
</div>
998
999
<!-- static channels -->
1000
<div style="margin-top:20px;margin-bottom:8px;display:flex;align-items:center;gap:10px">
1001
<strong style="font-size:13px">static channels</strong>
1002
<span style="font-size:11px;color:#8b949e;flex:1">Provisioned at startup. ChanServ registers these channels and invites the listed bots.</span>
1003
<button class="sm" onclick="topoAddStaticChannel()">+ add</button>
1004
</div>
1005
<div id="topo-static-channels">
1006
<div class="empty-state" style="padding:12px;font-size:12px">no static channels configured</div>
1007
</div>
1008
1009
<!-- channel types -->
1010
<div style="margin-top:20px;margin-bottom:8px;display:flex;align-items:center;gap:10px">
1011
<strong style="font-size:13px">channel types</strong>
1012
<span style="font-size:11px;color:#8b949e;flex:1">Prefix-based rules applied when agents create channels.</span>
1013
<button class="sm" onclick="topoAddChannelType()">+ add</button>
1014
</div>
1015
<div id="topo-channel-types">
1016
<div class="empty-state" style="padding:12px;font-size:12px">no channel types configured</div>
1017
</div>
1018
1019
<div id="topo-save-result" style="display:none;margin-top:12px"></div>
1020
</div>
1021
</div>
1022
1023
<!-- about -->
1024
<div class="card">
1025
<div class="card-header" style="cursor:default"><h2>about</h2></div>
1026
<div class="card-body" style="font-size:13px;color:#8b949e;line-height:1.8">
1027
<p><strong style="color:#e6edf3">ScuttleBot</strong> — agent coordination backplane over IRC.</p>
1028
<p>Agents register, receive SASL credentials, and coordinate in IRC channels.</p>
1029
<p>Everything is human observable: all activity is visible in the IRC channel log.</p>
1030
<p style="margin-top:12px;font-size:11px;color:#6e7681">
1031
Copyright &copy; 2026 CONFLICT LLC. All rights reserved.<br>
1032
<a href="https://scuttlebot.dev" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">ScuttleBot</a> — Powered by <a href="https://weareconflict.com" target="_blank" rel="noopener" style="color:#58a6ff;text-decoration:none">CONFLICT</a>
1033
</p>
1034
</div>
1035
</div>
1036
1037
</div>
1038
</div>
1039
1040
<!-- AI -->
1041
<div class="tab-pane" id="pane-ai">
1042
<div class="pane-inner">
1043
1044
<!-- LLM backends -->
1045
<div class="card" id="card-ai-backends">
1046
<div class="card-header" style="cursor:default">
1047
<h2>LLM backends</h2><span class="card-desc">configured providers for oracle and other LLM bots</span>
1048
<div class="spacer"></div>
1049
<button class="sm" onclick="loadAI()">↺ refresh</button>
1050
<button class="sm primary" onclick="openAddBackend()">+ add backend</button>
1051
</div>
1052
<div class="card-body" style="padding:0">
1053
<div id="ai-backends-list" style="padding:16px">
1054
<div class="empty-state">loading…</div>
1055
</div>
1056
</div>
1057
</div>
1058
1059
<!-- add/edit backend form (hidden until opened) -->
1060
<div class="card" id="card-ai-form" style="display:none">
1061
<div class="card-header" style="cursor:default">
1062
<h2 id="ai-form-title">add backend</h2>
1063
<div class="spacer"></div>
1064
<button class="sm" onclick="closeBackendForm()">✕ cancel</button>
1065
</div>
1066
<div class="card-body">
1067
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
1068
<div>
1069
<label>name *</label>
1070
<input type="text" id="bf-name" placeholder="openai-main" autocomplete="off">
1071
<div class="hint">unique identifier — used in oracle's backend field</div>
1072
</div>
1073
<div>
1074
<label>backend type *</label>
1075
<select id="bf-backend" onchange="onBackendTypeChange()">
1076
<option value="">— select type —</option>
1077
<optgroup label="Native APIs">
1078
<option value="anthropic">anthropic</option>
1079
<option value="gemini">gemini</option>
1080
<option value="bedrock">bedrock</option>
1081
<option value="ollama">ollama</option>
1082
</optgroup>
1083
<optgroup label="OpenAI-compatible">
1084
<option value="openai">openai</option>
1085
<option value="openrouter">openrouter</option>
1086
<option value="together">together</option>
1087
<option value="groq">groq</option>
1088
<option value="fireworks">fireworks</option>
1089
<option value="mistral">mistral</option>
1090
<option value="ai21">ai21</option>
1091
<option value="huggingface">huggingface</option>
1092
<option value="deepseek">deepseek</option>
1093
<option value="cerebras">cerebras</option>
1094
<option value="xai">xai</option>
1095
<option value="litellm">litellm (local)</option>
1096
<option value="lmstudio">lm studio (local)</option>
1097
<option value="jan">jan (local)</option>
1098
<option value="localai">localai (local)</option>
1099
<option value="vllm">vllm (local)</option>
1100
<option value="anythingllm">anythingllm (local)</option>
1101
</optgroup>
1102
</select>
1103
</div>
1104
1105
<!-- shown for non-bedrock backends -->
1106
<div id="bf-apikey-row">
1107
<label>API key</label>
1108
<input type="password" id="bf-apikey" placeholder="sk-…" autocomplete="new-password">
1109
<div class="hint" id="bf-apikey-hint">Leave blank to use env var or instance role</div>
1110
</div>
1111
1112
<!-- shown for ollama and OpenAI-compat local backends -->
1113
<div id="bf-baseurl-row">
1114
<label>base URL</label>
1115
<input type="text" id="bf-baseurl" placeholder="http://localhost:11434" autocomplete="off">
1116
<div class="hint">Override default endpoint for self-hosted backends</div>
1117
</div>
1118
1119
<div>
1120
<label>model</label>
1121
<div style="display:flex;gap:6px;align-items:flex-start">
1122
<div style="flex:1">
1123
<select id="bf-model-select" onchange="onModelSelectChange()" style="width:100%">
1124
<option value="">— select or load models —</option>
1125
</select>
1126
<input type="text" id="bf-model-custom" placeholder="model-id" autocomplete="off" style="display:none;margin-top:6px">
1127
</div>
1128
<button type="button" class="sm" id="bf-load-models-btn" onclick="loadLiveModels(this)" style="white-space:nowrap;margin-top:1px">↺ load models</button>
1129
</div>
1130
<div class="hint">Pick from list or load live from API. Leave blank to auto-select.</div>
1131
</div>
1132
1133
<div style="display:flex;align-items:flex-end;gap:8px">
1134
<label style="margin:0;cursor:pointer;display:flex;align-items:center;gap:6px">
1135
<input type="checkbox" id="bf-default"> mark as default backend
1136
</label>
1137
</div>
1138
1139
<!-- Bedrock-specific fields -->
1140
<div id="bf-bedrock-group" style="display:none;grid-column:1/-1">
1141
<div style="font-size:12px;color:#8b949e;font-weight:500;text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px">AWS / Bedrock</div>
1142
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
1143
<div>
1144
<label>region *</label>
1145
<input type="text" id="bf-region" placeholder="us-east-1" autocomplete="off">
1146
</div>
1147
<div>
1148
<label>AWS key ID</label>
1149
<input type="text" id="bf-aws-key-id" placeholder="AKIA… (or leave blank for IAM role)" autocomplete="off">
1150
<div class="hint">Leave blank — scuttlebot will auto-detect IAM role (ECS/EC2/EKS)</div>
1151
</div>
1152
<div>
1153
<label>AWS secret key</label>
1154
<input type="password" id="bf-aws-secret" placeholder="(or leave blank for IAM role)" autocomplete="new-password">
1155
</div>
1156
</div>
1157
</div>
1158
1159
<!-- allow/block filters -->
1160
<div style="grid-column:1/-1">
1161
<div style="font-size:12px;color:#8b949e;font-weight:500;text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px">Model filters (regex, one per line)</div>
1162
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
1163
<div>
1164
<label>allow list</label>
1165
<textarea id="bf-allow" rows="3" placeholder="^gpt-4&#10;^claude-3" style="font-family:var(--font-mono);font-size:12px"></textarea>
1166
<div class="hint">Only these models shown. Empty = all.</div>
1167
</div>
1168
<div>
1169
<label>block list</label>
1170
<textarea id="bf-block" rows="3" placeholder=".*-instruct$&#10;.*-preview$" style="font-family:var(--font-mono);font-size:12px"></textarea>
1171
<div class="hint">Always hidden.</div>
1172
</div>
1173
</div>
1174
</div>
1175
</div>
1176
<div id="ai-form-result" style="display:none;margin-top:12px"></div>
1177
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px">
1178
<button class="sm" onclick="closeBackendForm()">cancel</button>
1179
<button class="sm primary" id="bf-submit-btn" onclick="submitBackendForm()">add backend</button>
1180
</div>
1181
</div>
1182
</div>
1183
1184
<!-- supported backends reference -->
1185
<div class="card" id="card-ai-supported">
1186
<div class="card-header" onclick="toggleCard('card-ai-supported',event)"><h2>supported backends</h2><span class="card-desc">all available provider types</span><span class="collapse-icon">▾</span></div>
1187
<div class="card-body" id="ai-supported-list">
1188
<div class="empty-state">loading…</div>
1189
</div>
1190
</div>
1191
1192
<!-- config example -->
1193
<div class="card" id="card-ai-example" style="display:none">
1194
<div class="card-header" onclick="toggleCard('card-ai-example',event)"><h2>YAML example</h2><span class="card-desc">copy-paste starter config</span><span class="collapse-icon">▾</span></div>
1195
<div class="card-body">
1196
<pre style="font-size:12px;color:#a5d6ff;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:12px;overflow-x:auto;white-space:pre">llm:
1197
backends:
1198
- name: openai-main
1199
backend: openai
1200
api_key: sk-...
1201
model: gpt-4o-mini
1202
block: [".*-instruct$"] # optional regex filter
1203
1204
- name: local-ollama
1205
backend: ollama
1206
base_url: http://localhost:11434
1207
model: llama3.2
1208
default: true
1209
1210
- name: anthropic-claude
1211
backend: anthropic
1212
api_key: sk-ant-...
1213
model: claude-3-5-sonnet-20241022
1214
1215
- name: bedrock-us
1216
backend: bedrock
1217
region: us-east-1
1218
aws_key_id: AKIA...
1219
aws_secret_key: ...
1220
allow: ["^anthropic\\."] # only Anthropic models
1221
1222
- name: groq-fast
1223
backend: groq
1224
api_key: gsk_...</pre>
1225
<p style="font-size:12px;color:#8b949e;margin-top:8px">
1226
Reference a backend from oracle's behavior config using the <code>backend</code> key.
1227
</p>
1228
</div>
1229
</div>
1230
1231
</div>
1232
</div>
1233
1234
<!-- Register drawer -->
1235
<div class="drawer-overlay" id="drawer-overlay" onclick="closeDrawer()"></div>
1236
<div class="drawer" id="register-drawer">
1237
<div class="drawer-header">
1238
<h3>register agent</h3>
1239
<div class="spacer"></div>
1240
<button class="sm" onclick="closeDrawer()">✕</button>
1241
</div>
1242
<div class="drawer-body">
1243
<form id="register-form" onsubmit="handleRegister(event)">
1244
<div class="form-row">
1245
<div>
1246
<label>nick *</label>
1247
<input type="text" id="reg-nick" placeholder="my-agent-01" required autocomplete="off">
1248
</div>
1249
<div>
1250
<label>type</label>
1251
<select id="reg-type">
1252
<option value="worker" selected>worker — +v</option>
1253
<option value="orchestrator">orchestrator — +o</option>
1254
<option value="observer">observer — read only</option>
1255
</select>
1256
</div>
1257
</div>
1258
<div>
1259
<label>channels</label>
1260
<input type="text" id="reg-channels" placeholder="#fleet, #ops, #project.foo" autocomplete="off">
1261
<div class="hint">comma-separated; must start with #</div>
1262
</div>
1263
<div>
1264
<label>permissions</label>
1265
<input type="text" id="reg-permissions" placeholder="task.create, task.update" autocomplete="off">
1266
<div class="hint">comma-separated message types this agent may send</div>
1267
</div>
1268
<div id="register-result" style="display:none"></div>
1269
<div style="display:flex;justify-content:flex-end;gap:8px">
1270
<button type="button" onclick="closeDrawer()">cancel</button>
1271
<button type="submit" class="primary">register</button>
1272
</div>
1273
</form>
1274
</div>
1275
</div>
1276
1277
<!-- Register user drawer (operator with fresh credentials) -->
1278
<div class="drawer-overlay" id="register-user-overlay" onclick="closeRegisterUserDrawer()"></div>
1279
<div class="drawer" id="register-user-drawer">
1280
<div class="drawer-header">
1281
<h3>register user</h3>
1282
<div class="spacer"></div>
1283
<button class="sm" onclick="closeRegisterUserDrawer()">✕</button>
1284
</div>
1285
<div class="drawer-body">
1286
<form id="register-user-form" onsubmit="handleRegisterUser(event)">
1287
<div>
1288
<label>nick *</label>
1289
<input type="text" id="regu-nick" placeholder="alice" required autocomplete="off">
1290
<div class="hint">new NickServ account will be created; credentials returned once</div>
1291
</div>
1292
<div>
1293
<label>channels</label>
1294
<input type="text" id="regu-channels" placeholder="#ops, #general" autocomplete="off">
1295
<div class="hint">comma-separated</div>
1296
</div>
1297
<div id="register-user-result" style="display:none"></div>
1298
<div style="display:flex;justify-content:flex-end;gap:8px">
1299
<button type="button" onclick="closeRegisterUserDrawer()">cancel</button>
1300
<button type="submit" class="primary">register</button>
1301
</div>
1302
</form>
1303
</div>
1304
</div>
1305
1306
<!-- Adopt user drawer (claim pre-existing NickServ account) -->
1307
<div class="drawer-overlay" id="adopt-overlay" onclick="closeAdoptDrawer()"></div>
1308
<div class="drawer" id="adopt-drawer">
1309
<div class="drawer-header">
1310
<h3>adopt existing user</h3>
1311
<div class="spacer"></div>
1312
<button class="sm" onclick="closeAdoptDrawer()">✕</button>
1313
</div>
1314
<div class="drawer-body">
1315
<p style="font-size:12px;color:#8b949e;margin-bottom:16px">Adds a pre-existing NickServ account to the registry without changing its password. Use this for accounts already connected to IRC.</p>
1316
<form id="adopt-form" onsubmit="handleAdopt(event)">
1317
<div>
1318
<label>nick *</label>
1319
<input type="text" id="adopt-nick" placeholder="existing-irc-nick" required autocomplete="off">
1320
</div>
1321
<div>
1322
<label>channels</label>
1323
<input type="text" id="adopt-channels" placeholder="#ops, #general" autocomplete="off">
1324
<div class="hint">comma-separated</div>
1325
</div>
1326
<div id="adopt-result" style="display:none"></div>
1327
<div style="display:flex;justify-content:flex-end;gap:8px">
1328
<button type="button" onclick="closeAdoptDrawer()">cancel</button>
1329
<button type="submit" class="primary">adopt</button>
1330
</div>
1331
</form>
1332
</div>
1333
</div>
1334
1335
1336
<script>
1337
// --- tabs ---
1338
const TAB_LOADERS = { status: loadStatus, users: loadAgents, agents: loadAgents, channels: loadChanTab, chat: () => { populateChatIdentity(); loadChannels(); }, ai: loadAI, settings: loadSettings };
1339
function switchTab(name) {
1340
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
1341
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
1342
document.getElementById('tab-' + name).classList.add('active');
1343
document.getElementById('pane-' + name).classList.add('active');
1344
if (name === 'chat') {
1345
_chatUnread = 0;
1346
delete document.getElementById('tab-chat').dataset.unread;
1347
}
1348
if (TAB_LOADERS[name]) TAB_LOADERS[name]();
1349
}
1350
1351
// --- auth ---
1352
function getToken() { return localStorage.getItem('sb_token') || ''; }
1353
function getUsername() { return localStorage.getItem('sb_username') || ''; }
1354
1355
function showLoginScreen() {
1356
document.getElementById('login-screen').style.display = 'flex';
1357
setTimeout(() => document.getElementById('login-username')?.focus(), 80);
1358
}
1359
function hideLoginScreen() {
1360
document.getElementById('login-screen').style.display = 'none';
1361
}
1362
1363
function updateHeaderUser() {
1364
const u = getUsername();
1365
const t = getToken();
1366
const label = u ? '@' + u : (t ? t.slice(0,8)+'…' : '');
1367
document.getElementById('header-user-display').textContent = label;
1368
const su = document.getElementById('settings-username-display');
1369
if (su) su.textContent = u || 'token auth';
1370
document.getElementById('settings-api-url').textContent = location.origin;
1371
document.getElementById('no-token-banner').style.display = t ? 'none' : 'flex';
1372
}
1373
1374
async function handleLogin(e) {
1375
e.preventDefault();
1376
const username = document.getElementById('login-username').value.trim();
1377
const password = document.getElementById('login-password').value;
1378
const btn = document.getElementById('login-btn');
1379
const errEl = document.getElementById('login-error');
1380
if (!username || !password) return;
1381
btn.disabled = true; btn.textContent = 'signing in…';
1382
errEl.style.display = 'none';
1383
try {
1384
const resp = await fetch('/login', {
1385
method: 'POST',
1386
headers: {'Content-Type':'application/json'},
1387
body: JSON.stringify({username, password}),
1388
});
1389
const data = await resp.json().catch(() => ({}));
1390
if (!resp.ok) throw new Error(data.error || 'Login failed');
1391
localStorage.setItem('sb_token', data.token);
1392
localStorage.setItem('sb_username', data.username || username);
1393
hideLoginScreen();
1394
updateHeaderUser();
1395
loadAll();
1396
} catch(err) {
1397
errEl.style.display = 'block';
1398
errEl.innerHTML = renderAlert('error', err.message);
1399
btn.disabled = false; btn.textContent = 'sign in';
1400
}
1401
}
1402
1403
function saveTokenLogin() {
1404
const v = document.getElementById('token-login-input').value.trim();
1405
if (!v) return;
1406
localStorage.setItem('sb_token', v);
1407
localStorage.removeItem('sb_username');
1408
hideLoginScreen();
1409
updateHeaderUser();
1410
loadAll();
1411
}
1412
1413
function logout() {
1414
localStorage.removeItem('sb_token');
1415
localStorage.removeItem('sb_username');
1416
location.reload();
1417
}
1418
1419
function initAuth() {
1420
if (!getToken()) { showLoginScreen(); return; }
1421
hideLoginScreen();
1422
updateHeaderUser();
1423
loadAll();
1424
}
1425
1426
document.addEventListener('keydown', e => { if(e.key==='Escape'){ closeDrawer(); closeRegisterUserDrawer(); closeAdoptDrawer(); } });
1427
1428
// --- API ---
1429
async function api(method, path, body) {
1430
const opts = { method, cache: 'no-store', headers: { 'Authorization':'Bearer '+getToken(), 'Content-Type':'application/json' } };
1431
if (body !== undefined) opts.body = JSON.stringify(body);
1432
const res = await fetch(path, opts);
1433
if (res.status === 401) {
1434
localStorage.removeItem('sb_token');
1435
localStorage.removeItem('sb_username');
1436
showLoginScreen();
1437
throw new Error('Session expired — please sign in again');
1438
}
1439
if (res.status === 204) return null;
1440
const data = await res.json().catch(() => ({ error: res.statusText }));
1441
if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status });
1442
return data;
1443
}
1444
function copyText(text, btn) {
1445
navigator.clipboard.writeText(text).then(() => { const o=btn.textContent; btn.textContent='✓'; setTimeout(()=>{btn.textContent=o;},1200); });
1446
}
1447
1448
// --- charts ---
1449
const CHART_POINTS = 60; // 5 min at 5s intervals
1450
const chartData = {
1451
labels: Array(CHART_POINTS).fill(''),
1452
heap: Array(CHART_POINTS).fill(null),
1453
goroutines: Array(CHART_POINTS).fill(null),
1454
messages: Array(CHART_POINTS).fill(null),
1455
};
1456
let charts = {};
1457
1458
function mkChart(id, label, color) {
1459
const ctx = document.getElementById(id).getContext('2d');
1460
return new Chart(ctx, {
1461
type: 'line',
1462
data: {
1463
labels: chartData.labels,
1464
datasets: [{
1465
label,
1466
data: chartData[id.replace('chart-','')],
1467
borderColor: color,
1468
backgroundColor: color+'22',
1469
borderWidth: 1.5,
1470
pointRadius: 0,
1471
tension: 0.3,
1472
fill: true,
1473
}],
1474
},
1475
options: {
1476
responsive: true,
1477
animation: false,
1478
plugins: { legend: { display: false } },
1479
scales: {
1480
x: { display: false },
1481
y: { display: true, grid: { color: '#21262d' }, ticks: { color: '#8b949e', font: { size: 10 }, maxTicksLimit: 4 } },
1482
},
1483
},
1484
});
1485
}
1486
1487
function initCharts() {
1488
if (charts.heap) return;
1489
charts.heap = mkChart('chart-heap', 'heap', '#58a6ff');
1490
charts.goroutines = mkChart('chart-goroutines', 'goroutines', '#3fb950');
1491
charts.messages = mkChart('chart-messages', 'messages', '#d2a8ff');
1492
}
1493
1494
function fmtBytes(b) {
1495
if (b == null) return '—';
1496
if (b < 1024) return b+'B';
1497
if (b < 1048576) return (b/1024).toFixed(1)+'KB';
1498
return (b/1048576).toFixed(1)+'MB';
1499
}
1500
1501
function pushMetrics(m) {
1502
chartData.heap.push(m.runtime.heap_alloc_bytes/1048576);
1503
chartData.heap.shift();
1504
chartData.goroutines.push(m.runtime.goroutines);
1505
chartData.goroutines.shift();
1506
const msgs = m.bridge ? m.bridge.messages_total : null;
1507
chartData.messages.push(msgs);
1508
chartData.messages.shift();
1509
1510
// Reassign dataset data arrays (shared reference, Chart.js reads them directly).
1511
charts.heap.data.datasets[0].data = chartData.heap;
1512
charts.goroutines.data.datasets[0].data = chartData.goroutines;
1513
charts.messages.data.datasets[0].data = chartData.messages;
1514
charts.heap.update('none');
1515
charts.goroutines.update('none');
1516
charts.messages.update('none');
1517
}
1518
1519
// --- status ---
1520
async function loadStatus() {
1521
try {
1522
const [s, m] = await Promise.all([
1523
api('GET', '/v1/status'),
1524
api('GET', '/v1/metrics'),
1525
]);
1526
1527
// Status card.
1528
document.getElementById('stat-status').innerHTML = '<span class="dot green"></span>'+s.status;
1529
document.getElementById('stat-uptime').textContent = s.uptime;
1530
const onlineAgents = allAgents.filter(a => a.online).length;
1531
document.getElementById('stat-agents').textContent = onlineAgents + '/' + s.agents;
1532
const d = new Date(s.started);
1533
document.getElementById('stat-started').textContent = d.toLocaleTimeString();
1534
document.getElementById('stat-started-rel').textContent = d.toLocaleDateString();
1535
document.getElementById('status-error').style.display = 'none';
1536
document.getElementById('metrics-updated').textContent = 'updated '+new Date().toLocaleTimeString();
1537
1538
// Runtime card.
1539
document.getElementById('stat-goroutines').textContent = m.runtime.goroutines;
1540
document.getElementById('stat-heap').textContent = fmtBytes(m.runtime.heap_alloc_bytes);
1541
document.getElementById('stat-heapsys').textContent = fmtBytes(m.runtime.heap_sys_bytes);
1542
document.getElementById('stat-gc').textContent = m.runtime.gc_runs;
1543
document.getElementById('chart-heap-val').textContent = fmtBytes(m.runtime.heap_alloc_bytes);
1544
document.getElementById('chart-goroutines-val').textContent = m.runtime.goroutines;
1545
document.getElementById('chart-messages-val').textContent = m.bridge ? m.bridge.messages_total : '—';
1546
1547
// Bridge card.
1548
if (m.bridge) {
1549
document.getElementById('bridge-card').style.display = '';
1550
document.getElementById('stat-bridge-channels').textContent = m.bridge.channels;
1551
document.getElementById('stat-bridge-msgs').textContent = m.bridge.messages_total;
1552
document.getElementById('stat-bridge-subs').textContent = m.bridge.active_subscribers;
1553
}
1554
1555
// Registry card.
1556
document.getElementById('stat-reg-total').textContent = m.registry.total;
1557
document.getElementById('stat-reg-active').textContent = m.registry.active;
1558
document.getElementById('stat-reg-revoked').textContent = m.registry.revoked;
1559
1560
// Push to charts.
1561
initCharts();
1562
pushMetrics(m);
1563
} catch(e) {
1564
document.getElementById('stat-status').innerHTML = '<span class="dot red"></span>error';
1565
const el = document.getElementById('status-error');
1566
el.style.display = 'block';
1567
el.innerHTML = renderAlert('error', e.message);
1568
}
1569
}
1570
1571
let metricsTimer = null;
1572
function startMetricsPoll() {
1573
if (metricsTimer) return;
1574
metricsTimer = setInterval(() => {
1575
if (document.getElementById('pane-status').classList.contains('active')) loadStatus();
1576
}, 5000);
1577
}
1578
1579
// --- agents + users (shared data) ---
1580
let allAgents = [];
1581
async function loadAgents() {
1582
try {
1583
const data = await api('GET', '/v1/agents');
1584
allAgents = data.agents || [];
1585
renderUsersTable();
1586
renderAgentTable();
1587
populateChatIdentity();
1588
} catch(e) {
1589
const msg = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1590
document.getElementById('agents-container').innerHTML = msg;
1591
document.getElementById('users-container').innerHTML = msg;
1592
}
1593
}
1594
1595
function renderTable(container, countEl, rows, emptyMsg, cols) {
1596
if (rows.length === 0) {
1597
document.getElementById(container).innerHTML = '<div class="empty">'+emptyMsg+'</div>';
1598
} else {
1599
const ths = cols.map(c=>`<th>${c}</th>`).join('');
1600
document.getElementById(container).innerHTML =
1601
`<table><thead><tr>${ths}</tr></thead><tbody>${rows.join('')}</tbody></table>`;
1602
}
1603
if (countEl) document.getElementById(countEl).textContent = rows.length;
1604
}
1605
1606
function renderUsersTable() {
1607
const q = (document.getElementById('user-search').value||'').toLowerCase();
1608
const users = allAgents.filter(a => a.type === 'operator' && (!q ||
1609
a.nick.toLowerCase().includes(q) ||
1610
(a.config?.channels||[]).some(c => c.toLowerCase().includes(q))));
1611
const rows = users.map(a => {
1612
const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1613
const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1614
return `<tr>
1615
<td><strong>${esc(a.nick)}</strong></td>
1616
<td><span class="tag type-operator">operator</span>${rev}</td>
1617
<td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1618
<td style="white-space:nowrap">${fmtTime(a.created_at)}</td>
1619
<td><div class="actions">${!a.revoked?`
1620
<button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
1621
<button class="sm danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>`:``}
1622
<button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
1623
</tr>`;
1624
});
1625
const all = allAgents.filter(a => a.type === 'operator');
1626
const countTxt = all.length + (rows.length !== all.length ? ' / '+rows.length+' shown' : '');
1627
document.getElementById('user-count').textContent = countTxt;
1628
renderTable('users-container', null, rows,
1629
all.length ? 'no users match the filter' : 'no users registered yet',
1630
['nick','type','channels','registered','']);
1631
}
1632
1633
function relTime(ts) {
1634
if (!ts) return 'never';
1635
const ms = Date.now() - new Date(ts).getTime();
1636
if (ms < 0) return 'just now';
1637
const s = Math.floor(ms/1000), m = Math.floor(s/60), h = Math.floor(m/60), d = Math.floor(h/24);
1638
if (d > 0) return d + 'd ago';
1639
if (h > 0) return h + 'h ago';
1640
if (m > 0) return m + 'm ago';
1641
return s + 's ago';
1642
}
1643
1644
function presenceDot(a) {
1645
if (a.revoked) return '<span style="color:#f85149" title="revoked">◼</span>';
1646
if (a.online) return '<span style="color:#3fb950" title="online">●</span>';
1647
if (a.last_seen) {
1648
const mins = (Date.now() - new Date(a.last_seen).getTime()) / 60000;
1649
if (mins < 10) return '<span style="color:#d29922" title="idle">●</span>';
1650
}
1651
return '<span style="color:#484f58" title="offline">●</span>';
1652
}
1653
1654
let agentPage = 0;
1655
const AGENTS_PER_PAGE = 25;
1656
1657
function renderAgentTable() {
1658
const q = (document.getElementById('agent-search').value||'').toLowerCase();
1659
const statusFilter = document.getElementById('agent-status-filter').value;
1660
const bots = allAgents.filter(a => a.type !== 'operator');
1661
1662
// Status filter.
1663
let filtered = bots;
1664
if (statusFilter === 'online') filtered = bots.filter(a => a.online);
1665
else if (statusFilter === 'offline') filtered = bots.filter(a => !a.online && !a.revoked);
1666
else if (statusFilter === 'revoked') filtered = bots.filter(a => a.revoked);
1667
1668
// Text search.
1669
const agents = filtered.filter(a => !q ||
1670
a.nick.toLowerCase().includes(q) ||
1671
a.type.toLowerCase().includes(q) ||
1672
(a.config?.channels||[]).some(c => c.toLowerCase().includes(q)) ||
1673
(a.config?.permissions||[]).some(p => p.toLowerCase().includes(q)));
1674
1675
// Sort: online first, then by last_seen descending, then by nick.
1676
agents.sort((a, b) => {
1677
if (a.online !== b.online) return a.online ? -1 : 1;
1678
const aT = a.last_seen ? new Date(a.last_seen).getTime() : 0;
1679
const bT = b.last_seen ? new Date(b.last_seen).getTime() : 0;
1680
if (aT !== bT) return bT - aT;
1681
return a.nick.localeCompare(b.nick);
1682
});
1683
1684
// Pagination.
1685
const totalPages = Math.max(1, Math.ceil(agents.length / AGENTS_PER_PAGE));
1686
if (agentPage >= totalPages) agentPage = totalPages - 1;
1687
if (agentPage < 0) agentPage = 0;
1688
const start = agentPage * AGENTS_PER_PAGE;
1689
const pageAgents = agents.slice(start, start + AGENTS_PER_PAGE);
1690
1691
const onlineCount = bots.filter(a => a.online).length;
1692
let countText = onlineCount + '/' + bots.length;
1693
if (agents.length !== bots.length) countText = agents.length + ' of ' + bots.length;
1694
document.getElementById('agent-count').textContent = countText;
1695
1696
// Pagination controls.
1697
const pagEl = document.getElementById('agent-pagination');
1698
if (agents.length > AGENTS_PER_PAGE) {
1699
pagEl.style.display = 'flex';
1700
document.getElementById('agent-prev').disabled = agentPage === 0;
1701
document.getElementById('agent-next').disabled = agentPage >= totalPages - 1;
1702
document.getElementById('agent-page-info').textContent = `page ${agentPage+1}/${totalPages}`;
1703
} else {
1704
pagEl.style.display = 'none';
1705
}
1706
1707
const rows = pageAgents.map(a => {
1708
const chs = (a.config?.channels||[]).map(c=>`<span class="tag ch">${esc(c)}</span>`).join('');
1709
const rev = a.revoked ? '<span class="tag revoked">revoked</span>' : '';
1710
const seen = a.last_seen ? relTime(a.last_seen) : 'never';
1711
const seenStyle = a.online ? 'color:#3fb950' : 'color:#8b949e';
1712
return `<tr${a.revoked?' style="opacity:0.5"':''}>
1713
<td><input type="checkbox" class="agent-select" value="${esc(a.nick)}" onchange="updateBulkBtn()" style="margin-right:6px">${presenceDot(a)} <strong>${esc(a.nick)}</strong></td>
1714
<td><span class="tag type-${a.type}">${esc(a.type)}</span>${rev}</td>
1715
<td>${chs||'<span style="color:#8b949e">—</span>'}</td>
1716
<td style="white-space:nowrap;${seenStyle}">${seen}</td>
1717
<td><div class="actions">${!a.revoked?`
1718
<button class="sm" onclick="rotateAgent('${esc(a.nick)}')">rotate</button>
1719
<button class="sm danger" onclick="revokeAgent('${esc(a.nick)}')">revoke</button>`:``}
1720
<button class="sm danger" onclick="deleteAgent('${esc(a.nick)}')">delete</button></div></td>
1721
</tr>`;
1722
});
1723
renderTable('agents-container', null, rows,
1724
bots.length ? 'no agents match the filter' : 'no agents registered yet',
1725
['<input type="checkbox" id="agent-select-all" onchange="toggleSelectAllAgents(this.checked)" style="margin-right:6px">nick','type','channels','last seen','']);
1726
}
1727
1728
async function revokeAgent(nick) {
1729
if (!confirm(`Revoke "${nick}"? This cannot be undone.`)) return;
1730
try { await api('POST', `/v1/agents/${nick}/revoke`); await loadAgents(); await loadStatus(); }
1731
catch(e) { alert('Revoke failed: '+e.message); }
1732
}
1733
async function deleteAgent(nick) {
1734
if (!confirm(`Delete "${nick}"? This permanently removes the agent from the registry.`)) return;
1735
try { await api('DELETE', `/v1/agents/${nick}`); await loadAgents(); await loadStatus(); }
1736
catch(e) { alert('Delete failed: '+e.message); }
1737
}
1738
function toggleSelectAllAgents(checked) {
1739
document.querySelectorAll('.agent-select').forEach(cb => cb.checked = checked);
1740
updateBulkBtn();
1741
}
1742
function updateBulkBtn() {
1743
const checked = document.querySelectorAll('.agent-select:checked');
1744
const btn = document.getElementById('bulk-delete-btn');
1745
btn.style.display = checked.length > 0 ? '' : 'none';
1746
btn.textContent = `delete selected (${checked.length})`;
1747
}
1748
async function bulkDeleteAgents() {
1749
const nicks = [...document.querySelectorAll('.agent-select:checked')].map(cb => cb.value);
1750
if (!nicks.length) return;
1751
if (!confirm(`Delete ${nicks.length} agent(s)? This permanently removes them from the registry.\n\n${nicks.join(', ')}`)) return;
1752
try {
1753
const result = await api('POST', '/v1/agents/bulk-delete', {nicks});
1754
await loadAgents();
1755
await loadStatus();
1756
if (result.failed > 0) alert(`Deleted ${result.deleted}, failed ${result.failed}`);
1757
} catch(e) { alert('Bulk delete failed: ' + e.message); }
1758
}
1759
async function rotateAgent(nick) {
1760
try {
1761
const creds = await api('POST', `/v1/agents/${nick}/rotate`);
1762
// Show result in whichever drawer is relevant.
1763
showCredentials(nick, creds, null, 'rotate');
1764
openDrawer();
1765
}
1766
catch(e) { alert('Rotate failed: '+e.message); }
1767
}
1768
1769
// --- users drawers ---
1770
function openRegisterUserDrawer() {
1771
document.getElementById('register-user-overlay').classList.add('open');
1772
document.getElementById('register-user-drawer').classList.add('open');
1773
setTimeout(() => document.getElementById('regu-nick').focus(), 100);
1774
}
1775
function closeRegisterUserDrawer() {
1776
document.getElementById('register-user-overlay').classList.remove('open');
1777
document.getElementById('register-user-drawer').classList.remove('open');
1778
}
1779
function openAdoptDrawer() {
1780
document.getElementById('adopt-overlay').classList.add('open');
1781
document.getElementById('adopt-drawer').classList.add('open');
1782
setTimeout(() => document.getElementById('adopt-nick').focus(), 100);
1783
}
1784
function closeAdoptDrawer() {
1785
document.getElementById('adopt-overlay').classList.remove('open');
1786
document.getElementById('adopt-drawer').classList.remove('open');
1787
}
1788
1789
async function handleRegisterUser(e) {
1790
e.preventDefault();
1791
const nick = document.getElementById('regu-nick').value.trim();
1792
const channels = document.getElementById('regu-channels').value.split(',').map(s=>s.trim()).filter(Boolean);
1793
const resultEl = document.getElementById('register-user-result');
1794
resultEl.style.display = 'none';
1795
try {
1796
const res = await api('POST', '/v1/agents/register', { nick, type: 'operator', channels, permissions: [] });
1797
resultEl.style.display = 'block';
1798
const pass = res.credentials?.passphrase || '';
1799
resultEl.innerHTML = renderAlert('success',
1800
`<div><strong>Registered: ${esc(nick)}</strong>
1801
<div class="cred-box">
1802
<div class="cred-row"><span class="cred-key">nick</span><span class="cred-val">${esc(nick)}</span><button class="sm" onclick="copyText('${esc(nick)}',this)">copy</button></div>
1803
<div class="cred-row"><span class="cred-key">passphrase</span><span class="cred-val">${esc(pass)}</span><button class="sm" onclick="copyText('${esc(pass)}',this)">copy</button></div>
1804
</div>
1805
<div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase — shown once only.</div></div>`);
1806
document.getElementById('register-user-form').reset();
1807
await loadAgents(); await loadStatus();
1808
} catch(e) { resultEl.style.display='block'; resultEl.innerHTML=renderAlert('error', e.message); }
1809
}
1810
1811
async function handleAdopt(e) {
1812
e.preventDefault();
1813
const nick = document.getElementById('adopt-nick').value.trim();
1814
const channels = document.getElementById('adopt-channels').value.split(',').map(s=>s.trim()).filter(Boolean);
1815
const resultEl = document.getElementById('adopt-result');
1816
resultEl.style.display = 'none';
1817
try {
1818
await api('POST', `/v1/agents/${nick}/adopt`, { type: 'operator', channels, permissions: [] });
1819
resultEl.style.display = 'block';
1820
resultEl.innerHTML = renderAlert('success',
1821
`<strong>${esc(nick)}</strong> adopted as operator — existing IRC session and passphrase unchanged.`);
1822
document.getElementById('adopt-form').reset();
1823
await loadAgents(); await loadStatus();
1824
} catch(e) { resultEl.style.display='block'; resultEl.innerHTML=renderAlert('error', e.message); }
1825
}
1826
1827
// --- drawer ---
1828
function openDrawer() {
1829
document.getElementById('drawer-overlay').classList.add('open');
1830
document.getElementById('register-drawer').classList.add('open');
1831
setTimeout(() => document.getElementById('reg-nick').focus(), 100);
1832
}
1833
function closeDrawer() {
1834
document.getElementById('drawer-overlay').classList.remove('open');
1835
document.getElementById('register-drawer').classList.remove('open');
1836
}
1837
1838
// --- register ---
1839
async function handleRegister(e) {
1840
e.preventDefault();
1841
const nick = document.getElementById('reg-nick').value.trim();
1842
const type = document.getElementById('reg-type').value;
1843
const channels = document.getElementById('reg-channels').value.split(',').map(s=>s.trim()).filter(Boolean);
1844
const permissions = document.getElementById('reg-permissions').value.split(',').map(s=>s.trim()).filter(Boolean);
1845
const resultEl = document.getElementById('register-result');
1846
resultEl.style.display = 'none';
1847
try {
1848
const res = await api('POST', '/v1/agents/register', { nick, type, channels, permissions });
1849
showCredentials(nick, res.credentials, res.payload, 'register');
1850
document.getElementById('register-form').reset();
1851
await loadAgents(); await loadStatus();
1852
} catch(e) { resultEl.style.display='block'; resultEl.innerHTML=renderAlert('error', e.message); }
1853
}
1854
function showCredentials(nick, creds, payload, mode) {
1855
const resultEl = document.getElementById('register-result');
1856
resultEl.style.display = 'block';
1857
const pass = creds?.passphrase || creds?.Passphrase || '';
1858
const sig = payload?.signature || '';
1859
resultEl.innerHTML = renderAlert('success',
1860
`<div><strong>${mode==='register'?'Registered':'Rotated'}: ${esc(nick)}</strong>
1861
<div class="cred-box">
1862
<div class="cred-row"><span class="cred-key">nick</span><span class="cred-val">${esc(creds?.nick||nick)}</span><button class="sm" onclick="copyText('${esc(creds?.nick||nick)}',this)">copy</button></div>
1863
<div class="cred-row"><span class="cred-key">passphrase</span><span class="cred-val">${esc(pass)}</span><button class="sm" onclick="copyText('${esc(pass)}',this)">copy</button></div>
1864
${sig?`<div class="cred-row"><span class="cred-key">sig</span><span class="cred-val" style="font-size:11px">${esc(sig)}</span></div>`:''}
1865
</div>
1866
<div style="margin-top:8px;font-size:11px;color:#7ee787">⚠ Save the passphrase — shown once only.</div></div>`
1867
);
1868
}
1869
1870
// --- channels tab ---
1871
let allChannels = [];
1872
1873
async function loadChanTab() {
1874
if (!getToken()) return;
1875
try {
1876
const data = await api('GET', '/v1/channels');
1877
allChannels = (data.channels || []).sort();
1878
renderChanList();
1879
} catch(e) {
1880
document.getElementById('channels-list').innerHTML = '<div style="padding:16px">'+renderAlert('error', e.message)+'</div>';
1881
}
1882
loadTopology();
1883
// Load ROE templates from policies for the ROE card.
1884
try {
1885
const s = await api('GET', '/v1/settings');
1886
if (s && s.policies) {
1887
currentPolicies = s.policies;
1888
renderROETemplates(s.policies.roe_templates || []);
1889
}
1890
} catch(e) {}
1891
}
1892
1893
function renderChanList() {
1894
const q = (document.getElementById('chan-search').value||'').toLowerCase();
1895
const filtered = allChannels.filter(ch => !q || ch.toLowerCase().includes(q));
1896
document.getElementById('chan-count').textContent = filtered.length + (filtered.length !== allChannels.length ? '/' + allChannels.length : '');
1897
if (allChannels.length === 0) {
1898
document.getElementById('channels-list').innerHTML = '<div class="empty">no channels joined yet — type a channel name above and click join</div>';
1899
return;
1900
}
1901
if (filtered.length === 0) {
1902
document.getElementById('channels-list').innerHTML = '<div class="empty">no channels match the filter</div>';
1903
return;
1904
}
1905
document.getElementById('channels-list').innerHTML = filtered.map(ch =>
1906
`<div class="chan-card">
1907
<div>
1908
<div class="chan-name">${esc(ch)}</div>
1909
<div class="chan-meta">joined</div>
1910
</div>
1911
<div class="spacer"></div>
1912
<button class="sm" onclick="switchTab('chat');setTimeout(()=>selectChannel('${esc(ch)}'),50)">open chat →</button>
1913
</div>`
1914
).join('');
1915
}
1916
async function quickJoin() {
1917
let ch = document.getElementById('quick-join-input').value.trim();
1918
if (!ch) return;
1919
if (!ch.startsWith('#')) ch = '#' + ch;
1920
const slug = ch.replace(/^#/,'');
1921
try {
1922
await api('POST', `/v1/channels/${slug}/join`);
1923
document.getElementById('quick-join-input').value = '';
1924
await loadChanTab();
1925
renderChanSidebar((await api('GET','/v1/channels')).channels||[]);
1926
} catch(e) { alert('Join failed: '+e.message); }
1927
}
1928
document.getElementById('quick-join-input').addEventListener('keydown', e => { if(e.key==='Enter')quickJoin(); });
1929
1930
// --- topology panel (#115) + task channels (#114) ---
1931
async function loadTopology() {
1932
try {
1933
const data = await api('GET', '/v1/topology');
1934
renderTopologyTypes(data.types || []);
1935
renderTopologyActive(data.active_channels || [], data.types || []);
1936
} catch(e) {
1937
document.getElementById('topology-types').innerHTML = '';
1938
document.getElementById('topology-active').innerHTML = '<div style="color:#8b949e;font-size:12px">topology not configured</div>';
1939
}
1940
}
1941
1942
function renderTopologyTypes(types) {
1943
if (!types.length) { document.getElementById('topology-types').innerHTML = ''; return; }
1944
const rows = types.map(t => {
1945
const ttl = t.ttl_seconds > 0 ? `${Math.round(t.ttl_seconds/3600)}h` : '—';
1946
const tags = [];
1947
if (t.ephemeral) tags.push('<span style="background:#f8514922;color:#f85149;padding:1px 5px;border-radius:3px;font-size:10px">ephemeral</span>');
1948
if (t.supervision) tags.push(`<span style="font-size:11px;color:#8b949e">→ ${esc(t.supervision)}</span>`);
1949
return `<tr>
1950
<td><strong>${esc(t.name)}</strong></td>
1951
<td><code style="font-size:11px">#${esc(t.prefix)}*</code></td>
1952
<td style="font-size:12px">${(t.autojoin||[]).map(n => `<code style="font-size:11px;background:#21262d;padding:1px 4px;border-radius:3px">${esc(n)}</code>`).join(' ')}</td>
1953
<td style="font-size:12px">${ttl}</td>
1954
<td>${tags.join(' ')}</td>
1955
</tr>`;
1956
}).join('');
1957
document.getElementById('topology-types').innerHTML = `<table><thead><tr><th>type</th><th>prefix</th><th>autojoin</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1958
}
1959
1960
function renderTopologyActive(channels, types) {
1961
const el = document.getElementById('topology-active');
1962
const tasks = channels.filter(c => c.ephemeral || c.type === 'task');
1963
if (!tasks.length) {
1964
el.innerHTML = '<div style="color:#8b949e;font-size:12px;padding:4px 0">no active task channels</div>';
1965
return;
1966
}
1967
const rows = tasks.map(c => {
1968
const age = c.provisioned_at ? timeSince(new Date(c.provisioned_at)) : '—';
1969
const ttl = c.ttl_seconds > 0 ? `${Math.round(c.ttl_seconds/3600)}h` : '—';
1970
return `<tr>
1971
<td><strong>${esc(c.name)}</strong></td>
1972
<td style="font-size:12px;color:#8b949e">${esc(c.type || '—')}</td>
1973
<td style="font-size:12px">${age}</td>
1974
<td style="font-size:12px">${ttl}</td>
1975
<td><button class="sm danger" onclick="dropChannel('${esc(c.name)}')">drop</button></td>
1976
</tr>`;
1977
}).join('');
1978
el.innerHTML = `<table><thead><tr><th>channel</th><th>type</th><th>age</th><th>TTL</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
1979
}
1980
1981
function timeSince(date) {
1982
const s = Math.floor((new Date() - date) / 1000);
1983
if (s < 60) return s + 's';
1984
if (s < 3600) return Math.floor(s/60) + 'm';
1985
if (s < 86400) return Math.floor(s/3600) + 'h';
1986
return Math.floor(s/86400) + 'd';
1987
}
1988
1989
async function provisionChannel() {
1990
let ch = document.getElementById('provision-channel-input').value.trim();
1991
if (!ch) return;
1992
if (!ch.startsWith('#')) ch = '#' + ch;
1993
try {
1994
await api('POST', '/v1/channels', {name: ch});
1995
document.getElementById('provision-channel-input').value = '';
1996
loadTopology();
1997
loadChanTab();
1998
} catch(e) { alert('Provision failed: ' + e.message); }
1999
}
2000
2001
async function dropChannel(ch) {
2002
if (!confirm('Drop channel ' + ch + '? This unregisters it from ChanServ.')) return;
2003
const slug = ch.replace(/^#/,'');
2004
try {
2005
await api('DELETE', `/v1/topology/channels/${slug}`);
2006
loadTopology();
2007
loadChanTab();
2008
} catch(e) { alert('Drop failed: ' + e.message); }
2009
}
2010
2011
// --- ROE template editor (#118) ---
2012
function renderROETemplates(templates) {
2013
const el = document.getElementById('roe-list');
2014
if (!templates || !templates.length) {
2015
el.innerHTML = '<div style="color:#8b949e;font-size:12px">No ROE templates defined. Click + add template to create one.</div>';
2016
return;
2017
}
2018
el.innerHTML = templates.map((t, i) => `
2019
<div style="border:1px solid #21262d;border-radius:6px;padding:12px;margin-bottom:10px;background:#0d1117">
2020
<div style="display:flex;gap:10px;align-items:center;margin-bottom:8px">
2021
<input type="text" value="${esc(t.name)}" placeholder="template name" style="flex:1;font-weight:600" onchange="updateROE(${i},'name',this.value)">
2022
<button class="sm danger" onclick="removeROE(${i})">remove</button>
2023
</div>
2024
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">
2025
<div style="flex:1;min-width:200px"><label style="font-size:11px">channels (comma-separated)</label><input type="text" value="${esc((t.channels||[]).join(', '))}" onchange="updateROE(${i},'channels',this.value)" style="width:100%"></div>
2026
<div style="flex:1;min-width:200px"><label style="font-size:11px">permissions</label><input type="text" value="${esc((t.permissions||[]).join(', '))}" onchange="updateROE(${i},'permissions',this.value)" style="width:100%"></div>
2027
</div>
2028
<div style="display:flex;gap:10px">
2029
<div><label style="font-size:11px">msg/sec</label><input type="number" value="${t.rate_limit?.messages_per_second||''}" placeholder="10" style="width:70px" onchange="updateROERateLimit(${i},'messages_per_second',this.value)"></div>
2030
<div><label style="font-size:11px">burst</label><input type="number" value="${t.rate_limit?.burst||''}" placeholder="50" style="width:70px" onchange="updateROERateLimit(${i},'burst',this.value)"></div>
2031
<div style="flex:1"><label style="font-size:11px">description</label><input type="text" value="${esc(t.description||'')}" onchange="updateROE(${i},'description',this.value)" style="width:100%"></div>
2032
</div>
2033
</div>
2034
`).join('');
2035
}
2036
2037
function addROETemplate() {
2038
if (!currentPolicies.roe_templates) currentPolicies.roe_templates = [];
2039
currentPolicies.roe_templates.push({name: 'new-template', channels: [], permissions: []});
2040
renderROETemplates(currentPolicies.roe_templates);
2041
}
2042
function removeROE(i) {
2043
currentPolicies.roe_templates.splice(i, 1);
2044
renderROETemplates(currentPolicies.roe_templates);
2045
}
2046
function updateROE(i, field, val) {
2047
if (field === 'channels' || field === 'permissions') {
2048
currentPolicies.roe_templates[i][field] = val.split(',').map(s => s.trim()).filter(Boolean);
2049
} else {
2050
currentPolicies.roe_templates[i][field] = val;
2051
}
2052
}
2053
function updateROERateLimit(i, field, val) {
2054
if (!currentPolicies.roe_templates[i].rate_limit) currentPolicies.roe_templates[i].rate_limit = {};
2055
currentPolicies.roe_templates[i].rate_limit[field] = Number(val) || 0;
2056
}
2057
2058
// --- chat ---
2059
let chatChannel = null, chatSSE = null;
2060
2061
async function loadChannels() {
2062
if (!getToken()) return;
2063
try {
2064
const data = await api('GET', '/v1/channels');
2065
renderChanSidebar(data.channels || []);
2066
} catch(e) {}
2067
}
2068
function renderChanSidebar(channels) {
2069
const list = document.getElementById('chan-list');
2070
list.innerHTML = '';
2071
channels.sort().forEach(ch => {
2072
const el = document.createElement('div');
2073
el.className = 'chan-item' + (ch===chatChannel?' active':'');
2074
el.textContent = ch;
2075
el.onclick = () => selectChannel(ch);
2076
list.appendChild(el);
2077
});
2078
}
2079
async function joinChannel() {
2080
let ch = document.getElementById('join-channel-input').value.trim();
2081
if (!ch) return;
2082
if (!ch.startsWith('#')) ch = '#' + ch;
2083
const slug = ch.replace(/^#/,'');
2084
try {
2085
await api('POST', `/v1/channels/${slug}/join`);
2086
document.getElementById('join-channel-input').value = '';
2087
const data = await api('GET', '/v1/channels');
2088
renderChanSidebar(data.channels||[]);
2089
selectChannel(ch);
2090
} catch(e) { alert('Join failed: '+e.message); }
2091
}
2092
document.getElementById('join-channel-input').addEventListener('keydown', e => { if(e.key==='Enter')joinChannel(); });
2093
2094
async function selectChannel(ch) {
2095
_lastMsgNick = null; _lastMsgAt = 0; _hideChatBanner(); _chatUnread = 0;
2096
chatChannel = ch;
2097
document.getElementById('chat-ch-name').textContent = ch;
2098
document.getElementById('chat-placeholder').style.display = 'none';
2099
document.querySelectorAll('.chan-item').forEach(el => el.classList.toggle('active', el.textContent===ch));
2100
2101
const area = document.getElementById('chat-msgs');
2102
Array.from(area.children).forEach(el => { if(!el.id) el.remove(); });
2103
2104
try {
2105
const slug = ch.replace(/^#/,'');
2106
const data = await api('GET', `/v1/channels/${slug}/messages`);
2107
(data.messages||[]).forEach(m => appendMsg(m, true));
2108
area.scrollTop = area.scrollHeight;
2109
} catch(e) {}
2110
2111
if (chatSSE) { chatSSE.close(); chatSSE = null; }
2112
const slug = ch.replace(/^#/,'');
2113
const es = new EventSource(`/v1/channels/${slug}/stream?token=${encodeURIComponent(getToken())}`);
2114
chatSSE = es;
2115
const badge = document.getElementById('chat-stream-status');
2116
es.onopen = () => { badge.textContent='● live'; badge.style.color='#3fb950'; };
2117
es.onmessage = ev => { try { appendMsg(JSON.parse(ev.data)); area.scrollTop=area.scrollHeight; } catch(_){} };
2118
es.onerror = () => { badge.textContent='○ reconnecting…'; badge.style.color='#8b949e'; };
2119
2120
loadNicklist(ch);
2121
if (_nicklistTimer) clearInterval(_nicklistTimer);
2122
_nicklistTimer = setInterval(() => loadNicklist(chatChannel), 10000);
2123
}
2124
2125
let _nicklistTimer = null;
2126
async function loadNicklist(ch) {
2127
if (!ch) return;
2128
try {
2129
const slug = ch.replace(/^#/,'');
2130
const data = await api('GET', `/v1/channels/${slug}/users`);
2131
renderNicklist(data.users || [], data.channel_modes || '');
2132
} catch(e) {}
2133
}
2134
const SYSTEM_BOTS = new Set(['bridge','oracle','sentinel','steward','scribe','warden','snitch','herald','scroll','systembot','auditbot']);
2135
const AGENT_PREFIXES = ['claude-','codex-','gemini-','openclaw-'];
2136
2137
function nickTier(nick) {
2138
const lower = nick.toLowerCase();
2139
// Check if operator (registered as operator type).
2140
const agent = allAgents.find(a => a.nick === nick);
2141
if (agent && agent.type === 'operator') return 0; // ops
2142
if (SYSTEM_BOTS.has(lower)) return 1; // system bots
2143
if (AGENT_PREFIXES.some(p => lower.startsWith(p))) return 2; // agents
2144
return 3; // regular users
2145
}
2146
2147
function nickPrefix(nick) {
2148
const tier = nickTier(nick);
2149
if (tier === 0) return '@';
2150
if (tier === 1) return '+';
2151
return '';
2152
}
2153
2154
function renderNicklist(users, channelModes) {
2155
const el = document.getElementById('nicklist-users');
2156
// users may be [{nick, modes}] or ["nick"] for backwards compat.
2157
const normalized = users.map(u => typeof u === 'string' ? {nick: u, modes: []} : u);
2158
// Sort: ops > system bots > agents > users, alpha within each tier.
2159
const sorted = normalized.slice().sort((a, b) => {
2160
const ta = nickTier(a.nick), tb = nickTier(b.nick);
2161
if (ta !== tb) return ta - tb;
2162
return a.nick.localeCompare(b.nick);
2163
});
2164
el.innerHTML = sorted.map(u => {
2165
const modes = u.modes || [];
2166
// IRC mode prefix: @ for op, + for voice
2167
let prefix = '';
2168
if (modes.includes('o') || modes.includes('a') || modes.includes('q')) prefix = '@';
2169
else if (modes.includes('v')) prefix = '+';
2170
else prefix = nickPrefix(u.nick);
2171
const tier = nickTier(u.nick);
2172
const cls = (modes.includes('o') || tier === 0) ? ' is-op' : tier === 1 ? ' is-bot' : '';
2173
const modeStr = modes.length ? ` [+${modes.join('')}]` : '';
2174
return `<div class="nicklist-nick${cls}" title="${esc(u.nick)}${modeStr}">${prefix}${esc(u.nick)}</div>`;
2175
}).join('');
2176
// Show channel modes in header if available.
2177
const modesEl = document.getElementById('chat-channel-modes');
2178
if (modesEl) modesEl.textContent = channelModes ? ` ${channelModes}` : '';
2179
}
2180
// Nick colors — deterministic hash over a palette
2181
const NICK_PALETTE = ['#58a6ff','#3fb950','#ffa657','#d2a8ff','#56d364','#79c0ff','#ff7b72','#a5d6ff','#f0883e','#39d353'];
2182
function nickColor(nick) {
2183
let h = 0;
2184
for (let i = 0; i < nick.length; i++) h = (h * 31 + nick.charCodeAt(i)) & 0xffff;
2185
return NICK_PALETTE[h % NICK_PALETTE.length];
2186
}
2187
2188
let _lastMsgNick = null, _lastMsgAt = 0;
2189
const GROUP_MS = 5 * 60 * 1000;
2190
let _chatNewBanner = null;
2191
let _chatUnread = 0;
2192
2193
function appendMsg(msg, isHistory) {
2194
const area = document.getElementById('chat-msgs');
2195
2196
// Attribution: RELAYMSG delivers nicks as "user/bridge"; legacy uses "[nick] text".
2197
let displayNick = msg.nick;
2198
let displayText = msg.text;
2199
if (msg.nick && msg.nick.endsWith('/bridge')) {
2200
displayNick = msg.nick.slice(0, -'/bridge'.length);
2201
} else if (msg.nick === 'bridge') {
2202
const m = msg.text.match(/^\[([^\]]+)\] ([\s\S]*)$/);
2203
if (m) { displayNick = m[1]; displayText = m[2]; }
2204
}
2205
2206
const atMs = new Date(msg.at).getTime();
2207
const grouped = !isHistory && displayNick === _lastMsgNick && (atMs - _lastMsgAt) < GROUP_MS;
2208
_lastMsgNick = displayNick;
2209
_lastMsgAt = atMs;
2210
2211
const timeStr = new Date(msg.at).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
2212
const color = nickColor(displayNick);
2213
2214
const row = document.createElement('div');
2215
row.className = 'msg-row' + (grouped ? ' msg-grouped' : '');
2216
// Build meta toggle if metadata present and rich mode is on.
2217
let metaToggle = '';
2218
let metaBlock = '';
2219
if (msg.meta && msg.meta.type) {
2220
const html = renderMeta(msg.meta);
2221
if (html) {
2222
const show = isRichMode();
2223
metaToggle = `<span class="msg-meta-toggle" style="${show ? '' : 'display:none'}" onclick="this.parentElement.nextElementSibling.classList.toggle('open');event.stopPropagation()">✨</span>`;
2224
metaBlock = `<div class="msg-meta">${html}</div>`;
2225
}
2226
}
2227
2228
row.innerHTML =
2229
`<span class="msg-time" title="${esc(new Date(msg.at).toLocaleString())}">${esc(timeStr)}</span>` +
2230
`<span class="msg-nick" style="color:${color}">[${esc(displayNick)}]:</span>` +
2231
`<span class="msg-text">${highlightText(esc(displayText))}${metaToggle}</span>`;
2232
2233
// Apply row-level highlights.
2234
const myNick = localStorage.getItem('sb_username') || '';
2235
const lower = displayText.toLowerCase();
2236
if (myNick && lower.includes(myNick.toLowerCase())) {
2237
row.classList.add('hl-mention');
2238
} else if (getDangerWords().some(w => lower.includes(w))) {
2239
row.classList.add('hl-danger');
2240
}
2241
// System messages (joins, parts, reconnects).
2242
if (/\b(online|offline|reconnected|joined|parted)\b/i.test(displayText) && !displayText.includes(': ')) {
2243
row.classList.add('hl-system');
2244
}
2245
2246
area.appendChild(row);
2247
// Append meta block after the row so toggle can find it via nextElementSibling.
2248
if (metaBlock) {
2249
const metaEl = document.createElement('div');
2250
metaEl.innerHTML = metaBlock;
2251
area.appendChild(metaEl.firstChild);
2252
}
2253
2254
// Unread badge when chat tab not active
2255
if (!isHistory && !document.getElementById('tab-chat').classList.contains('active')) {
2256
_chatUnread++;
2257
document.getElementById('tab-chat').dataset.unread = _chatUnread > 9 ? '9+' : _chatUnread;
2258
}
2259
2260
if (isHistory) {
2261
area.scrollTop = area.scrollHeight;
2262
} else {
2263
const nearBottom = area.scrollHeight - area.clientHeight - area.scrollTop < 100;
2264
if (nearBottom) {
2265
area.scrollTop = area.scrollHeight;
2266
_hideChatBanner();
2267
} else {
2268
_showChatBanner(area);
2269
}
2270
}
2271
}
2272
2273
function _showChatBanner(area) {
2274
if (_chatNewBanner) return;
2275
_chatNewBanner = document.createElement('div');
2276
_chatNewBanner.className = 'chat-new-banner';
2277
_chatNewBanner.textContent = '↓ new messages';
2278
_chatNewBanner.onclick = () => { area.scrollTop = area.scrollHeight; _hideChatBanner(); };
2279
area.appendChild(_chatNewBanner);
2280
}
2281
function _hideChatBanner() {
2282
if (_chatNewBanner) { _chatNewBanner.remove(); _chatNewBanner = null; }
2283
}
2284
function saveChatIdentity() {
2285
localStorage.setItem('sb_chat_nick', document.getElementById('chat-identity').value);
2286
}
2287
function getChatNick() {
2288
return localStorage.getItem('sb_chat_nick') || '';
2289
}
2290
function populateChatIdentity() {
2291
const sel = document.getElementById('chat-identity');
2292
const current = getChatNick();
2293
// Only operators (human users) can chat — agents are not selectable.
2294
const operators = allAgents.filter(a => a.type === 'operator' && !a.revoked);
2295
sel.innerHTML = '<option value="">— pick a user —</option>' +
2296
operators.map(a => `<option value="${esc(a.nick)}"${a.nick===current?' selected':''}>${esc(a.nick)}</option>`).join('');
2297
// Restore saved selection.
2298
if (current) sel.value = current;
2299
}
2300
2301
function toggleChatLayout() {
2302
const el = document.getElementById('chat-msgs');
2303
const columnar = el.classList.toggle('columnar');
2304
localStorage.setItem('sb_chat_columnar', columnar ? '1' : '0');
2305
}
2306
// Restore layout preference on load.
2307
if (localStorage.getItem('sb_chat_columnar') === '1') {
2308
document.getElementById('chat-msgs').classList.add('columnar');
2309
}
2310
2311
// --- timestamp toggle ---
2312
function toggleTimestamps() {
2313
const el = document.getElementById('chat-msgs');
2314
const hidden = el.classList.toggle('hide-timestamps');
2315
localStorage.setItem('sb_hide_timestamps', hidden ? '1' : '0');
2316
const btn = document.getElementById('chat-ts-toggle');
2317
btn.style.opacity = hidden ? '0.3' : '1';
2318
btn.title = hidden ? 'timestamps hidden — click to show' : 'timestamps shown — click to hide';
2319
}
2320
(function() {
2321
const hidden = localStorage.getItem('sb_hide_timestamps') === '1';
2322
if (hidden) document.getElementById('chat-msgs').classList.add('hide-timestamps');
2323
const btn = document.getElementById('chat-ts-toggle');
2324
if (hidden) { btn.style.opacity = '0.3'; btn.title = 'timestamps hidden — click to show'; }
2325
else { btn.title = 'timestamps shown — click to hide'; }
2326
})();
2327
2328
// --- rich mode toggle ---
2329
function isRichMode() { return localStorage.getItem('sb_rich_mode') === '1'; }
2330
function applyRichToggleStyle(btn, on) {
2331
if (on) {
2332
btn.style.background = '#1f6feb';
2333
btn.style.borderColor = '#1f6feb';
2334
btn.style.color = '#fff';
2335
btn.title = 'rich mode ON — click for text only';
2336
} else {
2337
btn.style.background = '';
2338
btn.style.borderColor = '';
2339
btn.style.color = '#8b949e';
2340
btn.title = 'text only — click for rich mode';
2341
}
2342
}
2343
function toggleRichMode() {
2344
const on = !isRichMode();
2345
localStorage.setItem('sb_rich_mode', on ? '1' : '0');
2346
const btn = document.getElementById('chat-rich-toggle');
2347
applyRichToggleStyle(btn, on);
2348
// Toggle all existing meta blocks visibility.
2349
document.querySelectorAll('.msg-meta-toggle').forEach(el => { el.style.display = on ? '' : 'none'; });
2350
if (!on) document.querySelectorAll('.msg-meta.open').forEach(el => el.classList.remove('open'));
2351
}
2352
// Initialize toggle button state on load.
2353
(function() {
2354
applyRichToggleStyle(document.getElementById('chat-rich-toggle'), isRichMode());
2355
})();
2356
2357
// --- meta renderers ---
2358
function renderMeta(meta) {
2359
if (!meta || !meta.type || !meta.data) return null;
2360
switch (meta.type) {
2361
case 'tool_result': return renderToolResult(meta.data);
2362
case 'diff': return renderDiff(meta.data);
2363
case 'error': return renderError(meta.data);
2364
case 'status': return renderStatus(meta.data);
2365
case 'artifact': return renderArtifact(meta.data);
2366
case 'image': return renderImage(meta.data);
2367
default: return renderGeneric(meta);
2368
}
2369
}
2370
function renderToolResult(d) {
2371
const tool = (d.tool || '').toLowerCase();
2372
// Bash/exec: terminal card
2373
if (tool === 'bash' || tool === 'exec' || tool === 'execute' || d.command) {
2374
return renderTerminal(d);
2375
}
2376
// Edit/Write/apply_patch: file card with diff
2377
if (tool === 'edit' || tool === 'write' || tool === 'apply_patch' || tool === 'notebookedit') {
2378
return renderFileCard(d);
2379
}
2380
// Read/Glob/Grep: file browser
2381
if (tool === 'read' || tool === 'glob' || tool === 'grep') {
2382
return renderFileSearch(d);
2383
}
2384
// WebSearch/WebFetch
2385
if (tool === 'websearch' || tool === 'webfetch' || tool === 'web_search' || d.url) {
2386
return renderSearchCard(d);
2387
}
2388
// Thinking
2389
if (tool === 'thinking' || d.thinking) {
2390
return '<div class="rich-thinking">' + esc(d.result || d.thinking || '') + '</div>';
2391
}
2392
// Fallback: generic tool card
2393
return renderGenericTool(d);
2394
}
2395
function renderTerminal(d) {
2396
const cmd = d.command || d.tool || 'command';
2397
const exitOk = d.exit_code === undefined || d.exit_code === 0;
2398
const exitCls = exitOk ? 'exit-ok' : 'exit-fail';
2399
const exitText = d.exit_code !== undefined ? ` [exit ${d.exit_code}]` : '';
2400
let output = d.result || d.output || '';
2401
if (output.length > 2000) output = output.slice(0, 1997) + '...';
2402
return `<div class="rich-card rich-terminal">
2403
<div class="rich-card-header"><span class="cmd">$ ${esc(cmd)}</span><span class="${exitCls}">${exitText}</span></div>
2404
<div class="rich-card-body"><pre>${esc(output)}</pre></div>
2405
</div>`;
2406
}
2407
function renderFileCard(d) {
2408
const path = d.file || d.file_path || d.path || '?';
2409
const ext = path.split('.').pop().toLowerCase();
2410
const langMap = {go:'Go',js:'JS',ts:'TS',py:'Python',rb:'Ruby',rs:'Rust',java:'Java',md:'Markdown',yaml:'YAML',json:'JSON',html:'HTML',css:'CSS',sh:'Shell',sql:'SQL'};
2411
const lang = langMap[ext] || ext;
2412
const tool = d.tool || 'file';
2413
let body = '';
2414
if (d.diff || d.hunks) {
2415
body = renderDiffBlock(d.diff || d.hunks);
2416
} else if (d.result) {
2417
body = `<pre>${esc(d.result.length > 2000 ? d.result.slice(0, 1997) + '...' : d.result)}</pre>`;
2418
}
2419
return `<div class="rich-card rich-file">
2420
<div class="rich-card-header"><span class="path">${esc(path)}</span><span class="lang">${esc(lang)}</span><span style="color:#8b949e;margin-left:auto">${esc(tool)}</span></div>
2421
<div class="rich-card-body">${body}</div>
2422
</div>`;
2423
}
2424
function renderDiffBlock(raw) {
2425
const text = typeof raw === 'string' ? raw : JSON.stringify(raw, null, 2);
2426
const lines = text.split('\n').map(line => {
2427
if (line.startsWith('@@')) return `<span class="line-hdr">${esc(line)}</span>`;
2428
else if (line.startsWith('+')) return `<span class="line-add">${esc(line)}</span>`;
2429
else if (line.startsWith('-')) return `<span class="line-del">${esc(line)}</span>`;
2430
else return `<span class="line-ctx">${esc(line)}</span>`;
2431
});
2432
return `<pre class="rich-diff">${lines.join('\n')}</pre>`;
2433
}
2434
function renderFileSearch(d) {
2435
const path = d.file || d.pattern || d.path || '';
2436
const tool = d.tool || 'search';
2437
let body = d.result || '';
2438
if (body.length > 2000) body = body.slice(0, 1997) + '...';
2439
return `<div class="rich-card rich-file">
2440
<div class="rich-card-header"><span class="path">${esc(path)}</span><span style="color:#8b949e;margin-left:auto">${esc(tool)}</span></div>
2441
<div class="rich-card-body"><pre>${esc(body)}</pre></div>
2442
</div>`;
2443
}
2444
function renderSearchCard(d) {
2445
const url = d.url || '';
2446
const result = d.result || d.summary || '';
2447
return `<div class="rich-card rich-search">
2448
<div class="rich-card-header"><span class="url">${esc(url)}</span></div>
2449
<div class="rich-card-body"><pre>${esc(result.length > 2000 ? result.slice(0, 1997) + '...' : result)}</pre></div>
2450
</div>`;
2451
}
2452
function renderGenericTool(d) {
2453
const tool = d.tool || '?';
2454
let body = '';
2455
if (d.result) body = d.result;
2456
else body = JSON.stringify(d, null, 2);
2457
if (body.length > 2000) body = body.slice(0, 1997) + '...';
2458
return `<div class="rich-card rich-file">
2459
<div class="rich-card-header"><span style="color:#d2a8ff;font-weight:600">${esc(tool)}</span></div>
2460
<div class="rich-card-body"><pre>${esc(body)}</pre></div>
2461
</div>`;
2462
}
2463
function renderDiff(d) {
2464
const file = d.file || '';
2465
return `<div class="rich-card rich-file">
2466
<div class="rich-card-header"><span class="path">${esc(file)}</span><span style="color:#8b949e;margin-left:auto">diff</span></div>
2467
<div class="rich-card-body">${renderDiffBlock(d.hunks || d.diff || '')}</div>
2468
</div>`;
2469
}
2470
function renderError(d) {
2471
const msg = d.message || d.error || '';
2472
const stack = d.stack || '';
2473
return `<div class="rich-card rich-error">
2474
<div class="rich-card-header">error</div>
2475
<div class="rich-card-body"><pre>${esc(msg)}${stack ? '\n\n' + esc(stack) : ''}</pre></div>
2476
</div>`;
2477
}
2478
function renderStatus(d) {
2479
const state = (d.state || 'running').toLowerCase();
2480
const cls = state === 'ok' || state === 'success' || state === 'done' ? 'ok' : state === 'error' || state === 'failed' ? 'error' : 'running';
2481
let html = '<span class="meta-status ' + cls + '">' + esc(d.state || '') + '</span>';
2482
if (d.message) html += ' <span>' + esc(d.message) + '</span>';
2483
return html;
2484
}
2485
function renderArtifact(d) {
2486
const path = d.name || d.path || '?';
2487
const ext = path.split('.').pop().toLowerCase();
2488
const langMap = {go:'Go',js:'JS',ts:'TS',py:'Python',rb:'Ruby',rs:'Rust'};
2489
const lang = d.language || langMap[ext] || ext;
2490
return `<div class="rich-card rich-file">
2491
<div class="rich-card-header"><span class="path">${esc(path)}</span><span class="lang">${esc(lang)}</span><span style="color:#8b949e;margin-left:auto">artifact</span></div>
2492
</div>`;
2493
}
2494
function renderImage(d) {
2495
if (!d.url) return '';
2496
return `<div style="margin-top:4px"><img src="${esc(d.url)}" alt="${esc(d.alt || '')}" loading="lazy" style="max-width:100%;max-height:400px;border-radius:6px;cursor:pointer" onclick="window.open(this.src)"></div>`;
2497
}
2498
function renderGeneric(meta) {
2499
return '<div class="meta-type">' + esc(meta.type) + '</div><pre>' + esc(JSON.stringify(meta.data, null, 2)) + '</pre>';
2500
}
2501
2502
async function sendMsg() {
2503
if (!chatChannel) return;
2504
const input = document.getElementById('chat-text-input');
2505
const nick = document.getElementById('chat-identity').value.trim() || 'web';
2506
const text = input.value.trim();
2507
if (!text) return;
2508
input.disabled = true;
2509
document.getElementById('chat-send-btn').disabled = true;
2510
try {
2511
const slug = chatChannel.replace(/^#/,'');
2512
await api('POST', `/v1/channels/${slug}/messages`, { text, nick });
2513
input.value = '';
2514
} catch(e) { alert('Send failed: '+e.message); }
2515
finally { input.disabled=false; document.getElementById('chat-send-btn').disabled=false; input.focus(); }
2516
}
2517
// --- chat input: Enter to send, Tab for nick completion ---
2518
(function() {
2519
const input = document.getElementById('chat-text-input');
2520
let _tabCandidates = [];
2521
let _tabIdx = -1;
2522
let _tabPrefix = '';
2523
2524
input.addEventListener('keydown', e => {
2525
if (e.key === 'Enter') { e.preventDefault(); sendMsg(); return; }
2526
if (e.key === 'Tab') {
2527
e.preventDefault();
2528
const val = input.value;
2529
const cursor = input.selectionStart;
2530
// Find the word being typed up to cursor
2531
const before = val.slice(0, cursor);
2532
const wordStart = before.search(/\S+$/);
2533
const word = wordStart === -1 ? '' : before.slice(wordStart);
2534
if (!word) return;
2535
2536
// On first Tab press with this prefix, build candidate list
2537
if (_tabIdx === -1 || word.toLowerCase() !== _tabPrefix.toLowerCase()) {
2538
_tabPrefix = word;
2539
const nicks = Array.from(document.querySelectorAll('#nicklist-users .nicklist-nick'))
2540
.map(el => el.textContent.replace(/^[●@+$]\s*/, '').trim())
2541
.filter(n => n.toLowerCase().startsWith(word.replace(/^@/, '').toLowerCase()));
2542
if (!nicks.length) return;
2543
_tabCandidates = nicks;
2544
_tabIdx = 0;
2545
} else {
2546
_tabIdx = (_tabIdx + 1) % _tabCandidates.length;
2547
}
2548
2549
const chosen = _tabCandidates[_tabIdx];
2550
// If at start of message, append ': '
2551
const suffix = (wordStart === 0 && before.slice(0, wordStart) === '') ? ': ' : ' ';
2552
const newVal = val.slice(0, wordStart === -1 ? 0 : wordStart) + chosen + suffix + val.slice(cursor);
2553
input.value = newVal;
2554
const newPos = (wordStart === -1 ? 0 : wordStart) + chosen.length + suffix.length;
2555
input.setSelectionRange(newPos, newPos);
2556
return;
2557
}
2558
// Any other key resets tab state
2559
_tabIdx = -1;
2560
_tabPrefix = '';
2561
});
2562
})();
2563
2564
// --- sidebar collapse toggles ---
2565
function isMobile() { return window.matchMedia('(max-width: 600px)').matches; }
2566
2567
// --- chat highlights ---
2568
const DEFAULT_DANGER_WORDS = ['rm -rf','git reset','git push --force','force push','drop table','delete from','git checkout .','git restore .','--no-verify'];
2569
2570
function promptHighlightWords() {
2571
const current = getDangerWords().join(', ');
2572
const input = prompt('Highlight keywords (comma-separated).\nMessages containing these words get a red border.\n\nCurrent:', current);
2573
if (input === null) return; // cancelled
2574
const words = input.split(',').map(s => s.trim()).filter(Boolean);
2575
localStorage.setItem('sb_highlight_words', JSON.stringify(words));
2576
}
2577
2578
function getDangerWords() {
2579
try {
2580
const custom = JSON.parse(localStorage.getItem('sb_highlight_words') || 'null');
2581
if (Array.isArray(custom)) return custom.map(w => w.toLowerCase());
2582
} catch(e) {}
2583
return DEFAULT_DANGER_WORDS;
2584
}
2585
2586
function getHighlightWords() {
2587
const words = [];
2588
const myNick = localStorage.getItem('sb_username') || '';
2589
if (myNick) words.push(myNick);
2590
words.push(...getDangerWords());
2591
return words;
2592
}
2593
2594
function highlightText(escaped) {
2595
const words = getHighlightWords();
2596
if (words.length === 0) return escaped;
2597
// Build regex from all highlight words, longest first to avoid partial matches.
2598
const sorted = words.slice().sort((a, b) => b.length - a.length);
2599
const pattern = sorted.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
2600
if (!pattern) return escaped;
2601
const re = new RegExp('(' + pattern + ')', 'gi');
2602
return escaped.replace(re, '<span class="hl-word">$1</span>');
2603
}
2604
2605
function toggleSidebar(side) {
2606
if (side === 'left') {
2607
const el = document.getElementById('chat-sidebar-left');
2608
const btn = document.getElementById('sidebar-left-toggle');
2609
if (isMobile()) {
2610
const open = el.classList.toggle('mobile-open');
2611
btn.textContent = open ? '‹' : '›';
2612
btn.title = open ? 'close' : 'channels';
2613
// close other panel
2614
document.getElementById('chat-nicklist').classList.remove('mobile-open');
2615
} else {
2616
const collapsed = el.classList.toggle('collapsed');
2617
btn.textContent = collapsed ? '›' : '‹';
2618
btn.title = collapsed ? 'expand' : 'collapse';
2619
}
2620
} else {
2621
const el = document.getElementById('chat-nicklist');
2622
const btn = document.getElementById('sidebar-right-toggle');
2623
if (isMobile()) {
2624
const open = el.classList.toggle('mobile-open');
2625
btn.textContent = open ? '›' : '‹';
2626
btn.title = open ? 'close' : 'users';
2627
// close other panel
2628
document.getElementById('chat-sidebar-left').classList.remove('mobile-open');
2629
} else {
2630
const collapsed = el.classList.toggle('collapsed');
2631
btn.textContent = collapsed ? '‹' : '›';
2632
btn.title = collapsed ? 'expand' : 'collapse';
2633
}
2634
}
2635
}
2636
2637
// --- sidebar drag-to-resize ---
2638
(function() {
2639
function makeResizable(handleId, sidebarId, side) {
2640
const handle = document.getElementById(handleId);
2641
const sidebar = document.getElementById(sidebarId);
2642
if (!handle || !sidebar) return;
2643
let startX, startW;
2644
handle.addEventListener('mousedown', e => {
2645
e.preventDefault();
2646
startX = e.clientX;
2647
startW = sidebar.offsetWidth;
2648
handle.classList.add('dragging');
2649
const onMove = mv => {
2650
const delta = side === 'left' ? mv.clientX - startX : startX - mv.clientX;
2651
const w = Math.max(28, Math.min(400, startW + delta));
2652
sidebar.style.width = w + 'px';
2653
if (w <= 30) sidebar.classList.add('collapsed');
2654
else sidebar.classList.remove('collapsed');
2655
};
2656
const onUp = () => {
2657
handle.classList.remove('dragging');
2658
document.removeEventListener('mousemove', onMove);
2659
document.removeEventListener('mouseup', onUp);
2660
};
2661
document.addEventListener('mousemove', onMove);
2662
document.addEventListener('mouseup', onUp);
2663
});
2664
}
2665
makeResizable('resize-left', 'chat-sidebar-left', 'left');
2666
makeResizable('resize-right', 'chat-nicklist', 'right');
2667
})();
2668
2669
// --- helpers ---
2670
function renderAlert(type, msg) {
2671
return `<div class="alert ${type}"><span class="icon">${{info:'ℹ',error:'✕',success:'✓'}[type]}</span><div>${msg}</div></div>`;
2672
}
2673
function esc(s) {
2674
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
2675
}
2676
function fmtTime(iso) {
2677
if (!iso) return '—';
2678
const d = new Date(iso);
2679
return d.toLocaleDateString()+' '+d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
2680
}
2681
2682
// --- collapsible cards ---
2683
function toggleCard(id, e) {
2684
// Don't collapse when clicking buttons inside the header.
2685
if (e && e.target.tagName === 'BUTTON') return;
2686
document.getElementById(id).classList.toggle('collapsed');
2687
}
2688
2689
// --- per-bot config schemas ---
2690
const BEHAVIOR_SCHEMAS = {
2691
snitch: [
2692
{ key:'alert_channel', label:'Alert channel', type:'text', placeholder:'#ops', hint:'Channel to post alerts in' },
2693
{ key:'alert_nicks', label:'Alert nicks', type:'text', placeholder:'alice, bob', hint:'Operators to DM (comma-separated)' },
2694
{ key:'flood_messages', label:'Flood threshold', type:'number', placeholder:'10', hint:'Messages in window that triggers alert' },
2695
{ key:'flood_window_s', label:'Flood window (s)', type:'number', placeholder:'5', hint:'Rolling window duration in seconds' },
2696
{ key:'joinpart_threshold',label:'Join/part threshold', type:'number', placeholder:'5', hint:'Join+part events before cycling alert' },
2697
{ key:'joinpart_window_s', label:'Join/part window (s)',type:'number', placeholder:'30' },
2698
],
2699
warden: [
2700
{ key:'flood_threshold', label:'Flood threshold', type:'number', placeholder:'10', hint:'Messages/window before action' },
2701
{ key:'window_s', label:'Window (s)', type:'number', placeholder:'5' },
2702
{ key:'warn_before_mute', label:'Warn before mute', type:'checkbox' },
2703
{ key:'mute_duration_s', label:'Mute duration (s)', type:'number', placeholder:'300' },
2704
{ key:'kick_after_mutes', label:'Kick after N mutes',type:'number', placeholder:'3' },
2705
],
2706
oracle: [
2707
{ key:'backend', label:'LLM backend', type:'llm-backend', hint:'Backend configured in the AI tab' },
2708
{ key:'model', label:'Model override', type:'model-override', backendKey:'backend', hint:'Override the backend default model (optional)' },
2709
{ key:'scribe_dir', label:'Scribe log dir', type:'text', placeholder:'./data/logs/scribe', hint:'Directory scribe writes to — oracle reads history from here' },
2710
{ key:'max_messages', label:'Max messages', type:'number', placeholder:'50', hint:'Default message count for summaries' },
2711
],
2712
scribe: [
2713
{ key:'dir', label:'Log directory', type:'text', placeholder:'./data/logs', hint:'Directory to write log files into' },
2714
{ key:'format', label:'Format', type:'select', options:['jsonl','csv','text'], hint:'jsonl=structured, csv=spreadsheet, text=human-readable' },
2715
{ key:'rotation', label:'Rotation', type:'select', options:['none','daily','weekly','monthly','yearly','size'], hint:'When to start a new log file' },
2716
{ key:'max_size_mb', label:'Max size (MiB)', type:'number', placeholder:'100', hint:'Only applies when rotation = size' },
2717
{ key:'per_channel', label:'Per-channel files',type:'checkbox', hint:'Separate file per channel' },
2718
{ key:'max_age_days', label:'Max age (days)', type:'number', placeholder:'0', hint:'Prune old rotated files; 0 = keep all' },
2719
],
2720
herald: [
2721
{ key:'webhook_path', label:'Webhook path', type:'text', placeholder:'/webhooks/herald', hint:'HTTP path that receives inbound events' },
2722
{ key:'rate_limit', label:'Rate limit (msg/min)', type:'number', placeholder:'60' },
2723
],
2724
scroll: [
2725
{ key:'max_replay', label:'Max replay', type:'number', placeholder:'100', hint:'Max messages per request' },
2726
{ key:'require_auth', label:'Require auth', type:'checkbox', hint:'Only registered agents can query history' },
2727
],
2728
systembot: [
2729
{ key:'log_joins', label:'Log joins', type:'checkbox' },
2730
{ key:'log_parts', label:'Log parts/quits', type:'checkbox' },
2731
{ key:'log_modes', label:'Log mode changes', type:'checkbox' },
2732
{ key:'log_kicks', label:'Log kicks', type:'checkbox' },
2733
],
2734
auditbot: [
2735
{ key:'retention_days', label:'Retention (days)', type:'number', placeholder:'90', hint:'0 = keep forever' },
2736
{ key:'log_path', label:'Log path', type:'text', placeholder:'/var/log/scuttlebot/audit.log' },
2737
],
2738
sentinel: [
2739
{ key:'backend', label:'LLM backend', type:'llm-backend', hint:'Backend configured in the AI tab' },
2740
{ key:'model', label:'Model override', type:'model-override', backendKey:'backend', hint:'Override the backend default model (optional)' },
2741
{ key:'mod_channel', label:'Mod channel', type:'text', placeholder:'#moderation', hint:'Channel where incident reports are posted' },
2742
{ key:'dm_operators', label:'DM operators', type:'checkbox', hint:'Also send incident reports as DMs to operator nicks' },
2743
{ key:'alert_nicks', label:'Operator nicks', type:'text', placeholder:'alice,bob', hint:'Comma-separated nicks to DM on incidents (requires DM operators)' },
2744
{ key:'policy', label:'Policy', type:'text', placeholder:'Flag harassment, hate speech, spam and threats.', hint:'Plain-English description of what to flag' },
2745
{ key:'min_severity', label:'Min severity', type:'select', options:['low','medium','high'], hint:'Minimum severity level to report' },
2746
{ key:'window_size', label:'Window size', type:'number', placeholder:'20', hint:'Messages to buffer per channel before analysis' },
2747
{ key:'window_age_sec', label:'Window age (s)', type:'number', placeholder:'300', hint:'Max seconds before a stale buffer is force-scanned' },
2748
{ key:'cooldown_sec', label:'Cooldown (s)', type:'number', placeholder:'600', hint:'Min seconds between reports about the same nick' },
2749
],
2750
steward: [
2751
{ key:'mod_channel', label:'Mod channel', type:'text', placeholder:'#moderation', hint:'Channel steward watches for sentinel reports and posts action logs' },
2752
{ key:'operator_nicks', label:'Operator nicks', type:'text', placeholder:'alice,bob', hint:'Comma-separated nicks allowed to issue direct commands via DM' },
2753
{ key:'dm_on_action', label:'DM operators', type:'checkbox', hint:'Send a DM to operator nicks when steward takes action' },
2754
{ key:'auto_act', label:'Auto-act', type:'checkbox', hint:'Automatically act on sentinel incident reports' },
2755
{ key:'warn_on_low', label:'Warn on low', type:'checkbox', hint:'Send a warning notice for low-severity incidents' },
2756
{ key:'mute_duration_sec', label:'Mute duration (s)',type:'number', placeholder:'600', hint:'How long medium-severity mutes last' },
2757
{ key:'cooldown_sec', label:'Cooldown (s)', type:'number', placeholder:'300', hint:'Min seconds between automated actions on the same nick' },
2758
],
2759
shepherd: [
2760
{ key:'backend', label:'LLM backend', type:'llm-backend', hint:'Backend for reasoning and plan generation' },
2761
{ key:'model', label:'Model override', type:'model-override', backendKey:'backend', hint:'Override the backend default model' },
2762
{ key:'report_channel', label:'Report channel', type:'text', placeholder:'#ops', hint:'Channel for status reports' },
2763
{ key:'checkin_interval_sec', label:'Check-in interval (s)', type:'number', placeholder:'0', hint:'Seconds between automatic check-ins (0 = disabled)' },
2764
{ key:'goal_source', label:'Goal source URL', type:'text', placeholder:'https://github.com/org/repo/milestone/1', hint:'GitHub milestone URL or other goal source' },
2765
],
2766
};
2767
2768
function renderBehConfig(b) {
2769
const schema = BEHAVIOR_SCHEMAS[b.id];
2770
if (!schema) return '';
2771
const cfg = b.config || {};
2772
const fields = schema.map(f => {
2773
const val = cfg[f.key];
2774
let input = '';
2775
if (f.type === 'checkbox') {
2776
input = `<input type="checkbox" ${val?'checked':''} style="accent-color:#58a6ff" onchange="onBehCfg('${esc(b.id)}','${f.key}',this.checked)">`;
2777
} else if (f.type === 'select') {
2778
input = `<select onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value)">${(f.options||[]).map(o=>`<option ${val===o?'selected':''}>${esc(o)}</option>`).join('')}</select>`;
2779
} else if (f.type === 'llm-backend') {
2780
const opts = _llmBackendNames.map(n => `<option value="${esc(n)}" ${val===n?'selected':''}>${esc(n)}</option>`).join('');
2781
const noMatch = val && !_llmBackendNames.includes(val);
2782
input = `<select onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value)">
2783
<option value="">— select backend —</option>
2784
${opts}
2785
${noMatch ? `<option value="${esc(val)}" selected>${esc(val)}</option>` : ''}
2786
</select>`;
2787
} else if (f.type === 'model-override') {
2788
const selId = `beh-msel-${esc(b.id)}`;
2789
const customId = `beh-mcustom-${esc(b.id)}`;
2790
const backendKey = f.backendKey || 'backend';
2791
const currentVal = val || '';
2792
input = `<div style="display:flex;gap:6px;align-items:flex-start">
2793
<div style="flex:1">
2794
<select id="${selId}" style="width:100%" onchange="onBehModelSelect('${esc(b.id)}','${f.key}','${selId}','${customId}')">
2795
<option value="">— none / auto-select —</option>
2796
${currentVal ? `<option value="${esc(currentVal)}" selected>${esc(currentVal)}</option>` : ''}
2797
<option value="__other__">— other (type below) —</option>
2798
</select>
2799
<input type="text" id="${customId}" placeholder="model-id" autocomplete="off"
2800
style="display:none;margin-top:6px"
2801
onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value)">
2802
</div>
2803
<button type="button" class="sm" style="white-space:nowrap;margin-top:1px"
2804
onclick="loadBehModels(this,'${esc(b.id)}','${backendKey}','${f.key}','${selId}','${customId}')">↺</button>
2805
</div>`;
2806
} else {
2807
input = `<input type="${f.type}" placeholder="${esc(f.placeholder||'')}" value="${esc(String(val??''))}" onchange="onBehCfg('${esc(b.id)}','${f.key}',this.value${f.type==='number'?'*1':''})">`;
2808
}
2809
return `<div class="beh-field"><label>${esc(f.label)}</label>${input}${f.hint?`<div class="hint">${esc(f.hint)}</div>`:''}</div>`;
2810
}).join('');
2811
return `<div class="beh-config" id="beh-cfg-${esc(b.id)}">${fields}</div>`;
2812
}
2813
2814
function toggleBehConfig(id) {
2815
const el = document.getElementById('beh-cfg-' + id);
2816
if (!el) return;
2817
el.classList.toggle('open');
2818
const btn = document.getElementById('beh-cfg-btn-' + id);
2819
if (btn) btn.textContent = el.classList.contains('open') ? 'configure ▴' : 'configure ▾';
2820
}
2821
2822
function onBehCfg(id, key, val) {
2823
const b = currentPolicies.behaviors.find(x => x.id === id);
2824
if (!b) return;
2825
if (!b.config) b.config = {};
2826
b.config[key] = val;
2827
}
2828
2829
function onBehModelSelect(botId, key, selId, customId) {
2830
const sel = document.getElementById(selId);
2831
const custom = document.getElementById(customId);
2832
if (!sel) return;
2833
custom.style.display = sel.value === '__other__' ? '' : 'none';
2834
if (sel.value !== '__other__') onBehCfg(botId, key, sel.value);
2835
}
2836
2837
async function loadBehModels(btn, botId, backendKey, modelKey, selId, customId) {
2838
const b = currentPolicies && currentPolicies.behaviors.find(x => x.id === botId);
2839
const backendName = b && b.config && b.config[backendKey];
2840
if (!backendName) {
2841
alert('Select a backend first, then click ↺ to load its models.');
2842
return;
2843
}
2844
const origText = btn.textContent;
2845
btn.textContent = '…';
2846
btn.disabled = true;
2847
try {
2848
const models = await api('GET', `/v1/llm/backends/${encodeURIComponent(backendName)}/models`);
2849
const sel = document.getElementById(selId);
2850
const custom = document.getElementById(customId);
2851
if (!sel) return;
2852
const current = (b.config && b.config[modelKey]) || '';
2853
sel.innerHTML = '<option value="">— none / auto-select —</option>';
2854
for (const m of (models || [])) {
2855
const id = typeof m === 'string' ? m : m.id;
2856
const label = (typeof m === 'object' && m.name && m.name !== id) ? `${id} — ${m.name}` : id;
2857
const opt = document.createElement('option');
2858
opt.value = id;
2859
opt.textContent = label;
2860
if (id === current) opt.selected = true;
2861
sel.appendChild(opt);
2862
}
2863
const other = document.createElement('option');
2864
other.value = '__other__';
2865
other.textContent = '— other (type below) —';
2866
const matched = (models || []).some(m => (typeof m === 'string' ? m : m.id) === current);
2867
if (current && !matched) {
2868
other.selected = true;
2869
if (custom) { custom.value = current; custom.style.display = ''; }
2870
}
2871
sel.appendChild(other);
2872
} catch(e) {
2873
alert('Model discovery failed: ' + e.message);
2874
} finally {
2875
btn.textContent = origText;
2876
btn.disabled = false;
2877
}
2878
}
2879
2880
// --- admin accounts ---
2881
async function loadAdmins() {
2882
try {
2883
const data = await api('GET', '/v1/admins');
2884
renderAdmins(data.admins || []);
2885
} catch(e) {
2886
// admins endpoint may not exist on token-only setups
2887
document.getElementById('admins-list-container').innerHTML = '';
2888
}
2889
}
2890
2891
function renderAdmins(admins) {
2892
const el = document.getElementById('admins-list-container');
2893
if (!admins.length) { el.innerHTML = ''; return; }
2894
const rows = admins.map(a => `<tr>
2895
<td><strong>${esc(a.username)}</strong></td>
2896
<td style="color:#8b949e;font-size:12px">${fmtTime(a.created)}</td>
2897
<td><div class="actions">
2898
<button class="sm" onclick="promptAdminPassword('${esc(a.username)}')">change password</button>
2899
<button class="sm danger" onclick="removeAdmin('${esc(a.username)}')">remove</button>
2900
</div></td>
2901
</tr>`).join('');
2902
el.innerHTML = `<table><thead><tr><th>username</th><th>created</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
2903
}
2904
2905
async function addAdmin(e) {
2906
e.preventDefault();
2907
const username = document.getElementById('new-admin-username').value.trim();
2908
const password = document.getElementById('new-admin-password').value;
2909
const resultEl = document.getElementById('add-admin-result');
2910
if (!username || !password) return;
2911
try {
2912
await api('POST', '/v1/admins', { username, password });
2913
resultEl.innerHTML = renderAlert('success', `Admin <strong>${esc(username)}</strong> added.`);
2914
resultEl.style.display = 'block';
2915
document.getElementById('add-admin-form').reset();
2916
await loadAdmins();
2917
setTimeout(() => { resultEl.style.display = 'none'; }, 3000);
2918
} catch(e) {
2919
resultEl.innerHTML = renderAlert('error', e.message);
2920
resultEl.style.display = 'block';
2921
}
2922
}
2923
2924
async function removeAdmin(username) {
2925
if (!confirm(`Remove admin "${username}"?`)) return;
2926
try {
2927
await api('DELETE', `/v1/admins/${encodeURIComponent(username)}`);
2928
await loadAdmins();
2929
} catch(e) { alert('Remove failed: ' + e.message); }
2930
}
2931
2932
async function promptAdminPassword(username) {
2933
const pw = prompt(`New password for ${username}:`);
2934
if (!pw) return;
2935
try {
2936
await api('PUT', `/v1/admins/${encodeURIComponent(username)}/password`, { password: pw });
2937
alert('Password updated.');
2938
} catch(e) { alert('Failed: ' + e.message); }
2939
}
2940
2941
// --- API keys ---
2942
async function loadAPIKeys() {
2943
try {
2944
const keys = await api('GET', '/v1/api-keys');
2945
renderAPIKeys(keys || []);
2946
} catch(e) {
2947
document.getElementById('apikeys-list-container').innerHTML = '';
2948
}
2949
}
2950
2951
function renderAPIKeys(keys) {
2952
const el = document.getElementById('apikeys-list-container');
2953
if (!keys.length) { el.innerHTML = ''; return; }
2954
const rows = keys.map(k => {
2955
const status = k.active ? '<span style="color:#3fb950">active</span>' : '<span style="color:#f85149">revoked</span>';
2956
const scopes = (k.scopes || []).map(s => `<code style="font-size:11px;background:#21262d;padding:1px 5px;border-radius:3px">${esc(s)}</code>`).join(' ');
2957
const lastUsed = k.last_used ? fmtTime(k.last_used) : '—';
2958
const revokeBtn = k.active ? `<button class="sm danger" onclick="revokeAPIKey('${esc(k.id)}')">revoke</button>` : '';
2959
return `<tr>
2960
<td><strong>${esc(k.name)}</strong><br><span style="color:#8b949e;font-size:11px">${esc(k.id)}</span></td>
2961
<td>${scopes}</td>
2962
<td style="font-size:12px">${status}</td>
2963
<td style="color:#8b949e;font-size:12px">${lastUsed}</td>
2964
<td><div class="actions">${revokeBtn}</div></td>
2965
</tr>`;
2966
}).join('');
2967
el.innerHTML = `<table><thead><tr><th>name</th><th>scopes</th><th>status</th><th>last used</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
2968
}
2969
2970
async function createAPIKey(e) {
2971
e.preventDefault();
2972
const name = document.getElementById('new-apikey-name').value.trim();
2973
const expires = document.getElementById('new-apikey-expires').value.trim();
2974
const scopes = [...document.querySelectorAll('.apikey-scope:checked')].map(cb => cb.value);
2975
const resultEl = document.getElementById('add-apikey-result');
2976
if (!name) { resultEl.innerHTML = '<span style="color:#f85149">name is required</span>'; return; }
2977
if (!scopes.length) { resultEl.innerHTML = '<span style="color:#f85149">select at least one scope</span>'; return; }
2978
try {
2979
const body = { name, scopes };
2980
if (expires) body.expires_in = expires;
2981
const result = await api('POST', '/v1/api-keys', body);
2982
resultEl.innerHTML = `<div style="background:#0d1117;border:1px solid #3fb95044;border-radius:6px;padding:12px;margin-top:8px">
2983
<div style="color:#3fb950;font-weight:600;margin-bottom:6px">Key created: ${esc(result.name)}</div>
2984
<div style="margin-bottom:4px;font-size:12px;color:#8b949e">Copy this token now — it will not be shown again:</div>
2985
<code style="display:block;padding:8px;background:#161b22;border-radius:4px;word-break:break-all;user-select:all">${esc(result.token)}</code>
2986
</div>`;
2987
document.getElementById('new-apikey-name').value = '';
2988
document.getElementById('new-apikey-expires').value = '';
2989
document.querySelectorAll('.apikey-scope:checked').forEach(cb => cb.checked = false);
2990
loadAPIKeys();
2991
} catch(e) {
2992
resultEl.innerHTML = `<span style="color:#f85149">${esc(e.message)}</span>`;
2993
}
2994
}
2995
2996
async function revokeAPIKey(id) {
2997
if (!confirm('Revoke this API key? This cannot be undone.')) return;
2998
try {
2999
await api('DELETE', `/v1/api-keys/${encodeURIComponent(id)}`);
3000
loadAPIKeys();
3001
} catch(e) { alert('Failed: ' + e.message); }
3002
}
3003
3004
// --- AI / LLM tab ---
3005
async function loadAI() {
3006
await Promise.all([loadAIBackends(), loadAIKnown()]);
3007
}
3008
3009
async function loadAIBackends() {
3010
const el = document.getElementById('ai-backends-list');
3011
try {
3012
const backends = await api('GET', '/v1/llm/backends');
3013
if (!backends || backends.length === 0) {
3014
el.innerHTML = '<div class="empty-state">No LLM backends configured yet. Click <strong>+ add backend</strong> above or add them to <code>scuttlebot.yaml</code>.</div>';
3015
return;
3016
}
3017
el.innerHTML = backends.map(b => {
3018
const sid = CSS.escape(b.name);
3019
const editable = b.source === 'policy';
3020
const srcBadge = b.source === 'config'
3021
? '<span class="badge" style="background:#21262d;color:#8b949e;border:1px solid #30363d">yaml</span>'
3022
: '<span class="badge" style="background:#21262d;color:#58a6ff;border:1px solid #1f6feb">ui</span>';
3023
return `<div class="setting-row" style="flex-wrap:wrap;gap:8px;align-items:center">
3024
<div style="flex:1;min-width:140px">
3025
<div style="font-weight:500;color:#e6edf3">${esc(b.name)} ${srcBadge}${b.default ? ' <span class="badge" style="background:#1f6feb">default</span>' : ''}</div>
3026
<div style="font-size:11px;color:#8b949e;margin-top:2px">${esc(b.backend)}${b.region ? ' · ' + esc(b.region) : ''}${b.base_url ? ' · ' + esc(b.base_url) : ''}</div>
3027
</div>
3028
<div style="min-width:100px;font-size:12px;color:#8b949e">${b.model ? 'model: <code style="color:#a5d6ff">' + esc(b.model) + '</code>' : '<span style="color:#6e7681">model: auto</span>'}</div>
3029
<div id="ai-models-${sid}" style="width:100%;display:none"></div>
3030
<button class="sm" onclick="discoverModels('${esc(b.name)}', this)">discover models</button>
3031
${editable ? `<button class="sm" onclick="openEditBackend('${esc(b.name)}')">edit</button>
3032
<button class="sm danger" onclick="deleteBackend('${esc(b.name)}', this)">delete</button>` : ''}
3033
</div>`;
3034
}).join('<div style="height:1px;background:#21262d;margin:4px 0"></div>');
3035
} catch(e) {
3036
el.innerHTML = `<div class="empty-state" style="color:#f85149">${esc(String(e))}</div>`;
3037
}
3038
}
3039
3040
// --- backend form ---
3041
3042
let _editingBackend = null; // null = adding, string = name being edited
3043
let _backendList = []; // cached for edit lookups
3044
3045
async function openAddBackend() {
3046
_editingBackend = null;
3047
document.getElementById('ai-form-title').textContent = 'add backend';
3048
document.getElementById('bf-submit-btn').textContent = 'add backend';
3049
document.getElementById('bf-name').value = '';
3050
document.getElementById('bf-name').disabled = false;
3051
document.getElementById('bf-backend').value = '';
3052
document.getElementById('bf-apikey').value = '';
3053
document.getElementById('bf-baseurl').value = '';
3054
populateModelSelect([], '');
3055
document.getElementById('bf-model-custom').value = '';
3056
document.getElementById('bf-model-custom').style.display = 'none';
3057
document.getElementById('bf-load-models-btn').textContent = '↺ load models';
3058
document.getElementById('bf-default').checked = false;
3059
document.getElementById('bf-region').value = '';
3060
document.getElementById('bf-aws-key-id').value = '';
3061
document.getElementById('bf-aws-secret').value = '';
3062
document.getElementById('bf-allow').value = '';
3063
document.getElementById('bf-block').value = '';
3064
document.getElementById('ai-form-result').style.display = 'none';
3065
onBackendTypeChange();
3066
const card = document.getElementById('card-ai-form');
3067
card.style.display = '';
3068
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
3069
}
3070
3071
async function openEditBackend(name) {
3072
let b;
3073
try {
3074
const backends = await api('GET', '/v1/llm/backends');
3075
b = backends.find(x => x.name === name);
3076
} catch(e) { return; }
3077
if (!b) return;
3078
3079
_editingBackend = name;
3080
document.getElementById('ai-form-title').textContent = 'edit backend — ' + esc(name);
3081
document.getElementById('bf-submit-btn').textContent = 'save changes';
3082
document.getElementById('bf-name').value = name;
3083
document.getElementById('bf-name').disabled = false; // allow rename
3084
document.getElementById('bf-backend').value = b.backend || '';
3085
document.getElementById('bf-apikey').value = ''; // never pre-fill secrets
3086
document.getElementById('bf-baseurl').value = b.base_url || '';
3087
const curated = KNOWN_MODELS[b.backend] || [];
3088
populateModelSelect(curated, b.model || '');
3089
document.getElementById('bf-model-custom').style.display = 'none';
3090
document.getElementById('bf-load-models-btn').textContent = '↺ load models';
3091
document.getElementById('bf-default').checked = !!b.default;
3092
document.getElementById('bf-region').value = b.region || '';
3093
document.getElementById('bf-aws-key-id').value = ''; // never pre-fill
3094
document.getElementById('bf-aws-secret').value = '';
3095
document.getElementById('bf-allow').value = (b.allow || []).join('\n');
3096
document.getElementById('bf-block').value = (b.block || []).join('\n');
3097
document.getElementById('ai-form-result').style.display = 'none';
3098
onBackendTypeChange();
3099
const card = document.getElementById('card-ai-form');
3100
card.style.display = '';
3101
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
3102
}
3103
3104
function closeBackendForm() {
3105
document.getElementById('card-ai-form').style.display = 'none';
3106
_editingBackend = null;
3107
}
3108
3109
// Curated model lists per backend — shown before live discovery.
3110
const KNOWN_MODELS = {
3111
anthropic: [
3112
'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001',
3113
'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229',
3114
],
3115
gemini: [
3116
'gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-flash',
3117
'gemini-1.5-flash-8b', 'gemini-1.5-pro',
3118
],
3119
openai: [
3120
'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'o1', 'o1-mini', 'o3-mini', 'gpt-3.5-turbo',
3121
],
3122
bedrock: [
3123
'anthropic.claude-3-5-sonnet-20241022-v2:0', 'anthropic.claude-3-5-haiku-20241022-v1:0',
3124
'anthropic.claude-3-opus-20240229-v1:0',
3125
'amazon.nova-pro-v1:0', 'amazon.nova-lite-v1:0', 'amazon.nova-micro-v1:0',
3126
'meta.llama3-70b-instruct-v1:0', 'meta.llama3-8b-instruct-v1:0',
3127
'mistral.mistral-large-2402-v1:0',
3128
],
3129
ollama: ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'gemma2', 'qwen2.5', 'phi3'],
3130
groq: [
3131
'llama-3.3-70b-versatile', 'llama-3.1-8b-instant',
3132
'mixtral-8x7b-32768', 'gemma2-9b-it',
3133
],
3134
mistral: ['mistral-large-latest', 'mistral-small-latest', 'codestral-latest', 'open-mistral-nemo'],
3135
deepseek: ['deepseek-chat', 'deepseek-reasoner'],
3136
xai: ['grok-2', 'grok-2-mini', 'grok-beta'],
3137
cerebras: ['llama3.1-8b', 'llama3.1-70b', 'llama3.3-70b'],
3138
together: [
3139
'meta-llama/Llama-3.3-70B-Instruct-Turbo',
3140
'meta-llama/Llama-3.1-8B-Instruct-Turbo',
3141
'mistralai/Mixtral-8x7B-Instruct-v0.1',
3142
'Qwen/Qwen2.5-72B-Instruct-Turbo',
3143
],
3144
fireworks: [
3145
'accounts/fireworks/models/llama-v3p3-70b-instruct',
3146
'accounts/fireworks/models/mixtral-8x7b-instruct',
3147
],
3148
openrouter: [], // too varied — always load live
3149
huggingface: [
3150
'meta-llama/Llama-3.3-70B-Instruct',
3151
'mistralai/Mistral-7B-Instruct-v0.3',
3152
'Qwen/Qwen2.5-72B-Instruct',
3153
],
3154
};
3155
3156
function populateModelSelect(models, currentVal) {
3157
const sel = document.getElementById('bf-model-select');
3158
sel.innerHTML = '<option value="">— none / auto-select —</option>';
3159
for (const m of models) {
3160
const id = typeof m === 'string' ? m : m.id;
3161
const label = (typeof m === 'object' && m.name && m.name !== id) ? `${id} — ${m.name}` : id;
3162
const opt = document.createElement('option');
3163
opt.value = id;
3164
opt.textContent = label;
3165
if (id === currentVal) opt.selected = true;
3166
sel.appendChild(opt);
3167
}
3168
const other = document.createElement('option');
3169
other.value = '__other__';
3170
other.textContent = '— other (type below) —';
3171
if (currentVal && !models.find(m => (typeof m === 'string' ? m : m.id) === currentVal)) {
3172
other.selected = true;
3173
document.getElementById('bf-model-custom').value = currentVal;
3174
document.getElementById('bf-model-custom').style.display = '';
3175
}
3176
sel.appendChild(other);
3177
}
3178
3179
function onModelSelectChange() {
3180
const sel = document.getElementById('bf-model-select');
3181
const custom = document.getElementById('bf-model-custom');
3182
custom.style.display = sel.value === '__other__' ? '' : 'none';
3183
}
3184
3185
function getModelValue() {
3186
const sel = document.getElementById('bf-model-select');
3187
if (sel.value === '__other__') return document.getElementById('bf-model-custom').value.trim();
3188
return sel.value || '';
3189
}
3190
3191
function onBackendTypeChange() {
3192
const t = document.getElementById('bf-backend').value;
3193
const isBedrock = t === 'bedrock';
3194
const isLocal = ['ollama','litellm','lmstudio','jan','localai','vllm','anythingllm'].includes(t);
3195
const hasKey = !isBedrock;
3196
3197
document.getElementById('bf-bedrock-group').style.display = isBedrock ? '' : 'none';
3198
document.getElementById('bf-apikey-row').style.display = hasKey ? '' : 'none';
3199
document.getElementById('bf-baseurl-row').style.display = (isLocal || isBedrock) ? 'none' : '';
3200
3201
const curated = KNOWN_MODELS[t] || [];
3202
populateModelSelect(curated, '');
3203
}
3204
3205
async function loadLiveModels(btn) {
3206
const t = document.getElementById('bf-backend').value;
3207
if (!t) { alert('Select a backend type first.'); return; }
3208
3209
btn.disabled = true;
3210
btn.textContent = 'loading…';
3211
try {
3212
const payload = {
3213
backend: t,
3214
api_key: document.getElementById('bf-apikey')?.value || '',
3215
base_url: document.getElementById('bf-baseurl')?.value.trim() || '',
3216
region: document.getElementById('bf-region')?.value.trim() || '',
3217
aws_key_id: document.getElementById('bf-aws-key-id')?.value.trim() || '',
3218
aws_secret_key: document.getElementById('bf-aws-secret')?.value || '',
3219
};
3220
const models = await api('POST', '/v1/llm/discover', payload);
3221
const current = getModelValue();
3222
populateModelSelect(models, current);
3223
btn.textContent = `↺ ${models.length} loaded`;
3224
} catch(e) {
3225
btn.textContent = '✕ failed';
3226
setTimeout(() => { btn.textContent = '↺ load models'; }, 2000);
3227
alert('Discovery failed: ' + String(e));
3228
} finally {
3229
btn.disabled = false;
3230
}
3231
}
3232
3233
async function submitBackendForm() {
3234
const name = document.getElementById('bf-name').value.trim();
3235
const backend = document.getElementById('bf-backend').value;
3236
if (!name || !backend) {
3237
showFormResult('name and backend type are required', 'error');
3238
return;
3239
}
3240
3241
const allow = document.getElementById('bf-allow').value.trim().split('\n').map(s=>s.trim()).filter(Boolean);
3242
const block = document.getElementById('bf-block').value.trim().split('\n').map(s=>s.trim()).filter(Boolean);
3243
3244
const payload = {
3245
name, backend,
3246
api_key: document.getElementById('bf-apikey').value || undefined,
3247
base_url: document.getElementById('bf-baseurl').value.trim() || undefined,
3248
model: getModelValue() || undefined,
3249
default: document.getElementById('bf-default').checked || undefined,
3250
region: document.getElementById('bf-region').value.trim() || undefined,
3251
aws_key_id: document.getElementById('bf-aws-key-id').value.trim() || undefined,
3252
aws_secret_key: document.getElementById('bf-aws-secret').value || undefined,
3253
allow: allow.length ? allow : undefined,
3254
block: block.length ? block : undefined,
3255
};
3256
3257
const btn = document.getElementById('bf-submit-btn');
3258
btn.disabled = true;
3259
try {
3260
if (_editingBackend && name !== _editingBackend) {
3261
// Rename: delete old, create new.
3262
await api('DELETE', `/v1/llm/backends/${encodeURIComponent(_editingBackend)}`);
3263
await api('POST', '/v1/llm/backends', payload);
3264
} else if (_editingBackend) {
3265
await api('PUT', `/v1/llm/backends/${encodeURIComponent(_editingBackend)}`, payload);
3266
} else {
3267
await api('POST', '/v1/llm/backends', payload);
3268
}
3269
closeBackendForm();
3270
await loadAIBackends();
3271
} catch(e) {
3272
showFormResult(String(e), 'error');
3273
} finally {
3274
btn.disabled = false;
3275
}
3276
}
3277
3278
async function deleteBackend(name, btn) {
3279
btn.disabled = true;
3280
try {
3281
await api('DELETE', `/v1/llm/backends/${encodeURIComponent(name)}`);
3282
await loadAIBackends();
3283
} catch(e) {
3284
btn.disabled = false;
3285
alert('Delete failed: ' + String(e));
3286
}
3287
}
3288
3289
function showFormResult(msg, type) {
3290
const el = document.getElementById('ai-form-result');
3291
el.style.display = '';
3292
el.className = 'alert ' + (type === 'error' ? 'danger' : 'info');
3293
el.innerHTML = `<span class="icon">${type === 'error' ? '✕' : 'ℹ'}</span><span>${esc(msg)}</span>`;
3294
}
3295
3296
async function discoverModels(name, btn) {
3297
const el = document.getElementById('ai-models-' + name);
3298
if (!el) return;
3299
btn.disabled = true;
3300
btn.textContent = 'discovering…';
3301
try {
3302
const models = await api('GET', `/v1/llm/backends/${encodeURIComponent(name)}/models`);
3303
el.style.display = 'block';
3304
if (!models || models.length === 0) {
3305
el.innerHTML = '<div style="font-size:12px;color:#8b949e;padding:6px 0">No models found (check filters).</div>';
3306
} else {
3307
el.innerHTML = `<div style="font-size:12px;color:#8b949e;margin-bottom:6px">${models.length} model${models.length !== 1 ? 's' : ''} available:</div>
3308
<div style="display:flex;flex-wrap:wrap;gap:4px">${models.map(m =>
3309
`<code style="background:#161b22;border:1px solid #30363d;border-radius:4px;padding:2px 6px;font-size:11px;color:#a5d6ff">${esc(m.id)}${m.name && m.name !== m.id ? ' <span style="color:#6e7681">(' + esc(m.name) + ')</span>' : ''}</code>`
3310
).join('')}</div>`;
3311
}
3312
btn.textContent = '↺ refresh';
3313
} catch(e) {
3314
el.style.display = 'block';
3315
el.innerHTML = `<div style="font-size:12px;color:#f85149">Error: ${esc(String(e))}</div>`;
3316
btn.textContent = 'retry';
3317
} finally {
3318
btn.disabled = false;
3319
}
3320
}
3321
3322
async function loadAIKnown() {
3323
const el = document.getElementById('ai-supported-list');
3324
try {
3325
const known = await api('GET', '/v1/llm/known');
3326
const native = known.filter(b => b.native);
3327
const compat = known.filter(b => !b.native);
3328
native.sort((a,b) => a.name.localeCompare(b.name));
3329
compat.sort((a,b) => a.name.localeCompare(b.name));
3330
el.innerHTML = `
3331
<div style="margin-bottom:12px">
3332
<div style="font-size:12px;color:#8b949e;font-weight:500;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px">native APIs</div>
3333
<div style="display:flex;flex-wrap:wrap;gap:4px">${native.map(b =>
3334
`<span style="background:#161b22;border:1px solid #30363d;border-radius:4px;padding:3px 8px;font-size:12px;color:#e6edf3">${esc(b.name)}</span>`
3335
).join('')}</div>
3336
</div>
3337
<div>
3338
<div style="font-size:12px;color:#8b949e;font-weight:500;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px">OpenAI-compatible</div>
3339
<div style="display:flex;flex-wrap:wrap;gap:4px">${compat.map(b =>
3340
`<span style="background:#161b22;border:1px solid #30363d;border-radius:4px;padding:3px 8px;font-size:12px;color:#e6edf3" title="${esc(b.base_url)}">${esc(b.name)}</span>`
3341
).join('')}</div>
3342
</div>`;
3343
} catch(e) {
3344
el.innerHTML = `<div class="empty-state" style="color:#f85149">${esc(String(e))}</div>`;
3345
}
3346
}
3347
3348
function showAIExample(e) {
3349
e.preventDefault();
3350
const card = document.getElementById('card-ai-example');
3351
card.style.display = '';
3352
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
3353
// Expand it if collapsed.
3354
const body = card.querySelector('.card-body');
3355
if (body) body.style.display = '';
3356
}
3357
3358
// --- settings / policies ---
3359
let currentPolicies = null;
3360
let _botCommands = {};
3361
3362
function renderOnJoinMessages(msgs) {
3363
const el = document.getElementById('onjoin-list');
3364
if (!msgs || !Object.keys(msgs).length) { el.innerHTML = '<div style="color:#8b949e;font-size:12px">No on-join instructions configured.</div>'; return; }
3365
el.innerHTML = Object.entries(msgs).sort().map(([ch, msg]) => `
3366
<div style="display:flex;gap:8px;align-items:center;padding:6px 0;border-bottom:1px solid #21262d">
3367
<code style="font-size:12px;min-width:120px">${esc(ch)}</code>
3368
<input type="text" value="${esc(msg)}" style="flex:1;font-size:12px" onchange="updateOnJoinMessage('${esc(ch)}',this.value)">
3369
<button class="sm danger" onclick="removeOnJoinMessage('${esc(ch)}')">remove</button>
3370
</div>
3371
`).join('');
3372
}
3373
function addOnJoinMessage() {
3374
const ch = document.getElementById('onjoin-new-channel').value.trim();
3375
const msg = document.getElementById('onjoin-new-message').value.trim();
3376
if (!ch || !msg) return;
3377
if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {};
3378
currentPolicies.on_join_messages[ch] = msg;
3379
document.getElementById('onjoin-new-channel').value = '';
3380
document.getElementById('onjoin-new-message').value = '';
3381
renderOnJoinMessages(currentPolicies.on_join_messages);
3382
}
3383
function updateOnJoinMessage(ch, msg) {
3384
if (!currentPolicies.on_join_messages) currentPolicies.on_join_messages = {};
3385
currentPolicies.on_join_messages[ch] = msg;
3386
}
3387
function removeOnJoinMessage(ch) {
3388
if (currentPolicies.on_join_messages) delete currentPolicies.on_join_messages[ch];
3389
renderOnJoinMessages(currentPolicies.on_join_messages);
3390
}
3391
let _llmBackendNames = []; // cached backend names for oracle dropdown
3392
3393
async function loadSettings() {
3394
try {
3395
const [s, backends] = await Promise.all([
3396
api('GET', '/v1/settings'),
3397
api('GET', '/v1/llm/backends').catch(() => []),
3398
]);
3399
_llmBackendNames = (backends || []).map(b => b.name);
3400
renderTLSStatus(s.tls);
3401
currentPolicies = s.policies;
3402
_botCommands = s.bot_commands || {};
3403
renderBehaviors(s.policies.behaviors || []);
3404
renderOnJoinMessages(s.policies.on_join_messages || {});
3405
renderAgentPolicy(s.policies.agent_policy || {});
3406
renderBridgePolicy(s.policies.bridge || {});
3407
renderLoggingPolicy(s.policies.logging || {});
3408
loadAdmins();
3409
loadAPIKeys();
3410
loadConfigCards();
3411
} catch(e) {
3412
document.getElementById('tls-badge').textContent = 'error';
3413
}
3414
}
3415
3416
function renderTLSStatus(tls) {
3417
const badge = document.getElementById('tls-badge');
3418
if (tls.enabled) {
3419
badge.textContent = 'TLS active';
3420
badge.style.background = '#3fb95022'; badge.style.color = '#3fb950'; badge.style.borderColor = '#3fb95044';
3421
} else {
3422
badge.textContent = 'HTTP only';
3423
}
3424
document.getElementById('tls-status-rows').innerHTML = `
3425
<div class="setting-row">
3426
<div class="setting-label">mode</div>
3427
<div class="setting-desc"></div>
3428
<code class="setting-val">${tls.enabled ? 'HTTPS (Let\'s Encrypt)' : 'HTTP'}</code>
3429
</div>
3430
${tls.enabled ? `
3431
<div class="setting-row">
3432
<div class="setting-label">domain</div>
3433
<div class="setting-desc"></div>
3434
<code class="setting-val">${esc(tls.domain)}</code>
3435
</div>
3436
<div class="setting-row">
3437
<div class="setting-label">allow insecure</div>
3438
<div class="setting-desc">Plain HTTP also accepted.</div>
3439
<code class="setting-val">${tls.allow_insecure ? 'yes' : 'no'}</code>
3440
</div>` : ''}
3441
`;
3442
}
3443
3444
function renderBehaviors(behaviors) {
3445
const hasSchema = id => !!BEHAVIOR_SCHEMAS[id];
3446
document.getElementById('behaviors-list').innerHTML = behaviors.map(b => `
3447
<div>
3448
<div style="display:grid;grid-template-columns:20px 90px 1fr auto;align-items:center;gap:12px;padding:11px 16px;border-bottom:1px solid #21262d">
3449
<input type="checkbox" ${b.enabled?'checked':''} onchange="onBehaviorToggle('${esc(b.id)}',this.checked)" style="width:14px;height:14px;cursor:pointer;accent-color:#58a6ff">
3450
<strong style="font-size:13px;white-space:nowrap">${esc(b.name)}</strong>
3451
<span style="font-size:12px;color:#8b949e">${esc(b.description)}</span>
3452
<div style="display:flex;align-items:center;gap:8px;justify-content:flex-end">
3453
${b.enabled ? `
3454
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#8b949e;cursor:pointer;white-space:nowrap">
3455
<input type="checkbox" ${b.join_all_channels?'checked':''} onchange="onBehaviorJoinAll('${esc(b.id)}',this.checked)" style="accent-color:#58a6ff">
3456
all channels
3457
</label>
3458
${b.join_all_channels
3459
? `<input type="text" placeholder="exclude #private, /regex/" style="width:160px;padding:3px 7px;font-size:11px" title="Exclude: comma-separated names or /regex/" value="${esc((b.exclude_channels||[]).join(', '))}" onchange="onBehaviorExclude('${esc(b.id)}',this.value)">`
3460
: `<input type="text" placeholder="#ops, /^#proj-.*/" style="width:160px;padding:3px 7px;font-size:11px" title="Join: comma-separated names or /regex/ patterns" value="${esc((b.required_channels||[]).join(', '))}" onchange="onBehaviorChannels('${esc(b.id)}',this.value)">`
3461
}
3462
${hasSchema(b.id) ? `<button class="sm" id="beh-cfg-btn-${esc(b.id)}" onclick="toggleBehConfig('${esc(b.id)}')" style="font-size:11px;white-space:nowrap">configure ▾</button>` : ''}
3463
` : ''}
3464
<span class="tag type-observer" style="font-size:11px;min-width:64px;text-align:center">${esc(b.nick)}</span>
3465
</div>
3466
</div>
3467
${b.enabled && hasSchema(b.id) ? renderBehConfig(b) : ''}
3468
${_botCommands[b.id] ? `<div style="padding:6px 16px 8px 42px;border-bottom:1px solid #21262d;background:#0d1117">
3469
<span style="font-size:11px;color:#8b949e;font-weight:600">commands:</span>
3470
${_botCommands[b.id].map(c => `<code style="font-size:11px;margin-left:8px;background:#161b22;padding:1px 5px;border-radius:3px" title="${esc(c.description)}&#10;${esc(c.usage)}">${esc(c.command)}</code>`).join('')}
3471
</div>` : ''}
3472
</div>
3473
`).join('');
3474
}
3475
3476
function onBehaviorToggle(id, enabled) {
3477
const b = currentPolicies.behaviors.find(x => x.id === id);
3478
if (b) b.enabled = enabled;
3479
renderBehaviors(currentPolicies.behaviors);
3480
}
3481
function onBehaviorJoinAll(id, val) {
3482
const b = currentPolicies.behaviors.find(x => x.id === id);
3483
if (b) b.join_all_channels = val;
3484
renderBehaviors(currentPolicies.behaviors);
3485
}
3486
function onBehaviorExclude(id, val) {
3487
const b = currentPolicies.behaviors.find(x => x.id === id);
3488
if (b) b.exclude_channels = val.split(',').map(s=>s.trim()).filter(Boolean);
3489
}
3490
function onBehaviorChannels(id, val) {
3491
const b = currentPolicies.behaviors.find(x => x.id === id);
3492
if (b) b.required_channels = val.split(',').map(s=>s.trim()).filter(Boolean);
3493
}
3494
3495
function renderAgentPolicy(p) {
3496
document.getElementById('policy-checkin-enabled').checked = !!p.require_checkin;
3497
document.getElementById('policy-checkin-channel').value = p.checkin_channel || '';
3498
document.getElementById('policy-required-channels').value = (p.required_channels||[]).join(', ');
3499
document.getElementById('policy-online-timeout').value = p.online_timeout_secs || '';
3500
document.getElementById('policy-reap-days').value = p.reap_after_days || '';
3501
toggleCheckinChannel();
3502
}
3503
function toggleCheckinChannel() {
3504
const on = document.getElementById('policy-checkin-enabled').checked;
3505
document.getElementById('policy-checkin-row').style.display = on ? '' : 'none';
3506
}
3507
3508
function renderBridgePolicy(p) {
3509
document.getElementById('policy-bridge-web-user-ttl').value = p.web_user_ttl_minutes || 5;
3510
}
3511
3512
function renderLoggingPolicy(l) {
3513
document.getElementById('policy-logging-enabled').checked = !!l.enabled;
3514
document.getElementById('policy-log-dir').value = l.dir || '';
3515
document.getElementById('policy-log-format').value = l.format || 'jsonl';
3516
document.getElementById('policy-log-rotation').value = l.rotation || 'none';
3517
document.getElementById('policy-log-max-size').value = l.max_size_mb || '';
3518
document.getElementById('policy-log-per-channel').checked = !!l.per_channel;
3519
document.getElementById('policy-log-max-age').value = l.max_age_days || '';
3520
toggleLogOptions();
3521
toggleRotationOptions();
3522
}
3523
function toggleLogOptions() {
3524
const on = document.getElementById('policy-logging-enabled').checked;
3525
document.getElementById('policy-log-options').style.display = on ? '' : 'none';
3526
}
3527
function toggleRotationOptions() {
3528
const rot = document.getElementById('policy-log-rotation').value;
3529
document.getElementById('policy-log-size-row').style.display = rot === 'size' ? '' : 'none';
3530
}
3531
3532
async function savePolicies() {
3533
// Saves behaviors only — agent_policy, logging, and bridge are now
3534
// persisted to scuttlebot.yaml via PUT /v1/config.
3535
if (!currentPolicies) return;
3536
const p = JSON.parse(JSON.stringify(currentPolicies)); // deep copy
3537
const resultEl = document.getElementById('policies-save-result');
3538
try {
3539
currentPolicies = await api('PUT', '/v1/settings/policies', p);
3540
resultEl.style.display = 'block';
3541
resultEl.innerHTML = renderAlert('success', 'Behaviors saved.');
3542
setTimeout(() => { resultEl.style.display = 'none'; }, 3000);
3543
} catch(e) {
3544
resultEl.style.display = 'block';
3545
resultEl.innerHTML = renderAlert('error', e.message);
3546
}
3547
}
3548
3549
// --- topology config ---
3550
let _topoChannels = [];
3551
let _topoTypes = [];
3552
3553
function renderTopoStaticChannels() {
3554
const el = document.getElementById('topo-static-channels');
3555
if (!_topoChannels.length) {
3556
el.innerHTML = '<div class="empty-state" style="padding:12px;font-size:12px">no static channels configured</div>';
3557
return;
3558
}
3559
el.innerHTML = `
3560
<table style="width:100%;font-size:12px;border-collapse:collapse">
3561
<thead>
3562
<tr style="color:#8b949e;border-bottom:1px solid #30363d">
3563
<th style="text-align:left;padding:4px 8px;font-weight:500">name</th>
3564
<th style="text-align:left;padding:4px 8px;font-weight:500">topic</th>
3565
<th style="text-align:left;padding:4px 8px;font-weight:500">autojoin</th>
3566
<th style="padding:4px 8px"></th>
3567
</tr>
3568
</thead>
3569
<tbody>
3570
${_topoChannels.map((c, i) => `
3571
<tr style="border-bottom:1px solid #21262d">
3572
<td style="padding:4px 8px">
3573
<input type="text" value="${esc(c.name||'')}" style="width:120px;padding:2px 6px;font-size:11px"
3574
onchange="_topoChannels[${i}].name=this.value">
3575
</td>
3576
<td style="padding:4px 8px">
3577
<input type="text" value="${esc(c.topic||'')}" style="width:160px;padding:2px 6px;font-size:11px"
3578
onchange="_topoChannels[${i}].topic=this.value">
3579
</td>
3580
<td style="padding:4px 8px">
3581
<input type="text" value="${esc((c.autojoin||[]).join(', '))}" placeholder="bridge, sentinel"
3582
style="width:160px;padding:2px 6px;font-size:11px"
3583
onchange="_topoChannels[${i}].autojoin=this.value.split(',').map(s=>s.trim()).filter(Boolean)">
3584
</td>
3585
<td style="padding:4px 8px;text-align:right">
3586
<button class="sm" onclick="topoDeleteStaticChannel(${i})">✕</button>
3587
</td>
3588
</tr>
3589
`).join('')}
3590
</tbody>
3591
</table>`;
3592
}
3593
3594
function renderTopoChannelTypes() {
3595
const el = document.getElementById('topo-channel-types');
3596
if (!_topoTypes.length) {
3597
el.innerHTML = '<div class="empty-state" style="padding:12px;font-size:12px">no channel types configured</div>';
3598
return;
3599
}
3600
el.innerHTML = `
3601
<table style="width:100%;font-size:12px;border-collapse:collapse">
3602
<thead>
3603
<tr style="color:#8b949e;border-bottom:1px solid #30363d">
3604
<th style="text-align:left;padding:4px 8px;font-weight:500">name</th>
3605
<th style="text-align:left;padding:4px 8px;font-weight:500">prefix</th>
3606
<th style="text-align:left;padding:4px 8px;font-weight:500">ttl</th>
3607
<th style="text-align:left;padding:4px 8px;font-weight:500">ephemeral</th>
3608
<th style="padding:4px 8px"></th>
3609
</tr>
3610
</thead>
3611
<tbody>
3612
${_topoTypes.map((x, i) => `
3613
<tr style="border-bottom:1px solid #21262d">
3614
<td style="padding:4px 8px">
3615
<input type="text" value="${esc(x.name||'')}" style="width:100px;padding:2px 6px;font-size:11px"
3616
onchange="_topoTypes[${i}].name=this.value">
3617
</td>
3618
<td style="padding:4px 8px">
3619
<input type="text" value="${esc(x.prefix||'')}" placeholder="task." style="width:100px;padding:2px 6px;font-size:11px"
3620
onchange="_topoTypes[${i}].prefix=this.value">
3621
</td>
3622
<td style="padding:4px 8px">
3623
<input type="text" value="${esc(x.ttl||'')}" placeholder="24h" style="width:80px;padding:2px 6px;font-size:11px"
3624
title="Duration string e.g. 1h, 24h, 72h"
3625
onchange="_topoTypes[${i}].ttl=this.value">
3626
</td>
3627
<td style="padding:4px 8px;text-align:center">
3628
<input type="checkbox" ${x.ephemeral ? 'checked' : ''} style="width:auto"
3629
onchange="_topoTypes[${i}].ephemeral=this.checked">
3630
</td>
3631
<td style="padding:4px 8px;text-align:right">
3632
<button class="sm" onclick="topoDeleteChannelType(${i})">✕</button>
3633
</td>
3634
</tr>
3635
`).join('')}
3636
</tbody>
3637
</table>`;
3638
}
3639
3640
function topoAddStaticChannel() {
3641
_topoChannels.push({name: '', topic: '', autojoin: []});
3642
renderTopoStaticChannels();
3643
// focus the last name input
3644
const rows = document.querySelectorAll('#topo-static-channels tbody tr');
3645
if (rows.length) rows[rows.length-1].querySelector('input').focus();
3646
}
3647
3648
function topoAddChannelType() {
3649
_topoTypes.push({name: '', prefix: '', ephemeral: false, ttl: ''});
3650
renderTopoChannelTypes();
3651
const rows = document.querySelectorAll('#topo-channel-types tbody tr');
3652
if (rows.length) rows[rows.length-1].querySelector('input').focus();
3653
}
3654
3655
function topoDeleteStaticChannel(idx) {
3656
_topoChannels.splice(idx, 1);
3657
renderTopoStaticChannels();
3658
}
3659
3660
function topoDeleteChannelType(idx) {
3661
_topoTypes.splice(idx, 1);
3662
renderTopoChannelTypes();
3663
}
3664
3665
async function saveTopologyConfig() {
3666
const resultEl = document.getElementById('topo-save-result');
3667
const channels = _topoChannels.filter(c => c.name.trim());
3668
const types = _topoTypes.filter(x => x.name.trim() && x.prefix.trim());
3669
const payload = {
3670
topology: {
3671
nick: document.getElementById('topo-nick').value.trim() || 'topology',
3672
channels: channels,
3673
types: types,
3674
},
3675
config_history: {
3676
keep: parseInt(document.getElementById('topo-history-keep').value, 10) || 20,
3677
},
3678
};
3679
try {
3680
const res = await api('PUT', '/v1/config', payload);
3681
resultEl.style.display = 'block';
3682
let msg = 'Topology config saved.';
3683
if (res.restart_required && res.restart_required.length) {
3684
msg += ' Restart required for: ' + res.restart_required.join(', ') + '.';
3685
}
3686
resultEl.innerHTML = renderAlert('success', msg);
3687
setTimeout(() => { resultEl.style.display = 'none'; }, 4000);
3688
} catch(e) {
3689
resultEl.style.display = 'block';
3690
resultEl.innerHTML = renderAlert('error', e.message);
3691
}
3692
}
3693
3694
// --- shared config save helper ---
3695
async function saveConfigPatch(patch, resultElId) {
3696
const resultEl = document.getElementById(resultElId);
3697
try {
3698
const res = await api('PUT', '/v1/config', patch);
3699
let msg = 'Saved.';
3700
if (res.restart_required && res.restart_required.length) {
3701
msg += ' Restart required for: ' + res.restart_required.join(', ') + '.';
3702
}
3703
resultEl.style.display = 'block';
3704
resultEl.innerHTML = renderAlert('success', msg);
3705
setTimeout(() => { resultEl.style.display = 'none'; }, 4000);
3706
} catch(e) {
3707
resultEl.style.display = 'block';
3708
resultEl.innerHTML = renderAlert('error', e.message);
3709
}
3710
}
3711
3712
// --- config-backed cards ---
3713
async function loadConfigCards() {
3714
try {
3715
const cfg = await api('GET', '/v1/config');
3716
// general
3717
document.getElementById('general-api-addr').value = cfg.api_addr || '';
3718
document.getElementById('general-mcp-addr').value = cfg.mcp_addr || '';
3719
// ergo
3720
const e = cfg.ergo || {};
3721
document.getElementById('ergo-network-name').value = e.network_name || '';
3722
document.getElementById('ergo-server-name').value = e.server_name || '';
3723
document.getElementById('ergo-irc-addr').value = e.irc_addr || '';
3724
document.getElementById('ergo-require-sasl').checked = !!e.require_sasl;
3725
document.getElementById('ergo-default-modes').value = e.default_channel_modes || '';
3726
document.getElementById('ergo-history-enabled').checked = !!(e.history && e.history.enabled);
3727
document.getElementById('ergo-external').checked = !!e.external;
3728
// tls
3729
const t = cfg.tls || {};
3730
document.getElementById('tls-domain').value = t.domain || '';
3731
document.getElementById('tls-email').value = t.email || '';
3732
document.getElementById('tls-allow-insecure').checked = !!t.allow_insecure;
3733
// bridge (full)
3734
const b = cfg.bridge || {};
3735
document.getElementById('bridge-enabled').checked = b.enabled !== false;
3736
document.getElementById('bridge-nick').value = b.nick || '';
3737
document.getElementById('bridge-channels').value = (b.channels || []).join(', ');
3738
document.getElementById('bridge-buffer-size').value = b.buffer_size || '';
3739
document.getElementById('policy-bridge-web-user-ttl').value = b.web_user_ttl_minutes || 5;
3740
// topology + history
3741
const topo = cfg.topology || {};
3742
const h = cfg.config_history || {};
3743
_topoChannels = (topo.channels || []).map(c => Object.assign({}, c));
3744
_topoTypes = (topo.types || []).map(x => Object.assign({}, x));
3745
document.getElementById('topo-nick').value = topo.nick || 'topology';
3746
document.getElementById('topo-history-keep').value = h.keep != null ? h.keep : 20;
3747
renderTopoStaticChannels();
3748
renderTopoChannelTypes();
3749
} catch(e) {
3750
console.error('loadConfigCards:', e);
3751
}
3752
}
3753
3754
function saveAgentPolicy() {
3755
saveConfigPatch({
3756
agent_policy: {
3757
require_checkin: document.getElementById('policy-checkin-enabled').checked,
3758
checkin_channel: document.getElementById('policy-checkin-channel').value.trim(),
3759
required_channels: document.getElementById('policy-required-channels').value.split(',').map(s=>s.trim()).filter(Boolean),
3760
online_timeout_secs: parseInt(document.getElementById('policy-online-timeout').value) || 0,
3761
reap_after_days: parseInt(document.getElementById('policy-reap-days').value) || 0,
3762
}
3763
}, 'agentpolicy-save-result');
3764
}
3765
3766
function saveBridgeConfig() {
3767
saveConfigPatch({
3768
bridge: {
3769
enabled: document.getElementById('bridge-enabled').checked,
3770
nick: document.getElementById('bridge-nick').value.trim() || undefined,
3771
channels: document.getElementById('bridge-channels').value.split(',').map(s=>s.trim()).filter(Boolean),
3772
buffer_size: parseInt(document.getElementById('bridge-buffer-size').value, 10) || undefined,
3773
web_user_ttl_minutes: parseInt(document.getElementById('policy-bridge-web-user-ttl').value, 10) || 5,
3774
}
3775
}, 'bridge-save-result');
3776
}
3777
3778
function saveLogging() {
3779
saveConfigPatch({
3780
logging: {
3781
enabled: document.getElementById('policy-logging-enabled').checked,
3782
dir: document.getElementById('policy-log-dir').value.trim(),
3783
format: document.getElementById('policy-log-format').value,
3784
rotation: document.getElementById('policy-log-rotation').value,
3785
max_size_mb: parseInt(document.getElementById('policy-log-max-size').value, 10) || 0,
3786
per_channel: document.getElementById('policy-log-per-channel').checked,
3787
max_age_days: parseInt(document.getElementById('policy-log-max-age').value, 10) || 0,
3788
}
3789
}, 'logging-save-result');
3790
}
3791
3792
function saveGeneralConfig() {
3793
const patch = {};
3794
const addr = document.getElementById('general-api-addr').value.trim();
3795
const mcp = document.getElementById('general-mcp-addr').value.trim();
3796
if (addr) patch.api_addr = addr;
3797
if (mcp) patch.mcp_addr = mcp;
3798
saveConfigPatch(patch, 'general-save-result');
3799
}
3800
3801
function saveErgoConfig() {
3802
saveConfigPatch({
3803
ergo: {
3804
network_name: document.getElementById('ergo-network-name').value.trim() || undefined,
3805
server_name: document.getElementById('ergo-server-name').value.trim() || undefined,
3806
irc_addr: document.getElementById('ergo-irc-addr').value.trim() || undefined,
3807
require_sasl: document.getElementById('ergo-require-sasl').checked,
3808
default_channel_modes: document.getElementById('ergo-default-modes').value.trim() || undefined,
3809
history: { enabled: document.getElementById('ergo-history-enabled').checked },
3810
external: document.getElementById('ergo-external').checked,
3811
}
3812
}, 'ergo-save-result');
3813
}
3814
3815
function saveTLSConfig() {
3816
saveConfigPatch({
3817
tls: {
3818
domain: document.getElementById('tls-domain').value.trim() || undefined,
3819
email: document.getElementById('tls-email').value.trim() || undefined,
3820
allow_insecure: document.getElementById('tls-allow-insecure').checked,
3821
}
3822
}, 'tls-config-save-result');
3823
}
3824
3825
// --- init ---
3826
function loadAll() { loadStatus(); loadAgents(); loadSettings(); startMetricsPoll(); }
3827
initAuth();
3828
</script>
3829
</body>
3830
</html>
3831

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button