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.
@@ -40,3 +40,40 @@ export async function saveConfig(cfg, patch) {
40
40
  export function field(label, input, hint) {
41
41
  return h("label", { class: "field" }, h("span", {}, label), input, hint ? h("small", {}, hint) : null);
42
42
  }
43
+
44
+ // ---------- 要点「保存」的表单 ----------
45
+ // 容器上标 data-form,「保存」用 saveButton()。里面的输入框改过(index.js 在整页上统一监听 input / change),
46
+ // 保存按钮才能点;还没保存就切页、关设置时先问一句。改了马上生效的开关(记忆页那些)不放进 data-form。
47
+ // 不想算作改动的输入框(筛选框之类)标 data-nodirty。
48
+
49
+ export function markDirty(form) {
50
+ if (!form) return;
51
+ form.dataset.dirty = "1";
52
+ for (const b of form.querySelectorAll(".save-btn")) b.disabled = false;
53
+ }
54
+
55
+ export function markClean(form) {
56
+ if (!form) return;
57
+ delete form.dataset.dirty;
58
+ for (const b of form.querySelectorAll(".save-btn")) b.disabled = true;
59
+ }
60
+
61
+ export const hasDirty = (root) => !!root.querySelector("[data-form][data-dirty]");
62
+
63
+ /** onSave 返回 false(或抛错)= 没存成,按钮保持可点 */
64
+ export function saveButton(onSave, label = "保存") {
65
+ const btn = h("button", { class: "btn primary save-btn", disabled: true }, label);
66
+ btn.onclick = async () => {
67
+ const form = btn.closest("[data-form]");
68
+ btn.disabled = true;
69
+ let ok = false;
70
+ try {
71
+ ok = (await onSave()) !== false;
72
+ } catch (err) {
73
+ toast(err.message);
74
+ }
75
+ if (ok) markClean(form);
76
+ else btn.disabled = false;
77
+ };
78
+ return btn;
79
+ }
@@ -1,35 +1,77 @@
1
1
  import { fill, h, toast } from "../lib/dom.js";
2
2
  import { api } from "../lib/api.js";
3
+ import { relTime } from "../lib/time.js";
3
4
  import { S } from "../state.js";
4
5
  import { confirmDialog, modal } from "../lib/overlay.js";
5
- import { field, saveConfig } from "./common.js";
6
+ import { field, markDirty, saveButton, saveConfig } from "./common.js";
7
+
8
+ /**
9
+ * 设置 → 飞书:机器人、谁能用(配对请求、已配对的人)、指令面板同步,都是飞书这一侧的事,放在一页。
10
+ * 每个机器人一张表单各自保存;存了一个,其它机器人没保存的改动原样留着(表单按机器人缓存,不重建)。
11
+ */
12
+ export function feishuTab(cfg, page) {
13
+ /** 已经存进配置的机器人(以服务端为准) + 手动添加、还没保存的 */
14
+ let saved = structuredClone(cfg.feishu.bots);
15
+ let drafts = [];
16
+ const forms = new Map();
17
+ const statusTags = new Map();
18
+ const botsBox = h("div", { class: "settings-stack" });
19
+ const pairBox = h("div", { class: "settings-stack" });
20
+ const slashBox = h("div");
21
+
22
+ const refresh = () => {
23
+ saved = structuredClone(cfg.feishu.bots);
24
+ render();
25
+ };
26
+ /** 把 next(整个机器人列表)存下去;成功后以服务端返回的为准 */
27
+ const persist = async (next) => {
28
+ if (!(await saveConfig(cfg, { feishu: { bots: next } }))) return false;
29
+ refresh();
30
+ void loadStatus();
31
+ return true;
32
+ };
6
33
 
7
- export function feishuTab(cfg, panel) {
8
- const bots = structuredClone(cfg.feishu.bots);
9
- const statusBox = new Map();
10
34
  const render = () => {
11
- fill(panel,
35
+ const all = [...saved, ...drafts];
36
+ // 已删掉的机器人,表单也扔掉
37
+ const keep = new Set(all.map((b) => b._draft ?? b.id));
38
+ for (const k of forms.keys()) if (!keep.has(k)) forms.delete(k);
39
+ fill(
40
+ botsBox,
12
41
  h(
13
42
  "div",
14
- { class: "row" },
15
- h("button", { class: "btn accent", onclick: () => registerBot() }, "扫码创建新机器人"),
43
+ { class: "section" },
44
+ h("h4", {}, "飞书机器人"),
45
+ h("div", { class: "muted" }, "一个机器人可以对应一个 agent,也可以在会话里用 /agent 切换。扫码创建会自动建好飞书应用;Lark 国际版请用「手动添加」,填开发者后台里的 App ID 和 App Secret。"),
16
46
  h(
17
- "button",
18
- {
19
- class: "btn",
20
- onclick: () => {
21
- bots.push({ id: "", name: "新机器人", enabled: true, appId: "", appSecret: "", domain: "feishu", allowFrom: [], agents: ["claude"], defaultAgent: "claude", _new: true });
22
- render();
47
+ "div",
48
+ { class: "row" },
49
+ h("button", { class: "btn accent", onclick: () => registerBot() }, "扫码创建新机器人"),
50
+ h(
51
+ "button",
52
+ {
53
+ class: "btn",
54
+ onclick: () => {
55
+ drafts.push({ _draft: Symbol("draft"), id: "", name: "新机器人", enabled: true, appId: "", appSecret: "", domain: "feishu", allowFrom: [], agents: ["claude"], defaultAgent: "claude" });
56
+ render();
57
+ botsBox.lastElementChild?.scrollIntoView({ behavior: "smooth", block: "nearest" });
58
+ },
23
59
  },
24
- },
25
- "手动添加",
60
+ "手动添加",
61
+ ),
26
62
  ),
63
+ all.length === 0 ? h("div", { class: "muted" }, "还没有飞书机器人。") : null,
27
64
  ),
28
- ...bots.map((b, i) => botSection(b, i)),
29
- bots.length === 0 ? h("div", { class: "muted" }, "还没有飞书机器人。") : null,
65
+ all.map((b) => {
66
+ const key = b._draft ?? b.id;
67
+ if (!forms.has(key)) forms.set(key, botSection(b));
68
+ return forms.get(key);
69
+ }),
30
70
  );
31
71
  };
32
- const botSection = (b, i) => {
72
+
73
+ const botSection = (b) => {
74
+ const isDraft = !!b._draft;
33
75
  const name = h("input", { value: b.name });
34
76
  const appId = h("input", { value: b.appId, placeholder: "cli_xxx" });
35
77
  const secret = h("input", { value: b.appSecret, placeholder: "App Secret", type: "password", autocomplete: "off" });
@@ -42,45 +84,54 @@ export function feishuTab(cfg, panel) {
42
84
  return { id: a.id, box, el: h("label", { class: "check" }, box, a.label) };
43
85
  });
44
86
  const def = h("select", {}, S.agents.map((a) => h("option", { value: a.id, selected: a.id === b.defaultAgent }, a.label)));
45
- const status = h("span", { class: "status-tag" }, "…");
46
- statusBox.set(b.id, status);
47
- const collect = () => ({
48
- ...b,
49
- name: name.value.trim(),
50
- appId: appId.value.trim(),
51
- appSecret: secret.value.trim(),
52
- domain: domain.value,
53
- allowFrom: allow.value.split(/[\s,]+/).filter(Boolean),
54
- enabled: enabled.checked,
55
- agents: agentChecks.filter((c) => c.box.checked).map((c) => c.id),
56
- defaultAgent: def.value,
57
- });
58
- return h(
87
+ const status = h("span", { class: "status-tag" }, b.enabled ? "…" : "未启用");
88
+ if (!isDraft) statusTags.set(b.id, status);
89
+ const collect = () => {
90
+ const { _draft, ...rest } = b;
91
+ return {
92
+ ...rest,
93
+ name: name.value.trim(),
94
+ appId: appId.value.trim(),
95
+ appSecret: secret.value.trim(),
96
+ domain: domain.value,
97
+ allowFrom: allow.value.split(/[\s,]+/).filter(Boolean),
98
+ enabled: enabled.checked,
99
+ agents: agentChecks.filter((c) => c.box.checked).map((c) => c.id),
100
+ defaultAgent: def.value,
101
+ };
102
+ };
103
+ const save = async () => {
104
+ const v = collect();
105
+ if (!v.agents.length) {
106
+ toast("至少勾选一个 agent");
107
+ return false;
108
+ }
109
+ // 只存这一个:其它机器人按服务端现在的样子原样带上,别的草稿不跟着存
110
+ const next = isDraft ? [...saved, v] : saved.map((x) => (x.id === b.id ? v : x));
111
+ if (isDraft) drafts = drafts.filter((d) => d !== b);
112
+ const ok = await persist(next);
113
+ if (!ok && isDraft) drafts.push(b);
114
+ // 存好的机器人表单要换成新的(标题、连接状态)
115
+ if (ok) {
116
+ forms.delete(b._draft ?? b.id);
117
+ render();
118
+ }
119
+ return ok;
120
+ };
121
+ const sec = h(
59
122
  "div",
60
- { class: "section" },
61
- h("h4", {}, b.name || "机器人", b._new ? null : status, h("span", { class: "grow" }), h("label", { class: "check" }, enabled, "启用")),
123
+ { class: "section", "data-form": "" },
124
+ h("h4", {}, b.name || "机器人", isDraft ? h("span", { class: "status-tag" }, "未保存") : status, h("span", { class: "grow" }), h("label", { class: "check" }, enabled, "启用")),
62
125
  field("名称", name),
63
126
  h("div", { class: "row" }, h("div", { style: { flex: 1 } }, field("App ID", appId)), h("div", { style: { flex: 1 } }, field("App Secret", secret))),
64
127
  field("域名", domain),
65
128
  field("可用的 Agent", h("div", { class: "row" }, agentChecks.map((c) => c.el)), "勾一个 = 一个机器人对应一个 agent;勾多个可在会话里用 /agent 切换"),
66
129
  field("默认 Agent", def),
67
- field("Owner 白名单(open_id)", allow, "陌生人发消息会拿到配对码,在「授权」里批准即可,不必手填"),
130
+ field("Owner 白名单(open_id)", allow, "陌生人发消息会拿到配对码,在下面「配对请求」里批准即可,不必手填"),
68
131
  h(
69
132
  "div",
70
133
  { class: "row" },
71
- h(
72
- "button",
73
- {
74
- class: "btn primary",
75
- onclick: async () => {
76
- bots[i] = collect();
77
- delete bots[i]._new;
78
- await saveConfig(cfg, { feishu: { bots } });
79
- feishuTab(cfg, (fill(panel, ), panel));
80
- },
81
- },
82
- "保存",
83
- ),
134
+ saveButton(save),
84
135
  h(
85
136
  "button",
86
137
  {
@@ -97,22 +148,30 @@ export function feishuTab(cfg, panel) {
97
148
  },
98
149
  "测试凭据",
99
150
  ),
151
+ h("span", { class: "grow" }),
100
152
  h(
101
153
  "button",
102
154
  {
103
155
  class: "btn danger",
104
156
  onclick: async () => {
105
- if (!(await confirmDialog({ title: `删除机器人「${b.name}」?`, confirmText: "删除", danger: true }))) return;
106
- bots.splice(i, 1);
107
- if (!b._new) await saveConfig(cfg, { feishu: { bots } });
108
- render();
157
+ if (isDraft) {
158
+ drafts = drafts.filter((d) => d !== b);
159
+ render();
160
+ return;
161
+ }
162
+ if (!(await confirmDialog({ title: `删除机器人「${b.name}」?`, message: "机器人会立刻停止,飞书那边的应用不会删除。", confirmText: "删除", danger: true }))) return;
163
+ await persist(saved.filter((x) => x.id !== b.id));
109
164
  },
110
165
  },
111
- "删除",
166
+ isDraft ? "取消" : "删除",
112
167
  ),
113
168
  ),
114
169
  );
170
+ // 新加的还没存过:一开始就能点保存,离开时也会提醒
171
+ if (isDraft) markDirty(sec);
172
+ return sec;
115
173
  };
174
+
116
175
  const registerBot = async () => {
117
176
  const nameInput = h("input", { value: "Agent 助手" });
118
177
  const checks = S.agents.filter((a) => a.enabled).map((a) => ({ id: a.id, box: h("input", { type: "checkbox", checked: true }), label: a.label }));
@@ -133,7 +192,8 @@ export function feishuTab(cfg, panel) {
133
192
  body: { name: nameInput.value, agents: checks.filter((c) => c.box.checked).map((c) => c.id) },
134
193
  });
135
194
  const state = h("div", { class: "muted", style: { textAlign: "center" } }, "等待扫码确认…");
136
- fill(body,
195
+ fill(
196
+ body,
137
197
  h("div", { class: "qr", html: reg.qrSvg }),
138
198
  h("div", { class: "row", style: { justifyContent: "center" } }, h("a", { href: reg.qrUrl, target: "_blank", rel: "noopener" }, "手机上直接打开链接")),
139
199
  state,
@@ -149,8 +209,11 @@ export function feishuTab(cfg, panel) {
149
209
  closeReg();
150
210
  const fresh = await api("/api/config");
151
211
  Object.assign(cfg, fresh);
212
+ // 顺带刷新侧栏、顶栏里的机器人列表
152
213
  await saveConfig(cfg, {});
153
- feishuTab(cfg, (fill(panel, ), panel));
214
+ refresh();
215
+ void loadStatus();
216
+ void loadPairing();
154
217
  return;
155
218
  }
156
219
  state.textContent = { access_denied: "已拒绝授权", expired: "二维码已过期", timeout: "超时了" }[r.status] ?? `出错:${r.message ?? r.status}`;
@@ -165,17 +228,140 @@ export function feishuTab(cfg, panel) {
165
228
  }
166
229
  };
167
230
  };
168
- render();
169
- api("/api/feishu/status")
170
- .then((list) => {
171
- for (const s of list) {
172
- const el = statusBox.get(s.id);
173
- if (!el) continue;
174
- const label = { connected: "已连接", connecting: "连接中", reconnecting: "重连中", failed: "连接失败", stopped: "已停止" }[s.state] ?? s.state;
175
- el.textContent = label;
176
- el.className = `status-tag ${s.state === "connected" ? "ok" : s.state === "failed" ? "bad" : ""}`;
177
- if (s.error) el.title = s.error;
231
+
232
+ const loadStatus = () =>
233
+ api("/api/feishu/status")
234
+ .then((list) => {
235
+ for (const s of list) {
236
+ const el = statusTags.get(s.id);
237
+ if (!el) continue;
238
+ el.textContent = { connected: "已连接", connecting: "连接中", reconnecting: "重连中", failed: "连接失败", stopped: "已停止" }[s.state] ?? s.state;
239
+ el.className = `status-tag ${s.state === "connected" ? "ok" : s.state === "failed" ? "bad" : ""}`;
240
+ if (s.error) el.title = s.error;
241
+ }
242
+ })
243
+ .catch(() => {});
244
+
245
+ // ---- 配对:陌生人发消息拿到配对码,在这里批准 ----
246
+ const loadPairing = async () => {
247
+ let pairs;
248
+ try {
249
+ pairs = await api("/api/pairing");
250
+ } catch (err) {
251
+ fill(pairBox, h("div", { class: "section" }, h("h4", {}, "配对请求"), h("div", { class: "error-text" }, err.message)));
252
+ return;
253
+ }
254
+ // 没有机器人、也没有配对过的人:这两节用不上
255
+ if (!cfg.feishu.bots.length && !pairs.pending.length && !pairs.approved.length) return fill(pairBox);
256
+ const botName = (id) => S.bots.find((b) => b.id === id)?.name ?? cfg.feishu.bots.find((b) => b.id === id)?.name ?? id;
257
+ /** fn 返回 false = 没做(比如取消了确认),不用重新拉 */
258
+ const act = (btn, fn) => async () => {
259
+ btn.disabled = true;
260
+ try {
261
+ if ((await fn()) !== false) return void loadPairing();
262
+ } catch (err) {
263
+ toast(err.message);
178
264
  }
179
- })
180
- .catch(() => {});
265
+ btn.disabled = false;
266
+ };
267
+ fill(
268
+ pairBox,
269
+ h(
270
+ "div",
271
+ { class: "section" },
272
+ h("h4", {}, "配对请求", pairs.pending.length ? h("span", { class: "status-tag bad" }, `${pairs.pending.length} 个待批准`) : null, h("span", { class: "grow" }), h("button", { class: "btn small", onclick: () => void loadPairing() }, "刷新")),
273
+ h("div", { class: "muted" }, "不在白名单里的人给机器人发消息,会收到一个配对码,同时出现在这里;批准后他就能用这个机器人。每个机器人单独授权。"),
274
+ pairs.pending.length === 0
275
+ ? h("div", { class: "muted" }, "没有待批准的请求。")
276
+ : pairs.pending.map(({ channelId, request }) => {
277
+ const ok = h("button", { class: "btn small accent" }, "批准");
278
+ ok.onclick = act(ok, async () => {
279
+ await api("/api/pairing/approve", { method: "POST", body: { code: request.code } });
280
+ toast("已批准");
281
+ });
282
+ return h(
283
+ "div",
284
+ { class: "list-item" },
285
+ h("div", { class: "grow" }, h("div", { class: "t" }, `${request.code} · ${botName(channelId)}`), h("div", { class: "s" }, `${request.userId} · ${relTime(request.createdAt)}`)),
286
+ ok,
287
+ );
288
+ }),
289
+ ),
290
+ h(
291
+ "div",
292
+ { class: "section" },
293
+ h("h4", {}, `已配对的用户(${pairs.approved.length})`),
294
+ pairs.approved.length === 0
295
+ ? h("div", { class: "muted" }, "还没有")
296
+ : pairs.approved.map(({ channelId, userId }) => {
297
+ const rm = h("button", { class: "btn small danger" }, "撤销");
298
+ rm.onclick = act(rm, async () => {
299
+ const sure = await confirmDialog({ title: "撤销配对?", message: `${userId} 之后再给「${botName(channelId)}」发消息,要重新配对。`, confirmText: "撤销", danger: true });
300
+ if (!sure) return false;
301
+ await api("/api/pairing/revoke", { method: "POST", body: { channelId, userId } });
302
+ });
303
+ return h("div", { class: "list-item" }, h("div", { class: "grow" }, h("div", { class: "t" }, userId), h("div", { class: "s" }, botName(channelId))), rm);
304
+ }),
305
+ ),
306
+ );
307
+ };
308
+
309
+ // ---- 指令面板:飞书输入框打「/」看到的指令,由「命令」页的设置同步过去 ----
310
+ let slashTimer = null;
311
+ const describeSlash = (st) => {
312
+ if (st.state === "syncing") return "同步中…(重建时每条间隔 1 秒,命令多要一两分钟)";
313
+ if (st.state === "error") return `同步失败:${st.error}`;
314
+ if (st.state === "done") {
315
+ const when = new Date(st.at).toLocaleTimeString();
316
+ const skipped = st.skipped?.length
317
+ ? `;${st.skipped.length} 个名字不符合飞书规则(只能小写字母、数字、- _,最长 32)没注册:${st.skipped.slice(0, 8).join("、")}${st.skipped.length > 8 ? " 等" : ""}`
318
+ : "";
319
+ return `${when} ${st.changed ? "已同步" : "已是最新"},共 ${st.registered} 条${skipped}。飞书端约 5~8 分钟后生效。`;
320
+ }
321
+ return "等待首次同步";
322
+ };
323
+ const loadSlash = async () => {
324
+ if (!cfg.feishu.bots.length) return fill(slashBox);
325
+ const list = h("div", {}, h("span", { class: "spinner" }));
326
+ const syncBtn = h("button", { class: "btn small" }, "立即同步");
327
+ syncBtn.onclick = async () => {
328
+ try {
329
+ await api("/api/feishu/slash/sync", { method: "POST" });
330
+ setTimeout(() => void loadSlash(), 500);
331
+ } catch (err) {
332
+ toast(err.message);
333
+ }
334
+ };
335
+ fill(
336
+ slashBox,
337
+ h(
338
+ "div",
339
+ { class: "section" },
340
+ h("h4", {}, "指令面板", h("span", { class: "grow" }), syncBtn),
341
+ h("div", { class: "muted" }, "飞书输入框打「/」看到的指令,内容和顺序跟着 Agent →「命令」里的设置走。面板是每个机器人一份:默认 agent 的命令在前,其它 agent 的命令接在后面。启动时、保存命令设置后、以及每 6 小时会自动同步。"),
342
+ list,
343
+ ),
344
+ );
345
+ let data;
346
+ try {
347
+ data = await api("/api/feishu/slash");
348
+ } catch (err) {
349
+ fill(list, h("div", { class: "error-text" }, err.message));
350
+ return;
351
+ }
352
+ fill(
353
+ list,
354
+ data.bots.length === 0
355
+ ? h("div", { class: "muted" }, "没有运行中的飞书机器人。")
356
+ : data.bots.map((b) => h("div", { class: `muted${b.status.state === "error" ? " error-text" : ""}` }, h("b", {}, b.name), ":", describeSlash(b.status))),
357
+ );
358
+ clearTimeout(slashTimer);
359
+ slashTimer = data.bots.some((b) => b.status.state === "syncing") ? setTimeout(() => slashBox.isConnected && void loadSlash(), 2000) : null;
360
+ };
361
+
362
+ page.append(botsBox, pairBox, slashBox);
363
+ render();
364
+ void loadStatus();
365
+ void loadPairing();
366
+ void loadSlash();
181
367
  }