agent-loft 0.2.29 → 0.2.30
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/package.json +1 -1
- package/src/channels/web/server.ts +1 -1
- package/web/app.js +8 -5560
- package/web/css/base.css +291 -0
- package/web/css/composer.css +506 -0
- package/web/css/loader.css +196 -0
- package/web/css/messages.css +734 -0
- package/web/css/mobile.css +100 -0
- package/web/css/overlay.css +437 -0
- package/web/css/schedule.css +93 -0
- package/web/css/settings.css +335 -0
- package/web/css/sidebar.css +593 -0
- package/web/css/topbar.css +205 -0
- package/web/index.html +11 -1
- package/web/lib/api.js +26 -0
- package/web/lib/dom.js +101 -0
- package/web/lib/markdown.js +52 -0
- package/web/lib/overlay.js +144 -0
- package/web/lib/push.js +70 -0
- package/web/lib/time.js +60 -0
- package/web/lib/ws.js +232 -0
- package/web/settings/agents.js +210 -0
- package/web/settings/commands.js +411 -0
- package/web/settings/common.js +42 -0
- package/web/settings/feishu.js +181 -0
- package/web/settings/general.js +184 -0
- package/web/settings/index.js +63 -0
- package/web/settings/memory.js +366 -0
- package/web/settings/notify.js +80 -0
- package/web/settings/security.js +188 -0
- package/web/settings/users.js +189 -0
- package/web/state.js +44 -0
- package/web/views/auth.js +61 -0
- package/web/views/composer.js +324 -0
- package/web/views/conv-actions.js +404 -0
- package/web/views/interaction.js +140 -0
- package/web/views/layout.js +186 -0
- package/web/views/messages.js +510 -0
- package/web/views/quota.js +145 -0
- package/web/views/schedule.js +120 -0
- package/web/views/sidebar.js +455 -0
- package/web/views/slash.js +185 -0
- package/web/views/topbar.js +394 -0
- package/web/views/voice.js +189 -0
- package/web/style.css +0 -3500
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { fill, h, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { relTime } from "../lib/time.js";
|
|
4
|
+
import { currentPushSub, enablePush, pushSupport } from "../lib/push.js";
|
|
5
|
+
|
|
6
|
+
export async function notifyTab(panel) {
|
|
7
|
+
const support = pushSupport();
|
|
8
|
+
const box = h("div", { class: "section" }, h("h4", {}, "系统通知"), h("span", { class: "spinner" }));
|
|
9
|
+
fill(panel, box);
|
|
10
|
+
const render = async () => {
|
|
11
|
+
let data = { subscriptions: [] };
|
|
12
|
+
try {
|
|
13
|
+
data = await api("/api/push");
|
|
14
|
+
} catch (err) {
|
|
15
|
+
fill(box, h("h4", {}, "系统通知"), h("div", { class: "error-text" }, err.message));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const mine = await currentPushSub();
|
|
19
|
+
const here = mine && data.subscriptions.some((d) => d.endpoint === mine.endpoint);
|
|
20
|
+
const busy = (btn, fn) => async () => {
|
|
21
|
+
btn.disabled = true;
|
|
22
|
+
try {
|
|
23
|
+
await fn();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
toast(err.message);
|
|
26
|
+
}
|
|
27
|
+
btn.disabled = false;
|
|
28
|
+
await render();
|
|
29
|
+
};
|
|
30
|
+
const onBtn = h("button", { class: "btn primary" }, "在此设备开启通知");
|
|
31
|
+
onBtn.onclick = busy(onBtn, async () => {
|
|
32
|
+
await enablePush();
|
|
33
|
+
toast("已开启,可以发一条测试通知看看");
|
|
34
|
+
});
|
|
35
|
+
const offBtn = h("button", { class: "btn" }, "关闭此设备的通知");
|
|
36
|
+
offBtn.onclick = busy(offBtn, async () => {
|
|
37
|
+
await api("/api/push/unsubscribe", { method: "POST", body: { endpoint: mine.endpoint } });
|
|
38
|
+
await mine.unsubscribe().catch(() => {});
|
|
39
|
+
toast("已关闭");
|
|
40
|
+
});
|
|
41
|
+
const testBtn = h("button", { class: "btn" }, "发送测试通知");
|
|
42
|
+
testBtn.onclick = busy(testBtn, async () => {
|
|
43
|
+
const r = await api("/api/push/test", { method: "POST" });
|
|
44
|
+
toast(`已发送到 ${r.sent} 台设备`);
|
|
45
|
+
});
|
|
46
|
+
fill(
|
|
47
|
+
box,
|
|
48
|
+
h("h4", {}, "系统通知"),
|
|
49
|
+
h("div", { class: "muted" }, "Web 对话里 agent 回复完成、出错、需要授权或向你提问时,推送到开启了通知的设备。正在前台看着那条对话的时候不推。飞书对话由飞书自己提醒。"),
|
|
50
|
+
support.ok
|
|
51
|
+
? h("div", { class: "row" }, here ? offBtn : onBtn, data.subscriptions.length ? testBtn : null)
|
|
52
|
+
: h("div", { class: "error-text" }, support.why),
|
|
53
|
+
support.ok && Notification.permission === "denied"
|
|
54
|
+
? h("div", { class: "error-text" }, "这个设备的通知权限已被拒绝,需要到系统设置(iPhone:设置 → 通知 → Agent Loft)里重新打开。")
|
|
55
|
+
: null,
|
|
56
|
+
h("h4", {}, "已开启通知的设备"),
|
|
57
|
+
data.subscriptions.length === 0
|
|
58
|
+
? h("div", { class: "muted" }, "还没有")
|
|
59
|
+
: data.subscriptions.map((d) => {
|
|
60
|
+
const rm = h("button", { class: "btn small" }, "移除");
|
|
61
|
+
rm.onclick = busy(rm, async () => {
|
|
62
|
+
await api("/api/push/unsubscribe", { method: "POST", body: { endpoint: d.endpoint } });
|
|
63
|
+
if (mine && mine.endpoint === d.endpoint) await mine.unsubscribe().catch(() => {});
|
|
64
|
+
});
|
|
65
|
+
return h(
|
|
66
|
+
"div",
|
|
67
|
+
{ class: "list-item" },
|
|
68
|
+
h(
|
|
69
|
+
"div",
|
|
70
|
+
{ class: "grow" },
|
|
71
|
+
h("div", { class: "t" }, d.label, mine && mine.endpoint === d.endpoint ? "(本设备)" : ""),
|
|
72
|
+
h("div", { class: "s" }, `开启于 ${relTime(d.createdAt)}${d.lastOkAt ? ` · 上次送达 ${relTime(d.lastOkAt)}` : ""}`),
|
|
73
|
+
),
|
|
74
|
+
rm,
|
|
75
|
+
);
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
await render();
|
|
80
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { fill, h, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { relTime } from "../lib/time.js";
|
|
4
|
+
import { isAdmin, S } from "../state.js";
|
|
5
|
+
import { dropPushSub } from "../lib/push.js";
|
|
6
|
+
import { field } from "./common.js";
|
|
7
|
+
import { feishuLinkSection } from "./memory.js";
|
|
8
|
+
|
|
9
|
+
export async function authTab(panel) {
|
|
10
|
+
const [pairs, allowlist] = await Promise.all([api("/api/pairing"), api("/api/claude/allowlist")]);
|
|
11
|
+
const botName = (id) => S.bots.find((b) => b.id === id)?.name ?? id;
|
|
12
|
+
const allowText = h("textarea", { rows: 8 });
|
|
13
|
+
allowText.value = allowlist.join("\n");
|
|
14
|
+
panel.append(
|
|
15
|
+
h(
|
|
16
|
+
"div",
|
|
17
|
+
{ class: "section" },
|
|
18
|
+
h("h4", {}, "待批准的配对"),
|
|
19
|
+
pairs.pending.length === 0
|
|
20
|
+
? h("div", { class: "muted" }, "没有待批准的请求。陌生人给机器人发消息时会出现在这里。")
|
|
21
|
+
: pairs.pending.map(({ channelId, request }) =>
|
|
22
|
+
h(
|
|
23
|
+
"div",
|
|
24
|
+
{ class: "list-item" },
|
|
25
|
+
h("div", { class: "grow" }, h("div", { class: "t" }, `${request.code} · ${botName(channelId)}`), h("div", { class: "s" }, `${request.userId} · ${relTime(request.createdAt)}`)),
|
|
26
|
+
h(
|
|
27
|
+
"button",
|
|
28
|
+
{
|
|
29
|
+
class: "btn small accent",
|
|
30
|
+
onclick: async () => {
|
|
31
|
+
try {
|
|
32
|
+
await api("/api/pairing/approve", { method: "POST", body: { code: request.code } });
|
|
33
|
+
toast("已批准");
|
|
34
|
+
fill(panel, );
|
|
35
|
+
void authTab(panel);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
toast(err.message);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
"批准",
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
h(
|
|
47
|
+
"div",
|
|
48
|
+
{ class: "section" },
|
|
49
|
+
h("h4", {}, "已配对的用户"),
|
|
50
|
+
pairs.approved.length === 0
|
|
51
|
+
? h("div", { class: "muted" }, "无")
|
|
52
|
+
: pairs.approved.map(({ channelId, userId }) =>
|
|
53
|
+
h(
|
|
54
|
+
"div",
|
|
55
|
+
{ class: "list-item" },
|
|
56
|
+
h("div", { class: "grow" }, h("div", { class: "t" }, userId), h("div", { class: "s" }, botName(channelId))),
|
|
57
|
+
h(
|
|
58
|
+
"button",
|
|
59
|
+
{
|
|
60
|
+
class: "btn small danger",
|
|
61
|
+
onclick: async () => {
|
|
62
|
+
await api("/api/pairing/revoke", { method: "POST", body: { channelId, userId } });
|
|
63
|
+
fill(panel, );
|
|
64
|
+
void authTab(panel);
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
"撤销",
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
h(
|
|
73
|
+
"div",
|
|
74
|
+
{ class: "section" },
|
|
75
|
+
h("h4", {}, "Claude「总是允许」名单"),
|
|
76
|
+
h("div", { class: "muted" }, "点过「总是允许」的会记在这里。Bash:git 表示 git 开头的命令都放行;Edit:/路径 表示该目录下的修改都放行。一行一条,删掉即撤销。"),
|
|
77
|
+
allowText,
|
|
78
|
+
h(
|
|
79
|
+
"div",
|
|
80
|
+
{ class: "row" },
|
|
81
|
+
h(
|
|
82
|
+
"button",
|
|
83
|
+
{
|
|
84
|
+
class: "btn primary",
|
|
85
|
+
onclick: async () => {
|
|
86
|
+
await api("/api/claude/allowlist", { method: "PUT", body: { list: allowText.value.split("\n") } });
|
|
87
|
+
toast("已保存");
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
"保存",
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function securityTab(panel) {
|
|
98
|
+
const old = h("input", { type: "password", autocomplete: "current-password" });
|
|
99
|
+
const pw = h("input", { type: "password", autocomplete: "new-password" });
|
|
100
|
+
const sessions = await api("/api/web-sessions");
|
|
101
|
+
// 管理员看到的是所有人的设备:不止一个人时标上是谁的
|
|
102
|
+
const owners = new Set(sessions.map((s) => s.userId));
|
|
103
|
+
const showOwner = isAdmin() && owners.size > 1;
|
|
104
|
+
panel.append(
|
|
105
|
+
S.user
|
|
106
|
+
? h(
|
|
107
|
+
"div",
|
|
108
|
+
{ class: "section" },
|
|
109
|
+
h("h4", {}, "当前账号"),
|
|
110
|
+
h("div", {}, S.user.name, " ", h("span", { class: "status-tag" }, S.user.role === "admin" ? "管理员" : "成员")),
|
|
111
|
+
)
|
|
112
|
+
: null,
|
|
113
|
+
h(
|
|
114
|
+
"div",
|
|
115
|
+
{ class: "section" },
|
|
116
|
+
h("h4", {}, "修改登录密码"),
|
|
117
|
+
field("原密码", old),
|
|
118
|
+
field("新密码(至少 8 位)", pw),
|
|
119
|
+
h(
|
|
120
|
+
"div",
|
|
121
|
+
{ class: "row" },
|
|
122
|
+
h(
|
|
123
|
+
"button",
|
|
124
|
+
{
|
|
125
|
+
class: "btn primary",
|
|
126
|
+
onclick: async () => {
|
|
127
|
+
try {
|
|
128
|
+
await api("/api/password", { method: "POST", body: { old: old.value, password: pw.value } });
|
|
129
|
+
toast("密码已修改,你的其它设备已下线");
|
|
130
|
+
old.value = pw.value = "";
|
|
131
|
+
} catch (err) {
|
|
132
|
+
toast(err.message);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
"修改",
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
feishuLinkSection(),
|
|
141
|
+
h(
|
|
142
|
+
"div",
|
|
143
|
+
{ class: "section" },
|
|
144
|
+
h("h4", {}, showOwner ? "已登录的设备(所有用户)" : "已登录的设备"),
|
|
145
|
+
sessions.map((s) =>
|
|
146
|
+
h(
|
|
147
|
+
"div",
|
|
148
|
+
{ class: "list-item" },
|
|
149
|
+
h(
|
|
150
|
+
"div",
|
|
151
|
+
{ class: "grow" },
|
|
152
|
+
h("div", { class: "t" }, s.userAgent.slice(0, 80) || "未知设备"),
|
|
153
|
+
h("div", { class: "s" }, `${showOwner ? `${s.userName ?? "已删除的用户"} · ` : ""}${s.ip} · 最近活动 ${relTime(s.lastSeen)}`),
|
|
154
|
+
),
|
|
155
|
+
h(
|
|
156
|
+
"button",
|
|
157
|
+
{
|
|
158
|
+
class: "btn small danger",
|
|
159
|
+
onclick: async () => {
|
|
160
|
+
await api("/api/web-sessions/revoke", { method: "POST", body: { id: s.id } });
|
|
161
|
+
fill(panel, );
|
|
162
|
+
void securityTab(panel);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
"下线",
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
h(
|
|
171
|
+
"div",
|
|
172
|
+
{ class: "row" },
|
|
173
|
+
h(
|
|
174
|
+
"button",
|
|
175
|
+
{
|
|
176
|
+
class: "btn danger",
|
|
177
|
+
onclick: async () => {
|
|
178
|
+
// 退出了就别再收这个账号的通知:换个人在这台设备上登录,不能收到上一个人的
|
|
179
|
+
await dropPushSub().catch(() => {});
|
|
180
|
+
await api("/api/logout", { method: "POST" });
|
|
181
|
+
location.reload();
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
"退出登录",
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { fill, h, toast } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
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";
|
|
7
|
+
|
|
8
|
+
// ---------- 用户(管理员) ----------
|
|
9
|
+
|
|
10
|
+
export async function usersTab(panel) {
|
|
11
|
+
const box = h("div", { class: "section" }, h("h4", {}, "用户"), h("span", { class: "spinner" }));
|
|
12
|
+
const addBox = h("div", { class: "section" });
|
|
13
|
+
fill(panel, box, addBox);
|
|
14
|
+
const reload = () => {
|
|
15
|
+
fill(panel);
|
|
16
|
+
void usersTab(panel);
|
|
17
|
+
};
|
|
18
|
+
let data;
|
|
19
|
+
try {
|
|
20
|
+
data = await api("/api/users");
|
|
21
|
+
} catch (err) {
|
|
22
|
+
fill(box, h("h4", {}, "用户"), h("div", { class: "error-text" }, err.message));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const act = (btn, fn) => async () => {
|
|
26
|
+
btn.disabled = true;
|
|
27
|
+
try {
|
|
28
|
+
if ((await fn()) !== false) reload();
|
|
29
|
+
} catch (err) {
|
|
30
|
+
toast(err.message);
|
|
31
|
+
}
|
|
32
|
+
btn.disabled = false;
|
|
33
|
+
};
|
|
34
|
+
const patch = (u, body) => api(`/api/users/${encodeURIComponent(u.id)}`, { method: "PATCH", body });
|
|
35
|
+
|
|
36
|
+
/** 重置密码:就地展开一个输入框 */
|
|
37
|
+
const resetRow = (u, holder) => {
|
|
38
|
+
const pw = h("input", { type: "password", autocomplete: "new-password", placeholder: "新密码(至少 8 位)" });
|
|
39
|
+
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));
|
|
45
|
+
pw.focus();
|
|
46
|
+
};
|
|
47
|
+
|
|
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
|
+
disBtn.onclick = act(disBtn, () => patch(u, { disabled: !u.disabled }));
|
|
70
|
+
const memBtn = h("button", { class: "btn small", title: "删除他的「关于我」和所有记住的事。管理员看不到记忆的内容" }, "清空记忆");
|
|
71
|
+
memBtn.onclick = act(memBtn, async () => {
|
|
72
|
+
const sure = await confirmDialog({
|
|
73
|
+
title: `清空「${u.name}」的记忆?`,
|
|
74
|
+
message: "他的「关于我」和所有记住的事都会删除,不能恢复。",
|
|
75
|
+
confirmText: "清空",
|
|
76
|
+
danger: true,
|
|
77
|
+
});
|
|
78
|
+
if (!sure) return false;
|
|
79
|
+
await api(`/api/users/${encodeURIComponent(u.id)}/memory`, { method: "DELETE" });
|
|
80
|
+
toast("已清空");
|
|
81
|
+
});
|
|
82
|
+
const linkBtn = u.feishuLinks ? h("button", { class: "btn small" }, "解除飞书绑定") : null;
|
|
83
|
+
if (linkBtn) {
|
|
84
|
+
linkBtn.onclick = act(linkBtn, async () => {
|
|
85
|
+
await api(`/api/users/${encodeURIComponent(u.id)}/feishu`, { method: "DELETE" });
|
|
86
|
+
toast("已解除,他和机器人的私聊交还给管理员");
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const rmBtn = h("button", { class: "btn small danger", disabled: self }, "删除");
|
|
90
|
+
rmBtn.onclick = act(rmBtn, async () => {
|
|
91
|
+
const sure = await confirmDialog({
|
|
92
|
+
title: `删除用户「${u.name}」?`,
|
|
93
|
+
message: "他的设备会立刻下线,记忆和飞书绑定一起删除。他的对话不会删除,之后归管理员。",
|
|
94
|
+
confirmText: "删除",
|
|
95
|
+
danger: true,
|
|
96
|
+
});
|
|
97
|
+
if (!sure) return false;
|
|
98
|
+
await api(`/api/users/${encodeURIComponent(u.id)}`, { method: "DELETE" });
|
|
99
|
+
toast("已删除");
|
|
100
|
+
});
|
|
101
|
+
return h(
|
|
102
|
+
"div",
|
|
103
|
+
{ class: "list-item", style: { flexWrap: "wrap" } },
|
|
104
|
+
h(
|
|
105
|
+
"div",
|
|
106
|
+
{ class: "grow" },
|
|
107
|
+
h(
|
|
108
|
+
"div",
|
|
109
|
+
{ class: "t" },
|
|
110
|
+
u.name,
|
|
111
|
+
self ? "(你)" : "",
|
|
112
|
+
" ",
|
|
113
|
+
h("span", { class: "status-tag" }, u.role === "admin" ? "管理员" : "成员"),
|
|
114
|
+
u.disabled ? h("span", { class: "status-tag bad" }, "已停用") : null,
|
|
115
|
+
),
|
|
116
|
+
h("div", { class: "s" }, `创建于 ${relTime(u.createdAt)} · ${u.devices} 台设备已登录${u.feishuLinks ? ` · 绑定了 ${u.feishuLinks} 个飞书账号` : ""}`),
|
|
117
|
+
extra,
|
|
118
|
+
),
|
|
119
|
+
pwBtn,
|
|
120
|
+
roleBtn,
|
|
121
|
+
disBtn,
|
|
122
|
+
memBtn,
|
|
123
|
+
linkBtn,
|
|
124
|
+
rmBtn,
|
|
125
|
+
);
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const name = h("input", { autocomplete: "off", autocapitalize: "off", spellcheck: "false", placeholder: "登录用的名字" });
|
|
130
|
+
const pw = h("input", { type: "password", autocomplete: "new-password", placeholder: "至少 8 位" });
|
|
131
|
+
const role = h("select", {}, h("option", { value: "member" }, "成员"), h("option", { value: "admin" }, "管理员"));
|
|
132
|
+
const add = h("button", { class: "btn primary" }, "添加");
|
|
133
|
+
add.onclick = act(add, async () => {
|
|
134
|
+
await api("/api/users", { method: "POST", body: { name: name.value.trim(), password: pw.value, role: role.value } });
|
|
135
|
+
toast(`已添加「${name.value.trim()}」`);
|
|
136
|
+
});
|
|
137
|
+
fill(
|
|
138
|
+
addBox,
|
|
139
|
+
h("h4", {}, "添加用户"),
|
|
140
|
+
field("用户名", name),
|
|
141
|
+
field("密码", pw, "告诉对方,让他登录后在 设置 → 账号 里自己改掉"),
|
|
142
|
+
field("角色", role, "成员只能用自己的对话;管理员能改所有设置、管理用户"),
|
|
143
|
+
h("div", { class: "row" }, add),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function systemTab(cfg, panel) {
|
|
148
|
+
// 系统信息要等接口返回,先占好位置,版本和网络访问两节不用等它
|
|
149
|
+
const sysBox = h("div", { class: "section" }, h("h4", {}, "系统", h("span", { class: "grow" }), h("span", { class: "spinner" })));
|
|
150
|
+
panel.append(sysBox, serverVersionSection(), networkSection(cfg));
|
|
151
|
+
let sys;
|
|
152
|
+
try {
|
|
153
|
+
sys = await api("/api/system");
|
|
154
|
+
} catch (err) {
|
|
155
|
+
fill(sysBox, h("h4", {}, "系统"), h("div", { class: "error-text" }, err.message));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
fill(
|
|
159
|
+
sysBox,
|
|
160
|
+
h("h4", {}, "系统"),
|
|
161
|
+
h("div", {}, `版本 ${sys.version} · Node ${sys.node} · 已运行 ${Math.round(sys.uptimeMs / 60000)} 分钟`),
|
|
162
|
+
h("div", { class: "muted" }, sys.underSystemd ? "以系统服务运行" : "前台运行(重启需要在终端里操作)"),
|
|
163
|
+
h(
|
|
164
|
+
"div",
|
|
165
|
+
{ class: "row" },
|
|
166
|
+
h(
|
|
167
|
+
"button",
|
|
168
|
+
{
|
|
169
|
+
class: "btn",
|
|
170
|
+
disabled: !sys.underSystemd,
|
|
171
|
+
onclick: async () => {
|
|
172
|
+
if (!(await confirmDialog({ title: "重启 agent-loft?", message: "进行中的对话会被中断。", confirmText: "重启", danger: true }))) return;
|
|
173
|
+
try {
|
|
174
|
+
await api("/api/system/restart", { method: "POST" });
|
|
175
|
+
toast("正在重启…");
|
|
176
|
+
setTimeout(() => location.reload(), 5000);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
toast(err.message);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
"重启服务",
|
|
183
|
+
),
|
|
184
|
+
"Notification" in window && Notification.permission !== "granted"
|
|
185
|
+
? h("button", { class: "btn", onclick: () => Notification.requestPermission() }, "开启浏览器通知")
|
|
186
|
+
: null,
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
}
|
package/web/state.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// ---------- 状态 ----------
|
|
2
|
+
|
|
3
|
+
export const S = {
|
|
4
|
+
agents: [],
|
|
5
|
+
bots: [],
|
|
6
|
+
convs: new Map(),
|
|
7
|
+
pending: new Map(),
|
|
8
|
+
current: null,
|
|
9
|
+
draftAgent: localStorage.getItem("aim.agent") || null,
|
|
10
|
+
/** 新对话用聊天模式还是工作模式,记住上次的选择 */
|
|
11
|
+
draftMode: localStorage.getItem("aim.mode") === "chat" ? "chat" : "work",
|
|
12
|
+
/** 新对话(还没发消息)选的工作目录,null 表示工作区根目录。开新对话时沿用上次选的 */
|
|
13
|
+
draftCwd: null,
|
|
14
|
+
items: [],
|
|
15
|
+
turnAi: null,
|
|
16
|
+
seq: new Map(),
|
|
17
|
+
uploads: [],
|
|
18
|
+
busy: false,
|
|
19
|
+
allowedAgents: [],
|
|
20
|
+
search: "",
|
|
21
|
+
loadToken: 0,
|
|
22
|
+
/** 登录的用户 { id, name, role } */
|
|
23
|
+
user: null,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const isAdmin = () => S.user?.role === "admin";
|
|
27
|
+
|
|
28
|
+
export const ui = {};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 运行在 macOS 桌面端(agent-loft-desktop)里时,由它的 preload 注入。
|
|
32
|
+
* 桌面端收不到 Web Push,系统通知、Dock 角标、菜单栏的待处理数都交给它。
|
|
33
|
+
*/
|
|
34
|
+
export const desktop = window.agentLoftDesktop ?? null;
|
|
35
|
+
/** 新版桌面端:额度由它统一查好推过来,页面自己不查(老版本桌面端没有 onQuota,照旧自己查) */
|
|
36
|
+
export const desktopQuota = typeof desktop?.onQuota === "function";
|
|
37
|
+
// 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 css/topbar.css 的 .in-desktop)
|
|
38
|
+
if (desktop) document.documentElement.classList.add("in-desktop");
|
|
39
|
+
/**
|
|
40
|
+
* 新版桌面端顶栏的「更多」里能打开每台服务器的设置:侧栏底部的设置按钮就不显示了(额度照常显示,数据由桌面端推)。
|
|
41
|
+
* 老版本桌面端没有这个入口,照旧显示,免得设置打不开。
|
|
42
|
+
*/
|
|
43
|
+
export const desktopSettings = typeof desktop?.onOpenSettings === "function";
|
|
44
|
+
if (desktopSettings) document.documentElement.classList.add("desktop-settings");
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { $app, fill, h } from "../lib/dom.js";
|
|
2
|
+
import { api } from "../lib/api.js";
|
|
3
|
+
import { WS } from "../lib/ws.js";
|
|
4
|
+
import { boot } from "./layout.js";
|
|
5
|
+
|
|
6
|
+
// ---------- 登录 ----------
|
|
7
|
+
|
|
8
|
+
export async function showAuth() {
|
|
9
|
+
WS.stopped = true;
|
|
10
|
+
WS.sock?.close();
|
|
11
|
+
let me = { needsSetup: false };
|
|
12
|
+
try {
|
|
13
|
+
me = await api("/api/me");
|
|
14
|
+
} catch {
|
|
15
|
+
// 网络问题时按登录处理
|
|
16
|
+
}
|
|
17
|
+
const err = h("div", { class: "error-text" });
|
|
18
|
+
const pw = h("input", { type: "password", placeholder: "密码", autocomplete: me.needsSetup ? "new-password" : "current-password" });
|
|
19
|
+
const token = h("input", { placeholder: "设置码(在启动日志里)", autocomplete: "off" });
|
|
20
|
+
// 只有一个用户时和以前一样只要密码;多个用户才要填用户名
|
|
21
|
+
const askName = me.needsSetup || me.multiUser;
|
|
22
|
+
const name = h("input", {
|
|
23
|
+
placeholder: me.needsSetup ? "admin" : "用户名",
|
|
24
|
+
autocomplete: "username",
|
|
25
|
+
autocapitalize: "off",
|
|
26
|
+
spellcheck: "false",
|
|
27
|
+
});
|
|
28
|
+
const btn = h("button", { class: "btn primary", type: "submit" }, me.needsSetup ? "设置密码并进入" : "登录");
|
|
29
|
+
const form = h(
|
|
30
|
+
"form",
|
|
31
|
+
{
|
|
32
|
+
class: "auth-card",
|
|
33
|
+
onsubmit: async (e) => {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
err.textContent = "";
|
|
36
|
+
btn.disabled = true;
|
|
37
|
+
try {
|
|
38
|
+
if (me.needsSetup) {
|
|
39
|
+
await api("/api/setup", { method: "POST", body: { token: token.value, name: name.value.trim() || "admin", password: pw.value } });
|
|
40
|
+
} else {
|
|
41
|
+
await api("/api/login", { method: "POST", body: { ...(askName ? { name: name.value.trim() } : {}), password: pw.value } });
|
|
42
|
+
}
|
|
43
|
+
await boot();
|
|
44
|
+
} catch (e2) {
|
|
45
|
+
err.textContent = e2.message;
|
|
46
|
+
} finally {
|
|
47
|
+
btn.disabled = false;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
h("h1", {}, "Agent Loft"),
|
|
52
|
+
h("p", {}, me.needsSetup ? "首次使用:输入启动日志里的设置码,建一个管理员账号(密码至少 8 位)" : "登录以继续"),
|
|
53
|
+
me.needsSetup ? h("label", { class: "field" }, h("span", {}, "设置码"), token) : null,
|
|
54
|
+
askName ? h("label", { class: "field" }, h("span", {}, me.needsSetup ? "管理员用户名" : "用户名"), name) : null,
|
|
55
|
+
h("label", { class: "field" }, h("span", {}, me.needsSetup ? "新密码" : "密码"), pw),
|
|
56
|
+
btn,
|
|
57
|
+
err,
|
|
58
|
+
);
|
|
59
|
+
fill($app, h("div", { class: "auth" }, form));
|
|
60
|
+
(me.needsSetup ? token : askName ? name : pw).focus();
|
|
61
|
+
}
|