dsh-mini-remote 0.1.7 → 0.1.8

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/CHANGELOG.md CHANGED
@@ -2,27 +2,30 @@
2
2
 
3
3
  本文件记录每个版本改了什么。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
- ## [未发布]
6
-
7
- ### 重画的
8
-
9
- - **九张动画(18 帧)全部重画,两帧之间不再有"上半身和下半身割裂"。** 起因是老工艺——一张图里并排画两格,模型两格各画一遍同一个角色,裙子上的图案、袜口条纹、腿和发丝都会各差一点;后来靠"把第二帧手臂以下整段取自第一帧"去补,补丁落在腰上、胳膊上,于是又出现"书举起来和举起前割裂"。这版换了工艺:**先出第一帧,再由第一帧改出第二帧**(只喂第一帧、只描述该动的那一处),两帧出自同一张画;九张的形象统一照同一张母版。裙子以下那一段的帧间差异,除跑、冲刺(按设计动腿)外都在 5% 以下,多数 1% 上下。
10
- - **"跑"那张返工过一次。** 第一版两帧都是"抬起同一条腿"的镜像姿势,外轮廓几乎重合,手机上翻起来像没动(用户报「腿部没有摆动起来」——量出来的下半身帧间差其实有 52.8%,问题在剪影,不在有没有动)。现在一膝抬到大腿近水平、脚提到膝高,另一条腿伸直踩地,第二帧换边。
11
- - **新增两个出图工具。** `tools/whale-draw.py` 直连 OpenRouter 出图、可带参考图——插件自带的改图接口在 OpenRouter 上用不了(它固定打不存在的 `/images/edits`,实测 404),带参考图得走 `/images/generations` 的 `input_references`。`tools/whale-pair.py` 把两张单人格拼成两格并排的洋红底图再交给原来的切图脚本,两帧共用同一缩放,并自检底色是不是纯洋红。
5
+ ## [0.1.8] - 2026-10-02
12
6
 
13
7
  ### 改了的
14
8
 
15
- - **手机上终于能看到上下文用到多少了。** 顶栏那句「上下文 42%」一直是空的:原方案是去问 DSH 的 `contextPressure` 投影,实测这一组合里拿不到——接口通、不报错、投影里就是没有那三个数(第 27 轮就担心过这件事,见 `tasks/todo.md`)。现在改成从事件里量:分母取每次请求前 `request/context` 报的窗口大小,分子取每一步 `assistant/message` 带的用量,而且**把缓存读、缓存写一起加上**——三块计数互不重叠,少加一块量出来会偏小,越长的一轮偏得越多。两个数缺任一个就整段不显示,不编、不放占位符。
9
+ - **手机导航加载提速。** 手机连接后会在空闲时预取工作区,服务端对整理后的工作区列表做 30 秒过期返回与后台刷新;展开工作区时,标题通过 SSE 增量推送,不再反复传整份会话列表。旧 `import-*` 日志走本地轻量标题读取,避免进入 DSH 的旧格式迁移路径;标题读取失败或超时也不会永久卡在「正在读取」。
16
10
 
17
11
  ### 新加的
18
12
 
19
- - **图没显示出来时,手机会说一声。** 正文里写错图片地址(文件不在、或者类型不是图片),原来只画一个破图标,用户只看到"图没了"、不知道原因。现在手机弹一句「这张图手机看不到:那个位置没有这张图,或者它不是 PNG/JPG/WEBP/GIF」,图的位置也画一圈虚线标出来。
13
+ - **多了一条通路:在飞书里直接遥控电脑上的会话。** 在飞书里给机器人发一句话,电脑上当前的那个会话跑一轮,回答回到你那条消息下面——不需要公网地址、不需要域名,也不用装别的东西。
14
+ - 收消息走飞书的长连接;只处理单聊,群里发的这一版不处理。
15
+ - **默认谁都不认**:两个允许名单都空着时不处理任何来源。所以第一次发消息一定会被挡下,插件**直接在飞书里回你一句**,把你这边的编号(open_id,可能还带一个 chat_id)和该填到哪一格写清楚;照着填进「允许的 open_id」保存,再发一次就能用了。
16
+ - 每条回答末尾另起一行写明它出自哪个会话(形如「会话:部署脚本」;取不到名字就写「未命名会话」)。
17
+ - 会话中途停下来问你的时候,飞书里会收到一条普通文字:选择题把选项列成 `1.` `2.`,审批写清要让哪个工具、哪条命令动手。回数字、或回「同意」「拒绝」就算答复;回「取消」=不拍板,这道题交回电脑处理。
18
+ - `/会话` 列出能切的会话并标出当前那个,`/会话 3` 切过去;切的是手机页同一份绑定,切完手机页也跟着变。以 `/` 开头的消息一律当命令,不会被当成对提问的答复。
19
+ - 设置页多了「飞书」这一块(开关、两个凭据、两个允许名单),以及一份**默认收起**的「配置指引(飞书后台六步)」:从建应用、加机器人能力、配权限到发布,每一步点哪里都写在里面。
20
+ - 这一版只做纯文字:没有交互卡片、图片与文件、群聊和流式输出(回答是整段一次回来的),同一时间只跑一轮。
21
+ - **手机上能看见它派出去的子智能体了。** 右上角设置图标左边多了一个入口,点开列出当前会话派出去的子智能体(名字、类型、用掉多少、跑了多久);点某一行能看到它自己的运行过程,还在跑、还能接着用的可以叫停,也可以补一句话让它接着干;一次性的只能看,不许做的动作各给一句说人话的理由。
20
22
 
21
23
  ### 修了的
22
24
 
