agent-loft 0.2.20 → 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.
package/README.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  # agent-loft
4
4
 
5
- **多个 agent 的工作台。** 把本机的 **Claude Code**、**Codex**(以后还有更多)放到同一张台面上统一管理,用飞书(以后还有 Telegram 等)和 Web 页面随时随地驱动它们。
5
+ **多个 agent 的工作台。** 把本机的 **Claude Code**、**Codex**(以后还有更多)放到同一张台面上统一管理,在网页、飞书和 Mac 桌面端随时驱动它们,进展推送到飞书、iPhone 和 Mac。
6
+
7
+ ![Web 工作台](docs/images/web-chat.png)
8
+
9
+ > 📖 完整的产品介绍和使用指南见 **[docs/产品介绍.md](docs/产品介绍.md)**:六大核心亮点、快速上手、使用指南、命令速查、常见问题和故障排查。
6
10
 
7
11
  由 `feishu-claude-bridge` 和 `feishu-codex-bridge` 合并而来:一个进程、一份配置,飞书和网页共用同一套会话、审批和模型切换。
8
12
 
@@ -10,57 +14,71 @@
10
14
 
11
15
  这里的 Loft 不是阁楼,是**工作台**:agent 是干活的人,agent-loft 是它们共用的那张台面。
12
16
 
13
- - **多 agent,一张台面**:Claude Code、Codex 各有所长,不该各开一个窗口、各记一套会话。它们在这里共用工作目录、会话列表、审批和模型切换,同一条对话里随时换人接手,各自的上下文都还在。
17
+ - **多 agent,一张台面**:Claude Code、Codex 各有所长,不该各开一个窗口、各记一套会话。它们在这里共用工作目录、会话列表、审批和模型切换,同一条会话里随时换人接手,各自的上下文都保留着。
14
18
  - **统一管理,而不是再造一个 agent**:agent-loft 不自己做模型、不改 agent 的行为,只负责把官方的 agent 接进来、管起来。新的 agent 接一个驱动就能上台。
15
19
  - **人在哪,台面就在哪**:电脑上用网页,手机上用 PWA 或飞书。同一个会话、同一个授权请求,在哪个渠道处理都行,其他地方自动同步。
16
20
  - **跑在你自己的机器上**:代码、凭据和会话都留在本机,agent-loft 只是一层安全的遥控。
17
21
 
18
22
  Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张台面上。
19
23
 
24
+ ## 六大核心亮点
25
+
26
+ | | 亮点 | 说明 |
27
+ |---|---|---|
28
+ | 🤖 | **同时管理多个 agent** | Claude Code(官方 Claude Agent SDK)和 Codex(官方 `codex app-server`)共用会话列表、工作目录和授权。同一条会话里用 `/claude`、`/codex`、`/agent` 切换,每个 agent 保留自己的上下文 |
29
+ | 🔀 | **飞书机器人灵活绑定 agent** | 机器人数量不限,每个机器人可以只对应一个 agent,也可以对应多个(在会话里切换)。网页上扫码就能创建,改完立即生效;由服务端主动连向飞书,家里没有公网 IP 也能在外面用 |
30
+ | 🔗 | **飞书续接 Web 任务** | 飞书里发 `/web` 接上一条 Web 会话,或者在网页侧栏右键会话选「在飞书继续…」。之后回复、授权、提问都发到飞书,发 `/detach` 回来 |
31
+ | 🖥️ | **完整的 Web 工作台** | 流式回复、附件、语音输入、模型和思考程度切换、上下文占用、从任意回复分叉、按项目分组的侧栏、全部设置,电脑和手机(PWA)都能用 |
32
+ | 🔔 | **飞书 + 苹果系统推送** | 除了飞书消息,Web 会话还能推送到 iPhone、iPad 锁屏和 Mac 通知中心(标准 Web Push,不需要开发者账号),图标上显示未读数 |
33
+ | 🗂️ | **桌面端同时接入多台设备** | macOS 桌面端同时连多台服务器,菜单栏一眼看完各台的额度、待处理和运行中的会话 |
34
+
20
35
  ## 能做什么
21
36
 
