@songsid/agend 2.0.11-beta.4 → 2.0.11-beta.41

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.
Files changed (54) hide show
  1. package/dist/channel/adapters/discord.d.ts +2 -0
  2. package/dist/channel/adapters/discord.js +45 -36
  3. package/dist/channel/adapters/discord.js.map +1 -1
  4. package/dist/channel/adapters/telegram.js +1 -0
  5. package/dist/channel/adapters/telegram.js.map +1 -1
  6. package/dist/channel/factory.d.ts +4 -0
  7. package/dist/channel/factory.js +1 -0
  8. package/dist/channel/factory.js.map +1 -1
  9. package/dist/channel/mcp-tools.js +3 -0
  10. package/dist/channel/mcp-tools.js.map +1 -1
  11. package/dist/channel/tool-router.js +29 -2
  12. package/dist/channel/tool-router.js.map +1 -1
  13. package/dist/channel/types.d.ts +2 -0
  14. package/dist/classic-channel-manager.d.ts +59 -12
  15. package/dist/classic-channel-manager.js +121 -30
  16. package/dist/classic-channel-manager.js.map +1 -1
  17. package/dist/cli.js +94 -7
  18. package/dist/cli.js.map +1 -1
  19. package/dist/config-validator.d.ts +20 -0
  20. package/dist/config-validator.js +154 -0
  21. package/dist/config-validator.js.map +1 -0
  22. package/dist/fleet-manager.d.ts +29 -6
  23. package/dist/fleet-manager.js +454 -258
  24. package/dist/fleet-manager.js.map +1 -1
  25. package/dist/instance-lifecycle.d.ts +1 -1
  26. package/dist/instance-lifecycle.js +15 -13
  27. package/dist/instance-lifecycle.js.map +1 -1
  28. package/dist/locale.d.ts +19 -0
  29. package/dist/locale.js +179 -0
  30. package/dist/locale.js.map +1 -0
  31. package/dist/outbound-schemas.d.ts +1 -0
  32. package/dist/outbound-schemas.js +1 -0
  33. package/dist/outbound-schemas.js.map +1 -1
  34. package/dist/quickstart.js +252 -16
  35. package/dist/quickstart.js.map +1 -1
  36. package/dist/scheduler/scheduler.js +8 -2
  37. package/dist/scheduler/scheduler.js.map +1 -1
  38. package/dist/service-installer.d.ts +1 -0
  39. package/dist/service-installer.js +2 -1
  40. package/dist/service-installer.js.map +1 -1
  41. package/dist/settings-api.d.ts +33 -0
  42. package/dist/settings-api.js +283 -0
  43. package/dist/settings-api.js.map +1 -0
  44. package/dist/topic-commands.d.ts +14 -0
  45. package/dist/topic-commands.js +78 -16
  46. package/dist/topic-commands.js.map +1 -1
  47. package/dist/types.d.ts +2 -0
  48. package/dist/ui/settings.html +617 -0
  49. package/dist/ui/view.html +300 -39
  50. package/dist/view-api.d.ts +9 -0
  51. package/dist/view-api.js +101 -40
  52. package/dist/view-api.js.map +1 -1
  53. package/package.json +1 -1
  54. package/templates/systemd.service.ejs +2 -1