23
- - 构建指纹不再把 `lib/art/out-*.webp`(给手机看图的展示图,不进版本库)算进去。那批文件一增一删指纹就变,重启时"跑的是不是新代码"这条校验会因此白报一次红——2026-09-28 连着踩了两回。
24
- - **手机上显示图片不再那么挑写法。** 原来正文里只有两种写法能用:完整网址,或者一个裸名字。写全路径(哪怕是 `lib/art/out-demo.webp` 这种本机路径)都只会画一个破图标——2026-09-28 用户为此第三次来问。现在第三种写法也认:电脑上任意一张图的位置(绝对路径,或相对项目根),PNG/JPG/WEBP/GIF 都行,不必先拷进 `lib/art`。这条通道只发图片类型(扩展名是唯一的闸门)、单张不超过 20MB、照旧要 token;地址里没有构建指纹,所以不让浏览器缓存。
25
- - 两处会把「会话睡着了」说成「这个会话已经结束了,请重新选一个会话」的提示改准:撤回排队时说「队列里排着的也跟着没了——重新发一条吧」,停止时说「现在没在跑,不用停」。会话会醒,手机上还挂着它,说"结束了"会把人引到重选会话上去。
25
+ - **手机上找图也认工作目录了。** 正文里写相对路径的图(如 `scratch/a.png`)原来只按插件自己的位置去找,会话开在别的项目里就必然显示不出来;现在也按这段对话的工作目录去找。只放宽「到哪找」:口令、只发图片、单张不超过 20MB、相对写法里带 `..` 一律拒绝这些口径都没变。
26
+ - **手机上指令吐出的长结果不再把页面顶宽。** 执行 `/doctor` 这类会输出长行的指令时,结果原来是「不许压缩、一行放完」,把页面撑得比屏幕宽,右边那截得左右滑才看得见;现在长行自己折行,页面不再横向溢出,短指令的结果照旧。
27
+ - **飞书里答完提问,这一轮剩下的输出不会再莫名消失。** 原来回答要靠「这一轮的第一句话」逐字比对才认得回原消息,你在同一轮里再补一句,第一句被顶掉就认不出处,整轮静默不发,两条原话相同的待答消息还会串台;现在改成记住这一轮从哪条消息起跑就答回哪儿,比对退成兜底,那句「收到」之类的回执也照旧只发一句、不进会话。
28
+ - **一边答过的提问,另一边会自己收起来。** 会话中途的提问(选择题和审批)同时挂在飞书和手机页上时,任意一侧答完,另一侧收起卡片并说明已在别处答过,不用再对着一张作废的卡片发懵;只有真在另一侧答了才这么说,超时或断线不冒充。
26
29
 
27
30
  ## [0.1.7] - 2026-09-29
28
31
 
package/README.en.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  Tool calls, file reads and writes, sub-agent dispatch, and the body of the reasoning trace never reach the phone. This plugin exists for one situation: you're out, and all you want to do is send an instruction and read the result — a phone, one input box, the latest reply.
14
14
 
