agent-loft 0.2.19 → 0.2.20

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 CHANGED
@@ -37,6 +37,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
37
37
  - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类,飞书机器人各占一行并带飞书图标;分叉出来的对话挂在主对话下面,用虚线连起来;分组可折叠,宽度可拖动,双击会话重命名,名字太长时悬停滚动显示。
38
38
  - 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,悬停看重置时间);空闲时按设置的间隔自动刷新(默认 10 分钟),跑完一轮后也会刷新。
39
39
  - **跨渠道同步**:飞书里的会话在 Web 上也能打开,接着聊。同一个授权请求同时出现在飞书卡片和网页上,哪边先点算哪边,另一边自动变成「已处理」。
40
+ - **在飞书里接着 Web 会话**:飞书里发 `/web` 列出最近的 Web 会话,点「接上」后这个飞书聊天说的话、发的命令都进那条会话,它的回复、授权和提问也发到飞书,Web 上说的话同步过来;发 `/detach` 回到这个聊天自己的会话。也可以在 Web 侧栏右键会话 →「在飞书继续…」,选一个机器人聊过的飞书聊天直接接上(只有一个聊天时不用选)。Web 会话被删除时飞书那边自动退回来。
40
41
  - **设置都在网页上**:工作目录、各 agent 的默认模型和思考程度、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、登录设备管理。
41
42
 
42
43
  ## 安装
@@ -85,7 +86,7 @@ agent-loft service install
85
86
  | `agent-loft service install \| uninstall \| restart \| status` | 开机自启 |
86
87
  | `agent-loft doctor` | 体检 |
87
88
 
88
- 聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/approve <id> allow-once|allow-always|allow-session|deny` `/help`
89
+ 聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/approve <id> allow-once|allow-always|allow-session|deny` `/help`;只在飞书里有的:`/web` `/detach`
89
90
 
90
91
  自定义命令:设置 → 命令 → 「添加命令」,填命令名、说明和一段提示词,之后发 `/命令名 参数` 就会把这段提示词发给 agent(提示词里的 `$ARGUMENTS` 换成参数,没写就把参数接在后面)。按 agent 分开存,可以编辑、删除,也会同步到飞书指令面板。
91
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.19",
3
+ "version": "0.2.20",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -0,0 +1,48 @@
1
+ // 飞书聊天「挂到」Web 会话上:挂上之后,这个飞书聊天说的话、发的命令都送进那条 Web 会话,
2
+ // 那条会话的回复、授权、提问也画到这个飞书聊天里。发 /detach 回到聊天自己的会话。
3
+ //
4
+ // 只是一层路由,会话本身(agent 进程、历史、审批)一点不动。按飞书会话键记:
5
+ // feishu:<机器人id>:<chatId>[:<话题id>] → web:<…>
6
+ // 一条 Web 会话可以同时被好几个飞书聊天挂着。要落盘:重启之后飞书那边还接着它聊。
7
+ import path from "node:path";
8
+ import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
9
+
10
+ type Shape = Record<string, { target: string; at: number }>;
11
+
12
+ const storePath = path.join(dataDir, "feishu-attach.json");
13
+ let cache: Shape | null = null;
14
+
15
+ function load(): Shape {
16
+ cache ??= readJson<Shape>(storePath, {});
17
+ return cache;
18
+ }
19
+
20
+ function save(): void {
21
+ writeJsonAtomic(storePath, cache ?? {});
22
+ }
23
+
24
+ /** 这个飞书会话挂着的 Web 会话键 */
25
+ export function attachedTarget(feishuKey: string): string | undefined {
26
+ return load()[feishuKey]?.target;
27
+ }
28
+
29
+ export function attach(feishuKey: string, target: string): void {
30
+ load()[feishuKey] = { target, at: Date.now() };
31
+ save();
32
+ }
33
+
34
+ /** 返回原来挂着的目标(没挂着返回 undefined) */
35
+ export function detach(feishuKey: string): string | undefined {
36
+ const prev = load()[feishuKey]?.target;
37
+ if (prev === undefined) return undefined;
38
+ delete load()[feishuKey];
39
+ save();
40
+ return prev;
41
+ }
42
+
43
+ /** 挂在 target 上的所有飞书会话键(可选只要某个前缀的,也就是某个机器人的) */
44
+ export function watchersOf(target: string, prefix = ""): string[] {
45
+ return Object.entries(load())
46
+ .filter(([k, v]) => v.target === target && k.startsWith(prefix))
47
+ .map(([k]) => k);
48
+ }
@@ -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
+ }
@@ -112,6 +112,28 @@ export class FeishuClient {
112
112
  }
