agent-loft 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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);
@@ -2120,9 +2174,11 @@ const VOICE_ERR = {
2120
2174
  };
2121
2175
 
2122
2176
  /** rec:当前识别实例;before/after:开始时光标前后的文字;committed:已确认的识别结果 */
2123
- const voice = { rec: null, want: false, before: "", after: "", committed: "", target: null };
2177
+ const voice = { rec: null, want: false, before: "", after: "", committed: "", target: null, stopTimer: null };
2124
2178
 
2125
2179
  function toggleVoice() {
2180
+ // 点过停止、正在等收尾(按钮已经不亮了):再点就是要重新开始,先丢掉旧的
2181
+ if (voice.rec && !voice.want) cancelVoice();
2126
2182
  if (voice.rec) stopVoice();
2127
2183
  else startVoice();
2128
2184
  }
@@ -2145,61 +2201,121 @@ function startVoice() {
2145
2201
  updateMicBtn();
2146
2202
  }
2147
2203
 
2204
+ /**
2205
+ * 正常结束的识别实例留着下次复用:iOS Safari 每次 new 一个新实例,
2206
+ * 第二次开始往往一个结果都不回。被 abort / 卡住的实例不复用,下次换新的。
2207
+ */
2208
+ let spareRec = null;
2209
+
2210
+ /** voice.rec 是一轮识别(run):{ rec, heard, shown, done },事件都按 run 对号入座 */
2148
2211
  function listenOnce() {
2149
- const rec = new SpeechRec();
2150
- rec.lang = navigator.language || "zh-CN";
2151
- rec.continuous = voiceContinuous;
2152
- rec.interimResults = true;
2153
- let heard = "";
2154
- rec.onresult = (e) => {
2155
- let fin = "";
2156
- let interim = "";
2157
- for (const r of e.results) {
2158
- if (r.isFinal) fin += r[0].transcript;
2159
- else interim += r[0].transcript;
2160
- }
2161
- heard = fin;
2162
- showVoiceText(joinVoice(voice.committed, fin + interim));
2163
- };
2164
- rec.onerror = (e) => {
2165
- if (e.error === "no-speech" || e.error === "aborted") return;
2166
- voice.want = false;
2167
- toast(VOICE_ERR[e.error] ?? `语音识别出错:${e.error}`);
2168
- };
2169
- rec.onend = () => {
2170
- if (voice.rec !== rec) return; // 已被取消
2171
- voice.committed = joinVoice(voice.committed, heard);
2172
- showVoiceText(voice.committed);
2173
- // 这一轮什么都没听到就当说完了,避免麦克风一直开着
2174
- if (voice.want && heard) {
2175
- try {
2176
- listenOnce();
2177
- return;
2178
- } catch {
2179
- // 续不上就结束
2212
+ const run = { rec: null, heard: "", shown: "", done: false };
2213
+ const wire = (rec) => {
2214
+ rec.lang = navigator.language || "zh-CN";
2215
+ rec.continuous = voiceContinuous;
2216
+ rec.interimResults = true;
2217
+ rec.onresult = (e) => {
2218
+ if (voice.rec !== run) return;
2219
+ let fin = "";
2220
+ let interim = "";
2221
+ for (const r of e.results) {
2222
+ if (r.isFinal) fin += r[0].transcript;
2223
+ else interim += r[0].transcript;
2180
2224
  }
2181
- }
2182
- voice.rec = null;
2183
- voice.want = false;
2184
- updateMicBtn();
2225
+ run.heard = fin;
2226
+ run.shown = fin + interim;
2227
+ showVoiceText(joinVoice(voice.committed, run.shown));
2228
+ };
2229
+ rec.onerror = (e) => {
2230
+ if (voice.rec !== run) return;
2231
+ if (e.error === "no-speech" || e.error === "aborted") return;
2232
+ voice.want = false;
2233
+ toast(VOICE_ERR[e.error] ?? `语音识别出错:${e.error}`);
2234
+ };
2235
+ rec.onend = () => {
2236
+ if (voice.rec !== run) return; // 已被取消 / 已被强制结束
2237
+ finishRun(run, true);
2238
+ };
2239
+ return rec;
2185
2240
  };
2186
- voice.rec = rec;
2187
- rec.start();
2241
+ let rec = wire(spareRec ?? new SpeechRec());
2242
+ spareRec = null;
2243
+ run.rec = rec;
2244
+ voice.rec = run;
2245
+ try {
2246
+ rec.start();
2247
+ } catch (err) {
2248
+ // 复用的实例还没完全停下(InvalidStateError):换个新的再来
2249
+ if (err?.name !== "InvalidStateError") throw err;
2250
+ rec = wire(new SpeechRec());
2251
+ run.rec = rec;
2252
+ rec.start();
2253
+ }
2254
+ }
2255
+
2256
+ /** 一轮识别结束:把这轮听到的定下来,需要的话接着听下一轮 */
2257
+ function finishRun(run, clean) {
2258
+ if (run.done) return;
2259
+ run.done = true;
2260
+ clearTimeout(voice.stopTimer);
2261
+ // Safari 有时整轮都不给 isFinal:没有定稿就以屏幕上显示的为准,不能把已经显示的字吞掉
2262
+ const heard = run.heard || run.shown;
2263
+ voice.committed = joinVoice(voice.committed, heard);
2264
+ showVoiceText(voice.committed);
2265
+ if (clean) spareRec = run.rec;
2266
+ // 这一轮什么都没听到就当说完了,避免麦克风一直开着
2267
+ if (clean && voice.want && heard) {
2268
+ try {
2269
+ listenOnce();
2270
+ return;
2271
+ } catch {
2272
+ // 续不上就结束
2273
+ }
2274
+ }
2275
+ voice.rec = null;
2276
+ voice.want = false;
2277
+ updateMicBtn();
2188
2278
  }
2189
2279
 
2190
2280
  /** 用户点停止:让浏览器把最后一句定稿后再结束 */
2191
2281
  function stopVoice() {
2282
+ const run = voice.rec;
2283
+ if (!run) return;
2192
2284
  voice.want = false;
2193
- voice.rec?.stop();
2285
+ // 先把按钮恢复,用户能立刻看到已经停了
2286
+ if (ui.micBtn) ui.micBtn.classList.remove("recording");
2287
+ try {
2288
+ run.rec.stop();
2289
+ } catch {
2290
+ // 已经停了
2291
+ }
2292
+ // iOS 上 stop() 之后有时永远等不来 onend,按钮就一直卡在「录音中」,再点只会又 stop 一次。
2293
+ // 等一会儿还没结束就强制收尾,这个实例不再复用
2294
+ clearTimeout(voice.stopTimer);
2295
+ voice.stopTimer = setTimeout(() => {
2296
+ if (voice.rec !== run) return;
2297
+ try {
2298
+ run.rec.abort();
2299
+ } catch {
2300
+ // 忽略
2301
+ }
2302
+ finishRun(run, false);
2303
+ }, 1500);
2194
2304
  }
2195
2305
 
2196
2306
  /** 发送、切会话、用户手动改输入框时:立刻丢掉识别,不再往输入框写 */
2197
2307
  function cancelVoice() {
2198
- const rec = voice.rec;
2199
- if (!rec) return;
2308
+ const run = voice.rec;
2309
+ if (!run) return;
2310
+ clearTimeout(voice.stopTimer);
2311
+ run.done = true;
2200
2312
  voice.rec = null;
2201
2313
  voice.want = false;
2202
- rec.abort();
2314
+ try {
2315
+ run.rec.abort();
2316
+ } catch {
2317
+ // 忽略
2318
+ }
2203
2319
  updateMicBtn();
2204
2320
  }
2205
2321
 
@@ -2238,6 +2354,13 @@ function hasDraft() {
2238
2354
 
2239
2355
  function updateSendBtn() {
2240
2356
  if (!ui.sendBtn) return;
2357
+ // 已经点了发送、还在等服务器确认:转圈,不能再点,避免以为没点上又点一次
2358
+ if (S.sending) {
2359
+ ui.sendBtn.innerHTML = '<span class="send-spinner"></span>';
2360
+ ui.sendBtn.title = "发送中…";
2361
+ ui.sendBtn.disabled = true;
2362
+ return;
2363
+ }
2241
2364
  const stop = S.busy && !hasDraft();
2242
2365
  ui.sendBtn.innerHTML = stop ? ICON.stop : ICON.send;
2243
2366
  ui.sendBtn.title = stop ? "停止" : "发送";
@@ -2348,7 +2471,18 @@ async function addFiles(files) {
2348
2471
  }
2349
2472
  }
2350
2473
 
2474
+ /** 发送中:按钮转圈、输入框只读(确认后要清空它,期间再打的字会丢) */
2475
+ function setSending(on) {
2476
+ S.sending = on;
2477
+ if (ui.input) {
2478
+ ui.input.readOnly = on;
2479
+ ui.input.classList.toggle("sending", on);
2480
+ }
2481
+ updateSendBtn();
2482
+ }
2483
+
2351
2484
  async function sendMessage() {
2485
+ if (S.sending) return;
2352
2486
  cancelVoice();
2353
2487
  const text = ui.input.value;
2354
2488
  if (!text.trim() && S.uploads.length === 0) return;
@@ -2359,6 +2493,7 @@ async function sendMessage() {
2359
2493
  closeSlashMenu();
2360
2494
  let key;
2361
2495
  let res;
2496
+ setSending(true);
2362
2497
  try {
2363
2498
  key = await ensureConversation();
2364
2499
  if ("Notification" in window && Notification.permission === "default") void Notification.requestPermission();
@@ -2367,6 +2502,8 @@ async function sendMessage() {
2367
2502
  } catch (err) {
2368
2503
  toast(err.message);
2369
2504
  return;
2505
+ } finally {
2506
+ setSending(false);
2370
2507
  }
2371
2508
  if (!res?.reply) {
2372
2509
  sentKeys.add(key);
@@ -3519,7 +3656,7 @@ function modelPicker(agent, model, effort, defaultLabel) {
3519
3656
  }
3520
3657
 
3521
3658
  /** agent-loft 内置命令名(和服务端 BUILTIN_COMMANDS 一致),自定义命令不能用 */
3522
- const BUILTIN_NAMES = ["new", "status", "stop", "model", "effort", "sessions", "resume", "fork", "agent", "claude", "codex", "cd", "pwd", "autoedit", "approve", "help"];
3659
+ const BUILTIN_NAMES = ["new", "status", "stop", "model", "effort", "sessions", "resume", "fork", "agent", "claude", "codex", "cd", "pwd", "autoedit", "web", "detach", "approve", "help"];
3523
3660
 
3524
3661
  /**
3525
3662
  * 添加 / 编辑一条自定义命令。onSave 返回 true 才关窗;失败(比如服务端校验没过)留着让用户改。
package/web/style.css CHANGED
@@ -180,6 +180,15 @@ select.input {
180
180
  opacity: 0.7;
181
181
  }
182
182
 
183
+ /* 放在 .field 里的复选框 / 单选框不套用上面输入框的宽度和内边距:
184
+ 否则复选框被撑宽,旁边的文字会被挤成两行(比如飞书机器人的「可用的 Agent」) */
185
+ .field input[type="checkbox"],
186
+ .field input[type="radio"] {
187
+ width: auto;
188
+ padding: 0;
189
+ flex-shrink: 0;
190
+ }
191
+
183
192
  .input:focus,
184
193
  .field input:focus,
185
194
  .field select:focus,
@@ -2091,6 +2100,24 @@ button.ctx-item:hover {
2091
2100
  opacity: 0.4;
2092
2101
  }
2093
2102
 
2103
+ .send-btn:has(.send-spinner) {
2104
+ opacity: 1;
2105
+ }
2106
+
2107
+ /* 发送中:按钮里转圈,颜色跟着按钮的前景色走(深浅模式都看得清) */
2108
+ .send-spinner {
2109
+ width: 14px;
2110
+ height: 14px;
2111
+ border: 2px solid currentColor;
2112
+ border-right-color: transparent;
2113
+ border-radius: 50%;
2114
+ animation: spin 0.8s linear infinite;
2115
+ }
2116
+
2117
+ textarea.sending {
2118
+ opacity: 0.6;
2119
+ }
2120
+
2094
2121
  .send-btn svg {
2095
2122
  width: 15px;
2096
2123
  height: 15px;