15
- [What it is](#what-it-is) ・ [When you'd use it](#when-youd-use-it) ・ [Install](#install) ・ [Connecting your phone](#connecting-your-phone-three-routes-pick-one) ・ [What the phone can do](#what-the-phone-can-do) ・ [FAQ](#faq) ・ [Security](#security) ・ [Why I built this](#why-i-built-this-plugin)
15
+ [What it is](#what-it-is) ・ [When you'd use it](#when-youd-use-it) ・ [Install](#install) ・ [Connecting your phone](#connecting-your-phone-three-routes-pick-one) ・ [What the phone can do](#what-the-phone-can-do) ・ [Remote control from Feishu](#remote-control-from-feishu) ・ [FAQ](#faq) ・ [Security](#security) ・ [Why I built this](#why-i-built-this-plugin)
16
16
 
17
17
  ---
18
18
 
@@ -84,6 +84,8 @@ All three connection methods **exist at the same time**. Pick one, or leave seve
84
84
  | **Tailscale** | You need it outside, and you want the address to stay fixed | Tailscale on the computer and on the phone | An app on both sides |
85
85
  | **Public tunnel** | Nothing installed, reachable on any network | Nothing at all | The address changes on every restart; the first run downloads a ~50 MB component |
86
86
 
87
+ There is one more route, and it isn't the phone: **Feishu** (see [Remote control from Feishu](#remote-control-from-feishu)). It never touches the browser, and none of the three routes above need to be on.
88
+
87
89
  Open DSH settings (bottom of the sidebar) → "Phone Remote" in the left column. Every route on that page has a QR code and a link, and the small text under each QR code says **when to use that route**. That's all you need to read.
88
90
 
89
91
  The plugin's own settings (default mode, external notifications, port, password and so on) also have a standard declaration, so writing them in DSH's standard configuration works too. If the same item is set in both places, **the standard configuration wins**. One difference worth knowing: **"default mode" and "external notifications" apply immediately, with no plugin restart**; port, bind address, tunnel, password and history length make the plugin restart and the phone reconnect once — those genuinely need to re-bind or rebuild, and the plugin says so instead of pretending they can be changed live.
@@ -153,6 +155,36 @@ Both are phone photographs (the browser's own chrome is in frame, so they sit sl
153
155
 
154
156
  ---
155
157
 
158
+ ## Remote control from Feishu
159
+
160
+ **Talk to the session on your computer from inside Feishu.** You send a line, the session runs a round, and the answer comes back under your message — with one more line underneath saying which session it came from (`会话:deploy script`, and `未命名会话` when the session has no name yet). Those two labels are Chinese and stay as they are.
161
+
162
+ It runs over Feishu's **long connection**: the plugin dials out to Feishu and keeps the line open, so there is no public address and no domain to arrange. It shares one session binding with the phone page.
163
+
164
+ ### Setup
165
+
166
+ Create an app in the [Feishu open platform](https://open.feishu.cn/) — the "custom app" type, which is also what you would use for yourself alone. Copy the App ID and App Secret from Credentials & Basic Info. **Then add a Bot capability and publish a version** — skip that step and the bot will not show up in Feishu search. Set the event subscription mode to "receive events over a long connection", and add the event `im.message.receive_v1`. Under permissions, search for just `im.message` (typing the rest of the name returns nothing) and tick three: receive messages, send messages as the app, get and send single-chat and group messages — **the count going from 0 to 3 is your proof you got it**. Any change to the capability, the permissions or the events needs another published version. Then back in DSH settings, fill the App ID and App Secret into "Phone Remote" → "Feishu" and turn the switch on.
167
+
168
+ Every menu and the sign of a successful publish are in the "配置指引(飞书后台六步)" guide inside that settings block. It is **collapsed by default**. The paragraph above only tells you how many things there are to do; it does not repeat the guide.
169
+
170
+ ### The first message is refused, and that's expected
171
+
172
+ Both allow-lists start empty, and **with both empty nobody is recognised**. So your first message to the bot does nothing. The plugin **answers you inside Feishu** instead: it hands you your own id (`open_id`, the internal number Feishu knows you by) and tells you which field it goes in. Paste it into "允许的 open_id", save, and send again — that one works.
173
+
174
+ Only **direct messages** are handled (you and the bot, one to one). Group messages are not read in this version.
175
+
176
+ ### What you can do from Feishu
177
+
178
+ **Plain text is you talking to the current session** — the same as sending it from the phone.
179
+
180
+ **When the session stops to ask you something, an ordinary text message arrives in Feishu.** A multiple-choice question lists its options as `1.` `2.`; an approval spells out which tool and which command it wants to run. Reply with a number, or with 「同意」 (agree) or 「拒绝」 (refuse) — `y`, `yes` and `ok` count as agree, `n` and `no` as refuse. Reply 「取消」 to decline to decide: the question goes back to the computer. That word has to be 「取消」; the English "cancel" is not read.
181
+
182
+ **`/会话` lists the sessions you can switch to**, marking the current one 「(当前)」. **`/会话 3`** switches to the third, and the phone page follows — the two sides share one binding. Anything starting with `/` is treated as a command and never as an answer to a pending question. It is the only command here, and it is Chinese; send those characters as they are.
183
+
184
+ **Not there yet.** Cards with buttons; images and files; group chats; streaming — the answer arrives in one piece, after a wait; one round at a time.
185
+
186
+ ---
187
+
156
188
  ## FAQ
157
189
 
158
190
  **The QR code scans but the page won't open.**
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  工具调用、文件读写、子 Agent 调度、思考过程的正文,全都不上手机。这个插件是给「出门在外,只想发指令、等结论」这件事做的——一部手机、一个输入框、最新的一条回答,就这些。
14
14
 
15
- [这是什么](#这是什么) ・ [什么时候会用到它](#什么时候会用到它) ・ [安装](#安装) ・ [连上手机](#连上手机三条路选一条) ・ [手机上能做什么](#手机上能做什么) ・ [常见问题](#常见问题) ・ [安全](#安全) ・ [为什么做这个插件](#为什么做这个插件)
15
+ [这是什么](#这是什么) ・ [什么时候会用到它](#什么时候会用到它) ・ [安装](#安装) ・ [连上手机](#连上手机三条路选一条) ・ [手机上能做什么](#手机上能做什么) ・ [在飞书里遥控](#在飞书里遥控) ・ [常见问题](#常见问题) ・ [安全](#安全) ・ [为什么做这个插件](#为什么做这个插件)
16
16
 
17
17
  ---
18
18
 
@@ -84,6 +84,8 @@ dsh plugin --profile web add <你放源码的目录>
84
84
  | **Tailscale** | 出门也要连,地址还想固定 | 电脑和手机各装一个 Tailscale | 两边都要装一个 App |
85
85
  | **公网隧道** | 什么都不想装,任何网络都要能连 | 什么都不用装 | 地址每次重启都换;第一次要下一个约 50 MB 的组件 |
86
86
 
87
+ 手机之外还有一条路:**飞书**(见[在飞书里遥控](#在飞书里遥控))。它不经过浏览器,上面这三条哪一条都不用开。
88
+
87
89
  打开 DSH 设置(侧边栏最下面)→ 左栏「手机遥控」。这一页上每条路都有一个二维码和一条链接,二维码底下那行小字写着**什么时候用这条**,看它就够了。
88
90
 
89
91
  插件的设置(默认模式、外部推送、端口、口令这些)也有一份标准声明,写在 DSH 的标准配置里同样认得。两处都写了同一项时,**以标准配置为准**。另有一条区别:**「默认模式」和「外部推送」改完立即生效、插件不重启**;端口、绑定地址、隧道、口令、历史条数这几样改完会让你重连一次——它们本来就得重新监听或重建,插件如实声明,不假装能热改。
@@ -153,6 +155,36 @@ dsh plugin --profile web add <你放源码的目录>
153
155
 
154
156
  ---
155
157
 
158
+ ## 在飞书里遥控
159
+
160
+ **在飞书里跟电脑上的会话对话**:你发一句,电脑上那个会话跑一轮,回答回到你那条消息下面,末尾另起一行写明这条回答出自哪个会话(形如「会话:部署脚本」;会话还没起名字就写「未命名会话」)。
161
+
162
+ 走的是飞书的**长连接**——由插件这边主动连到飞书、一直挂着,所以不需要公网地址、也不需要域名。它和手机页共用同一个会话绑定。
163
+
164
+ ### 怎么配
165
+
166
+ 在[飞书开放平台](https://open.feishu.cn/)建一个企业自建应用(自己一个人用也是这个类型),从「凭证与基础信息」里拿到 App ID 和 App Secret。**建完先给它加上「机器人」能力,再发布一次版本**——漏了这步,飞书里搜不到这个机器人。订阅方式选「使用长连接接收事件」,添加事件 `im.message.receive_v1`;权限里只搜 `im.message` 这半段(带上后面的部分搜不出结果),勾三项:接收消息、以应用身份发消息、获取与发送单聊群组消息——**那个数字从 0 变成 3 就是勾对了**。能力、权限、事件改过任何一样,都要重新发布一次版本。最后回 DSH 设置页,在「手机遥控」的「飞书」那一块填上 App ID / App Secret,打开开关。
167
+
168
+ 每一步点哪个菜单、发布成功是什么标志,设置页里那个**默认收起**的「配置指引(飞书后台六步)」写着;上面这段只让你先知道一共要做几件事,不重复它的正文。
169
+
170
+ ### 第一次发消息会被挡下,这是正常的
171
+
172
+ 允许名单默认是空的,**两份都空着的时候谁都不认**,所以第一次给机器人发消息不会生效。插件会**在飞书里回你一句**,把你这边的编号(open_id,飞书认你的那串内部号)交给你,并说明该填到哪一格。照着填进「允许的 open_id」保存,再发一次就能用了。
173
+
174
+ 只处理**单聊**(你和这个机器人私聊);群里的消息这一版不看。
175
+
176
+ ### 在飞书里能做什么
177
+
178
+ **普通文字就是对当前会话说话。** 和手机页上发的是一样的。
179
+
180
+ **会话中途停下来问你的时候,飞书里会收到一条普通文字。** 选择题把选项列成 `1.` `2.`,审批写清要批哪个工具、哪条命令;回数字,或回「同意」「拒绝」,都算答复。回**「取消」**表示不拍板,这道题交回电脑处理。
181
+
182
+ **`/会话` 列出能切的会话**,当前那个标着「(当前)」;**`/会话 3`** 切到第 3 个,切完手机页也跟着变。**以 `/` 开头的一律当命令**,不会当成对提问的答复——飞书里这一版只有 `/会话` 这一条命令。
183
+
184
+ **还没有的。** 带按钮的交互卡片;图片和文件;群聊;流式输出(回答是整段一次回来的,要等一会儿);同一时间只跑一轮。
185
+
186
+ ---
187
+
156
188
  ## 常见问题
157
189
 
158
190
  **扫码之后打不开。**
package/client/client.js CHANGED
@@ -26,6 +26,7 @@ window.__ModuleLoader__.load({
26
26
  var TOKEN_PATH = '/mini-remote/token';
27
27
  var TUNNEL_PATH = '/mini-remote/tunnel';
28
28
  var SERVE_PATH = '/mini-remote/serve';
29
+ var FEISHU_PATH = '/mini-remote/feishu';
29
30
 
30
31
  // 颜色走 DSH 主题变量(真名前缀是 --dsw-alias-,深浅色自动跟随)。
31
32
  // 每个变量都带一个浅色兜底值,变量缺失时界面仍然可读。
@@ -79,6 +80,18 @@ window.__ModuleLoader__.load({
79
80
  var serveErrState = useState(null);
80
81
  var serveError = serveErrState[0];
81
82
  var setServeError = serveErrState[1];
83
+ // 飞书那一块的草稿:服务端那份到了先填进来,之后以用户在这一页改的为准。
84
+ var feishuDraftState = useState(null);
85
+ var feishuDraft = feishuDraftState[0];
86
+ var setFeishuDraft = feishuDraftState[1];
87
+ var feishuErrState = useState(null);
88
+ var feishuError = feishuErrState[0];
89
+ var setFeishuError = feishuErrState[1];
90
+ // 配置指引的展开状态。**默认收起,而且不持久化**:每次进这一页都从头收起,
91
+ // 免得给已经配好的人摊着一屏用不上的字。
92
+ var guideState = useState(false);
93
+ var guideOpen = guideState[0];
94
+ var setGuideOpen = guideState[1];
82
95
 
83
96
  // 同源 GET,不带任何 token:这台电脑自己读得到,别人的电脑读不到。
84
97
  useEffect(function () {
@@ -110,6 +123,22 @@ window.__ModuleLoader__.load({
110
123
  return function () { clearTimeout(timer); };
111
124
  }, [copied]);
112
125
 
126
+ // 服务端那份到了就把飞书的草稿填上。**只填一次**:用户在这一页改过之后,
127
+ // 刷新回来的那份不许把他的编辑抹掉。appSecret 永远是空的——服务端从不把它
128
+ // 送回来(见 saveFeishu 上面那段说明)。
129
+ useEffect(function () {
130
+ if (feishuDraft) return;
131
+ var f = info && info.feishu;
132
+ if (!f) return;
133
+ setFeishuDraft({
134
+ enabled: Boolean(f.enabled),
135
+ appId: typeof f.appId === 'string' ? f.appId : '',
136
+ appSecret: '',
137
+ openIds: typeof f.openIds === 'string' ? f.openIds : '',
138
+ chatIds: typeof f.chatIds === 'string' ? f.chatIds : '',
139
+ });
140
+ }, [info, feishuDraft]);
141
+
113
142
  var copy = function (url) {
114
143
  if (!url) return;
115
144
  var clip = typeof navigator !== 'undefined' ? navigator.clipboard : null;
@@ -237,6 +266,53 @@ window.__ModuleLoader__.load({
237
266
  .then(function () { setBusy(false); });
238
267
  };
239
268
 
269
+ /**
270
+ * 存飞书那一块。
271
+ *
272
+ * **appSecret 那一格留空 = 不改动原来那份**。服务端从来不把它送回来(面板上
273
+ * 天生是空的),要是把「空」解释成「清空凭据」,用户每存一次都得重新贴一遍密码,
274
+ * 贴错一次就是一段查不出原因的鉴权失败。所以只在用户真填了东西时才把它发上去。
275
+ *
276
+ * 存完把那个框清掉:它已经落到服务端了,留在页面上只是多一份暴露面。
277
+ */
278
+ var saveFeishu = function () {
279
+ if (!feishuDraft || busy) return;
280
+ setFeishuError(null);
281
+ setBusy(true);
282
+ var body = {
283
+ enabled: Boolean(feishuDraft.enabled),
284
+ appId: feishuDraft.appId,
285
+ openIds: feishuDraft.openIds,
286
+ chatIds: feishuDraft.chatIds,
287
+ };
288
+ if (feishuDraft.appSecret) body.appSecret = feishuDraft.appSecret;
289
+ fetch(FEISHU_PATH, {
290
+ method: 'POST',
291
+ headers: { 'content-type': 'application/json' },
292
+ body: JSON.stringify(body),
293
+ })
294
+ .then(function (res) {
295
+ return res.json().then(
296
+ function (b) { return { status: res.status, body: b }; },
297
+ function () { return { status: res.status, body: null }; },
298
+ );
299
+ })
300
+ .then(function (r) {
301
+ var b = (r.body && typeof r.body === 'object') ? r.body : null;
302
+ if (!b) {
303
+ setFeishuError('保存飞书配置失败:服务返回了 HTTP ' + r.status + '。');
304
+ return;
305
+ }
306
+ setInfo(b);
307
+ setFeishuError(null);
308
+ setFeishuDraft(function (d) { return (d ? Object.assign({}, d, { appSecret: '' }) : d); });
309
+ })
310
+ .catch(function (err) {
311
+ setFeishuError('保存飞书配置失败:' + ((err && err.message) || String(err)));
312
+ })
313
+ .then(function () { setBusy(false); });
314
+ };
315
+
240
316
  return h('div', { style: styles.card },
241
317
  h('div', { style: styles.title }, '手机遥控'),
242
318
  h('div', { style: styles.muted }, '手机扫码就能用,不用输密码——密码已经编在二维码里了。'),
@@ -258,11 +334,19 @@ window.__ModuleLoader__.load({
258
334
  error: serveError ? serveError.error : null,
259
335
  enableLink: serveError ? serveError.enableLink : null,
260
336
  toggle: toggleServe,
337
+ }, {
338
+ draft: feishuDraft,
339
+ setDraft: setFeishuDraft,
340
+ error: feishuError,
341
+ busy: busy,
342
+ save: saveFeishu,
343
+ guideOpen: guideOpen,
344
+ toggleGuide: function () { setGuideOpen(function (v) { return !v; }); },
261
345
  }),
262
346
  );
263
347
  }
264
348
 
265
- function content(info, failure, copied, copy, busy, toggle, pwd, serve) {
349
+ function content(info, failure, copied, copy, busy, toggle, pwd, serve, feishu) {
266
350
  if (failure) return h('div', { style: styles.error }, failure);
267
351
  if (!info) return h('div', { style: styles.loading }, '正在读取配对信息…');
268
352
 
@@ -290,9 +374,196 @@ window.__ModuleLoader__.load({
290
374
  // tailnet 没开 Serve 的时候,用户需要的正是那条开启链接,而那时候面板上
291
375
  // 其它东西多半也在报错,正好是他最需要指路的时候。
292
376
  if (info.serve || serve.error) blocks.push(serveBlock(info.serve, serve, busy));
377
+ // 飞书那一块:**只要服务端带了 feishu 就出现**(成功、失败、服务还没起来那几条
378
+ // 路径上都带着它)。它是独立的一条通路,手机服务有没有起来都不该拦着用户改它。
379
+ if (info.feishu) blocks.push(feishuBlock(info.feishu, feishu));
293
380
  return h('div', null, blocks);
294
381
  }
295
382
 
383
+ /**
384
+ * 飞书那一块:开关、两个凭据、两个允许名单。
385
+ *
386
+ * 为什么两个名单必须摆在明面上:**都空着的时候谁都不认**(判据在 lib/lark.js 的
387
+ * admitSource)——这是刻意的默认值,但用户不会自己猜到。所以名单空着时当场说清,
388
+ * 并把「去哪儿抄自己的 open_id」写出来:发一条消息给这个机器人,DSH 那边的日志里
389
+ * 会打印出来。飞书后台要绕几层菜单才看得到那个 id,等于给非技术用户加门槛。
390
+ *
391
+ * 按钮**只在有改动时才可点**,反馈靠它自己的禁用态(保存中…),不发「已保存」这种话。
392
+ */
393
+ function feishuBlock(st, fs) {
394
+ var d = fs.draft;
395
+ if (!d) return null;
396
+ var status;
397
+ if (!d.enabled) status = '没开。开了之后,在飞书里给这个机器人发消息,指令会交给电脑上的会话,回答再回到那条消息下面。';
398
+ else if (st.error) status = '开着,但没连上。下面就是它说的话。';
399
+ else if (st.starting) status = '正在建立连接…';
400
+ else if (st.connected) status = '已连着。到飞书里给这个机器人发一条纯文本消息试试。';
401
+ else if (st.running) status = '连接挂上了,还没连上飞书。飞书那边一般要等几秒;一直这样多半是网络或者凭据的问题。';
402
+ else status = '开着,但连接还没挂上(保存一次试试)。';
403
+
404
+ var listed = d.openIds.trim() !== '' || d.chatIds.trim() !== '';
405
+ var changed = Boolean(
406
+ d.enabled !== Boolean(st.enabled)
407
+ || d.appId !== (st.appId || '')
408
+ || d.openIds !== (st.openIds || '')
409
+ || d.chatIds !== (st.chatIds || '')
410
+ || d.appSecret !== ''
411
+ );
412
+
413
+ return h('div', { key: 'feishu', style: styles.block },
414
+ h('div', { style: styles.entryTitle }, '飞书'),
415
+ h('div', { style: styles.muted }, '在飞书里跟这个机器人说话,等于在这里的会话里说话。走长连接,不需要公网地址、也不需要域名。'),
416
+ h('div', { style: styles.warn }, '要先在飞书开放平台建一个企业自建应用,并把它配成「用长连接收消息」。下面那份指引就是这件事的六步;按它点完,以后就不用再进后台了。'),
417
+ h('label', { style: styles.toggleRow },
418
+ h('input', {
419
+ type: 'checkbox',
420
+ checked: Boolean(d.enabled),
421
+ disabled: Boolean(fs.busy),
422
+ onChange: function (e) { fs.setDraft(Object.assign({}, d, { enabled: e.target.checked })); },
423
+ }),
424
+ h('span', null, d.enabled ? '已开启' : '开启'),
425
+ ),
426
+ h('div', { style: st.error ? styles.detail : styles.muted }, status),
427
+ // 上一次保存之后长连接没起来的原因,原文照登。**开关即使被关掉也留着它**——
428
+ // 那正说明用户刚才为什么把它关了,抹掉反而不好排查。
429
+ st.error ? h('div', { style: styles.detail }, st.error) : null,
430
+ // 上一次被白名单挡住的来源。**这一行是新用户能不能自己配通的关键**:
431
+ // 名单默认是空的(谁都不认),而他不可能凭空知道自己的 open_id 长什么样——
432
+ // 发一句、回到这一页刷新、照着抄进名单,再发一次就通了。
433
+ st.rejected ? h('div', { style: styles.warn },
434
+ '上一次被挡住的来源(照抄进下面的名单再保存):' + (st.rejected.reason || '')) : null,
435
+ field('appId', d.appId, 'cli_ 开头的那一串', function (v) {
436
+ fs.setDraft(Object.assign({}, d, { appId: v }));
437
+ }, 'text'),
438
+ // appSecret 打码,而且**永远是空的**:服务端从不把它送回来。留空保存 = 不动原来那份。
439
+ field('appSecret', d.appSecret, st.hasSecret ? '已经存过一份了,留空就不改动它' : 'App Secret', function (v) {
440
+ fs.setDraft(Object.assign({}, d, { appSecret: v }));
441
+ }, 'password'),
442
+ field('允许的 open_id(一行一个,留空 = 不限这一项。两份都留空 = 谁都不认)', d.openIds, '', function (v) {
443
+ fs.setDraft(Object.assign({}, d, { openIds: v }));
444
+ }, 'text'),
445
+ field('允许的 chat_id(一行一个,留空 = 不限这一项)', d.chatIds, '', function (v) {
446
+ fs.setDraft(Object.assign({}, d, { chatIds: v }));
447
+ }, 'text'),
448
+ listed ? null : h('div', { style: styles.warn },
449
+ '两个名单都空着 = 谁都不认。先给这个机器人发一条消息,再回到这一页刷新一下,'
450
+ + '这里就会显示你的 open_id 和 chat_id,照抄进来保存。'),
451
+ h('div', { style: styles.warn }, '只认单聊(你和他私聊),群里发的这一版不处理。'),
452
+ h('div', { style: { marginTop: 12 } },
453
+ h('button', {
454
+ type: 'button',
455
+ disabled: Boolean(fs.busy) || !changed,
456
+ onClick: fs.save,
457
+ style: styles.btn,
458
+ }, fs.busy ? '保存中…' : '保存'),
459
+ ),
460
+ // 失败才留一行,并写清是哪一环出的问题。
461
+ fs.error ? h('div', { style: styles.error }, fs.error) : null,
462
+ // 后台那六步:**默认收起**。配过一次的人不用再看,摊开着只会把要填的几个框挤下去;
463
+ // 第一次配的人被飞书后台绕住时,这里是他唯一不用另开文档的地方。
464
+ h('div', { style: { marginTop: 12 } },
465
+ h('button', { type: 'button', onClick: fs.toggleGuide, style: styles.btn },
466
+ fs.guideOpen ? '收起配置指引' : '配置指引(飞书后台六步)'),
467
+ ),
468
+ fs.guideOpen ? feishuGuide() : null,
469
+ );
470
+ }
471
+
472
+ /**
473
+ * 配置指引的正文。
474
+ *
475
+ * 文案照 `tasks/飞书配置六步.md` 那份原样搬过来,**不在代码里另编一套**:
476
+ * 两边写得不一样的话,用户按界面上说的做完,回头对着文档又会以为自己哪一步做错了。
477
+ * 第 8 步(第一次被挡下、插件在飞书里把 open_id 和 chat_id 回给发消息的人)和上面
478
+ * 「被挡下的来源」那一行说的是同一件事,两处必须对得上。
479
+ *
480
+ * 样式只复用这一页已有的那几种(标题、说明文字、链接),不新增颜色和圆角。
481
+ */
482
+ function feishuGuide() {
483
+ var sections = [
484
+ ['一、建应用并取凭据', [
485
+ '1. 打开飞书开放平台 →「开发者后台」→ 创建企业自建应用,名字随意。',
486
+ '建完先别走:左侧「应用能力」→「添加应用能力」里把机器人加上(漏了这步,飞书里搜不到它)。',
487
+ '2. 在首页「凭证与基础信息」里复制 App ID 和 App Secret——就是要填进上面那一块的那两个值。'
488
+ + 'App Secret 是密码性质的,别外传。',
489
+ ]],
490
+ ['二、告诉飞书「用长连接收消息」', [
491
+ '3. 进「事件与回调」页,订阅方式选「使用长连接接收事件」,不要选要填「请求地址」的那种'
492
+ + '(那种要求电脑能被公网访问)。',
493
+ '4. 同一页点「添加事件」,搜 im.message.receive_v1 加上——意思是「有人给机器人发消息时通知我」。',
494
+ ]],
495
+ ['三、开三项权限', [
496
+ '5. 进「权限管理」→「应用身份权限」(另一个标签「用户身份权限」不用管),搜索框只填 im.message 这半段,'
497
+ + '带上后面的 :receive_as_bot 会搜出 0 条;勾这三项:',
498
+ 'im:message:receive_as_bot —— 接收发给机器人的消息',
499
+ 'im:message:send_as_bot —— 以机器人身份发消息',
500
+ 'im:message —— 回复某条消息',
501
+ '勾完那个数字从 0 变成 3,就是勾对了。',
502
+ 'im.message.receive_v1 是事件,在第 4 步那个列表里配,权限页搜不到它。',
503
+ ]],
504
+ ['四、发布', [
505
+ '6. 进「版本管理与发布」→ 创建版本 → 申请发布(自己用选「仅我可见」)。',
506
+ '凡是改过能力、权限、事件,都要重新发布一次才生效,不发布一切不生效。',
507
+ '「开发者小助手」会推给你一条通知(应用审批通过/已发布成功),收到就是生效了。',
508
+ '企业里配了审批就得等管理员通过;自己一个人用通常自己就能批。',
509
+ ]],
510
+ ['五、在插件这边收尾', [
511
+ '7. 打开上面那一块,开关打开,App ID / App Secret 填进去保存。',
512
+ 'App Secret 那一格保存后会自动清空,这是故意的(密钥不回显):灰字变成「已经存过一份了,'
513
+ + '留空就不改动它」就是存上了,以后改别的设置留空保存 = 不动原来那份。',
514
+ '8. 在飞书里搜到这个机器人,给它发一句话:第一次一定被挡下(两份名单都留空时谁都不认);'
515
+ + '插件会直接在飞书里回你一句,把你的编号告诉你:open_id=ou_xxxxx(可能还带个 chat_id=oc_xxxxx),'
516
+ + '并说明该填到哪一格。照着它回的那句填就行,不用再回电脑上看。',
517
+ '9. 把那个编号填进「允许的 open_id」那一格(chat_id 可以留空),保存后再发一次就成了。',
518
+ ]],
519
+ ['六、用起来之后', [
520
+ '回答是整段一次回来的,会等一会儿。',
521
+ '回答落进手机页当前绑定的那个会话;回答末尾另起一行「会话:<标题>」写的就是它,'
522
+ + '取不到名字时写「未命名会话」。',
523
+ '一轮跑完才有回复;机器人同时只处理一轮。',
524
+ ]],
525
+ ['注意事项', [
526
+ '不要把机器人拉进群:这版只处理单聊,进群后群里任何人发的消息都可能驱动你这台电脑;'
527
+ + '群里被挡时不会回那句话(只对单聊回)。',
528
+ 'App Secret 在飞书后台重置过的话,插件这边要重新填一次。',
529
+ '飞书要求「三秒内响应」,我们收到就记账、随后再答,所以偶尔会看到飞书重发同一条'
530
+ + '——插件按消息编号去重,不会重复进会话。',
531
+ '手机上那套(浏览器遥控)不受影响,两条路可以同时用。',
532
+ ]],
533
+ ];
534
+ var out = [h('a', {
535
+ key: 'guide-open',
536
+ href: 'https://open.feishu.cn/',
537
+ target: '_blank',
538
+ rel: 'noreferrer',
539
+ style: styles.link,
540
+ }, '打开飞书开放平台')];
541
+ for (var i = 0; i < sections.length; i += 1) {
542
+ out.push(h('div', { key: sections[i][0], style: styles.entryTitle }, sections[i][0]));
543
+ var lines = sections[i][1];
544
+ for (var j = 0; j < lines.length; j += 1) {
545
+ out.push(h('div', { key: sections[i][0] + j, style: styles.muted }, lines[j]));
546
+ }
547
+ }
548
+ return h('div', { key: 'guide' }, out);
549
+ }
550
+
551
+ /** 飞书那一块的一个输入行。标签在上、输入框在下,和这一页其它地方一致。 */
552
+ function field(label, value, placeholder, onChange, type) {
553
+ return h('div', { key: label, style: { marginTop: 10 } },
554
+ h('div', { style: styles.muted }, label),
555
+ h('input', {
556
+ type: type,
557
+ value: value,
558
+ placeholder: placeholder,
559
+ spellCheck: false,
560
+ autoComplete: 'off',
561
+ onChange: function (e) { onChange(e.target.value); },
562
+ style: Object.assign({}, styles.input, { marginTop: 4 }),
563
+ }),
564
+ );
565
+ }
566
+
296
567
  /**
297
568
  * Tailscale 的 HTTPS 地址这一块。
298
569
  *
package/lib/events.js CHANGED
@@ -125,13 +125,13 @@ export function stepSelfTalk(content) {
125
125
  * 推空。宁可什么都不显示,也不能把旁白冒充成回答。
126
126
  */
127
127
  export function createTurnTracker() {
128
- /** @type {Map<string, { turn: number|null, lastText: string, userText: string, interrupted: boolean }>} */
128
+ /** @type {Map<string, { turn: number|null, lastText: string, userText: string, userMessageId: string, interrupted: boolean }>} */
129
129
  const bySession = new Map()
130
130
 
131
131
  function slot(sessionId) {
132
132
  let s = bySession.get(sessionId)
133
133
  if (!s) {
134
- s = { turn: null, lastText: '', userText: '', interrupted: false }
134
+ s = { turn: null, lastText: '', userText: '', userMessageId: '', interrupted: false }
135
135
  bySession.set(sessionId, s)
136
136
  }
137
137
  return s
@@ -140,7 +140,7 @@ export function createTurnTracker() {
140
140
  return {
141
141
  /**
142
142
  * 喂入一条会话事件。
143
- * @returns {{kind:'user', text:string}|{kind:'reply', text:string, reason:string}|null}
143
+ * @returns {{kind:'user', text:string}|{kind:'notice', text:string, summary:string, senderSessionId:string}|{kind:'reply', text:string, reason:string, userText:string, userMessageId:string, interrupted:boolean}|null}
144
144
  * 需要推送到手机时返回动作,否则返回 null。
145
145
  */
146
146
  feed(sessionId, event) {
@@ -155,12 +155,36 @@ export function createTurnTracker() {
155
155
  return null
156
156
 
157
157
  case 'user/message': {
158
+ // 子智能体跑完,DSH 往父会话里追加的一条**通知**(`source.kind === 'subagent-settled'`)。
159
+ //
160
+ // 它挂在 user/message 上,但**不是人说的话**——是「你派出去的子智能体干完了,
161
+ // 这是它最后那句话」。原来被下面那句 `kind !== 'user'` 一起挡掉了,于是电脑上
162
+ // 那张可展开的卡片在手机上从来没有出现过——**不是画成了普通气泡,是压根没进来**。
163
+ //
164
+ // 单独给一种 kind,让它在聊天记录里占「单独一件东西」的位置:套上用户气泡就
165
+ // 变成「我说过这句话」,那是假的;混进回答又会被读成模型的话。
166
+ //
167
+ // 注意这里**不碰** s.userText:它不是这一轮的「发源地」,后面 parent 若被它
168
+ // 唤起并作答,那一轮的回答照旧走 assistant/message → turn/end 那条路。
169
+ if (event.data?.source?.kind === 'subagent-settled') {
170
+ const notice = visibleText(event.data.content)
171
+ if (!notice) return null
172
+ return {
173
+ kind: 'notice',
174
+ text: notice,
175
+ summary: typeof event.data.source.summary === 'string' ? event.data.source.summary : '',
176
+ senderSessionId: String(event.data.source.senderSessionId ?? ''),
177
+ }
178
+ }
158
179
  // 只认人发的;agent.inject() 的合成上下文(文件变更通知、skill 内容等)
159
180
  // 的 source.kind 是 'plugin',必须排除,否则手机里会冒出莫名其妙的消息。
160
181
  if (event.data?.source?.kind !== 'user') return null
161
182
  const text = visibleText(event.data.content)
162
183
  if (!text) return null
163
184
  s.userText = text
185
+ // 这句话是哪条消息:调用方**按这个号认领这一轮的发源地**,不再只靠原文比对
186
+ // (见 lib/lark.js 的 takeTarget / markRoundStarted)。原文会被同轮后来的一句人话顶掉,号不会。
187
+ s.userMessageId = event.data?.id ?? ''
164
188
  // 带上 id:手机自己发出去的指令,注入后也会以这条事件回来,
165
189
  // 调用方要靠 id 把它认出来、不要记第二笔(见 index.js 的 injectedIds)。
166
190
  return { kind: 'user', text, id: event.data?.id }
@@ -195,13 +219,15 @@ export function createTurnTracker() {
195
219
  const reason = event.data?.reason?.kind ?? 'completed'
196
220
  const text = s.lastText
197
221
  const userText = s.userText
222
+ const userMessageId = s.userMessageId
198
223
  const interrupted = s.interrupted
199
224
  s.lastText = ''
200
225
  s.userText = ''
226
+ s.userMessageId = ''
201
227
  s.interrupted = false
202
228
  // 被取消/出错的轮次,以及卡在工具里没跑出回答的轮次,都没有什么可推的。
203
229
  if (!text) return null
204
- return { kind: 'reply', text, reason, userText, interrupted }
230
+ return { kind: 'reply', text, reason, userText, userMessageId, interrupted }
205
231
  }
206
232
 
207
233
  default:
package/lib/history.js CHANGED
@@ -17,9 +17,10 @@ import { foldCommandEvent } from './commands.js'
17
17
 
18
18
  /**
19
19
  * @param {Array<{type:string, time?:number, data?:any}>} events DSH 的原始事件,**按发生顺序**。
20
- * @returns {Array<{role:'user'|'assistant'|'command', text?:string|null, timestamp:number|null,
20
+ * @returns {Array<{role:'user'|'assistant'|'command'|'notice', text?:string|null, timestamp:number|null,
21
21
  * id?:string|null, reason?:string, interrupted?:boolean,
22
- * commandId?:string, name?:string, args?:string, kind?:string}>}
22
+ * commandId?:string, name?:string, args?:string, kind?:string,
23
+ * summary?:string, senderSessionId?:string}>}
23
24
  */
24
25
  export function replayHistory(events) {
25
26
  const tracker = createTurnTracker()
@@ -34,6 +35,18 @@ export function replayHistory(events) {
34
35
  const action = tracker.feed('replay', event)
35
36
  if (!action) continue
36
37
  const timestamp = typeof event?.time === 'number' ? event.time : null
38
+ // 子智能体完工的通知:它既不是用户说的话,也不是模型的回答,是**第三样东西**
39
+ // (和指令行同类)。单独一个 role,界面上按一张可展开的卡片画。
40
+ if (action.kind === 'notice') {
41
+ out.push({
42
+ role: 'notice',
43
+ text: action.text,
44
+ summary: action.summary,
45
+ senderSessionId: action.senderSessionId,
46
+ timestamp,
47
+ })
48
+ continue
49
+ }
37
50
  if (action.kind === 'user') {
38
51
  out.push({ role: 'user', text: action.text, id: action.id ?? null, timestamp })
39
52
  } else {