@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
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,66 @@
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
+ // If the user is selecting text inside the pane, skip this refresh so the
429
+ // 800ms poll doesn't wipe the selection mid-copy. Resumes on the next tick
430
+ // once the selection is released/collapsed.
431
+ const sel = window.getSelection();
432
+ if (sel && sel.toString().length > 0 && sel.anchorNode && q("pre").contains(sel.anchorNode)) return;
433
+ const target = current; // guard against a switch mid-fetch
189
434
  try {
190
- const r = await fetch(api(`/api/pane/${encodeURIComponent(current)}`));
435
+ const r = await fetch(api(`/api/pane/${encodeURIComponent(target)}`));
436
+ if (target !== current) return; // user switched while awaiting — drop stale
437
+ if (!r.ok) {
438
+ // Surface the failure instead of leaving the previous instance's output
439
+ // on screen (which reads as "switching did nothing").
440
+ q("pre").textContent = `⚠ Could not load pane for ${target} (HTTP ${r.status}).`;
441
+ return;
442
+ }
191
443
  const text = await r.text();
192
- const pre = q("pre");
193
- pre.innerHTML = ansiToHtml(text);
444
+ if (target !== current) return;
445
+ q("pre").innerHTML = ansiToHtml(text);
194
446
  if (!fitted) { fitFont(text); fitted = true; }
195
- } catch { /* transient */ }
447
+ } catch { /* transient network error — keep last frame */ }
196
448
  }
197
449
 
198
450
  // Fit font size so the widest line fits the terminal width. Recomputed only
@@ -211,6 +463,7 @@
211
463
  if (name === current) return;
212
464
  current = name; fitted = false;
213
465
  exitEdit();
466
+ q("pre").textContent = ""; // blank the old instance's output immediately
214
467
  renderList(); renderCard(); refreshPane();
215
468
  }
216
469
 
@@ -236,24 +489,25 @@
236
489
  async function save() {
237
490
  const it = roster.find(r => r.instance_name === current);
238
491
  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);
492
+ const wt = q("fToken").value.trim();
493
+ if (!wt) { q("msg").className = "err"; q("msg").textContent = "web.token required"; return; }
494
+ localStorage.setItem("agend_web_token", wt);
242
495
  const name = encodeURIComponent(current);
243
496
  try {
244
497
  const r = await fetch(`/api/profile/${name}`, {
245
498
  method: "POST",
246
- headers: { "Content-Type": "application/json", "X-Agend-Token": webToken },
499
+ headers: { "Content-Type": "application/json", "X-Agend-Token": wt },
247
500
  body: JSON.stringify({ display_name: q("fDisplay").value, role: q("fRole").value, description: q("fDesc").value }),
248
501
  });
249
502
  if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || ("HTTP " + r.status)); }
250
503
  const file = q("fAvatar").files[0];
251
504
  if (file) {
252
- const ar = await fetch(`/api/avatar/${name}`, { method: "POST", headers: { "Content-Type": file.type, "X-Agend-Token": webToken }, body: file });
505
+ const ar = await fetch(`/api/avatar/${name}`, { method: "POST", headers: { "Content-Type": file.type, "X-Agend-Token": wt }, body: file });
253
506
  if (!ar.ok) { const e = await ar.json().catch(() => ({})); throw new Error("avatar: " + (e.error || ar.status)); }
254
507
  }
255
508
  q("msg").className = "ok"; q("msg").textContent = "Saved";
256
509
  await loadRoster();
510
+ probeWrite(); // web.token now stored — enable drag-sort
257
511
  setTimeout(exitEdit, 500);
258
512
  } catch (e) { q("msg").className = "err"; q("msg").textContent = e.message; }
259
513
  }
@@ -262,6 +516,13 @@
262
516
  q("cancelBtn").onclick = exitEdit;
263
517
  q("saveBtn").onclick = save;
264
518
 
519
+ const setHelp = (open) => { q("helpPanel").classList.toggle("open", open); q("helpBackdrop").classList.toggle("open", open); };
520
+ q("helpBtn").onclick = () => setHelp(!q("helpPanel").classList.contains("open"));
521
+ q("helpClose").onclick = () => setHelp(false);
522
+ q("helpBackdrop").onclick = () => setHelp(false);
523
+ q("langBtn").onclick = () => { lang = lang === "en" ? "zh-TW" : "en"; localStorage.setItem("agend_lang", lang); applyI18n(); };
524
+ applyI18n();
525
+
265
526
  // ── Boot ──────────────────────────────────────────────
266
527
  loadRoster().then(() => { refreshPane(); });
267
528
  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
  }