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

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 +455 -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 +295 -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
package/dist/ui/view.html CHANGED
@@ -13,7 +13,15 @@
13
13
  /* Sidebar */
14
14
  #sidebar { width: 240px; flex: 0 0 240px; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; }
15
15
  #sidebar h1 { font-size: 14px; color: var(--accent); padding: 14px 16px; border-bottom: 1px solid var(--border); font-weight: 600; letter-spacing: .5px; }
16
- .inst { display: flex; align-items: center; gap: 8px; padding: 10px 16px; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,.03); font-size: 13px; }
16
+ .group-header { display: flex; align-items: center; gap: 6px; padding: 8px 12px; cursor: pointer; font-size: 12px; font-weight: 600; color: var(--dim); text-transform: uppercase; letter-spacing: .4px; background: rgba(255,255,255,.02); border-bottom: 1px solid var(--border); user-select: none; }
17
+ .group-header:hover { color: var(--fg); }
18
+ .group-header .caret { width: 10px; flex: 0 0 10px; }
19
+ .group-header .gcount { margin-left: auto; opacity: .7; font-weight: 400; }
20
+ .inst { display: flex; align-items: center; gap: 8px; padding: 10px 16px 10px 24px; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,.03); font-size: 13px; }
21
+ .dragging { opacity: .4; }
22
+ .drop-before { border-top: 2px solid var(--accent) !important; }
23
+ .drop-after { border-bottom: 2px solid var(--accent) !important; }
24
+ [draggable="true"] { cursor: grab; }
17
25
  .inst:hover { background: rgba(255,255,255,.04); }
18
26
  .inst.active { background: rgba(88,166,255,.14); border-left: 3px solid var(--accent); padding-left: 13px; }
19
27
  .dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 8px; }
@@ -28,7 +36,9 @@
28
36
 
29
37
  /* Card */
30
38
  #card { background: var(--panel); border-top: 1px solid var(--border); padding: 14px 18px; display: flex; gap: 16px; align-items: flex-start; }