22
- - **多个 agent**:Claude Code(官方 Claude Agent SDK)、Codex(官方 `codex app-server`)。在同一条会话里 `/agent` 切换,各 agent 的上下文各自保留。
23
- - **多个飞书机器人**:一个机器人对应一个 agent,或者一个机器人能切换多个 agent,都在设置页里勾选。机器人能在 Web 上扫码创建,改完配置立即生效,不用重启。
24
- - **Web 聊天页**:电脑和手机都能用,界面参照 ChatGPT。
25
- - 回复逐字显示,markdown 和代码高亮,思考过程和工具调用可以折叠。
37
+ - **多个 agent**:Claude Code、Codex。两个 agent 共用工作目录,能看到对方改过的文件,但聊天记录各自独立。
38
+ - **多个飞书机器人**:一个机器人对应一个 agent,或者一个机器人能切换多个 agent,都在设置页里勾选。机器人能在 Web 上扫码创建(Lark 国际版用「手动添加」),改完配置立即生效,不用重启。
39
+ - 群聊里要 @ 机器人;不同话题是各自独立的会话;支持文字、富文本、图片、文件、语音和视频。
40
+ - 思考和工具调用显示在实时刷新的过程卡片里,最终回复是普通消息。
41
+ - 陌生人发消息会拿到配对码,在设置页批准后才能使用;每个机器人单独授权。
42
+ - **Web 工作台**:电脑和手机都能用,界面参照 ChatGPT。
43
+ - 回复逐字显示,markdown 和代码高亮,思考过程和工具调用可以折叠;每条回复下方显示用时、费用和完成时间,可以复制,也可以「从这里分叉」成一条新会话(原会话不动)。
26
44
  - 授权卡、提问卡直接在页面上点。
27
- - 支持上传附件(粘贴、拖拽,手机上可以拍照)。
28
- - 可以「添加到主屏幕」当 App 用。手机切到后台再回来会自动重连,并补上错过的内容。
29
- - 系统通知:回复完成、出错、需要授权或提问时推送到手机 / 电脑(设置 → 通知 里按设备开启)。标准 Web Push,不用注册任何开发者账号;需要 HTTPS 访问,iPhone 需 iOS 16.4+ 且从主屏幕图标打开。
30
- - 收到推送时,主屏 / 桌面 PWA 图标上显示未读角标(iOS 16.4+、桌面 Chrome / Edge),打开页面自动清零。
31
- - 切换工作目录可以在目录树里一层层点选;新对话发第一条消息前也能先选好目录。
32
- - 语音输入:输入框旁的麦克风按钮,用浏览器自带的语音识别(Chrome / Edge / Safari,需要 HTTPS)。
33
- - 新对话页:大标题里点目录名切换工作目录;输入框上方的条里选目录和 agent,右下角直接选模型和思考程度(弹出菜单,不用开弹窗)。会话中 agent 不切换(命令 `/agent` 仍可切),模型和思考程度随时可换。
34
- - 输入框里显示上下文窗口占用(小圆环,悬停看已用 / 剩余 token)。
35
- - 输入框的模型按钮直接显示实际的模型和思考程度(如「Opus 5.5 high」),和选项里的名字一致;列表里显示哪些模型在 设置 → Agent 里勾选。
36
- - 每条回复下方显示用时、费用和完成时间,最后一条常驻,前面的悬停才出现;可以复制,也可以「从这里分叉」成一条新对话(只保留到这条回复,原对话不动)。
37
- - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类,飞书机器人各占一行并带飞书图标;分叉出来的对话挂在主对话下面,用虚线连起来;分组可折叠,宽度可拖动,双击会话重命名,名字太长时悬停滚动显示。
38
- - 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,悬停看重置时间);空闲时按设置的间隔自动刷新(默认 10 分钟),跑完一轮后也会刷新。
39
- - **跨渠道同步**:飞书里的会话在 Web 上也能打开,接着聊。同一个授权请求同时出现在飞书卡片和网页上,哪边先点算哪边,另一边自动变成「已处理」。
40
- - **在飞书里接着 Web 会话**:飞书里发 `/web` 列出最近的 Web 会话,点「接上」后这个飞书聊天说的话、发的命令都进那条会话,它的回复、授权和提问也发到飞书,Web 上说的话同步过来;发 `/detach` 回到这个聊天自己的会话。也可以在 Web 侧栏右键会话 →「在飞书继续…」,选一个机器人聊过的飞书聊天直接接上(只有一个聊天时不用选)。Web 会话被删除时飞书那边自动退回来。
41
- - **设置都在网页上**:工作目录、各 agent 的默认模型和思考程度、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、登录设备管理。
45
+ - 附件:粘贴、拖拽,手机上可以拍照;可以预览和下载。附件存在服务端的专用上传目录里,开新会话或切换目录时清理。
46
+ - 语音输入:用浏览器自带的语音识别(Chrome / Edge / Safari,需要 HTTPS)。
47
+ - 新对话页:点大标题里的目录名,在目录树里逐层选工作目录;输入框上方选 agent,右下角选模型和思考程度。会话进行中用 `/agent` 切换 agent,模型和思考程度随时可换。
48
+ - 输入框里的小圆环显示上下文窗口占用;模型按钮显示实际的模型和思考程度(如「Opus 5.5 high」)。
49
+ - 侧栏分「最近」和「项目」两组:Web 会话按工作目录归类,飞书机器人各占一行(右键看历史会话);分叉出来的会话用虚线挂在原会话下面;会话标题由 agent 自动生成,双击重命名。
50
+ - 侧栏底部显示 Claude / Codex 的订阅额度(5 小时 / 7 天窗口,以及多久后重置)。
51
+ - 「添加到主屏幕」后当 App 用;切到后台再回来会自动重连,补上错过的内容。
52
+ - **系统通知**:Web 会话回复完成、出错、需要授权或提问时推送到手机 / 电脑(设置 → 通知 → 「在此设备开启通知」,可以发测试通知)。飞书会话由飞书提醒,正在前台看着的会话不推。需要 HTTPS 访问,iPhone 需 iOS 16.4+ 且从主屏幕图标打开。
53
+ - **跨渠道同步**:飞书里的会话在 Web 上也能打开接着聊。同一个授权请求同时出现在飞书卡片和网页上,哪边先点算哪边,另一边自动变成「已处理」。
54
+ - **授权**:允许一次 / 本会话全部允许 / 总是允许(按钮上写明放行范围)/ 拒绝。`/autoedit on` 让 Claude Code 在这条会话里改工作目录内的文件不再询问(Bash 仍询问)。超时一律按拒绝处理。
55
+ - **设置都在网页上**:工作目录、会话闲置过期时间、局域网访问和端口、各 agent 的默认模型和思考程度、可选模型、Codex 沙箱、飞书机器人、配对批准、Claude 的「总是允许」名单、通知设备、登录设备管理、服务端升级。
42
56
 
