agent-loft 0.2.39 → 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.
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/channels/feishu/bot.ts +1 -1
- package/src/core/command-catalog.ts +1 -1
- package/src/core/feishu-links.ts +1 -1
- package/web/css/mobile.css +19 -29
- package/web/css/remote.css +4 -2
- package/web/css/settings.css +41 -1
- package/web/lib/overlay.js +7 -3
- package/web/settings/agents.js +82 -52
- package/web/settings/commands.js +41 -111
- package/web/settings/common.js +37 -0
- package/web/settings/feishu.js +253 -67
- package/web/settings/general.js +81 -131
- package/web/settings/index.js +108 -49
- package/web/settings/memory.js +3 -78
- package/web/settings/notify.js +13 -0
- package/web/settings/security.js +146 -159
- package/web/settings/users.js +228 -120
- package/web/views/composer.js +21 -0
- package/web/views/mention.js +12 -3
- package/web/views/quota.js +2 -2
- package/web/views/remote.js +10 -0
- package/web/views/sidebar.js +5 -2
- package/web/views/slash.js +7 -2
package/web/settings/users.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
16
|
-
void usersTab(
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
{
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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"
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
const sysBox = h("div", { class: "section" }, h("h4", {}, "
|
|
172
|
-
|
|
173
|
-
|
|
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", {}, "
|
|
306
|
+
fill(sysBox, h("h4", {}, "服务端"), h("div", { class: "error-text" }, err.message));
|
|
178
307
|
return;
|
|
179
308
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
}
|
package/web/views/composer.js
CHANGED
|
@@ -14,6 +14,23 @@ import { openSettings } from "./layout.js";
|
|
|
14
14
|
|
|
15
15
|
// ---------- 输入框 ----------
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* 手机上「/」「@」菜单刚弹出来时收起键盘:不然键盘加菜单把屏幕占满了。
|
|
19
|
+
* 菜单留着,点一条就选;想接着打字筛选就点一下输入框,点别处菜单关掉
|
|
20
|
+
*/
|
|
21
|
+
let keepMenus = false;
|
|
22
|
+
export function hideKeyboardForMenu() {
|
|
23
|
+
if (!isTouch || document.activeElement !== ui.input) return;
|
|
24
|
+
keepMenus = true;
|
|
25
|
+
ui.input.blur();
|
|
26
|
+
}
|
|
27
|
+
document.addEventListener("pointerdown", (e) => {
|
|
28
|
+
if (!keepMenus || [ui.input, ui.slashMenu, ui.mentionMenu].some((el) => el?.contains(e.target))) return;
|
|
29
|
+
keepMenus = false;
|
|
30
|
+
closeSlashMenu();
|
|
31
|
+
ui.mention?.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
17
34
|
export function renderComposer() {
|
|
18
35
|
cancelVoice();
|
|
19
36
|
ui.input = h("textarea", {
|
|
@@ -26,8 +43,11 @@ export function renderComposer() {
|
|
|
26
43
|
void updateSlashMenu();
|
|
27
44
|
void ui.mention.update();
|
|
28
45
|
},
|
|
46
|
+
onfocus: () => (keepMenus = false),
|
|
29
47
|
onblur: () =>
|
|
30
48
|
setTimeout(() => {
|
|
49
|
+
// 为了让出地方收起的键盘(见 hideKeyboardForMenu):菜单留着;点回输入框也不关
|
|
50
|
+
if (keepMenus || document.activeElement === ui.input) return;
|
|
31
51
|
closeSlashMenu();
|
|
32
52
|
ui.mention.close();
|
|
33
53
|
}, 150),
|
|
@@ -72,6 +92,7 @@ export function renderComposer() {
|
|
|
72
92
|
ui.mentionMenu = h("div", { class: "slash-menu", hidden: true });
|
|
73
93
|
ui.mention = attachMention(ui.input, ui.mentionMenu, {
|
|
74
94
|
onEmpty: () => openSettings("技能"),
|
|
95
|
+
onShown: hideKeyboardForMenu,
|
|
75
96
|
});
|
|
76
97
|
ui.composer = h(
|
|
77
98
|
"div",
|
package/web/views/mention.js
CHANGED
|
@@ -40,14 +40,21 @@ function match(list, q) {
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* 给一个输入框挂上 @ 补全。menu 是放菜单的元素(调用方决定放哪、怎么定位,样式用 .slash-menu)。
|
|
43
|
-
* onEmpty
|
|
43
|
+
* onEmpty:一个技能都没有时,菜单里给个入口(去设置里的技能页)。onShown:菜单关着时弹出来、有了内容那一下。返回 { update, keydown, close }:
|
|
44
44
|
* 输入框的 input / keydown / blur 里分别调用。
|
|
45
45
|
*/
|
|
46
|
-
export function attachMention(input, menu, { onEmpty, onPicked } = {}) {
|
|
47
|
-
const st = { open: false, items: [], index: 0, token: 0, at: null };
|
|
46
|
+
export function attachMention(input, menu, { onEmpty, onPicked, onShown } = {}) {
|
|
47
|
+
const st = { open: false, shown: false, items: [], index: 0, token: 0, at: null };
|
|
48
|
+
|
|
49
|
+
const shown = () => {
|
|
50
|
+
if (st.shown) return;
|
|
51
|
+
st.shown = true;
|
|
52
|
+
onShown?.();
|
|
53
|
+
};
|
|
48
54
|
|
|
49
55
|
const close = () => {
|
|
50
56
|
st.open = false;
|
|
57
|
+
st.shown = false;
|
|
51
58
|
st.items = [];
|
|
52
59
|
st.index = 0;
|
|
53
60
|
st.token++;
|
|
@@ -149,6 +156,7 @@ export function attachMention(input, menu, { onEmpty, onPicked } = {}) {
|
|
|
149
156
|
h("div", { class: "slash-desc" }, "去「设置 → 技能」新建、上传"),
|
|
150
157
|
),
|
|
151
158
|
);
|
|
159
|
+
shown();
|
|
152
160
|
return;
|
|
153
161
|
}
|
|
154
162
|
const items = match(list, now.query);
|
|
@@ -156,6 +164,7 @@ export function attachMention(input, menu, { onEmpty, onPicked } = {}) {
|
|
|
156
164
|
st.items = items;
|
|
157
165
|
st.index = Math.min(st.index, items.length - 1);
|
|
158
166
|
render(items);
|
|
167
|
+
shown();
|
|
159
168
|
};
|
|
160
169
|
|
|
161
170
|
/** 菜单开着时接管方向键 / Tab / 回车 / Esc;返回 true 表示已处理 */
|
package/web/views/quota.js
CHANGED
|
@@ -8,11 +8,11 @@ import { desktop, desktopQuota, S, ui } from "../state.js";
|
|
|
8
8
|
// (60 秒内再请求直接返回缓存;Claude 要临时拉一个 CLI 进程才问得到,不能太勤)。
|
|
9
9
|
// 前端什么时候请求:
|
|
10
10
|
// - 打开页面拉一次;
|
|
11
|
-
// - 空闲时每隔 S.quotaRefreshMin 分钟(设置 →
|
|
11
|
+
// - 空闲时每隔 S.quotaRefreshMin 分钟(设置 → Agent → 账号额度,默认 10,0 = 不自动刷新),只在页面可见时;
|
|
12
12
|
// - 有会话跑完一轮:10 秒后拉;若那时服务端缓存还没过期,就推迟到过期之后,保证拿到新数据;
|
|
13
13
|
// - 切回前台:距上次超过一个刷新间隔就拉;
|
|
14
14
|
// - 点一下额度区域:立刻拉(可能拿到 60 秒内的缓存)。
|
|
15
|
-
// 设置 →
|
|
15
|
+
// 设置 → Agent 里关了「查询额度」(S.quotaEnabled):以上全都不做,侧栏也不显示额度。
|
|
16
16
|
// 在桌面端里(desktopQuota)以上全都不做:桌面端统一去查(同一个账号的多台服务器只问一台),
|
|
17
17
|
// 查好推过来;点一下额度区域是请桌面端去刷新。
|
|
18
18
|
|
package/web/views/remote.js
CHANGED
|
@@ -110,9 +110,19 @@ export function leaveRemote() {
|
|
|
110
110
|
// 只在触屏设备上算:电脑上双指缩放网页、出了滚动条时 visualViewport 也会变小,不是键盘
|
|
111
111
|
const vv = window.visualViewport;
|
|
112
112
|
if (vv && isTouch) {
|
|
113
|
+
let last = 0;
|
|
113
114
|
const inset = () => {
|
|
114
115
|
const covered = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
|
|
115
116
|
document.documentElement.style.setProperty("--kb-inset", `${covered}px`);
|
|
117
|
+
// 键盘弹出时 iOS 会把页面(和溢出隐藏的容器)往上推,收起后不一定推回来:
|
|
118
|
+
// 横屏全屏时整页只剩顶上露一截按键。键盘收起时统统滚回原位
|
|
119
|
+
if (last > 0 && covered === 0) {
|
|
120
|
+
window.scrollTo(0, 0);
|
|
121
|
+
for (const el of [document.scrollingElement, ...document.querySelectorAll("#app, .layout, .main, .remote-pane, .remote-body")]) {
|
|
122
|
+
if (el && el.scrollTop) el.scrollTop = 0;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
last = covered;
|
|
116
126
|
};
|
|
117
127
|
vv.addEventListener("resize", inset);
|
|
118
128
|
vv.addEventListener("scroll", inset);
|
package/web/views/sidebar.js
CHANGED
|
@@ -3,6 +3,7 @@ import { api } from "../lib/api.js";
|
|
|
3
3
|
import { desktop, remoteTabs, S, ui } from "../state.js";
|
|
4
4
|
import { baseName } from "./topbar.js";
|
|
5
5
|
import { newDraft, openConversation } from "./messages.js";
|
|
6
|
+
import { leaveRemote } from "./remote.js";
|
|
6
7
|
|
|
7
8
|
// ---------- 侧栏标题跑马灯:名字被省略时,hover 来回滚动显示全名 ----------
|
|
8
9
|
|
|
@@ -420,13 +421,15 @@ export function renderSidebar() {
|
|
|
420
421
|
const open = (key) => {
|
|
421
422
|
closeSide();
|
|
422
423
|
if (key !== S.current) void openConversation(key);
|
|
424
|
+
// 在远程控制页上点进来之前那条对话:对话没动过,退出远程控制就露出来了
|
|
425
|
+
else if (S.view === "remote") leaveRemote();
|
|
423
426
|
};
|
|
424
427
|
// 一行会话。orphan:是分叉出来的、但父对话不在这一组里(删了 / 在别的目录),没有连线可画,用图标标一下
|
|
425
428
|
const convRow = (c, cls, detail, orphan) =>
|
|
426
429
|
h(
|
|
427
430
|
"button",
|
|
428
431
|
{
|
|
429
|
-
class: `conv-item${cls}${c.key === S.current ? " active" : ""}`,
|
|
432
|
+
class: `conv-item${cls}${c.key === S.current && !S.view ? " active" : ""}`,
|
|
430
433
|
"data-key": c.key,
|
|
431
434
|
title: `${convTitle(c)}${forkNote(c)}\n${detail}\n双击重命名,右键更多操作`,
|
|
432
435
|
onclick: () => open(c.key),
|
|
@@ -455,7 +458,7 @@ export function renderSidebar() {
|
|
|
455
458
|
el: h(
|
|
456
459
|
"button",
|
|
457
460
|
{
|
|
458
|
-
class: `conv-item${cur?.channel === "feishu" && cur.channelId === bot.id ? " active" : ""}`,
|
|
461
|
+
class: `conv-item${!S.view && cur?.channel === "feishu" && cur.channelId === bot.id ? " active" : ""}`,
|
|
459
462
|
"data-bot": bot.id,
|
|
460
463
|
title: latest ? `飞书 · ${bot.name}\n${agentLabel(latest.agent)} · ${latest.cwd}` : `飞书 · ${bot.name}\n还没有会话`,
|
|
461
464
|
onclick: () => (latest ? open(latest.key) : toast("这个机器人还没有会话,先在飞书里给它发条消息")),
|
package/web/views/slash.js
CHANGED
|
@@ -3,14 +3,14 @@ import { api } from "../lib/api.js";
|
|
|
3
3
|
import { S, ui } from "../state.js";
|
|
4
4
|
import { agentLabel } from "./sidebar.js";
|
|
5
5
|
import { usableAgents } from "./topbar.js";
|
|
6
|
-
import { autosize, sendMessage, updateSendBtn } from "./composer.js";
|
|
6
|
+
import { autosize, hideKeyboardForMenu, sendMessage, updateSendBtn } from "./composer.js";
|
|
7
7
|
|
|
8
8
|
// ---------- 斜杠命令补全 ----------
|
|
9
9
|
//
|
|
10
10
|
// 输入框里只有「/xxx」(还没打空格)时弹出命令表,顺序就是设置 → 命令里排好的。
|
|
11
11
|
// 列表按 agent + 会话目录缓存一分钟:Claude 的原生命令要起一次进程才问得到。
|
|
12
12
|
|
|
13
|
-
export const slash = { open: false, items: [], index: 0, cache: new Map(), token: 0 };
|
|
13
|
+
export const slash = { open: false, shown: false, items: [], index: 0, cache: new Map(), token: 0 };
|
|
14
14
|
|
|
15
15
|
export function commandUsage(c) {
|
|
16
16
|
return `/${c.name}${c.args ? ` ${c.args}` : ""}`;
|
|
@@ -75,6 +75,10 @@ export async function updateSlashMenu() {
|
|
|
75
75
|
slash.items = items;
|
|
76
76
|
slash.index = Math.min(slash.index, items.length - 1);
|
|
77
77
|
renderSlashMenu(items);
|
|
78
|
+
if (!slash.shown) {
|
|
79
|
+
slash.shown = true;
|
|
80
|
+
hideKeyboardForMenu();
|
|
81
|
+
}
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
function renderSlashMenu(items, error) {
|
|
@@ -126,6 +130,7 @@ function highlightSlash() {
|
|
|
126
130
|
|
|
127
131
|
export function closeSlashMenu() {
|
|
128
132
|
slash.open = false;
|
|
133
|
+
slash.shown = false;
|
|
129
134
|
slash.items = [];
|
|
130
135
|
slash.index = 0;
|
|
131
136
|
slash.token++;
|