31
- #avatar { width: 56px; height: 56px; border-radius: 8px; object-fit: cover; background: #21262d; flex: 0 0 56px; }
39
+ #avatarBox { width: 56px; height: 56px; flex: 0 0 56px; }
40
+ .avatar-img { width: 56px; height: 56px; border-radius: 50%; object-fit: cover; display: block; }
41
+ .avatar-ph { width: 56px; height: 56px; border-radius: 50%; background: #30363d; color: var(--dim); display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 600; text-transform: uppercase; user-select: none; }
32
42
  #cardBody { flex: 1; min-width: 0; }
33
43
  #cardBody .row1 { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
34
44
  #cardBody .dname { font-size: 16px; font-weight: 600; }
@@ -48,46 +58,104 @@
48
58
  #edit .hint { color: var(--dim); font-size: 11px; }
49
59
  #msg { font-size: 12px; }
50
60
  .err { color: #f85149; } .ok { color: #3fb950; }
61
+ .help-btn { position: fixed; top: 10px; right: 14px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid var(--border); background: var(--panel); color: var(--fg); cursor: pointer; font-size: 16px; z-index: 60; display: inline-flex; align-items: center; justify-content: center; padding: 0; }
62
+ .help-btn:hover { background: var(--accent); border-color: var(--accent); }
63
+ .help-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: none; z-index: 70; }
64
+ .help-backdrop.open { display: block; }
65
+ .help-panel { position: fixed; top: 0; right: 0; height: 100%; width: 360px; max-width: 90vw; background: var(--panel); border-left: 1px solid var(--border); box-shadow: -8px 0 24px rgba(0,0,0,.4); transform: translateX(100%); transition: transform .2s ease; z-index: 80; overflow-y: auto; padding: 20px 22px; }
66
+ .help-panel.open { transform: translateX(0); }
67
+ .help-panel h3 { font-size: 16px; margin-bottom: 8px; }
68
+ .help-panel ul { margin: 8px 0 0 18px; }
69
+ .help-panel li { font-size: 13px; margin: 6px 0; color: var(--fg); }
70
+ .help-panel .close { position: absolute; top: 14px; right: 16px; cursor: pointer; color: var(--dim); font-size: 22px; line-height: 1; background: none; border: none; }
51
71
  </style>
52
72
  </head>
53
73
  <body>
54
- <div id="sidebar"><h1>AgEnD · View</h1><div id="list"></div></div>
74
+ <button class="help-btn" id="langBtn" title="Language" style="right:56px">🌐</button>
75
+ <button class="help-btn" id="helpBtn" title="Help">ℹ️</button>
76
+ <div class="help-backdrop" id="helpBackdrop"></div>
77
+ <aside class="help-panel" id="helpPanel">
78
+ <button class="close" id="helpClose" title="Close">×</button>
79
+ <div id="helpContent"></div>
80
+ </aside>
81
+ <div id="sidebar"><h1 data-i18n="viewTitle">AgEnD · View</h1><div id="list"></div></div>
55
82
  <div id="main">
56
83
  <div id="term"><pre id="pre"></pre></div>
57
84
  <div id="card">
58
- <img id="avatar" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E">
85
+ <div id="avatarBox"></div>
59
86
  <div id="cardBody">
60
87
  <div class="row1"><span class="dname" id="dname">—</span><span class="role" id="role"></span></div>
61
88
  <div class="meta" id="meta"></div>
62
89
  <div class="desc" id="desc"></div>
63
90
  </div>
64
- <button id="editBtn">✏️ Edit</button>
91
+ <button id="editBtn" data-i18n="editProfile">✏️ Edit</button>
65
92
  <div id="edit">
66
- <input id="fDisplay" placeholder="Display name">
67
- <input id="fRole" placeholder="Role">
68
- <textarea id="fDesc" placeholder="Description"></textarea>
93
+ <input id="fDisplay" data-i18n-ph="displayName" placeholder="Display name">
94
+ <input id="fRole" data-i18n-ph="role" placeholder="Role">
95
+ <textarea id="fDesc" data-i18n-ph="description" placeholder="Description"></textarea>
69
96
  <input id="fAvatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp">
70
- <input id="fToken" type="password" placeholder="web.token (required to save)">
97
+ <input id="fToken" type="password" data-i18n-ph="tokenPh" placeholder="web.token (required to save)">
71
98
  <div class="actions">
72
- <button id="saveBtn">Save</button>
73
- <button id="cancelBtn">Cancel</button>
99
+ <button id="saveBtn" data-i18n="save">Save</button>
100
+ <button id="cancelBtn" data-i18n="cancel">Cancel</button>
74
101
  <span id="msg"></span>
75
102
  </div>
76
- <div class="hint">Read access uses the view token in the URL. Saving requires the web.token (~/.agend/web.token).</div>
103
+ <div class="hint" data-i18n="saveHint">Viewing is open (read-only). Saving requires the web.token (~/.agend/web.token).</div>
77
104
  </div>
78
105
  </div>
79
106
  </div>
80
107
 
81
108
  <script>
82
109
  (() => {
83
- const token = new URLSearchParams(location.search).get("token") || "";
110
+ // GET routes are open (no token). Writes (profile/avatar/sort-order POST)
111
+ // require the web.token, which the user enters in the edit panel; we keep it
112
+ // in localStorage. A ?token= URL param is still honored as a fallback.
113
+ const urlToken = new URLSearchParams(location.search).get("token") || "";
114
+ const webToken = () => localStorage.getItem("agend_web_token") || urlToken;
84
115
  const q = (id) => document.getElementById(id);
116
+
117
+ // ── i18n ──
118
+ const I18N = {
119
+ en: { viewTitle: "AgEnD · View", editProfile: "✏️ Edit", save: "Save", cancel: "Cancel",
120
+ displayName: "Display name", role: "Role", description: "Description", tokenPh: "web.token (required to save)",
121
+ saveHint: "Viewing is open (read-only). Saving requires the web.token (~/.agend/web.token).", helpTitle: "📖 Help",
122
+ help1: "Pick an instance on the left → its live terminal shows on the right",
123
+ help2: "Click a group header ▼/▶ to collapse/expand", help3: "✏️ Edit changes display name, role, description",
124
+ help4: "Drag to reorder (needs web.token permission)" },
125
+ "zh-TW": { viewTitle: "AgEnD · 檢視", editProfile: "✏️ 編輯", save: "儲存", cancel: "取消",
126
+ displayName: "顯示名稱", role: "角色", description: "描述", tokenPh: "web.token(儲存時需要)",
127
+ saveHint: "檢視為公開(唯讀)。儲存需要 web.token(~/.agend/web.token)。", helpTitle: "📖 使用說明",
128
+ help1: "左側選 instance → 右側顯示即時 terminal 畫面",
129
+ help2: "點 group header ▼/▶ 收合/展開", help3: "✏️ Edit 可修改顯示名稱、角色、描述",
130
+ help4: "拖曳可調整順序(需 web.token 權限)" },
131
+ };
132
+ let lang = localStorage.getItem("agend_lang") || (navigator.language && navigator.language.toLowerCase().startsWith("zh") ? "zh-TW" : "en");
133
+ if (!I18N[lang]) lang = "en";
134
+ const T = (k) => (I18N[lang] && I18N[lang][k]) || I18N.en[k] || k;
135
+ function applyI18n() {
136
+ document.querySelectorAll("[data-i18n]").forEach(e => { e.textContent = T(e.getAttribute("data-i18n")); });
137
+ document.querySelectorAll("[data-i18n-ph]").forEach(e => { e.setAttribute("placeholder", T(e.getAttribute("data-i18n-ph"))); });
138
+ const hc = q("helpContent");
139
+ if (hc) { hc.innerHTML = ""; const h3 = document.createElement("h3"); h3.textContent = T("helpTitle"); hc.append(h3);
140
+ const ul = document.createElement("ul"); for (const k of ["help1","help2","help3","help4"]) { const li = document.createElement("li"); li.textContent = T(k); ul.append(li); } hc.append(ul); }
141
+ }
142
+
85
143
  let roster = [];
144
+ let rosterByName = new Map();
86
145
  let current = null;
87
146
  let poll = null;
88
147
  let fitted = false;
148
+ const collapsed = new Set(); // group names the user has collapsed
149
+ // Drag-sort state
150
+ let sortGroups = new Map(); // groupName -> sort_index (persisted override)
151
+ let sortInsts = new Map(); // instanceName -> sort_index (within its group)
152
+ let groupNames = []; // group display order (computed)
153
+ let instByGroup = new Map(); // group -> [instance_name, …] display order (computed)
154
+ let writable = false; // whether the current web.token can POST
155
+ let probed = false;
156
+ let dragItem = null; // { type:'group'|'instance', name, group }
89
157
 
90
- const api = (path) => `${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
158
+ const api = (path) => urlToken ? `${path}${path.includes("?") ? "&" : "?"}token=${encodeURIComponent(urlToken)}` : path;
91
159
 
92
160
  // ── ANSI (SGR) → HTML ─────────────────────────────────
93
161
  const BASE = ["#000000","#cd3131","#0dbc79","#e5e510","#2472c8","#bc3fbc","#11a8cd","#e5e5e5"];
@@ -147,18 +215,169 @@
147
215
  const l = (n - 232) * 10 + 8; return `rgb(${l},${l},${l})`;
148
216
  }
149
217
 
150
- // ── Sidebar ───────────────────────────────────────────
218
+ // ── Sidebar (grouped by tag) ──────────────────────────
219
+ // Group name: "Classic" if tagged classic, else the first tag, else "Other".
220
+ function groupOf(it) {
221
+ const tags = it.tags || [];
222
+ if (tags.includes("classic")) return "Classic";
223
+ if (tags.length) return tags[0];
224
+ return "Other";
225
+ }
226
+ // Short label: display name if set, else instance name minus its tag prefix
227
+ // and the -t<id> suffix (doupo-server-t1503… → server).
228
+ function shortName(it) {
229
+ if (it.display_name) return it.display_name;
230
+ let n = it.instance_name.replace(/-t[0-9a-z]+$/i, "");
231
+ const tag = (it.tags || [])[0];
232
+ if (tag && n.toLowerCase().startsWith(tag.toLowerCase() + "-")) n = n.slice(tag.length + 1);
233
+ return n || it.instance_name;
234
+ }
235
+
236
+ // Compute display order: persisted sort-order first (by index), then any
237
+ // new/unsorted groups & instances by the tag+alpha fallback. Stored in
238
+ // groupNames / instByGroup so drag handlers can mutate + persist them.
239
+ function computeOrder() {
240
+ const groups = new Map();
241
+ for (const it of roster) {
242
+ const g = groupOf(it);
243
+ if (!groups.has(g)) groups.set(g, []);
244
+ groups.get(g).push(it);
245
+ }
246
+ const gidx = (g) => (sortGroups.has(g) ? sortGroups.get(g) : Infinity);
247
+ groupNames = [...groups.keys()].sort((a, b) => {
248
+ const ia = gidx(a), ib = gidx(b);
249
+ if (ia !== ib) return ia - ib;
250
+ if (a === "Other") return 1;
251
+ if (b === "Other") return -1;
252
+ return a.localeCompare(b);
253
+ });
254
+ instByGroup = new Map();
255
+ for (const [g, items] of groups) {
256
+ // stable sort keeps roster order for equal (unsorted) indices
257
+ const names = items.map(it => it.instance_name).sort((a, b) => {
258
+ const ia = sortInsts.has(a) ? sortInsts.get(a) : Infinity;
259
+ const ib = sortInsts.has(b) ? sortInsts.get(b) : Infinity;
260
+ return ia - ib;
261
+ });
262
+ instByGroup.set(g, names);
263
+ }
264
+ }
265
+
151
266
  function renderList() {
152
267
  const list = q("list");
153
268
  list.innerHTML = "";
154
- for (const it of roster) {
155
- const el = document.createElement("div");
156
- el.className = "inst" + (it.instance_name === current ? " active" : "");
157
- const label = it.display_name || it.instance_name;
158
- el.innerHTML = `<span class="dot ${it.status}"></span><span class="nm">${esc(label)}</span><span class="pct">${it.context_pct ? Math.round(it.context_pct)+"%" : ""}</span>`;
159
- el.onclick = () => select(it.instance_name);
160
- list.appendChild(el);
269
+ for (const g of groupNames) {
270
+ const names = instByGroup.get(g) || [];
271
+ if (!names.length) continue;
272
+ const isCollapsed = collapsed.has(g);
273
+ const header = document.createElement("div");
274
+ header.className = "group-header";
275
+ header.draggable = writable;
276
+ header.innerHTML = `<span class="caret">${isCollapsed ? "▶" : "▼"}</span><span>${esc(g)}</span><span class="gcount">${names.length}</span>`;
277
+ header.onclick = () => { if (collapsed.has(g)) collapsed.delete(g); else collapsed.add(g); renderList(); };
278
+ if (writable) wireDrag(header, { type: "group", name: g });
279
+ list.appendChild(header);
280
+ if (isCollapsed) continue;
281
+
282
+ for (const name of names) {
283
+ const it = rosterByName.get(name);
284
+ if (!it) continue;
285
+ const el = document.createElement("div");
286
+ el.className = "inst" + (name === current ? " active" : "");
287
+ el.draggable = writable;
288
+ el.innerHTML = `<span class="dot ${it.status}"></span><span class="nm">${esc(shortName(it))}</span><span class="pct">${it.context_pct ? Math.round(it.context_pct)+"%" : ""}</span>`;
289
+ el.onclick = () => select(name);
290
+ if (writable) wireDrag(el, { type: "instance", name, group: g });
291
+ list.appendChild(el);
292
+ }
293
+ }
294
+ }
295
+
296
+ // ── Drag & drop ───────────────────────────────────────
297
+ function wireDrag(el, item) {
298
+ el.addEventListener("dragstart", (e) => {
299
+ dragItem = item;
300
+ el.classList.add("dragging");
301
+ e.dataTransfer.effectAllowed = "move";
302
+ try { e.dataTransfer.setData("text/plain", item.name); } catch { /* Firefox needs data set */ }
303
+ });
304
+ el.addEventListener("dragend", () => {
305
+ el.classList.remove("dragging");
306
+ document.querySelectorAll(".drop-before,.drop-after").forEach(n => n.classList.remove("drop-before", "drop-after"));
307
+ dragItem = null;
308
+ });
309
+ el.addEventListener("dragover", (e) => {
310
+ if (!dragItem || dragItem.type !== item.type) return; // only same kind
311
+ if (item.type === "instance" && dragItem.group !== item.group) return; // no cross-group
312
+ if (dragItem.name === item.name) return;
313
+ e.preventDefault();
314
+ const before = e.clientY < el.getBoundingClientRect().top + el.offsetHeight / 2;
315
+ el.classList.toggle("drop-before", before);
316
+ el.classList.toggle("drop-after", !before);
317
+ });
318
+ el.addEventListener("dragleave", () => el.classList.remove("drop-before", "drop-after"));
319
+ el.addEventListener("drop", (e) => {
320
+ e.preventDefault();
321
+ const before = el.classList.contains("drop-before");
322
+ el.classList.remove("drop-before", "drop-after");
323
+ if (!dragItem || dragItem.type !== item.type) return;
324
+ if (item.type === "group") reorder(groupNames, dragItem.name, item.name, before);
325
+ else if (dragItem.group === item.group) reorder(instByGroup.get(item.group), dragItem.name, item.name, before);
326
+ else return;
327
+ renderList();
328
+ persistOrder();
329
+ });
330
+ }
331
+
332
+ function reorder(arr, moving, target, before) {
333
+ if (!arr) return;
334
+ const from = arr.indexOf(moving);
335
+ if (from === -1) return;
336
+ arr.splice(from, 1);
337
+ let to = arr.indexOf(target);
338
+ if (to === -1) { arr.push(moving); return; }
339
+ if (!before) to += 1;
340
+ arr.splice(to, 0, moving);
341
+ }
342
+
343
+ function orderRows() {
344
+ const rows = [];
345
+ groupNames.forEach((g, i) => rows.push({ item_type: "group", item_name: g, sort_index: i, group_name: null }));
346
+ for (const g of groupNames) {
347
+ (instByGroup.get(g) || []).forEach((n, j) => rows.push({ item_type: "instance", item_name: n, sort_index: j, group_name: g }));
161
348
  }
349
+ return rows;
350
+ }
351
+
352
+ async function persistOrder() {
353
+ // Mirror into the local maps so a mid-flight refresh doesn't revert.
354
+ sortGroups = new Map(groupNames.map((g, i) => [g, i]));
355
+ sortInsts = new Map();
356
+ for (const g of groupNames) (instByGroup.get(g) || []).forEach((n, j) => sortInsts.set(n, j));
357
+ try {
358
+ const r = await fetch("/api/sort-order", {
359
+ method: "POST",
360
+ headers: { "Content-Type": "application/json", "X-Agend-Token": webToken() },
361
+ body: JSON.stringify(orderRows()),
362
+ });
363
+ if (r.status === 401) { writable = false; renderList(); }
364
+ } catch { /* transient */ }
365
+ }
366
+
367
+ // Probe write permission by POSTing the current order (idempotent for a
368
+ // web-token user; 401 without a valid web.token → drag stays disabled).
369
+ // Re-run after the user enters a web.token so drag-sort lights up.
370
+ async function probeWrite() {
371
+ if (!webToken()) { if (writable) { writable = false; renderList(); } return; }
372
+ try {
373
+ const r = await fetch("/api/sort-order", {
374
+ method: "POST",
375
+ headers: { "Content-Type": "application/json", "X-Agend-Token": webToken() },
376
+ body: JSON.stringify(orderRows()),
377
+ });
378
+ const nowWritable = r.ok;
379
+ if (nowWritable !== writable) { writable = nowWritable; renderList(); }
380
+ } catch { /* leave disabled */ }
162
381
  }
163
382
 
164
383
  function renderCard() {
@@ -166,33 +385,61 @@
166
385
  if (!it) return;
167
386
  q("dname").textContent = it.display_name || it.instance_name;
168
387
  q("role").textContent = it.role || "";
169
- q("meta").textContent = `${it.instance_name} · ${it.backend}${it.model ? " · " + it.model : ""} · ctx ${Math.round(it.context_pct || 0)}% · ${it.status}`;
388
+ // Only show ctx% when we actually have a reading (>0); 0/null means the
389
+ // backend didn't report one, so hiding it avoids a misleading "0%".
390
+ const ctxPart = it.context_pct ? ` · ctx ${Math.round(it.context_pct)}%` : "";
391
+ q("meta").textContent = `${it.instance_name} · ${it.backend}${it.model ? " · " + it.model : ""}${ctxPart} · ${it.status}`;
170
392
  q("desc").textContent = it.description || "";
171
- const av = q("avatar");
172
- if (it.has_avatar) av.src = api(`/api/avatar/${encodeURIComponent(it.instance_name)}`) + "&t=" + Date.now();
173
- else av.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E";
393
+ // Only issue an <img> request when an avatar actually exists — otherwise a
394
+ // 404 shows a broken-image icon. No avatar CSS placeholder (initial).
395
+ const box = q("avatarBox");
396
+ if (it.has_avatar) {
397
+ const base = api(`/api/avatar/${encodeURIComponent(it.instance_name)}`);
398
+ const src = base + (base.includes("?") ? "&" : "?") + "t=" + Date.now(); // cache-bust, correct query separator
399
+ box.innerHTML = `<img class="avatar-img" alt="" src="${src}">`;
400
+ } else {
401
+ const initial = (it.display_name || it.instance_name || "?").trim().charAt(0) || "?";
402
+ box.innerHTML = `<div class="avatar-ph">${esc(initial)}</div>`;
403
+ }
174
404
  }
175
405
 
176
406
  async function loadRoster() {
177
407
  try {
178
- const r = await fetch(api("/api/profiles"));
179
- if (!r.ok) throw new Error("HTTP " + r.status);
180
- roster = await r.json();
408
+ const [pr, so] = await Promise.all([fetch(api("/api/profiles")), fetch(api("/api/sort-order"))]);
409
+ if (!pr.ok) throw new Error("HTTP " + pr.status);
410
+ roster = await pr.json();
411
+ rosterByName = new Map(roster.map(r => [r.instance_name, r]));
412
+ const soRows = so.ok ? await so.json() : [];
413
+ sortGroups = new Map(); sortInsts = new Map();
414
+ for (const row of soRows) {
415
+ if (row.item_type === "group") sortGroups.set(row.item_name, row.sort_index);
416
+ else if (row.item_type === "instance") sortInsts.set(row.item_name, row.sort_index);
417
+ }
181
418
  if (!current && roster.length) current = roster[0].instance_name;
419
+ computeOrder();
182
420
  renderList(); renderCard();
183
- } catch (e) { q("pre").textContent = "Failed to load roster: " + e.message + "\n(Check the token in the URL.)"; }
421
+ if (!probed) { probed = true; probeWrite(); }
422
+ } catch (e) { q("pre").textContent = "Failed to load roster: " + e.message + "\n(Is the fleet running?)"; }
184
423
  }
185
424
 
186
425
  // ── Terminal ──────────────────────────────────────────
187
426
  async function refreshPane() {
188
427
  if (!current) return;
428
+ const target = current; // guard against a switch mid-fetch
189
429
  try {
190
- const r = await fetch(api(`/api/pane/${encodeURIComponent(current)}`));
430
+ const r = await fetch(api(`/api/pane/${encodeURIComponent(target)}`));
431
+ if (target !== current) return; // user switched while awaiting — drop stale
432
+ if (!r.ok) {
433
+ // Surface the failure instead of leaving the previous instance's output
434
+ // on screen (which reads as "switching did nothing").
435
+ q("pre").textContent = `⚠ Could not load pane for ${target} (HTTP ${r.status}).`;
436
+ return;
437
+ }
191
438
  const text = await r.text();
192
- const pre = q("pre");
193
- pre.innerHTML = ansiToHtml(text);
439
+ if (target !== current) return;
440
+ q("pre").innerHTML = ansiToHtml(text);
194
441
  if (!fitted) { fitFont(text); fitted = true; }
195
- } catch { /* transient */ }
442
+ } catch { /* transient network error — keep last frame */ }
196
443
  }
197
444
 
198
445
  // Fit font size so the widest line fits the terminal width. Recomputed only
@@ -211,6 +458,7 @@
211
458
  if (name === current) return;
212
459
  current = name; fitted = false;
213
460
  exitEdit();
461
+ q("pre").textContent = ""; // blank the old instance's output immediately
214
462
  renderList(); renderCard(); refreshPane();
215
463
  }
216
464
 
@@ -236,24 +484,25 @@
236
484
  async function save() {
237
485
  const it = roster.find(r => r.instance_name === current);
238
486
  if (!it) return;
239
- const webToken = q("fToken").value.trim();
240
- if (!webToken) { q("msg").className = "err"; q("msg").textContent = "web.token required"; return; }
241
- localStorage.setItem("agend_web_token", webToken);
487
+ const wt = q("fToken").value.trim();
488
+ if (!wt) { q("msg").className = "err"; q("msg").textContent = "web.token required"; return; }
489
+ localStorage.setItem("agend_web_token", wt);
242
490
  const name = encodeURIComponent(current);
243
491
  try {
244
492
  const r = await fetch(`/api/profile/${name}`, {
245
493
  method: "POST",
246
- headers: { "Content-Type": "application/json", "X-Agend-Token": webToken },
494
+ headers: { "Content-Type": "application/json", "X-Agend-Token": wt },
247
495
  body: JSON.stringify({ display_name: q("fDisplay").value, role: q("fRole").value, description: q("fDesc").value }),
248
496
  });
249
497
  if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || ("HTTP " + r.status)); }
250
498
  const file = q("fAvatar").files[0];
251
499
  if (file) {
252
- const ar = await fetch(`/api/avatar/${name}`, { method: "POST", headers: { "Content-Type": file.type, "X-Agend-Token": webToken }, body: file });
500
+ const ar = await fetch(`/api/avatar/${name}`, { method: "POST", headers: { "Content-Type": file.type, "X-Agend-Token": wt }, body: file });
253
501
  if (!ar.ok) { const e = await ar.json().catch(() => ({})); throw new Error("avatar: " + (e.error || ar.status)); }
254
502
  }
255
503
  q("msg").className = "ok"; q("msg").textContent = "Saved";
256
504
  await loadRoster();
505
+ probeWrite(); // web.token now stored — enable drag-sort
257
506
  setTimeout(exitEdit, 500);
258
507
  } catch (e) { q("msg").className = "err"; q("msg").textContent = e.message; }
259
508
  }
@@ -262,6 +511,13 @@
262
511
  q("cancelBtn").onclick = exitEdit;
263
512
  q("saveBtn").onclick = save;
264
513
 
514
+ const setHelp = (open) => { q("helpPanel").classList.toggle("open", open); q("helpBackdrop").classList.toggle("open", open); };
515
+ q("helpBtn").onclick = () => setHelp(!q("helpPanel").classList.contains("open"));
516
+ q("helpClose").onclick = () => setHelp(false);
517
+ q("helpBackdrop").onclick = () => setHelp(false);
518
+ q("langBtn").onclick = () => { lang = lang === "en" ? "zh-TW" : "en"; localStorage.setItem("agend_lang", lang); applyI18n(); };
519
+ applyI18n();
520
+
265
521
  // ── Boot ──────────────────────────────────────────────
266
522
  loadRoster().then(() => { refreshPane(); });
267
523
  poll = setInterval(refreshPane, 800);
@@ -7,6 +7,15 @@ export interface ViewApiContext {
7
7
  readonly dataDir: string;
8
8
  readonly fleetConfig: FleetConfig | null;
9
9
  readonly logger: Logger;
10
+ readonly classicChannels: {
11
+ getAll(): {
12
+ instanceName: string;
13
+ name: string;
14
+ backend?: string;
15
+ channelId: string;
16
+ }[];
17
+ getBackendByInstance(name: string, fleetDefault?: string): string;
18
+ } | null;
10
19
  getInstanceStatus(name: string): "running" | "stopped" | "crashed";
11
20
  getUiStatus(): unknown;
12
21
  }