agent-loft 0.2.19 → 0.2.21

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.
@@ -13,15 +13,17 @@ import { interactions, type Interaction } from "../../core/interactions.js";
13
13
  import { parseCommand } from "../../core/commands.js";
14
14
  import { Dedup } from "../../core/dedup.js";
15
15
  import { isPaired, requestPairing } from "../../core/pairing.js";
16
- import { getConversation, updateConversation } from "../../core/store.js";
16
+ import { getConversation, listConversations, updateConversation } from "../../core/store.js";
17
17
  import { adoptFile, KIND_LABEL } from "../../core/uploads.js";
18
18
  import type { SavedAttachment } from "../../core/types.js";
19
19
  import { FeishuClient } from "./client.js";
20
20
  import { SlashSync } from "./slash-sync.js";
21
21
  import type { CommandEntry } from "../../core/command-catalog.js";
22
+ import { attach, attachedTarget, detach, watchersOf } from "./attach.js";
22
23
  import {
23
24
  agentPickerCard,
24
25
  approvalCard,
26
+ attachedCard,
25
27
  commandListCard,
26
28
  modelPickerCard,
27
29
  progressCard,
@@ -29,6 +31,8 @@ import {
29
31
  replyCard,
30
32
  sessionListCard,
31
33
  settledCard,
34
+ webListCard,
35
+ type WebConvEntry,
32
36
  } from "./cards.js";
33
37
  import { decodeEnvelope, DECODE_FAILURE_TEXT, type CardCtx, type CardEnvelope } from "./envelope.js";
34
38
  import { parseMessage, unsupportedTypeLabel, type AttachmentRef } from "./message.js";
@@ -41,15 +45,27 @@ type CardResponse = {
41
45
 
42
46
  export type BotStatus = { state: string; since: number; error?: string };
43
47
 
48
+ /** 只在飞书里认的命令:挂到 Web 会话上 / 退回来。整条消息只有命令才算 */
49
+ function feishuCommandOf(text: string): "web" | "detach" | null {
50
+ const t = text.trim().replace(/^//, "/").toLowerCase();
51
+ if (t === "/web") return "web";
52
+ if (t === "/detach") return "detach";
53
+ return null;
54
+ }
55
+
56
+ /** 一条会话画到某个飞书聊天里:流式卡片、操作人都按这一对记 */
57
+ const pairKey = (key: string, chatId: string) => `${key}\n${chatId}`;
58
+
44
59
  export class FeishuBot {
45
60
  readonly client: FeishuClient;
46
61
  private ws: Lark.WSClient | null = null;
47
62
  private dedup = new Dedup();
63
+ /** (会话键, 聊天) → 这一轮的输出编排。一条 Web 会话可能同时画在好几个飞书聊天里 */
48
64
  private streams = new Map<string, TurnStream>();
49
65
  private typing = new Map<string, { messageId: string; reactionId: string }>();
50
- /** 交互 id → 飞书卡片的 message_id */
51
- private cards = new Map<string, string>();
52
- /** 会话键 → 最后一个在飞书上说话的人。卡片默认只让他点 */
66
+ /** 交互 id → 发出去的飞书卡片(可能发在好几个聊天里) */
67
+ private cards = new Map<string, { chatId: string; messageId: string }[]>();
68
+ /** (会话键, 聊天) → 最后一个在这个聊天里说话的人。卡片默认只让他点 */
53
69
  private operators = new Map<string, string>();
54
70
  /** 附件要先下载才能送,紧跟着的文字不能抢在前面 —— 每条会话一条串行链 */
55
71
  private chains = new Map<string, Promise<void>>();
@@ -77,7 +93,7 @@ export class FeishuBot {
77
93
  const out: CommandEntry[] = [];
78
94
  for (const agent of agents) {
79
95
  if (!hub.driver(agent)) continue;
80
- out.push(...(await hub.commandCatalog(agent, { allowedAgents: this.cfg.agents })));
96
+ out.push(...(await hub.commandCatalog(agent, { allowedAgents: this.cfg.agents, channel: "feishu" })));
81
97
  }
82
98
  return out;
83
99
  }
@@ -112,6 +128,159 @@ export class FeishuBot {
112
128
  return this.cfg.agents.filter((a) => enabled.has(a));
113
129
  }
114
130
 
131
+ /** 某条会话能切到哪些 agent:自己的会话按机器人的勾选;接着的 Web 会话和 Web 上一样,启用的都行 */
132
+ private allowedFor(key: string): string[] {
133
+ return this.mine(key) ? this.allowedAgents() : hub.enabledAgents().map((d) => d.id);
134
+ }
135
+
136
+ // ---------- 接着 Web 会话 ----------
137
+
138
+ /** 飞书会话键挂着的 Web 会话(还在、没被删的);没挂着返回 undefined */
139
+ attachedWeb(feishuKey: string): string | undefined {
140
+ const target = attachedTarget(feishuKey);
141
+ if (!target) return undefined;
142
+ const conv = getConversation(target);
143
+ return conv && !conv.hidden ? target : undefined;
144
+ }
145
+
146
+ /** 这条会话的事件该画到哪些飞书聊天里:自己的那个聊天,加上挂着它的聊天 */
147
+ chatsOf(key: string): string[] {
148
+ const out = new Set<string>();
149
+ if (this.mine(key)) out.add(this.chatIdOf(key));
150
+ for (const k of watchersOf(key, this.prefix)) out.add(this.chatIdOf(k));
151
+ return [...out];
152
+ }
153
+
154
+ /** 这个聊天能不能操作这条会话(卡片按钮里带的会话键) */
155
+ private controls(key: string, chatId: string | undefined): boolean {
156
+ if (!chatId) return false;
157
+ if (this.mine(key)) return this.chatIdOf(key) === chatId;
158
+ return watchersOf(key, this.prefix).some((k) => this.chatIdOf(k) === chatId);
159
+ }
160
+
161
+ private webEntry(key: string): WebConvEntry | undefined {
162
+ const info = hub.info(key);
163
+ if (!info) return undefined;
164
+ return {
165
+ key,
166
+ title: info.title || "(未命名)",
167
+ agentLabel: hub.label(info.agent),
168
+ cwd: info.cwd,
169
+ busy: info.busy,
170
+ activeAt: info.activeAt,
171
+ };
172
+ }
173
+
174
+ /** 这个机器人聊过的飞书聊天 → 最近活跃时间 */
175
+ private chatActivity(): Map<string, number> {
176
+ const latest = new Map<string, number>();
177
+ for (const c of listConversations()) {
178
+ if (c.channel !== "feishu" || c.channelId !== this.cfg.id) continue;
179
+ const chatId = this.chatIdOf(c.key);
180
+ latest.set(chatId, Math.max(latest.get(chatId) ?? 0, c.activeAt));
181
+ }
182
+ return latest;
183
+ }
184
+
185
+ hasChat(chatId: string): boolean {
186
+ return this.chatActivity().has(chatId);
187
+ }
188
+
189
+ /** 这个机器人聊过的飞书聊天(Web 上选「在哪继续」用),按最近活跃排 */
190
+ async knownChats(): Promise<{ chatId: string; label: string; activeAt: number; attached?: string }[]> {
191
+ const chats = [...this.chatActivity()].sort((a, b) => b[1] - a[1]).slice(0, 20);
192
+ return Promise.all(
193
+ chats.map(async ([chatId, activeAt]) => {
194
+ const info = await this.client.chatInfo(chatId);
195
+ const label =
196
+ info?.mode === "p2p" ? "私聊" : info?.name ? info.name : `聊天 ${chatId.slice(-6)}`;
197
+ const attached = this.attachedWeb(this.keyOf(chatId));
198
+ return { chatId, label, activeAt, ...(attached ? { attached } : {}) };
199
+ }),
200
+ );
201
+ }
202
+
203
+ /**
204
+ * 把飞书会话 feishuKey 挂到 Web 会话 webKey 上,并在聊天里发一张「已接上」卡(带最近几句)。
205
+ * 返回给人看的结果;挂不上时抛错。
206
+ */
207
+ async attachTo(feishuKey: string, webKey: string, operatorOpenId?: string): Promise<string> {
208
+ const conv = getConversation(webKey);
209
+ if (!conv || conv.channel !== "web" || conv.hidden) throw new Error("这条 Web 会话已不存在");
210
+ const chatId = this.chatIdOf(feishuKey);
211
+ // 本聊天自己的会话也得有:之后 /detach 回来、侧栏里显示机器人都要它
212
+ const allowed = this.allowedAgents();
213
+ if (allowed.length > 0) {
214
+ hub.ensure({
215
+ key: feishuKey,
216
+ channel: "feishu",
217
+ channelId: this.cfg.id,
218
+ defaultAgent: allowed.includes(this.cfg.defaultAgent) ? this.cfg.defaultAgent : allowed[0]!,
219
+ });
220
+ }
221
+ const already = attachedTarget(feishuKey) === webKey;
222
+ attach(feishuKey, webKey);
223
+ if (operatorOpenId) this.operators.set(pairKey(webKey, chatId), operatorOpenId.trim().toLowerCase());
224
+ const entry = this.webEntry(webKey)!;
225
+ console.log(`${this.tag} ${feishuKey} 接上 Web 会话 ${webKey}`);
226
+ // 最近几句:读历史可能要一会儿,读不到也不影响接上
227
+ const history = await hub.history(webKey).catch(() => []);
228
+ const recent = history
229
+ .filter((m) => m.text.trim())
230
+ .slice(-4)
231
+ .map((m) => ({ role: m.role, text: m.text }));
232
+ await this.client.sendCard(
233
+ chatId,
234
+ attachedCard({ conv: entry, recent, sessionKey: feishuKey, ctx: this.cardCtx(feishuKey, chatId) }),
235
+ );
236
+ // 已经挂着的授权 / 提问:这个聊天也要能点
237
+ for (const i of interactions.listPending(webKey)) {
238
+ if (!this.cards.get(i.id)?.some((c) => c.chatId === chatId)) await this.showInteractionIn(i, chatId);
239
+ }
240
+ return already ? `已经接着「${entry.title}」了` : `已在飞书接上「${entry.title}」`;
241
+ }
242
+
243
+ /** 不再接着 Web 会话。返回给人看的结果 */
244
+ detachFrom(feishuKey: string): string {
245
+ const prev = detach(feishuKey);
246
+ if (!prev) return "这个聊天没有接着 Web 会话,本来就在用自己的会话。";
247
+ console.log(`${this.tag} ${feishuKey} 不再接着 Web 会话 ${prev}`);
248
+ const title = getConversation(prev)?.title;
249
+ return `已回到这个聊天自己的会话${title ? `,不再接着「${title}」` : ""}。之后发 \`/web\` 可以再接上。`;
250
+ }
251
+
252
+ /** 发 /web:列最近的 Web 会话 */
253
+ private async sendWebList(feishuKey: string, chatId: string): Promise<void> {
254
+ const current = this.attachedWeb(feishuKey);
255
+ const convs = hub
256
+ .list()
257
+ .filter((c) => c.channel === "web" && !c.hidden)
258
+ .slice(0, 10)
259
+ .map((c) => this.webEntry(c.key)!)
260
+ .filter(Boolean);
261
+ await this.client.sendCard(
262
+ chatId,
263
+ webListCard({
264
+ convs,
265
+ ...(current ? { current } : {}),
266
+ sessionKey: feishuKey,
267
+ ctx: this.cardCtx(feishuKey, chatId),
268
+ }),
269
+ );
270
+ }
271
+
272
+ /** 挂着的 Web 会话被删了 / 没了:自动退回来,告诉一声 */
273
+ private dropStale(feishuKey: string): boolean {
274
+ const target = attachedTarget(feishuKey);
275
+ if (!target || this.attachedWeb(feishuKey)) return false;
276
+ detach(feishuKey);
277
+ void this.client.sendCard(
278
+ this.chatIdOf(feishuKey),
279
+ replyCard("之前接着的 Web 会话已经被删除,已回到这个聊天自己的会话。"),
280
+ );
281
+ return true;
282
+ }
283
+
115
284
  isAuthorized(openId: string | undefined): boolean {
116
285
  if (!openId) return false;
117
286
  const id = openId.trim().toLowerCase();
@@ -123,14 +292,20 @@ export class FeishuBot {
123
292
  start(): void {
124
293
  this.stopped = false;
125
294
  const onEvent = (env: EventEnvelope) => {
126
- if (this.mine(env.key)) this.render(env);
295
+ // 流式片段飞书不画(回复走整段),先挡掉,免得每个片段都去翻一遍挂载表
296
+ if (env.ev.type === "delta") return;
297
+ if (env.ev.type === "meta") {
298
+ for (const k of watchersOf(env.key, this.prefix)) this.dropStale(k);
299
+ return;
300
+ }
301
+ for (const chatId of this.chatsOf(env.key)) this.render(env, chatId);
127
302
  };
128
303
  hub.on("event", onEvent);
129
304
  this.offs.push(() => hub.off("event", onEvent));
130
305
 
131
- const onCreated = (i: Interaction) => this.mine(i.convKey) && void this.showInteraction(i);
132
- const onUpdated = (i: Interaction) => this.mine(i.convKey) && void this.refreshInteraction(i);
133
- const onSettled = (i: Interaction) => this.mine(i.convKey) && void this.refreshInteraction(i);
306
+ const onCreated = (i: Interaction) => void this.showInteraction(i);
307
+ const onUpdated = (i: Interaction) => this.cards.has(i.id) && void this.refreshInteraction(i);
308
+ const onSettled = (i: Interaction) => this.cards.has(i.id) && void this.refreshInteraction(i);
134
309
  interactions.on("created", onCreated);
135
310
  interactions.on("updated", onUpdated);
136
311
  interactions.on("settled", onSettled);
@@ -250,10 +425,10 @@ export class FeishuBot {
250
425
 
251
426
  // ---------- 渲染 hub 事件 ----------
252
427
 
253
- private stream(key: string): TurnStream {
254
- let stream = this.streams.get(key);
428
+ private stream(key: string, chatId: string): TurnStream {
429
+ const id = pairKey(key, chatId);
430
+ let stream = this.streams.get(id);
255
431
  if (stream) return stream;
256
- const chatId = this.chatIdOf(key);
257
432
  stream = new TurnStream({
258
433
  sendCard: (card) => this.client.sendCard(chatId, card),
259
434
  updateCard: (messageId, card) => this.client.updateCard(messageId, card),
@@ -261,35 +436,38 @@ export class FeishuBot {
261
436
  sendReply: (text) => this.client.sendCard(chatId, replyCard(text)),
262
437
  onError: (err) => console.error(`${this.tag} 卡片更新失败:`, err),
263
438
  });
264
- this.streams.set(key, stream);
439
+ this.streams.set(id, stream);
265
440
  return stream;
266
441
  }
267
442
 
268
- private render(env: EventEnvelope): void {
443
+ private render(env: EventEnvelope, chatId: string): void {
269
444
  const { key, ev } = env;
270
- const chatId = this.chatIdOf(key);
271
445
  switch (ev.type) {
272
- case "user":
273
- // 别的渠道(Web)说的话,同步一句过来,不然飞书这边看着没头没尾
274
- if (ev.origin.channel !== "feishu" || ev.origin.channelId !== this.cfg.id) {
446
+ case "user": {
447
+ // 别处(Web、别的飞书聊天)说的话,同步一句过来,不然这边看着没头没尾
448
+ const o = ev.origin;
449
+ const here = o.channel === "feishu" && o.channelId === this.cfg.id && (!o.chatId || o.chatId === chatId);
450
+ if (!here) {
275
451
  const files = ev.attachments.length ? `\n\n📎 ${ev.attachments.join("、")}` : "";
276
- void this.client.sendCard(chatId, replyCard(`💻 **${ev.origin.name ?? "Web"}**:${ev.text}${files}`));
452
+ const icon = o.channel === "web" ? "💻" : "💬";
453
+ void this.client.sendCard(chatId, replyCard(`${icon} **${o.name ?? "Web"}**:${ev.text}${files}`));
277
454
  }
278
455
  return;
456
+ }
279
457
  case "step":
280
- this.stream(key).addStep(ev.text);
458
+ this.stream(key, chatId).addStep(ev.text);
281
459
  return;
282
460
  case "text":
283
- this.stream(key).addText(ev.text);
461
+ this.stream(key, chatId).addText(ev.text);
284
462
  return;
285
463
  case "result": {
286
- const stream = this.stream(key);
464
+ const stream = this.stream(key, chatId);
287
465
  stream.finish(ev.text, ev.footer);
288
466
  void stream.drain().then(() => this.clearTyping(key));
289
467
  return;
290
468
  }
291
469
  case "error": {
292
- const stream = this.stream(key);
470
+ const stream = this.stream(key, chatId);
293
471
  stream.finish("", "出错");
294
472
  void stream.drain().then(() => {
295
473
  this.clearTyping(key);
@@ -305,38 +483,45 @@ export class FeishuBot {
305
483
  }
306
484
  }
307
485
 
308
- private cardCtx(key: string): CardCtx {
309
- const operator = this.operators.get(key);
486
+ private cardCtx(key: string, chatId: string): CardCtx {
487
+ const operator = this.operators.get(pairKey(key, chatId));
310
488
  return {
311
489
  ...(operator ? { operatorOpenId: operator } : {}),
312
- chatId: this.chatIdOf(key),
490
+ chatId,
313
491
  expiresAt: Date.now() + config().approvalTimeoutMs,
314
492
  };
315
493
  }
316
494
 
317
- private interactionCard(i: Interaction, ctx?: CardCtx) {
495
+ private interactionCard(i: Interaction, chatId: string, ctx?: CardCtx) {
318
496
  if (i.settled) return settledCard(i);
319
- const c = ctx ?? this.cardCtx(i.convKey);
497
+ const c = ctx ?? this.cardCtx(i.convKey, chatId);
320
498
  if (i.spec.kind === "approval") return approvalCard(i, c);
321
499
  return questionCard(i, c, hub.label(getConversation(i.convKey)?.agent ?? ""));
322
500
  }
323
501
 
324
502
  private async showInteraction(i: Interaction): Promise<void> {
325
- const messageId = await this.client.sendCard(this.chatIdOf(i.convKey), this.interactionCard(i));
503
+ for (const chatId of this.chatsOf(i.convKey)) await this.showInteractionIn(i, chatId);
504
+ }
505
+
506
+ private async showInteractionIn(i: Interaction, chatId: string): Promise<void> {
507
+ const messageId = await this.client.sendCard(chatId, this.interactionCard(i, chatId));
326
508
  if (!messageId) return;
327
- this.cards.set(i.id, messageId);
509
+ const list = this.cards.get(i.id) ?? [];
510
+ list.push({ chatId, messageId });
511
+ this.cards.set(i.id, list);
328
512
  // 发卡的空当里可能已经在 Web 上处理掉了
329
513
  const latest = interactions.get(i.id);
330
514
  if (latest?.settled) await this.refreshInteraction(latest);
331
515
  }
332
516
 
333
517
  private async refreshInteraction(i: Interaction): Promise<void> {
334
- const messageId = this.cards.get(i.id);
335
- if (!messageId) return;
518
+ const list = this.cards.get(i.id);
519
+ if (!list) return;
336
520
  if (i.settled) this.cards.delete(i.id);
337
- await this.client.updateCard(messageId, this.interactionCard(i));
521
+ await Promise.all(list.map((c) => this.client.updateCard(c.messageId, this.interactionCard(i, c.chatId))));
338
522
  }
339
523
 
524
+
340
525
  // ---------- 正在输入 ----------
341
526
 
342
527
  private clearTyping(key: string): void {
@@ -400,24 +585,36 @@ export class FeishuBot {
400
585
  return;
401
586
  }
402
587
 
403
- const key = this.keyOf(chatId, message.thread_id);
588
+ const ownKey = this.keyOf(chatId, message.thread_id);
404
589
  const conv = hub.ensure({
405
- key,
590
+ key: ownKey,
406
591
  channel: "feishu",
407
592
  channelId: this.cfg.id,
408
593
  defaultAgent: allowed.includes(this.cfg.defaultAgent) ? this.cfg.defaultAgent : allowed[0]!,
409
594
  });
410
595
  // 配置改过(这个机器人不再能用当前 agent):退回默认
411
596
  if (!allowed.includes(conv.agent)) {
412
- updateConversation(key, { agent: allowed.includes(this.cfg.defaultAgent) ? this.cfg.defaultAgent : allowed[0]! });
597
+ updateConversation(ownKey, { agent: allowed.includes(this.cfg.defaultAgent) ? this.cfg.defaultAgent : allowed[0]! });
413
598
  }
414
- this.operators.set(key, openId!.trim().toLowerCase());
599
+ const operator = openId!.trim().toLowerCase();
600
+ this.operators.set(pairKey(ownKey, chatId), operator);
601
+
602
+ const feishuCmd = attachments.length > 0 ? null : feishuCommandOf(text);
603
+ if (feishuCmd) {
604
+ await this.runFeishuCommand(feishuCmd, ownKey, chatId);
605
+ return;
606
+ }
607
+
608
+ // 挂着 Web 会话:说的话、命令都送进那条会话
609
+ this.dropStale(ownKey);
610
+ const key = this.attachedWeb(ownKey) ?? ownKey;
611
+ this.operators.set(pairKey(key, chatId), operator);
415
612
 
416
613
  const command = attachments.length > 0 ? ({ kind: "chat", text } as const) : parseCommand(text);
417
614
  if (command.kind !== "chat") {
418
615
  try {
419
- const reply = await hub.command(key, command, { allowedAgents: allowed, by: "飞书命令" });
420
- await this.renderReply(key, reply);
616
+ const reply = await hub.command(key, command, { allowedAgents: this.allowedFor(key), by: "飞书命令", channel: "feishu" });
617
+ await this.renderReply(key, chatId, reply);
421
618
  } catch (err) {
422
619
  await this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`);
423
620
  }
@@ -425,7 +622,7 @@ export class FeishuBot {
425
622
  }
426
623
 
427
624
  void this.markTyping(key, message.message_id);
428
- const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书" };
625
+ const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书", chatId };
429
626
  // 收到消息这一刻的代次:下载附件期间点了 /stop、或者 /cd /new 换了会话,这条就作废
430
627
  const gen = hub.generation(key);
431
628
  this.enqueue(key, async () => {
@@ -476,9 +673,18 @@ export class FeishuBot {
476
673
  return { saved, failed };
477
674
  }
478
675
 
479
- private async renderReply(key: string, reply: CommandReply): Promise<void> {
480
- const chatId = this.chatIdOf(key);
481
- const ctx = this.cardCtx(key);
676
+ /** /web、/detach:只认飞书会话自己的键,不管现在挂没挂着 */
677
+ private async runFeishuCommand(cmd: "web" | "detach", ownKey: string, chatId: string): Promise<void> {
678
+ try {
679
+ if (cmd === "web") await this.sendWebList(ownKey, chatId);
680
+ else await this.client.sendCard(chatId, replyCard(this.detachFrom(ownKey)));
681
+ } catch (err) {
682
+ await this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`);
683
+ }
684
+ }
685
+
686
+ private async renderReply(key: string, chatId: string, reply: CommandReply): Promise<void> {
687
+ const ctx = this.cardCtx(key, chatId);
482
688
  switch (reply.type) {
483
689
  case "text":
484
690
  await this.client.sendCard(chatId, replyCard(reply.text));
@@ -602,7 +808,7 @@ export class FeishuBot {
602
808
  }
603
809
  return {
604
810
  toast: { type: "info", content: "已勾选" },
605
- card: { type: "raw", data: this.interactionCard(state.interaction) },
811
+ card: { type: "raw", data: this.interactionCard(state.interaction, chatId ?? "") },
606
812
  };
607
813
  }
608
814
  case "ask-submit": {
@@ -619,19 +825,39 @@ export class FeishuBot {
619
825
  return { toast: { type: "info", content: "已跳过" }, card: { type: "raw", data: settledCard(result) } };
620
826
  }
621
827
  case "resume":
622
- this.background(envelope.k, () => hub.resume(envelope.k, envelope.s, envelope.c));
623
- return { toast: { type: "info", content: "正在恢复该会话…" } };
828
+ return this.background(envelope.k, chatId, "正在恢复该会话…", () => hub.resume(envelope.k, envelope.s, envelope.c));
624
829
  case "model":
625
- this.background(envelope.k, () => hub.setModel(envelope.k, envelope.m));
626
- return { toast: { type: "success", content: `正在切换到 ${envelope.m}` } };
830
+ return this.background(envelope.k, chatId, `正在切换到 ${envelope.m}`, () => hub.setModel(envelope.k, envelope.m));
627
831
  case "effort":
628
- this.background(envelope.k, () => hub.setEffort(envelope.k, envelope.f));
629
- return { toast: { type: "success", content: `思考程度设为 ${envelope.f}` } };
832
+ return this.background(envelope.k, chatId, `思考程度设为 ${envelope.f}`, () => hub.setEffort(envelope.k, envelope.f));
630
833
  case "agent":
631
- this.background(envelope.k, () => hub.setAgent(envelope.k, envelope.g, this.allowedAgents()));
632
- return { toast: { type: "success", content: `正在切换到 ${hub.label(envelope.g)}` } };
834
+ return this.background(envelope.k, chatId, `正在切换到 ${hub.label(envelope.g)}`, () =>
835
+ hub.setAgent(envelope.k, envelope.g, this.allowedFor(envelope.k)),
836
+ );
633
837
  case "cmd":
634
- return this.runCardCommand(envelope.k, `/${envelope.c}`);
838
+ return this.runCardCommand(envelope.k, chatId, operatorOpenId, `/${envelope.c}`);
839
+ case "attach": {
840
+ // k 必须是这个聊天自己的会话:卡片被转发到别处点,挂的也只能是那个聊天
841
+ if (!this.mine(envelope.k) || this.chatIdOf(envelope.k) !== chatId) {
842
+ return { toast: { type: "error", content: DECODE_FAILURE_TEXT.wrong_conversation } };
843
+ }
844
+ const conv = getConversation(envelope.w);
845
+ if (!conv || conv.channel !== "web" || conv.hidden) {
846
+ return { toast: { type: "error", content: "这条 Web 会话已不存在,请重新发 /web。" } };
847
+ }
848
+ void this.attachTo(envelope.k, envelope.w, operatorOpenId).catch((err) =>
849
+ this.client.sendText(chatId!, `出错了:${err instanceof Error ? err.message : String(err)}`),
850
+ );
851
+ return { toast: { type: "success", content: `正在接上「${conv.title ?? "Web 会话"}」` } };
852
+ }
853
+ case "detach": {
854
+ if (!this.mine(envelope.k) || this.chatIdOf(envelope.k) !== chatId) {
855
+ return { toast: { type: "error", content: DECODE_FAILURE_TEXT.wrong_conversation } };
856
+ }
857
+ const text = this.detachFrom(envelope.k);
858
+ void this.client.sendCard(chatId!, replyCard(text));
859
+ return { toast: { type: "success", content: "已回到本聊天" } };
860
+ }
635
861
  }
636
862
  }
637
863
 
@@ -639,31 +865,47 @@ export class FeishuBot {
639
865
  * 命令表上点「执行」:等同于用户自己发了这条命令。
640
866
  * agent-loft 的命令就地执行并回卡片;agent 原生命令(skills 等)当一条消息发给 agent。
641
867
  */
642
- private runCardCommand(key: string, text: string): CardResponse {
643
- if (!this.mine(key) || !getConversation(key)) {
868
+ private runCardCommand(key: string, chatId: string | undefined, operatorOpenId: string | undefined, text: string): CardResponse {
869
+ if (!this.controls(key, chatId) || !getConversation(key)) {
644
870
  return { toast: { type: "error", content: "这条会话已不存在,请重新发一次「/」。" } };
645
871
  }
872
+ const chat = chatId!;
873
+ // /web、/detach 作用在这个聊天自己的会话上:命令表可能是接着 Web 会话时发的,k 是 Web 会话
874
+ const feishuCmd = feishuCommandOf(text);
875
+ if (feishuCmd) {
876
+ const ownKey = this.mine(key) ? key : this.keyOf(chat);
877
+ void this.runFeishuCommand(feishuCmd, ownKey, chat);
878
+ return { toast: { type: "info", content: `正在执行 ${text}` } };
879
+ }
880
+ if (operatorOpenId) this.operators.set(pairKey(key, chat), operatorOpenId.trim().toLowerCase());
646
881
  const command = parseCommand(text);
647
882
  if (command.kind === "chat") {
648
- const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书" };
883
+ const origin = { channel: "feishu", channelId: this.cfg.id, name: "飞书", chatId: chat };
649
884
  const gen = hub.generation(key);
650
885
  this.enqueue(key, async () => hub.send(key, { text, attachments: [] }, origin, gen));
651
886
  return { toast: { type: "success", content: `已发送 ${text}` } };
652
887
  }
653
- const chatId = this.chatIdOf(key);
654
888
  void hub
655
- .command(key, command, { allowedAgents: this.allowedAgents(), by: "飞书命令" })
656
- .then((reply) => this.renderReply(key, reply))
657
- .catch((err) => this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`));
889
+ .command(key, command, { allowedAgents: this.allowedFor(key), by: "飞书命令", channel: "feishu" })
890
+ .then((reply) => this.renderReply(key, chat, reply))
891
+ .catch((err) => this.client.sendText(chat, `出错了:${err instanceof Error ? err.message : String(err)}`));
658
892
  return { toast: { type: "info", content: `正在执行 ${text}` } };
659
893
  }
660
894
 
661
- /** 卡片回调里耗时的操作:先应答,做完在会话里回一句。 */
662
- private background(key: string, task: () => Promise<string>): void {
663
- if (!this.mine(key) || !getConversation(key)) return;
664
- const chatId = this.chatIdOf(key);
895
+ /** 卡片回调里耗时的操作:先应答,做完在点的那个聊天里回一句。 */
896
+ private background(
897
+ key: string,
898
+ chatId: string | undefined,
899
+ toast: string,
900
+ task: () => Promise<string>,
901
+ ): CardResponse {
902
+ if (!this.controls(key, chatId) || !getConversation(key)) {
903
+ return { toast: { type: "error", content: "这条会话已不存在,或者这个聊天已经不接着它了。" } };
904
+ }
905
+ const chat = chatId!;
665
906
  void task()
666
- .then((text) => this.client.sendCard(chatId, replyCard(text)))
667
- .catch((err) => this.client.sendText(chatId, `出错了:${err instanceof Error ? err.message : String(err)}`));
907
+ .then((text) => this.client.sendCard(chat, replyCard(text)))
908
+ .catch((err) => this.client.sendText(chat, `出错了:${err instanceof Error ? err.message : String(err)}`));
909
+ return { toast: { type: "info", content: toast } };
668
910
  }
669
911
  }
@@ -363,3 +363,100 @@ export function relativeTime(ts: number, now = Date.now()): string {
363
363
  if (days < 30) return `${days} 天前`;
364
364
  return new Date(ts).toLocaleDateString("zh-CN");
365
365
  }
366
+
367
+ // ---------- 接着 Web 会话 ----------
368
+
369
+ export type WebConvEntry = {
370
+ key: string;
371
+ title: string;
372
+ agentLabel: string;
373
+ cwd: string;
374
+ busy: boolean;
375
+ activeAt: number;
376
+ };
377
+
378
+ /** /web:最近的 Web 会话,点「接上」后这个聊天就接着它聊 */
379
+ export function webListCard(params: {
380
+ convs: WebConvEntry[];
381
+ /** 当前挂着的 Web 会话 */
382
+ current?: string;
383
+ /** 这个飞书聊天自己的会话键 */
384
+ sessionKey: string;
385
+ ctx: CardCtx;
386
+ }) {
387
+ const elements: unknown[] = [
388
+ {
389
+ tag: "markdown",
390
+ content: params.current
391
+ ? "这个聊天正接着下面标 ✅ 的 Web 会话。点别的「接上」换一条,或者回到这个聊天自己的会话。"
392
+ : "点「接上」后,在这里说的话都发进那条 Web 会话,它的回复、授权和提问也会发到这里。发 `/detach` 回到这个聊天自己的会话。",
393
+ },
394
+ ];
395
+ if (params.current) {
396
+ elements.push(buttonRow([button("回到本聊天", { a: "detach", k: params.sessionKey }, params.ctx)]));
397
+ }
398
+ if (params.convs.length === 0) {
399
+ elements.push({ tag: "hr" }, { tag: "markdown", content: "还没有 Web 会话。" });
400
+ }
401
+ params.convs.forEach((c, index) => {
402
+ const isCurrent = c.key === params.current;
403
+ elements.push({ tag: "hr" });
404
+ elements.push({
405
+ tag: "column_set",
406
+ horizontal_spacing: "8px",
407
+ columns: [
408
+ {
409
+ tag: "column",
410
+ width: "weighted",
411
+ weight: 1,
412
+ vertical_align: "center",
413
+ elements: [
414
+ {
415
+ tag: "markdown",
416
+ content:
417
+ `**${index + 1}. ${clip(c.title, 60)}**${isCurrent ? " ✅ 当前" : ""}${c.busy ? " ⏳ 回复中" : ""}\n` +
418
+ `<font color='grey'>${c.agentLabel} · \`${c.cwd}\` · ${relativeTime(c.activeAt)}</font>`,
419
+ },
420
+ ],
421
+ },
422
+ {
423
+ tag: "column",
424
+ width: "auto",
425
+ vertical_align: "center",
426
+ elements: isCurrent ? [] : [button("接上", { a: "attach", w: c.key, k: params.sessionKey }, params.ctx)],
427
+ },
428
+ ],
429
+ });
430
+ });
431
+ return card(elements, { title: "Web 会话", template: "blue" });
432
+ }
433
+
434
+ /** 接上之后发一张:接的是哪条、最近聊到哪,以及回来的按钮 */
435
+ export function attachedCard(params: {
436
+ conv: WebConvEntry;
437
+ recent: { role: "user" | "assistant"; text: string }[];
438
+ sessionKey: string;
439
+ ctx: CardCtx;
440
+ }) {
441
+ const { conv } = params;
442
+ const elements: unknown[] = [
443
+ {
444
+ tag: "markdown",
445
+ content: `**${clip(conv.title, 80)}**\n<font color='grey'>${conv.agentLabel} · \`${conv.cwd}\`</font>`,
446
+ },
447
+ ];
448
+ if (params.recent.length > 0) {
449
+ const lines = params.recent.map((m) => {
450
+ const who = m.role === "user" ? "**你**" : `**${conv.agentLabel}**`;
451
+ return `${who}:${clip(m.text.trim(), 400)}`;
452
+ });
453
+ elements.push({ tag: "hr" }, { tag: "markdown", content: clip(`最近的对话:\n\n${lines.join("\n\n")}`, 3000) });
454
+ }
455
+ elements.push({ tag: "hr" });
456
+ elements.push({
457
+ tag: "markdown",
458
+ content: `${conv.busy ? "它正在回复,后面的进度会接着发到这里。\n" : ""}直接在这里说话就会发进这条会话;Web 上说的话也会同步过来。发 \`/detach\` 回到这个聊天自己的会话。`,
459
+ });
460
+ elements.push(buttonRow([button("回到本聊天", { a: "detach", k: params.sessionKey }, params.ctx)]));
461
+ return card(elements, { title: "已接上 Web 会话", template: "turquoise" });
462
+ }