43
57
  ## 安装
44
58
 
59
+ 需要 Linux 或 macOS,Node.js 22 及以上。
60
+
45
61
  ```bash
46
62
  npm install -g agent-loft # 或者在仓库里 npm install 后用 node bin/cli.js
47
63
  agent-loft start # 前台运行,日志里会打印首次登录用的设置码
48
- agent-loft service install # 注册为开机自启服务(systemd / launchd)
64
+ agent-loft service install # 注册为开机自启服务(systemd / launchd),日志在 ~/.agent-loft/logs/
49
65
  ```
50
66
 
51
- 打开 `http://<机器 IP>:8800`:第一次需要输入日志里的设置码,然后设一个登录密码。
67
+ 打开 `http://<机器 IP>:8800`(本机用 `http://localhost:8800`):第一次需要输入日志里的设置码,然后设一个登录密码。设置码找不到的话,直接运行 `agent-loft passwd` 设密码。
68
+
69
+ 到 设置 → Agent 检查 Claude Code、Codex 装好、登录了没有:Claude Code 随 agent-loft 一起安装,只需在服务端终端里登录;Codex 可以点「直接在服务器上安装」。设置 → 通用 里能看到服务端版本,有新版本时点「升级」即可(`npm install -g agent-loft@latest`,以系统服务运行时会自动重启,打开的页面会自动刷新)。
52
70
 