113
113
  }
114
114
 
115
+ /**
116
+ * 群名 / 是不是私聊,Web 上选「在哪个飞书聊天继续」时显示用。
117
+ * 要 im:chat 读权限,没有就返回 undefined,调用方退回显示 chat_id。成功的结果缓存起来。
118
+ */
119
+ private chatInfoCache = new Map<string, { name?: string; mode?: string }>();
120
+ async chatInfo(chatId: string): Promise<{ name?: string; mode?: string } | undefined> {
121
+ const cached = this.chatInfoCache.get(chatId);
122
+ if (cached) return cached;
123
+ try {
124
+ const res = await this.lark.im.v1.chat.get({ path: { chat_id: chatId } });
125
+ if (res.code !== 0 || !res.data) return undefined;
126
+ const info = {
127
+ ...(res.data.name ? { name: res.data.name } : {}),
128
+ ...(res.data.chat_mode ? { mode: res.data.chat_mode } : {}),
129
+ };
130
+ rememberBounded(this.chatInfoCache, chatId, info, 200);
131
+ return info;
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
115
137
  /** 飞书没有给机器人开放 typing 指示器,表情回复是最接近的替代。 */
116
138
  async addTypingReaction(messageId: string): Promise<string | undefined> {
117
139
  try {
@@ -23,7 +23,11 @@ export type EnvelopeAction =
23
23
  | { a: "agent"; g: string; k: string }
24
24
  | { a: "resume"; s: string; k: string; c?: string }
25
25
  /** 命令列表卡上的「执行」:c 是命令名,不带斜杠 */
26
- | { a: "cmd"; c: string; k: string };
26
+ | { a: "cmd"; c: string; k: string }
27
+ /** 把飞书会话 k 挂到 Web 会话 w 上 */
28
+ | { a: "attach"; w: string; k: string }
29
+ /** 飞书会话 k 不再挂着 Web 会话 */
30
+ | { a: "detach"; k: string };
27
31
 
28
32
  export type CardEnvelope = EnvelopeAction & {
29
33
  oc: typeof ENVELOPE_VERSION;
@@ -103,6 +107,10 @@ function validAction(v: Record<string, unknown>): boolean {
103
107
  case "cmd":
104
108
  // 只放行命令名该有的字符(skill 名会带冒号),别让参数、换行之类混进来
105
109
  return str(v.c) && /^[\w:.-]+$/.test(v.c) && str(v.k);
110
+ case "attach":
111
+ return str(v.w) && v.w.startsWith("web:") && str(v.k);
112
+ case "detach":
113
+ return str(v.k);
106
114
  default:
107
115
  return false;
108
116
  }
@@ -518,6 +518,20 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
518
518
  sendJson(res, 200, { info: hub.info(newKey) });
519
519
  return;
520
520
  }
521
+ if (sub === "feishu" && method === "POST") {
522
+ // 在飞书继续:把选中的飞书聊天挂到这条 Web 会话上
523
+ if (conv.channel !== "web" || conv.hidden) throw new HttpError(400, "只有 Web 会话能到飞书继续");
524
+ const body = await readJsonBody<{ botId?: string; chatId?: string }>(req);
525
+ const bot = feishuBots().find((b) => b.id === body.botId);
526
+ if (!bot) throw new HttpError(404, "这个飞书机器人没有在运行");
527
+ const chatId = String(body.chatId ?? "");
528
+ // 只能挂到这个机器人聊过的聊天上,不能拿它往任意 chat_id 发消息
529
+ if (!bot.hasChat(chatId)) {
530
+ throw new HttpError(404, "这个机器人没在那个聊天里说过话");
531
+ }
532
+ sendJson(res, 200, { text: await bot.attachTo(bot.keyOf(chatId), key) });
533
+ return;
534
+ }
521
535
  if (sub === "sessions" && method === "GET") {
522
536
  sendJson(res, 200, await hub.sessions(key, url.searchParams.get("all") === "1"));
523
537
  return;
@@ -652,6 +666,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
652
666
  const commands = await hub.commandCatalog(m[1]!, {
653
667
  ...(conv ? { cwd: conv.cwd } : {}),
654
668
  allowedAgents: hub.enabledAgents().map((d) => d.id),
669
+ // 带 key 是输入框补全:只列 Web 能用的;不带是设置页排序,全列
670
+ ...(conv ? { channel: "web" } : {}),
655
671
  });
656
672
  sendJson(res, 200, { commands });
657
673
  return;
@@ -712,6 +728,14 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
712
728
  sendJson(res, 200, { ok: true });
713
729
  return;
714
730
  }
731
+ if (p === "/api/feishu/targets" && method === "GET") {
732
+ // 「在飞书继续」能选的地方:每个运行中的机器人,以及它聊过的聊天
733
+ const bots = await Promise.all(
734
+ feishuBots().map(async (b) => ({ id: b.id, name: b.cfg.name, state: b.status.state, chats: await b.knownChats() })),
735
+ );
736
+ sendJson(res, 200, { bots });
737
+ return;
738
+ }
715
739
  if (p === "/api/feishu/status" && method === "GET") {
716
740
  sendJson(res, 200, feishuBots().map((b) => ({ id: b.id, name: b.cfg.name, ...b.status })));
717
741
  return;
@@ -7,7 +7,14 @@
7
7
  // 顺序和隐藏按 agent 分开存在配置里(设置 → 命令),用户自己排。
8
8
 
9
9
  /** agent-loft 内置命令。name 不带斜杠;args 里尖括号是必填、方括号是可选 */
10
- export type BuiltinSpec = { name: string; args?: string; description: string; multiAgentOnly?: boolean };
10
+ export type BuiltinSpec = {
11
+ name: string;
12
+ args?: string;
13
+ description: string;
14
+ multiAgentOnly?: boolean;
15
+ /** 只在飞书里有意义(挂到 Web 会话上 / 退回来),Web 的补全里不列 */
16
+ feishuOnly?: boolean;
17
+ };
11
18
 
12
19
  export const BUILTIN_COMMANDS: BuiltinSpec[] = [
13
20
  { name: "new", args: "[模型]", description: "开一条新会话" },
@@ -24,6 +31,8 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
24
31
  { name: "cd", args: "<相对目录>", description: "切换工作目录(限 workspace 内)" },
25
32
  { name: "pwd", description: "显示当前工作目录" },
26
33
  { name: "autoedit", args: "[on|off]", description: "本对话自动允许改工作目录内的文件(Bash 仍询问)" },
34
+ { name: "web", description: "接上一条 Web 会话,在飞书里查看和回复", feishuOnly: true },
35
+ { name: "detach", description: "不再接着 Web 会话,回到这个聊天自己的会话", feishuOnly: true },
27
36
  { name: "approve", args: "<请求ID> allow-once|allow-always|allow-session|deny", description: "审批兜底(卡片失效时用)" },
28
37
  { name: "help", description: "显示帮助" },
29
38
  ];
@@ -129,11 +138,14 @@ export function buildCatalog(params: {
129
138
  allowedAgents: string[];
130
139
  native: AgentCommand[];
131
140
  prefs: CommandPrefs | undefined;
141
+ /** 给哪个渠道看:不是飞书就不列飞书专用的命令。不给就全列(设置页排序用) */
142
+ channel?: string;
132
143
  }): CommandEntry[] {
133
144
  const multi = params.allowedAgents.length > 1;
134
145
  const builtin: CommandEntry[] = BUILTIN_COMMANDS.filter(
135
146
  (c) =>
136
147
  (!c.multiAgentOnly || multi) &&
148
+ (!c.feishuOnly || params.channel === undefined || params.channel === "feishu") &&
137
149
  // /claude /codex 只在这个渠道能用对应 agent 时才有意义
138
150
  (!["claude", "codex"].includes(c.name) || params.allowedAgents.includes(c.name)),
139
151
  ).map((c) => ({
@@ -84,7 +84,7 @@ export function parseCommand(raw: string): ParsedCommand {
84
84
  }
85
85
  }
86
86
 
87
- export function helpText(agents: string[]): string {
87
+ export function helpText(agents: string[], channel?: string): string {
88
88
  const multi = agents.length > 1;
89
89
  return [
90
90
  "直接说话即可,会自动接着上次的会话。闲置太久会自动开新会话。",
@@ -94,6 +94,7 @@ export function helpText(agents: string[]): string {
94
94
  "**命令**(需整条消息只有命令;只发一个 `/` 可以列出全部命令,包括 agent 自己的)",
95
95
  "",
96
96
  ...BUILTIN_COMMANDS.filter((c) => !c.multiAgentOnly || multi)
97
+ .filter((c) => !c.feishuOnly || channel === "feishu")
97
98
  .filter((c) => !["claude", "codex"].includes(c.name))
98
99
  .map((c) =>
99
100
  c.name === "agent"
package/src/core/hub.ts CHANGED
@@ -50,6 +50,8 @@ export type Origin = {
50
50
  channelId: string;
51
51
  /** 显示用的人名 / 设备名 */
52
52
  name?: string;
53
+ /** 飞书:从哪个聊天说的(一条会话可能同时挂在好几个飞书聊天上,回显时要分清) */
54
+ chatId?: string;
53
55
  };
54
56
 
55
57
  export type ConvEvent =
@@ -1113,14 +1115,24 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1113
1115
  }
1114
1116
 
1115
1117
  /** 执行一条已解析的命令。chat 不在这里处理。 */
1116
- async command(key: string, cmd: ParsedCommand, ctx: { allowedAgents: string[]; by: string }): Promise<CommandReply> {
1118
+ /** ctx.channel:命令是从哪个渠道发的(飞书聊天挂在 Web 会话上时,和会话自己的渠道不一样) */
1119
+ async command(
1120
+ key: string,
1121
+ cmd: ParsedCommand,
1122
+ ctx: { allowedAgents: string[]; by: string; channel?: string },
1123
+ ): Promise<CommandReply> {
1117
1124
  const text = (t: string): CommandReply => ({ type: "text", text: t });
1125
+ const channel = ctx.channel ?? getConversation(key)?.channel;
1118
1126
  switch (cmd.kind) {
1119
1127
  case "help":
1120
- return text(helpText(ctx.allowedAgents));
1128
+ return text(helpText(ctx.allowedAgents, channel));
1121
1129
  case "commands": {
1122
1130
  const conv = getConversation(key)!;
1123
- const all = await this.commandCatalog(conv.agent, { cwd: conv.cwd, allowedAgents: ctx.allowedAgents });
1131
+ const all = await this.commandCatalog(conv.agent, {
1132
+ cwd: conv.cwd,
1133
+ allowedAgents: ctx.allowedAgents,
1134
+ ...(channel ? { channel } : {}),
1135
+ });
1124
1136
  return { type: "commands", agent: conv.agent, commands: all.filter((c) => !c.hidden) };
1125
1137
  }
1126
1138
  case "pwd":
@@ -1180,7 +1192,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1180
1192
  */
1181
1193
  async commandCatalog(
1182
1194
  agent: string,
1183
- opts: { cwd?: string; allowedAgents: string[] },
1195
+ opts: { cwd?: string; allowedAgents: string[]; channel?: string },
1184
1196
  ): Promise<CommandEntry[]> {
1185
1197
  const driver = this.enabledAgents().find((d) => d.id === agent);
1186
1198
  let native: AgentCommand[] = [];
@@ -1191,7 +1203,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope] }> {
1191
1203
  });
1192
1204
  }
1193
1205
  const prefs = (config().commands as Record<string, CommandPrefs | undefined>)[agent];
1194
- return buildCatalog({ allowedAgents: opts.allowedAgents, native, prefs });
1206
+ return buildCatalog({ allowedAgents: opts.allowedAgents, native, prefs, ...(opts.channel ? { channel: opts.channel } : {}) });
1195
1207
  }
1196
1208
 
1197
1209
  async shutdown(): Promise<void> {
package/web/app.js CHANGED
@@ -1428,10 +1428,64 @@ function openConvContextMenu(key, at) {
1428
1428
  },
1429
1429
  },
1430
1430
  { label: "重命名", onclick: () => renameConv(key) },
1431
+ ...(c.channel === "web" && S.bots.length > 0 ? [{ label: "在飞书继续…", onclick: () => continueInFeishu(key) }] : []),
1431
1432
  ...(c.channel === "web" ? [{ label: "删除", danger: true, onclick: () => deleteConv(key) }] : []),
1432
1433
  ]);
1433
1434
  }
1434
1435
 
1436
+ /**
1437
+ * 在飞书继续:选一个机器人聊过的飞书聊天,把它接到这条 Web 会话上。
1438
+ * 之后在那个聊天里说话就进这条会话,回复、授权、提问也发过去;飞书里发 /detach 退回来。
1439
+ * 只有一个聊天可选时直接接上,不弹窗。
1440
+ */
1441
+ async function continueInFeishu(key) {
1442
+ let data;
1443
+ try {
1444
+ data = await api("/api/feishu/targets");
1445
+ } catch (err) {
1446
+ return toast(err.message);
1447
+ }
1448
+ const targets = data.bots.flatMap((b) => b.chats.map((c) => ({ bot: b, chat: c })));
1449
+ if (data.bots.length === 0) return toast("没有运行中的飞书机器人");
1450
+ if (targets.length === 0) return toast("机器人还没有聊天:先在飞书里给它发一条消息,再回来推过去");
1451
+ const go = async (t, close) => {
1452
+ try {
1453
+ const r = await api(`/api/conversations/${encodeURIComponent(key)}/feishu`, {
1454
+ method: "POST",
1455
+ body: { botId: t.bot.id, chatId: t.chat.chatId },
1456
+ });
1457
+ close?.();
1458
+ toast(r.text);
1459
+ } catch (err) {
1460
+ toast(err.message);
1461
+ }
1462
+ };
1463
+ if (targets.length === 1) return go(targets[0]);
1464
+ const multiBot = data.bots.filter((b) => b.chats.length > 0).length > 1;
1465
+ const body = h("div", { class: "modal-body" });
1466
+ const close = modal({ title: "在飞书继续", body });
1467
+ fill(
1468
+ body,
1469
+ h("div", { class: "muted" }, "选一个飞书聊天,之后在那里说话就会发进这条会话,回复和授权也会发过去。在飞书里发 /detach 回到那个聊天自己的会话。"),
1470
+ ...targets.map((t) => {
1471
+ const here = t.chat.attached === key;
1472
+ const other = t.chat.attached && !here ? S.convs.get(t.chat.attached) : null;
1473
+ const note = here ? "已接着这条" : other ? `正接着「${convTitle(other)}」,选它会换过来` : "";
1474
+ return h(
1475
+ "button",
1476
+ { class: `list-item${here ? " on" : ""}`, onclick: () => go(t, close) },
1477
+ h("span", { class: "brand-icon", html: ICON.feishu }),
1478
+ h(
1479
+ "div",
1480
+ { class: "grow" },
1481
+ h("div", { class: "t" }, multiBot ? `${t.bot.name} · ${t.chat.label}` : t.chat.label),
1482
+ h("div", { class: "s" }, [`${relTime(t.chat.activeAt)}活跃`, t.bot.state !== "connected" ? "机器人未连上" : "", note].filter(Boolean).join(" · ")),
1483
+ ),
1484
+ );
1485
+ }),
1486
+ );
1487
+ }
1488
+
1435
1489
  /** 飞书机器人的历史会话:这个机器人的所有聊天,按活跃时间排,点一下打开 */
1436
1490
  function openBotConversations(botId) {
1437
1491
  const bot = S.bots.find((b) => b.id === botId);
@@ -3519,7 +3573,7 @@ function modelPicker(agent, model, effort, defaultLabel) {
3519
3573
  }
3520
3574
 
3521
3575
  /** agent-loft 内置命令名(和服务端 BUILTIN_COMMANDS 一致),自定义命令不能用 */
3522
- const BUILTIN_NAMES = ["new", "status", "stop", "model", "effort", "sessions", "resume", "fork", "agent", "claude", "codex", "cd", "pwd", "autoedit", "approve", "help"];
3576
+ const BUILTIN_NAMES = ["new", "status", "stop", "model", "effort", "sessions", "resume", "fork", "agent", "claude", "codex", "cd", "pwd", "autoedit", "web", "detach", "approve", "help"];
3523
3577
 
3524
3578
  /**
3525
3579
  * 添加 / 编辑一条自定义命令。onSave 返回 true 才关窗;失败(比如服务端校验没过)留着让用户改。