agent-loft 0.2.18 → 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.18",
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;
@@ -13,6 +13,7 @@ import { parseCommand } from "../../core/commands.js";
13
13
  import { hub, type EventEnvelope } from "../../core/hub.js";
14
14
  import { interactions, type Interaction } from "../../core/interactions.js";
15
15
  import { getConversation } from "../../core/store.js";
16
+ import { RUNNING_VERSION } from "../../core/upgrade.js";
16
17
  import type { Settlement } from "../../core/types.js";
17
18
  import { authed, handleApi, takeUploads } from "./api.js";
18
19
  import { authEvents, ensureSetupToken, validateSession } from "./auth.js";
@@ -60,18 +61,33 @@ const MIME: Record<string, string> = {
60
61
  ".webmanifest": "application/manifest+json",
61
62
  };
62
63
 
63
- function serveFile(res: http.ServerResponse, file: string, cache: string): void {
64
+ /**
65
+ * 静态文件一律 no-cache + Last-Modified:每次都向服务端确认一下,没变就回 304。
66
+ * 升级后 app.js、style.css、vendor 都会变,但地址不变,带 max-age 的话刷新页面还会拿到旧的。
67
+ */
68
+ function serveFile(req: http.IncomingMessage, res: http.ServerResponse, file: string): void {
64
69
  fs.stat(file, (err, stat) => {
65
70
  if (err || !stat.isFile()) {
66
71
  res.writeHead(404, SECURITY_HEADERS);
67
72
  res.end("not found");
68
73
  return;
69
74
  }
70
- res.writeHead(200, {
75
+ const lastModified = new Date(Math.floor(stat.mtimeMs / 1000) * 1000);
76
+ const since = Date.parse(req.headers["if-modified-since"] ?? "");
77
+ const headers = {
71
78
  ...SECURITY_HEADERS,
79
+ "Cache-Control": "no-cache",
80
+ "Last-Modified": lastModified.toUTCString(),
81
+ };
82
+ if (!Number.isNaN(since) && lastModified.getTime() <= since) {
83
+ res.writeHead(304, headers);
84
+ res.end();
85
+ return;
86
+ }
87
+ res.writeHead(200, {
88
+ ...headers,
72
89
  "Content-Type": MIME[path.extname(file)] ?? "application/octet-stream",
73
90
  "Content-Length": stat.size,
74
- "Cache-Control": cache,
75
91
  });
76
92
  fs.createReadStream(file).pipe(res);
77
93
  });
@@ -81,7 +97,7 @@ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: U
81
97
  const vendor = VENDOR[url.pathname];
82
98
  if (vendor) {
83
99
  try {
84
- serveFile(res, vendor(), "public, max-age=86400");
100
+ serveFile(req, res, vendor());
85
101
  } catch {
86
102
  res.writeHead(404);
87
103
  res.end();
@@ -108,9 +124,7 @@ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, url: U
108
124
  res.end();
109
125
  return;
110
126
  }
111
- // 页面本身别缓存:升级后要立刻生效
112
- serveFile(res, file, rel === "/index.html" || rel === "/sw.js" ? "no-cache" : "public, max-age=300");
113
- void req;
127
+ serveFile(req, res, file);
114
128
  }
115
129
 
116
130
  // ---------- WebSocket ----------
@@ -397,7 +411,7 @@ export async function startWeb(): Promise<void> {
397
411
  });
398
412
  ws.on("close", () => clients.delete(client));
399
413
  ws.on("error", () => clients.delete(client));
400
- send(client, { t: "hello", seq: hub.currentSeq(), epoch: EPOCH });
414
+ send(client, { t: "hello", seq: hub.currentSeq(), epoch: EPOCH, version: RUNNING_VERSION });
401
415
  });
402
416
  } catch (err) {
403
417
  console.error("[web] WebSocket 握手出错:", err);
@@ -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
@@ -38,7 +38,6 @@ const ICON = {
38
38
  stop: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="7" y="7" width="10" height="10" rx="1.5"/></svg>',
39
39
  clip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.6-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>',
40
40
  mic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
41
- more: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>',
42
41
  down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg>',
43
42
  gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/></svg>',
44
43
  fork: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10"/><path d="M18 9c0 5-6 4-11.2 8.6"/></svg>',
@@ -269,6 +268,12 @@ const desktop = window.agentLoftDesktop ?? null;
269
268
  const desktopQuota = typeof desktop?.onQuota === "function";
270
269
  // 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 style.css 的 .in-desktop)
271
270
  if (desktop) document.documentElement.classList.add("in-desktop");
271
+ /**
272
+ * 新版桌面端顶栏的「更多」里能打开每台服务器的设置,额度也在它的菜单栏里:侧栏底部的额度和设置就不显示了。
273
+ * 老版本桌面端没有这个入口,照旧显示,免得设置打不开。
274
+ */
275
+ const desktopSettings = typeof desktop?.onOpenSettings === "function";
276
+ if (desktopSettings) document.documentElement.classList.add("desktop-settings");
272
277
 
273
278
  // ---------- WebSocket ----------
274
279
 
@@ -402,6 +407,7 @@ function markGone(ix) {
402
407
  function onWsMessage(msg) {
403
408
  switch (msg.t) {
404
409
  case "hello": {
410
+ if (checkServerVersion(msg.version)) return;
405
411
  const restarted = WS.epoch && WS.epoch !== msg.epoch;
406
412
  WS.epoch = msg.epoch;
407
413
  WS.hellos += 1;
@@ -464,14 +470,31 @@ function onWsMessage(msg) {
464
470
  }
465
471
  }
466
472
 
467
- function setConnBanner(text) {
473
+ function setConnBanner(text, onclick) {
468
474
  ui.banner?.remove();
469
475
  ui.banner = null;
470
476
  if (!text || !ui.main) return;
471
- ui.banner = h("div", { class: "conn-banner" }, text);
477
+ ui.banner = onclick ? h("button", { class: "conn-banner clickable", onclick }, text) : h("div", { class: "conn-banner" }, text);
472
478
  ui.main.append(ui.banner);
473
479
  }
474
480
 
481
+ /** 页面加载时连上的服务端版本。服务端升级重启后版本变了,页面上跑的还是旧前端,得刷新 */
482
+ let loadedServerVersion = null;
483
+
484
+ /** 服务端换了版本:刷新页面换上新前端。返回 true 表示已经在刷新了,后面不用再处理 */
485
+ function checkServerVersion(version) {
486
+ if (!version) return false;
487
+ if (!loadedServerVersion) loadedServerVersion = version;
488
+ if (version === loadedServerVersion) return false;
489
+ // 输入框里有没发出去的字:别直接刷掉,提示一下让用户自己点
490
+ if (ui.input?.value.trim()) {
491
+ setConnBanner(`服务端已升级到 ${version},点这里刷新页面`, () => location.reload());
492
+ return false;
493
+ }
494
+ location.reload();
495
+ return true;
496
+ }
497
+
475
498
  // ---------- 登录 ----------
476
499
 
477
500
  async function showAuth() {
@@ -549,6 +572,12 @@ async function boot() {
549
572
  desktop?.onOpen((key) => {
550
573
  if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
551
574
  });
575
+ // 桌面端「更多」里点了这台服务器的设置;已经开着就不再叠一层
576
+ if (desktopSettings) {
577
+ desktop.onOpenSettings(() => {
578
+ if (!document.querySelector(".overlay .settings")) void openSettings();
579
+ });
580
+ }
552
581
  if ("serviceWorker" in navigator) {
553
582
  navigator.serviceWorker.register("/sw.js").catch(() => {});
554
583
  clearAppBadge();
@@ -579,6 +608,13 @@ function renderLayout() {
579
608
  lastDown = { key, at: now };
580
609
  });
581
610
  ui.convList.addEventListener("contextmenu", (e) => {
611
+ // 飞书机器人那一行:右键看这个机器人的所有聊天
612
+ const bot = e.target.closest?.("[data-bot]")?.dataset.bot;
613
+ if (bot) {
614
+ e.preventDefault();
615
+ showMenu({ x: e.clientX, y: e.clientY }, [{ label: "历史会话…", onclick: () => openBotConversations(bot) }]);
616
+ return;
617
+ }
582
618
  const key = e.target.closest?.("[data-key]")?.dataset.key;
583
619
  if (!key || !S.convs.has(key)) return;
584
620
  e.preventDefault();
@@ -1025,6 +1061,7 @@ function renderSidebar() {
1025
1061
  "button",
1026
1062
  {
1027
1063
  class: `conv-item${cur?.channel === "feishu" && cur.channelId === bot.id ? " active" : ""}`,
1064
+ "data-bot": bot.id,
1028
1065
  title: latest ? `飞书 · ${bot.name}\n${agentLabel(latest.agent)} · ${latest.cwd}` : `飞书 · ${bot.name}\n还没有会话`,
1029
1066
  onclick: () => (latest ? open(latest.key) : toast("这个机器人还没有会话,先在飞书里给它发条消息")),
1030
1067
  },
@@ -1117,13 +1154,9 @@ function renderSidebar() {
1117
1154
 
1118
1155
  function renderTopbar() {
1119
1156
  const c = S.current ? S.convs.get(S.current) : null;
1120
- const more = h("button", { class: "icon-btn", title: "更多", onclick: (e) => openMoreMenu(e.currentTarget) }, icon("more"));
1121
1157
  fill(ui.topbar,
1122
- ...[
1123
- h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
1124
- h("div", { class: "top-title" }, c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null),
1125
- c ? more : null,
1126
- ].filter(Boolean),
1158
+ h("button", { class: "icon-btn menu-btn", onclick: openSide, title: "会话列表" }, icon("menu")),
1159
+ h("div", { class: "top-title" }, c ? h("span", { class: "conv-name", title: convTitle(c) }, convTitle(c)) : null),
1127
1160
  );
1128
1161
  renderContext();
1129
1162
  }
@@ -1380,24 +1413,6 @@ function closeMenu() {
1380
1413
  removeEventListener("blur", closeMenu);
1381
1414
  }
1382
1415
 
1383
- function openMoreMenu(anchor) {
1384
- const c = S.convs.get(S.current);
1385
- if (!c) return;
1386
- showMenu(anchor, [
1387
- // 飞书机器人在侧栏只占一行(点开最近活跃的那个聊天),它的其他聊天从这里找
1388
- ...(c.channel === "feishu" ? [{ label: "历史会话…", onclick: () => openBotConversations(c.channelId) }] : []),
1389
- { label: "开新会话(清空上下文)", onclick: () => doAction("new") },
1390
- c.canFork
1391
- ? { label: "分叉成新对话", onclick: () => forkConversation() }
1392
- : { label: "分叉当前会话", onclick: () => doAction("fork") },
1393
- { label: "切换工作目录…", onclick: () => changeCwd() },
1394
- { label: "查看状态", onclick: () => showStatus() },
1395
- "sep",
1396
- { label: "重命名", onclick: () => renameConv(S.current) },
1397
- ...(c.channel === "web" ? [{ label: "从列表删除", danger: true, onclick: () => deleteConv() }] : []),
1398
- ]);
1399
- }
1400
-
1401
1416
  /** 侧栏会话的右键菜单:分叉 / 重命名 / 删除 */
1402
1417
  function openConvContextMenu(key, at) {
1403
1418
  const c = S.convs.get(key);
@@ -1413,10 +1428,64 @@ function openConvContextMenu(key, at) {
1413
1428
  },
1414
1429
  },
1415
1430
  { label: "重命名", onclick: () => renameConv(key) },
1431
+ ...(c.channel === "web" && S.bots.length > 0 ? [{ label: "在飞书继续…", onclick: () => continueInFeishu(key) }] : []),
1416
1432
  ...(c.channel === "web" ? [{ label: "删除", danger: true, onclick: () => deleteConv(key) }] : []),
1417
1433
  ]);
1418
1434
  }
1419
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
+
1420
1489
  /** 飞书机器人的历史会话:这个机器人的所有聊天,按活跃时间排,点一下打开 */
1421
1490
  function openBotConversations(botId) {
1422
1491
  const bot = S.bots.find((b) => b.id === botId);
@@ -2041,7 +2110,8 @@ function renderComposer() {
2041
2110
  },
2042
2111
  });
2043
2112
  ui.sendBtn = h("button", { class: "send-btn", title: "发送", onclick: () => (S.busy && !hasDraft() ? stopTurn() : sendMessage()) });
2044
- ui.micBtn = SpeechRec
2113
+ // 桌面端(Electron)里不放语音输入
2114
+ ui.micBtn = SpeechRec && !desktop
2045
2115
  ? h("button", { class: "icon-btn mic-btn", title: "语音输入", onclick: toggleVoice }, icon("mic"))
2046
2116
  : null;
2047
2117
  ui.ctxBar = h("div", { class: "ctx-bar" });
@@ -2945,15 +3015,6 @@ async function changeCwd() {
2945
3015
  await load(startRel);
2946
3016
  }
2947
3017
 
2948
- async function showStatus() {
2949
- try {
2950
- const data = await api(`/api/conversations/${encodeURIComponent(S.current)}/status`);
2951
- modal({ title: "状态", body: h("div", { class: "modal-body" }, renderMarkdown("```\n" + data.text + "\n```")) });
2952
- } catch (err) {
2953
- toast(err.message);
2954
- }
2955
- }
2956
-
2957
3018
  /** 重命名:小弹窗,回车保存、Esc 取消,打开时全选原名 */
2958
3019
  function renameConv(key = S.current) {
2959
3020
  const c = S.convs.get(key);
@@ -3512,7 +3573,7 @@ function modelPicker(agent, model, effort, defaultLabel) {
3512
3573
  }
3513
3574
 
3514
3575
  /** agent-loft 内置命令名(和服务端 BUILTIN_COMMANDS 一致),自定义命令不能用 */
3515
- 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"];
3516
3577
 
3517
3578
  /**
3518
3579
  * 添加 / 编辑一条自定义命令。onSave 返回 true 才关窗;失败(比如服务端校验没过)留着让用户改。
package/web/style.css CHANGED
@@ -550,6 +550,11 @@ button.conv-group:hover {
550
550
  margin-left: auto;
551
551
  }
552
552
 
553
+ /* 桌面端里:额度在菜单栏、设置在顶栏「更多」里,侧栏底部整条不要 */
554
+ .desktop-settings .side-bottom {
555
+ display: none;
556
+ }
557
+
553
558
  /* ---- 侧栏底部的额度 ---- */
554
559
  .quota {
555
560
  position: relative;
@@ -2139,6 +2144,13 @@ button.ctx-item:hover {
2139
2144
  z-index: 5;
2140
2145
  }
2141
2146
 
2147
+ .conn-banner.clickable {
2148
+ border: 0;
2149
+ font: inherit;
2150
+ font-size: 12px;
2151
+ cursor: pointer;
2152
+ }
2153
+
2142
2154
  /* ---------- 弹层 ---------- */
2143
2155
 
2144
2156
  .overlay {