53
- 设置 → 通用 里能看到服务端版本,有新版本时点「升级」即可(`npm install -g agent-loft@latest`,以系统服务运行时会自动重启);设置 → Agent 里能检查 Claude Code、Codex 装好、登录了没有,Codex 可以一键装到服务端上。
71
+ 在外面只用飞书的话不需要任何网络配置(机器人由服务端主动连向飞书)。在外面用网页、系统推送和语音输入需要 HTTPS,见下面的「安全」和[产品介绍里的「从外网访问」](docs/产品介绍.md#从外网访问)。
54
72
 
55
73
  ### macOS 桌面端
56
74
 
57
75
  到 [Releases](https://github.com/qijieinnet/agent-loft/releases) 下载 `desktop-v*` 里对应芯片的 DMG(Apple 芯片选 arm64,Intel 选 x64),拖进「应用程序」。还没有 Apple 开发者签名,第一次打开如果提示无法验证,到 系统设置 → 隐私与安全性 里点「仍要打开」。
58
76
 
59
- - 同时连多台服务器,左边一栏切换;每台各自登录,切走了也在后台连着,回复完成、需要授权时弹系统通知。
60
- - 菜单栏图标:悬停看各服务器的 Claude / Codex 额度、待处理和运行中的会话。
77
+ - 同时连多台服务器,顶部标签栏切换、拖动排序;每台各自登录,切走了也在后台连着,回复完成、需要授权时弹系统通知,程序坞图标显示未读数。
78
+ - 菜单栏图标:悬停看各服务器的 Claude / Codex 额度、待处理和运行中的会话;双击打开主窗口。同一个账号的额度只查一次。
61
79
  - 支持前面挡着 Cloudflare Access 的服务器:验证页直接在窗口里完成,过期会提醒;也可以配 Service Token 免验证。
62
- - 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。
63
- - 服务端有新版本时在服务器列表里点「升级服务端」;桌面端自己的更新在 设置 → 更新 里点「更新并重启」。
80
+ - 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。可选开启「本机有会话在跑时不休眠」。
81
+ - 设置(`⌘,`)的服务器列表里展开某台服务器,能看它的 Agent 环境和版本,有新版本时点「升级服务端」;桌面端自己的更新在同一页的「更新」里点「更新并重启」。
64
82
 
65
83
  桌面端只是个壳(代码在 `desktop/`),服务端照常用 npm 安装和升级。
66
84
 
@@ -86,7 +104,9 @@ agent-loft service install
86
104
  | `agent-loft service install \| uninstall \| restart \| status` | 开机自启 |
87
105
  | `agent-loft doctor` | 体检 |
88
106
 
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`
107
+ `aloft` 是 `agent-loft` 的简写。
108
+
109
+ 聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/autoedit [on|off]` `/approve <id> allow-once|allow-always|allow-session|deny` `/help`;只在飞书里有的:`/web` `/detach`
90
110
 
91
111
  自定义命令:设置 → 命令 → 「添加命令」,填命令名、说明和一段提示词,之后发 `/命令名 参数` 就会把这段提示词发给 agent(提示词里的 `$ARGUMENTS` 换成参数,没写就把参数接在后面)。按 agent 分开存,可以编辑、删除,也会同步到飞书指令面板。
92
112
 
@@ -96,7 +116,7 @@ agent-loft service install
96
116
 
97
117
  - Web 登录密码用 scrypt 哈希保存。首次设置必须用启动日志里的一次性设置码,防止「谁先打开页面谁就成了主人」。
98
118
  - 登录态通过 HttpOnly cookie 保存,服务端只存它的哈希。同一 IP 连续输错 5 次锁 15 分钟。所有写请求和 WebSocket 都校验 Origin。页面开启了严格的 CSP。
99
- - 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky)访问,并把反代的 IP 加进 `web.trustedProxies`(只有从这些地址来的连接才采信 `X-Forwarded-*`)。不要直接把 8800 端口映射出去。
119
+ - 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky)或 Cloudflare Tunnel 访问,并把不在本机的反代 IP 加进 `web.trustedProxies`(只有从这些地址来的连接才采信 `X-Forwarded-*`)。不要直接把 8800 端口映射出去。局域网访问可以在 设置 → 通用 → 网络访问 里关掉。
100
120
  - 审批超时一律按拒绝处理。Codex 默认沙箱为 `workspace-write`,不会因为 `~/.codex/config.toml` 里配了 `never` 或 `danger-full-access` 就绕过审批。
101
121
  - agent 子进程拿不到本服务自己的凭据(`FEISHU_*`、`AGENT_LOFT_*` 等环境变量会被剔除)。
102
122
 
@@ -117,6 +137,8 @@ src/
117
137
  feishu/ 飞书:一个 FeishuBot 对应一个机器人,支持多个
118
138
  web/ HTTP + WebSocket + 鉴权
119
139
  web/ 前端(原生 JS,不需要构建)
