agent-loft 0.2.21 → 0.2.23
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/README.md +24 -13
- package/bin/cli.js +4 -2
- package/package.json +1 -1
- package/src/agents/claude/session.ts +1 -1
- package/src/agents/codex/session.ts +1 -0
- package/src/channels/feishu/bot.ts +22 -8
- package/src/channels/web/access.ts +26 -0
- package/src/channels/web/api.ts +176 -42
- package/src/channels/web/auth.ts +92 -38
- package/src/channels/web/push.ts +30 -8
- package/src/channels/web/server.ts +50 -17
- package/src/cli/doctor.ts +2 -1
- package/src/cli/passwd.ts +24 -7
- package/src/cli/user.ts +99 -0
- package/src/core/command-catalog.ts +11 -7
- package/src/core/commands.ts +1 -1
- package/src/core/hub.ts +49 -8
- package/src/core/store.ts +33 -0
- package/src/core/types.ts +5 -0
- package/src/core/uploads.ts +12 -0
- package/src/core/users.ts +261 -0
- package/web/app.js +238 -43
package/web/app.js
CHANGED
|
@@ -255,8 +255,12 @@ const S = {
|
|
|
255
255
|
allowedAgents: [],
|
|
256
256
|
search: "",
|
|
257
257
|
loadToken: 0,
|
|
258
|
+
/** 登录的用户 { id, name, role } */
|
|
259
|
+
user: null,
|
|
258
260
|
};
|
|
259
261
|
|
|
262
|
+
const isAdmin = () => S.user?.role === "admin";
|
|
263
|
+
|
|
260
264
|
const ui = {};
|
|
261
265
|
|
|
262
266
|
/**
|
|
@@ -509,6 +513,14 @@ async function showAuth() {
|
|
|
509
513
|
const err = h("div", { class: "error-text" });
|
|
510
514
|
const pw = h("input", { type: "password", placeholder: "密码", autocomplete: me.needsSetup ? "new-password" : "current-password" });
|
|
511
515
|
const token = h("input", { placeholder: "设置码(在启动日志里)", autocomplete: "off" });
|
|
516
|
+
// 只有一个用户时和以前一样只要密码;多个用户才要填用户名
|
|
517
|
+
const askName = me.needsSetup || me.multiUser;
|
|
518
|
+
const name = h("input", {
|
|
519
|
+
placeholder: me.needsSetup ? "admin" : "用户名",
|
|
520
|
+
autocomplete: "username",
|
|
521
|
+
autocapitalize: "off",
|
|
522
|
+
spellcheck: "false",
|
|
523
|
+
});
|
|
512
524
|
const btn = h("button", { class: "btn primary", type: "submit" }, me.needsSetup ? "设置密码并进入" : "登录");
|
|
513
525
|
const form = h(
|
|
514
526
|
"form",
|
|
@@ -519,8 +531,11 @@ async function showAuth() {
|
|
|
519
531
|
err.textContent = "";
|
|
520
532
|
btn.disabled = true;
|
|
521
533
|
try {
|
|
522
|
-
if (me.needsSetup)
|
|
523
|
-
|
|
534
|
+
if (me.needsSetup) {
|
|
535
|
+
await api("/api/setup", { method: "POST", body: { token: token.value, name: name.value.trim() || "admin", password: pw.value } });
|
|
536
|
+
} else {
|
|
537
|
+
await api("/api/login", { method: "POST", body: { ...(askName ? { name: name.value.trim() } : {}), password: pw.value } });
|
|
538
|
+
}
|
|
524
539
|
await boot();
|
|
525
540
|
} catch (e2) {
|
|
526
541
|
err.textContent = e2.message;
|
|
@@ -530,14 +545,15 @@ async function showAuth() {
|
|
|
530
545
|
},
|
|
531
546
|
},
|
|
532
547
|
h("h1", {}, "Agent Loft"),
|
|
533
|
-
h("p", {}, me.needsSetup ? "
|
|
548
|
+
h("p", {}, me.needsSetup ? "首次使用:输入启动日志里的设置码,建一个管理员账号(密码至少 8 位)" : "登录以继续"),
|
|
534
549
|
me.needsSetup ? h("label", { class: "field" }, h("span", {}, "设置码"), token) : null,
|
|
550
|
+
askName ? h("label", { class: "field" }, h("span", {}, me.needsSetup ? "管理员用户名" : "用户名"), name) : null,
|
|
535
551
|
h("label", { class: "field" }, h("span", {}, me.needsSetup ? "新密码" : "密码"), pw),
|
|
536
552
|
btn,
|
|
537
553
|
err,
|
|
538
554
|
);
|
|
539
555
|
fill($app, h("div", { class: "auth" }, form));
|
|
540
|
-
(me.needsSetup ? token : pw).focus();
|
|
556
|
+
(me.needsSetup ? token : askName ? name : pw).focus();
|
|
541
557
|
}
|
|
542
558
|
|
|
543
559
|
// ---------- 主界面 ----------
|
|
@@ -549,6 +565,7 @@ async function boot() {
|
|
|
549
565
|
WS.epoch = null;
|
|
550
566
|
WS.hellos = 0;
|
|
551
567
|
const data = await api("/api/bootstrap");
|
|
568
|
+
S.user = data.user ?? me.user ?? null;
|
|
552
569
|
S.agents = data.agents;
|
|
553
570
|
S.bots = data.bots;
|
|
554
571
|
S.workspaceRoot = data.workspaceRoot;
|
|
@@ -581,6 +598,7 @@ async function boot() {
|
|
|
581
598
|
if ("serviceWorker" in navigator) {
|
|
582
599
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
|
583
600
|
clearAppBadge();
|
|
601
|
+
void releaseForeignPushSub().catch(() => {});
|
|
584
602
|
// 点了系统通知、页面本来就开着:跳到通知对应的会话
|
|
585
603
|
navigator.serviceWorker.addEventListener("message", (e) => {
|
|
586
604
|
if (e.data?.type !== "open") return;
|
|
@@ -2020,7 +2038,8 @@ function renderInteraction(item) {
|
|
|
2020
2038
|
spec.allowSession
|
|
2021
2039
|
? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-session" }) }, "本会话全部允许")
|
|
2022
2040
|
: null,
|
|
2023
|
-
|
|
2041
|
+
// 会落盘、对所有人生效的「总是允许」只给管理员
|
|
2042
|
+
spec.alwaysLabel && (!spec.alwaysGlobal || isAdmin())
|
|
2024
2043
|
? h("button", { class: "btn", onclick: () => ixOp(ix.id, "decide", { decision: "allow-always" }) }, spec.alwaysLabel)
|
|
2025
2044
|
: null,
|
|
2026
2045
|
h("button", { class: "btn danger", onclick: () => ixOp(ix.id, "decide", { decision: "deny" }) }, "拒绝"),
|
|
@@ -3224,7 +3243,22 @@ async function openSettings() {
|
|
|
3224
3243
|
const nav = h("div", { class: "settings-nav" });
|
|
3225
3244
|
const panel = h("div", { class: "settings-body" });
|
|
3226
3245
|
// 设置弹窗高度固定,切标签页时不跟着内容一伸一缩
|
|
3227
|
-
|
|
3246
|
+
modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel) });
|
|
3247
|
+
// 普通成员只能管自己的通知、密码和登录设备;其余设置都是管理员的
|
|
3248
|
+
if (!isAdmin()) {
|
|
3249
|
+
const tabs = [
|
|
3250
|
+
["通知", () => notifyTab(panel)],
|
|
3251
|
+
["账号", () => securityTab(panel)],
|
|
3252
|
+
];
|
|
3253
|
+
const select = (i) => {
|
|
3254
|
+
nav.querySelectorAll("button").forEach((b, j) => b.classList.toggle("on", i === j));
|
|
3255
|
+
fill(panel);
|
|
3256
|
+
tabs[i][1]();
|
|
3257
|
+
};
|
|
3258
|
+
nav.append(...tabs.map(([name], i) => h("button", { onclick: () => select(i) }, name)));
|
|
3259
|
+
select(0);
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3228
3262
|
let cfg;
|
|
3229
3263
|
try {
|
|
3230
3264
|
cfg = await api("/api/config");
|
|
@@ -3239,8 +3273,9 @@ async function openSettings() {
|
|
|
3239
3273
|
["飞书机器人", () => feishuTab(cfg, panel)],
|
|
3240
3274
|
["授权", () => authTab(panel)],
|
|
3241
3275
|
["通知", () => notifyTab(panel)],
|
|
3242
|
-
["
|
|
3243
|
-
["
|
|
3276
|
+
["账号", () => securityTab(panel)],
|
|
3277
|
+
["用户", () => usersTab(panel)],
|
|
3278
|
+
["系统", () => systemTab(cfg, panel)],
|
|
3244
3279
|
];
|
|
3245
3280
|
const select = (i) => {
|
|
3246
3281
|
nav.querySelectorAll("button").forEach((b, j) => b.classList.toggle("on", i === j));
|
|
@@ -3300,6 +3335,25 @@ async function enablePush() {
|
|
|
3300
3335
|
await api("/api/push/subscribe", { method: "POST", body: { subscription: sub.toJSON() } });
|
|
3301
3336
|
}
|
|
3302
3337
|
|
|
3338
|
+
/** 这台设备不再收通知:服务端删掉订阅,浏览器也退订 */
|
|
3339
|
+
async function dropPushSub() {
|
|
3340
|
+
const sub = await currentPushSub();
|
|
3341
|
+
if (!sub) return;
|
|
3342
|
+
await api("/api/push/unsubscribe", { method: "POST", body: { endpoint: sub.endpoint } }).catch(() => {});
|
|
3343
|
+
await sub.unsubscribe().catch(() => {});
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
/**
|
|
3347
|
+
* 登录后:这个浏览器上的通知订阅如果不是当前账号的(上一个人没退出就换了账号),
|
|
3348
|
+
* 退订掉 —— 推送服务随后会让服务端那条订阅失效,上一个人的通知就不会再推到这里。
|
|
3349
|
+
*/
|
|
3350
|
+
async function releaseForeignPushSub() {
|
|
3351
|
+
const sub = await currentPushSub();
|
|
3352
|
+
if (!sub) return;
|
|
3353
|
+
const { subscriptions } = await api("/api/push");
|
|
3354
|
+
if (!subscriptions.some((d) => d.endpoint === sub.endpoint)) await sub.unsubscribe().catch(() => {});
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3303
3357
|
async function notifyTab(panel) {
|
|
3304
3358
|
const support = pushSupport();
|
|
3305
3359
|
const box = h("div", { class: "section" }, h("h4", {}, "系统通知"), h("span", { class: "spinner" }));
|
|
@@ -3461,7 +3515,6 @@ function serverVersionSection() {
|
|
|
3461
3515
|
}
|
|
3462
3516
|
|
|
3463
3517
|
function generalTab(cfg, panel) {
|
|
3464
|
-
panel.append(serverVersionSection());
|
|
3465
3518
|
const root = h("input", { value: cfg.workspaceRoot });
|
|
3466
3519
|
const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
|
|
3467
3520
|
const ttl = h("input", { type: "number", min: 0, value: Math.round(cfg.sessionTtlMs / 3600000) });
|
|
@@ -3501,7 +3554,6 @@ function generalTab(cfg, panel) {
|
|
|
3501
3554
|
),
|
|
3502
3555
|
),
|
|
3503
3556
|
);
|
|
3504
|
-
panel.append(networkSection(cfg));
|
|
3505
3557
|
}
|
|
3506
3558
|
|
|
3507
3559
|
/** 只有本机能连的监听地址(和服务端 listen.ts 的判断一致) */
|
|
@@ -3707,7 +3759,8 @@ function customDialog({ agent, entry, taken }, onSave) {
|
|
|
3707
3759
|
|
|
3708
3760
|
/**
|
|
3709
3761
|
* 设置 → 命令:每个 agent 一张表,拖动或用箭头排序,取消勾选就不在「/」列表里出现。
|
|
3710
|
-
*
|
|
3762
|
+
* 默认 agent-loft 内置命令按常用程度排在最前,agent 自己的命令排在后面且默认不勾(服务端 buildCatalog 决定)。
|
|
3763
|
+
* 保存时把整张表的顺序存下来;以后新装的 skill 不在表里,会自动接在最后、默认不勾。
|
|
3711
3764
|
* 用户可以自己添加命令(一段预设的提示词),自己加的可以编辑、删除,增删改后立即保存。
|
|
3712
3765
|
*/
|
|
3713
3766
|
function commandsTab(cfg, panel) {
|
|
@@ -3919,7 +3972,7 @@ function commandsTab(cfg, panel) {
|
|
|
3919
3972
|
h(
|
|
3920
3973
|
"div",
|
|
3921
3974
|
{ class: "muted" },
|
|
3922
|
-
"在输入框里打「/」时弹出的命令,按这里的顺序排列;保存后会自动同步到飞书机器人的指令面板。取消勾选的不会出现在列表里,但照样可以手动输入。带 agent 名标记的是 agent 自己的命令(skills
|
|
3975
|
+
"在输入框里打「/」时弹出的命令,按这里的顺序排列;保存后会自动同步到飞书机器人的指令面板。取消勾选的不会出现在列表里,但照样可以手动输入。带 agent 名标记的是 agent 自己的命令(skills 等),默认不勾选,需要的话勾上并保存;它们会原样转给 agent。",
|
|
3923
3976
|
),
|
|
3924
3977
|
tabs,
|
|
3925
3978
|
h(
|
|
@@ -4460,7 +4513,18 @@ async function securityTab(panel) {
|
|
|
4460
4513
|
const old = h("input", { type: "password", autocomplete: "current-password" });
|
|
4461
4514
|
const pw = h("input", { type: "password", autocomplete: "new-password" });
|
|
4462
4515
|
const sessions = await api("/api/web-sessions");
|
|
4516
|
+
// 管理员看到的是所有人的设备:不止一个人时标上是谁的
|
|
4517
|
+
const owners = new Set(sessions.map((s) => s.userId));
|
|
4518
|
+
const showOwner = isAdmin() && owners.size > 1;
|
|
4463
4519
|
panel.append(
|
|
4520
|
+
S.user
|
|
4521
|
+
? h(
|
|
4522
|
+
"div",
|
|
4523
|
+
{ class: "section" },
|
|
4524
|
+
h("h4", {}, "当前账号"),
|
|
4525
|
+
h("div", {}, S.user.name, " ", h("span", { class: "status-tag" }, S.user.role === "admin" ? "管理员" : "成员")),
|
|
4526
|
+
)
|
|
4527
|
+
: null,
|
|
4464
4528
|
h(
|
|
4465
4529
|
"div",
|
|
4466
4530
|
{ class: "section" },
|
|
@@ -4477,7 +4541,7 @@ async function securityTab(panel) {
|
|
|
4477
4541
|
onclick: async () => {
|
|
4478
4542
|
try {
|
|
4479
4543
|
await api("/api/password", { method: "POST", body: { old: old.value, password: pw.value } });
|
|
4480
|
-
toast("
|
|
4544
|
+
toast("密码已修改,你的其它设备已下线");
|
|
4481
4545
|
old.value = pw.value = "";
|
|
4482
4546
|
} catch (err) {
|
|
4483
4547
|
toast(err.message);
|
|
@@ -4491,12 +4555,17 @@ async function securityTab(panel) {
|
|
|
4491
4555
|
h(
|
|
4492
4556
|
"div",
|
|
4493
4557
|
{ class: "section" },
|
|
4494
|
-
h("h4", {}, "已登录的设备"),
|
|
4558
|
+
h("h4", {}, showOwner ? "已登录的设备(所有用户)" : "已登录的设备"),
|
|
4495
4559
|
sessions.map((s) =>
|
|
4496
4560
|
h(
|
|
4497
4561
|
"div",
|
|
4498
4562
|
{ class: "list-item" },
|
|
4499
|
-
h(
|
|
4563
|
+
h(
|
|
4564
|
+
"div",
|
|
4565
|
+
{ class: "grow" },
|
|
4566
|
+
h("div", { class: "t" }, s.userAgent.slice(0, 80) || "未知设备"),
|
|
4567
|
+
h("div", { class: "s" }, `${showOwner ? `${s.userName ?? "已删除的用户"} · ` : ""}${s.ip} · 最近活动 ${relTime(s.lastSeen)}`),
|
|
4568
|
+
),
|
|
4500
4569
|
h(
|
|
4501
4570
|
"button",
|
|
4502
4571
|
{
|
|
@@ -4520,6 +4589,8 @@ async function securityTab(panel) {
|
|
|
4520
4589
|
{
|
|
4521
4590
|
class: "btn danger",
|
|
4522
4591
|
onclick: async () => {
|
|
4592
|
+
// 退出了就别再收这个账号的通知:换个人在这台设备上登录,不能收到上一个人的
|
|
4593
|
+
await dropPushSub().catch(() => {});
|
|
4523
4594
|
await api("/api/logout", { method: "POST" });
|
|
4524
4595
|
location.reload();
|
|
4525
4596
|
},
|
|
@@ -4530,40 +4601,164 @@ async function securityTab(panel) {
|
|
|
4530
4601
|
);
|
|
4531
4602
|
}
|
|
4532
4603
|
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4604
|
+
// ---------- 用户(管理员) ----------
|
|
4605
|
+
|
|
4606
|
+
async function usersTab(panel) {
|
|
4607
|
+
const box = h("div", { class: "section" }, h("h4", {}, "用户"), h("span", { class: "spinner" }));
|
|
4608
|
+
const addBox = h("div", { class: "section" });
|
|
4609
|
+
fill(panel, box, addBox);
|
|
4610
|
+
const reload = () => {
|
|
4611
|
+
fill(panel);
|
|
4612
|
+
void usersTab(panel);
|
|
4613
|
+
};
|
|
4614
|
+
let data;
|
|
4615
|
+
try {
|
|
4616
|
+
data = await api("/api/users");
|
|
4617
|
+
} catch (err) {
|
|
4618
|
+
fill(box, h("h4", {}, "用户"), h("div", { class: "error-text" }, err.message));
|
|
4619
|
+
return;
|
|
4620
|
+
}
|
|
4621
|
+
const act = (btn, fn) => async () => {
|
|
4622
|
+
btn.disabled = true;
|
|
4623
|
+
try {
|
|
4624
|
+
if ((await fn()) !== false) reload();
|
|
4625
|
+
} catch (err) {
|
|
4626
|
+
toast(err.message);
|
|
4627
|
+
}
|
|
4628
|
+
btn.disabled = false;
|
|
4629
|
+
};
|
|
4630
|
+
const patch = (u, body) => api(`/api/users/${encodeURIComponent(u.id)}`, { method: "PATCH", body });
|
|
4631
|
+
|
|
4632
|
+
/** 重置密码:就地展开一个输入框 */
|
|
4633
|
+
const resetRow = (u, holder) => {
|
|
4634
|
+
const pw = h("input", { type: "password", autocomplete: "new-password", placeholder: "新密码(至少 8 位)" });
|
|
4635
|
+
const ok = h("button", { class: "btn small primary" }, "确定");
|
|
4636
|
+
ok.onclick = act(ok, async () => {
|
|
4637
|
+
await patch(u, { password: pw.value });
|
|
4638
|
+
toast(`已重置「${u.name}」的密码,他的设备已下线`);
|
|
4639
|
+
});
|
|
4640
|
+
fill(holder, h("div", { class: "row" }, pw, ok));
|
|
4641
|
+
pw.focus();
|
|
4642
|
+
};
|
|
4643
|
+
|
|
4644
|
+
fill(
|
|
4645
|
+
box,
|
|
4646
|
+
h("h4", {}, "用户"),
|
|
4536
4647
|
h(
|
|
4537
4648
|
"div",
|
|
4538
|
-
{ class: "
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4649
|
+
{ class: "muted" },
|
|
4650
|
+
"每个人只看得到自己的对话、授权和通知。管理员能改所有设置,还能看到飞书机器人的对话。",
|
|
4651
|
+
),
|
|
4652
|
+
h(
|
|
4653
|
+
"div",
|
|
4654
|
+
{ class: "error-text" },
|
|
4655
|
+
"注意:账号不是安全隔离。所有 agent 都以服务器上的同一个系统用户运行,任何用户都能让 agent 读到这台机器上的文件(包括别人的对话记录和配置里的密钥),用的也是同一个 Claude / Codex 订阅。只把账号给你信任的人。",
|
|
4656
|
+
),
|
|
4657
|
+
data.users.map((u) => {
|
|
4658
|
+
const self = u.id === data.me;
|
|
4659
|
+
const extra = h("div");
|
|
4660
|
+
const pwBtn = h("button", { class: "btn small" }, "重置密码");
|
|
4661
|
+
pwBtn.onclick = () => resetRow(u, extra);
|
|
4662
|
+
const roleBtn = h("button", { class: "btn small", disabled: self }, u.role === "admin" ? "设为成员" : "设为管理员");
|
|
4663
|
+
roleBtn.onclick = act(roleBtn, () => patch(u, { role: u.role === "admin" ? "member" : "admin" }));
|
|
4664
|
+
const disBtn = h("button", { class: "btn small", disabled: self }, u.disabled ? "启用" : "停用");
|
|
4665
|
+
disBtn.onclick = act(disBtn, () => patch(u, { disabled: !u.disabled }));
|
|
4666
|
+
const rmBtn = h("button", { class: "btn small danger", disabled: self }, "删除");
|
|
4667
|
+
rmBtn.onclick = act(rmBtn, async () => {
|
|
4668
|
+
const sure = await confirmDialog({
|
|
4669
|
+
title: `删除用户「${u.name}」?`,
|
|
4670
|
+
message: "他的设备会立刻下线。他的对话不会删除,之后归管理员。",
|
|
4671
|
+
confirmText: "删除",
|
|
4672
|
+
danger: true,
|
|
4673
|
+
});
|
|
4674
|
+
if (!sure) return false;
|
|
4675
|
+
await api(`/api/users/${encodeURIComponent(u.id)}`, { method: "DELETE" });
|
|
4676
|
+
toast("已删除");
|
|
4677
|
+
});
|
|
4678
|
+
return h(
|
|
4543
4679
|
"div",
|
|
4544
|
-
{ class: "
|
|
4680
|
+
{ class: "list-item", style: { flexWrap: "wrap" } },
|
|
4545
4681
|
h(
|
|
4546
|
-
"
|
|
4547
|
-
{
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
},
|
|
4560
|
-
},
|
|
4561
|
-
"重启服务",
|
|
4682
|
+
"div",
|
|
4683
|
+
{ class: "grow" },
|
|
4684
|
+
h(
|
|
4685
|
+
"div",
|
|
4686
|
+
{ class: "t" },
|
|
4687
|
+
u.name,
|
|
4688
|
+
self ? "(你)" : "",
|
|
4689
|
+
" ",
|
|
4690
|
+
h("span", { class: "status-tag" }, u.role === "admin" ? "管理员" : "成员"),
|
|
4691
|
+
u.disabled ? h("span", { class: "status-tag bad" }, "已停用") : null,
|
|
4692
|
+
),
|
|
4693
|
+
h("div", { class: "s" }, `创建于 ${relTime(u.createdAt)} · ${u.devices} 台设备已登录`),
|
|
4694
|
+
extra,
|
|
4562
4695
|
),
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4696
|
+
pwBtn,
|
|
4697
|
+
roleBtn,
|
|
4698
|
+
disBtn,
|
|
4699
|
+
rmBtn,
|
|
4700
|
+
);
|
|
4701
|
+
}),
|
|
4702
|
+
);
|
|
4703
|
+
|
|
4704
|
+
const name = h("input", { autocomplete: "off", autocapitalize: "off", spellcheck: "false", placeholder: "登录用的名字" });
|
|
4705
|
+
const pw = h("input", { type: "password", autocomplete: "new-password", placeholder: "至少 8 位" });
|
|
4706
|
+
const role = h("select", {}, h("option", { value: "member" }, "成员"), h("option", { value: "admin" }, "管理员"));
|
|
4707
|
+
const add = h("button", { class: "btn primary" }, "添加");
|
|
4708
|
+
add.onclick = act(add, async () => {
|
|
4709
|
+
await api("/api/users", { method: "POST", body: { name: name.value.trim(), password: pw.value, role: role.value } });
|
|
4710
|
+
toast(`已添加「${name.value.trim()}」`);
|
|
4711
|
+
});
|
|
4712
|
+
fill(
|
|
4713
|
+
addBox,
|
|
4714
|
+
h("h4", {}, "添加用户"),
|
|
4715
|
+
field("用户名", name),
|
|
4716
|
+
field("密码", pw, "告诉对方,让他登录后在 设置 → 账号 里自己改掉"),
|
|
4717
|
+
field("角色", role, "成员只能用自己的对话;管理员能改所有设置、管理用户"),
|
|
4718
|
+
h("div", { class: "row" }, add),
|
|
4719
|
+
);
|
|
4720
|
+
}
|
|
4721
|
+
|
|
4722
|
+
async function systemTab(cfg, panel) {
|
|
4723
|
+
// 系统信息要等接口返回,先占好位置,版本和网络访问两节不用等它
|
|
4724
|
+
const sysBox = h("div", { class: "section" }, h("h4", {}, "系统", h("span", { class: "grow" }), h("span", { class: "spinner" })));
|
|
4725
|
+
panel.append(sysBox, serverVersionSection(), networkSection(cfg));
|
|
4726
|
+
let sys;
|
|
4727
|
+
try {
|
|
4728
|
+
sys = await api("/api/system");
|
|
4729
|
+
} catch (err) {
|
|
4730
|
+
fill(sysBox, h("h4", {}, "系统"), h("div", { class: "error-text" }, err.message));
|
|
4731
|
+
return;
|
|
4732
|
+
}
|
|
4733
|
+
fill(
|
|
4734
|
+
sysBox,
|
|
4735
|
+
h("h4", {}, "系统"),
|
|
4736
|
+
h("div", {}, `版本 ${sys.version} · Node ${sys.node} · 已运行 ${Math.round(sys.uptimeMs / 60000)} 分钟`),
|
|
4737
|
+
h("div", { class: "muted" }, sys.underSystemd ? "以系统服务运行" : "前台运行(重启需要在终端里操作)"),
|
|
4738
|
+
h(
|
|
4739
|
+
"div",
|
|
4740
|
+
{ class: "row" },
|
|
4741
|
+
h(
|
|
4742
|
+
"button",
|
|
4743
|
+
{
|
|
4744
|
+
class: "btn",
|
|
4745
|
+
disabled: !sys.underSystemd,
|
|
4746
|
+
onclick: async () => {
|
|
4747
|
+
if (!(await confirmDialog({ title: "重启 agent-loft?", message: "进行中的对话会被中断。", confirmText: "重启", danger: true }))) return;
|
|
4748
|
+
try {
|
|
4749
|
+
await api("/api/system/restart", { method: "POST" });
|
|
4750
|
+
toast("正在重启…");
|
|
4751
|
+
setTimeout(() => location.reload(), 5000);
|
|
4752
|
+
} catch (err) {
|
|
4753
|
+
toast(err.message);
|
|
4754
|
+
}
|
|
4755
|
+
},
|
|
4756
|
+
},
|
|
4757
|
+
"重启服务",
|
|
4566
4758
|
),
|
|
4759
|
+
"Notification" in window && Notification.permission !== "granted"
|
|
4760
|
+
? h("button", { class: "btn", onclick: () => Notification.requestPermission() }, "开启浏览器通知")
|
|
4761
|
+
: null,
|
|
4567
4762
|
),
|
|
4568
4763
|
);
|
|
4569
4764
|
}
|