@@ -0,0 +1,617 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>AgEnD Settings</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ :root {
10
+ --bg:#ffffff; --card:#f9fafb; --border:#e5e7eb; --fg:#111827; --muted:#6b7280;
11
+ --accent:#2563eb; --accent-hover:#1d4ed8; --green:#059669; --red:#dc2626;
12
+ --radius:8px; --radius-sm:6px;
13
+ }
14
+ body { background: var(--bg); color: var(--fg); font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.5; padding-bottom: 60px; }
15
+ header { display: flex; align-items: center; gap: 12px; padding: 16px 24px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 20; }
16
+ header h1 { font-size: 18px; font-weight: 700; }
17
+ .grow { flex: 1; }
18
+ main { max-width: 860px; margin: 0 auto; padding: 24px; }
19
+ section { margin-bottom: 28px; }
20
+ .sec-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
21
+ .sec-head h2 { font-size: 16px; font-weight: 600; }
22
+ .sec-head .grow { flex: 1; }
23
+ .card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
24
+ .card.empty { padding: 16px; color: var(--muted); }
25
+ .item { padding: 9px 16px; border-bottom: 1px solid var(--border); }
26
+ .item:last-child { border-bottom: none; }
27
+ .item-row { display: flex; align-items: center; gap: 10px; min-height: 24px; }
28
+ .group-head { display: flex; align-items: center; gap: 8px; padding: 7px 16px; cursor: pointer; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .3px; background: #f3f4f6; border-bottom: 1px solid var(--border); user-select: none; }
29
+ .group-head:hover { color: var(--fg); }
30
+ .group-head .caret { width: 12px; flex: 0 0 12px; font-size: 10px; }
31
+ .group-head .gcount { color: var(--muted); font-weight: 400; }
32
+ .item .del-btn { visibility: hidden; }
33
+ .item:hover .del-btn { visibility: visible; }
34
+ .dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; background: #9ca3af; }
35
+ .dot.running { background: var(--green); } .dot.crashed { background: var(--red); } .dot.stopped { background: #9ca3af; }
36
+ .name { font-weight: 600; }
37
+ .tag { font-size: 12px; color: var(--muted); background: #eef2ff; color: #3730a3; padding: 1px 7px; border-radius: 10px; }
38
+ .tag.persona { background: #fdf2f8; color: #9d174d; }
39
+ .tag.tg { background: #e0f2fe; color: #075985; }
40
+ .sub { font-size: 13px; color: var(--muted); margin-top: 3px; margin-left: 19px; }
41
+ .spacer { flex: 1; }
42
+ button { font-family: inherit; font-size: 13px; border-radius: var(--radius-sm); padding: 6px 12px; border: 1px solid var(--border); background: #fff; color: var(--fg); cursor: pointer; }
43
+ button:hover { background: #f3f4f6; }
44
+ button.primary { background: var(--accent); color: #fff; border-color: var(--accent); font-weight: 600; }
45
+ button.primary:hover { background: var(--accent-hover); }
46
+ button.ghost { border-color: transparent; background: transparent; color: var(--muted); }
47
+ button.ghost:hover { background: #f3f4f6; color: var(--fg); }
48
+ button.danger { color: var(--red); }
49
+ button.danger:hover { background: #fef2f2; }
50
+ button.sm { padding: 4px 9px; font-size: 12px; }
51
+ label { display: block; font-size: 12px; font-weight: 500; color: var(--muted); margin: 10px 0 4px; }
52
+ input[type=text], select { width: 100%; font-family: inherit; font-size: 13px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: #fff; color: var(--fg); }
53
+ input:focus, select:focus { outline: 2px solid #bfdbfe; border-color: var(--accent); }
54
+ .edit { padding: 14px 16px; background: #fff; border-top: 1px solid var(--border); }
55
+ .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
56
+ .actions { display: flex; gap: 8px; margin-top: 12px; align-items: center; flex-wrap: wrap; }
57
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
58
+ .chip { display: inline-flex; align-items: center; gap: 6px; background: #fff; border: 1px solid var(--border); border-radius: 14px; padding: 3px 6px 3px 10px; font-size: 12px; }
59
+ .chip .x { cursor: pointer; color: var(--muted); font-weight: 700; } .chip .x:hover { color: var(--red); }
60
+ .chip-add { display: flex; gap: 6px; margin-top: 6px; max-width: 320px; }
61
+ .field { padding: 12px 16px; border-bottom: 1px solid var(--border); }
62
+ .field:last-child { border-bottom: none; }
63
+ .msg { font-size: 12px; margin-left: 4px; }
64
+ .msg .e { color: var(--red); } .msg .w { color: #b45309; } .msg .s { color: var(--green); }
65
+ .hint { font-size: 12px; color: var(--muted); margin-top: 6px; }
66
+ .adv-body { display: none; padding: 14px 16px; }
67
+ .adv-body.open { display: block; }
68
+ .toolbar { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
69
+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
70
+ .seg button { border: none; border-radius: 0; }
71
+ .seg button.on { background: var(--accent); color: #fff; }
72
+ textarea { width: 100%; min-height: 380px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.5; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; color: var(--fg); background: #fff; resize: vertical; }
73
+ pre.preview { background: #f9fafb; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.5; overflow: auto; max-height: 480px; white-space: pre; min-height: 380px; }
74
+ .banner { display: none; padding: 10px 24px; font-size: 13px; background: #ecfdf5; color: var(--green); border-bottom: 1px solid var(--border); }
75
+ .help-btn { width: 34px; height: 34px; border-radius: 50%; border: 1px solid var(--border); background: #f3f4f6; cursor: pointer; font-size: 16px; display: inline-flex; align-items: center; justify-content: center; padding: 0; }
76
+ .help-btn:hover { background: var(--accent); border-color: var(--accent); }
77
+ .help-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.3); display: none; z-index: 40; }
78
+ .help-backdrop.open { display: block; }
79
+ .help-panel { position: fixed; top: 0; right: 0; height: 100%; width: 380px; max-width: 90vw; background: var(--bg); border-left: 1px solid var(--border); box-shadow: -8px 0 24px rgba(0,0,0,.12); transform: translateX(100%); transition: transform .2s ease; z-index: 50; overflow-y: auto; padding: 20px 22px; }
80
+ .help-panel.open { transform: translateX(0); }
81
+ .help-panel h3 { font-size: 16px; margin-bottom: 8px; }
82
+ .help-panel h4 { font-size: 14px; margin: 16px 0 6px; color: var(--accent); }
83
+ .help-panel ul { margin: 0 0 8px 18px; }
84
+ .help-panel li { font-size: 13px; margin: 3px 0; }
85
+ .help-panel .close { position: absolute; top: 14px; right: 16px; cursor: pointer; color: var(--muted); font-size: 22px; line-height: 1; background: none; border: none; }
86
+ .help-panel .tip { margin-top: 16px; padding: 10px 12px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 13px; }
87
+ </style>
88
+ </head>
89
+ <body>
90
+ <header>
91
+ <h1 data-i18n="title">⚙️ AgEnD Settings</h1>
92
+ <span id="verBadge" title="What's New" style="font-size:12px;color:var(--muted);cursor:pointer;padding:2px 8px;border:1px solid var(--border);border-radius:12px">v…</span>
93
+ <div class="grow"></div>
94
+ <button class="help-btn" id="langBtn" title="Language">🌐</button>
95
+ <button class="help-btn" id="helpBtn" title="Help">ℹ️</button>
96
+ <button id="reloadBtn" class="primary" data-i18n="reloadFleet" title="Apply saved config to running instances (SIGHUP)">↻ Reload Fleet</button>
97
+ </header>
98
+ <div class="help-backdrop" id="helpBackdrop"></div>
99
+ <aside class="help-panel" id="helpPanel">
100
+ <button class="close" id="helpClose" title="Close">×</button>
101
+ <div id="helpContent"></div>
102
+ </aside>
103
+ <div class="banner" id="banner"></div>
104
+ <main>
105
+ <section>
106
+ <div class="sec-head"><h2 data-i18n="myAgents">🤖 My Agents</h2><div class="grow"></div><input type="text" id="agentSearch" data-i18n-ph="searchPh" placeholder="🔍 Search…" style="width:180px;margin-right:8px"><button class="sm" id="newAgentBtn" data-i18n="new">+ New</button></div>
107
+ <div id="newAgent"></div>
108
+ <div class="card" id="agents"></div>
109
+ </section>
110
+
111
+ <section id="classicSection">
112
+ <div class="sec-head"><h2 data-i18n="classicChannels">🎮 ClassicBot Channels</h2></div>
113
+ <div class="card" id="classicList"></div>
114
+ </section>
115
+
116
+ <section>
117
+ <div class="sec-head"><h2 data-i18n="bots">🔌 Bots</h2><div class="grow"></div><button class="sm" id="newBotBtn" data-i18n="add">+ Add</button></div>
118
+ <div id="newBot"></div>
119
+ <div class="card" id="bots"></div>
120
+ </section>
121
+
122
+ <section>
123
+ <div class="sec-head"><h2 data-i18n="general">⚙️ General Settings</h2></div>
124
+ <div class="card" id="general"></div>
125
+ </section>
126
+
127
+ <section>
128
+ <div class="sec-head"><h2 data-i18n="whatsNew">🆕 What's New</h2><div class="grow"></div><button class="sm" id="wnToggle" data-i18n="expand">▶ Expand</button></div>
129
+ <div class="card"><div class="adv-body" id="wnBody"></div></div>
130
+ </section>
131
+
132
+ <section>
133
+ <div class="sec-head"><h2 data-i18n="advanced">📋 Advanced</h2><div class="grow"></div><button class="sm" id="advToggle" data-i18n="expand">▶ Expand</button></div>
134
+ <div class="card"><div class="adv-body" id="advBody"></div></div>
135
+ </section>
136
+ </main>
137
+ <script>
138
+ (() => {
139
+ "use strict";
140
+ const token = new URLSearchParams(location.search).get("token") || "";
141
+ const $ = (id) => document.getElementById(id);
142
+ 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) if (c != null) e.append(c); return e; };
143
+ const esc = (s) => String(s).replace(/[&<>]/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;" }[c]));
144
+ const BACKENDS = ["claude-code", "kiro-cli", "codex", "opencode", "gemini-cli", "antigravity"];
145
+
146
+ // ── i18n ──
147
+ const I18N = {
148
+ en: {
149
+ title: "⚙️ AgEnD Settings", reloadFleet: "↻ Reload Fleet", myAgents: "🤖 My Agents", bots: "🔌 Bots", classicChannels: "🎮 ClassicBot Channels", noClassic: "No ClassicBot channels — start one with /start in a chat.",
150
+ general: "⚙️ General Settings", whatsNew: "🆕 What's New", advanced: "📋 Advanced",
151
+ expand: "▶ Expand", collapse: "▼ Collapse", new: "+ New", add: "+ Add", searchPh: "🔍 Search…",
152
+ edit: "Edit", close: "Close", save: "Save", cancel: "Cancel", create: "Create", start: "Start", stop: "Stop",
153
+ backend: "Backend", model: "Model", workingDir: "Working directory", channelBinding: "Channel binding",
154
+ description: "Description", systemPrompt: "System prompt", tags: "Tags", generalTopicField: "General topic (fleet dispatcher)",
155
+ language: "Language (bot replies)", defaultBackend: "Default backend", defaultModel: "Default model", allowedUsers: "Allowed users (primary bot)",
156
+ classicAdmins: "ClassicBot admins", allowedGuilds: "Allowed guilds", type: "Type", adapterId: "Adapter id",
157
+ tokenEnv: "Bot token env var", groupId: "Group / guild id", nameField: "Name",
158
+ envHint: "Put the actual token in ~/.agend/.env under this env var, then Reload Fleet.",
159
+ noAgents: "No agents yet — click + New.", noBots: "No bots configured.", online: "✅ Online",
160
+ currentVersion: "Current version",
161
+ helpTitle: "📖 Help", tipReload: "💡 Click [↻ Reload Fleet] after changes to apply them.",
162
+ wnIntro: "Recent highlights:", releaseNotes: "📄 View full Release Notes →",
163
+ },
164
+ "zh-TW": {
165
+ title: "⚙️ AgEnD 設定", reloadFleet: "↻ 重新載入", myAgents: "🤖 我的 Agent", bots: "🔌 機器人", classicChannels: "🎮 ClassicBot 頻道", noClassic: "沒有 ClassicBot 頻道 — 在聊天中用 /start 建立。",
166
+ general: "⚙️ 一般設定", whatsNew: "🆕 更新重點", advanced: "📋 進階",
167
+ expand: "▶ 展開", collapse: "▼ 收合", new: "+ 新增", add: "+ 加入", searchPh: "🔍 搜尋…",
168
+ edit: "編輯", close: "關閉", save: "儲存", cancel: "取消", create: "建立", start: "啟動", stop: "停止",
169
+ backend: "後端", model: "模型", workingDir: "工作目錄", channelBinding: "頻道綁定",
170
+ description: "描述", systemPrompt: "System prompt", tags: "標籤", generalTopicField: "General topic(fleet 分派器)",
171
+ language: "語言(bot 回覆)", defaultBackend: "預設後端", defaultModel: "預設模型", allowedUsers: "允許使用者(主 bot)",
172
+ classicAdmins: "ClassicBot 管理員", allowedGuilds: "允許的伺服器", type: "類型", adapterId: "Adapter id",
173
+ tokenEnv: "Bot token 環境變數", groupId: "群組 / guild id", nameField: "名稱",
174
+ envHint: "把實際 token 放到 ~/.agend/.env 的這個環境變數下,然後點重新載入。",
175
+ noAgents: "還沒有 agent — 點 + 新增。", noBots: "尚未設定機器人。", online: "✅ 上線",
176
+ currentVersion: "目前版本",
177
+ helpTitle: "📖 使用說明", tipReload: "💡 修改後點 [↻ 重新載入] 讓設定生效。",
178
+ wnIntro: "近期更新重點:", releaseNotes: "📄 查看完整 Release Notes →",
179
+ },
180
+ };
181
+ const HELP = {
182
+ en: [["🤖 My Agents", ["[+ New] add an agent (name, backend, working directory)", "[Edit] change settings (backend, model, tags, systemPrompt)", "[Start/Stop] run or stop an agent", "🗑️ delete an agent (shows on hover, needs confirm)", "🔍 search — live-filter the list"]],
183
+ ["🔌 Bots", ["[+ Add] add a Discord/TG bot", "🗑️ remove a bot"]],
184
+ ["⚙️ General Settings", ["Edit global backend/model, allowed users, ClassicBot admins", "Click [Save]"]],
185
+ ["📋 Advanced", ["Edit YAML/JSON directly", "[⬇ fleet.yaml] download a backup"]]],
186
+ "zh-TW": [["🤖 我的 Agent", ["[+ 新增] 新增 agent(name、backend、working directory)", "[編輯] 修改設定(backend、model、tags、systemPrompt)", "[啟動/停止] 啟動或停止 agent", "🗑️ 刪除 agent(hover 顯示,需確認)", "🔍 搜尋 — 即時過濾列表"]],
187
+ ["🔌 機器人", ["[+ 加入] 加入 Discord/TG bot", "🗑️ 移除 bot"]],
188
+ ["⚙️ 一般設定", ["修改全域 backend/model、允許使用者、ClassicBot 管理員", "點 [儲存]"]],
189
+ ["📋 進階", ["直接編輯 YAML/JSON", "[⬇ fleet.yaml] 下載備份"]]],
190
+ };
191
+ const WHATSNEW = [
192
+ "Settings redesign — user-flow sections, white theme, i18n (EN / 繁中)",
193
+ "What's New panel + info help panel + ClassicBot channels list",
194
+ "Same-channel multi-bot ClassicBot; per-bot cancel-button + react fixes",
195
+ "/view avatar & ctx% fixes; config validator + `agend validate` + validate_config MCP tool",
196
+ ];
197
+ let lang = localStorage.getItem("agend_lang") || (navigator.language && navigator.language.toLowerCase().startsWith("zh") ? "zh-TW" : "en");
198
+ if (!I18N[lang]) lang = "en";
199
+ const t = (k) => (I18N[lang] && I18N[lang][k]) || I18N.en[k] || k;
200
+ function applyStaticI18n() {
201
+ document.querySelectorAll("[data-i18n]").forEach(e => { e.textContent = t(e.getAttribute("data-i18n")); });
202
+ document.querySelectorAll("[data-i18n-ph]").forEach(e => { e.setAttribute("placeholder", t(e.getAttribute("data-i18n-ph"))); });
203
+ // toggle labels that carry expand/collapse state
204
+ for (const [id, body] of [["advToggle", "advBody"], ["wnToggle", "wnBody"]]) {
205
+ const b = $(body); if (b) $(id).textContent = b.classList.contains("open") ? t("collapse") : t("expand");
206
+ }
207
+ renderHelp();
208
+ }
209
+ function renderHelp() {
210
+ const host = $("helpContent"); if (!host) return; host.innerHTML = "";
211
+ host.append(el("h3", {}, t("helpTitle")));
212
+ for (const [h, items] of (HELP[lang] || HELP.en)) {
213
+ host.append(el("h4", {}, h));
214
+ const ul = el("ul"); for (const it of items) ul.append(el("li", {}, it)); host.append(ul);
215
+ }
216
+ host.append(el("div", { class: "tip" }, t("tipReload")));
217
+ }
218
+ const CH_TYPES = ["discord", "telegram"];
219
+
220
+ // ── YAML (config subset; round-trip verified) ──
221
+ function toYaml(value, indent = 0) {
222
+ const pad = " ".repeat(indent);
223
+ const scalar = (v) => { if (v === null || v === undefined) return "null"; if (typeof v === "boolean" || typeof v === "number") return String(v); const s = String(v); if (s === "") return '""'; if (/^(true|false|null|~|-?\d+(\.\d+)?)$/i.test(s) || /[:#\[\]{}&*!|>'"%@`]/.test(s) || /^[\s-]/.test(s) || /\s$/.test(s) || s.includes("\n")) return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; return s; };
224
+ if (Array.isArray(value)) { if (!value.length) return "[]"; return value.map(item => { if (item !== null && typeof item === "object") { const d = toYaml(item, indent + 1); const lines = d.split("\n"); return pad + "- " + lines[0].slice((indent + 1) * 2) + (lines.length > 1 ? "\n" + lines.slice(1).join("\n") : ""); } return pad + "- " + scalar(item); }).join("\n"); }
225
+ if (value !== null && typeof value === "object") { const keys = Object.keys(value); if (!keys.length) return "{}"; return keys.map(k => { const v = value[k]; if (v !== null && typeof v === "object" && (Array.isArray(v) ? v.length : Object.keys(v).length)) return pad + k + ":\n" + toYaml(v, indent + 1); if (v !== null && typeof v === "object") return pad + k + ": " + (Array.isArray(v) ? "[]" : "{}"); return pad + k + ": " + scalar(v); }).join("\n"); }
226
+ return pad + scalar(value);
227
+ }
228
+ function parseScalar(s) { s = s.trim(); if (s === "" || s === "~" || s === "null") return null; if (s === "true") return true; if (s === "false") return false; if (/^-?\d+$/.test(s)) return parseInt(s, 10); if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s); if (s.startsWith('"') && s.endsWith('"')) return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\"); if (s.startsWith("'") && s.endsWith("'")) return s.slice(1, -1); if (s === "[]") return []; if (s === "{}") return {}; return s; }
229
+ function fromYaml(text) {
230
+ const lines = [];
231
+ for (const raw of text.replace(/\r/g, "").split("\n")) { let inS = false, inD = false, out = ""; 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; } if (out.trim() === "") continue; lines.push({ indent: out.match(/^ */)[0].length, text: out.trim() }); }
232
+ let pos = 0;
233
+ function block() { if (pos >= lines.length) return null; const first = lines[pos]; if (first.text.startsWith("- ")) { const arr = []; while (pos < lines.length && lines[pos].indent === first.indent && lines[pos].text.startsWith("- ")) { const at = lines[pos].indent, rest = lines[pos].text.slice(2); if (rest.includes(":") && !/^".*"$/.test(rest) && !rest.startsWith("[") && !rest.startsWith("{")) { lines[pos] = { indent: at + 2, text: rest }; arr.push(map(at + 2)); } else { arr.push(parseScalar(rest)); pos++; } } return arr; } return map(first.indent); }
234
+ function map(indent) { const obj = {}; while (pos < lines.length && lines[pos].indent === indent) { const line = lines[pos].text, ci = line.indexOf(":"); if (ci < 0) { pos++; continue; } const key = line.slice(0, ci).trim(), val = line.slice(ci + 1).trim(); if (val === "") { pos++; if (pos < lines.length && lines[pos].indent > indent) obj[key] = block(); else obj[key] = null; } else { obj[key] = parseScalar(val); pos++; } } return obj; }
235
+ return block();
236
+ }
237
+
238
+ // ── API ──
239
+ async function api(path, opts = {}) {
240
+ const headers = Object.assign({ "X-Agend-Token": token }, opts.headers || {});
241
+ if (opts.body) headers["Content-Type"] = "application/json";
242
+ const r = await fetch(path, Object.assign({}, opts, { headers }));
243
+ let body = null; try { body = await r.json(); } catch {}
244
+ return { ok: r.ok, status: r.status, body };
245
+ }
246
+ function resultMsg(elm, res) {
247
+ const b = res.body || {}; let html = "";
248
+ if (b.errors && b.errors.length) html += b.errors.map(e => `<span class="e">✗ ${esc(e.path ? e.path + ": " : "")}${esc(e.message)}</span>`).join(" ");
249
+ if (b.warnings && b.warnings.length) html += " " + b.warnings.map(w => `<span class="w">⚠ ${esc(w.message)}</span>`).join(" ");
250
+ if (res.ok) html = `<span class="s">✓ Saved</span>` + (html ? " " + html : "");
251
+ else if (!html) html = `<span class="e">✗ ${esc((b && b.error) || ("HTTP " + res.status))}</span>`;
252
+ elm.innerHTML = html;
253
+ }
254
+
255
+ // ── State ──
256
+ const state = { fleet: {}, classic: {}, classicStatus: {}, live: {}, fleetUp: false, advFmt: "yaml", advEdit: false, collapsedGroups: new Set(), version: "", newAgentOpen: false, newBotOpen: false };
257
+ const channels = () => state.fleet.channels || (state.fleet.channel ? [state.fleet.channel] : []);
258
+ const channelIds = () => channels().map(c => c.id || c.type).filter(Boolean);
259
+ const chLabel = (i) => i === 0 ? "primary" : "persona";
260
+ function chById(id) { return channels().find(c => (c.id || c.type) === id); }
261
+
262
+ function chipList(arr, onChange, placeholder) {
263
+ const box = el("div"); const chips = el("div", { class: "chips" });
264
+ 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 && onChange(); } }, "×")))); };
265
+ const inp = el("input", { type: "text", placeholder: placeholder || "add…" });
266
+ const add = () => { const v = inp.value.trim(); if (v) { arr.push(/^-?\d+$/.test(v) ? Number(v) : v); inp.value = ""; redraw(); onChange && onChange(); } };
267
+ inp.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); add(); } });
268
+ box.append(chips, el("div", { class: "chip-add" }, inp, el("button", { class: "sm", onclick: add }, "+ Add")));
269
+ redraw(); return box;
270
+ }
271
+ function select(value, options) { const s = el("select"); for (const o of options) { const opt = el("option", {}, o); opt.value = o; if (o === value) opt.selected = true; s.append(opt); } return s; }
272
+
273
+ // ── My Agents ──
274
+ let editing = null;
275
+ function agentChannelLabel(inst) {
276
+ if (inst.general_topic) return "general";
277
+ if (!inst.channel_id) return "";
278
+ const idx = channels().findIndex(c => (c.id || c.type) === inst.channel_id);
279
+ const ch = channels()[idx];
280
+ return ch ? `${ch.type} (${chLabel(idx)})` : inst.channel_id;
281
+ }
282
+ // Display name: profile display_name → else strip the -t<topicId> suffix.
283
+ const shortName = (name, inst) => (inst && inst.display_name) || name.replace(/-t\d+$/, "");
284
+ const STATUS_TEXT = { running: "🟢 Running", crashed: "🔴 Crashed", stopped: "⚪ Stopped" };
285
+ const STATUS_ORDER = { running: 0, crashed: 1, stopped: 2 };
286
+ const groupOf = (inst) => (inst.tags && inst.tags[0]) || "Other";
287
+ function compactRow(name, inst) {
288
+ const st = state.live[name]?.status || "stopped";
289
+ const running = st === "running";
290
+ const short = shortName(name, inst);
291
+ const chTag = agentChannelLabel(inst);
292
+ const item = el("div", { class: "item" });
293
+ // Single fixed-height line: dot + short name + backend + channel tag + actions.
294
+ // Full ID lives in the tooltip and the Edit form; working dir only in Edit.
295
+ item.append(el("div", { class: "item-row" },
296
+ el("span", { class: "dot " + st, title: STATUS_TEXT[st] || st }),
297
+ el("span", { class: "name", title: name }, short),
298
+ el("span", { class: "sub", style: "margin:0" }, inst.backend || state.fleet.defaults?.backend || "claude-code"),
299
+ chTag ? el("span", { class: "tag" + (chTag.includes("persona") ? " persona" : chTag.includes("telegram") ? " tg" : "") }, chTag) : null,
300
+ el("span", { class: "spacer" }),
301
+ el("button", { class: "sm ghost", onclick: () => { editing = editing === name ? null : name; renderAgents(); } }, editing === name ? t("close") : t("edit")),
302
+ el("button", { class: "sm", onclick: () => toggleAgent(name, running) }, running ? t("stop") : t("start")),
303
+ el("button", { class: "sm danger del-btn", title: t("close"), onclick: () => delAgent(name) }, "🗑️"),
304
+ ));
305
+ if (editing === name) item.append(agentEditForm(name, inst));
306
+ return item;
307
+ }
308
+ function renderAgents() {
309
+ const host = $("agents"); host.innerHTML = "";
310
+ const insts = state.fleet.instances || {};
311
+ if (!Object.keys(insts).length) { host.className = "card empty"; host.textContent = t("noAgents"); return; }
312
+ const filter = ($("agentSearch")?.value || "").trim().toLowerCase();
313
+ let names = Object.keys(insts);
314
+ if (filter) names = names.filter(n => n.toLowerCase().includes(filter) || shortName(n, insts[n]).toLowerCase().includes(filter));
315
+ if (!names.length) { host.className = "card empty"; host.textContent = "No agents match \"" + filter + "\"."; return; }
316
+ host.className = "card";
317
+ // Group by first tag; "Other" sorts last. Within a group: Running → Crashed
318
+ // → Stopped, then name.
319
+ const groups = {};
320
+ for (const n of names) (groups[groupOf(insts[n])] ??= []).push(n);
321
+ const gnames = Object.keys(groups).sort((a, b) => a === "Other" ? 1 : b === "Other" ? -1 : a.localeCompare(b));
322
+ for (const g of gnames) {
323
+ const members = groups[g].sort((a, b) => {
324
+ const sa = STATUS_ORDER[state.live[a]?.status || "stopped"], sb = STATUS_ORDER[state.live[b]?.status || "stopped"];
325
+ return sa !== sb ? sa - sb : a.localeCompare(b);
326
+ });
327
+ const collapsed = state.collapsedGroups.has(g) && !filter; // searching expands all
328
+ host.append(el("div", { class: "group-head", onclick: () => { state.collapsedGroups.has(g) ? state.collapsedGroups.delete(g) : state.collapsedGroups.add(g); renderAgents(); } },
329
+ el("span", { class: "caret" }, collapsed ? "▶" : "▼"), el("span", {}, g), el("span", { class: "gcount" }, `(${members.length})`)));
330
+ if (collapsed) continue;
331
+ for (const name of members) host.append(compactRow(name, insts[name]));
332
+ }
333
+ }
334
+ function agentEditForm(name, inst) {
335
+ const box = el("div", { class: "edit" });
336
+ const fBackend = select(inst.backend || state.fleet.defaults?.backend || "claude-code", BACKENDS);
337
+ const fModel = el("input", { type: "text", value: inst.model || "", placeholder: "(inherit default)" });
338
+ const fWd = el("input", { type: "text", value: inst.working_directory || "" });
339
+ const fChan = select(inst.channel_id || "", ["", ...channelIds()]);
340
+ const fDesc = el("input", { type: "text", value: inst.description || "" });
341
+ const fSys = el("input", { type: "text", value: inst.systemPrompt || "", placeholder: "e.g. file:prompts/dev.md" });
342
+ const tags = [...(inst.tags || [])];
343
+ const fGeneral = el("input", { type: "checkbox" }); fGeneral.checked = !!inst.general_topic;
344
+ const msg = el("span", { class: "msg" });
345
+ box.append(
346
+ el("div", { class: "sub", style: "margin:0 0 8px" }, "ID: " + name),
347
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, t("backend")), fBackend), el("div", {}, el("label", {}, t("model")), fModel)),
348
+ el("label", {}, t("workingDir")), fWd,
349
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, t("channelBinding")), fChan), el("div", {}, el("label", {}, t("description")), fDesc)),
350
+ el("label", {}, t("systemPrompt")), fSys,
351
+ el("label", {}, t("tags")), chipList(tags, null, "tag"),
352
+ el("label", { style: "display:flex;align-items:center;gap:8px;cursor:pointer" }, fGeneral, el("span", {}, t("generalTopicField"))),
353
+ el("div", { class: "actions" },
354
+ el("button", { class: "primary sm", onclick: async () => {
355
+ const patch = {
356
+ backend: fBackend.value, model: fModel.value || undefined,
357
+ working_directory: fWd.value, channel_id: fChan.value || undefined,
358
+ description: fDesc.value || undefined, systemPrompt: fSys.value || undefined,
359
+ tags: tags.length ? tags : undefined, general_topic: fGeneral.checked,
360
+ };
361
+ const res = await api(`/api/settings/fleet/instances/${encodeURIComponent(name)}`, { method: "PATCH", body: JSON.stringify(patch) });
362
+ resultMsg(msg, res); if (res.ok) await reload();
363
+ } }, t("save")),
364
+ el("button", { class: "sm ghost", onclick: () => { editing = null; renderAgents(); } }, t("cancel")),
365
+ msg),
366
+ );
367
+ return box;
368
+ }
369
+ async function toggleAgent(name, running) {
370
+ if (running) await api(`/stop/${encodeURIComponent(name)}`, { method: "POST" });
371
+ else await api(`/api/instance/${encodeURIComponent(name)}/start`, { method: "POST" });
372
+ setTimeout(reloadLive, 800);
373
+ }
374
+ async function delAgent(name) {
375
+ if (!confirm(`Delete agent "${name}"? (config removed; running daemon stops on next Reload)`)) return;
376
+ const res = await api(`/api/settings/fleet/instances/${encodeURIComponent(name)}`, { method: "DELETE" });
377
+ if (res.ok) await reload(); else alert((res.body && res.body.error) || "Delete failed");
378
+ }
379
+ function renderNewAgent(open) {
380
+ const host = $("newAgent"); host.innerHTML = "";
381
+ if (!open) return;
382
+ const box = el("div", { class: "card", style: "margin-bottom:12px" });
383
+ const fName = el("input", { type: "text", placeholder: "agent name" });
384
+ const fBackend = select(state.fleet.defaults?.backend || "claude-code", BACKENDS);
385
+ const fWd = el("input", { type: "text", placeholder: "~/Projects/… (optional)" });
386
+ const fChan = select("", ["", ...channelIds()]);
387
+ const msg = el("span", { class: "msg" });
388
+ box.append(el("div", { class: "edit", style: "border:none" },
389
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, t("nameField")), fName), el("div", {}, el("label", {}, t("backend")), fBackend)),
390
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, t("workingDir")), fWd), el("div", {}, el("label", {}, t("channelBinding")), fChan)),
391
+ el("div", { class: "actions" }, el("button", { class: "primary sm", onclick: async () => {
392
+ const name = fName.value.trim(); if (!name) { msg.innerHTML = `<span class="e">✗ name required</span>`; return; }
393
+ const body = { working_directory: fWd.value, backend: fBackend.value, channel_id: fChan.value || undefined };
394
+ const res = await api(`/api/settings/fleet/instances/${encodeURIComponent(name)}`, { method: "POST", body: JSON.stringify(body) });
395
+ resultMsg(msg, res); if (res.ok) { state.newAgentOpen = false; $("newAgentBtn").textContent = t("new"); renderNewAgent(false); await reload(); }
396
+ } }, t("create")), el("button", { class: "sm ghost", onclick: () => { state.newAgentOpen = false; $("newAgentBtn").textContent = t("new"); renderNewAgent(false); } }, t("cancel")), msg),
397
+ ));
398
+ host.append(box);
399
+ }
400
+
401
+ // ── Bots ──
402
+ function renderBots() {
403
+ const host = $("bots"); host.innerHTML = ""; const chs = channels();
404
+ if (!chs.length) { host.className = "card empty"; host.textContent = t("noBots"); return; }
405
+ host.className = "card";
406
+ chs.forEach((ch, i) => {
407
+ const item = el("div", { class: "item" });
408
+ const type = ch.type || "?";
409
+ item.append(el("div", { class: "item-row" },
410
+ el("span", { class: "name" }, type === "telegram" ? "Telegram" : "Discord"),
411
+ el("span", { class: "tag" + (i === 0 ? "" : " persona") + (type === "telegram" ? " tg" : "") }, chLabel(i)),
412
+ el("span", { class: "sub", style: "margin:0" }, ch.bot_token_env || "(no token env)"),
413
+ el("span", { class: "spacer" }),
414
+ el("span", { style: "color:var(--green);font-size:13px" }, state.fleetUp ? t("online") : "—"),
415
+ el("button", { class: "sm danger", onclick: () => delBot(i) }, "🗑️"),
416
+ ));
417
+ host.append(item);
418
+ });
419
+ }
420
+
421
+ // ── ClassicBot Channels (read-only; created dynamically via /start /stop) ──
422
+ function renderClassic() {
423
+ const host = $("classicList"); if (!host) return; host.innerHTML = "";
424
+ const chans = Object.values(state.classic?.channels || {});
425
+ if (!chans.length) { host.className = "card empty"; host.textContent = t("noClassic"); return; }
426
+ const filter = ($("agentSearch")?.value || "").trim().toLowerCase();
427
+ let list = chans;
428
+ if (filter) list = list.filter(c => String(c.name || "").toLowerCase().includes(filter) || String(c.instanceName || "").toLowerCase().includes(filter));
429
+ if (!list.length) { host.className = "card empty"; host.textContent = "—"; return; }
430
+ host.className = "card";
431
+ list.sort((a, b) => {
432
+ const sa = STATUS_ORDER[state.classicStatus[a.instanceName] || "stopped"], sb = STATUS_ORDER[state.classicStatus[b.instanceName] || "stopped"];
433
+ return sa !== sb ? sa - sb : String(a.name || "").localeCompare(String(b.name || ""));
434
+ });
435
+ for (const c of list) {
436
+ const st = state.classicStatus[c.instanceName] || "stopped";
437
+ const adapter = c.adapterId || "discord";
438
+ host.append(el("div", { class: "item" }, el("div", { class: "item-row" },
439
+ el("span", { class: "dot " + st, title: STATUS_TEXT[st] || st }),
440
+ el("span", { class: "name", title: c.instanceName || "" }, c.name || c.instanceName || "?"),
441
+ el("span", { class: "sub", style: "margin:0" }, c.backend || state.classic?.defaults?.backend || "claude-code"),
442
+ el("span", { class: "tag" + (adapter !== "discord" ? " persona" : "") }, adapter),
443
+ c.collab ? el("span", { class: "tag", style: "background:#ecfdf5;color:#065f46" }, "collab") : null,
444
+ )));
445
+ }
446
+ }
447
+
448
+ async function saveChannels(msgEl) {
449
+ const res = await api("/api/settings/fleet/channels", { method: "PUT", body: JSON.stringify(channels()) });
450
+ if (msgEl) resultMsg(msgEl, res);
451
+ return res;
452
+ }
453
+ async function delBot(i) {
454
+ const chs = channels(); const ch = chs[i];
455
+ if (!confirm(`Remove bot "${ch.type} (${chLabel(i)})"? (the token in .env is left untouched)`)) return;
456
+ chs.splice(i, 1);
457
+ state.fleet.channels = chs; delete state.fleet.channel;
458
+ const res = await saveChannels(); if (res.ok) await reload(); else alert((res.body && res.body.error) || "Failed");
459
+ }
460
+ function renderNewBot(open) {
461
+ const host = $("newBot"); host.innerHTML = ""; if (!open) return;
462
+ const box = el("div", { class: "card", style: "margin-bottom:12px" });
463
+ const fType = select("discord", CH_TYPES);
464
+ const fId = el("input", { type: "text", placeholder: "adapter id (e.g. persona1)" });
465
+ const fEnv = el("input", { type: "text", placeholder: "BOT_TOKEN_ENV" });
466
+ const fGroup = el("input", { type: "text", placeholder: "guild / supergroup id" });
467
+ const msg = el("span", { class: "msg" });
468
+ box.append(el("div", { class: "edit", style: "border:none" },
469
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, t("type")), fType), el("div", {}, el("label", {}, t("adapterId")), fId)),
470
+ el("div", { class: "grid2" }, el("div", {}, el("label", {}, t("tokenEnv")), fEnv), el("div", {}, el("label", {}, t("groupId")), fGroup)),
471
+ el("div", { class: "hint" }, t("envHint")),
472
+ el("div", { class: "actions" }, el("button", { class: "primary sm", onclick: async () => {
473
+ if (!fEnv.value.trim()) { msg.innerHTML = `<span class="e">✗ token env required</span>`; return; }
474
+ const chs = channels();
475
+ chs.push({ id: fId.value.trim() || undefined, type: fType.value, bot_token_env: fEnv.value.trim(), group_id: fGroup.value.trim() || undefined, access: { mode: "locked", allowed_users: [] } });
476
+ state.fleet.channels = chs; delete state.fleet.channel;
477
+ const res = await saveChannels(msg); if (res.ok) { state.newBotOpen = false; $("newBotBtn").textContent = t("add"); renderNewBot(false); await reload(); }
478
+ } }, t("save")), el("button", { class: "sm ghost", onclick: () => { state.newBotOpen = false; $("newBotBtn").textContent = t("add"); renderNewBot(false); } }, t("cancel")), msg),
479
+ ));
480
+ host.append(box);
481
+ }
482
+
483
+ // ── General Settings ──
484
+ function renderGeneral() {
485
+ const host = $("general"); host.innerHTML = "";
486
+ state.fleet.defaults = state.fleet.defaults || {};
487
+ state.classic.defaults = state.classic.defaults || {};
488
+ const d = state.fleet.defaults, c = state.classic.defaults;
489
+ const primary = channels()[0];
490
+ if (primary) primary.access = primary.access || { mode: "locked", allowed_users: [] };
491
+ const allowedUsers = primary ? (primary.access.allowed_users = primary.access.allowed_users || []) : [];
492
+ c.admin_users = c.admin_users || []; c.allowed_guilds = c.allowed_guilds || [];
493
+
494
+ const fBackend = select(d.backend || "claude-code", BACKENDS);
495
+ const fModel = el("input", { type: "text", value: d.model || "", placeholder: "e.g. claude-opus-4-8" });
496
+ const fLocale = el("select");
497
+ [["en", "English"], ["zh-TW", "繁體中文 (zh-TW)"]].forEach(([v, lbl]) => { const o = el("option", {}, lbl); o.value = v; if ((d.locale || "") === v) o.selected = true; fLocale.append(o); });
498
+ const msg = el("span", { class: "msg" });
499
+ host.append(
500
+ el("div", { class: "field" }, el("label", {}, t("language")), fLocale),
501
+ el("div", { class: "field" }, el("label", {}, t("defaultBackend")), fBackend),
502
+ el("div", { class: "field" }, el("label", {}, t("defaultModel")), fModel),
503
+ el("div", { class: "field" }, el("label", {}, t("allowedUsers")), chipList(allowedUsers, null, "user id")),
504
+ el("div", { class: "field" }, el("label", {}, t("classicAdmins")), chipList(c.admin_users, null, "user id")),
505
+ el("div", { class: "field" }, el("label", {}, t("allowedGuilds")), chipList(c.allowed_guilds, null, "guild id")),
506
+ el("div", { class: "field" }, el("div", { class: "actions" }, el("button", { class: "primary", onclick: async () => {
507
+ d.backend = fBackend.value; d.model = fModel.value || undefined; d.locale = fLocale.value;
508
+ const r1 = await api("/api/settings/fleet/defaults", { method: "PUT", body: JSON.stringify({ backend: d.backend, model: d.model, locale: d.locale }) });
509
+ let r2 = { ok: true, body: {} };
510
+ if (primary) r2 = await saveChannels();
511
+ const r3 = await api("/api/settings/classic/defaults", { method: "PUT", body: JSON.stringify({ backend: c.backend, admin_users: c.admin_users, allowed_guilds: c.allowed_guilds }) });
512
+ const bad = [r1, r2, r3].find(r => !r.ok);
513
+ resultMsg(msg, bad || { ok: true, body: { warnings: [...(r1.body?.warnings || []), ...(r3.body?.warnings || [])] } });
514
+ } }, t("save")), msg)),
515
+ );
516
+ }
517
+
518
+ // ── What's New ──
519
+ function renderWhatsNew() {
520
+ const host = $("wnBody"); host.innerHTML = "";
521
+ host.append(el("div", { class: "sub", style: "margin:0 0 8px" }, `${t("currentVersion")}: ${state.version || "?"}`));
522
+ host.append(el("div", { style: "font-size:13px;margin-bottom:6px" }, t("wnIntro")));
523
+ const ul = el("ul", { style: "margin:0 0 0 18px" });
524
+ for (const line of WHATSNEW) ul.append(el("li", { style: "font-size:13px;margin:4px 0" }, line));
525
+ host.append(ul);
526
+ // Beta builds have no per-tag release page → link to the latest release.
527
+ const v = state.version || "";
528
+ const url = !v || v.includes("-") ? "https://github.com/songsid/AgEnD/releases/latest" : `https://github.com/songsid/AgEnD/releases/tag/v${v}`;
529
+ host.append(el("div", { style: "margin-top:10px" }, el("a", { href: url, target: "_blank", rel: "noopener", style: "color:var(--accent);font-size:13px;text-decoration:none" }, t("releaseNotes"))));
530
+ }
531
+
532
+ // ── Advanced (YAML/JSON) ──
533
+ const fleetModel = () => ({ ...(state.fleet.channels ? { channels: state.fleet.channels } : state.fleet.channel ? { channel: state.fleet.channel } : {}), defaults: state.fleet.defaults || {}, instances: state.fleet.instances || {} });
534
+ function renderAdvanced() {
535
+ const host = $("advBody"); host.innerHTML = "";
536
+ const fmt = state.advFmt; const model = fleetModel();
537
+ const ser = fmt === "yaml" ? toYaml(model) : JSON.stringify(model, null, 2);
538
+ const msg = el("div", { class: "msg", style: "margin-top:8px" });
539
+ const toolbar = el("div", { class: "toolbar" },
540
+ el("div", { class: "seg" },
541
+ el("button", { class: "sm" + (fmt === "yaml" ? " on" : ""), onclick: () => { state.advFmt = "yaml"; renderAdvanced(); } }, "YAML"),
542
+ el("button", { class: "sm" + (fmt === "json" ? " on" : ""), onclick: () => { state.advFmt = "json"; renderAdvanced(); } }, "JSON")),
543
+ el("button", { class: "sm", onclick: () => { state.advEdit = !state.advEdit; renderAdvanced(); } }, state.advEdit ? "View" : "Edit"),
544
+ el("button", { class: "sm", onclick: () => navigator.clipboard.writeText(ser).then(() => { msg.innerHTML = `<span class="s">Copied</span>`; }) }, "Copy"),
545
+ el("button", { class: "sm", onclick: () => { const blob = new Blob([toYaml(fleetModel())], { type: "text/plain" }); const a = el("a", { href: URL.createObjectURL(blob), download: "fleet.yaml" }); a.click(); URL.revokeObjectURL(a.href); } }, "⬇ fleet.yaml"),
546
+ );
547
+ host.append(toolbar);
548
+ if (state.advEdit) {
549
+ const ta = el("textarea"); ta.value = ser;
550
+ host.append(ta, el("div", { class: "actions" }, el("button", { class: "primary sm", onclick: async () => {
551
+ let parsed; try { parsed = fmt === "yaml" ? fromYaml(ta.value) : JSON.parse(ta.value || "{}"); } catch (e) { msg.innerHTML = `<span class="e">✗ ${esc(e.message)}</span>`; return; }
552
+ await applyFullModel(parsed, msg);
553
+ } }, "Apply & Save"), msg));
554
+ } else {
555
+ const pre = el("pre", { class: "preview" }); pre.textContent = ser; host.append(pre, msg);
556
+ }
557
+ }
558
+ // Apply the edited full model: PUT defaults + channels, and diff instances.
559
+ async function applyFullModel(m, msg) {
560
+ if (!m || typeof m !== "object") { msg.innerHTML = `<span class="e">✗ expected a mapping</span>`; return; }
561
+ const results = [];
562
+ if (m.defaults) results.push(await api("/api/settings/fleet/defaults", { method: "PUT", body: JSON.stringify(m.defaults) }));
563
+ const chs = m.channels || (m.channel ? [m.channel] : null);
564
+ if (chs) results.push(await api("/api/settings/fleet/channels", { method: "PUT", body: JSON.stringify(chs) }));
565
+ if (m.instances && typeof m.instances === "object") {
566
+ const cur = state.fleet.instances || {};
567
+ for (const [n, cfg] of Object.entries(m.instances)) {
568
+ const exists = !!cur[n];
569
+ results.push(await api(`/api/settings/fleet/instances/${encodeURIComponent(n)}`, { method: exists ? "PATCH" : "POST", body: JSON.stringify(cfg) }));
570
+ }
571
+ for (const n of Object.keys(cur)) if (!(n in m.instances)) results.push(await api(`/api/settings/fleet/instances/${encodeURIComponent(n)}`, { method: "DELETE" }));
572
+ }
573
+ const bad = results.find(r => !r.ok);
574
+ resultMsg(msg, bad || { ok: true, body: {} });
575
+ if (!bad) { state.advEdit = false; await reload(); }
576
+ }
577
+
578
+ // ── Load / render ──
579
+ function renderAll() { renderAgents(); renderClassic(); renderBots(); renderGeneral(); renderWhatsNew(); renderAdvanced(); applyStaticI18n(); }
580
+ async function reloadLive() {
581
+ const f = await api("/api/fleet");
582
+ state.fleetUp = f.ok;
583
+ state.live = {}; if (f.ok && f.body?.instances) for (const i of f.body.instances) state.live[i.name] = { status: i.status };
584
+ // /api/fleet is fleet-config only; classic instance statuses come from the
585
+ // merged roster (/api/profiles, open GET).
586
+ const pr = await api("/api/profiles");
587
+ state.classicStatus = {}; if (pr.ok && Array.isArray(pr.body)) for (const r of pr.body) state.classicStatus[r.instance_name] = r.status;
588
+ if (f.ok && f.body?.version) { state.version = f.body.version; $("verBadge").textContent = "v" + state.version; }
589
+ renderAgents(); renderBots(); renderClassic();
590
+ }
591
+ async function reload() {
592
+ const f = await api("/api/settings/fleet");
593
+ if (!f.ok) { $("banner").style.display = "block"; $("banner").style.background = "#fef2f2"; $("banner").style.color = "var(--red)"; $("banner").textContent = "Failed to load config — check the token in the URL."; return; }
594
+ state.fleet = f.body || {};
595
+ const c = await api("/api/settings/classic"); state.classic = c.body || {};
596
+ await reloadLive();
597
+ renderAll();
598
+ }
599
+
600
+ const setHelp = (open) => { $("helpPanel").classList.toggle("open", open); $("helpBackdrop").classList.toggle("open", open); };
601
+ $("helpBtn").onclick = () => setHelp(!$("helpPanel").classList.contains("open"));
602
+ $("helpClose").onclick = () => setHelp(false);
603
+ $("helpBackdrop").onclick = () => setHelp(false);
604
+ $("langBtn").onclick = () => { lang = lang === "en" ? "zh-TW" : "en"; localStorage.setItem("agend_lang", lang); renderAll(); };
605
+ $("verBadge").onclick = () => { const b = $("wnBody"); b.classList.add("open"); $("wnToggle").textContent = t("collapse"); b.scrollIntoView({ behavior: "smooth" }); };
606
+ $("agentSearch").oninput = () => { renderAgents(); renderClassic(); };
607
+ $("newAgentBtn").onclick = () => { state.newAgentOpen = !state.newAgentOpen; $("newAgentBtn").textContent = state.newAgentOpen ? t("cancel") : t("new"); renderNewAgent(state.newAgentOpen); };
608
+ $("newBotBtn").onclick = () => { state.newBotOpen = !state.newBotOpen; $("newBotBtn").textContent = state.newBotOpen ? t("cancel") : t("add"); renderNewBot(state.newBotOpen); };
609
+ $("advToggle").onclick = () => { const b = $("advBody"); const open = !b.classList.contains("open"); b.classList.toggle("open", open); $("advToggle").textContent = open ? t("collapse") : t("expand"); };
610
+ $("wnToggle").onclick = () => { const b = $("wnBody"); const open = !b.classList.contains("open"); b.classList.toggle("open", open); $("wnToggle").textContent = open ? t("collapse") : t("expand"); };
611
+ $("reloadBtn").onclick = async () => { const r = await api("/api/settings/reload", { method: "POST" }); const b = $("banner"); b.style.display = "block"; b.style.background = "#ecfdf5"; b.style.color = "var(--green)"; b.textContent = r.ok ? "✓ Reload signal sent — instances reconciling." : "Reload failed."; setTimeout(() => b.style.display = "none", 4000); setTimeout(reloadLive, 1500); };
612
+
613
+ reload();
614
+ })();
615
+ </script>
616
+ </body>
617
+ </html>