ScuttleBot

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

Keyboard Shortcuts

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