agent-loft 0.2.20 → 0.2.22
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 +60 -32
- package/package.json +1 -1
- package/src/core/command-catalog.ts +11 -7
- package/src/core/commands.ts +1 -1
- package/web/app.js +165 -77
- package/web/style.css +27 -0
package/README.md
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# agent-loft
|
|
4
4
|
|
|
5
|
-
**多个 agent 的工作台。** 把本机的 **Claude Code**、**Codex
|
|
5
|
+
**多个 agent 的工作台。** 把本机的 **Claude Code**、**Codex**(以后还有更多)放到同一张台面上统一管理,在网页、飞书和桌面端(macOS / Windows / Linux)随时驱动它们,进展推送到飞书、iPhone 和 Mac。
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
> 📖 完整的产品介绍和使用指南见 **[docs/产品介绍.md](docs/产品介绍.md)**:六大核心亮点、快速上手、使用指南、命令速查、常见问题和故障排查。
|
|
6
10
|
|
|
7
11
|
由 `feishu-claude-bridge` 和 `feishu-codex-bridge` 合并而来:一个进程、一份配置,飞书和网页共用同一套会话、审批和模型切换。
|
|
8
12
|
|
|
@@ -10,57 +14,77 @@
|
|
|
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 / Windows / Linux)同时连多台服务器,菜单栏 / 托盘一眼看完各台的额度、待处理和运行中的会话 |
|
|
34
|
+
|
|
20
35
|
## 能做什么
|
|
21
36
|
|
|
22
|
-
- **多个 agent**:Claude Code
|
|
23
|
-
- **多个飞书机器人**:一个机器人对应一个 agent,或者一个机器人能切换多个 agent,都在设置页里勾选。机器人能在 Web
|
|
24
|
-
-
|
|
25
|
-
-
|
|
37
|
+
- **多个 agent**:Claude Code、Codex。两个 agent 共用工作目录,能看到对方改过的文件,但聊天记录各自独立。
|
|
38
|
+
- **多个飞书机器人**:一个机器人对应一个 agent,或者一个机器人能切换多个 agent,都在设置页里勾选。机器人能在 Web 上扫码创建(Lark 国际版用「手动添加」),改完配置立即生效,不用重启。
|
|
39
|
+
- 群聊里要 @ 机器人;不同话题是各自独立的会话;支持文字、富文本、图片、文件、语音和视频。
|
|
40
|
+
- 思考和工具调用显示在实时刷新的过程卡片里,最终回复是普通消息。
|
|
41
|
+
- 陌生人发消息会拿到配对码,在设置页批准后才能使用;每个机器人单独授权。
|
|
42
|
+
- **Web 工作台**:电脑和手机都能用,界面参照 ChatGPT。
|
|
43
|
+
- 回复逐字显示,markdown 和代码高亮,思考过程和工具调用可以折叠;每条回复下方显示用时、费用和完成时间,可以复制,也可以「从这里分叉」成一条新会话(原会话不动)。
|
|
26
44
|
- 授权卡、提问卡直接在页面上点。
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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` 设密码。
|
|
52
68
|
|
|
53
|
-
设置 →
|
|
69
|
+
到 设置 → Agent 检查 Claude Code、Codex 装好、登录了没有:Claude Code 随 agent-loft 一起安装,只需在服务端终端里登录;Codex 可以点「直接在服务器上安装」。设置 → 系统 里能看到服务端版本,有新版本时点「升级」即可(`npm install -g agent-loft@latest`,以系统服务运行时会自动重启,打开的页面会自动刷新)。
|
|
54
70
|
|
|
55
|
-
|
|
71
|
+
在外面只用飞书的话不需要任何网络配置(机器人由服务端主动连向飞书)。在外面用网页、系统推送和语音输入需要 HTTPS,见下面的「安全」和[产品介绍里的「从外网访问」](docs/产品介绍.md#从外网访问)。
|
|
56
72
|
|
|
57
|
-
|
|
73
|
+
### 桌面端(macOS / Windows / Linux)
|
|
58
74
|
|
|
59
|
-
-
|
|
60
|
-
|
|
75
|
+
到 [Releases](https://github.com/qijieinnet/agent-loft/releases) 下载 `desktop-v*` 里对应的安装包:
|
|
76
|
+
|
|
77
|
+
- **macOS**:对应芯片的 DMG(Apple 芯片选 arm64,Intel 选 x64),拖进「应用程序」。还没有 Apple 开发者签名,第一次打开如果提示无法验证,到 系统设置 → 隐私与安全性 里点「仍要打开」。
|
|
78
|
+
- **Windows**:`Agent-Loft-Setup-*-x64.exe`,装在用户目录里,不用管理员权限。没有代码签名,SmartScreen 拦的话点「更多信息 → 仍要运行」。
|
|
79
|
+
- **Linux**:Debian / Ubuntu 用 deb(`sudo apt install ./agent-loft-desktop_*_amd64.deb`),其他发行版用 AppImage(需要 libfuse2);ARM 选 arm64。App 内一键更新只支持 AppImage。GNOME 要装 AppIndicator 扩展才看得到托盘图标。
|
|
80
|
+
|
|
81
|
+
下面以 macOS 为例,Windows / Linux 上「菜单栏」是托盘、`⌘` 是 `Ctrl`:
|
|
82
|
+
|
|
83
|
+
- 同时连多台服务器,顶部标签栏切换、拖动排序;每台各自登录,切走了也在后台连着,回复完成、需要授权时弹系统通知,程序坞图标显示未读数。
|
|
84
|
+
- 菜单栏图标:悬停看各服务器的 Claude / Codex 额度、待处理和运行中的会话;双击打开主窗口。同一个账号的额度只查一次。(Windows 上点托盘图标弹出;Linux 上在托盘菜单里点「额度和待处理」。)
|
|
61
85
|
- 支持前面挡着 Cloudflare Access 的服务器:验证页直接在窗口里完成,过期会提醒;也可以配 Service Token 免验证。
|
|
62
|
-
- 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。
|
|
63
|
-
-
|
|
86
|
+
- 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。Linux 上一样(需要先装好 Node.js 22+,开机自启用 systemd 用户服务);Windows 上服务端还没有开机自启,可以在 WSL 里装,桌面端会自动检测到 `127.0.0.1` 上的它。可选开启「本机有会话在跑时不休眠」。
|
|
87
|
+
- 设置(`⌘,`)的服务器列表里展开某台服务器,能看它的 Agent 环境和版本,有新版本时点「升级服务端」;桌面端自己的更新在同一页的「更新」里点「更新并重启」。
|
|
64
88
|
|
|
65
89
|
桌面端只是个壳(代码在 `desktop/`),服务端照常用 npm 安装和升级。
|
|
66
90
|
|
|
@@ -86,7 +110,9 @@ agent-loft service install
|
|
|
86
110
|
| `agent-loft service install \| uninstall \| restart \| status` | 开机自启 |
|
|
87
111
|
| `agent-loft doctor` | 体检 |
|
|
88
112
|
|
|
89
|
-
|
|
113
|
+
`aloft` 是 `agent-loft` 的简写。
|
|
114
|
+
|
|
115
|
+
聊天里的命令(飞书和 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
116
|
|
|
91
117
|
自定义命令:设置 → 命令 → 「添加命令」,填命令名、说明和一段提示词,之后发 `/命令名 参数` 就会把这段提示词发给 agent(提示词里的 `$ARGUMENTS` 换成参数,没写就把参数接在后面)。按 agent 分开存,可以编辑、删除,也会同步到飞书指令面板。
|
|
92
118
|
|
|
@@ -96,7 +122,7 @@ agent-loft service install
|
|
|
96
122
|
|
|
97
123
|
- Web 登录密码用 scrypt 哈希保存。首次设置必须用启动日志里的一次性设置码,防止「谁先打开页面谁就成了主人」。
|
|
98
124
|
- 登录态通过 HttpOnly cookie 保存,服务端只存它的哈希。同一 IP 连续输错 5 次锁 15 分钟。所有写请求和 WebSocket 都校验 Origin。页面开启了严格的 CSP。
|
|
99
|
-
- 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky
|
|
125
|
+
- 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky)或 Cloudflare Tunnel 访问,并把不在本机的反代 IP 加进 `web.trustedProxies`(只有从这些地址来的连接才采信 `X-Forwarded-*`)。不要直接把 8800 端口映射出去。局域网访问可以在 设置 → 系统 → 网络访问 里关掉。
|
|
100
126
|
- 审批超时一律按拒绝处理。Codex 默认沙箱为 `workspace-write`,不会因为 `~/.codex/config.toml` 里配了 `never` 或 `danger-full-access` 就绕过审批。
|
|
101
127
|
- agent 子进程拿不到本服务自己的凭据(`FEISHU_*`、`AGENT_LOFT_*` 等环境变量会被剔除)。
|
|
102
128
|
|
|
@@ -117,6 +143,8 @@ src/
|
|
|
117
143
|
feishu/ 飞书:一个 FeishuBot 对应一个机器人,支持多个
|
|
118
144
|
web/ HTTP + WebSocket + 鉴权
|
|
119
145
|
web/ 前端(原生 JS,不需要构建)
|
|
146
|
+
desktop/ 桌面端(Electron 外壳,macOS / Windows / Linux)
|
|
147
|
+
docs/ 产品介绍和截图
|
|
120
148
|
```
|
|
121
149
|
|
|
122
150
|
### 接入新的消息渠道(比如 Telegram)
|
package/package.json
CHANGED
|
@@ -16,25 +16,26 @@ export type BuiltinSpec = {
|
|
|
16
16
|
feishuOnly?: boolean;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
// 按常用程度排:这也是设置 → 命令、「/」列表和 /help 里的默认顺序
|
|
19
20
|
export const BUILTIN_COMMANDS: BuiltinSpec[] = [
|
|
20
21
|
{ name: "new", args: "[模型]", description: "开一条新会话" },
|
|
21
|
-
{ name: "status", description: "查看当前会话、模型、目录、剩余额度" },
|
|
22
22
|
{ name: "stop", description: "中断当前执行" },
|
|
23
23
|
{ name: "model", args: "[名称]", description: "切换模型(不带参数弹选择)" },
|
|
24
24
|
{ name: "effort", args: "[程度]", description: "切换思考程度" },
|
|
25
|
+
{ name: "status", description: "查看当前会话、模型、目录、剩余额度" },
|
|
25
26
|
{ name: "sessions", args: "[all]", description: "列出历史会话,点按钮恢复;加 all 列所有目录" },
|
|
26
27
|
{ name: "resume", args: "<会话ID>", description: "恢复指定会话" },
|
|
28
|
+
{ name: "cd", args: "<相对目录>", description: "切换工作目录(限 workspace 内)" },
|
|
29
|
+
{ name: "pwd", description: "显示当前工作目录" },
|
|
27
30
|
{ name: "fork", description: "从当前会话分叉,原会话保留" },
|
|
31
|
+
{ name: "autoedit", args: "[on|off]", description: "本对话自动允许改工作目录内的文件(Bash 仍询问)" },
|
|
28
32
|
{ name: "agent", args: "[名称]", description: "查看 / 切换 agent", multiAgentOnly: true },
|
|
29
33
|
{ name: "claude", description: "切换到 Claude Code", multiAgentOnly: true },
|
|
30
34
|
{ name: "codex", description: "切换到 Codex", multiAgentOnly: true },
|
|
31
|
-
{ name: "cd", args: "<相对目录>", description: "切换工作目录(限 workspace 内)" },
|
|
32
|
-
{ name: "pwd", description: "显示当前工作目录" },
|
|
33
|
-
{ name: "autoedit", args: "[on|off]", description: "本对话自动允许改工作目录内的文件(Bash 仍询问)" },
|
|
34
35
|
{ name: "web", description: "接上一条 Web 会话,在飞书里查看和回复", feishuOnly: true },
|
|
35
36
|
{ name: "detach", description: "不再接着 Web 会话,回到这个聊天自己的会话", feishuOnly: true },
|
|
36
|
-
{ name: "approve", args: "<请求ID> allow-once|allow-always|allow-session|deny", description: "审批兜底(卡片失效时用)" },
|
|
37
37
|
{ name: "help", description: "显示帮助" },
|
|
38
|
+
{ name: "approve", args: "<请求ID> allow-once|allow-always|allow-session|deny", description: "审批兜底(卡片失效时用)" },
|
|
38
39
|
];
|
|
39
40
|
|
|
40
41
|
/** agent 自己报上来的命令 */
|
|
@@ -132,7 +133,10 @@ function needsArgs(args: string | undefined): boolean {
|
|
|
132
133
|
* 合并内置命令和 agent 原生命令,再按用户配置排序。
|
|
133
134
|
* - 只对接得上的 agent 才列 /claude /codex 这类快捷切换;
|
|
134
135
|
* - 和内置命令重名的原生命令丢掉:反正会被 agent-loft 先拦下来,列出来只会误导;
|
|
135
|
-
* - 配置里排过序的在前,按配置的顺序;没排过的(比如新装的 skill
|
|
136
|
+
* - 配置里排过序的在前,按配置的顺序;没排过的(比如新装的 skill)按默认顺序接在后面;
|
|
137
|
+
* - 默认顺序:agent-loft 内置命令(按常用程度)→ 自定义命令 → agent 原生命令;
|
|
138
|
+
* - agent 原生命令默认不显示:只有在设置里保存过(出现在 order 里)且没被隐藏的才显示。
|
|
139
|
+
* 设置页每次保存都会把整张表的顺序存下来,所以存过的照用户的选择,以后新装的 skill 默认不勾。
|
|
136
140
|
*/
|
|
137
141
|
export function buildCatalog(params: {
|
|
138
142
|
allowedAgents: string[];
|
|
@@ -202,7 +206,7 @@ export function buildCatalog(params: {
|
|
|
202
206
|
if (rb !== undefined) return 1;
|
|
203
207
|
return a.i - b.i;
|
|
204
208
|
})
|
|
205
|
-
.map(({ c }) => ({ ...c, hidden: hidden.has(c.name) }));
|
|
209
|
+
.map(({ c }) => ({ ...c, hidden: hidden.has(c.name) || (c.source === "agent" && !rank.has(c.name)) }));
|
|
206
210
|
}
|
|
207
211
|
|
|
208
212
|
/** 「/name args」,给人看的写法 */
|
package/src/core/commands.ts
CHANGED
|
@@ -91,7 +91,7 @@ export function helpText(agents: string[], channel?: string): string {
|
|
|
91
91
|
"",
|
|
92
92
|
"Agent 给选项时会发一张提问卡,点按钮作答;都不合适就直接发消息,那条消息会被当成你的回答。",
|
|
93
93
|
"",
|
|
94
|
-
"**命令**(需整条消息只有命令;只发一个 `/`
|
|
94
|
+
"**命令**(需整条消息只有命令;只发一个 `/` 可以列出命令;agent 自己的命令默认不在列表里,可以在设置 → 命令里勾选,没勾的照样可以直接发)",
|
|
95
95
|
"",
|
|
96
96
|
...BUILTIN_COMMANDS.filter((c) => !c.multiAgentOnly || multi)
|
|
97
97
|
.filter((c) => !c.feishuOnly || channel === "feishu")
|
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
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
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
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
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
|
-
|
|
2241
|
-
|
|
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
|
-
|
|
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
|
|
2253
|
-
if (!
|
|
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
|
-
|
|
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);
|
|
@@ -3141,7 +3224,7 @@ async function openSettings() {
|
|
|
3141
3224
|
const nav = h("div", { class: "settings-nav" });
|
|
3142
3225
|
const panel = h("div", { class: "settings-body" });
|
|
3143
3226
|
// 设置弹窗高度固定,切标签页时不跟着内容一伸一缩
|
|
3144
|
-
|
|
3227
|
+
modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel) });
|
|
3145
3228
|
let cfg;
|
|
3146
3229
|
try {
|
|
3147
3230
|
cfg = await api("/api/config");
|
|
@@ -3157,7 +3240,7 @@ async function openSettings() {
|
|
|
3157
3240
|
["授权", () => authTab(panel)],
|
|
3158
3241
|
["通知", () => notifyTab(panel)],
|
|
3159
3242
|
["安全", () => securityTab(panel)],
|
|
3160
|
-
["系统", () => systemTab(
|
|
3243
|
+
["系统", () => systemTab(cfg, panel)],
|
|
3161
3244
|
];
|
|
3162
3245
|
const select = (i) => {
|
|
3163
3246
|
nav.querySelectorAll("button").forEach((b, j) => b.classList.toggle("on", i === j));
|
|
@@ -3378,7 +3461,6 @@ function serverVersionSection() {
|
|
|
3378
3461
|
}
|
|
3379
3462
|
|
|
3380
3463
|
function generalTab(cfg, panel) {
|
|
3381
|
-
panel.append(serverVersionSection());
|
|
3382
3464
|
const root = h("input", { value: cfg.workspaceRoot });
|
|
3383
3465
|
const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
|
|
3384
3466
|
const ttl = h("input", { type: "number", min: 0, value: Math.round(cfg.sessionTtlMs / 3600000) });
|
|
@@ -3418,7 +3500,6 @@ function generalTab(cfg, panel) {
|
|
|
3418
3500
|
),
|
|
3419
3501
|
),
|
|
3420
3502
|
);
|
|
3421
|
-
panel.append(networkSection(cfg));
|
|
3422
3503
|
}
|
|
3423
3504
|
|
|
3424
3505
|
/** 只有本机能连的监听地址(和服务端 listen.ts 的判断一致) */
|
|
@@ -3624,7 +3705,8 @@ function customDialog({ agent, entry, taken }, onSave) {
|
|
|
3624
3705
|
|
|
3625
3706
|
/**
|
|
3626
3707
|
* 设置 → 命令:每个 agent 一张表,拖动或用箭头排序,取消勾选就不在「/」列表里出现。
|
|
3627
|
-
*
|
|
3708
|
+
* 默认 agent-loft 内置命令按常用程度排在最前,agent 自己的命令排在后面且默认不勾(服务端 buildCatalog 决定)。
|
|
3709
|
+
* 保存时把整张表的顺序存下来;以后新装的 skill 不在表里,会自动接在最后、默认不勾。
|
|
3628
3710
|
* 用户可以自己添加命令(一段预设的提示词),自己加的可以编辑、删除,增删改后立即保存。
|
|
3629
3711
|
*/
|
|
3630
3712
|
function commandsTab(cfg, panel) {
|
|
@@ -3836,7 +3918,7 @@ function commandsTab(cfg, panel) {
|
|
|
3836
3918
|
h(
|
|
3837
3919
|
"div",
|
|
3838
3920
|
{ class: "muted" },
|
|
3839
|
-
"在输入框里打「/」时弹出的命令,按这里的顺序排列;保存后会自动同步到飞书机器人的指令面板。取消勾选的不会出现在列表里,但照样可以手动输入。带 agent 名标记的是 agent 自己的命令(skills
|
|
3921
|
+
"在输入框里打「/」时弹出的命令,按这里的顺序排列;保存后会自动同步到飞书机器人的指令面板。取消勾选的不会出现在列表里,但照样可以手动输入。带 agent 名标记的是 agent 自己的命令(skills 等),默认不勾选,需要的话勾上并保存;它们会原样转给 agent。",
|
|
3840
3922
|
),
|
|
3841
3923
|
tabs,
|
|
3842
3924
|
h(
|
|
@@ -4447,40 +4529,46 @@ async function securityTab(panel) {
|
|
|
4447
4529
|
);
|
|
4448
4530
|
}
|
|
4449
4531
|
|
|
4450
|
-
async function systemTab(panel) {
|
|
4451
|
-
|
|
4452
|
-
|
|
4532
|
+
async function systemTab(cfg, panel) {
|
|
4533
|
+
// 系统信息要等接口返回,先占好位置,版本和网络访问两节不用等它
|
|
4534
|
+
const sysBox = h("div", { class: "section" }, h("h4", {}, "系统", h("span", { class: "grow" }), h("span", { class: "spinner" })));
|
|
4535
|
+
panel.append(sysBox, serverVersionSection(), networkSection(cfg));
|
|
4536
|
+
let sys;
|
|
4537
|
+
try {
|
|
4538
|
+
sys = await api("/api/system");
|
|
4539
|
+
} catch (err) {
|
|
4540
|
+
fill(sysBox, h("h4", {}, "系统"), h("div", { class: "error-text" }, err.message));
|
|
4541
|
+
return;
|
|
4542
|
+
}
|
|
4543
|
+
fill(
|
|
4544
|
+
sysBox,
|
|
4545
|
+
h("h4", {}, "系统"),
|
|
4546
|
+
h("div", {}, `版本 ${sys.version} · Node ${sys.node} · 已运行 ${Math.round(sys.uptimeMs / 60000)} 分钟`),
|
|
4547
|
+
h("div", { class: "muted" }, sys.underSystemd ? "以系统服务运行" : "前台运行(重启需要在终端里操作)"),
|
|
4453
4548
|
h(
|
|
4454
4549
|
"div",
|
|
4455
|
-
{ class: "
|
|
4456
|
-
h("h4", {}, "系统"),
|
|
4457
|
-
h("div", {}, `版本 ${sys.version} · Node ${sys.node} · 已运行 ${Math.round(sys.uptimeMs / 60000)} 分钟`),
|
|
4458
|
-
h("div", { class: "muted" }, sys.underSystemd ? "以系统服务运行" : "前台运行(重启需要在终端里操作)"),
|
|
4550
|
+
{ class: "row" },
|
|
4459
4551
|
h(
|
|
4460
|
-
"
|
|
4461
|
-
{
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
{
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
} catch (err) {
|
|
4474
|
-
toast(err.message);
|
|
4475
|
-
}
|
|
4476
|
-
},
|
|
4552
|
+
"button",
|
|
4553
|
+
{
|
|
4554
|
+
class: "btn",
|
|
4555
|
+
disabled: !sys.underSystemd,
|
|
4556
|
+
onclick: async () => {
|
|
4557
|
+
if (!(await confirmDialog({ title: "重启 agent-loft?", message: "进行中的对话会被中断。", confirmText: "重启", danger: true }))) return;
|
|
4558
|
+
try {
|
|
4559
|
+
await api("/api/system/restart", { method: "POST" });
|
|
4560
|
+
toast("正在重启…");
|
|
4561
|
+
setTimeout(() => location.reload(), 5000);
|
|
4562
|
+
} catch (err) {
|
|
4563
|
+
toast(err.message);
|
|
4564
|
+
}
|
|
4477
4565
|
},
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
"Notification" in window && Notification.permission !== "granted"
|
|
4481
|
-
? h("button", { class: "btn", onclick: () => Notification.requestPermission() }, "开启浏览器通知")
|
|
4482
|
-
: null,
|
|
4566
|
+
},
|
|
4567
|
+
"重启服务",
|
|
4483
4568
|
),
|
|
4569
|
+
"Notification" in window && Notification.permission !== "granted"
|
|
4570
|
+
? h("button", { class: "btn", onclick: () => Notification.requestPermission() }, "开启浏览器通知")
|
|
4571
|
+
: null,
|
|
4484
4572
|
),
|
|
4485
4573
|
);
|
|
4486
4574
|
}
|
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;
|