agent-loft 0.2.40 → 0.2.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.
@@ -1,19 +1,21 @@
1
1
  import { fill, h, toast } from "../lib/dom.js";
2
2
  import { api } from "../lib/api.js";
3
3
  import { relTime } from "../lib/time.js";
4
- import { confirmDialog } from "../lib/overlay.js";
5
- import { field } from "./common.js";
6
- import { networkSection, serverVersionSection } from "./general.js";
4
+ import { confirmDialog, showMenu } from "../lib/overlay.js";
5
+ import { field, saveButton } from "./common.js";
6
+ import { networkSection } from "./general.js";
7
+ import { deviceList } from "./security.js";
7
8
 
8
9
  // ---------- 用户(管理员) ----------
9
10
 
10
- export async function usersTab(panel) {
11
+ export async function usersTab(page) {
11
12
  const box = h("div", { class: "section" }, h("h4", {}, "用户"), h("span", { class: "spinner" }));
12
- const addBox = h("div", { class: "section" });
13
- fill(panel, box, addBox);
13
+ const addBox = h("div", { class: "section", "data-form": "" });
14
+ const devicesBox = h("div", { class: "section" });
15
+ fill(page, box, addBox, devicesBox);
14
16
  const reload = () => {
15
- fill(panel);
16
- void usersTab(panel);
17
+ fill(page);
18
+ void usersTab(page);
17
19
  };
18
20
  let data;
19
21
  try {
@@ -22,14 +24,13 @@ export async function usersTab(panel) {
22
24
  fill(box, h("h4", {}, "用户"), h("div", { class: "error-text" }, err.message));
23
25
  return;
24
26
  }
25
- const act = (btn, fn) => async () => {
26
- btn.disabled = true;
27
+ /** 菜单里的一项:做完重新拉一遍;fn 返回 false = 取消了 */
28
+ const act = (fn) => async () => {
27
29
  try {
28
30
  if ((await fn()) !== false) reload();
29
31
  } catch (err) {
30
32
  toast(err.message);
31
33
  }
32
- btn.disabled = false;
33
34
  };
34
35
  const patch = (u, body) => api(`/api/users/${encodeURIComponent(u.id)}`, { method: "PATCH", body });
35
36
 
@@ -37,42 +38,50 @@ export async function usersTab(panel) {
37
38
  const resetRow = (u, holder) => {
38
39
  const pw = h("input", { type: "password", autocomplete: "new-password", placeholder: "新密码(至少 8 位)" });
39
40
  const ok = h("button", { class: "btn small primary" }, "确定");
40
- ok.onclick = act(ok, async () => {
41
- await patch(u, { password: pw.value });
42
- toast(`已重置「${u.name}」的密码,他的设备已下线`);
43
- });
44
- fill(holder, h("div", { class: "row" }, pw, ok));
41
+ const cancel = h("button", { class: "btn small", onclick: () => fill(holder) }, "取消");
42
+ ok.onclick = async () => {
43
+ ok.disabled = true;
44
+ try {
45
+ await patch(u, { password: pw.value });
46
+ toast(`已重置「${u.name}」的密码,他的设备已下线`);
47
+ reload();
48
+ } catch (err) {
49
+ toast(err.message);
50
+ ok.disabled = false;
51
+ }
52
+ };
53
+ pw.onkeydown = (e) => e.key === "Enter" && !e.isComposing && ok.click();
54
+ fill(holder, h("div", { class: "row user-reset" }, pw, ok, cancel));
45
55
  pw.focus();
46
56
  };
47
57
 
48
- fill(
49
- box,
50
- h("h4", {}, "用户"),
51
- h(
52
- "div",
53
- { class: "muted" },
54
- "每个人只看得到自己的对话、授权和通知。管理员能改所有设置,还能看到飞书机器人的对话。",
55
- ),
56
- h(
57
- "div",
58
- { class: "error-text" },
59
- "注意:账号不是安全隔离。所有 agent 都以服务器上的同一个系统用户运行,任何用户都能让 agent 读到这台机器上的文件(包括别人的对话记录和配置里的密钥),用的也是同一个 Claude / Codex 订阅。只把账号给你信任的人。",
60
- ),
61
- data.users.map((u) => {
62
- const self = u.id === data.me;
63
- const extra = h("div");
64
- const pwBtn = h("button", { class: "btn small" }, "重置密码");
65
- pwBtn.onclick = () => resetRow(u, extra);
66
- const roleBtn = h("button", { class: "btn small", disabled: self }, u.role === "admin" ? "设为成员" : "设为管理员");
67
- roleBtn.onclick = act(roleBtn, () => patch(u, { role: u.role === "admin" ? "member" : "admin" }));
68
- const disBtn = h("button", { class: "btn small", disabled: self }, u.disabled ? "启用" : "停用");
69
- // 远程控制:管理员一直能用,成员要单独开
70
- const remoteBtn =
71
- u.role === "admin"
72
- ? null
73
- : h("button", { class: "btn small", title: "远程控制:直接用服务器的终端和桌面,不经过授权" }, u.remote ? "关闭远程控制" : "开通远程控制");
74
- if (remoteBtn) {
75
- remoteBtn.onclick = act(remoteBtn, async () => {
58
+ /** 一个用户能做的事:常用的直接放在行上,其余收进「⋯」 */
59
+ const actions = (u, self, extra) => {
60
+ const items = [{ label: "重置密码", onclick: () => resetRow(u, extra) }];
61
+ if (!self) {
62
+ items.push({ label: u.role === "admin" ? "设为成员" : "设为管理员", onclick: act(() => patch(u, { role: u.role === "admin" ? "member" : "admin" })) });
63
+ items.push({
64
+ label: u.disabled ? "启用账号" : "停用账号…",
65
+ onclick: act(async () => {
66
+ if (!u.disabled) {
67
+ const sure = await confirmDialog({
68
+ title: `停用「${u.name}」?`,
69
+ message: "他的设备会立刻下线,之后登录不了。对话、记忆都保留,随时可以再启用。",
70
+ confirmText: "停用",
71
+ danger: true,
72
+ });
73
+ if (!sure) return false;
74
+ }
75
+ await patch(u, { disabled: !u.disabled });
76
+ toast(u.disabled ? "已启用" : "已停用");
77
+ }),
78
+ });
79
+ }
80
+ // 远程控制:管理员一直能用,成员要单独开
81
+ if (u.role !== "admin") {
82
+ items.push({
83
+ label: u.remote ? "关闭远程控制" : "开通远程控制…",
84
+ onclick: act(async () => {
76
85
  if (!u.remote) {
77
86
  const sure = await confirmDialog({
78
87
  title: `给「${u.name}」开通远程控制?`,
@@ -85,42 +94,71 @@ export async function usersTab(panel) {
85
94
  }
86
95
  await patch(u, { remote: !u.remote });
87
96
  toast(u.remote ? "已关闭,他开着的终端和桌面已经断开" : "已开通");
88
- });
89
- }
90
- const memBtn = h("button", { class: "btn small", title: "删除他的「关于我」和所有记住的事。管理员看不到记忆的内容" }, "清空记忆");
91
- memBtn.onclick = act(memBtn, async () => {
97
+ }),
98
+ });
99
+ }
100
+ items.push("sep");
101
+ if (u.feishuLinks) {
102
+ items.push({
103
+ label: "解除飞书绑定",
104
+ onclick: act(async () => {
105
+ await api(`/api/users/${encodeURIComponent(u.id)}/feishu`, { method: "DELETE" });
106
+ toast("已解除,他和机器人的私聊交还给管理员");
107
+ }),
108
+ });
109
+ }
110
+ items.push({
111
+ label: "清空记忆…",
112
+ danger: true,
113
+ onclick: act(async () => {
92
114
  const sure = await confirmDialog({
93
115
  title: `清空「${u.name}」的记忆?`,
94
- message: "他的「关于我」和所有记住的事都会删除,不能恢复。",
116
+ message: "他的「关于我」和所有记住的事都会删除,不能恢复。管理员看不到记忆的内容,只能清空。",
95
117
  confirmText: "清空",
96
118
  danger: true,
97
119
  });
98
120
  if (!sure) return false;
99
121
  await api(`/api/users/${encodeURIComponent(u.id)}/memory`, { method: "DELETE" });
100
122
  toast("已清空");
123
+ }),
124
+ });
125
+ if (!self) {
126
+ items.push({
127
+ label: "删除用户…",
128
+ danger: true,
129
+ onclick: act(async () => {
130
+ const sure = await confirmDialog({
131
+ title: `删除用户「${u.name}」?`,
132
+ message: "他的设备会立刻下线,记忆和飞书绑定一起删除。他的对话不会删除,之后归管理员。",
133
+ confirmText: "删除",
134
+ danger: true,
135
+ });
136
+ if (!sure) return false;
137
+ await api(`/api/users/${encodeURIComponent(u.id)}`, { method: "DELETE" });
138
+ toast("已删除");
139
+ }),
101
140
  });
102
- const linkBtn = u.feishuLinks ? h("button", { class: "btn small" }, "解除飞书绑定") : null;
103
- if (linkBtn) {
104
- linkBtn.onclick = act(linkBtn, async () => {
105
- await api(`/api/users/${encodeURIComponent(u.id)}/feishu`, { method: "DELETE" });
106
- toast("已解除,他和机器人的私聊交还给管理员");
107
- });
108
- }
109
- const rmBtn = h("button", { class: "btn small danger", disabled: self }, "删除");
110
- rmBtn.onclick = act(rmBtn, async () => {
111
- const sure = await confirmDialog({
112
- title: `删除用户「${u.name}」?`,
113
- message: "他的设备会立刻下线,记忆和飞书绑定一起删除。他的对话不会删除,之后归管理员。",
114
- confirmText: "删除",
115
- danger: true,
116
- });
117
- if (!sure) return false;
118
- await api(`/api/users/${encodeURIComponent(u.id)}`, { method: "DELETE" });
119
- toast("已删除");
120
- });
141
+ }
142
+ const more = h("button", { class: "btn small", "aria-haspopup": "menu" }, "管理 ▾");
143
+ more.onclick = () => showMenu(more, items);
144
+ return more;
145
+ };
146
+
147
+ fill(
148
+ box,
149
+ h("h4", {}, `用户(${data.users.length})`),
150
+ h("div", { class: "muted" }, "每个人只看得到自己的对话、授权和通知。管理员能改所有设置,还能看到飞书机器人的对话。"),
151
+ h(
152
+ "div",
153
+ { class: "error-text" },
154
+ "注意:账号不是安全隔离。所有 agent 都以服务器上的同一个系统用户运行,任何用户都能让 agent 读到这台机器上的文件(包括别人的对话记录和配置里的密钥),用的也是同一个 Claude / Codex 订阅。只把账号给你信任的人。",
155
+ ),
156
+ data.users.map((u) => {
157
+ const self = u.id === data.me;
158
+ const extra = h("div");
121
159
  return h(
122
160
  "div",
123
- { class: "list-item", style: { flexWrap: "wrap" } },
161
+ { class: "list-item" },
124
162
  h(
125
163
  "div",
126
164
  { class: "grow" },
@@ -137,13 +175,7 @@ export async function usersTab(panel) {
137
175
  h("div", { class: "s" }, `创建于 ${relTime(u.createdAt)} · ${u.devices} 台设备已登录${u.feishuLinks ? ` · 绑定了 ${u.feishuLinks} 个飞书账号` : ""}`),
138
176
  extra,
139
177
  ),
140
- pwBtn,
141
- roleBtn,
142
- disBtn,
143
- remoteBtn,
144
- memBtn,
145
- linkBtn,
146
- rmBtn,
178
+ actions(u, self, extra),
147
179
  );
148
180
  }),
149
181
  );
@@ -151,61 +183,137 @@ export async function usersTab(panel) {
151
183
  const name = h("input", { autocomplete: "off", autocapitalize: "off", spellcheck: "false", placeholder: "登录用的名字" });
152
184
  const pw = h("input", { type: "password", autocomplete: "new-password", placeholder: "至少 8 位" });
153
185
  const role = h("select", {}, h("option", { value: "member" }, "成员"), h("option", { value: "admin" }, "管理员"));
154
- const add = h("button", { class: "btn primary" }, "添加");
155
- add.onclick = act(add, async () => {
156
- await api("/api/users", { method: "POST", body: { name: name.value.trim(), password: pw.value, role: role.value } });
157
- toast(`已添加「${name.value.trim()}」`);
158
- });
159
186
  fill(
160
187
  addBox,
161
188
  h("h4", {}, "添加用户"),
162
189
  field("用户名", name),
163
- field("密码", pw, "告诉对方,让他登录后在 设置 → 账号 里自己改掉"),
190
+ field("密码", pw, "告诉对方,让他登录后在 设置 →「账号」里自己改掉"),
164
191
  field("角色", role, "成员只能用自己的对话;管理员能改所有设置、管理用户"),
165
- h("div", { class: "row" }, add),
192
+ h(
193
+ "div",
194
+ { class: "row" },
195
+ saveButton(async () => {
196
+ await api("/api/users", { method: "POST", body: { name: name.value.trim(), password: pw.value, role: role.value } });
197
+ toast(`已添加「${name.value.trim()}」`);
198
+ // 先标成已保存再刷新,免得离开提醒
199
+ addBox.removeAttribute("data-dirty");
200
+ reload();
201
+ }, "添加"),
202
+ ),
166
203
  );
204
+
205
+ // 所有人登录着的设备:哪台不认识就让它下线
206
+ const loadDevices = async () => {
207
+ let sessions;
208
+ try {
209
+ sessions = await api("/api/web-sessions");
210
+ } catch (err) {
211
+ fill(devicesBox, h("h4", {}, "所有已登录的设备"), h("div", { class: "error-text" }, err.message));
212
+ return;
213
+ }
214
+ fill(
215
+ devicesBox,
216
+ h("h4", {}, `所有已登录的设备(${sessions.length})`),
217
+ deviceList(sessions, { showOwner: true, onChange: loadDevices }),
218
+ );
219
+ };
220
+ void loadDevices();
167
221
  }
168
222
 
169
- export async function systemTab(cfg, panel) {
170
- // 系统信息要等接口返回,先占好位置,版本和网络访问两节不用等它
171
- const sysBox = h("div", { class: "section" }, h("h4", {}, "系统", h("span", { class: "grow" }), h("span", { class: "spinner" })));
172
- panel.append(sysBox, serverVersionSection(), networkSection(cfg));
173
- let sys;
223
+ /** 设置 → 系统:服务端运行状态、版本和升级、重启,网络访问 */
224
+ export async function systemTab(cfg, page) {
225
+ const sysBox = h("div", { class: "section" }, h("h4", {}, "服务端", h("span", { class: "grow" }), h("span", { class: "spinner" })));
226
+ page.append(sysBox, networkSection(cfg));
227
+
228
+ const restart = async () => {
229
+ if (!(await confirmDialog({ title: "重启 agent-loft?", message: "进行中的对话会被中断。", confirmText: "重启", danger: true }))) return;
230
+ try {
231
+ await api("/api/system/restart", { method: "POST" });
232
+ toast("正在重启…");
233
+ setTimeout(() => location.reload(), 5000);
234
+ } catch (err) {
235
+ toast(err.message);
236
+ }
237
+ };
238
+
239
+ // 运行状态马上就有;最新版本要去 npm 查,可能要几秒,查到了再补上
240
+ let sys = null;
241
+ let v = null;
242
+ let checking = true;
243
+ const render = () => {
244
+ const recheck = h("button", { class: "btn small", disabled: checking }, checking ? "检查中…" : "检查更新");
245
+ recheck.onclick = () => void checkVersion(true);
246
+ const status = checking
247
+ ? h("span", { class: "status-tag" }, "正在检查更新…")
248
+ : !v
249
+ ? null
250
+ : v.updateAvailable
251
+ ? h("span", { class: "status-tag ok" }, `有新版本 ${v.latest}`)
252
+ : v.latest
253
+ ? h("span", { class: "status-tag" }, "已是最新版本")
254
+ : h("span", { class: "status-tag bad", title: v.latestError ?? "" }, "查不到最新版本");
255
+ const upgradeBtn = v?.updateAvailable && v.canUpgrade && !checking ? h("button", { class: "btn small primary" }, `升级到 ${v.latest}`) : null;
256
+ if (upgradeBtn) {
257
+ upgradeBtn.onclick = async () => {
258
+ upgradeBtn.disabled = true;
259
+ upgradeBtn.textContent = "正在升级…";
260
+ try {
261
+ const r = await api("/api/server/upgrade", { method: "POST" });
262
+ toast(r.restarting ? `已升级到 ${r.to},服务正在重启,页面会自动重连` : `已升级到 ${r.to},重启 agent-loft 后生效`, 6000);
263
+ void checkVersion(false);
264
+ } catch (err) {
265
+ toast(`升级失败:${err.message}`, 6000);
266
+ upgradeBtn.disabled = false;
267
+ upgradeBtn.textContent = `升级到 ${v.latest}`;
268
+ }
269
+ };
270
+ }
271
+ fill(
272
+ sysBox,
273
+ h("h4", {}, "服务端"),
274
+ h("div", { class: "row" }, h("b", {}, `agent-loft ${v?.version ?? sys.version}`), status),
275
+ h("div", { class: "muted" }, `Node ${sys.node} · 已运行 ${uptime(sys.uptimeMs)} · ${sys.underSystemd ? "以系统服务运行" : "前台运行(重启需要在终端里操作)"}`),
276
+ v && !checking && !v.latest && v.latestError ? h("div", { class: "muted" }, v.latestError) : null,
277
+ v?.updateAvailable && !v.canUpgrade && v.reason ? h("div", { class: "muted" }, v.reason) : null,
278
+ v?.updateAvailable && v.canUpgrade && !v.service ? h("div", { class: "muted" }, "agent-loft 是在前台手动运行的,升级后要自己重启一下。") : null,
279
+ h(
280
+ "div",
281
+ { class: "row" },
282
+ upgradeBtn,
283
+ recheck,
284
+ h("span", { class: "grow" }),
285
+ h("button", { class: "btn small", disabled: !sys.underSystemd, title: sys.underSystemd ? "" : "前台运行时请在终端里重启", onclick: () => void restart() }, "重启服务"),
286
+ ),
287
+ );
288
+ };
289
+ /** refresh:「检查更新」时让服务端重新去 npm 查最新版本 */
290
+ const checkVersion = async (refresh) => {
291
+ checking = true;
292
+ if (sys) render();
293
+ try {
294
+ v = await api(`/api/server/version${refresh ? "?refresh=1" : ""}`);
295
+ } catch (err) {
296
+ v = null;
297
+ toast(`检查更新失败:${err.message}`);
298
+ }
299
+ checking = false;
300
+ if (sys) render();
301
+ };
302
+ const versionDone = checkVersion(false);
174
303
  try {
175
304
  sys = await api("/api/system");
176
305
  } catch (err) {
177
- fill(sysBox, h("h4", {}, "系统"), h("div", { class: "error-text" }, err.message));
306
+ fill(sysBox, h("h4", {}, "服务端"), h("div", { class: "error-text" }, err.message));
178
307
  return;
179
308
  }
180
- fill(
181
- sysBox,
182
- h("h4", {}, "系统"),
183
- h("div", {}, `版本 ${sys.version} · Node ${sys.node} · 已运行 ${Math.round(sys.uptimeMs / 60000)} 分钟`),
184
- h("div", { class: "muted" }, sys.underSystemd ? "以系统服务运行" : "前台运行(重启需要在终端里操作)"),
185
- h(
186
- "div",
187
- { class: "row" },
188
- h(
189
- "button",
190
- {
191
- class: "btn",
192
- disabled: !sys.underSystemd,
193
- onclick: async () => {
194
- if (!(await confirmDialog({ title: "重启 agent-loft?", message: "进行中的对话会被中断。", confirmText: "重启", danger: true }))) return;
195
- try {
196
- await api("/api/system/restart", { method: "POST" });
197
- toast("正在重启…");
198
- setTimeout(() => location.reload(), 5000);
199
- } catch (err) {
200
- toast(err.message);
201
- }
202
- },
203
- },
204
- "重启服务",
205
- ),
206
- "Notification" in window && Notification.permission !== "granted"
207
- ? h("button", { class: "btn", onclick: () => Notification.requestPermission() }, "开启浏览器通知")
208
- : null,
209
- ),
210
- );
309
+ render();
310
+ await versionDone;
311
+ }
312
+
313
+ /** 运行了多久:分钟 / 小时 / 天 */
314
+ function uptime(ms) {
315
+ const min = Math.round(ms / 60000);
316
+ if (min < 60) return `${min} 分钟`;
317
+ if (min < 1440) return `${Math.floor(min / 60)} 小时 ${min % 60} 分钟`;
318
+ return `${Math.floor(min / 1440)} 天 ${Math.floor((min % 1440) / 60)} 小时`;
211
319
  }
@@ -8,11 +8,11 @@ import { desktop, desktopQuota, S, ui } from "../state.js";
8
8
  // (60 秒内再请求直接返回缓存;Claude 要临时拉一个 CLI 进程才问得到,不能太勤)。
9
9
  // 前端什么时候请求:
10
10
  // - 打开页面拉一次;
11
- // - 空闲时每隔 S.quotaRefreshMin 分钟(设置 → 通用,默认 10,0 = 不自动刷新),只在页面可见时;
11
+ // - 空闲时每隔 S.quotaRefreshMin 分钟(设置 → Agent → 账号额度,默认 10,0 = 不自动刷新),只在页面可见时;
12
12
  // - 有会话跑完一轮:10 秒后拉;若那时服务端缓存还没过期,就推迟到过期之后,保证拿到新数据;
13
13
  // - 切回前台:距上次超过一个刷新间隔就拉;
14
14
  // - 点一下额度区域:立刻拉(可能拿到 60 秒内的缓存)。
15
- // 设置 → 通用里关了「查询额度」(S.quotaEnabled):以上全都不做,侧栏也不显示额度。
15
+ // 设置 → Agent 里关了「查询额度」(S.quotaEnabled):以上全都不做,侧栏也不显示额度。
16
16
  // 在桌面端里(desktopQuota)以上全都不做:桌面端统一去查(同一个账号的多台服务器只问一台),
17
17
  // 查好推过来;点一下额度区域是请桌面端去刷新。
18
18