140
+ desktop/ macOS 桌面端(Electron 外壳)
141
+ docs/ 产品介绍和截图
120
142
  ```
121
143
 
122
144
  ### 接入新的消息渠道(比如 Telegram)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
package/web/app.js CHANGED
@@ -2174,9 +2174,11 @@ const VOICE_ERR = {
2174
2174
  };
2175
2175
 
2176
2176
  /** rec:当前识别实例;before/after:开始时光标前后的文字;committed:已确认的识别结果 */
2177
- 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 };
2178
2178
 
2179
2179
  function toggleVoice() {
2180
+ // 点过停止、正在等收尾(按钮已经不亮了):再点就是要重新开始,先丢掉旧的
2181
+ if (voice.rec && !voice.want) cancelVoice();
2180
2182
  if (voice.rec) stopVoice();
2181
2183
  else startVoice();
2182
2184
  }
@@ -2199,61 +2201,121 @@ function startVoice() {
2199
2201
  updateMicBtn();
2200
2202
  }
2201
2203
 
2204
+ /**
2205
+ * 正常结束的识别实例留着下次复用:iOS Safari 每次 new 一个新实例,
2206
+ * 第二次开始往往一个结果都不回。被 abort / 卡住的实例不复用,下次换新的。
2207
+ */
2208
+ let spareRec = null;
2209
+
2210
+ /** voice.rec 是一轮识别(run):{ rec, heard, shown, done },事件都按 run 对号入座 */
2202
2211
  function listenOnce() {
2203
- const rec = new SpeechRec();
2204
- rec.lang = navigator.language || "zh-CN";
2205
- rec.continuous = voiceContinuous;
2206
- rec.interimResults = true;
2207
- let heard = "";
2208
- rec.onresult = (e) => {
2209
- let fin = "";
2210
- let interim = "";
2211
- for (const r of e.results) {
2212
- if (r.isFinal) fin += r[0].transcript;
2213
- else interim += r[0].transcript;
2214
- }
2215
- heard = fin;
2216
- showVoiceText(joinVoice(voice.committed, fin + interim));
2217
- };
2218
- rec.onerror = (e) => {
2219
- if (e.error === "no-speech" || e.error === "aborted") return;
2220
- voice.want = false;
2221
- toast(VOICE_ERR[e.error] ?? `语音识别出错:${e.error}`);
2222
- };
2223
- rec.onend = () => {
2224
- if (voice.rec !== rec) return; // 已被取消
2225
- voice.committed = joinVoice(voice.committed, heard);
2226
- showVoiceText(voice.committed);
2227
- // 这一轮什么都没听到就当说完了,避免麦克风一直开着
2228
- if (voice.want && heard) {
2229
- try {
2230
- listenOnce();
2231
- return;
2232
- } catch {
2233
- // 续不上就结束
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;
2234
2224
  }
2235
- }
2236
- voice.rec = null;
2237
- voice.want = false;
2238
- 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;
2239
2240
  };
2240
- voice.rec = rec;
2241
- 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();
2242
2278
  }
2243
2279
 
2244
2280
  /** 用户点停止:让浏览器把最后一句定稿后再结束 */
2245
2281
  function stopVoice() {
2282
+ const run = voice.rec;
2283
+ if (!run) return;
2246
2284
  voice.want = false;
2247
- 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);
2248
2304
  }
2249
2305
 
2250
2306
  /** 发送、切会话、用户手动改输入框时:立刻丢掉识别,不再往输入框写 */
2251
2307
  function cancelVoice() {
2252
- const rec = voice.rec;
2253
- if (!rec) return;
2308
+ const run = voice.rec;
2309
+ if (!run) return;
2310
+ clearTimeout(voice.stopTimer);
2311
+ run.done = true;
2254
2312
  voice.rec = null;
2255
2313
  voice.want = false;
2256
- rec.abort();
2314
+ try {
2315
+ run.rec.abort();
2316
+ } catch {
2317
+ // 忽略
2318
+ }
2257
2319
  updateMicBtn();
2258
2320
  }
2259
2321
 
@@ -2292,6 +2354,13 @@ function hasDraft() {
2292
2354
 
2293
2355
  function updateSendBtn() {
2294
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
+ }
2295
2364
  const stop = S.busy && !hasDraft();
2296
2365
  ui.sendBtn.innerHTML = stop ? ICON.stop : ICON.send;
2297
2366
  ui.sendBtn.title = stop ? "停止" : "发送";
@@ -2402,7 +2471,18 @@ async function addFiles(files) {
2402
2471
  }
2403
2472
  }
2404
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
+
2405
2484
  async function sendMessage() {
2485
+ if (S.sending) return;
2406
2486
  cancelVoice();
2407
2487
  const text = ui.input.value;
2408
2488
  if (!text.trim() && S.uploads.length === 0) return;
@@ -2413,6 +2493,7 @@ async function sendMessage() {
2413
2493
  closeSlashMenu();
2414
2494
  let key;
2415
2495
  let res;
2496
+ setSending(true);
2416
2497
  try {
2417
2498
  key = await ensureConversation();
2418
2499
  if ("Notification" in window && Notification.permission === "default") void Notification.requestPermission();
@@ -2421,6 +2502,8 @@ async function sendMessage() {
2421
2502
  } catch (err) {
2422
2503
  toast(err.message);
2423
2504
  return;
2505
+ } finally {
2506
+ setSending(false);
2424
2507
  }
2425
2508
  if (!res?.reply) {
2426
2509
  sentKeys.add(key);
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;