@modelzen/feishu-codex-bridge 0.6.2 → 0.6.3

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.
Files changed (3) hide show
  1. package/README.md +72 -263
  2. package/dist/cli.js +203 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,334 +5,143 @@
5
5
  [![downloads/month](https://badgen.net/npm/dm/@modelzen/feishu-codex-bridge)](https://www.npmjs.com/package/@modelzen/feishu-codex-bridge)
6
6
  [![license](https://badgen.net/npm/license/@modelzen/feishu-codex-bridge)](https://github.com/modelzen/feishu-codex-bridge/blob/main/LICENSE)
7
7
 
8
- > 把飞书 / Lark 桥接到你本机的 [Codex](https://github.com/openai/codex),在群里 @ 机器人就能让 Codex 在指定项目目录里干活,结果以流式 Markdown 卡片实时回到群里。
8
+ > 把飞书 / Lark 桥接到你本机的 [Codex](https://github.com/openai/codex) 或 [Claude Code](https://www.anthropic.com/claude-code),在群里 @ 机器人就能让它在指定项目目录里干活,结果以流式 Markdown 卡片实时回到群里。
9
9
  >
10
- > **项目 = 群 = 固定工作目录(cwd)**,**话题(thread)= 一个 Codex 会话(session)**。
10
+ > **项目 = 群 = 固定工作目录(cwd)**,**话题(thread)= 一个会话(session)**。
11
11
 
12
- 一句话:你在飞书群里发「帮我加个登录接口」,机器人就在这个群绑定的代码目录里跑 Codex,边跑边把推理、命令、改动、结果更新到一张卡片上;点 ⏹ 可随时终止。
12
+ 一句话:你在飞书群里发「帮我加个登录接口」,机器人就在这个群绑定的代码目录里跑 Codex / Claude,边跑边把推理、命令、改动、结果更新到一张卡片上;点 ⏹ 可随时终止。
13
13
 
14
- > 🚀 **最快上手(推荐,全程浏览器点点点):把这个仓库链接 `https://github.com/modelzen/feishu-codex-bridge` 交给你本机在用的 Codex / Claude Code,让它照下面 [「最省事」一节](#0-最省事让-codex--claude-帮你装剩下全在浏览器里点推荐) 帮你装好、起后台、并在浏览器里打开本机控制台——之后「扫码加机器人 / 启停 / 重启 / 更新」全在网页里点,不碰命令行。**
15
- >
16
14
  > 🎀 **想先看它在飞书里长啥样、能干嘛?** 看这篇图文介绍 👉 [《让 Codex 当你飞书里的同事》](https://my.feishu.cn/docx/AFKNdf4QaooL5OxSR8bc5H7vn7b)
17
15
 
18
16
  ```
19
- 飞书群消息 ──长连接(WSClient)──▶ bridge ──JSON-RPC/stdio──▶ codex app-server (每会话一进程)
20
-
21
- └──────── 流式 Markdown 卡片 ◀──────┘
17
+ 飞书群消息 ──长连接──▶ bridge ──▶ Codex app-server / Claude Agent SDK(每会话一独立后端)
18
+
19
+ └─── 流式 Markdown 卡片 ◀┘
22
20
  ```
23
21
 
24
22
  ---
25
23
 
26
- ## 特性
27
-
28
- - **群 = 项目**:每个群绑定一个本地目录与默认参数;@ 机器人即在该目录跑 Codex。
29
- - **话题 = 会话**:在群里对某条消息开话题,话题内是一条连续的 Codex 会话(自动 resume)。
30
- - **流式卡片**:推理 / 命令 / 文件改动 / 结果实时刷新到一张可折叠卡片。
31
- - **免 @ 对话**:项目群话题内可直接说话、不必每次 @(可逐群开关)。
32
- - **文档评论回复(可选)**:在飞书云文档(doc/docx/sheet/file,含知识库 wiki)的评论里 **@机器人**,它会读评论、跑 Codex、把答案回到同一条评论线程里;每篇文档一条连续会话。需额外开通文档评论权限并订阅评论事件(见下方配置)。
33
- - **私聊控制台**:私聊机器人弹交互菜单 —— 新建项目、项目列表、设置、用量、诊断、重连。
34
- - **📊 Codex 用量**:5 小时 / 7 天限额进度(剩余 % + 重置时间)、lifetime tokens、连续使用天数、GitHub 风格每日用量热力图;一键生成**战绩分享卡**,可原生转发给任何人或群(数据来自 Codex 个人资料页同款接口,需 ChatGPT 登录)。
35
- - **稳定隔离**:每会话独立 app-server 进程;卡死有 watchdog(默认 120s)→ 终止 → 回收,异常不波及其他群。
36
- - **本地加密密钥库**:飞书应用密钥用 AES-256-GCM 存在 `~/.feishu-codex-bridge/`,不入仓库、不进环境变量。
37
- - **跨平台常驻**:macOS / Windows / Linux·WSL 均可注册成后台服务、开机或登录自启(分别走 launchd / 登录自启免管理员 / systemd)。注:跨平台指进程运行与后台自启;「项目内只读/读写」隐私沙箱仅 macOS / 原生 Windows 可强制(见[安全须知](#-安全须知))。
38
-
39
- ---
40
-
41
- ## 📦 前置条件
42
-
43
- | 依赖 | 说明 | 获取方式 |
44
- |------|------|----------|
45
- | **操作系统** | 运行/后台常驻:**macOS / Windows** 均支持,Linux·WSL 为 best-effort(已实现 systemd,未广泛实测)。注意:「项目内只读 / 读写」隐私档的沙箱强制仅 **macOS / 原生 Windows**,Linux·WSL 上这两档会 fail-closed 拒绝启动(见下方[安全须知](#-安全须知)) | — |
46
- | **Node.js ≥ 20** | 运行时 | <https://nodejs.org> 或 `nvm install 20` |
47
- | **Codex CLI** | 后端,bridge 会 spawn `codex app-server` | `npm i -g @openai/codex`,或装 Codex.app,或用 `CODEX_BIN` 指向已有二进制 |
48
- | **Codex 已登录** | app-server 需要 `~/.codex/auth.json` | `codex login` |
49
- | **飞书 / Lark 账号** | 租户需允许「扫码创建应用」(个人/开发者租户一般可以) | 首次 `run` 时扫码创建 |
50
- | **lark-cli**(可选) | 仅「文档评论回复」需读文档正文时用到;不装也能跑,只是读不到正文 | `lark-cli auth login`,确保在 PATH 上 |
51
-
52
- > 收发消息、回卡片、发评论回复均走 `@larksuiteoapi/node-sdk` 长连接,**不依赖** `lark-cli`。⚠️ `lark-cli` 以**你的身份**登录,仅供 Codex **读**文档;prompt 已禁止用它发评论(否则评论会署你本人)。
53
-
54
- ---
55
-
56
- ## 🚀 安装与启动
57
-
58
- ### 0. 最省事:让 Codex / Claude 帮你装,剩下全在浏览器里点(推荐)
59
-
60
- 不想碰命令行?如果你本机已经在用 **Codex** 或 **Claude Code**,**直接把下面这段发给它**即可。它会帮你装好、起好后台,并**在浏览器里打开本机控制台**——之后**扫码加机器人、启动 / 停止 / 重启、检查更新**全部在网页里点完,你不用懂任何命令:
61
-
62
- ```text
63
- 帮我安装并启动「飞书 Codex 桥」,让我之后全在浏览器里操作:
64
- 1) 全局安装:npm i -g @modelzen/feishu-codex-bridge
65
- 2) 起后台:feishu-codex-bridge start(没有机器人时它不会卡在终端扫码,会进引导控制台,这是正常的)
66
- 3) 等约 3 秒,跑:feishu-codex-bridge web,从输出里取本机网址(形如 http://127.0.0.1:51847/?token=...,含 ?token= 不能丢)
67
- 4) 用 open(macOS) / start(Windows) / xdg-open(Linux) 在我的浏览器里打开那条完整网址;打不开就把网址原样发我
68
- 别替我登录 codex、也别替我改飞书后台配置——剩下的我在浏览器里点(扫码加机器人 / 启停 / 更新)。
69
- ```
70
-
71
- > 原理:`start` 在「还没有机器人」时不会卡在终端扫码,而是直接起好后台 + 内嵌 **Web 控制台**;
72
- > `web` 把带 token 的本机网址打印出来(daemon 在跑时立即返回、不占终端)。打开后用飞书扫码创建机器人,
73
- > 按页面 checklist 开权限 / 订阅事件、点完成即上线。**整台机器的安装、加机器人、启停、更新都收敛进这一个网页**,全程不用懂命令行。
74
-
75
- 如果你想自己用命令行装,往下看 👇
76
-
77
- ### 1. 安装
78
-
79
- ```bash
80
- # 推荐:全局安装到稳定路径(后台 daemon 需要稳定的 CLI 路径)
81
- npm i -g @modelzen/feishu-codex-bridge
82
-
83
- # 或:免安装、单次前台运行
84
- npx -y @modelzen/feishu-codex-bridge run
85
- ```
86
-
87
- > 安装只装命令、**不会自动建机器人**(包已预编译,安装即用);装好后命令名是 `feishu-codex-bridge`。
24
+ ## 安装
88
25
 
89
- ### 2. 前台启动(`run`)
26
+ 两条命令装好、打开网页控制台,**剩下全在网页里点**(扫码加机器人 / 开权限 / 启停):
90
27
 
91
28
  ```bash
92
- feishu-codex-bridge run
29
+ npm i -g @modelzen/feishu-codex-bridge # 1. 装命令
30
+ feishu-codex-bridge web # 2. 打开本机网页控制台
93
31
  ```
94
32
 
95
- `run` 没配置时会**先扫码 init**:检查 codex → 扫码创建/授权飞书应用(密钥进本地加密库)→ 校验凭据并**自动打开浏览器到「一键开通全部权限」页**(同时打印链接)→ 起长连接。**Ctrl+C 优雅退出**(关掉所有 codex 子进程,无孤儿)。支持 npx。
96
-
97
- > 权限即时生效:`run` 跑着时直接在浏览器开通权限即可,无需重启。另外还要去飞书后台**订阅事件 + 发布版本**(见下节)。
98
-
99
- ### 3. 后台 daemon(`start` —— 日常这么跑)
100
-
101
- ```bash
102
- feishu-codex-bridge start # 装系统后台服务并启动:开机/登录自启、崩溃自动拉起、关终端照跑
103
- feishu-codex-bridge status # 状态 / pid / 日志路径 / 上次退出码
104
- feishu-codex-bridge logs -f # 跟踪日志
105
- feishu-codex-bridge restart # 重启
106
- feishu-codex-bridge stop # 停止并关闭开机自启
107
- feishu-codex-bridge update # 更新到最新版(npm i -g)并自动重启 daemon(--check 只查不装)
108
- ```
109
-
110
- > 💡 升级很省事:装了后台 daemon 的,直接 `feishu-codex-bridge update` 一条命令 = 拉最新版 + 自动 `restart`;也可在**私聊管理台**点 **⬆️ 版本更新** 按钮,机器人自更新后重启服务。
111
-
112
- `start` 会**先在当前终端完成 init**(没配置则扫码),并**阻塞到授权完成**——权限全部开通、且你确认已订阅事件/发布版本——才真正装服务,绝不会装一个收不到消息的空壳。daemon 体跑的就是 `run`。
113
-
114
- > 🖥 **各平台后台机制**:macOS = launchd 用户服务;**Windows = 登录自启(写 `HKCU\…\Run`,隐藏启动,全程免管理员)**;Linux·WSL = systemd 用户单元(`systemctl --user`,需要 `loginctl enable-linger` 才能登出后续跑;WSL 还需在 `/etc/wsl.conf` 开 `[boot] systemd=true`,否则用前台 `run`)。三者命令一致(`start`/`status`/`stop`/`restart`/`logs`),状态/日志路径统一。
33
+ **Codex / Claude** 替你装?把下面这段发给它:
115
34
 
116
- > ⚠️ **后台服务必须全局安装(`npm i -g`),不要用 npx**:服务里硬编码了 CLI 路径,而 npx 的临时缓存(`~/.npm/_npx/...`)会被清理,缓存一没服务就起不来。前台 `run` 用 npx 没问题(单次进程)。
117
-
118
- ### 4. 多飞书机器人(可选)
119
-
120
- 一台机器可保存多个机器人配置,运行时只用「当前」一个:
121
-
122
- ```bash
123
- feishu-codex-bridge bot init [名] # 再注册一个飞书应用并授权(额外机器人)
124
- feishu-codex-bridge bot list # 列出已注册机器人(👉 标当前)
125
- feishu-codex-bridge bot use <名> # 切换 run / start 启动时使用的机器人
126
- feishu-codex-bridge bot rm <名> # 移除一个机器人配置
35
+ ```text
36
+ 如果本地没有 feishu-codex-bridge 命令,就先用 npm i -g @modelzen/feishu-codex-bridge 安装。
37
+ 然后运行 feishu-codex-bridge web 打开网页控制台,把输出的网址给我打开。
38
+ 装好、开完网页就停,别的不用做。
127
39
  ```
128
40
 
129
- 每个机器人的 projects / sessions 各自独立(`~/.feishu-codex-bridge/bots/<appId>/`)。切换后前台 `run` 直接生效,后台 `restart` 生效。
130
-
131
- 自检随时可用:`feishu-codex-bridge doctor`。
41
+ > 前置:**Node 20**,外加一个登录好的后端 —— **Codex**(`npm i -g @openai/codex && codex login`)或 **Claude Code**(SDK 随桥内置、复用本机 `claude` 登录态,首次按需下载约 265MB)。打开网页后扫码加机器人、按 checklist 开权限 / 订阅事件,全程点点点,不用碰命令行。
132
42
 
133
43
  ---
134
44
 
135
- ## 🔧 飞书开放平台后台配置(关键,必须手动一次)
136
-
137
- 扫码向导只负责**创建应用 + 拿到凭据**。下面这些(事件 / 回调 / 权限勾选 / 版本发布)飞书**没有写入类 API**,必须你在[开发者后台](https://open.feishu.cn/app)手动配一次(Lark 为 <https://open.larksuite.com/app>);不过配置**状态**可以通过 API 检测——本工具会在 `run` / `start` / `doctor` / 私聊「🩺 诊断」里自动诊断事件订阅(从未发布版本 / 缺 `im.message.receive_v1` / 配置齐全),不用你猜:
138
-
139
- ### 1)开通权限(Scope)
140
-
141
- 启动时若有缺失权限,会**自动打开浏览器**到形如 `https://open.feishu.cn/app/<app_id>/auth?q=...` 的页面(同时在终端打印链接),**一次性勾选全部 → 确认**即可(即时生效、无需重启)。`start`(后台 daemon)会阻塞到这步开通完成才装服务。
142
-
143
- 本桥需要的全部权限以 [`src/config/scopes.ts`](src/config/scopes.ts) 的 `REQUIRED_SCOPES` 为权威清单,包含:收群 @ 消息 / 全量群消息(免 @)/ 私聊消息、以机器人身份发消息与回话题、消息置顶、表情回复、上传下载资源、建群 / 转让群主 / 设群管理员、群公告读写、置顶横幅、群标签页、交互卡片。**这些都在首次开通链接里一并申请,正常用不会再遇到「权限不足」。**
144
-
145
- > 「**文档评论回复**」功能另需 `docs:document.comment:read`、`docs:document.comment:create`、`wiki:wiki:readonly` 三项(见 `COMMENT_SCOPES`)。它们**已预勾选进同一个开通链接**,但**不属于** `REQUIRED_SCOPES` —— 不开通也不会卡住后台服务安装,只是该功能静默关闭。
146
-
147
- > 「**把我加进已有群**」功能另需 `im:chat:readonly`(读群名)、`im:chat.members:write_only`(解绑时机器人退群)两项(见 `JOIN_GROUP_SCOPES`)。同样**已预勾选进同一个开通链接**、**不属于** `REQUIRED_SCOPES`,不开通只是该功能静默关闭。
148
-
149
- > 「**事件订阅自动诊断**」另需 `application:application.app_version:readonly`(读应用版本信息,见 `APP_VERSION_SCOPES`)。同样**已预勾选**、**不属于** `REQUIRED_SCOPES`,不开通则诊断降级为「未能自动检测」,其余照常。
150
-
151
- > 「**群内可发现性**」另需 `im:chat.menu_tree:write_only`(建群时挂「🤖 Codex」群菜单)、`im:message.reactions:read`(接收表情回复事件:终态卡 👍 续轮 / 运行卡 OK 终止,见 `DISCOVERY_SCOPES`)。同样**已预勾选**、**不属于** `REQUIRED_SCOPES`,不开通只是群菜单不出现 / 表情驱动静默关闭。
152
-
153
- ### 2)订阅事件 + 回调(长连接模式)
154
-
155
- `run` / `start` 初始化到这步会**自动打开**「**事件与回调**」页(`https://open.feishu.cn/app/<app_id>/event`)。这页顶部有「**事件配置**」「**回调配置**」两个独立标签,要分别配(飞书对事件/回调**既无开通 API、也无预选深链**,只能手点;但**事件**的订阅状态可经「获取应用版本信息」API 读到——你配完并发布版本后,前台 `run` 会自动确认并播报「**事件已生效**」。**回调**不在该 API 里,无法检测):
156
-
157
- **「事件配置」标签** → 「订阅方式」改**长连接** → 点「添加事件」:
158
-
159
- - `im.message.receive_v1` —— 收群/私聊消息
160
- - `application.bot.menu_v6` —— 机器人菜单点击
161
- - `drive.notice.comment_add_v1` —— 云文档新增评论(**仅「文档评论回复」功能需要**;不加则该功能静默关闭,其余照常)
162
- - `im.chat.member.bot.added_v1` —— 机器人被加入群(**仅「把我加进已有群」功能需要**;触发私聊推送绑定卡,不加则拉我进群没反应)
163
- - `im.chat.member.bot.deleted_v1` —— 机器人被移出群(同上;触发自动解绑项目,不加则被踢后项目不会自动清理)
164
- - `im.message.reaction.created_v1` —— 新增消息表情回复(**仅「表情驱动」功能需要**:终态卡点 👍 续轮、运行卡点 OK 终止;不加则该功能静默关闭)
165
-
166
- **「回调配置」标签** → 「订阅方式」改**长连接** → 点「添加回调」:
167
-
168
- - `card.action.trigger`(卡片回传交互)—— 卡片按钮回调
169
-
170
- > ⚠️ `card.action.trigger` 是**回调**不是事件,在「添加事件」里**搜不到**,必须切到「**回调配置**」这个标签去加。
171
- > ⚠️ 不订阅事件 → @ 机器人没反应;不订阅回调 → 卡片按钮点了没反应(长连接照样能连上,但都收不到)。
172
- > 保存「长连接」订阅方式时要求长连接**在线**;若提示连接未建立,先开个终端跑 `feishu-codex-bridge run` 连上,再回这页保存。
173
-
174
- ### 3)(可选)机器人自定义菜单
175
-
176
- 后台「**机器人能力 → 机器人自定义菜单**」配置菜单项(如:新建项目 / 项目列表 / 设置 / 诊断 / 重连),各设一个推送事件的 `event_key`,发布版本生效。不配也能用 —— 私聊机器人发任意消息同样会弹出交互菜单。
177
-
178
- ### 4)发布版本
45
+ ## 特性
179
46
 
180
- 在后台发布应用版本,机器人才真正上线。发布通过后,跑着的 `run` 会经版本信息 API 自动确认并播报「**事件已生效**」。
47
+ - **群 = 项目,话题 = 会话**:每个群绑定一个本地目录;群里 @ 机器人就在该目录跑 agent。对某条消息开话题 = 一条独立连续会话(自动 resume)。
48
+ - **两种后端**:**Codex**(能力最全:goal / steer / compact / resume + 真沙箱只读档)或 **Claude Code**(SDK 内置、复用本机登录、能力较精简)。建项目时按需选,同一台机可混用。
49
+ - **流式卡片**:推理 / 命令 / 文件改动 / 结果实时刷新到一张可折叠卡片;⏹ 随时终止,卡死有 watchdog 自动回收,异常不波及其他群。
50
+ - **免 @ + 自主目标**:话题 / 单会话群里可直接说话不必每次 @;`/goal <目标>` 让它自主多轮干到完成。
51
+ - **多模态**:消息里直接发图片(读图)、发文件附件(下载到本地交给 agent 打开分析)。
52
+ - **☕ 咖啡一下(反向桥)**:离开电脑时,把你本机正在跑的 Claude Code / Codex CLI 的「需要审批 / 提问 / 任务完成」接管到飞书私聊 —— 在手机上点确认 / 回答它就继续,机器保持不睡。
53
+ - **文档评论回复**:在飞书云文档(doc / docx / sheet / file,含 wiki)的评论里 @ 机器人,它读评论、跑 agent、把答案回到同一条评论线程。
54
+ - **双控制台**:私聊机器人弹交互菜单(新建项目 / 设置 / 用量 / 诊断 / 重连);网页控制台还能管后台服务、看实时日志、扫码加机器人。
55
+ - **多飞书机器人**:一台机器注册多个机器人、可同时连接,各自项目 / 会话独立。
56
+ - **三档权限沙箱**:每个项目可设「只读 / 读写 / 完全访问」,由 OS 沙箱强制(macOS / 原生 Windows)。
57
+ - **跨平台常驻**:macOS / Windows / Linux·WSL 均可注册成后台服务、开机或登录自启、崩溃自动拉起。
181
58
 
182
59
  ---
183
60
 
184
61
  ## 💬 使用
185
62
 
186
- - **建项目**:私聊机器人 弹出控制台菜单 「新建项目」→ 绑定一个本地目录(或新建空白项目)→ 选群类型 → 机器人建好群、置顶命令说明、把你拉进去。
187
- - **两种群按场景选**:
188
- - **👥 多话题群**:主群区 @ 机器人开话题,每个话题是一条**独立会话**(上下文隔离、可 `/resume`、话题间并行)。适合**多人协作**——一个项目群里各人 / 各任务开各自话题、上下文互不串味;也适合一人并行多任务。
189
- - **💬 单会话群**:整群就是**一条连续会话**(全程**免 @**、消息按序排队、无 `/resume`)。适合**个人单线深入**、像私聊一样直接聊。
190
- - **干活**:在项目群里 **@机器人** 描述需求;机器人在该群绑定的目录里跑 Codex,流式卡片回结果。
191
- - **话题 = 会话**:对某条消息开话题后,话题内可**免 @** 连续对话,是一条连贯的 Codex 会话。
192
- - **🎯 自主目标(`/goal`)**:发 `/goal <目标>`(主群区会新开话题;话题内 / 单会话群直接发),Codex **自主多轮**连续执行直到完成——每轮一张流式卡片,自然结束后出总结卡。运行中卡片上有 **⏹ 终止**(立刻停)和 **🎯 结束目标**(本轮跑完后停)两个按钮;goal 运行期间该会话不接收新消息(会收到提示,终止 / 结束目标后重发)。没有总时长上限,只有 30 分钟完全无事件的 idle 兜底。
193
- - **发图 / 发附件**:直接在消息里**发图片**,Codex 能看到(多模态读图);**发文件附件**(日志 / PDF / 代码等),桥会把它下载到本地并把**绝对路径**告诉 Codex,让它用工具直接打开分析。⚠️ 附件落在桥的全局临时目录(`~/.feishu-codex-bridge/inbound`,1h 后自动清),**只有「完全访问」档**能读到——「项目内只读 / 读写」档的沙箱把读取锁在项目目录内,读不到该目录。单文件上限 50MB、单条消息最多 9 个;合并转发里的附件飞书官方不支持取,故不支持。
194
- - **文档评论 @机器人**:在飞书文档评论里 @ 它就回(前提:已开通文档评论权限 + 订阅 `drive.notice.comment_add_v1`,且机器人对该文档有访问权限)。只支持 doc/docx/sheet/file;评论框不渲染 markdown,回复为纯文本,超长会截断。
195
- - **终止**:卡片上的 **⏹** 随时终止当前轮;卡死超过 watchdog 阈值(默认 120s)自动中止并回收进程。
196
- - **私聊控制台**:项目列表、设置(模型 / 推理强度 / 免 @ / watchdog / 管理员)、用量、诊断、重连,全在私聊菜单里。
197
- - **📊 用量**:点「用量」看 5h/7d 限额(剩余 % + 重置时间)与 Codex 个人统计(lifetime tokens / streak / 每日热力图);点「📤 生成分享卡」得到一张可转发的战绩卡——长按(手机)或右键(电脑)即可转发,数据定格在生成时刻。
198
-
199
- ---
200
-
201
- ## ⚙️ 配置与数据位置
202
-
203
- 所有本地状态在 `~/.feishu-codex-bridge/`:
204
-
205
- | 文件 | 内容 |
206
- |------|------|
207
- | `bots.json` | 已注册机器人列表 + 当前选中(`current`) |
208
- | `bots/<appId>/config.json` | 该机器人的 id / 租户 / 偏好(**不含明文密钥**) |
209
- | `bots/<appId>/projects.json` | 群 → 目录 + 默认参数 注册表(**按机器人隔离**) |
210
- | `bots/<appId>/sessions.json` | 话题 → Codex thread_id + cwd(**按机器人隔离**) |
211
- | `secrets.enc` + `.keystore.salt` | AES-256-GCM 加密的应用密钥(按 appId 存,多机器人共用一库;密钥由机器 + 用户派生) |
212
- | `media/` | 临时媒体 |
63
+ 它有两个方向 —— 飞书群指挥本机 agent,和把本机 agent 接管到飞书。
213
64
 
214
- > 旧版单机器人布局(顶层 `config.json` / `projects.json` / `sessions.json`)会在首次运行时**自动迁移**为名为 `default` 的机器人。
65
+ ### A. 飞书群 本机 agent(主用法)
215
66
 
216
- 环境变量:
67
+ - **建项目**:私聊机器人 → 控制台菜单「新建项目」→ 绑定一个本地目录 → **选后端(Codex / Claude)** → 机器人建好群、置顶命令说明、把你拉进去。
68
+ - **两种群按场景选**:
69
+ - **👥 多话题群**:@ 机器人开话题,每个话题是一条**独立会话**(上下文隔离、可并行)。适合多人协作 / 一人并行多任务。
70
+ - **💬 单会话群**:整群就是**一条连续会话**、全程**免 @**。适合个人单线深入、像私聊一样直接聊。
71
+ - **干活**:群里 @ 机器人(或话题内免 @)描述需求,流式卡片回结果;卡片上 ⏹ 随时终止当前轮。
72
+ - **自主目标**:`/goal <目标>` 让它多轮自主执行到完成;运行卡上有 **⏹ 终止**(立刻停)和 **🎯 结束目标**(本轮跑完停)。
73
+ - **斜杠命令**:`/model`、`/resume`、`/compact`、`/context` 等,按所选后端能力自适应裁剪(Claude 不显示它不支持的项)。
74
+ - **发图 / 附件**:发图片读图、发文件(日志 / PDF / 代码)让 agent 打开分析。
75
+ - **用量**:私聊「用量」看 5h / 7d 限额(剩余 % + 重置时间)与个人统计,一键生成可转发的**战绩分享卡**(数据来自 Codex 个人资料页,需 ChatGPT 登录)。
217
76
 
218
- | 变量 | 作用 |
219
- |------|------|
220
- | `CODEX_BIN` | 显式指定 codex 二进制路径 |
221
- | `CODEX_HOME` | codex 配置/登录目录(默认 `~/.codex`) |
222
- | `FEISHU_CODEX_CWD` | 未注册群的兜底工作目录(默认进程 cwd;常驻服务建议显式设置) |
77
+ ### B. 本机 agent → 飞书(☕ 咖啡一下)
223
78
 
224
- > 群里那张「👈 使用说明」标签页指向的命令手册文档,可在 [`src/project/onboarding.ts`](src/project/onboarding.ts) `HELP_DOC_URL` 改成你自己发布的飞书文档;设为空串则不挂该标签页。
79
+ 在本机用 Claude Code / Codex 干活、要离开电脑时开启「咖啡一下」:本机 agent 需要**审批 / 提问 / 报告完成**时,推到你的飞书私聊,你在手机上点一下就让它继续;机器保持不睡(屏幕可关、CPU 照跑),回到电脑自动交还终端。
225
80
 
226
81
  ---
227
82
 
228
- ## 🛠 CLI 一览
83
+ ## 🖥️ CLI 一览
84
+
85
+ 日常基本只用 `start`(起后台)和 `web`(开控制台),其余动作网页里都有按钮。
229
86
 
230
87
  ```
231
- feishu-codex-bridge run 前台启动(没配置先扫码 init;Ctrl+C 优雅退出)
232
- feishu-codex-bridge start 后台 daemon 启动(装系统后台服务、开机/登录自启;阻塞到授权完成)
233
- feishu-codex-bridge stop|restart|status|logs 后台 daemon 生命周期
234
- feishu-codex-bridge update 更新到最新版并自动重启 daemon(--check 只查不装)
235
- feishu-codex-bridge bot init|list|use|rm 多飞书机器人:注册 / 列表 / 切当前 / 移除
236
- feishu-codex-bridge doctor 本地自检:codex / 登录 / lark-cli / 当前机器人
88
+ feishu-codex-bridge run [--bot <名>] 前台启动(没配置先扫码 init;Ctrl+C 优雅退出)
89
+ feishu-codex-bridge start 后台 daemon:装系统服务、开机/登录自启、崩溃自动拉起
90
+ feishu-codex-bridge status|logs|restart|stop daemon 生命周期(logs -f 跟随日志)
91
+ feishu-codex-bridge update [--check] 更新到最新版(npm i -g)并自动重启 daemon
92
+ feishu-codex-bridge web [--port <端口>] 打开本机网页控制台(默认端口 51847)
93
+ feishu-codex-bridge bot init|list|use|rm 多机器人:注册 / 列表 / 选要连接的 / 移除
94
+ feishu-codex-bridge doctor 本地自检:后端 / 登录 / 当前机器人
237
95
  ```
238
96
 
239
- ---
240
-
241
- ## 🧑‍💻 开发
97
+ > ⚠️ 后台服务必须**全局安装**(`npm i -g`),别用 npx —— 服务里硬编码了 CLI 路径,npx 临时缓存会被清理。前台 `run` 用 npx 没问题(单次进程)。
242
98
 
243
- ```bash
244
- npm run typecheck # tsc --noEmit
245
- npm run build # tsup → dist/
246
- npm test # vitest
247
- npm run dev # tsup --watch
248
- ```
249
-
250
- 本地开发:`git clone https://github.com/modelzen/feishu-codex-bridge.git && cd feishu-codex-bridge && npm i`(`prepare` 自动构建),前台跑 `npm start` 或 `./scripts/dev-run.sh`。
99
+ ---
251
100
 
252
- 目录结构:
101
+ ## ⚙️ 配置与数据
253
102
 
254
- ```
255
- src/
256
- bot/ 长连接 bridge、消息处理、私聊控制台、扫码向导
257
- card/ 流式运行卡片、命令卡、回调分发
258
- agent/ Codex app-server 后端(进程生命周期、JSON-RPC、事件映射、协议类型)
259
- project/ 项目注册表、建群/公告/标签页 onboarding、生命周期
260
- config/ 加密密钥库、密钥解析、配置存储、多机器人注册表、scope 清单、路径
261
- core/ watchdog、单实例锁、日志
262
- cli/ commander 命令(run / start / stop / restart / status / logs / update / bot / doctor / secrets)
263
- service/ 后台服务适配器(launchd / Windows 登录自启 / systemd)+ 跨平台 spawn
264
- ```
265
-
266
- 架构与实现细节见 [`docs/design/feishu-codex-bridge-design.md`](docs/design/feishu-codex-bridge-design.md) 与 [`docs/design/implementation-plan.md`](docs/design/implementation-plan.md)。
103
+ 所有本地状态都在 `~/.feishu-codex-bridge/`(机器人配置、项目 / 会话注册表、AES-256-GCM 加密的密钥库)。卸载时删掉这个目录即可清干净。
267
104
 
268
105
  ---
269
106
 
270
107
  ## ⚠️ 安全须知
271
108
 
272
- 机器人调用 Codex 始终是 **`approvalPolicy: "never"`**(无人工逐条审批),**沙箱就是唯一的安全闸门**。每个项目有一档**权限**,在私聊控制台「📁 项目列表 → ⚙️ 设置 → 🔐 权限」里用下拉框选择后提交:
109
+ 机器人跑 agent 始终是 **`approvalPolicy: never`**(无逐条人工审批),**沙箱是唯一的安全闸门**。每个项目在私聊 / 网页控制台里可设三档权限:
273
110
 
274
- | 档位 | 能读 | 能写 | 适用 |
275
- |------|------|------|------|
276
- | 🔒 **项目内只读** | 仅项目文件夹 | | 外部群 / 不可信场景的问答机器人 |
277
- | ✏️ **项目内读写** | 仅项目文件夹 | 仅项目文件夹 | 自己的编码项目,但禁止它碰机器其余部分 |
278
- | ⚠️ **完全访问** | 整台电脑 | 整台电脑 | 完全信任、你自己掌控的机器 |
111
+ | 档位 | 能读 / 能写 | 适用 |
112
+ |------|------------|------|
113
+ | 🔒 **项目内只读** | 仅项目目录 / 不可写 | 外部群、不可信场景的问答机器人 |
114
+ | ✏️ **项目内读写** | 仅项目目录 | 自己的编码项目,禁止它碰机器其余部分 |
115
+ | ⚠️ **完全访问** | 整台电脑 | 完全信任、你自己掌控的机器 |
279
116
 
280
- - **管理员 / 普通用户可分设**:🔐 权限里有「管理员档」和「普通用户档」两个下拉。两档**不同**时,管理员与群里其他人**各用独立的 Codex 线程**(互不串沙箱、也互不串对话历史)——典型:外部群里管理员 `完全访问`、其他人 `项目内只读`。两档**相同** = 所有人一致(默认)。
281
- - **默认值**:你自己新建的项目群 = `完全访问`(与历史行为一致);**别人把机器人拉进的存量/外部群** = `项目内只读`;普通用户档默认**同管理员档**(不分档)。**升级前没有 `mode` 字段的老项目按 `完全访问` 处理**,行为不变。
282
- - 🔒/✏️ 靠 Codex 的自定义 permissions 档把读写都**锁死在项目文件夹内**(读不到 `~/.ssh`、`/etc` 等),由操作系统沙箱强制:**macOS(Seatbelt)与原生 Windows(restricted token)可强制**,其中 Windows 需 Codex 以 elevated 沙箱运行、否则它会**拒绝执行**(仍不泄漏)。**Linux / WSL 无法强制读限定**(沙箱只挡写、不限读,Landlock 读限制尚未实现,WSL 等同 Linux)——在这些平台选 🔒/✏️ 会被**直接拒绝启动(fail-closed),绝不静默降级为完全访问**;要在 Linux/WSL 用,请把 Codex 跑在容器/隔离环境里。
283
- > Windows 上的强制是 Codex 自己做的,请先在真机自测一次(让机器人读项目文件夹外的文件,应被拒)再用于真实外部群。
284
- - ⚠️ `完全访问` 档意味着:**任何能给机器人发消息的人,都能在你这台机器上、以你的身份执行任意命令(读写文件、联网、跑脚本)**。这一档只把**你信任的人**拉进群,在**你自己掌控的隔离机器**上跑,目录里别放不愿被读写的敏感数据。
285
- - 「联网」是档位之外的独立开关,只影响它执行的 shell 命令能否上网,不影响模型本身和 Codex 自带的联网搜索。
117
+ - 🔒 / ✏️ 的读写限定由 OS 沙箱强制,仅 **macOS / 原生 Windows** 可强制;**Linux·WSL 选这两档会 fail-closed 拒绝启动**(绝不静默降级为完全访问),要用请把后端跑在容器 / 隔离环境里。
118
+ - ⚠️ **完全访问** = 任何能给机器人发消息的人都能以你的身份在这台机器上执行任意命令 —— 只把信任的人拉进群、在你自己掌控的机器上跑、目录里别放敏感数据。
286
119
  - 它不是多租户托管服务,是给你(和你信任的小团队)自用的桥。
287
120
 
288
- > 把机器人拉进**外部群**做只读问答前,先在飞书开发者后台开启应用的「可被添加到外部群 / 外部可用范围」,再由群里的真人手动把机器人加进群(机器人无法自行加入)。
289
-
290
121
  ---
291
122
 
292
- ## 故障排查
123
+ ## 🌐 Web 控制台
293
124
 
294
- | 现象 | 排查 |
295
- |------|------|
296
- | `✗ 未找到 codex CLI` | 装 Codex 并 `codex login`;或设 `CODEX_BIN`。`doctor` 会显示解析到的路径 |
297
- | `应用凭据校验失败` | 应用可能被禁用/未发布;重跑 `run` 重新校验,或 `bot rm <名>` 后 `bot init` 重新扫码 |
298
- | @ 机器人没反应 | 多半是**事件未订阅**(长连接模式)或**版本未发布**;跑 `feishu-codex-bridge doctor`(或看启动日志)会精确诊断出是哪种,再按上面「后台配置」修 |
299
- | 提示某项「权限不足」 | 点 `run`/`start` 打印(或自动打开)的一键开通链接补齐权限(即时生效) |
300
- | 按钮「时灵时不灵」 | 检查是否**重复启动了两个 bridge 进程**抢回调;本桥有单实例锁,正常会拒绝第二个 |
301
- | 点 ⏹ 没反应 / 卡片不收尾 | 同群另一话题在跑长任务占住了串行队列;稍候或重连 |
125
+ `feishu-codex-bridge web` 打开本机浏览器里的管理面板(只绑 `127.0.0.1` + 每次启动随机 token 鉴权),一屏搞定:扫码加机器人、开权限 / 订阅事件 checklist、启停 / 重启 / 更新后台服务、看所有 bot / 项目 / 话题 / 实时日志、后端环境检测。daemon 在跑时是可写控制台;没跑时退化为只读预览,仍可一键启动 daemon。日常管理基本只跟它和飞书私聊控制台打交道。
302
126
 
303
127
  ---
304
128
 
305
- ## 🌐 Web 控制台(推荐的管理入口)
306
-
307
- 本机浏览器里的管理面板,**安装收尾、加机器人、启停 / 重启 / 更新、看 bot / 项目 / 话题 / 实时日志** 一屏全办——日常基本只跟它打交道,命令行是给爱折腾的人的备选。
129
+ ## 🧑‍💻 开发
308
130
 
309
131
  ```bash
310
- feishu-codex-bridge web # 取控制台网址(daemon 在跑→直接给 51847 那条;没跑→只读预览)
311
- feishu-codex-bridge web --port 8080
132
+ npm run typecheck # tsc --noEmit
133
+ npm run build # tsup → dist/
134
+ npm test # vitest
312
135
  ```
313
136
 
314
- **从零到跑起来(浏览器路径)**:`npm i -g @modelzen/feishu-codex-bridge` `feishu-codex-bridge start`(起后台,没机器人时进引导控制台)→ `feishu-codex-bridge web`(拿到 `http://127.0.0.1:51847/?token=…` 网址)→ 浏览器打开 → **➕ 添加机器人** 扫码 + 按 checklist 开权限/订阅 → 完成。之后启停、重启、检查更新都在「📊 总览」Tab 的按钮上。让 Codex/Claude 代劳整个流程的话,见上方 [「最省事」一节](#0-最省事让-codex--claude-帮你装剩下全在浏览器里点推荐) 的提示词。
315
-
316
- `run` / `start` 起来的 daemon 会**自动内嵌**控制台(多 bot 时由 supervisor 聚合所有机器人),固定占规范端口 **51847**;此时 `web` 命令直接打开 daemon 的控制台——写操作可用、长连接状态实时;daemon 没跑时 `web` 退化为只读预览(直读本机数据文件,但仍放行「启动 daemon / 检查更新」两个动作,启动后页面自动跳到 51847 那条可写控制台)。启动会打印一行**带 token 的完整 URL**,用它打开即可(首跳自动换成 cookie,URL 上的 token 随即失效于地址栏;命令在有桌面的终端里会顺带自动弹浏览器)。
317
-
318
- **多 Tab 架构**(hash 路由,可刷新/可前进后退/可分享):
319
-
320
- - **📊 总览 Tab**(全局维度,默认落地):后台 daemon 状态/重启/升级、所有 bot 在线聚合 + 启用开关、🩺 Codex 环境状态(在 PATH / 版本 / 未装时的安装提示)、🩺 宿主机体检、📜 全局实时日志 SSE(`stream.timing` / `agent.*` 关键事件高亮,**整段 SSE 全程只连一次、切 Tab 不断连**)。
321
- - **每个机器人一个 Tab**:该 bot 的概览(运行 pid / 真实长连接状态)、🩺 接入诊断(事件订阅三态 + Codex 环境)、📁 项目列表(群形态 / 🔐 权限档 / 🧵 话题数)+ ⚙️ 设置抽屉,以及停用/删除该 bot(下沉到 bot Tab 头部)。
322
-
323
- **扫码添加机器人**(➕ 添加机器人 → 默认主入口):点「➕ 添加机器人」走三步向导——① **扫码创建**(前端零依赖内嵌 QR 编码器把飞书返回的创建链接画成 SVG 二维码,飞书 App 扫一下自动建应用、拿密钥入库、把你设为管理员;带倒计时 + 过期重生成 + 「在浏览器打开」纯链接兜底)→ ② 接入检测 checklist(5 秒轮询事件订阅三态、缺失 scope 深链、长连接状态)→ ③ 完成并跳进该机器人的 Tab。**已有飞书应用?**向导里有「手动填 App ID/Secret」折叠次级入口(降级 fallback,覆盖扫码不可用的租户/接入既有应用)。
324
-
325
- **🩺 Codex 环境状态**:后端就是本机的 **Codex CLI**(轻核心、零额外运行时依赖)。总览的后端卡只显示 Codex 的检测态——已就绪显 ✅(含版本),未装/未登录时给一句安装提示(`npm i -g @openai/codex` + `codex login`)。Codex 走 PATH 检测,不经控制台下载。
326
-
327
- - **安全模型**:只绑定 `127.0.0.1`(无任何远程访问配置项)+ 每次启动随机生成的 token 鉴权 + Host/Origin 校验防 DNS rebinding;daemon 控制台的地址记录在 `~/.feishu-codex-bridge/web-console.json`(0600 仅本用户可读,daemon 退出自动清理)。扫码会话的 client_secret 只在 server 内存流过即进 keystore,SSE 推给前端的 `done` 事件是白名单字段、**永不含密钥**。要远程管理请用飞书私聊控制台——那是带飞书身份鉴权的。
328
- - **与飞书卡片的关系**:体验对齐「Web 能操作的飞书也能操作」——Web 的方法清单严格对齐 DM 私聊卡片(🧠 后端 / 🔐 权限 / ✋ 免@ / 🗜️ 自动压缩 / 🩺 诊断…),两面**共享同一写入逻辑**(同样的校验、同样的会话驱逐、同样的落盘——`AdminService` + 共享写操作层),不会出现两套行为。Web 只补 DM 够不着的宿主机域(日志流 / 多 bot 聚合 / Codex 环境检测),飞书域操作(建项目 / 建群 / 用量)仍在 DM 卡片。
137
+ `git clone https://github.com/modelzen/feishu-codex-bridge.git && cd feishu-codex-bridge && npm i`(`prepare` 自动构建),前台跑 `npm start`。架构与实现见 [`docs/design/feishu-codex-bridge-design.md`](docs/design/feishu-codex-bridge-design.md) [`docs/design/implementation-plan.md`](docs/design/implementation-plan.md)
329
138
 
330
139
  ---
331
140
 
332
141
  ## 💬 文档 & 交流
333
142
 
334
- - 🎀 **图文介绍(先看它能干嘛)**:<https://my.feishu.cn/docx/AFKNdf4QaooL5OxSR8bc5H7vn7b> —— 配大量截图,讲清它在飞书里长什么样、有哪些细节、可以怎么用。
335
- - 📖 **命令手册(飞书文档)**:<https://my.feishu.cn/wiki/PZ23wGr7JiKK5RkIG4rcZXzGn5g> —— 各场景可用命令速查(机器人建群时也会自动挂成群标签页)。
143
+ - 🎀 **图文介绍**:<https://my.feishu.cn/docx/AFKNdf4QaooL5OxSR8bc5H7vn7b> —— 配大量截图,讲清它在飞书里长什么样、怎么用。
144
+ - 📖 **命令手册**:<https://my.feishu.cn/wiki/PZ23wGr7JiKK5RkIG4rcZXzGn5g> —— 各场景可用命令速查。
336
145
  - 🐛 **反馈 / 贡献**:<https://github.com/modelzen/feishu-codex-bridge/issues>
337
146
  - 👥 **交流群**:扫码加入「Vonvon 灵感研究所」👇
338
147
 
package/dist/cli.js CHANGED
@@ -5670,6 +5670,9 @@ var DM = {
5670
5670
  setNoMentionDm: "dm.proj.noMention",
5671
5671
  // 🗜️ 自动压缩:项目级开关(同群设置里的那个,DM 里也能改),按钮携带项目名 n
5672
5672
  setAutoCompactDm: "dm.proj.autoCompact",
5673
+ // 🤖 默认模型/强度:新话题的起始模型 + 推理强度(选完提交的下拉表单子卡,仿权限卡)
5674
+ modelDefault: "dm.proj.modelDefault",
5675
+ modelDefaultSubmit: "dm.proj.modelDefault.submit",
5673
5676
  // 🔐 权限:codex 沙箱档位(管理员档 + 普通用户档)+ 联网,做成下拉表单(选+提交)
5674
5677
  permission: "dm.proj.perm",
5675
5678
  permissionSubmit: "dm.proj.perm.submit"
@@ -5678,7 +5681,11 @@ var DM = {
5678
5681
  };
5679
5682
  var GS = {
5680
5683
  setNoMention: "gs.noMention",
5681
- setAutoCompact: "gs.autoCompact"
5684
+ setAutoCompact: "gs.autoCompact",
5685
+ // 🤖 默认模型/强度:群内 /settings 的镜像入口(open=进子卡,submit=保存,settings=返回群设置)
5686
+ settings: "gs.settings",
5687
+ modelDefault: "gs.modelDefault",
5688
+ modelDefaultSubmit: "gs.modelDefault.submit"
5682
5689
  };
5683
5690
  function kindLabel(kind) {
5684
5691
  return kind === "single" ? "\u{1F4AC} \u5355\u4F1A\u8BDD\u7FA4" : "\u{1F465} \u591A\u8BDD\u9898\u7FA4";
@@ -6055,15 +6062,19 @@ function buildNewProjectDoneCard(p) {
6055
6062
  return card(elements, { header: { title, template: "green" } });
6056
6063
  }
6057
6064
  var PROJECT_TOPICS_MAX = 50;
6058
- function buildProjectListCard(projects, sessionsByChat = /* @__PURE__ */ new Map()) {
6065
+ var PROJECT_LIST_PAGE_SIZE = 8;
6066
+ function buildProjectListCard(projects, sessionsByChat = /* @__PURE__ */ new Map(), page = 0) {
6059
6067
  if (projects.length === 0) {
6060
6068
  return card(
6061
6069
  [md("\u8FD8\u6CA1\u6709\u9879\u76EE\u3002\u70B9 **\u2795 \u65B0\u5EFA\u9879\u76EE** \u6216\u76F4\u63A5\u53D1\u6211\u4E00\u4E2A\u9879\u76EE\u540D\u3002"), actions([button("\u2B05\uFE0F \u83DC\u5355", { a: DM.menu })])],
6062
6070
  { header: { title: "\u{1F4C1} \u9879\u76EE\u5217\u8868", template: "wathet" } }
6063
6071
  );
6064
6072
  }
6073
+ const pageCount = Math.ceil(projects.length / PROJECT_LIST_PAGE_SIZE);
6074
+ const cur = Math.min(Math.max(Math.trunc(page) || 0, 0), pageCount - 1);
6075
+ const start = cur * PROJECT_LIST_PAGE_SIZE;
6065
6076
  const elements = [];
6066
- for (const p of projects) {
6077
+ for (const p of projects.slice(start, start + PROJECT_LIST_PAGE_SIZE)) {
6067
6078
  const topicCount = (p.chatId ? sessionsByChat.get(p.chatId) : void 0)?.length ?? 0;
6068
6079
  const dir = `\u{1F4C2} \`${p.cwd}\`${p.branch && p.branch !== "\u2014" ? ` \u{1F33F} ${p.branch}` : ""}`;
6069
6080
  const meta = p.chatId ? `${kindLabel(p.kind)}${(p.origin ?? "created") === "joined" ? " \xB7 \u{1F517}\u5DF2\u52A0\u5165" : ""} \xB7 \u514D@\uFF1A${p.noMention ?? defaultNoMention(p) ? "\u5F00" : "\u5173"}` : "\u26A0\uFE0F \u672A\u7ED1\u5B9A\u7FA4";
@@ -6078,8 +6089,14 @@ ${meta}`));
6078
6089
  elements.push(actions(row));
6079
6090
  elements.push(hr());
6080
6091
  }
6081
- elements.push(note(`\u5171 ${projects.length} \u4E2A\u9879\u76EE`));
6082
- elements.push(actions([button("\u2B05\uFE0F \u83DC\u5355", { a: DM.menu })]));
6092
+ elements.push(
6093
+ note(pageCount > 1 ? `\u5171 ${projects.length} \u4E2A\u9879\u76EE \xB7 \u7B2C ${cur + 1}/${pageCount} \u9875` : `\u5171 ${projects.length} \u4E2A\u9879\u76EE`)
6094
+ );
6095
+ const nav = [];
6096
+ if (cur > 0) nav.push(button("\u2B05\uFE0F \u4E0A\u4E00\u9875", { a: DM.projects, p: cur - 1 }));
6097
+ if (cur < pageCount - 1) nav.push(button("\u4E0B\u4E00\u9875 \u27A1\uFE0F", { a: DM.projects, p: cur + 1 }));
6098
+ nav.push(button("\u2B05\uFE0F \u83DC\u5355", { a: DM.menu }));
6099
+ elements.push(actions(nav));
6083
6100
  return card(elements, { header: { title: "\u{1F4C1} \u9879\u76EE\u5217\u8868", template: "wathet" } });
6084
6101
  }
6085
6102
  function buildProjectTopicsCard(project, sessions) {
@@ -6233,7 +6250,11 @@ function buildGroupSettingsCard(project) {
6233
6250
  { label: "\u5F00", value: "on" },
6234
6251
  { label: "\u5173", value: "off" }
6235
6252
  ]),
6236
- note("\u5F00\u542F\u540E\uFF1A\u4E0A\u4E0B\u6587\u63A5\u8FD1\u4E0A\u9650\u65F6 Codex \u81EA\u52A8\u603B\u7ED3\u65E9\u524D\u5BF9\u8BDD\u3001\u91CA\u653E\u7A7A\u95F4\uFF08\u9ED8\u8BA4\u5F00\uFF09\u3002\u6539\u52A8\u4E0B\u4E00\u8F6E\u4F1A\u8BDD\u751F\u6548\u3002")
6253
+ note("\u5F00\u542F\u540E\uFF1A\u4E0A\u4E0B\u6587\u63A5\u8FD1\u4E0A\u9650\u65F6 Codex \u81EA\u52A8\u603B\u7ED3\u65E9\u524D\u5BF9\u8BDD\u3001\u91CA\u653E\u7A7A\u95F4\uFF08\u9ED8\u8BA4\u5F00\uFF09\u3002\u6539\u52A8\u4E0B\u4E00\u8F6E\u4F1A\u8BDD\u751F\u6548\u3002"),
6254
+ hr(),
6255
+ md("\u{1F916} \u9ED8\u8BA4\u6A21\u578B / \u63A8\u7406\u5F3A\u5EA6"),
6256
+ actions([button("\u8BBE\u7F6E\u9ED8\u8BA4\u6A21\u578B", { a: GS.modelDefault }, "primary")]),
6257
+ note(`\u5F53\u524D ${modelDefaultSummary(project)}\u3000\xB7\u3000\u65B0\u8BDD\u9898\u7684\u8D77\u59CB\u6A21\u578B / \u63A8\u7406\u5F3A\u5EA6\uFF08\u8BDD\u9898\u5185 \`/model\` \u53EF\u4E34\u65F6\u6539\uFF09\u3002`)
6237
6258
  ],
6238
6259
  { header: { title: "\u2699\uFE0F \u7FA4\u8BBE\u7F6E", template: "blue" } }
6239
6260
  );
@@ -6348,6 +6369,81 @@ function buildPermissionCard(p) {
6348
6369
  { header: { title: "\u{1F510} \u6743\u9650", template: "blue" } }
6349
6370
  );
6350
6371
  }
6372
+ var EFFORT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh"];
6373
+ function modelDefaultSummary(p) {
6374
+ if (!p.defaultModel) return "\u540E\u7AEF\u9ED8\u8BA4\uFF08\u672A\u8BBE\uFF09";
6375
+ const eff = p.defaultEffort ? ` \xB7 \u5F3A\u5EA6 ${EFFORT_LABEL[p.defaultEffort]}` : "";
6376
+ return `${p.defaultModel}${eff}`;
6377
+ }
6378
+ function buildModelDefaultCard(p, models, ctx, notice) {
6379
+ const visible = models.filter((m) => !m.hidden);
6380
+ const explicit = p.defaultModel ? visible.find((m) => m.id === p.defaultModel) : void 0;
6381
+ const curModel = explicit ?? visible.find((m) => m.isDefault) ?? visible[0];
6382
+ const curEfforts = curModel?.supportedEfforts ?? [];
6383
+ const curEffort = explicit && p.defaultEffort && curEfforts.includes(p.defaultEffort) ? p.defaultEffort : curModel?.defaultEffort;
6384
+ const unionEfforts = EFFORT_ORDER.filter((e) => visible.some((m) => (m.supportedEfforts ?? []).includes(e)));
6385
+ const canPickModel = visible.length > 1;
6386
+ const canPickEffort = unionEfforts.length > 0;
6387
+ const submit = ctx === "dm" ? { a: DM.modelDefaultSubmit, n: p.name } : { a: GS.modelDefaultSubmit };
6388
+ const back = ctx === "dm" ? { a: DM.projectSettings, n: p.name } : { a: GS.settings };
6389
+ const head = [
6390
+ ...notice ? [md(notice)] : [],
6391
+ md(`**\u{1F916} \u9ED8\u8BA4\u6A21\u578B / \u63A8\u7406\u5F3A\u5EA6** \xB7 ${p.name}`),
6392
+ note(
6393
+ "\u672C\u9879\u76EE**\u65B0\u8BDD\u9898**\u7684\u8D77\u59CB\u6A21\u578B\u4E0E\u63A8\u7406\u5F3A\u5EA6\u3002\u8FDB\u884C\u4E2D / \u5DF2\u6062\u590D\u7684\u4F1A\u8BDD\u4E0D\u53D7\u5F71\u54CD\uFF1B\u8BDD\u9898\u5185\u968F\u65F6\u53EF\u7528 `/model` \u4E34\u65F6\u6539\u3002\u672A\u8BBE\u65F6\u7528\u540E\u7AEF\u81EA\u5E26\u9ED8\u8BA4\u3002"
6394
+ )
6395
+ ];
6396
+ if (!canPickModel && !canPickEffort) {
6397
+ return card(
6398
+ [
6399
+ ...head,
6400
+ hr(),
6401
+ md(`\u5F53\u524D\u6A21\u578B\uFF1A**${curModel?.displayName ?? p.defaultModel ?? "\u540E\u7AEF\u9ED8\u8BA4"}**`),
6402
+ note("\u8BE5\u540E\u7AEF\u53EA\u6709\u4E00\u4E2A\u6A21\u578B\u4E14\u4E0D\u652F\u6301\u8C03\u8282\u63A8\u7406\u5F3A\u5EA6\uFF0C\u65E0\u9700\u8BBE\u7F6E\u9ED8\u8BA4\u3002"),
6403
+ actions([button("\u2B05\uFE0F \u8FD4\u56DE", back)])
6404
+ ],
6405
+ { header: { title: "\u{1F916} \u9ED8\u8BA4\u6A21\u578B", template: "blue" } }
6406
+ );
6407
+ }
6408
+ const formEls = [];
6409
+ if (canPickModel) {
6410
+ formEls.push(
6411
+ md("\u{1F916} **\u9ED8\u8BA4\u6A21\u578B**"),
6412
+ selectMenu({
6413
+ name: "model",
6414
+ placeholder: "\u9009\u62E9\u9ED8\u8BA4\u6A21\u578B",
6415
+ options: visible.map((m) => ({ label: m.displayName, value: m.id })),
6416
+ initial: curModel?.id
6417
+ })
6418
+ );
6419
+ }
6420
+ if (canPickEffort) {
6421
+ formEls.push(
6422
+ md("\u{1F9E0} **\u9ED8\u8BA4\u63A8\u7406\u5F3A\u5EA6**"),
6423
+ selectMenu({
6424
+ name: "effort",
6425
+ placeholder: "\u9009\u62E9\u9ED8\u8BA4\u63A8\u7406\u5F3A\u5EA6",
6426
+ options: unionEfforts.map((e) => ({ label: `\u5F3A\u5EA6\uFF1A${EFFORT_LABEL[e]}`, value: e })),
6427
+ initial: curEffort
6428
+ })
6429
+ );
6430
+ }
6431
+ formEls.push(actions([submitButton("\u2705 \u4FDD\u5B58\u9ED8\u8BA4", submit, "primary", "submit_model_default")]));
6432
+ return card(
6433
+ [
6434
+ ...head,
6435
+ hr(),
6436
+ // single-model backend (effort-only form): name the locked model so the lone
6437
+ // effort dropdown isn't confusing.
6438
+ ...canPickModel ? [] : [md(`\u9ED8\u8BA4\u6A21\u578B\uFF1A**${curModel?.displayName ?? "\u540E\u7AEF\u9ED8\u8BA4"}**\uFF08\u8BE5\u540E\u7AEF\u4EC5\u4E00\u4E2A\u6A21\u578B\uFF09`)],
6439
+ form("model_default", formEls),
6440
+ ...canPickModel && !canPickEffort ? [note("\u8BE5\u540E\u7AEF\u4E0D\u8C03\u8282\u63A8\u7406\u5F3A\u5EA6\uFF08\u601D\u8003\u7531\u6A21\u578B\u81EA\u52A8\u8C03\u5EA6\uFF0C\u65E0 effort \u6863\uFF09\u3002")] : [],
6441
+ note("\u4FDD\u5B58\u53EA\u5F71\u54CD\u4E4B\u540E\u65B0\u5EFA\u7684\u8BDD\u9898\uFF0C\u4E0D\u4F1A\u6253\u65AD\u6B63\u5728\u8FDB\u884C\u7684\u4F1A\u8BDD\u3002"),
6442
+ actions([button("\u2B05\uFE0F \u8FD4\u56DE", back)])
6443
+ ],
6444
+ { header: { title: "\u{1F916} \u9ED8\u8BA4\u6A21\u578B", template: "blue" } }
6445
+ );
6446
+ }
6351
6447
  function buildProjectSettingsCard(project, backendName, notice) {
6352
6448
  const kind = project.kind ?? "multi";
6353
6449
  const noMention = project.noMention ?? defaultNoMention(project);
@@ -6382,6 +6478,10 @@ function buildProjectSettingsCard(project, backendName, notice) {
6382
6478
  ]),
6383
6479
  note("\u5F00\u542F\u540E\uFF1A\u4E0A\u4E0B\u6587\u63A5\u8FD1\u4E0A\u9650\u65F6 Codex \u81EA\u52A8\u603B\u7ED3\u65E9\u524D\u5BF9\u8BDD\u3001\u91CA\u653E\u7A7A\u95F4\uFF08\u9ED8\u8BA4\u5F00\uFF09\u3002\u6539\u52A8\u4E0B\u4E00\u8F6E\u4F1A\u8BDD\u751F\u6548\u3002"),
6384
6480
  hr(),
6481
+ md("\u{1F916} \u9ED8\u8BA4\u6A21\u578B / \u63A8\u7406\u5F3A\u5EA6"),
6482
+ actions([button("\u8BBE\u7F6E\u9ED8\u8BA4\u6A21\u578B", { a: DM.modelDefault, n: project.name }, "primary")]),
6483
+ note(`\u5F53\u524D ${modelDefaultSummary(project)}\u3000\xB7\u3000\u65B0\u8BDD\u9898\u7684\u8D77\u59CB\u6A21\u578B / \u63A8\u7406\u5F3A\u5EA6\uFF08\u8BDD\u9898\u5185 \`/model\` \u53EF\u4E34\u65F6\u6539\uFF09\u3002`),
6484
+ hr(),
6385
6485
  actions([button("\u{1F6E1} \u54CD\u5E94\u767D\u540D\u5355", { a: DM.allowlist, n: project.name }, "primary")]),
6386
6486
  note("\u8BBE\u7F6E\u8C01\u80FD\u8BA9\u6211\u5728\u672C\u7FA4\u54CD\u5E94 / \u8DD1 codex\uFF08\u7A7A = \u6240\u6709\u4EBA\uFF09\u3002"),
6387
6487
  hr(),
@@ -6547,6 +6647,12 @@ async function performSetAutoCompact(opts) {
6547
6647
  await opts.evictLiveSessionsForChat(p.chatId);
6548
6648
  return { ok: true, project: await freshOr(opts.projectName, { ...p, autoCompact: opts.on }) };
6549
6649
  }
6650
+ async function performSetModelDefault(opts) {
6651
+ const p = await getProjectByName(opts.projectName);
6652
+ if (!p) return { ok: false, reason: `\u9879\u76EE\u300C${opts.projectName}\u300D\u4E0D\u5B58\u5728` };
6653
+ await updateProject(opts.projectName, { defaultModel: opts.model, defaultEffort: opts.effort });
6654
+ return { ok: true, project: await freshOr(opts.projectName, { ...p, defaultModel: opts.model, defaultEffort: opts.effort }) };
6655
+ }
6550
6656
  function createAdminWriteExecutor(deps) {
6551
6657
  return async (op) => {
6552
6658
  const outcome = await runAdminWriteOp(op, deps);
@@ -10686,6 +10792,17 @@ function selectValue(formValue, name) {
10686
10792
  function asTier(v) {
10687
10793
  return v === "qa" || v === "write" || v === "full" ? v : void 0;
10688
10794
  }
10795
+ var REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"];
10796
+ function asEffort(v) {
10797
+ return v !== void 0 && REASONING_EFFORTS.includes(v) ? v : void 0;
10798
+ }
10799
+ function pickDefault(models, prefer) {
10800
+ const preferred = prefer?.model ? models.find((m) => m.id === prefer.model && !m.hidden) : void 0;
10801
+ const def = preferred ?? models.find((m) => m.isDefault && !m.hidden) ?? models.find((m) => !m.hidden) ?? models[0];
10802
+ const supported = def?.supportedEfforts ?? [];
10803
+ const effort = preferred && prefer?.effort && supported.includes(prefer.effort) ? prefer.effort : def?.defaultEffort ?? "medium";
10804
+ return { model: def?.id ?? "gpt-5.5", effort };
10805
+ }
10689
10806
  function backendOptionsFor(mode) {
10690
10807
  const opts = visibleCatalog().filter((e) => !e.supportedModes || e.supportedModes.includes(mode)).map((e) => {
10691
10808
  const installed = e.id === DEFAULT_BACKEND_ID || isBackendEntryInstalled(e);
@@ -10806,10 +10923,6 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
10806
10923
  const lastUsage = /* @__PURE__ */ new Map();
10807
10924
  const seenInbound = new RecentIdCache();
10808
10925
  const listModels = (be = backend) => be.listModels();
10809
- function pickDefault(models) {
10810
- const def = models.find((m) => m.isDefault && !m.hidden) ?? models.find((m) => !m.hidden) ?? models[0];
10811
- return { model: def?.id ?? "gpt-5.5", effort: def?.defaultEffort ?? "medium" };
10812
- }
10813
10926
  async function addReaction(messageId, emoji) {
10814
10927
  try {
10815
10928
  const r = await channel.rawClient.im.v1.messageReaction.create({
@@ -11272,7 +11385,10 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
11272
11385
  const tIntake = Date.now();
11273
11386
  let tResolveDone = tIntake;
11274
11387
  const threadP = (async () => {
11275
- const { model: model2, effort: effort2 } = pickDefault(await listModels(be));
11388
+ const { model: model2, effort: effort2 } = pickDefault(await listModels(be), {
11389
+ model: project?.defaultModel,
11390
+ effort: project?.defaultEffort
11391
+ });
11276
11392
  const thread2 = await be.startThread({ cwd, model: model2, effort: effort2, mode: perm.mode, network: perm.network, autoCompact: perm.autoCompact });
11277
11393
  tResolveDone = Date.now();
11278
11394
  return { thread: thread2, model: model2, effort: effort2 };
@@ -11360,7 +11476,7 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
11360
11476
  const [rec, project] = await Promise.all([getSession(sessionKey), getProjectByChatId(msg.chatId)]);
11361
11477
  const be = backendFor(rec?.backend ?? project?.backend);
11362
11478
  const models = await listModels(be);
11363
- const def = pickDefault(models);
11479
+ const def = pickDefault(models, { model: project?.defaultModel, effort: project?.defaultEffort });
11364
11480
  const recModel = rec?.model && models.some((m) => m.id === rec.model) ? rec.model : void 0;
11365
11481
  const state = {
11366
11482
  chatId: msg.chatId,
@@ -11700,7 +11816,7 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
11700
11816
  }
11701
11817
  })();
11702
11818
  };
11703
- const renderProjectList = async () => {
11819
+ const renderProjectList = async (page = 0) => {
11704
11820
  const [projects, sessions2] = await Promise.all([listProjects(), listSessions()]);
11705
11821
  const byChat = /* @__PURE__ */ new Map();
11706
11822
  for (const s of sessions2) {
@@ -11708,7 +11824,7 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
11708
11824
  if (arr) arr.push(s);
11709
11825
  else byChat.set(s.chatId, [s]);
11710
11826
  }
11711
- return buildProjectListCard(projects, byChat);
11827
+ return buildProjectListCard(projects, byChat, page);
11712
11828
  };
11713
11829
  const buildDoctorInfo = async () => {
11714
11830
  const codexProbe = await backend.doctor({ force: true });
@@ -11799,9 +11915,10 @@ function createOrchestrator(channel, cfg, fallbackCwd, cliBridge) {
11799
11915
  (e) => log.fail("console", e, { phase: "join-group-result" })
11800
11916
  );
11801
11917
  })();
11802
- }).on(DM.projects, ({ evt }) => {
11918
+ }).on(DM.projects, ({ evt, value }) => {
11803
11919
  if (!dmAdmin(evt.operator?.openId)) return;
11804
- patch(evt, renderProjectList);
11920
+ const page = typeof value.p === "number" ? value.p : Number(value.p) || 0;
11921
+ patch(evt, () => renderProjectList(page));
11805
11922
  }).on(DM.settings, async ({ evt }) => {
11806
11923
  if (dmAdmin(evt.operator?.openId)) await patch(evt, () => renderSettings(true));
11807
11924
  }).on(CLI.toggleEnabled, async ({ evt, value }) => {
@@ -12021,6 +12138,40 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
12021
12138
  }
12022
12139
  return buildGroupSettingsCard({ name: "\u672C\u7FA4", kind: "multi", autoCompact: on });
12023
12140
  });
12141
+ }).on(GS.settings, ({ evt }) => {
12142
+ if (!isAdmin(cfg, evt.operator?.openId ?? "")) return;
12143
+ patch(evt, async () => {
12144
+ const project = await getProjectByChatId(evt.chatId);
12145
+ return buildGroupSettingsCard(project ?? { name: "\u672C\u7FA4", kind: "multi" });
12146
+ });
12147
+ }).on(GS.modelDefault, ({ evt }) => {
12148
+ if (!isAdmin(cfg, evt.operator?.openId ?? "")) return;
12149
+ patch(evt, async () => {
12150
+ const project = await getProjectByChatId(evt.chatId);
12151
+ if (!project) return buildGroupSettingsCard({ name: "\u672C\u7FA4", kind: "multi" });
12152
+ const models = await listModels(backendFor(project.backend));
12153
+ return buildModelDefaultCard(project, models, "group");
12154
+ });
12155
+ }).on(GS.modelDefaultSubmit, ({ evt, formValue }) => {
12156
+ if (!isAdmin(cfg, evt.operator?.openId ?? "")) return;
12157
+ const modelId = selectValue(formValue, "model");
12158
+ const effortRaw = asEffort(selectValue(formValue, "effort"));
12159
+ void (async () => {
12160
+ const project = await getProjectByChatId(evt.chatId);
12161
+ if (!project) return;
12162
+ const models = await listModels(backendFor(project.backend));
12163
+ const m = modelId ? models.find((x) => x.id === modelId && !x.hidden) : void 0;
12164
+ if (m) {
12165
+ const supported = m.supportedEfforts ?? [];
12166
+ const effort = effortRaw && supported.includes(effortRaw) ? effortRaw : supported.length ? m.defaultEffort : void 0;
12167
+ const r = await performSetModelDefault({ projectName: project.name, model: m.id, effort });
12168
+ if (r.ok) log.info("console", "group-model-default", { project: project.name, model: m.id, effort });
12169
+ }
12170
+ const fresh = await getProjectByChatId(evt.chatId) ?? project;
12171
+ await sendManagedCard(channel, evt.chatId, buildGroupSettingsCard(fresh)).catch(
12172
+ (e) => log.fail("console", e, { phase: "group-model-default-result" })
12173
+ );
12174
+ })();
12024
12175
  }).on(DM.admins, ({ evt }) => {
12025
12176
  if (!dmAdmin(evt.operator?.openId)) return;
12026
12177
  patch(
@@ -12161,6 +12312,42 @@ ${tail}` }, { replyTo: evt.messageId }).catch(() => void 0);
12161
12312
  (e) => log.fail("console", e, { phase: "permission-result" })
12162
12313
  );
12163
12314
  })();
12315
+ }).on(DM.modelDefault, ({ evt, value }) => {
12316
+ if (!dmAdmin(evt.operator?.openId)) return;
12317
+ const name = typeof value.n === "string" ? value.n : "";
12318
+ patch(evt, async () => {
12319
+ const p = await getProjectByName(name);
12320
+ if (!p) return buildDmMenuCard({ webConsoleUrl: webConsoleUrl(), version: bridgeVersion() });
12321
+ const models = await listModels(backendFor(p.backend));
12322
+ return buildModelDefaultCard(p, models, "dm");
12323
+ });
12324
+ }).on(DM.modelDefaultSubmit, ({ evt, value, formValue }) => {
12325
+ if (!dmAdmin(evt.operator?.openId)) return;
12326
+ const name = typeof value.n === "string" ? value.n : "";
12327
+ const modelId = selectValue(formValue, "model");
12328
+ const effortRaw = asEffort(selectValue(formValue, "effort"));
12329
+ void (async () => {
12330
+ const p = await getProjectByName(name);
12331
+ if (!p) return;
12332
+ const models = await listModels(backendFor(p.backend));
12333
+ const m = modelId ? models.find((x) => x.id === modelId && !x.hidden) : void 0;
12334
+ let notice;
12335
+ if (!m) {
12336
+ notice = "\u26A0\uFE0F \u6240\u9009\u6A21\u578B\u65E0\u6548\u6216\u5DF2\u4E0B\u67B6\uFF0C\u672A\u4FDD\u5B58\u3002";
12337
+ } else {
12338
+ const supported = m.supportedEfforts ?? [];
12339
+ const effort = effortRaw && supported.includes(effortRaw) ? effortRaw : supported.length ? m.defaultEffort : void 0;
12340
+ const r = await performSetModelDefault({ projectName: name, model: m.id, effort });
12341
+ notice = r.ok ? `\u2705 \u9ED8\u8BA4\u5DF2\u8BBE\u4E3A\u300C${m.displayName}\u300D${effort ? ` \xB7 \u5F3A\u5EA6 ${effort}` : ""}\uFF0C\u65B0\u8BDD\u9898\u751F\u6548\u3002` : `\u26A0\uFE0F ${r.reason}`;
12342
+ if (r.ok) log.info("console", "project-model-default", { project: name, model: m.id, effort });
12343
+ }
12344
+ const fresh = await getProjectByName(name) ?? p;
12345
+ await sendManagedCard(
12346
+ channel,
12347
+ evt.chatId,
12348
+ buildProjectSettingsCard(fresh, backendDisplayName(fresh.backend), notice)
12349
+ ).catch((e) => log.fail("console", e, { phase: "model-default-result" }));
12350
+ })();
12164
12351
  });
12165
12352
  async function resumeFromCard(evt, state, sessionId, backendId) {
12166
12353
  const be = backendFor(backendId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelzen/feishu-codex-bridge",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Bridge Feishu/Lark messenger with local Codex via app-server (project=group, thread=session)",
5
5
  "type": "module",
6
6
  "bin": {