@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.
- package/dist/channel/adapters/discord.d.ts +2 -0
- package/dist/channel/adapters/discord.js +45 -36
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.js +1 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/factory.d.ts +4 -0
- package/dist/channel/factory.js +1 -0
- package/dist/channel/factory.js.map +1 -1
- package/dist/channel/mcp-tools.js +3 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/tool-router.js +29 -2
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +2 -0
- package/dist/classic-channel-manager.d.ts +59 -12
- package/dist/classic-channel-manager.js +121 -30
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +94 -7
- package/dist/cli.js.map +1 -1
- package/dist/config-validator.d.ts +20 -0
- package/dist/config-validator.js +154 -0
- package/dist/config-validator.js.map +1 -0
- package/dist/fleet-manager.d.ts +29 -6
- package/dist/fleet-manager.js +454 -258
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +1 -1
- package/dist/instance-lifecycle.js +15 -13
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.d.ts +19 -0
- package/dist/locale.js +179 -0
- package/dist/locale.js.map +1 -0
- package/dist/outbound-schemas.d.ts +1 -0
- package/dist/outbound-schemas.js +1 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/quickstart.js +252 -16
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/scheduler.js +8 -2
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/service-installer.d.ts +1 -0
- package/dist/service-installer.js +2 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.d.ts +33 -0
- package/dist/settings-api.js +283 -0
- package/dist/settings-api.js.map +1 -0
- package/dist/topic-commands.d.ts +14 -0
- package/dist/topic-commands.js +78 -16
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/ui/settings.html +617 -0
- package/dist/ui/view.html +300 -39
- package/dist/view-api.d.ts +9 -0
- package/dist/view-api.js +101 -40
- package/dist/view-api.js.map +1 -1
- package/package.json +1 -1
- 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
|
-
.
|
|
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
|
-
#
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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">
|
|
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
|
-
|
|
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(
|
|
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
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
179
|
-
if (!
|
|
180
|
-
roster = await
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
240
|
-
if (!
|
|
241
|
-
localStorage.setItem("agend_web_token",
|
|
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":
|
|
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":
|
|
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);
|
package/dist/view-api.d.ts
CHANGED
|
@@ -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
|
}
|