@songsid/agend 2.0.11-beta.18 → 2.0.11-beta.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ui/settings.html +341 -161
- package/package.json +1 -1
package/dist/ui/settings.html
CHANGED
|
@@ -8,35 +8,46 @@
|
|
|
8
8
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
9
|
:root { --bg:#0d1117; --panel:#161b22; --border:#21262d; --fg:#c9d1d9; --dim:#8b949e; --accent:#58a6ff; --err:#f85149; --warn:#d29922; --ok:#3fb950; }
|
|
10
10
|
html, body { height: 100%; }
|
|
11
|
-
body { background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 0 0
|
|
12
|
-
header { display: flex; align-items: center; gap:
|
|
11
|
+
body { background: var(--bg); color: var(--fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 0 0 40px; }
|
|
12
|
+
header { display: flex; align-items: center; gap: 14px; padding: 12px 20px; background: var(--panel); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
|
13
13
|
header h1 { font-size: 15px; color: var(--accent); font-weight: 600; letter-spacing: .5px; }
|
|
14
|
-
.tabs { display: flex; gap: 4px;
|
|
14
|
+
.tabs { display: flex; gap: 4px; }
|
|
15
15
|
.tab { padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--dim); border: 1px solid transparent; }
|
|
16
|
-
.tab:hover { color: var(--fg); }
|
|
17
|
-
.tab.active { background: var(--bg); color: var(--accent); border-color: var(--border); }
|
|
16
|
+
.tab:hover { color: var(--fg); } .tab.active { background: var(--bg); color: var(--accent); border-color: var(--border); }
|
|
18
17
|
.spacer { flex: 1; }
|
|
19
18
|
button { background: #21262d; color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; }
|
|
20
|
-
button:hover { border-color: var(--accent); }
|
|
21
|
-
button.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
.
|
|
25
|
-
.
|
|
26
|
-
.
|
|
27
|
-
.card
|
|
28
|
-
.card
|
|
29
|
-
.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
textarea {
|
|
33
|
-
|
|
34
|
-
.
|
|
35
|
-
.
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
19
|
+
button:hover { border-color: var(--accent); } button.primary { background: var(--accent); color: #05070a; border-color: var(--accent); font-weight: 600; }
|
|
20
|
+
button.sm { padding: 3px 8px; font-size: 12px; } button.danger { color: var(--err); }
|
|
21
|
+
main { max-width: 1200px; margin: 0 auto; padding: 18px; }
|
|
22
|
+
.panel { display: none; } .panel.active { display: block; }
|
|
23
|
+
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
|
|
24
|
+
@media (max-width: 900px) { .split { grid-template-columns: 1fr; } }
|
|
25
|
+
.col { min-width: 0; }
|
|
26
|
+
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 14px; }
|
|
27
|
+
.card h2 { font-size: 13px; color: var(--accent); margin-bottom: 10px; display:flex; align-items:center; gap:8px; }
|
|
28
|
+
.card h2 .grow { flex:1; }
|
|
29
|
+
label { display: block; font-size: 12px; color: var(--dim); margin: 8px 0 3px; }
|
|
30
|
+
input[type=text], input[type=number], select, textarea { width: 100%; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: 7px 9px; font-size: 13px; font-family: inherit; }
|
|
31
|
+
textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.5; resize: vertical; min-height: 420px; }
|
|
32
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0; }
|
|
33
|
+
.chip { display: inline-flex; align-items: center; gap: 6px; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; padding: 3px 6px 3px 10px; font-size: 12px; }
|
|
34
|
+
.chip span.x { cursor: pointer; color: var(--dim); font-weight: 700; } .chip span.x:hover { color: var(--err); }
|
|
35
|
+
.chip-add { display: flex; gap: 6px; margin-top: 4px; } .chip-add input { flex: 1; }
|
|
36
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
37
|
+
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--border); }
|
|
38
|
+
th { color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
|
|
39
|
+
tr.inst-row { cursor: pointer; } tr.inst-row:hover td { background: rgba(255,255,255,.02); }
|
|
40
|
+
.edit-row td { background: rgba(88,166,255,.05); }
|
|
41
|
+
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
42
|
+
.right-toolbar { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
|
|
43
|
+
.right-toolbar .seg { display: flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
|
44
|
+
.right-toolbar .seg button { border: none; border-radius: 0; }
|
|
45
|
+
.right-toolbar .seg button.on { background: var(--accent); color: #05070a; }
|
|
46
|
+
pre.preview { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.5; overflow: auto; max-height: 560px; white-space: pre; min-height: 420px; }
|
|
47
|
+
.msg { font-size: 12px; margin-top: 8px; white-space: pre-wrap; } .msg .e { color: var(--err); } .msg .w { color: var(--warn); } .msg .s { color: var(--ok); }
|
|
48
|
+
.row-actions { display: flex; gap: 6px; margin-top: 10px; align-items: center; flex-wrap: wrap; }
|
|
49
|
+
.banner { padding: 9px 20px; font-size: 13px; background: rgba(210,153,34,.12); border-bottom: 1px solid var(--border); color: var(--warn); display: none; }
|
|
50
|
+
code { background: var(--bg); padding: 1px 4px; border-radius: 4px; font-size: 12px; }
|
|
40
51
|
</style>
|
|
41
52
|
</head>
|
|
42
53
|
<body>
|
|
@@ -48,55 +59,119 @@
|
|
|
48
59
|
<div class="tab" data-tab="classic">ClassicBot</div>
|
|
49
60
|
</div>
|
|
50
61
|
<div class="spacer"></div>
|
|
51
|
-
<button id="reloadBtn" title="Apply config
|
|
62
|
+
<button id="reloadBtn" title="Apply saved config to running instances (SIGHUP)">↻ Reload fleet</button>
|
|
52
63
|
</header>
|
|
53
64
|
<div class="banner" id="banner"></div>
|
|
54
65
|
<main>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
<div class="
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<div class="actions"><button class="primary" onclick="saveChannels()">Save channels</button><span class="msg" id="msg-channels"></span></div>
|
|
68
|
-
</div>
|
|
69
|
-
</section>
|
|
70
|
-
|
|
71
|
-
<!-- Instances -->
|
|
72
|
-
<section class="panel" id="panel-instances">
|
|
73
|
-
<div class="card">
|
|
74
|
-
<h2>Add instance</h2>
|
|
75
|
-
<div class="actions">
|
|
76
|
-
<input type="text" id="newInstName" placeholder="instance name" style="flex:0 0 220px">
|
|
77
|
-
<button class="primary" onclick="addInstance()">Create</button>
|
|
78
|
-
<span class="msg" id="msg-newinst"></span>
|
|
79
|
-
</div>
|
|
80
|
-
</div>
|
|
81
|
-
<div id="instList"></div>
|
|
82
|
-
</section>
|
|
83
|
-
|
|
84
|
-
<!-- ClassicBot -->
|
|
85
|
-
<section class="panel" id="panel-classic">
|
|
86
|
-
<div class="card">
|
|
87
|
-
<h2>ClassicBot defaults</h2>
|
|
88
|
-
<div class="hint">backend, admin_users, allowed_guilds, allowed_groups, allowed_users. Merged on save.</div>
|
|
89
|
-
<textarea id="classicDefaults" style="min-height:160px"></textarea>
|
|
90
|
-
<div class="actions"><button class="primary" onclick="saveClassicDefaults()">Save classic defaults</button><span class="msg" id="msg-classic"></span></div>
|
|
91
|
-
</div>
|
|
92
|
-
</section>
|
|
66
|
+
<section class="panel active" id="panel-fleet"><div class="split">
|
|
67
|
+
<div class="col" id="fleet-left"></div>
|
|
68
|
+
<div class="col" id="fleet-right"></div>
|
|
69
|
+
</div></section>
|
|
70
|
+
<section class="panel" id="panel-instances"><div class="split">
|
|
71
|
+
<div class="col" id="instances-left"></div>
|
|
72
|
+
<div class="col" id="instances-right"></div>
|
|
73
|
+
</div></section>
|
|
74
|
+
<section class="panel" id="panel-classic"><div class="split">
|
|
75
|
+
<div class="col" id="classic-left"></div>
|
|
76
|
+
<div class="col" id="classic-right"></div>
|
|
77
|
+
</div></section>
|
|
93
78
|
</main>
|
|
79
|
+
|
|
94
80
|
<script>
|
|
95
81
|
(() => {
|
|
82
|
+
"use strict";
|
|
96
83
|
const token = new URLSearchParams(location.search).get("token") || "";
|
|
97
|
-
const
|
|
98
|
-
|
|
84
|
+
const $ = (id) => document.getElementById(id);
|
|
85
|
+
const el = (tag, attrs = {}, ...kids) => { const e = document.createElement(tag); for (const k in attrs) { if (k === "class") e.className = attrs[k]; else if (k === "html") e.innerHTML = attrs[k]; else if (k.startsWith("on")) e.addEventListener(k.slice(2), attrs[k]); else e.setAttribute(k, attrs[k]); } for (const c of kids) e.append(c); return e; };
|
|
86
|
+
const esc = (s) => String(s).replace(/[&<>]/g, c => ({ "&":"&","<":"<",">":">" }[c]));
|
|
87
|
+
const BACKENDS = ["claude-code", "kiro-cli", "codex", "opencode", "gemini-cli", "antigravity"];
|
|
88
|
+
const CH_TYPES = ["telegram", "discord"];
|
|
89
|
+
|
|
90
|
+
// ── YAML dump/parse (config subset; round-trip verified) ──
|
|
91
|
+
function toYaml(value, indent = 0) {
|
|
92
|
+
const pad = " ".repeat(indent);
|
|
93
|
+
const scalar = (v) => {
|
|
94
|
+
if (v === null || v === undefined) return "null";
|
|
95
|
+
if (typeof v === "boolean" || typeof v === "number") return String(v);
|
|
96
|
+
const s = String(v);
|
|
97
|
+
if (s === "") return '""';
|
|
98
|
+
if (/^(true|false|null|~|-?\d+(\.\d+)?)$/i.test(s) || /[:#\[\]{}&*!|>'"%@`]/.test(s) || /^[\s-]/.test(s) || /\s$/.test(s) || s.includes("\n"))
|
|
99
|
+
return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
|
|
100
|
+
return s;
|
|
101
|
+
};
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
if (value.length === 0) return "[]";
|
|
104
|
+
return value.map(item => {
|
|
105
|
+
if (item !== null && typeof item === "object") {
|
|
106
|
+
const dumped = toYaml(item, indent + 1); const lines = dumped.split("\n");
|
|
107
|
+
const first = lines[0].slice((indent + 1) * 2);
|
|
108
|
+
return pad + "- " + first + (lines.length > 1 ? "\n" + lines.slice(1).join("\n") : "");
|
|
109
|
+
}
|
|
110
|
+
return pad + "- " + scalar(item);
|
|
111
|
+
}).join("\n");
|
|
112
|
+
}
|
|
113
|
+
if (value !== null && typeof value === "object") {
|
|
114
|
+
const keys = Object.keys(value); if (keys.length === 0) return "{}";
|
|
115
|
+
return keys.map(k => {
|
|
116
|
+
const v = value[k];
|
|
117
|
+
if (v !== null && typeof v === "object" && (Array.isArray(v) ? v.length : Object.keys(v).length)) return pad + k + ":\n" + toYaml(v, indent + 1);
|
|
118
|
+
if (v !== null && typeof v === "object") return pad + k + ": " + (Array.isArray(v) ? "[]" : "{}");
|
|
119
|
+
return pad + k + ": " + scalar(v);
|
|
120
|
+
}).join("\n");
|
|
121
|
+
}
|
|
122
|
+
return pad + scalar(value);
|
|
123
|
+
}
|
|
124
|
+
function parseScalar(s) {
|
|
125
|
+
s = s.trim();
|
|
126
|
+
if (s === "" || s === "~" || s === "null") return null;
|
|
127
|
+
if (s === "true") return true; if (s === "false") return false;
|
|
128
|
+
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
|
|
129
|
+
if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
|
|
130
|
+
if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
131
|
+
if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1);
|
|
132
|
+
if (s === "[]") return []; if (s === "{}") return {};
|
|
133
|
+
return s;
|
|
134
|
+
}
|
|
135
|
+
function fromYaml(text) {
|
|
136
|
+
const lines = [];
|
|
137
|
+
for (const raw of text.replace(/\r/g, "").split("\n")) {
|
|
138
|
+
let inS = false, inD = false, out = "";
|
|
139
|
+
for (let i = 0; i < raw.length; i++) { const c = raw[i]; if (c === "'" && !inD) inS = !inS; else if (c === '"' && !inS) inD = !inD; else if (c === "#" && !inS && !inD) break; out += c; }
|
|
140
|
+
if (out.trim() === "") continue;
|
|
141
|
+
lines.push({ indent: out.match(/^ */)[0].length, text: out.trim() });
|
|
142
|
+
}
|
|
143
|
+
let pos = 0;
|
|
144
|
+
function parseBlock() {
|
|
145
|
+
if (pos >= lines.length) return null;
|
|
146
|
+
const first = lines[pos];
|
|
147
|
+
if (first.text.startsWith("- ")) {
|
|
148
|
+
const arr = [];
|
|
149
|
+
while (pos < lines.length && lines[pos].indent === first.indent && lines[pos].text.startsWith("- ")) {
|
|
150
|
+
const at = lines[pos].indent, rest = lines[pos].text.slice(2);
|
|
151
|
+
if (rest.includes(":") && !/^".*"$/.test(rest) && !rest.startsWith("[") && !rest.startsWith("{")) { lines[pos] = { indent: at + 2, text: rest }; arr.push(parseMap(at + 2)); }
|
|
152
|
+
else { arr.push(parseScalar(rest)); pos++; }
|
|
153
|
+
}
|
|
154
|
+
return arr;
|
|
155
|
+
}
|
|
156
|
+
return parseMap(first.indent);
|
|
157
|
+
}
|
|
158
|
+
function parseMap(indent) {
|
|
159
|
+
const obj = {};
|
|
160
|
+
while (pos < lines.length && lines[pos].indent === indent) {
|
|
161
|
+
const line = lines[pos].text, ci = line.indexOf(":");
|
|
162
|
+
if (ci < 0) { pos++; continue; }
|
|
163
|
+
const key = line.slice(0, ci).trim(), val = line.slice(ci + 1).trim();
|
|
164
|
+
if (val === "") { pos++; if (pos < lines.length && lines[pos].indent > indent) obj[key] = parseBlock(); else obj[key] = null; }
|
|
165
|
+
else { obj[key] = parseScalar(val); pos++; }
|
|
166
|
+
}
|
|
167
|
+
return obj;
|
|
168
|
+
}
|
|
169
|
+
return parseBlock();
|
|
170
|
+
}
|
|
171
|
+
const serialize = (obj, fmt) => fmt === "yaml" ? toYaml(obj) : JSON.stringify(obj, null, 2);
|
|
172
|
+
const deserialize = (text, fmt) => fmt === "yaml" ? fromYaml(text) : JSON.parse(text || "{}");
|
|
99
173
|
|
|
174
|
+
// ── API ──
|
|
100
175
|
async function api(path, opts = {}) {
|
|
101
176
|
const headers = Object.assign({ "X-Agend-Token": token }, opts.headers || {});
|
|
102
177
|
if (opts.body) headers["Content-Type"] = "application/json";
|
|
@@ -104,113 +179,218 @@
|
|
|
104
179
|
let body = null; try { body = await r.json(); } catch {}
|
|
105
180
|
return { ok: r.ok, status: r.status, body };
|
|
106
181
|
}
|
|
182
|
+
function renderResult(msgEl, res) {
|
|
183
|
+
const b = res.body || {}; let html = "";
|
|
184
|
+
if (b.errors && b.errors.length) html += b.errors.map(e => `<span class="e">✗ ${e.path ? esc(e.path) + ": " : ""}${esc(e.message)}</span>`).join("\n");
|
|
185
|
+
if (b.warnings && b.warnings.length) html += (html ? "\n" : "") + b.warnings.map(w => `<span class="w">⚠ ${w.path ? esc(w.path) + ": " : ""}${esc(w.message)}</span>`).join("\n");
|
|
186
|
+
if (res.ok) html = `<span class="s">✓ Saved</span>` + (html ? "\n" + html : "");
|
|
187
|
+
else if (!html) html = `<span class="e">✗ ${esc((b && b.error) || ("HTTP " + res.status))}</span>`;
|
|
188
|
+
msgEl.innerHTML = html;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── State ──
|
|
192
|
+
const state = { fleet: {}, classic: {}, snap: "{}", tab: "fleet",
|
|
193
|
+
right: { fleet: { fmt: "yaml", edit: false }, instances: { fmt: "yaml", edit: false }, classic: { fmt: "yaml", edit: false } } };
|
|
107
194
|
|
|
108
|
-
//
|
|
109
|
-
function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (b.warnings && b.warnings.length) html += (html ? "\n" : "") + b.warnings.map(w => `<span class="w">⚠ ${w.path ? w.path + ": " : ""}${escapeHtml(w.message)}</span>`).join("\n");
|
|
114
|
-
if (res.ok && !(b.warnings && b.warnings.length)) html = `<span class="s">✓ Saved</span>`;
|
|
115
|
-
else if (res.ok && b.warnings && b.warnings.length) html = `<span class="s">✓ Saved</span>\n` + html;
|
|
116
|
-
else if (!res.ok && !html) html = `<span class="e">✗ ${escapeHtml((b && b.error) || ("HTTP " + res.status))}</span>`;
|
|
117
|
-
el.innerHTML = html;
|
|
195
|
+
// The object each tab's right-pane mirrors.
|
|
196
|
+
function modelOf(tab) {
|
|
197
|
+
if (tab === "fleet") return { defaults: state.fleet.defaults || {}, channels: state.fleet.channels || (state.fleet.channel ? [state.fleet.channel] : []) };
|
|
198
|
+
if (tab === "instances") return state.fleet.instances || {};
|
|
199
|
+
return { defaults: state.classic.defaults || {} };
|
|
118
200
|
}
|
|
119
|
-
const
|
|
120
|
-
const parseJson = (id) => { try { return { v: JSON.parse(q(id).value || "{}") }; } catch (e) { return { err: e.message }; } };
|
|
201
|
+
const channelIds = () => (state.fleet.channels || (state.fleet.channel ? [state.fleet.channel] : [])).map(c => c.id || c.type).filter(Boolean);
|
|
121
202
|
|
|
122
|
-
// ──
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
203
|
+
// ── Chip list widget ──
|
|
204
|
+
function chipList(arr, onChange, placeholder) {
|
|
205
|
+
const box = el("div"); const chips = el("div", { class: "chips" });
|
|
206
|
+
const redraw = () => { chips.innerHTML = ""; arr.forEach((v, i) => chips.append(el("span", { class: "chip" }, String(v), el("span", { class: "x", onclick: () => { arr.splice(i, 1); redraw(); onChange(); } }, "×")))); };
|
|
207
|
+
const inp = el("input", { type: "text", placeholder: placeholder || "add…" });
|
|
208
|
+
const add = () => { const v = inp.value.trim(); if (v) { arr.push(/^-?\d+$/.test(v) ? Number(v) : v); inp.value = ""; redraw(); onChange(); } };
|
|
209
|
+
inp.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); add(); } });
|
|
210
|
+
box.append(chips, el("div", { class: "chip-add" }, inp, el("button", { class: "sm", onclick: add }, "+ Add")));
|
|
211
|
+
redraw(); return box;
|
|
212
|
+
}
|
|
213
|
+
function select(value, options, onChange) {
|
|
214
|
+
const s = el("select", { onchange: () => onChange(s.value) });
|
|
215
|
+
for (const o of options) { const opt = el("option", {}, o); opt.value = o; if (o === value) opt.selected = true; s.append(opt); }
|
|
216
|
+
return s;
|
|
217
|
+
}
|
|
127
218
|
|
|
128
|
-
// ──
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
219
|
+
// ── Right pane (shared) ──
|
|
220
|
+
function renderRight(tab) {
|
|
221
|
+
const host = $(tab + "-right"); host.innerHTML = "";
|
|
222
|
+
const rs = state.right[tab]; const model = modelOf(tab);
|
|
223
|
+
const msg = el("div", { class: "msg" });
|
|
224
|
+
const seg = el("div", { class: "seg" },
|
|
225
|
+
el("button", { class: "sm" + (rs.fmt === "yaml" ? " on" : ""), onclick: () => { rs.fmt = "yaml"; renderRight(tab); } }, "YAML"),
|
|
226
|
+
el("button", { class: "sm" + (rs.fmt === "json" ? " on" : ""), onclick: () => { rs.fmt = "json"; renderRight(tab); } }, "JSON"));
|
|
227
|
+
const editBtn = el("button", { class: "sm", onclick: () => { rs.edit = !rs.edit; renderRight(tab); } }, rs.edit ? "View" : "Edit");
|
|
228
|
+
const copyBtn = el("button", { class: "sm", onclick: () => { navigator.clipboard.writeText(serialize(model, rs.fmt)).then(() => { msg.innerHTML = `<span class="s">Copied</span>`; }); } }, "Copy");
|
|
229
|
+
const dlBtn = el("button", { class: "sm", onclick: () => download(tab, rs.fmt) }, "Download");
|
|
230
|
+
const toolbar = el("div", { class: "right-toolbar" }, seg, editBtn, copyBtn, dlBtn);
|
|
231
|
+
if (tab === "fleet") toolbar.append(el("button", { class: "sm", onclick: () => download("full", "yaml") }, "⬇ full fleet.yaml"));
|
|
232
|
+
host.append(el("div", { class: "card" }, el("h2", {}, "Config " + (rs.edit ? "(editing)" : "(preview)")), toolbar, (() => {
|
|
233
|
+
if (rs.edit) {
|
|
234
|
+
const ta = el("textarea"); ta.value = serialize(model, rs.fmt);
|
|
235
|
+
const apply = el("button", { class: "primary sm", onclick: () => {
|
|
236
|
+
let parsed; try { parsed = deserialize(ta.value, rs.fmt); } catch (e) { msg.innerHTML = `<span class="e">✗ ${esc(e.message)}</span>`; return; }
|
|
237
|
+
applyModel(tab, parsed); rs.edit = false; renderTab(tab); msg.innerHTML = `<span class="s">Applied to form (not yet saved)</span>`;
|
|
238
|
+
} }, "Apply → form");
|
|
239
|
+
const box = el("div"); box.append(ta, el("div", { class: "row-actions" }, apply, msg)); return box;
|
|
240
|
+
}
|
|
241
|
+
const pre = el("pre", { class: "preview" }); pre.textContent = serialize(model, rs.fmt);
|
|
242
|
+
const box = el("div"); box.append(pre, msg); return box;
|
|
243
|
+
})()));
|
|
244
|
+
}
|
|
245
|
+
// Parse from the right pane back into left-form state.
|
|
246
|
+
function applyModel(tab, parsed) {
|
|
247
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
248
|
+
if (tab === "fleet") { if (parsed.defaults) state.fleet.defaults = parsed.defaults; if (parsed.channels) { state.fleet.channels = parsed.channels; delete state.fleet.channel; } }
|
|
249
|
+
else if (tab === "instances") { state.fleet.instances = parsed; }
|
|
250
|
+
else { if (parsed.defaults) state.classic.defaults = parsed.defaults; }
|
|
251
|
+
}
|
|
252
|
+
function download(which, fmt) {
|
|
253
|
+
let name, content;
|
|
254
|
+
if (which === "full") { name = "fleet.yaml"; content = toYaml(fleetForDownload()); }
|
|
255
|
+
else if (which === "fleet") { name = "fleet-section." + fmt; content = serialize(modelOf("fleet"), fmt); }
|
|
256
|
+
else if (which === "instances") { name = "instances." + fmt; content = serialize(modelOf("instances"), fmt); }
|
|
257
|
+
else { name = "classicBot." + fmt; content = serialize(modelOf("classic"), fmt); }
|
|
258
|
+
const blob = new Blob([content], { type: "text/plain" }); const a = el("a", { href: URL.createObjectURL(blob), download: name }); a.click(); URL.revokeObjectURL(a.href);
|
|
259
|
+
}
|
|
260
|
+
function fleetForDownload() {
|
|
261
|
+
const f = {};
|
|
262
|
+
if (state.fleet.channels || state.fleet.channel) f.channels = state.fleet.channels || [state.fleet.channel];
|
|
263
|
+
if (state.fleet.defaults) f.defaults = state.fleet.defaults;
|
|
264
|
+
if (state.fleet.instances) f.instances = state.fleet.instances;
|
|
265
|
+
return f;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Fleet left ──
|
|
269
|
+
function renderFleetLeft() {
|
|
270
|
+
const host = $("fleet-left"); host.innerHTML = "";
|
|
271
|
+
state.fleet.defaults = state.fleet.defaults || {};
|
|
272
|
+
const d = state.fleet.defaults; d.startup = d.startup || {};
|
|
273
|
+
const sync = () => renderRight("fleet");
|
|
274
|
+
|
|
275
|
+
// Defaults card
|
|
276
|
+
const dc = el("div", { class: "card" }, el("h2", {}, "Fleet Defaults"));
|
|
277
|
+
dc.append(el("label", {}, "Backend"), select(d.backend || "claude-code", BACKENDS, v => { d.backend = v; sync(); }));
|
|
278
|
+
const model = el("input", { type: "text", value: d.model || "", placeholder: "e.g. opus, sonnet", oninput: e => { d.model = e.target.value || undefined; sync(); } });
|
|
279
|
+
dc.append(el("label", {}, "Model"), model);
|
|
280
|
+
const g2 = el("div", { class: "grid2" });
|
|
281
|
+
const conc = el("input", { type: "number", value: d.startup.concurrency ?? "", placeholder: "e.g. 3", oninput: e => { d.startup.concurrency = e.target.value === "" ? undefined : Number(e.target.value); sync(); } });
|
|
282
|
+
const stag = el("input", { type: "number", value: d.startup.stagger_delay_ms ?? "", placeholder: "ms", oninput: e => { d.startup.stagger_delay_ms = e.target.value === "" ? undefined : Number(e.target.value); sync(); } });
|
|
283
|
+
g2.append(el("div", {}, el("label", {}, "Startup concurrency"), conc), el("div", {}, el("label", {}, "Stagger delay (ms)"), stag));
|
|
284
|
+
dc.append(g2);
|
|
285
|
+
const dmsg = el("div", { class: "msg" });
|
|
286
|
+
dc.append(el("div", { class: "row-actions" }, el("button", { class: "primary", onclick: async () => { renderResult(dmsg, await api("/api/settings/fleet/defaults", { method: "PUT", body: JSON.stringify(d) })); } }, "Save defaults"), dmsg));
|
|
287
|
+
host.append(dc);
|
|
288
|
+
|
|
289
|
+
// Channels card
|
|
290
|
+
state.fleet.channels = state.fleet.channels || (state.fleet.channel ? [state.fleet.channel] : []);
|
|
291
|
+
const cc = el("div", { class: "card" }, el("h2", {}, el("span", { class: "grow" }, "Channels"), el("button", { class: "sm", onclick: () => { state.fleet.channels.push({ type: "discord", bot_token_env: "" }); renderTab("fleet"); } }, "+ Add channel")));
|
|
292
|
+
state.fleet.channels.forEach((ch, i) => {
|
|
293
|
+
ch.access = ch.access || { mode: "locked", allowed_users: [] };
|
|
294
|
+
ch.access.allowed_users = ch.access.allowed_users || [];
|
|
295
|
+
const card = el("div", { class: "card", style: "background:var(--bg)" });
|
|
296
|
+
card.append(el("h2", {}, el("span", { class: "grow" }, "#" + (i + 1) + " " + esc(ch.id || ch.type || "channel")), el("button", { class: "sm danger", onclick: () => { state.fleet.channels.splice(i, 1); renderTab("fleet"); } }, "🗑 Delete")));
|
|
297
|
+
const gg = el("div", { class: "grid2" });
|
|
298
|
+
gg.append(el("div", {}, el("label", {}, "Type"), select(ch.type || "discord", CH_TYPES, v => { ch.type = v; renderTab("fleet"); })),
|
|
299
|
+
el("div", {}, el("label", {}, "id (adapter id)"), el("input", { type: "text", value: ch.id || "", placeholder: "optional", oninput: e => { ch.id = e.target.value || undefined; sync(); } })));
|
|
300
|
+
card.append(gg);
|
|
301
|
+
card.append(el("label", {}, "bot_token_env"), el("input", { type: "text", value: ch.bot_token_env || "", placeholder: "e.g. DISCORD_BOT_TOKEN", oninput: e => { ch.bot_token_env = e.target.value; sync(); } }));
|
|
302
|
+
card.append(el("label", {}, "group_id"), el("input", { type: "text", value: ch.group_id ?? "", placeholder: "guild / supergroup id", oninput: e => { ch.group_id = e.target.value || undefined; sync(); } }));
|
|
303
|
+
card.append(el("label", {}, "access.allowed_users"), chipList(ch.access.allowed_users, sync, "user id"));
|
|
304
|
+
cc.append(card);
|
|
305
|
+
});
|
|
306
|
+
const cmsg = el("div", { class: "msg" });
|
|
307
|
+
cc.append(el("div", { class: "row-actions" }, el("button", { class: "primary", onclick: async () => { const res = await api("/api/settings/fleet/channels", { method: "PUT", body: JSON.stringify(state.fleet.channels) }); renderResult(cmsg, res); if (res.ok) reload(); } }, "Save channels"), cmsg));
|
|
308
|
+
host.append(cc);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── Instances left ──
|
|
312
|
+
let expanded = null;
|
|
313
|
+
function renderInstancesLeft() {
|
|
314
|
+
const host = $("instances-left"); host.innerHTML = "";
|
|
315
|
+
state.fleet.instances = state.fleet.instances || {};
|
|
316
|
+
const insts = state.fleet.instances;
|
|
317
|
+
const card = el("div", { class: "card" }, el("h2", {}, el("span", { class: "grow" }, "Instances")));
|
|
318
|
+
const addName = el("input", { type: "text", placeholder: "new instance name", style: "flex:0 0 200px" });
|
|
319
|
+
const addMsg = el("div", { class: "msg" });
|
|
320
|
+
card.append(el("div", { class: "row-actions" }, addName, el("button", { class: "primary sm", onclick: async () => {
|
|
321
|
+
const name = addName.value.trim(); if (!name) { addMsg.innerHTML = `<span class="e">✗ name required</span>`; return; }
|
|
322
|
+
const res = await api(`/api/settings/fleet/instances/${encodeURIComponent(name)}`, { method: "POST", body: JSON.stringify({ working_directory: "" }) });
|
|
323
|
+
renderResult(addMsg, res); if (res.ok) { addName.value = ""; reload(); }
|
|
324
|
+
} }, "+ Add"), addMsg));
|
|
325
|
+
const tbl = el("table"); tbl.append(el("thead", {}, el("tr", {}, ...["Name", "Backend", "Channel", "General"].map(h => el("th", {}, h)))));
|
|
326
|
+
const tb = el("tbody");
|
|
145
327
|
for (const name of Object.keys(insts)) {
|
|
146
|
-
const card = document.createElement("div");
|
|
147
|
-
card.className = "card";
|
|
148
328
|
const inst = insts[name];
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
<div class="actions">
|
|
154
|
-
<button class="primary" data-act="save" data-name="${escapeHtml(name)}">Save</button>
|
|
155
|
-
<button class="danger" data-act="del" data-name="${escapeHtml(name)}">Delete</button>
|
|
156
|
-
<span class="msg" id="msg-inst-${cssId(name)}"></span>
|
|
157
|
-
</div>`;
|
|
158
|
-
list.appendChild(card);
|
|
159
|
-
q("inst-" + cssId(name)).value = JSON.stringify(inst, null, 2);
|
|
329
|
+
const row = el("tr", { class: "inst-row", onclick: () => { expanded = expanded === name ? null : name; renderInstancesLeft(); } },
|
|
330
|
+
el("td", {}, name), el("td", {}, inst.backend || "—"), el("td", {}, inst.channel_id || "—"), el("td", {}, inst.general_topic ? "✓" : ""));
|
|
331
|
+
tb.append(row);
|
|
332
|
+
if (expanded === name) tb.append(instEditRow(name, inst));
|
|
160
333
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
});
|
|
334
|
+
tbl.append(tb); card.append(tbl); host.append(card);
|
|
335
|
+
}
|
|
336
|
+
function instEditRow(name, inst) {
|
|
337
|
+
const td = el("td", { colspan: "4" });
|
|
338
|
+
const sync = () => renderRight("instances");
|
|
339
|
+
td.append(el("label", {}, "Backend"), select(inst.backend || "claude-code", BACKENDS, v => { inst.backend = v; sync(); }));
|
|
340
|
+
td.append(el("label", {}, "channel_id"), select(inst.channel_id || "", ["", ...channelIds()], v => { inst.channel_id = v || undefined; sync(); }));
|
|
341
|
+
td.append(el("label", {}, "working_directory"), el("input", { type: "text", value: inst.working_directory || "", oninput: e => { inst.working_directory = e.target.value; sync(); } }));
|
|
342
|
+
td.append(el("label", {}, "description"), el("input", { type: "text", value: inst.description || "", oninput: e => { inst.description = e.target.value || undefined; sync(); } }));
|
|
343
|
+
const msg = el("div", { class: "msg" });
|
|
344
|
+
td.append(el("div", { class: "row-actions" },
|
|
345
|
+
el("button", { class: "primary sm", onclick: async () => { renderResult(msg, await api(`/api/settings/fleet/instances/${encodeURIComponent(name)}`, { method: "PATCH", body: JSON.stringify(inst) })); } }, "Save"),
|
|
346
|
+
el("button", { class: "sm danger", onclick: async () => { if (!confirm(`Delete "${name}"?`)) return; const res = await api(`/api/settings/fleet/instances/${encodeURIComponent(name)}`, { method: "DELETE" }); if (res.ok) { expanded = null; reload(); } else renderResult(msg, res); } }, "🗑 Delete"), msg));
|
|
347
|
+
return el("tr", { class: "edit-row" }, td);
|
|
165
348
|
}
|
|
166
|
-
const cssId = (s) => s.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
167
349
|
|
|
168
|
-
// ──
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
q("reloadBtn").onclick = async () => {
|
|
350
|
+
// ── ClassicBot left ──
|
|
351
|
+
function renderClassicLeft() {
|
|
352
|
+
const host = $("classic-left"); host.innerHTML = "";
|
|
353
|
+
state.classic.defaults = state.classic.defaults || {};
|
|
354
|
+
const d = state.classic.defaults;
|
|
355
|
+
d.admin_users = d.admin_users || []; d.allowed_guilds = d.allowed_guilds || [];
|
|
356
|
+
const sync = () => renderRight("classic");
|
|
357
|
+
const card = el("div", { class: "card" }, el("h2", {}, "ClassicBot Defaults"));
|
|
358
|
+
card.append(el("label", {}, "Default backend"), select(d.backend || "claude-code", BACKENDS, v => { d.backend = v; sync(); }));
|
|
359
|
+
card.append(el("label", {}, "Admin users"), chipList(d.admin_users, sync, "user id"));
|
|
360
|
+
card.append(el("label", {}, "Allowed guilds"), chipList(d.allowed_guilds, sync, "guild id"));
|
|
361
|
+
const msg = el("div", { class: "msg" });
|
|
362
|
+
card.append(el("div", { class: "row-actions" }, el("button", { class: "primary", onclick: async () => { renderResult(msg, await api("/api/settings/classic/defaults", { method: "PUT", body: JSON.stringify(d) })); } }, "Save classic defaults"), msg));
|
|
363
|
+
host.append(card);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function renderTab(tab) {
|
|
367
|
+
if (tab === "fleet") renderFleetLeft();
|
|
368
|
+
else if (tab === "instances") renderInstancesLeft();
|
|
369
|
+
else renderClassicLeft();
|
|
370
|
+
renderRight(tab);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
document.querySelectorAll(".tab").forEach(t => t.onclick = () => {
|
|
374
|
+
state.tab = t.dataset.tab;
|
|
375
|
+
document.querySelectorAll(".tab").forEach(x => x.classList.toggle("active", x === t));
|
|
376
|
+
document.querySelectorAll(".panel").forEach(p => p.classList.toggle("active", p.id === "panel-" + state.tab));
|
|
377
|
+
renderTab(state.tab);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
async function reload() {
|
|
381
|
+
const f = await api("/api/settings/fleet");
|
|
382
|
+
if (!f.ok) { $("banner").style.display = "block"; $("banner").textContent = "Failed to load fleet config (check the token in the URL)."; return; }
|
|
383
|
+
state.fleet = f.body || {};
|
|
384
|
+
const c = await api("/api/settings/classic"); state.classic = c.body || {};
|
|
385
|
+
renderTab(state.tab);
|
|
386
|
+
}
|
|
387
|
+
$("reloadBtn").onclick = async () => {
|
|
207
388
|
const res = await api("/api/settings/reload", { method: "POST" });
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
setTimeout(() => q("banner").style.display = "none", 4000);
|
|
389
|
+
$("banner").style.display = "block"; $("banner").textContent = res.ok ? "✓ Reload signal sent — instances reconciling." : "Reload failed.";
|
|
390
|
+
setTimeout(() => $("banner").style.display = "none", 4000);
|
|
211
391
|
};
|
|
212
392
|
|
|
213
|
-
|
|
393
|
+
reload();
|
|
214
394
|
})();
|
|
215
395
|
</script>
|
|
216
396
|
</body>
|