dsh-feishu-cui
免运维的远程 DSH:一套完整的远程使用方案:拿飞书私聊当操作界面(CUI)——装一个插件、填一对凭据、在飞书里配对一次,之后在手机上就能用 DSH;常用操作都是卡片和菜单里点,不用像 TUI 那样记命令:发消息排进当前会话的队列、回答回到你那条消息上,工作区 / 会话(切换与改名)/ 模型 / 推理深度 / 权限 / 余额都在飞书里点,agent 反问与工具审批也在飞书答。
往下看:这是什么 · 怎么用 · 边界与约定 · 代码结构 · 用例脉络 · 看日志 · 术语表 · 还没做
这是什么
一套远程使用 DSH 的入口:拿飞书私聊当操作界面(CUI)——提问、切会话、切模型与权限、打断、答反问、批工具都在飞书里做。执行面在宿主:真正跑命令、改文件的是 DSH 里的 agent,沙箱与审批策略由 DSH 决定。它由两半组成——宿主半边的插件(lib/,跑在 dsh web 里)和浏览器半边的设置页(lib/settings/client.js,挂在 DSH 的 Web 界面上)。
它替你省掉的运维(这是它跟「自己搭一套远程访问」最大的区别):
- 不用公网入口:不需要公网 IP / 域名 / HTTPS 证书 / 反向代理。
- 不用内网穿透:不需要 frp / ngrok / tmate 这类东西。
- 不用开端口、改防火墙 / 安全组:机器人往飞书出站建一条长连接,网络侧什么都不用动。
- 不用自建中转服务:消息走飞书官方通道(机器人长连接 + 卡片回调)。
- 不用写客户端:飞书本身就是客户端——私聊收回答,卡片做选择,菜单做入口。
- 不用自己搓鉴权:配对码一次性绑定 owner,机器人只服务他一个人。
- 不用盯着断线:自动重连 + 存活看门狗(见长连接的存活与恢复)。
- 不用额外起服务:它就是
dsh web 的一个插件,装完跟着宿主加载。
- 不用搭服务端:服务端就是飞书。要自己动手的只有开发者后台那几步——建一个自建应用、加 3 项订阅(两个事件 + 卡片回调)、补 2 条权限、配 8 个菜单项、发一次版本(见飞书那边)。
跟 TUI 比,省的是「记」:终端里每一步都要敲命令、记参数、盯滚动;这里工作区 / 会话 / 模型 / 推理深度 / 权限 / 余额各是一张卡,切会话、改名、打断、答反问、批审批都在飞书的消息和按钮里完成,回答做成卡片回到你发消息的那条上。
能做什么
- 私聊发一句话 → 排进「当前会话」的队列(不打断正在跑的那一轮),回答回到那条消息上:卡片跟着状态走——被取走后是「处理中」(带「停止」按钮),刚进队列还没被取走是「排队中」(带「撤回」按钮),之后逐段把正文接上去,跑完收尾成「已完成」/「已停止」/「处理失败」。正文超过卡片体积上限(30KB)就换成「任务失败」+ 一句「去网页端看」。
- 菜单八项:申请配对、工作区列表、会话列表(含「新建」)、会话重命名、模型、推理深度、权限、余额。
- agent 反问(
ask_user_question)与工具审批(工具要授权)都在飞书弹卡:谁触发的谁答。
- 别处(网页端 / CLI)改了当前会话的模型 / 权限 / 会话名 → 主动私聊通知 owner 一张「模型已修改」/「权限已修改」/「会话名已修改」卡。
- 长连接自愈:休眠 / 半开连接有存活看门狗(见长连接的存活与恢复)。
不做什么(能力边界,详细取舍见边界与约定)
- 只服务 owner 一个人:别人发消息、点菜单一律回同一句「CUI会话未匹配」,且不区分「还没绑定」和「不是 owner」。
- 只认私聊、只认文本:图片 / 文件 / 附件永不支持。
- 只认「飞书当前会话」:别的会话怎么聊都不回答、也不推送。
- 反问只接「有选项、且不是多选」的题;多选题与全靠打字的题让给网页端(让路之前先回一句「这题飞书答不了」)。
- 模型 / 推理深度 / 权限都是会话级设置,只作用于当前会话。
- 卡片上不能自由输入文字(只有配对填配对码、会话重命名填名字这两张卡能输)。
前置条件
- 本机已装 DSH(
dsh 与 dsh plugin 可用),profile 用 web——设置页挂在它的 Web 服务器上。
- 一个自己的飞书自建应用(要能开长连接),并且只给自己用:开发者后台的可用范围只勾自己。
- 别跟
dsh-feishu-assistant 共用同一个飞书应用:长连接是集群模式、不广播,同 App 会互相抢事件。凭据引用名也各用各的(这个插件读 FEISHU_CUI_APP_ID / FEISHU_CUI_APP_SECRET)。
依赖的宿主能力(缺了哪个,对应功能就自动退化,并在日志里说明原因)
credentials、settings、webServer、sessionController(投喂 / 打断 / 撤排队项 / 模型目录 / 会话句柄)这四项写在 inject 里;另外按需取 sessionQuery(列会话)、workspaceRegistry(读工作区)、permissionPresets(读权限预设)、userQuestions 与 approval(两条要人答的 waterfall)、sessionProjections(读当前模型)。卡片按飞书卡片 JSON 2.0 写。
怎么用
安装与前置
dsh plugin --profile web add dsh-feishu-cui
装完重启 dsh web。装在别的 profile 上就把 --profile 换成那个名字;本机改这个插件自己的代码时,用 link: 指到仓库目录也一样。
DSH 版本:当前 0.2.x 需要 DSH 0.1.7-rc.1 及以上——设置走的是 0.1.7 起的插件条目 config(带 volatile 那几个字段)。
还在 DSH 0.1.5 上的,装最后一个支持它的版本:
dsh plugin --profile web add dsh-feishu-cui@0.1.0
装的时候如果收尾报 ERR_PNPM_IGNORED_BUILDS: protobufjs,插件其实没装上。
@larksuiteoapi/node-sdk 的依赖里有 protobufjs,它带 postinstall 脚本,pnpm 默认不跑;dsh 把 pnpm 的非零退出当成整体失败,于是没把插件登记进 profile。先放行再装一次:
# ~/.dsh/profiles/<profile>/pnpm-workspace.yaml
allowBuilds:
protobufjs: true
插件分两半:宿主半边 lib/index.js 跟着 dsh web 加载;浏览器半边是设置页,插件装上后在 DSH 的 Web 界面里出现「飞书CUI会话」分区。
改完代码要生效得重启 dsh web——插件只在启动时加载一次。
飞书那边
开发者后台 → 你的自建应用:
- 事件与回调的订阅方式选 长连接;
- 事件加
im.message.receive_v1、application.bot.menu_v6;
- 卡片回调加
card.action.trigger;
- 权限补
im:message.p2p_msg:readonly、im:message:send_as_bot;
- 可用范围只勾自己——机器人只对你一个人开放,别放给部门或全员。
机器人自定义菜单:下面这套是推荐配置,不是硬要求。
硬约束只有三条,少一条就不工作:
- 子菜单都选 推送事件;
event_key 必须和下表完全一致(插件按它分流);
- 父菜单只当容器、不配事件——配了它会推一个插件不认识的
event_key:点了没反应(它不执行动作,也不回话),只有日志里多一句「这个菜单项还没有去处」。
至于分几个父菜单、叫什么名字、谁挂在谁下面、子菜单怎么排序,插件一概不看(它只认叶子的 event_key),怎么分都行;展示形式也随你。飞书后台的顺序是先建子菜单、再建父菜单。
下面这套是现在后台里的实际配置,可以照抄:
| 父菜单 | 子菜单(从上到下) | event_key | 点了会怎样 |
|---|
| 会话设置 | 模型 | model | 发「模型」卡 |
| 推理深度 | effort | 发「推理深度」卡 |
| 重命名 | session-rename | 发一张输入框卡片,填新名字(改的是当前会话) |
| 权限 | permission | 发「权限」卡 |
| 导航 | 会话列表 | sessions | 发「选择会话」卡(底部多一个「新建」) |
| 工作区列表 | workspaces | 发「选择工作区」卡 |
| 操作 | 查询余额 | balance | 发「账户余额」卡 |
| 申请配对 | pairing | 发一张输入框卡片,让你填配对码(见下一节) |
「模型」和「推理深度」是两张独立的卡,所以两个菜单项都要配;工作区 / 会话 / 模型 / 推理深度 / 权限各是一张卡带一组选项,一个菜单项就够,不需要为每个选项各配一项。权限那三个预设(read-only / workspace-write / danger-full-access)配不配都行,配了就也打开权限卡,只是冗余。
机器人只对 owner 一个人开放。 可用范围里只勾你自己,这是第一道闸;插件这层是第二道:
除了配对,别人发消息、点菜单一律只收到「CUI会话未匹配」,卡片上的操作也只回一句同文案的提示——
不静默、不执行任何动作,而且不区分「还没绑定」和「不是 owner」(对外不暴露绑定状态)。
所以聊天里不会提示你去配对,配对指路只在设置页和本 README 里。
别和 dsh-feishu-assistant 共用同一个飞书应用:长连接是集群模式、不广播,同 App 会互相抢事件。
插件里配对
在飞书里点菜单「申请配对」,机器人发一张输入框卡片,卡片上写着「设置页里有一串配对码,把它填到下面这个输入框里。」打开 DSH 的设置页 →「飞书CUI会话」,那里显示这串码;把它填进卡片、点「确定」,填的那个人就成为 owner。
- 配对码 10 分钟有效、只能用一次;已经绑过就不再生成(再点菜单「申请配对」只会收到「存在绑定会话,无法申请」)。
- 设置页的状态快照里有:长连接是否建立、当前绑定的 user、在册的配对码、两项凭据配没配。页面不轮询,随时点「刷新」重新读一次。
- 换人要先解绑:设置页点「解绑」→ 清掉绑定的 user、在册的配对码一并作废;同时给原 owner 私聊发一张「已解绑」的卡(发不出去只记日志,不影响解绑)。之后回飞书重新点「申请配对」拿新码。
解绑不掐正在跑的那一轮、也不撤排队里的消息:那一轮照旧跑完、回答卡也会走完,但原 owner 之后发消息、点卡片就只会收到「CUI会话未匹配」。
- 凭据(App ID / App Secret)也在同一页填:保存后不再回显,存进
$DSH_HOME/.credentials.yaml,保存即按新凭据重建长连接。
日常用法
- 私聊机器人发消息(owner 专用):回你一张卡片(回复在你发的那条消息上),之后这一轮每产出一段就把那张卡整张换掉,跑完收尾。
上屏的只有正文(助手消息里的 text 块):思考、工具调用、工具结果都不上屏——思考一轮能写几万字、工具结果常常是整份文件,一张卡片装不下。想看完整过程在网页端看。
这句话同时排进当前会话的队列,不打断正在跑的那一轮;队列不设上限。
卡片的时机:进队列时先不开卡;被这一轮取走就是「处理中」;排了一小会儿(100ms)还没被取走,就先开一张「排队中」。不做兜底:两种信号一条都没来就不开卡。
跑完了却没有正文,卡片上补一句「这一轮没有可显示的正文」,不会一直挂在「处理中」。
「停止」:打断当前这一轮(跟网页端「停止生成」同一件事,走会话控制器的取消)——只停正在跑的这轮,已经在队列里排着的不受影响。点完先变「正在停止」,这一轮真正收尾时把已产出的正文留着、标题改成「已停止」。按钮只出现在飞书自己发起的那一轮。
「撤回」:把还没开跑的那条从队列里撤掉(走宿主的排队项移除),卡片换成「对话已取消」。
正文装不进一张卡片(超过 30KB)就不再往上接:卡片换成「任务失败」+「回答内容过多,卡片无法全部展示,请到网页端查看」。
还没选会话时回一句「还没有当前会话」;不是文本的消息回一句「只支持文本消息」(图片 / 附件永不支持)。
投喂走宿主的会话控制器(
mode: 'queue'),不是自己往会话日志里塞事件——模型可用性、会话是否还在这些校验都交给宿主。
- 会话列表里只列当前工作区的会话,摘要写「本工作区有 N 个会话」;一次都没跑过的空会话(没标题那种)不列。底部「新建」会在当前工作区里开一个新会话并切过去。
- 会话重命名改的是当前会话:点菜单发一张输入框卡片,填新名字、点「确定」;名字由宿主归一化(去掉转义序列与控制字符、空白压成一个、超长按字节截断),卡片先换成「已请求:会话改名为【X】」,宿主要是没收下就另发一张卡写「会话改名失败」;真改成了由宿主那条事件发一张「会话名已修改」。
- 有活没干完时不让换会话、换工作区:还有回答卡在册(排队中 / 处理中 / 正在停止)时,那两张卡的「确定」「新建」会被挡下来,回一句「有正在进行的任务,无法切换会话 / 工作区」。
主动通知
- 当前会话的模型(连同推理深度)、权限或会话名被改了,就往飞书发一张卡:标题「模型已修改」/「权限已修改」/「会话名已修改」,正文「模型改成【X】」「推理深度改成【X】」「权限改成【X】」「会话名改成【X】」。
- 不管是谁改的:宿主那三条事件(
model/selection、permission/preset、session/title)里没有「谁改的」这个信息,所以网页端改、CLI 改、以及你自己从飞书那几张设置卡改,都会收到这张卡。
从飞书改的那一次,它正好就是这次请求的回应:设置卡先变成「已请求:…」,紧接着这张「…已修改」的新卡到。
- 会话名那条事件要挑一下:自动起的标题(模型生成的、兜底的)也走它,只有
source.kind 是 user(人手动改名)才通知。
- 一次权限变更只发一张:预设名变了才通知,跟着变的沙箱模式与审批策略不单独报(它们就是预设的内容)。
长连接的存活与恢复
- 存活看门狗:发出去的 ping 15 秒没收到任何回帧就判定连接死了,拆掉重连。SDK 默认不开这个看门狗,不开的代价很隐蔽:Mac 休眠再回来、或者 NAT 悄悄掐掉连接之后 TCP 是半开的,没有 FIN/RST,socket 层永不报错——连接看着还是「已连接」,而飞书事件从此一条都收不到。
- 自动重连:SDK 自己重连(日志里是「飞书长连接断开,开始重连」→「飞书长连接已建立」)。断开这段时间的消息飞书会补推,恢复之后可能一次到一批。
- 迟到的消息直接丢:事件时间比本机时间早 3 秒以上的,不回执也不投喂(补推来的旧消息不能当成新话)。判据是本机时钟,机器时间快 3 秒以上会把所有消息都判成迟到。
- 同一条消息只处理一次:按飞书消息 ID 记账,最近 200 条(重投只发生在断线重连前后,够用)。
自检与排查
设置页「飞书CUI会话」那一块就能看大半:长连接是否建立、绑的是谁、在册的配对码、凭据配没配。页面不轮询,点「刷新」。
常见症状:
| 症状 | 大概是什么 |
|---|
| 发消息没任何反应 | 长连接断了(看日志里「飞书长连接断开」);或者飞书后台的事件订阅没配 |
| 菜单点了没反应,日志里写「这个菜单项还没有去处:…」 | 飞书后台那一项的 event_key 和上表不一致;或者你给父菜单配了事件(父菜单只当容器,不该推事件) |
| 回「CUI会话未匹配」 | 还没配对,或者发消息的人不是 owner |
| 回「还没有当前会话」 | 先去菜单里选一个会话 |
| 回「只支持文本消息」 | 发的是图片 / 文件 / 附件(永不支持) |
| 卡片点了只回「这张卡片已失效…」 | 这张卡已经被新卡顶掉,或者已经处理过了 |
| 设置卡点完只留「已请求:…」 | 这是设计:请求交出去了,结果一律另发一张卡——成功是宿主那条事件发的「…已修改」,失败是「…失败」那张 |
| 反问卡没弹到飞书 | 这一轮不是飞书发起的(让给网页端了);或者题目是多选 / 没选项 |
边界与约定
- 谁触发的谁答:只有飞书这边发起、而且正在跑的那一轮,反问卡和审批卡才会弹到飞书;网页端发起的轮次一律让给网页端的 UI(不然人坐在网页那边会干等)。判据是运行期那个「正在跑的那一轮是哪条飞书消息」的槽。
- 子代理问不到人:两条路都被宿主挡着——反问那边,被别人拥有的子代理一问就抛
DELEGATED_CALLER;审批那边,子会话的审批策略在派发时被钉成 never,需要审批的操作当场被拒。所以子代理的反问 / 审批根本走不到飞书。
- 会话级设置,下一次请求生效:模型、推理深度、权限都是写进当前会话的,不影响别的会话;改完从下一次提问开始按新的来。
- 设置卡点完只留「已请求」:模型、推理深度、权限、会话名这四张卡都一样——点「确定」之后卡片变「已请求:…」(校验过了、请求交出去了),后台才真正提交;结果一律另发一张卡:交成了由宿主那条事件发「…已修改」,没交成发一张写失败原因的卡。确定那张卡此后不再动,一直停在「已请求」。宿主那条会话名事件还带着自动起的标题(模型生成的和兜底的),这里只认人手动改名(
source.kind 是 user)那一种,不然每开一个新会话都要通知一次。
- 换会话 / 换工作区要有活没干完:有回答卡在册时挡下来(见日常用法)。
- 卡片体积:一张卡上限 30KB,回答卡到顶就换成「任务失败」;选项卡、反问卡在发之前也量一次,装不下的让给网页端。
- 反问只接单选且有选项:多选题、没有选项的题,飞书这边先回一句「这题飞书答不了(没有选项或者是多选题),去网页端答吧」,再把请求让给网页端。
- 反问可以不答:一道题都没选也能点「确定」,没答的题按跳过交回(
selected: [],跟网页端「跳过本题」交回的形状一样);点完换成的那张文字卡上,没答的题写「已跳过」。
- 空会话不列:一次都没跑过(0 轮)的会话不进会话列表——它没有标题,列出来只能显示一串会话 ID。读不到运行期投影时照样列(宁可多列不可漏列)。
- 在册的选项类卡片只有一张:全插件同一时刻只认一张;新发一张就把上一张作废(换成「…已取消」)。回答卡、反问卡、审批卡不在这张名册里,各自按自己的键在册。
代码结构
按「谁跟谁说话」分层,上层依赖下层,反过来不依赖:
dsh-feishu-cui/
├── package.json 三条脚本:check(对每个模块 node --check)、test(回归用例)、prepublishOnly(发布前跑前两条)
├── README.md 装 / 配 / 用法 / 通知 / 休眠 / 边界 / 结构 / 脉络 / 日志 / 术语
├── CHANGELOG.md 每一版改了什么
├── test/ 22 个文件 2285 行 回归用例:不联网;单元那份用假出站,端到端那份真起插件(假 SDK + 假宿主)
└── lib/
├── index.js 1 个文件 164 行 装配:造对象、接线、生命周期
├── init.js 1 个文件 26 行 启动时的初始化:把当前会话缓存填上
├── notices.js 1 个文件 73 行 给绑定的人发卡:连上通告、解绑
├── cache/ 7 个文件 245 行 需要跨文件读写的运行期状态
├── common/ 4 个文件 427 行 文案总表、飞书事件、CUI 事件、凭据引用名
├── driving/ 6 个文件 559 行 判定与分派(飞书那头 / 宿主那头)
├── handler/ 19 个文件 2353 行 干活:菜单、卡片、回答、反问、审批、通知
├── infra/ 13 个文件 1220 行 跟外面打交道:飞书出站、宿主服务、插件配置、本机防休眠
├── settings/ 4 个文件 750 行 设置页(四条回环路由 + 浏览器半边)
├── transport/ 6 个文件 284 行 连接:飞书长连接 / REST,宿主事件订阅、waterfall
└── ui/ 6 个文件 501 行 卡片长什么样(只出 JSON)
各层干什么:
lib/index.js:唯一的入口。apply() 里按顺序造出所有对象、把依赖递进去(transport → push → 各 handler → 路由 → 准入 → 两条宿主事件订阅 + 两条 waterfall 订阅),注册设置页,最后按凭据建连。不写业务。
init.js:启动时那一步初始化。读配置里配的当前会话,问宿主还在不在(session.js 的 hasSession),把结论写进 cache/current-session.js;之后上层读 readSettings() 拿到的就是这个缓存,不再问宿主。
notices.js:给绑定的那个人发卡。连接状态变了发一张「dsh 已连接,当前会话为【…】」;解绑时把 user 清掉、在册的配对码一起作废,再给原 user 发一张「已解绑」。
transport/:门外那一段。feishu/ 是飞书侧(websocket-client.js 长连接与心跳看门狗、http-client.js REST、transport.js 把两个客户端一起持有、换凭据时整组重建);host/ 是宿主侧(session-events.js 订 session/event、agent-events.js 订收件箱三条、waterfall.js 订要人答的两条 waterfall)。
driving/:判定和分派。feishu/ 把飞书原始事件转成 CUI 事件(receiver.js)、判准入(admission.js:去重、迟到、鉴权、只认文本、有没有当前会话)、按事件分给处理函数(router.js);host/ 对宿主事件做同样三件事,router.js 只把要盯的那几种事件分给 handler/host/。
handler/:干活的地方。feishu/ 是菜单和卡片点出来的(七个菜单各一张卡 + option-card-flow.js、input-card-flow.js 两套共用骨架 + deferred-submit.js 两套共用的后台提交 + message.js 投喂 + pairing.js 配对 + session-rename.js 会话重命名 + warn.js 判定没过时回话);host/ 是宿主推过来的(waterfall.js 是反问与审批共用的骨架 + answer.js 回答卡、question.js 反问卡、approval.js 审批卡、settings-watch.js 设置变更通知)。
infra/:跟外面的接口。feishu/push.js 发出站(发卡 / 换卡,失败重试);host/ 是宿主服务的封装(会话、工作区、模型目录、权限、余额,以及按名字借服务的取用口);plugin/ 是插件自己的配置与凭据(走宿主的 settings / credentials 服务);system/sleep-guard.js 是插件自己起本机进程那块——持有一个 caffeinate -s,插件跑着就防休眠。
ui/:只造卡片 JSON。六种:text-card.js(只有正文 / 带标题栏两种)、option-card.js(选项卡:确定|取消,和确定|新建|取消两种)、input-card.js(输入框)、answer-card.js(带一个按钮的回答卡)、approval-card.js(允许|拒绝)、(共用件)。给人看的字一律不在这里。
依赖方向:driving/ 认 handler/ 和 ui/(只为拿卡片类型与判定原因那几个常量),handler/ 能 import ui/、infra/、cache/、common/(文案在 common/copy.js),ui/ 只 import ui/,transport/ 只认 common/(事件名与凭据引用名),收到的东西交给装配时递进来的回调。
改完跑两条:npm run check(每个模块过一遍 node --check)、npm test(跑 test/ 下的 18 个用例,全部不联网)。用例分两档:*-check.mjs 里的单元那份只造要测的那几个对象(假出站、假会话目录);端到端那份(connection / no-credentials / inbound / answer-card / waterfall / settings-routes)用 harness.mjs 的 startPlugin() 真调一遍 apply(),把上下文、宿主服务和飞书 SDK 都换成假的(fake-lark.mjs 配 lark-hooks.mjs 顶掉那个 SDK)。
用例脉络
A. 入站:飞书 → 会话
transport/feishu/websocket-client.js(长连接收到私聊消息)→ driving/feishu/receiver.js(转成 CUI 事件:正文、消息 ID、操作者)→ driving/feishu/admission.js(过期丢弃 → 查重记账 → 是不是 owner → 是不是文本 → 有没有当前会话)→ driving/feishu/router.js → handler/feishu/message.js → infra/host/session.js(prompt)→ 宿主的会话控制器(mode: 'queue')。
投喂进去的那条随后会被宿主取走:transport/host/agent-events.js(agent/inbox/inserted / claimed / discarded)→ driving/host/receiver.js → driving/host/router.js → handler/host/answer.js(开卡 / 改标题 / 撤回),并把「这一轮是哪条飞书消息」记进 cache/running-turn.js。
B. 出站:会话 → 飞书
transport/host/session-events.js(session/event 那条 firehose)→ driving/host/receiver.js(assistant/message 里只取 text 块当正文;turn/end 里取结束原因)→ driving/host/router.js → handler/host/answer.js → ui/answer-card.js(有按钮那几张)/ ui/text-card.js(终态)→ infra/feishu/push.js(sendCard / patchCard)。
C. 菜单与五张选择卡
菜单:websocket-client.js(application.bot.menu_v6)→ driving/feishu/receiver.js(tag = 飞书的 event_key)→ admission.js(配对放行,其余只给 owner)→ router.js → 七个 handler(sessions / session-rename / workspaces / model / effort / permission / balance,外加 pairing)→ ui/option-card.js / ui/input-card.js / ui/text-card.js → push.sendCard,消息 ID 记进 cache/pending-cards.js。
卡片回调(card.action.trigger)→ admission.admitCard(是不是本人;换会话、换工作区那两个按钮还要看有没有活没干完)→ router.js → handler/feishu/option-card-flow.js(点一行只选中 → 重画;确定 → 交给各家的 onConfirm;取消 / 没选就确定 → 按取消)或 input-card-flow.js(重命名那张卡:确定 → 交给 onSubmit)→ infra/host/{session,workspace,models,permissions}.js → 宿主。四张设置卡都先回「已请求」,后台提交(handler/feishu/deferred-submit.js)——交成了由宿主那边的事件发「…已修改」(handler/host/settings-watch.js),没交成由这里另发一张写失败原因的卡。
D. 要人来答的两条口
反问:transport/host/waterfall.js(user-questions/request,带 prepend 抢在网页端前面)→ handler/host/question.js(四道检查:是当前会话、这一轮是飞书发起的、题目画得出来、卡片发得出去;发 ui/option-card.js 的卡)→ 卡片回调 → 点「确定」交回 { answers }(没答的题是空选择);取消拒 ASK_CANCELLED;这一轮被停掉作废并拒 ASK_ABORTED。
审批:同一条 transport(approval/request)→ handler/host/approval.js(同样四道检查,发 ui/approval-card.js)→ 卡片回调 → 交回 allowed-once / rejected;这一轮被停掉交回 cancelled 并把卡片收成「审批已结束」。批完那张换成 ui/text-card.js 的带标题栏卡片,正文照旧留着。
E. 生命周期、换人与换会话
启动:lib/index.js(apply)→ infra/plugin/config.js(注册 feishu-cui 那个设置命名空间)+ infra/plugin/credentials.js(凭据存储)→ init.js(把当前会话缓存填上)→ transport/feishu/transport.js(按凭据建连)→ notices.js(连上给 owner 发一条「dsh 已连接,当前会话为【…】」);apply 里同时按开关起 infra/system/sleep-guard.js(防休眠)。
换会话 / 换工作区:菜单 → handler/feishu/sessions.js / workspaces.js → infra/host/session.js(create / open)或 infra/host/workspace.js → 写回 feishu-cui 那几个设置键。
换人(解绑):设置页 → settings/panel.js(/dsh-feishu-cui/user/unbind)→ notices.js 的 unbindUser(先把 userId 读出来 → 清 userId + 作废配对码 → 给原 user 发一张 ui/text-card.js 的「已解绑」卡,这一发送不等着发完)。
F. 设置页(浏览器半边)
settings/panel.js 挂四条只判回环与 JSON 的路由(settings/http.js)→ settings/client.js 在 DSH 设置面板里注册「飞书CUI会话」分区:填凭据、看状态与配对码、点解绑。
G. 主动通知
session/event 里的 model/selection / permission/preset / session/title → driving/host/receiver.js(把事件载荷一起带出来)→ driving/host/router.js → handler/host/settings-watch.js(拼标题与正文;会话名那条只认人手动改名)→ ui/text-card.js(带标题栏)→ push.sendCard。
看日志
插件不自己写日志文件:infra/plugin/log-exporter.js 把这一路日志按级别交给 stdout / stderr,由启动 dsh web 的那一层落盘(用 supervisor 跑的话就是它的日志)。
排查时值得搜的几行:
已订阅 session/event、已订阅 agent/inbox/...、已订阅 user-questions/request、已订阅 approval/request——插件加载时各订了什么;
飞书长连接已建立 / 飞书长连接断开——连接状态;
已把消息交给会话 <会话 ID>——投喂成功;
回答卡片已开出 / 画回答卡片(…,N 字)——这一轮的卡片在长;
反问卡片已发出 / 反问卡片:… 第 N/M 题选了【…】 / … 被人取消——反问那条口;
审批卡片已发出 / 审批卡片:… allowed-once——审批那条口;
会话设置被改了,通知绑定的人:…——主动通知;
这批题飞书答不了、这一轮不是飞书这边发起来的,让给网页端——让路的原因。
术语表
| 说法 | 指什么 |
|---|
| owner | 配对绑定的那个人,也就是设置里那个 userId;机器人只服务他 |
| 会话 | 宿主里的一个 DSH 会话;插件同一时刻只认「当前会话」那一个 |
| 一轮 | 宿主的一次问答(turn/start 到 turn/end),对应一条飞书消息 |
| CUI(conversational user interface) | 拿「人跟系统来回对话」当界面的那一类形态,跟 GUI(图形界面)、TUI(终端界面)并列;名字出自维基百科 Conversational user interface 这个词条。飞书私聊这一套就是:发消息提问、点卡片选择、机器人回话 |
| ChatOps | 在聊天软件里操作一个系统的通行叫法,最初是 GitHub 内部这么叫、后来运维圈写开了(CMU SEI、Rapid7 都有文章)。这个插件干的就是它,差别是 ChatOps 一般指「敲命令触发脚本」,这里是「问一个会思考的 agent」 |
| CUI 事件 | common/cui-event-schema.js 定的六个字段:event / tag / time / content / messageId / operatorId |
卡片类型(tag) | 发卡时写进按钮 value 的 tag,卡片回调按它路由;菜单项的 event_key 是同类东西 |
| 在册 | 内存里还记着的一张卡片(cache/):不在册的卡片上的操作一律不执行,只回一句话 |
让路(next()) | waterfall 上的说法:不接这个请求,交给排在后面的回答者(网页端) |
| 回答卡 | 一轮回答那张卡(回复在你发的那条消息上),标题从「排队中」到终态 |
| 选项卡 | 若干组选项行 + 底部按钮:会话 / 工作区 / 模型 / 推理深度 / 权限这五张,以及反问卡 |
| 反问卡 | agent 调 ask_user_question 时弹的卡(user-questions/request) |
| 审批卡 | 工具要授权时弹的卡(approval/request) |
| 文字卡 | 只有正文(buildTextCard)或带标题栏(buildHeaderTextCard)的纯提示卡,回执和终态都用它 |
还没做
- 反问里的多选题与全靠打字的题(让给网页端);卡片上的自由输入(只有配对填码、会话重命名填名字这两张卡用)。
- 卡片翻页(超 30KB 就只提示去网页端看)。
更新日志
见 CHANGELOG.md。