DeepSeek Harness Plugin Hub

发布与管理完整 Harness Profiles,发现适合你的插件。

探索

插件目录环境预设文档中心动态

社区

发布插件联系我们报告问题

相关链接

Plugin Hub GitHubDeepSeek Harness 官方项目系统状态隐私说明
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

独立、非官方社区项目,与 DeepSeek 官方无隶属、授权或背书关系。

Plugin Mobile Bridge — DeepSeek Harness 插件(DSH Plugin)
DeepSeek Harness Plugin Hub
ProfilesPlugins分类动态文档登录管理 Profiles
ProfilesPlugins分类动态文档登录
← Plugins
P

dsh-plugin-mobile-bridge

Plugin Mobile Bridge

仅限局域网的 DeepSeek Harness 移动端界面:从手机导入照片,并通过实时反馈驱动 Session。

插件会安装到这里;不确定时保持 web。

npx -y @deepseek-ai/dsh plugin --profile web add github:jxboop/dsh-plugin-mobile-bridge#4cfff95f9647a9c433d71d18ead68a91cba5987d
README兼容性版本

兼容性与来源证明

Plugin Mobile Bridge 以 dsh-plugin-mobile-bridge 发布,当前版本为 4.16.0。Plugin Hub 会校验它的 manifest,并保存精确安装来源,便于复现安装结果。

DSH 兼容范围
*
运行环境
any
发布来源
github
Registry 更新时间
2026/10/16

版本

4.16.0stable
2026/10/16
4.14.1stable
2026/10/16
4.14.0stable
2026/10/16
查看其余 53 个版本收起版本
4.13.0stable
2026/10/16
4.12.0stable
2026/10/15
4.10.8stable
2026/10/15
4.10.4stable
2026/10/15
4.9.13stable
2026/10/15
4.9.11stable
2026/10/15
4.9.2stable
2026/10/15
4.9.0stable
2026/10/15
4.6.1stable
2026/10/15
4.5.2stable
2026/10/14
4.5.1stable
2026/10/14
4.4.0stable
2026/10/14
4.3.0stable
2026/10/14
4.2.1stable
2026/10/14
4.2.0stable
2026/10/14
4.0.0stable
2026/10/13
3.9.0stable
2026/10/13
3.8.0stable
2026/10/13

相关插件

正在加载相关插件…

最新版
4.16.0
DSH
*
HMR
重启进程
Tree shaking
未声明可安全裁剪
解包体积
未提供
文件数
未提供
Surface
any
许可证
MIT
发布源
github
GitHub
★ 0
周下载
0
最近提交
2026/10/16
查看源码 ↗
README Badge

点击下方 Badge 复制 Markdown,粘贴到 README 即可。

这是你的 Plugin?认领权益 · 优先安全扫描

验证 package.json 声明的 GitHub 仓库,即可管理这个公开页面。认领后,Hub 会优先安排当前版本的安全扫描,并在通过后公开展示结果。

认领这个 Plugin →
报告问题
3.4.3stable
2026/10/13
3.4.1stable
2026/10/13
3.4.0stable
2026/10/12
3.3.2stable
2026/10/12
3.3.1stable
2026/10/12
3.2.0stable
2026/10/12
3.1.0stable
2026/10/12
2.10.1stable
2026/10/12
2.8.0stable
2026/10/12
2.7.3stable
2026/10/12
2.7.0stable
2026/10/11
2.5.1stable
2026/10/11
2.4.2stable
2026/10/11
2.3.1stable
2026/10/11
2.0.0stable
2026/10/9
2.0.2stable
2026/10/9
1.8.1stable
2026/10/9
1.6.0stable
2026/10/9
1.5.0stable
2026/10/9
1.3.16stable
2026/10/9
1.3.12stable
2026/10/9
1.3.10stable
2026/10/8
1.3.9stable
2026/10/8
1.3.5stable
2026/10/8
1.3.2stable
2026/10/8
1.3.1stable
2026/10/8
0.9.12stable
2026/10/8
0.9.5stable
2026/10/8
0.9.4stable
2026/10/7
0.6.4stable
2026/10/7
0.5.0stable
2026/10/4
0.3.3stable
2026/10/4
0.3.2stable
2026/10/3
0.3.1stable
2026/10/3
0.2.0stable
2026/10/3

相关插件

继续浏览 integrations-communication 分类下经过校验的插件。

Im@xmanrui/dsh-im将微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord 和 Matrix 接入 DSH。Free Searchdsh-free-searchDeepSeek Harness 的免费网页搜索:24 个引擎(Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel 无需密钥;通过 MCP 使用 Zhihu 全局/站内搜索,以及 Perplexity/SerpBase/DeepSeek/You.com/Baidu/Kimi/Aliyun/Doubao,需密钥;OpenAI/Gemini/ClaudeDafeiyudsh-dafeiyu由 DeepSeek Harness 会话事件驱动的原生桌面端 BigFish companion。Wechat Plugindsh-wechat-pluginDeepSeek Harness 的微信渠道:锁定电脑后离开,通过手机驱动代理。无需 OpenClaw,也无需公网 IP。

README

dsh-plugin-mobile-bridge

给 DeepSeek Harness 用的手机端桥接插件: 在手机上导入图片、下发任务、实时看输出,从同一个 WiFi、USB 共享、或者外网隧道都行。

在电脑上跑 DSH,在手机上用。不用装 App,打开浏览器就是控制台。


它能做什么

  • 手机直接发图(截图、拍的照片)给 DSH,不用再经过微信/QQ 中转;动图、HEIC/BMP 等格式、视频也能发(图片先在手机上压到长边 1600,大文件分片上传)
  • 「文件」键:任意文件都能传(PDF / 文档 / 压缩包 / 代码 / 音频…),不限制类型 —— 原样传到电脑,agent 拿到的是路径(模型看不了这些格式,但 agent 能用工具读它、解压、转码、跑脚本)。一次最多 3 个(宿主的上限;手机上会拦住第 4 个并说清原因,不会让它悄悄消失)
  • 电脑给的素材在手机上直接看、直接存(agent 交付的「交付物」也会列出来:图片给缩略图):对话里出现图片/视频路径(脚本截的图、image_generate 出的图、导出的视频…)会自动变成缩略图,点开全屏,长按就能"存储到照片";附件也走真 URL(data: 在 iOS 上长按没有"存储到照片")
  • 自定义背景(手机上自己换):点工具栏「背景」→ 选一张图片 / 动图(GIF)/ 视频 → 传到电脑、当场铺满整个手机界面,不用碰电脑也不用重载插件;「去掉背景」一键还原。4.7.0 起面板里还多一个自带的 🌀 赛博朋克:纯 CSS 画的(霓虹地平线 + 透视网格 + 扫描线),不用传图、不占流量、任何分辨率都清晰;4.8.0 起它默认就在动,4.8.1 把动态感做足(六层:光晕漂 / 网格快速流 / 斜落的霓虹雨 / 地平线呼吸 / 扫描线 / 偶发故障闪,而且压暗层让开、气泡更透 —— 隔着气泡就看得见在动),配色跟着换成霓虹青 + 品红,面板上还有 🐢 让它静下来 的刹车(会把所有层一起停;系统开了"减弱动态效果"也会自动静) —— 它和图片背景二选一(设一个会摘掉另一个)。整套界面是毛玻璃风格(对话气泡透明、面板半透明 + blur,冷色冰蓝调子),跟你挑的背景同一个调子
  • 新建任务、下发指令、取消运行,实时看思考过程和工具调用
  • 它跑着的时候能插话:运行中右边的按钮变成「插话」,说的话排进队列、这一轮结束就接着处理,不用干等它跑完才敢打字(桌面端的 steer = 打断当前回合,手机上默认不用它:宁可等一轮,也不要把你正在跑的事打断)
  • 发出去的话立刻看得见、还能撤回:排队中的消息先在对话里显示成一条虚线气泡(带「排队中」状态),点「撤回」真的把它从队列里拿掉(DSH updateQueue 的 remove);已经开始处理的会如实告诉你撤不回来,绝不假装成功
  • 第一次连上会讲一遍怎么用(点「开始使用」收起);顶栏有「说明」键随时能再看。这篇说明按设备记"看过没有"(不是按 IP)—— 所以换一台手机、同学连进来都会各讲一遍,不会被人"代表"掉
  • 按会话回看历史;每条消息气泡外侧带一个看得见的「复制」(点一下复制这条的正文 —— agent 的回复只复制正文、不带思考过程;长按气泡也能复制,但那个入口藏得深,没人会去猜)
  • 会话能置顶、能标常用(2.5.0 新增):点顶部那一行打开列表 —— 📌 置顶(钉到最上面,重启手机还在)、⭐ 常用(自己打星)、以及手机按"你用得多的"自动排的前几个;点行切换、点 📌/⭐ 只改标记。这三份数据只存在你这台手机里,不碰电脑上的会话数据
  • 顶端就写着当前模型、列表能删会话(2.6.0 新增):最上面一行是当前模型(读宿主的真值,电脑端换过这里也跟着变,点开就能换);会话列表默认不摆 agent 子会话和自检会话(想看在底部勾「显示全部会话」),每行有 🗑 删除 —— 要输 PIN 才删得掉,正在跑的会话得再确认一次,删掉的记录在电脑端可以取消归档找回来
  • 余额查询(配合余额插件的同一份凭据)
  • 地址自动排序:USB 共享 / 蓝牙 PAN / 个人热点 / 局域网,哪个能通就把哪个排前面
  • 外网可达:推荐 Tailscale Funnel(一条固定的 https://<机器名>.<tailnet>.ts.net/ 地址、开机自恢复、手机不用装任何 App);也附带 Cloudflare 快速隧道脚本做临时方案
  • 手机上直接生图(1.0 新增):「生图」键 → 写一句英文提示词 → 它不是直连生图后端,而是把这条指令交给当前会话里的 image_generate 工具去出图 —— 图因此落在会话里,能像别的素材一样在对话中看、点开、长按存相册。第一次点会把那句话先放进输入框让你过目,之后照发。⚠️ 这需要电脑上装了 image_generate 工具(单独的插件,不在本仓库里),否则 agent 只会回一句"没有这个工具 / 没有生图模型" —— 那不是手机桥坏了,见下面《手机上直接生图》一节。
  • 手机上看 / 改权限档位(1.0 新增):权限面板 + 状态角标。读不需要额外证明;收紧到只读也不拦(往安全方向走不设障);但放开档位必须重新输 PIN —— 免得"捡到一台已解锁手机的人"直接把整台机器放开。3.5.0 起,新建任务那张纸里也能先选一档(建好的那一刻就按它跑;选「跟随默认」=什么都不改、不额外要 PIN)
  • 任何 App 的「分享」都能进电脑(3.6.0 新增):用 iOS 快捷指令做一个"分享表"动作 POST 到桥的一条短链,之后 Safari / 相册 / 备忘录里点「分享」→ 选它,内容就进了电脑上最近那个会话。页面上一个键都没加;默认关 + 每次带 PIN(见下面《分享到桥》一节)
  • 分享键还能给「网页版」(4.2.0):一份单文件、自带排版的 .html 安装页(GET /api/install?format=html ✓),同学直接浏览器打开就能读 ✓、不需要你电脑在线 ✓;手机上不能直接分享文件时会如实说并退到下载 ✓
  • 📋 那张纸上还多了两样(3.8.0,同样没加新键):「最近放进去的」 —— 你放进电脑剪贴板的文字在手机本地留最近 20 条,点一条只放进输入框(不替你按发送);⚠️ 从电脑读回来的不进历史(那可能是密码)。🔤 取字 —— 截一张电脑屏幕交给电脑上的 agent 认字、存成一份可搜索的笔记(桥自己不认字)
  • ⏰ 定时开机(4.13.0 新增):在「📊 状态盘」上写一个时刻,到点在睡眠里把机器叫醒 ✓。⚠️ 两条路必须分开说:睡眠 (S3) 系统还活着 → 计划任务的「唤醒定时器」能叫醒它 ✓;完全关机 (S5) 软件做不到 ✗(那时系统没在运行,任何软件都不存在 ✓)—— 那只能去主板 BIOS 里设「RTC 定时开机」✓,而"BIOS 里设没设"Windows 查不到 ✗,所以插件不摆那个键 ✗。默认关,跟 powerControl 那个开关走(见下面《定时开机》一节)
  • 🔌 蓝牙能「断开某一台」(4.14.0 新增):设备列表里已连接的那几行右边多一个「断开」✓。⚠️ 两条实话:断没断以电脑读回来的连接状态为准 ✗(实测音频/遥控两种服务返回"成功"却没断 ✓,真正断掉耳机的是免提那一种 ✓);有的设备就是断不掉 ✗(本机实测 iPhone 试了 14 种服务都不掉)→ 那就照实说"没断成" ✓ 并给两条替代 ✓。不会取消配对 ✓(只断连接);"连接"仍然只能去系统设置里点 ✗(Windows 没有可靠接口,实测 rc=87 ✓)

版本变更记录见 CHANGELOG.md。


想分享给同学?照这三步走

  1. 同学先得有 DSH(这个插件是扩展,不是独立程序;Node 22+)。

  2. 一条命令装插件,然后重启 DSH:

    dsh plugin --profile web add github:jxboop/dsh-plugin-mobile-bridge
    

    重启后日志里会打印他自己那台电脑的手机网址:

    [mobile-bridge] listening on 0.0.0.0:3081, PIN 123456
    [mobile-bridge] phone url: http://192.168.1.20:3081/ab12cd34ef56ab78/  (WLAN)
    
  3. 手机浏览器打开那条网址,输 PIN 就能用。桌面 DSH 左下角的徽标上也写着完整网址 + PIN。

两点务必说清楚:

  • PIN 和网址里的随机段是每台电脑各自生成的(~/.dsh/mobile-bridge.json)——别把自己的网址/PIN 发给别人,那等于把你电脑的后门给对方。
  • 同一个 WiFi(宿舍/家里)一般直接可用;校园网常常禁止客户端互访,那就按下面「外网访问」走 Tailscale Funnel —— 地址固定、手机不用装 App,也还是只认那台电脑自己的 PIN。

装成「App」放在手机桌面上(推荐)

不需要 App Store、不需要越狱、不用装任何第三方东西 —— 用手机浏览器自带的 「添加到主屏幕」,就是一个带图标、全屏、能离线打开的 App:

iPhone(Safari):打开手机桥的网址 → 底部「分享」按钮 → 添加到主屏幕 → 添加。 Android(Chrome):打开网址 → 右上角菜单 → 安装应用 / 添加到主屏幕。

装完从桌面图标点开:没有地址栏、没有浏览器按钮,启动有加载进度条,界面是毛玻璃那套; 后台断网也能打开外壳(页面走 network-first,永远拿最新的)。

图标是 tools/make-icons.mjs 生成的真 PNG(iOS 不认 SVG 图标),改了图标设计重跑一次即可:

node tools/make-icons.mjs

说明:原生 App 做不了(iOS 打包签名要 Mac + 开发者账号 + 上架审核), 而 PWA 在体验上已经等价:独立图标、全屏、启动图、离线外壳、无浏览器界面。

安装

前置条件:你得先有一个能正常运行的 DSH —— 这是个扩展插件,不是独立程序。需要 Node 22 以上(用的都是内置模块,没有构建步骤)。

1. 装插件

两个会直接卡住你的坑(同学那边实测踩到,都写在这里):

  1. 这条命令需要 Git。 pnpm 对 github: / git+https: 一律走 git 解析,不会自动改走 tarball;没装 Git 就是 ERR_PNPM_GIT_RESOLVE_FAILED。装一次 Git for Windows 再重跑;不想装 Git 就用下面的 tar.gz 地址。
  2. 必须是 1.8.2 及以上。 1.8.0 / 1.8.1 的包里缺 lib/addresses.js(files 漏列), 装上去插件一加载就报找不到模块 —— 症状是手机页面打不开、一直转圈。 判断办法:看 <profile>\node_modules\dsh-plugin-mobile-bridge\lib 里有没有 addresses.js。
# 从 GitHub 安装(`link:` 只接受本地路径,网址要用 git+https 或 github: 简写)
dsh plugin --profile web add git+https://github.com/jxboop/dsh-plugin-mobile-bridge.git

# 等价写法
dsh plugin --profile web add github:jxboop/dsh-plugin-mobile-bridge

# 没有 Git?用 codeload 的 tar.gz 地址(这条路不需要 git)
dsh plugin --profile web add "https://codeload.github.com/jxboop/dsh-plugin-mobile-bridge/tar.gz/refs/heads/main"

# 从本地目录(改代码时用)
dsh plugin --profile web add link:/path/to/dsh-plugin-mobile-bridge

tar.gz 地址不会自己更新到最新提交:它的版本串是"分支"而不是提交号,add 时 pnpm 会跳过已装的那一份。要更新得先 remove 再 add:

dsh plugin --profile web remove dsh-plugin-mobile-bridge
dsh plugin --profile web add "https://codeload.github.com/jxboop/dsh-plugin-mobile-bridge/tar.gz/refs/heads/main"

(用 github: 那条则可以直接重跑 add 更新 —— pnpm 会重新解析到最新提交。)

装完重启 DSH,日志里会出现:

[mobile-bridge] listening on 0.0.0.0:3081, PIN 123456
[mobile-bridge] phone url: http://192.168.1.20:3081/ab12cd34ef56ab78/  (WLAN)

更新到最新版

把上面那条安装命令原样再跑一遍就行,不需要先卸载:

dsh plugin --profile web add github:jxboop/dsh-plugin-mobile-bridge

然后重启 DSH。(dsh plugin 是 pnpm 的转发器;对分支形式的 git 依赖,add 会重新解析到最新提交。实测 0.2.2 → 0.3.0 可直接升级。)想确认装到了哪一版:看 <profile>/node_modules/dsh-plugin-mobile-bridge/package.json 的 version。

版本号从 0.9.4 起才是准的。0.7.0~0.9.3 那一段只在提交标题里写了版本,package.json 一直停在 0.6.7 —— 所以那几版看不出新旧,照"重跑一遍安装命令 + 重启"来判断即可。

版本怎么排(2026-10-09 起):加功能 = 进一位小数(1.3.17 之后的下一个功能是 1.4.0, 再下一个 1.5.0…);修 bug / 只改文案 = 补丁位(1.4.1、1.4.2…)。一次提交里两者都有时按功能算。 每个版本都带 package.json 版本、CHANGELOG.md 一段、git tag v<版本> 和 Release。

2. 首次启动会自动生成配置

~/.dsh/mobile-bridge.json(Windows:%USERPROFILE%\.dsh\mobile-bridge.json):

{
  "version": 1,
  "port": 3081,
  "pin": "123456",
  "pathSecret": "ab12cd34ef56ab78",
  "tokenTtlHours": 12,
  "bindTokenToIp": false,
  "elevationMinutes": 60
}

pin 和 pathSecret 都是自动生成的,别外传。 在 DSH 界面左下角的徽标上能直接看到完整网址和 PIN。

字段默认说明
port3081监听端口
pin随机 6 位登录密码
pathSecret随机 16 位十六进制所有路由都藏在这个随机段后面,没有它一律 404
tokenTtlHours12登录令牌有效期(上限 14 天)
bindTokenToIpfalse是否严格绑定令牌的来源 IP。默认 false:地址变化只记录告警(在 /api/bootstrap 的 foreignUses 里可见),不吊销登录。设 true 则换地址即吊销 —— 移动网络下不可用(运营商 NAT、IPv6 隐私扩展会不停换地址,手机会被反复踢下线),只适合固定网络
elevationMinutes60输一次 PIN 后,多久内可以"指挥 DSH 干活"
crashProbefalse排查用,平时别开。 设为 true 会把宿主的 uncaughtException / exit 事件写进 ~/.dsh/dsh-crash.log(含每 30 秒一条内存心跳)。它跑在宿主进程里、会注册全局处理器并持续写盘,所以默认关闭。
answerOnPhonefalse让手机回答 agent 的提问(ask_user_question)和工具审批。默认关:开着时"正在看这个会话的手机"会抢在桌面前面拿到提问,桌面要等 120 秒才轮到。没有手机在看这个会话时,无论开关如何都是桌面先拿到。
pcControl关手机上控制电脑(4.5.0 / 4.5.1):静音 / 音量 / 亮度 / 开-关蓝牙 / 看电量与联网 / 看蓝牙设备 / 断开某一台蓝牙设备(4.14.0)。默认关,改完立刻生效、不用重启。开着时"让电脑动手"的动作仍要 PIN 提权。⚠️ 亮度只能调笔记本内置屏(台式机 / 外接屏会如实说调不了);蓝牙设备列表走系统原生接口(约 0.8 秒、带连接状态);「连接某台设备」底层接口实测一律被拒(rc=87),所以列表里点一台就是打开系统蓝牙设置;「断开」以回读到的连接状态为准(详见下面《蓝牙:能「断开」,连不回来》一节)。
powerControl关手机上让这台机器动手:关机 / 重启 / 解锁,以及 4.13.0 起的「定时开机」(到点在睡眠里唤醒 ✓)。默认关,改完立刻生效、不用重启;每次动作仍要 PIN 提权。⚠️ 完全关机后开机软件做不到 ✗(详见下面《定时开机》一节)。

为什么过一会儿又让我输 PIN?

这是设计如此,不是掉线。手机上有两种状态:

  • 已登录(有 cookie):能看会话列表和聊天记录。令牌默认 12 小时有效,所以重启电脑、重启 DSH 都不会把你踢出去。
  • 已提权(最近输过一次 PIN):才能让这台电脑干活 —— 下发任务、取消、新建会话。

提权默认保持一个小时(elevationMinutes —— 2026-10-14 用户要求:从 15 分钟改成 60 分钟),过期后第一次动手会重新弹出 PIN 门;宿主重启后恢复的会话同样不带提权。觉得还不够就把它调大,上限 240 分钟:

{ "elevationMinutes": 240 }

如果你看到"需要重新输入 PIN"却找不到输入框,那是 0.2.0 及更早版本的 bug(页面只认 401、不认服务器用来表达提权的 403 + needPin,于是提示了却没有门)。0.2.1 起已修复:过期时页面会直接把 PIN 门打开。

3. 放行防火墙(Windows)

首次启动会弹 Windows 防火墙提示。**只勾"专用网络"**即可(手机和电脑在同一 WiFi 时够用)。若要走外网隧道,勾"公用网络"。


外网访问(可选)

局域网之外也能连。三条路,按这个顺序试:手机也装 Tailscale(最稳)→ Tailscale Funnel(手机不用装东西)→ Cloudflare 快速隧道(临时)。

方案 A:手机也装 Tailscale(最稳,推荐先试)

电脑和手机都装 Tailscale、用同一个账号登录,然后手机浏览器打开:

http://<电脑的 tailnet IP>:3081/<pathSecret>/

电脑的 tailnet IP 怎么拿:托盘图标点开就能看到(形如 100.122.188.30),或者命令行 tailscale ip -4。 <pathSecret> 是网址密钥段 —— DSH 窗口的日志里、或桌面 DSH 左下角徽标上都有完整网址。

为什么它最稳:流量走 tailnet(设备间端到端加密),完全不经过 Funnel 的公网入口。 2026-10-08 实测过一次:Funnel 的公网入口有两个 IP,其中一个偶发回不到机器 (外部 6 个国家的节点复测,一半报 Broken pipe)—— 手机就会间歇性"网络不通", 而同一时刻 tailnet 直连是好的。

代价:手机要多装一个 App(iPhone 需要非中国区 Apple ID),并且 Tailscale 要在后台常驻。

方案 B:Tailscale Funnel(地址永不变、开机自恢复、手机不用装东西)

为什么推荐:Cloudflare 免费隧道的域名每次重启都变,旧的永久失效;Funnel 给的是一条 https://<机器名>.<tailnet>.ts.net/ 的固定公网地址 —— Tailscale 服务随开机自启、配置存在 daemon 状态里,重启电脑后什么都不用做,网址照旧。而且它是标准 HTTPS,手机上不用装任何客户端(这点对 iPhone 尤其重要:Tailscale 的 iOS app 没有上架中国区 App Store)。

# 1. 装 Tailscale 并登录:https://tailscale.com/download

# 2. 首次开 Funnel 需要去控制台同意一次(只此一次),命令会把链接打出来:
tailscale funnel 3081
#    Funnel is not enabled on your tailnet.
#    To enable, visit: https://login.tailscale.com/f/funnel?node=xxxxx

# 3. 同意之后再执行一次,就常驻后台了:
tailscale funnel --bg 3081
#    Available on the internet:
#    https://<机器名>.<tailnet>.ts.net/
#    |-- proxy http://127.0.0.1:3081

# 4. 手机访问(结尾必须带密钥段):
#    https://<机器名>.<tailnet>.ts.net/<pathSecret>/

不用时关掉:tailscale funnel --https=443 off

⚠️ 选了一条路就别同时跑 tools/tunnel-start.ps1 —— 它会把配置里的 publicUrl 改回那个每次都变的 cloudflared 域名。

安全上如实说:Funnel 和免费隧道一样是公网地址,保护来自随机密钥段 + PIN 这两层;区别只是域名固定、不会更容易被猜到。要"只有自己的设备能进",就用方案 A(设备之间端到端加密、不经公网入口)。

Funnel 不稳时怎么办:今天实测到的症状是"手机说网络不通",但电脑这边一切正常(tools/tunnel-status.ps1 也是 OK)。 先 tailscale down 再 tailscale up(不需要管理员)常常能救回来;实在不行重启电脑,或直接用方案 A。

方案 C:Cloudflare 免费快速隧道(临时用)

tools/ 下有一套脚本:不需要公网 IP、不需要改路由器、不需要注册账号。代价是域名每次重启都变,旧的永久失效 —— 只适合临时开一下。

# 1. 下载 cloudflared.exe 放到 tools/ 目录
#    https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe

# 2. 启动隧道(会打印完整网址并复制到剪贴板)
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-start.ps1

# 3. 不用时关掉
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-stop.ps1

其它工具:

脚本用途
tools/tunnel-status.ps1体检:隧道现在是死是活(进程、端口、外网实测),手机上出现错误页时先跑它
tools/show-phone-url.ps1一个可复制的小窗口,显示外网/局域网网址 + PIN(带刷新按钮)
tools/revoke-phone.ps1应急:一键吊销所有手机登录(两步确认,防误触)

在手机上回答 agent 的提问

agent 卡在 ask_user_question 上等你选,或者某个工具调用需要你点头时,手机上会直接出现一张卡片:选项按钮、可多选、也能自己写一段话;审批则是「允许一次 / 拒绝」两个键。答完 agent 立刻继续,不用你跑回电脑前面。

默认关闭,打开方式:

{ "answerOnPhone": true }

打开后改配置需重启 DSH(或重载插件)生效。

它怎么决定谁来答:

情况谁作答
有手机正在看着这个会话手机(桌面等 120 秒才轮到,防手机没答导致卡死)
没有手机在看这个会话桌面,和我们没插手时一模一样
手机答了,别的手机也开着先答的算数,其余卡片自动撤掉
提问发生时手机不在场(在别的会话 / 页面被系统挂起 / 正在重连)桌面先答;手机上事后会出现一条能点的横幅,切过去还能补答(4.14.1)

4.14.1「回来还能看见、还能答」(用户 2026-10-16 报「我看不见你的问题手机端」✓):

  • 上面那张表里第四条就是当时那两次的情形 —— 卡片只在"提问发生那一刻有手机盯着这个会话"时才推给手机, 手机不在场就一个字都到不了 ✗(而桌面照常,这是设计 ✓,不是坏了 ✗);
  • 现在宿主会把超时之后仍然收得住的提问(userQuestions.continued)也告诉手机(GET /api/waiting): 手机显示「另一条会话在等你回答」✓,点进去直接看到那张卡 ✓ 并且答得出去 ✓(走宿主自己的 callId ✓, 不是桥上那个 120 秒就失效的临时 id ✗);页面从后台回到前台时也会主动补问一次 ✓;
  • ⚠️ 仍然没做:提问的那一瞬间手机不在场,卡片不会"立刻"出现在手机上 ✗ —— 它只能事后看到并补答。 要"立刻"就得让桥在没人在看时也把提问扣给手机,代价是桌面那条会晚 120 秒 ✗,所以没做。
  • "读不到"和"没有"是两件事 ✓:宿主没有那两个服务时,服务端回 available:false ✓、页面上写「读不到」✗ —— 绝不说成"没有提问" ✗。
  • 出了这种事不再只能靠猜 ✓:桥会把每一次「为什么没交给手机」记进日志 ✓ (例:ask(question) -> 桌面:现在没有手机在看 session-xxx(此刻有人在看:session-yyy) ✓), 以及手机开始看 / 不看哪个会话 ✓。

几个已经处理好的细节:

  • 答案要提权:和下发任务一样,光有 cookie 不够,得最近输过 PIN —— 否则偷走 cookie 的人能替你在批准框上点"允许"。
  • 同一个提问不能答两次:用过的 id 立刻失效。
  • 手机刷新不会丢卡片:新开的流会把还没答的提问补推一次。
  • 问题文本来自模型,一律当纯文本渲染(textContent,不是 innerHTML),模型塞不进脚本。
  • 贴到别的页签也看得见:卡片挂在主区域外面,切到「余额」不会漏掉提问。
  • 提问作废了就撤卡:超时/已在别处回答后,卡片自己消失(而不是点一次报一次错)。
  • 你正开着面板时也看得见(2.5.1):卡片一到会自动收起当前打开的工具面板(会话列表 / 录音 / 生图 / 背景 / 权限 / 删除纸),振动一下并提示「agent 要你点头」—— 面板只是收起,里面写好的内容不会丢;卡片的层级也不再低于任何全屏面板。 (这条是用户报「审批没弹出来」查出来的真因:卡片本来是流内元素,被那些 z-index:50 的全屏纸整个盖住, 而宿主只等 120 秒就退回桌面 —— 桌面上两分钟后才冒出来,看起来就像"手机上没提示"。)

已知限制:加载页和断线整屏(那两屏比卡片高)时不会显示卡片 —— 那属于"还没连上/断开了", 连上以后卡片自然回来。

断了会告诉你,不用你自己发现

手机和电脑之间的连接有三种坏法,每一种都会在页面顶部弹出一条说明,而不是像以前那样只把一个 9 像素的圆点变红:

情况页面会说什么
连接被切断(隧道没了 / 网络断了)「和电脑断开了」+ 可能的原因
连接还在、但对面不再说话(电脑睡眠最典型)「好像和电脑失去联系了」+ 最后收到消息是多久之前
提问过期、或已在别处回答卡片自己撤掉,并说明原因

而且它会给出两条能点的出路:

  1. 改用局域网地址 —— 同一个 WiFi 下,这条链路完全不依赖隧道。地址由宿主在 bootstrap 里给(含密钥段的完整 URL)。注意换地址等于换站点,cookie 带不过去,要重输一次 PIN。
  2. 重试连接 —— 不换地址,直接重连。

桌面那一侧也不再装死:插件每 120 秒从外网探一次自己的隧道,连续两次探不通就把徽标上的外网地址标红成「⚠️ 隧道已断,手机连不上」,并往 ~/.dsh/mobile-bridge.log 写一行。恢复时也会写一行。

手机上是 Cloudflare 错误页?(Error 1033 / 1016)

这两个错都不是插件的问题,而是隧道没了。

错误含义
1033 Cloudflare Tunnel error这个主机名曾经是一条隧道,但现在没有任何隧道连着它
1016 Origin DNS error这个主机名已经完全解析不到了(隧道早就没了)

最常见的两个原因:

  1. 免费快速隧道的网址,每次 cloudflared 重启都会换一个。 旧网址永久失效,救不回来 —— 不是"过一会儿就好",是那个域名再也不属于你了。手机上收藏的旧网址永远打不开。
  2. 跑隧道的电脑睡眠 / 休眠 / 合盖了,或者换了网络、被安全软件或加速器掐掉了连接 —— 隧道随之中断。

怎么办:

# 先体检:一眼看出是进程没了、端口没起、还是网址失效
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-status.ps1

# 网址失效就重拿一个(先停再起,确保拿到全新的)
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-stop.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File tools\tunnel-start.ps1

tunnel-start.ps1 会真的从外网打开一次再告诉你成功了 —— 如果它说"没能通过外网验证",那就是现在把网址给手机也没用,直接照它列的原因排查,别浪费时间。

想要一个永不变的网址,只有两条路:Cloudflare 命名隧道(要有自己的域名 + 账号),或者用 Tailscale / ZeroTier 这类组网工具(推荐:地址固定、端到端加密、且完全不暴露到公网)。

⚠️ 免费快速隧道的网址每次重启都会变,而且是一条公开网址 —— 拿到的人都能看到 PIN 登录页。手机桥有 PIN 认证和限速兜着,但别把网址发到群里。


安全模型

这个桥可以经隧道暴露到公网,所以"安全"必须是能验证的,不是一句承诺。

已实施的防护

防护说明
随机路径前缀所有路由藏在 /<pathSecret>/ 后面。无密钥路径一律 404
限速的钥匙不可伪造只在 socket 对端是 loopback(即自家 cloudflared)时才读转发头;cf-connecting-ip 优先,x-forwarded-for 取最后一段(自家代理追加的),绝不取第一段(那是调用方可控的)
两级登录限速每地址 12 次/10 分钟 + 所有人合计 60 次/10 分钟
令牌绑定来源 IP偷走的 cookie 换一台机器就是废纸
写操作需要再验 PINcookie 只能读;下发指令/取消/新建会话需要一小时内输过一次 PIN
Cookie Secure 条件化HTTPS 下带 Secure,明文 HTTP 下不带(否则局域网登录会静默失效)
令牌 12 小时过期上限 14 天,/api/logout 立即吊销
SSE 连接上限MAX_LIVE_STREAMS = 6,超限 503
网络层超时与连接上限headersTimeout 20s(挡 slowloris)、requestTimeout 180s、maxConnections 64
统一安全响应头nosniff、X-Frame-Options: DENY、cache-control: no-store、Referrer-Policy: no-referrer(防止密钥路径经 Referer 泄漏)
不回显内部错误细节只进日志,不回给调用方
探测留痕但密钥不落盘无密钥请求记为 GET <no-secret> <- ip;日志里永远不出现密钥路径
登录审计成功/失败的每一次登录都落一行(~/.dsh/mobile-bridge.logins.jsonl,超 256KB 时成功的那几行优先留 —— 否则约 1300 次错 PIN 就能把真记录挤光、面板假报"没有陌生设备")。失败尝试不算"已知设备" —— 否则攻击者先试错一次就把自己洗白;"新地址第一次成功登录"会在桌面角标上报警,并写明"走外网时这是隧道那端的地址"
看审计要现输 PINGET /api/logins 走 x-dsh-pin 头(绝不进 URL):这份账里有这台电脑的网络史,和"看剪贴板 / 截图"同一级。current("这台手机")按令牌判,不按 IP(IP 会被 warn-only 路径改写)
踢设备要现输 PIN手机端「踢掉其它设备」= 收回权限,仍需证明是本人(和"删除会话"同级);默认保留当前这台。桌面那个 /revoke 照旧(loopback),密钥泄漏也踢不掉你

怎么验证(请自己跑)

node test/security.mjs

51 项断言:限速钥匙的单元断言、密钥路径门禁、每个接口的未授权拒绝、限速生效、cookie 标志、令牌有效期、注销吊销、响应头、SSE 上限、附件不泄漏、日志脱敏。退出码非 0 即有失败。

其中只读的那半(密钥门禁、未授权拒绝、响应头、日志脱敏)打的是你正在用的那台桥;破坏性的那一半(连猜 15 次 PIN、真登录拿 cookie、打满 SSE 上限)会在临时目录里另起一台一次性实例,跑完即拆 —— 因为登录限速有一份全局额度,是和手机共用的,在真身上打就是拿手机当靶子。早期版本正是这么把用户锁在门外的。

要连真实 Cloudflare 边缘一起验(会消耗本机出口 IP 的登录额度,10 分钟后自愈):

$env:BRIDGE_TUNNEL_URL='https://<你的隧道域名>'
node test/security.mjs

其它测试。先装一次测试依赖(jsdom,只有 UI 测试用它;插件本身零运行时依赖):

npm install
文件内容需要
test/crashprobe.mjs崩溃探针开关:默认不碰宿主全局状态、开启后能在配置重写中存活(17 项)—
test/harness.mjs桥的端到端行为:PIN 门、cookie、prompt 组装、SSE 扇出、取消、附件、新建会话、余额缓存、重启保活(含"恢复的会话可读但不可写 = 403+needPin")、注销、桌面角标注入与 start() 不抛错、普通请求取对话 /api/transcript(巨型历史裁剪 / 一堆短字符串拼成的 stream 也要丢 / 缺 sessionId 400 / 客户端半路掐断后桥还活着)、子会话的错误帧带 unsupported 且从列表里消失,以及手机作答全链路(提问推到流上 / POST api/answer 收答案 / 防重放 / 审批 outcome 校验 / 取消不惊动桌面 / 刷新补推)(492 项)—
test/ui.mjs手机页面的 DOM 与交互:含"没有请求跑出密钥段"、"提权过期时必须把 PIN 门打开"、运行中同时给出「插话」与「停止」并确认插话已收下、换会话后「在跑」状态跟着重置、首次说明页(自动弹出 / 讲清四件事 / 点了就记住 / 真有提问时让位)、子会话不许谎报断线、也不再重连、迟到的旧会话回包不许贴到新会话上、localStorage 写不进去(无痕模式)也不许把页面搞死、Ctrl+Enter 连按两下只发一次、提问与审批卡片(渲染、作答编码、防 XSS、重推保草稿、单选与自定义互斥、作废撤卡、跨页签可见),以及断线提示条(弹条、说清原因、给局域网兜底、心跳到达后收起)(685 项)jsdom
test/addresses.mjs地址排序与分类(16 项)—
test/shot.mjs真浏览器截图 + 布局/滚动体检(scroll: 那行会报能不能滚到顶底、滚动中位重取后位置还在不在、有没有块在估算高度)Windows + Edge
test/switch-bench.mjs真浏览器量"切会话":切到没打开过的会话多久有内容、切回来是不是那一刻就有、这次切换下载了多少字节Windows + Edge
test/reopen-bench.mjs真浏览器量"从别的窗口点回来":把网络延迟拉到 2 秒再重载,报"对话出现"与"界面显示"两个时刻Windows + Edge
test/scroll-live.mjs真浏览器复现"回弹":开一个临时会话让它真的流式输出一千多行,一边往上滚一边采样 15 秒(回弹次数 / scrollHeight 变动 / 最长掉帧)Windows + Edge
test/phone-diag.mjs真浏览器诊断:抓页面异常、控制台、网络状态码Windows + Edge
test/boot-probe.mjs加载页进度条探针:网络节流下每 250ms 采样一次,回答"百分比会不会冻住"(这类 bug 本机复现不了,只有慢网络才看得见)Windows + Edge
node test/package.mjs && node test/harness.mjs && node test/qr.mjs && node test/ui.mjs
node test/security.mjs && node test/crashprobe.mjs && node test/addresses.mjs && node test/offline.mjs
node test/platform.mjs

九个套件共 1764 项(package 163 · harness 612 · qr 12 · ui 846 · security 54 · crashprobe 17 · addresses 16 · offline 17 · platform 27)。另有 test/voice.mjs 33 条语音草稿 —— 语音那一版 按用户要求先跳过,文件留在仓库里、能单独跑,但不并进这项门槛(tools/check-counts.mjs 只管上面九个)。 改完必跑全套 ✓ —— 只跑某一个会漏掉内容规矩与界面回归(2.7.2 就是这么漏了两版的 ✓)。

桌宠:小鲸(4.15.0 起)

手机界面上浮着一只 小鲸 / DeepSeek酱(用户自己的角色设定图 ✓):

  • 按住就拖走 ✓;右下角 ⤡ 拖一下改大小 ✓(两只手指捏也行 ✓);
  • 点一下有反应 ✓(随机换一只 + 说一句 + 弹一下 ✓);长按出小工具条(换立绘 / 大小 / 藏起来 ✓);
  • 表情长在她自己脸上 ✓(4.15.1:换的是整只立绘 ✓ —— 早先那个"头顶一个圆形脸框"已删干净 ✗, 连 CSS 与注释都不留 ✓,test/package.mjs 钉着「petFace 不许再出现」✓);
  • 会自己动 ✓:空闲时每 7 秒在 moods.idleCycle(中性 → 吃饭 → 开心 ✓)里整只轮换 ✓; 有事情就换对应的 —— 发成功 → 开心 ✓、报错 → 难受 ✓、在跑 → 中性 + 「在跑了」✓;
  • ⚠️ 代价如实 ✗:那 4 张立绘是分开生成的 ✓(衣服小细节彼此有差异 ✓),而且没有逐帧动画 ✗ (不眨眼 ✓)—— 要"完全一致的多表情"得手画一套帧 ✓,还没做 ✓;
  • 位置与大小存这台手机 ✓(百分比 + 倍率 ✓ —— 横竖屏切换不会跑到屏幕外 ✗);
  • 藏起来之后有路回来 ✓:「更多」→ 🐳 桌宠(点一下藏 / 再点一下放出来 ✓)。

素材怎么放(⚠️ 用户自己的东西只放本机 ✗,不进插件、不进 Release ✓ —— 和「夜行星」同一条规矩 ✓):

~/.dsh/mobile-bridge-pets/<名字>/
  pet.json          ← 清单:body / faces / faceLabels / palette / moods / lines
  body.png          ← 透明底立绘(插件不提供 ✗)
  faces/*.png       ← 表情(圆形脸用 ✓)
  • 配置里可以点名 { "petPreset": "<名字>" } ✓;不配就取目录里第一只有清单的 ✓;
  • 没有素材也不会空着 ✓:插件自带一只代码画的(内联 SVG ✓),所以别人装了也能看见一只 ✓;
  • 服务端两条只读接口:GET /api/pet ✓(清单 ✓)与 GET /api/pet/asset?name=… ✓(素材 ✓)—— 都要登录 ✓,而且取素材走白名单 + 逐段洗 + 落目录内三重校验 ✓(文件名只来自 pet.json ✓, 不拿 URL 拼路径 ✗)。
  • ⚠️ 电脑端(Harness Web UI)那只还没上 ✗ —— 下一版补 ✓。

蓝牙:能「断开」,连不回来(4.14.0 起)

用户 2026-10-16 原话:「还有蓝牙功能的断开设备连接,现在只有连接设备」✓ —— 这一版补上了「断开」那一半 ✓。

动作能不能做说明
看设备列表 + 真实连接状态✅系统原生接口(约 0.8 秒)
断开某一台(4.14.0)✅ 能(部分设备)见下面两条 ⚠️
连上某一台❌ 不能Windows 没有可靠接口(BluetoothSetServiceState(ENABLE) 实测一律 rc=87);面板里点一台 = 打开系统蓝牙设置 ✓
取消配对(忘记设备)❌ 故意不做那是 BluetoothRemoveDevice:要重配一遍,代价太大;本插件不动配对关系 ✓

⚠️ 两条必须知道的实话(都是 2026-10-16 在真机上验出来的 ✓):

  1. "断没断"以电脑读回来的连接状态为准 ✗ ✗ —— 不是"命令返回成功" ✓。 实测:耳机的 A2DP / AVRCP 两种服务关下去,系统返回成功(rc=0)却没断 ✗; 真正让它断开的是免提(HFP)那一种 ✓。所以插件是逐个服务关、每关一次回读状态 ✓,断了才说断了 ✓。
  2. 有的设备就是断不掉 ✗ —— 同一台机器上的 iPhone 试了 14 种服务都不掉 ✓ (其中 4 种还回 rc=0 ✗)。这种情况面板会照实说"没能断开" ✗,并给两条真能用的替代 ✓: 点「打开蓝牙设置」在电脑上手动关 ✓;或者「蓝牙关掉再打开」—— ⚠️ 后者会把所有连接一起断掉(耳机、手机热点都会断 ✓),代价写在面板上 ✓。

断开成功之后那一行会变成"未连接" ✓(那一步是重新读一次设备列表得到的 ✓ —— 不是页面自己改的 ✗);要连回来仍然只能去系统蓝牙设置里点一下 ✓。

定时开机(4.13.0 起):睡眠能醒,关机只能靠 BIOS

用户 2026-10-16 原话:「既然有关机那么可以做一个对应的定时开机功能」✓。

这个名字最容易让人误会,所以先把两句话放前面(手机面板上写的也是这两句):

机器当时的状态到点能不能自己开
睡眠 (S3)✅ 能 —— 系统还活着,计划任务带「唤醒定时器」(WakeToRun)就有权把它叫醒
休眠 (S4)✅ 能(这台机器上休眠没启用 —— 面板会照实说,这是另一件事)
完全关机 (S5)❌ 不能 —— 系统没在运行,任何软件都不存在

⇒ 完全关机之后到点开机,只能靠主板 BIOS/UEFI 的 RTC 定时开机(各家叫法不同:RTC Alarm Power On / Resume by RTC Alarm / Wake on RTC / Power On By RTC),设好之后不需要任何软件。 而"BIOS 里设没设",Windows 这边没有任何接口能读到 ✗ —— 面板会明说查不到,不给假安心; 任何人拿这个动作来问(接口是公开的),服务端也会如实拒绝并把"为什么做不到"说出来 ✓。 这也是插件不摆那个键的原因 ✗(摆一个按了没反应的键 = 骗人)。

怎么用(在「📊 状态盘」上,紧挨着「电脑端一键动作」):

  1. 配置里要有 "powerControl": true(见上表 —— 定时开机和"关机 / 重启"共用一个开关:都是"让这台机器自己动";默认关,改完立刻生效);
  2. 写一个时刻:07:30(今天已经过了就定明天)/ 10-17 07:30(今年过了就定明年)/ 2026-10-17 07:30(必须是将来,过去的一律拒绝)→ 点「设它」;
  3. 面板会当场读回来给你看:那个任务的时刻、有没有「唤醒」标志、以及这台机器的唤醒定时器策略 (交流 / 电池两档)。不是「启用」时,面板会把改成启用的那条命令摆出来(要管理员 —— 插件不偷偷提权 ✗);电池那档是「禁用」时,还会提醒你插着电才稳。

只响一次 ✓(同一时刻只留一个 ✓,再设一个就换掉它);「取消这个定时唤醒」删完会回读确认才说删掉了 ✓。 powercfg /waketimers("系统里还排着哪些唤醒定时器")要管理员才能读 —— 读不到时面板说读不到 ✗, 不当成"没有" ✗(这两件事完全不一样)。

分享到桥:任何 App → 分享 → 电脑会话(3.6.0 起)

在手机上看见一段话、一个链接、一张图,想直接丢给电脑上的会话 —— 不用切到桥的界面,**在原来的 App 里点「分享」**就行。

为什么做成"快捷指令 + 一条短链":手机上这个网页已经很大了(10.8k 行), 再加按钮就要跟"面板重渲染"那套脾气搏斗(3.5.0 为了一个「解锁」按钮折腾了五轮 ✗)。 短链那条路完全不碰页面 —— 更稳,也更快。

怎么开(默认关 ✓)

  1. 在这台电脑的 ~/.dsh/mobile-bridge.json 里加一行:

    { "shareIntake": true }
    

    (改完立刻生效,不用重启 ✓;不想要了删掉/改回 false 就立刻关 ✓。)

  2. 手机上打开桥 → 工具键那排的 🩺 一键自检 → 找到**「分享到桥」那一行 → 抄下它给出的短链** (那条链里带着这段密钥,别发给别人 ✓)。

  3. 在 iPhone 的快捷指令里新建一个快捷指令:

    • 打开「在分享表单中显示」;

    • 「接收」选 文本、网址(想分享图片再加 图像);

    • 加一个「获取 URL 内容」:方法 POST,URL 填第 2 步抄来的短链, 请求体选 JSON,内容形如:

      { "pin": "你的六位 PIN", "text": "分享进来的文本" }
      

      (有网址就再加 "url": "分享进来的网址";有图片可先「Base64 编码」再放进 "images": [{"mediaType": "image/png", "data": "…"}]。)

    • 保存 ✓ —— 之后在任何 App 点「分享」,列表里就有它。

它到底做了什么(以及没做什么)

  • 它把内容当成你发的一条消息送进电脑上最近那个会话(跳过空会话 / agent 子会话 / 自检会话); 消息最前面会带一句【分享到桥】。也可以带 "sessionId": "…" 指定会话 —— 指定的会话找不到就如实报 404, 不会偷偷换个会话发。
  • 每次都要带 PIN:它等于替你指挥这台电脑(和你在手机上点「发送」同级),而短链没有登录 cookie, 所以只能要"随这次请求的 PIN"。错一次就记一笔账(审计留一行、桌面角标会显示有人正在试); 同一 IP 10 分钟内错满 12 次才会被挡 10 分钟(和登录那条路同一个闸)。
  • 关着的时候(默认)→ 403 + 一句"怎么开";一个字都不会进会话。
  • 收不下的会明说:文本超 8000 字只取前 8000(内容里与回包里都写明)、一次最多 3 张图 (多出来的张数也会说明)—— 不静默丢。
  • 分享文件(PDF / 压缩包)这一版没做:大文件要走分片上传,在快捷指令里拼分片不划算 —— 用手机上的「文件」键传,那条路是现成的。

接口形状(想自己写脚本也一样):

curl -X POST "https://<你的地址>/<密钥段>/api/intake" \
  -H 'content-type: application/json' \
  -d '{"pin":"935881","text":"看到的这段话","url":"https://example.com/a"}'
# → {"ok":true,"sessionId":"…","title":"…","requestId":"…","accepted":true,"images":0}

一键 OCR / 扫码(3.7.0 起)

同一条短链,多带一个白名单里的 task 就行 —— 桥会把一句固定指令附在内容最前面:

# 拍张照 → 让它认字 → 整理成一份可搜索的 Markdown 笔记
curl -X POST "https://<你的地址>/<密钥段>/api/intake" -H 'content-type: application/json' \
  -d '{"pin":"935881","task":"ocr","images":[{"mediaType":"image/png","data":"<base64>"}]}'

# 拍二维码 / 条形码 / 书号 → 解出来 + 查一下它是什么
curl -X POST "https://<你的地址>/<密钥段>/api/intake" -H 'content-type: application/json' \
  -d '{"pin":"935881","task":"scan","images":[{"mediaType":"image/png","data":"<base64>"}]}'
  • task 是白名单(ocr / scan,GET /api/intake 会列出来):task 这个字段不接受自由指令 —— 这条链虽然是凭证(密钥段 + PIN),但它形状是公开的,让调用方自己写指令等于白送一个"远程指挥 agent"的入口。 名单外的值(含空值与非字符串)→ 400 + 列出能用哪些(不静默忽略)。 ⚠️ 说清楚:text 与图片名仍然是调用方自己的内容(它们本来就是"分享进来的东西"), 桥不改写它们、也不拿它们当系统指令用。
  • 认出来的字落在会话里 —— 所以挂了会话检索(宿主 dsh-session-query)的部署上, 手机上现成的「🔍 搜一搜」(搜这台电脑上的会话内容、不上云)直接就能搜到 ✓; 没挂的话搜一搜会如实说搜不了(那是宿主的原话,不是桥坏了)—— 文字照样在会话与那份 Markdown 笔记里, 在电脑上搜也一样。
  • 桥自己不做识别、也不做解码:它只把图和指令交给电脑上的 agent(它本来就会看图); 扫码那条指令里也写着"桥不做解码",让 agent 自己找工具或写一小段脚本。零新依赖。
  • 内容顺序是固定的:【分享到桥】 → 任务指令 → 你的 text → 图片。收不下的(文本超 8000 字 / 图超 3 张) 照旧明说(truncated / dropped)。

开放接口:把事件投给你自己的程序(3.4.0 起)

想让别的程序"知道电脑上发生了什么"(跑完了一轮、出错了、会话有新活动), 用 webhook 把事件 POST 到你自己写的地址上就行。

怎么开(默认关 ✓)

在电脑上的 ~/.dsh/mobile-bridge.json 里加三行(配置是现读的 ✓ —— 改完不用重启 ✓; 重启 / 开机之后也会自动按配置订上 ✓ —— 复查第 83 轮修掉了"重启后订阅会掉、还得手工再调一次"那个洞 ✗):

{
  "webhookUrl": "https://你的服务器/hook",
  "webhookSecret": "一串你自己的随机字(用来签名)",
  "webhookEvents": ["api-session/status", "api-session/error", "api-session/activity"]
}
  • webhookEvents 不写就默认订上面这三个 ✓;
  • 改完想立刻生效:调一次 POST /api/webhook/subscribe ✓(登录即可 ✓ —— 这个动作不改任何配置, 只是按现配置重新订阅 ✓;重启 / 开机本来就会自动订 ✓,它是"不想重启"时的入口 ✓);
  • 想先确认"发不发得出去":POST /api/webhook/test(要现输 PIN ✓)会真发一条测试投递 ✓。

你会收到什么

每条事件一个 POST,头里带三样东西 ✓:

头意思
x-dsh-event事件名(如 api-session/status)
x-dsh-timestamp毫秒时间戳
x-dsh-signaturesha256=<hex> —— 用 webhookSecret 对正文做 HMAC-SHA256(与 GitHub 的 X-Hub-Signature-256 同形)

正文只有元信息 ✓(这是有意的 ✓ —— 会话内容太敏感 ✗):

{"event":"api-session/status","at":1791800198081,"sessionId":"…","running":true}

三个事件分别是什么

事件名什么时候发正文里的字段
api-session/status某个会话开始跑 / 跑完了(宿主级状态,比手机上的"这一轮"更准)sessionId、running
api-session/error某个 agent 出错了(且不在某轮的可记录位置上)sessionId、message(宿主的错误原文,截断到 300 字 —— 可能夹带路径等上下文 ✓)
api-session/activity有一条用户消息推进了会话的活动时间sessionId、updatedAt(活动发生的时间 ✓)

at 一律是投递时刻 ✓(复查第 83 轮 R13:以前 activity 的发生时间被映射成 at ✗, 于是对方拿到的是"我们发出去的时间"、还看不出来 ✗ —— 现在发生时间单独叫 updatedAt ✓)。

一个最小接收端(Node,验签名)

import { createServer } from 'node:http'
import { createHmac } from 'node:crypto'

const SECRET = '你在配置里写的那串'
createServer((req, res) => {
  let body = ''
  req.on('data', (c) => { body += c })
  req.on('end', () => {
    const expected = 'sha256=' + createHmac('sha256', SECRET).update(body, 'utf8').digest('hex')
    if (req.headers['x-dsh-signature'] !== expected) { res.statusCode = 401; return res.end('bad signature') }
    console.log(req.headers['x-dsh-event'], JSON.parse(body))
    // 回 200 + 一句正文最省事 ✓;回 **204**(成功且没有正文)也完全可以 ✓ ——
    // 桥对 204 **不会**告警 ✓(复查第 83 轮 R9:以前会把规范意义上的成功记成"正文为空、说不准"✗)。
    res.statusCode = 200
    res.end('ok')
  })
}).listen(8787, () => console.log('listening'))

安全边界(都按项目的铁律 ✓)

  • 默认关 ✓:没配 webhookUrl 就一条都不订(审计日志里会明确写 「没配 webhookUrl —— 默认关 ✓,一个都没订 ✗」✓;订阅接口也会回同一句真话 ✓, 不会回"已订阅 ✓"那种假成功 ✗ —— 复查第 83 轮 R3);
  • 出站地址白名单 ✓:只允许 https:// 或本机 http://127.0.0.1|localhost; 拒 file:/ftp:/data:、拒内网段(10./172.16-31./192.168.)、拒云元数据地址(169.254.169.254)、 拒带用户名/密码的 URL(https://user:pass@… ✓ —— fetch 本来也不会用它 ✓,复查 R7);
  • ⚠️ 白名单是"字面串"判断 ✓ —— 挡不住「解析后才内指」的域名(*.nip.io、localtest.me ✓)。 要挡必须在投递时用 dns.lookup 校验全部结果 ✓,这一条还没做 ✗(套件里有一条哨兵断言 把"它现在仍被放行"钉着 ✓ —— 将来做了校验,那条会变红提醒改成"必须拒" ✓)。 同理:POST /api/webhook/test 拿 PIN 可以往任意 https 主机发一次固定正文的 POST 并拿到状态码 ✓, 在缺口没补之前,它相当于一个受 PIN 保护的 SSRF 探针 ✓("能拿到 PIN = 已完全信任"是当前威胁模型 ✓, 但这不等于零风险 ✓ —— 如实写在这儿 ✓);
  • 签名 ✓:对方能验真,伪造不了;
  • 投递失败会写审计日志 ✓(~/.dsh/mobile-bridge.log),不静默丢; 「2xx 但正文为空」这种"说不准收没收到"也会记一行 ✓(204 例外 ✓ 那是规范意义上的成功 ✓);
  • POST /api/webhook/test 与"读剪贴板/截屏"同级:要现输 PIN ✓。

还没做的(如实 ✗)

  • 手机侧还没有配置入口 ✗(现在只能改电脑上的文件 + 调一次订阅接口);
  • 只订了 emit 类事件 ✗ —— user-questions/request("agent 要你点头")与 approval/request 是 waterfall 类 ✓,订阅者必须调 next() 并给出决定 ✓;乱订会干扰宿主自己的流程 ✗,所以这两个 要按 waterfall 的规矩单独写并单独测;
  • 投递不重试 ✗(一次不成功只记一行 ✓)—— 要重试得有退避与上限,那是后面的事;
  • 正文不带会话内容 ✗(有意 ✓);想带内容需要另开一个开关并写明后果 ✓(还没做)。

个人知识库(3.3.0 起)

  • 搜一搜:工具键那排的 🔍 → 输入关键词 → 结果来自宿主自己的 sessionController.search ✓ (不会唤醒 agent ✓、不建索引 ✓)。点一条结果直接切到那个会话 ✓。 ⚠️ 先在电脑上挂 @deepseek-ai/dsh-session-query(+ -sqlite) ✓ —— 这台部署默认没挂 ✓, 没挂时界面会显示宿主的原话(搜不了:…)✓,不会假装搜过了没结果 ✗。

  • 这周学了什么:同一张纸的 📅 → 最近 7 天有活动的会话(days 可传 1–30 ✓,夹取 ✓)。

  • ⚠️ 标题那段更正:这一版最初写的是"这台宿主的列表不带标题" ✗ —— 那是我们读错了字段 ✓ (标题在 projections.values.title ✓,桥自己的 summarize() 一直读对 ✓;宿主 /api/boot 83 条全非空 ✓)。 已修 ✓:titleOfSummary() 共用 ✓ —— 所以现在列表里显示的是真标题 ✓, 短编号只是极少数兜底 ✓。把我们的 bug 说成宿主的能力缺失,是这一版最该记住的教训 ✓。 不留空白行 ✗。子会话也会混进来 ✓(筛不筛留到设置那一版 ✓)。

  • 两条路都是只读 ✓、不出这台电脑 ✓;读不到就说读不到 ✓(503 + 原话 ✓), 不显示空列表假装搜过 ✗。

手机上把步骤拼起来跑(3.2.0 起)

「📅 定时」纸里点开「🧩 工作流编排」:一行一步 ✓,写几行就是几步(最多 12 步 ✓)。

  • 点「▶ 跑一遍」:一步步来 ✓ —— 前一步跑完(这一轮结束)才发下一步 ✗(不会三条一起塞队列 ✓); 纸上写着“第 N / M 步”✓,随时能按「■ 停下」✓(停下之后再来的“这轮跑完”也不会继续发 ✗)。
  • ⚠️ 步骤之间不传数据 ✓:agent 就在同一个会话里 ✓,上一步的产物本来就在它眼前 ✓ —— 这也是“转写 → 出提纲 → 存笔记”这种链天然成立的原因 ✓。
  • 点「📅 定时跑」:每天到点把这条编排投给这个会话 ✓。⚠️ 那是另一种形态 ✗:无人值守时没人盯着 “这轮跑完”✓ → 所以整串压成一条提示词 ✓、交代“按顺序一步一步做完” ✓,让 agent 自己按顺序做 ✓。
  • 点「🔁 一轮跑完自动跑」:一轮跑完就自动跑这条编排 ✓(按钮上写着是哪条 ✓,再点一次取消 ✓)。 ⚠️ 需要页面开着 ✗(冻结/关掉就收不到 ✓);有三道防套娃闸 ✓:正在跑不起新的 ✓、 跑完冷却 90 秒 ✓、每小时最多 3 次 ✓(到顶会说出来 ✓,不静默 ✗)。
  • 「导出(复制)」给你一份 JSON ✓(发给别人或存起来 ✓);「导入(粘贴)」是追加 ✓,不覆盖 ✗。 编排存在手机本地 ✓(dshm.workflows ✓),也进备份 ✓。

自己写一个手机面板(3.1.0 起)

一个目录 + 一个 panel.json,就能在手机上多一个面板(默认关,见下面的信任边界):

~/.dsh/mobile-panels/my-notes/panel.json    ← { "title": "我的面板", "icon": "🧩", "kind": "tool", "entry": "index.html" }
~/.dsh/mobile-panels/my-notes/index.html    ← 你的页面

然后在 ~/.dsh/mobile-bridge.json 里加 "panels": true ✓,手机刷新一次 ✓。

  • ⚠️ 目录名(id)有规矩 ✗:只能小写字母/数字/短横线 ✓、字母或数字开头 ✓、最多 32 字 ✓ (my-notes ✓、我的面板 ✗)。写错了手机上不会出现 ✓,但 /api/panels 会带 skippedReasons 说清是哪一条、为什么 ✗(不再只给一个数字 ✓)。
  • kind:tool / sheet / screen(现在都表现为"工具键 + 整屏"✓,字段留着给以后分化 ✓)。
  • 页面里写相对路径就是在调桥的接口 ✓(同源 ✓)—— 例如 fetch('api/panels') ✓(那是清单 ✓; 面板自己的资源在 /panel/<id>/<文件> ✓)。 ⚠️ 别把密钥段写进页面 ✗(换地址就废 ✓,而且会进 git ✓)。
  • 同 id 时 ~/.dsh 里那份优先 ✓(能盖掉自带示例 ✓);桥自带 📝 便签 / ⏳ 计时 两个示例 ✓。
  • ⚠️ 信任边界(必须知道):面板与手机页同源 ⇒ 它拿得到手机页的 cookie ⇒ 装一个面板 = 把手机端的一切交给它 ✓。所以默认关 ✓;而"谁能装"的边界是 "谁能写这台电脑的 ~/.dsh" ✓ —— 那本来就是完全信任 ✓。
  • ⚠️ 那个目录整个"会被托管" ✗:只要扩展名在 html/js/css/json/svg/图片 里 ✓, 任何已登录的会话都能按路径取 ✓ —— 所以别把密钥或私有配置放进 mobile-panels/ ✓。
  • 桥这边只管发文件 ✓:大小上限 512 KB ✓;符号链接一律拒 ✓(lstat + realpath 双检 ✓ —— 否则 leak.json -> ~/.dsh/mobile-bridge.json 会把 PIN 与路径密钥整套读走 ✗); 词法穿越守卫 ✓(/panel/x/../../mobile-bridge.json 读不到 ✓)。

发版前顺手跑一次 node tools/check-counts.mjs ✓:它会真跑一遍九个套件、把实测项数 和 README / CHANGELOG 最新一节里写的数字逐项比对 ✓ —— 手写项数加错过一次(2.10.0 ✗), 而"套件全绿"的断言管不到文档里的数字 ✓。

仍未消除的风险(如实列出)

  • Cloudflare 能看到明文:HTTPS 在其边缘终结,prompt、会话内容、附件对它都是明文
  • 会话记录无独立保护:拿到有效 cookie 即可读取全部历史
  • 登录审计里的 User-Agent 是客户端自己写的:它只作为线索存进本机文件(前 120 字),绝不当证据 —— 真正的凭据始终是 PIN
  • 公网攻击面是一个手写的 Node HTTP 服务:没有框架兜底
  • 免费快速隧道:无可用性保证,网址每次重启都会变
  • 不用时请关闭:tools/tunnel-stop.ps1。不暴露就没有攻击面,这是最有效的一条

语音转文字:手机发的录音,模型直接"读"得到

模型读不了音频。桥会把手机发来的音频(语音备忘录、录音)转成文字交给模型; 视频里的语音也一起转(所以"发视频"= 关键帧 + 语音文字,讲解类视频全都能懂)。

用智谱 GLM-ASR,0.06 元/分钟(一分钟六分钱)。如果你已经装过本仓库的 生图插件,那份智谱 key 会被自动复用 —— 不用再配任何东西。接口限制单次 ≤30 秒,所以长音频按 25 秒切片转写,段与段之间把上一段 结果作为上下文传下去,句子不会在切口处断;超过 asrMaxMinutes(默认 10)分钟的录音不转, 并明确写出原因(省钱,也让模型知道为什么没有转写)。

~/.dsh/mobile-bridge.json 里的可选项:

{
  "asrKey": "(留空 = 自动复用生图插件的智谱 key)",
  "asrBaseUrl": "https://open.bigmodel.cn/api/paas/v4",
  "asrModel": "glm-asr-2512",
  "asrMaxMinutes": 10
}

没配 key、或没装 ffmpeg(切片要用它)时,音频会像以前一样只给路径,不影响发送。 实测:66 秒的 iPhone 讲课视频 → 239 字中文、2.2 秒、约 7 分钱。

一节课的录音(45~90 分钟)能不能识别?能,但要知道三件事

  1. 上传是最慢的一段:手机上行实测只有 20~70 KB/s。一节课的压缩录音约 20~40 MB, 也就是 5~30 分钟的上传时间(走分片上传,中断了能续,不会白传)。 想快:录音 App 里选「压缩」音质;或者干脆把文件拷到电脑上再让 agent 处理(见下)。
  2. 转写要花钱:智谱 GLM-ASR 0.06 元/分钟,一节课(45 分钟)约 2.7 元。 转写完成时消息里会写明这段花了多少(≈ x.xx 元),不用自己算。
  3. 上限已经放到 3 小时(asrMaxMinutes,默认 180)。以前是 10 分钟 —— 会把一整节课 直接挡在门外。转写本身按 25 秒切片逐段跑,45 分钟大约 2~4 分钟转完。

手机里的录音直接发过来就行;音频单文件上限放宽到 80 MB(其他文件仍是 28 MB)。


视频:手机发的视频,模型直接"看"得懂

模型本身看不了视频。桥会在电脑上自动抽 6 帧(均匀分布、每帧缩到 768 宽、JPEG), 连同一句"这是第几秒"的说明一起交给模型 —— 于是"这段视频里讲了什么"能直接答。 实测:一支 66 秒的 iPhone HEVC .MOV,抽帧 0.6 秒、6 帧合计 约 100 KB(比原片小三个数量级, 手机上行也扛得住)。原片照样留在 ~/.dsh/mobile-uploads/,路径也一并给模型, 要看细节/听声音时它自己用 ffmpeg 处理。

抽帧(以及语音转写的切片)靠 ffmpeg。插件本身不打包它(100 MB 的二进制不该塞进插件), 按下面的顺序找:

  1. 配置里的 ffmpegPath(~/.dsh/mobile-bridge.json,可选);
  2. ~/.dsh/bin/ffmpeg.exe;
  3. PATH 里的 ffmpeg。

装一个(Windows,不用管理员)。两条路都行,建议下官方的 essentials 包(同时给你 ffprobe,读时长更准更快:实测 65 ms,而不是解析 ffmpeg 的日志):

# 路 A(推荐,约 110 MB,含 ffprobe;国内可能慢,几十 KB/s 也能下完)
New-Item -ItemType Directory -Force "$env:USERPROFILE\.dsh\bin" | Out-Null
$zip = "$env:TEMP\ffmpeg.zip"
Invoke-WebRequest 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip' -OutFile $zip
Expand-Archive $zip "$env:TEMP\ffmpeg-x" -Force
Copy-Item (Get-ChildItem "$env:TEMP\ffmpeg-x" -Recurse -Filter ffmpeg.exe)[0].FullName "$env:USERPROFILE\.dsh\bin\" -Force
Copy-Item (Get-ChildItem "$env:TEMP\ffmpeg-x" -Recurse -Filter ffprobe.exe)[0].FullName "$env:USERPROFILE\.dsh\bin\" -Force

# 路 B(国内镜像,几秒钟,只有 ffmpeg 没有 ffprobe;够用,时长走 ffmpeg 日志兜底)
New-Item -ItemType Directory -Force "$env:USERPROFILE\.dsh\bin" | Out-Null
Invoke-WebRequest 'https://registry.npmmirror.com/-/binary/ffmpeg-static/b6.0/ffmpeg-win32-x64' `
  -OutFile "$env:USERPROFILE\.dsh\bin\ffmpeg.exe"
& "$env:USERPROFILE\.dsh\bin\ffmpeg.exe" -version

没装也能用:视频会像以前那样只把路径交给模型(它自己想办法),不影响发送。


HEIC 素材(iPhone 照片)

iPhone 拍的照片是 HEIC,而且扩展名经常骗人(名字叫 ..jpeg,内容却是 ftypheic)。 浏览器不一定解得了,插件里的背景/素材预览就会显示不出来。桥会按内容认出它是 HEIC (而不是当视频),但要在任何浏览器里都能显示,最好先转成 JPEG:

powershell -File tools\heic-to-jpg.ps1 -In <HEIC 文件> [-Out 输出.jpg] [-MaxEdge 1600]

它借 Windows 自带的 HEIF 解码器(Microsoft.HEIFImageExtension)转码,只读输入、 写输出。转出来的 1600px JPEG 任何浏览器都能显示,也能直接当手机界面背景。


已知坑(作者踩过的,写在代码注释里)

  1. res.write() 写到已断开的响应上不会同步抛错,而是异步 emit 'error' —— 无人监听时 Node 直接抛出,整个宿主进程陪葬。所以每一处响应都要挂 res.on('error')。

  2. 不要用局部变量遮蔽同名函数。曾经有一个 const record = ... 遮蔽了日志函数 record(),只在"令牌异地使用"这条路径上抛 TypeError,把整个 DSH 打死,排查了很久。后来又踩了一次:手机作答的等待记录也叫了 record,于是"提问推给手机"那一行直接把宿主打死。变量名起之前,先看一眼作用域里有没有同名函数。

  3. 注释里写了"要保住的东西",就得在每一条写入路径上兑现。 提问卡片的草稿注释着"存在 state 上就不会丢",但"宿主重推同一个 id"那条路径直接整体覆盖,把用户勾到一半的选择清光 —— 注释不能当实现。

  4. server.close() 不会断开已建立的 keep-alive 连接。 插件重载时旧实例的会话已清空,却还在服务复用连接,手机就会拿到莫名的 401(表现为"刚重载又要我输 PIN")。要显式 closeIdleConnections() / closeAllConnections()。

  5. waterfall 是顺序的,注册顺序决定谁作数。 手机应答者必须 prepend 在官方 api-remotes 之前,否则永远不会被调用(官方那一环会一直等到浏览器作答)。

  6. 超时和取消要分开。 超时该 next() 交还桌面;取消(agent 已放弃)绝不能 next(),否则桌面上会弹出一个已经作废的批准框。

  7. 退出前要同步落盘。异常处理器里用异步 appendFile 再 process.exit(),写入不会完成 —— 探针会把证据弄丢。

  8. pnpm/npx 建的联接,Target 可能是相对路径,重建时要相对原联接所在目录解析。

  9. robocopy 默认跟进目录联接,会把链接指向的整棵树也复制过来。

  10. str.Replace() 替换的是【全部】匹配,不是第一处。 曾用带锚点的替换插入 /api/revoke 路由,而那个锚点在文件里出现了两次:一处正确,另一处落在无密钥的 404 分支里、位于 const route 之前 —— 于是手机浏览器自动请求 /favicon.ico 时抛 ReferenceError: Cannot access 'route' before initialization,整个宿主进程直接退出。表现是"手机一连上就崩"。插入唯一代码块必须用强制唯一匹配的编辑方式,或先断言出现次数。

  11. 配置重写要带上所有字段。 loadConfig() 只写它列出的字段;新增的排查开关漏掉,就会被下一次"配置有变化"的重写悄悄抹掉(crashProbe 踩过)。

  12. 测试会因为路径形态漂移而静默烂掉。 页面改成相对地址(api/...)后,UI 测试的 mock 仍在按 startsWith('/api/...') 匹配,于是每个请求都 404 —— 测试不是失败,是整个不工作了。同一类漂移还让 harness.mjs 一次废掉 37 条断言(它的 base 里没有密钥段,于是 PIN 门、cookie、SSE、取消、余额缓存全部 404),让 shot.mjs 永远拿不到截图。规则:测试里凡是拼 URL,都必须从配置里读密钥段,绝不硬编码绝对路径;mock 要按浏览器语义把相对地址解析到带密钥段的页面地址上,并断言"没有请求跑出密钥段"。


诊断纪律:先复现,再治本(治表要被当成失败)

这个项目里最贵的几次返工,都是没复现就动手造成的。写成规矩,省得下次又犯:

  1. 先复现,再改代码。 用户报的现象必须在真环境里重现出来(真浏览器 + 真桥, 必要时真流式输出),或者从日志/崩溃遗言里拿到确凿证据。工具表里那几个 *-bench.mjs / scroll-live.mjs / shot.mjs / phone-diag.mjs 就是干这个的; 需要新的复现手段,就先写工具。
  2. 复现不了就明说,不许靠猜。 说清"我拿到什么证据、还差什么",然后去加测量点 (例如 window.__dshBoot() 这种读数),而不是先塞一个"看起来能缓解"的改动。
  3. 区分"症状"和"根因",改完要能解释为什么它不会再犯。 判据:这条改动如果拿掉, 问题会不会回来?会 —— 那它治的是根因。不会 —— 那它只是挡了一层。
  4. 不许用"性能"当估算的借口。 典型反面教材:content-visibility 让滚动容器里的 块按估算高度参与 scrollHeight,用户往上一滚内容就被顶回去 —— 那是拿正确性 换性能,而实测它连性能都没换来(去掉之后流式期间最长掉帧 108ms → 61ms)。 要性能就先量,量不出收益就别加。
  5. 同一个 bug 第二次回来时,先怀疑上一次的修法是治表。 "回弹"就是这样:第一次改 阈值和键盘(治表),第二次才挖到"边滚边改 DOM 会被 iOS 复位滚动位置"(根因)。
  6. 修完把根因写进这份 README 的坑表(现象 → 证据 → 根因 → 为什么不会再犯), 并在测试里钉住根因,而不是钉住那个数字。例:现在 ui.mjs 断言的是 "页面里不用 content-visibility"(根因),而不是"阈值等于 24px"。

开始页上的版本说明(1.8.0 起)

开始页两处都写着版本,报障时不用再问"你装的是哪一版":

  • PIN 登录页底部:手机桥 v1.8.0;
  • 「开始使用」说明页:版本号 + 最近三版的更新要点(只挑"加 / 改 / 修"这类用户能感知的, 不带"测试""实现细节")。

两个数字都是现读的,不是手抄:

显示什么从哪儿读
版本号package.json 的 version
更新要点CHANGELOG.md 头部最上面三节的小标题

手抄一份的下场是迟早对不上,而版本号一旦开始撒谎,排查就没有依据了。读不到就整块收起来 (不会变成报错)。更新清单以 <script type="application/json"> 注入,客户端用 textContent 建节点 —— CHANGELOG 是我们自己的文本,但"把文本当 HTML 塞进页面"这种事一次都不做。

顺带修掉的一个真坑:页面里的注入标记出现不止一次(版本标记在登录页和说明页各一处), 而 String.replace(字符串, …) 只换第一处 —— 结果是说明页上摆着一串 __DSH_BRIDGE_VERSION__ 给用户看。现在全部用 replaceAll,harness 里也钉了一条 "注入标记一处都没漏"。

手机打开电脑上的文档(2.1.0 起)

会话里出现的文档——agent 在电脑上写的文稿、导出的 PDF、脚本、数据表——在手机上多一个「打开 ›」:

类型手机上怎么呈现
Markdown / 文本 / 日志 / YAML / 代码 / CSV / JSON直接在页面里渲染(Markdown 会做标题/加粗/列表格式化)
PDF用 iframe 打开,手机浏览器自己翻页
图片 / 音频 / 视频直接看 / 听(原有能力)
Word / Excel / PPT / 压缩包浏览器渲染不了 → 页面会明说,并指向右上角「下载」(用手机上的 WPS/Office 打开)

范围是最小方案:只服务会话里给你的文件,不做目录浏览 ✓。

安全上有两条硬的:

  • 文档走 /api/file,路径必须在桥认识的目录里(工作区 / 会话目录 / ~/.dsh / 临时目录)✓;
  • 密钥类文件一律 403:mobile-bridge.json(PIN 明文)、.credentials.yaml(模型 key)、 image-gen.json(生图 key)、.env*、*.key/*.pem/*.pfx/*.p12/*.keystore 都不通过手机发送 ✓ (加文档类型时发现的洞:.json 一度让它们变成"点一下就下载")。
  • 文档另有 25 MB 上限(手机上打开几十 MB 的文件只会卡死)。

顶端显示当前模型(2.6.0 起)

网页最上面(标题栏正下方)有一行 「当前模型」:

  • 值来自宿主(每一行会话带的 modelSelection 投影),所以在电脑上换过模型,手机这里也跟着变 ✓ —— 2.4.2 那个"手机永远显示默认"的已知限制在这一版修掉了。宿主在流里推的 request/header(下一次请求真用哪个)和 model/selection(刚选了还没发)都会立刻反映过来, 不用重开页面。
  • 它同时是切换器:点开就能换,落在当前会话上(和「新建」面板里那份目录是同一份)。
  • 宿主报出来的模型不在目录里时(provider 被停用、目录没拉到)也照样显示那一个, 不会静默跳到第一项 —— 这个功能的全部意义就是"看到正在用的到底是哪个"。

删除会话(2.6.0 起)

会话列表里每一行右边多了一个 🗑(在 📌 / ⭐ 后面)。点它打开一张确认纸:

  • 必须现输 PIN(就是登录手机桥那个 6 位 PIN)—— 每次都要,不复用"最近提权过"。 这是手机上唯一不可从手机撤销的操作,所以门槛单独抬一级:手上有一部已解锁手机的人, 不能直接把你几十个会话从列表里清空。PIN 错了就地报错(不会把你踢回登录页), 而且同样进登录限速 —— 它不是可以无限试的接口。
  • 正在跑的会话不会被悄悄删掉:宿主先拒绝,页面把按钮换成「停掉它并删除」, 你再点一次才真的连正在跑的一起停。
  • 删完列表立刻刷新;如果删的正好是你正在看的那个,页面会自动让到另一个会话。

它到底是"删"还是"归档"?

是归档(宿主原生的 archiveSession,桌面端会话菜单里那一项就是它):

  • 会话不再出现在手机和电脑的会话列表里;内容仍留在电脑上,在电脑端可以取消归档找回来;
  • 桥不会自己去删会话文件。手删会话日志(JSONL)会绕过宿主的索引、附件目录和派生会话的 血缘 —— 那是真会弄坏东西的做法,所以这条线不碰。

列表默认不显示哪两类会话

用户报过"agent 子对话和测试对话占位置",所以这两类默认不摆出来:

类别判据为什么
agent 子会话origin: 'subagent' 或带 parentSessionId手机本来就打不开它们(宿主只认父会话地址)
自检 / 开发会话cwd 在桥自己的目录里那是开发测试跑出来的("输出数字1到1200"这类),不是你的工作区

两条都只是手机这一侧的显示规则:宿主的会话数据一个字不改,桌面上照旧全都能看到。 列表底下会写明"已藏起 N 个",并有一个 「显示全部会话」 开关(记在手机本地)能看回来 —— 不静默过滤、也不会让人以为会话丢了。

从 2.x 升到 3.0 要做什么

什么都不用做 ✓ —— 直接升级、手机刷新一次就行 ✓(所有新功能都默认"不动你现在的习惯" ✓)。 想让新东西生效,只有两三处可选动作:

想用要做什么
有事通知我用 https 的网址打开手机页(https://…ts.net ✓)→ 在「🩺 一键自检」里勾上「有事通知我」✓
说出来 → 文字同上,必须是 https(浏览器只在安全地址给麦克风 ✗)→ 「🎤 录音」纸里点「说一句」✓;电脑上第一次用要先点「准备识别器」✓
念出来不用配置 ✓(用手机自带的声音 ✓);点回答旁边的 🔊 ✓
新设备要你点头在 ~/.dsh/mobile-bridge.json 里加 "deviceApproval": true ✓,然后到电脑上打开 /approve 放行 ✓(⚠️ 默认关 ✓;一台可信设备都没有时自动放行 ✓,不会把你锁在外面 ✓)。放行是按设备记的 ✓(3.4.1 起 ✓):同一台手机换网络 / 换地址 / 登录过期后重登,都直接进 ✓;只有没见过的设备才要你点头 ✓
弱网模式不用配置 ✓;在「🩺」里勾上,或等它在你发得慢时主动问你要不要开 ✓

⚠️ 两个环境前提(不是配置能绕过的 ✗):通知和麦克风都要求浏览器认为你在"安全地址"上 ✓ (https 或 localhost ✓)——用 http://100.x.x.x:3081 这种局域网地址时,浏览器直接不给, 插件会如实告诉你,并保留"标签页标题提醒"这个兜底 ✓。

把手机当第一屏(3.0.0 起)

  • 离线优先:写到一半的话自动存住 ✓(点一下输入框就回来 ✓);整屏离线时能直接读到上次的对话 ✓、 能接着写 ✓、并且一眼看到"有几条还在等着发" ✓。
  • 有事一定找得到你:要你拍板 / 一轮跑完 → 页面在后台就弹通知 ✓(正看着时不打扰 ✓; 几分钟的一轮才叫 ✓);点通知回到页面 ✓。安全地址之外用标签页标题兜底 ✓。
  • 说 → 文字 → 你看一眼再发:转出的文字只进输入框 ✓,插件绝不替你发出去 ✓。
  • 念出来:助手回答旁的 🔊(代码不念 ✓、超 1200 字只念一段并说明 ✓、再点一下停 ✓)。
  • 新设备要你点头(可选,默认关):电脑上 /approve 放行 ✓;防锁死三条见上表 ✓。
  • 连不上自己撑住:带倒计时的重试 ✓、切到当前地址 ✓、改用局域网地址 ✓(会先判能不能用 ✓)、 离线读缓存 ✓、排查清单 ✓。

从 2.9 到 2.10(键盘手感 / 长回答 / 弱网 / 看图)

……见下面各节 ✓(README 这一版按"第一屏"重排 ✓,旧的说明都还在 ✓)。

看图 / 相册滑动(2.11.0 起)

点开一张图(或视频)进全屏:

  • 左右滑(或按 ‹ ›;桌面上按方向键)在同一组之间翻,计数写着 3 / 7。 "一组" = 点的那张所在的气泡/交付块;只有一张时退回整个对话(最多 40 张)。
  • 📌 钉住 = 并排对比(挑封面):钉住的那张留在左边不动,另一张随手指翻。 钉住当下不会把同一张复制成两份。
  • 选这张 = 把这个素材的电脑路径写进输入框(data-path;没有路径就退而写链接), 发出去就等于告诉 agent 用哪张。
  • ⚠️ 全屏看图器开着时,页面级左右翻页会让开 —— 不然在看图器里滑一下,底下的页面也跟着换。

弱网模式(2.11.0 起)

开关在「🩺 一键自检」纸里(不往工具键那排再加键)。开了之后:

  • 文字立刻发;图片/文件进"留着"的箱子,有网时自动补发(复用离线发件箱那条路: requestId 幂等 + 20 秒重试)。代价如实说:附件是单独再发一条,agent 晚一轮看到。
  • ⚠️ 存不下就绝不"留着":附件是 data URL,localStorage 只有 ~5MB —— 超过 1.2MB 或写入失败时**退回"一起发"**并说清原因(刷新后附件消失是最不能犯的错)。
  • 发送中屏幕上写「📡 正在发送…」,发完自己收走(只收自己写的那句,不吞别人的提示)。
  • 一条发得超过 6 秒 → 主动问一次要不要开(一个会话只问一次)。

读长回答 & 键盘手感(2.10.0 起)

工具键那一排里的 ⌨ 是这一批的两个入口(它俩都只记在这台手机上,也进备份):

打长句子顺手

  • ↑ 取上一条已发消息(↓ 往回翻;只记最近 20 条、单条 >4000 字的不进历史):只在"不会抢活"的时候接管 —— 光标不在第一行时,↑/↓ 还是正常的"看上一行"。一打字就退出翻历史状态(所以取回历史后再改字不会被抹掉)。 ⚠️ 历史是这台手机共用一份、不分会话(像终端的 history 一样)。
  • Enter 可直接发送:默认不勾(Enter 就是换行,老习惯不动);勾上后 Shift+Enter 仍换行、 Ctrl/⌘+Enter 一直是发送。
  • 长按输入框再左右拖 → 移光标:默认关(长按本来会触发系统的"选词 / 放大镜",硬抢只会两败俱伤)。

长回答不刷屏

  • 超过 22 行的回答只露开头 8 行,其余点「展开全文」;超过 18 行的代码块同样默认折。 折叠用的是原生 <details> —— 展开/收起不重排、不跳滚动。
  • 代码高亮(零依赖手写:关键字 / 字符串 / 注释 / 数字),铁律是只加标签、不改内容 —— 对代码块有一条往返断言(渲染出来的文字必须与原文一字不差,样本含转义反斜杠 / 反引号 / URL 里的 # / 十六进制 / 中文 / emoji)+ 一个注入探针。⚠️ 两处口径边界如实写在这儿: · 行内格式(**粗**、`代码`)不是往返 —— 它本来就是 Markdown 渲染,标记会被吃掉; · \r\n 会归一成 \n(和 textarea 一致);单条 >8000 字符的代码块不做高亮(只转义,保内容)。
  • Markdown 表格画成真表格,外面那层声明了 overflow-x: auto(⚠️ jsdom 没有布局引擎, "真的滑得动"这条只能真机看 —— 测试里也这么写的)。
  • 单条 >24KB 会降级成纯文本(流式输出每 80ms 重画一次,长文里做排版会把帧率吃光)。
  • 想全部铺开:⌨ 里把「长回答默认折叠」关掉,已有对话立刻重画。

收起看(2.9.0 / 2.9.1 起)

手机上竖着的地方最贵,所以上下两条栏都能收 ✓:

  • 底部工具键:输入框左边的 ⋯ —— 收起的只有那 13 个工具键;输入框、发送、页签栏、 附件缩略图一个都不藏。开关自己一直看得见(放进被收起的那排 = 再也找不到)。
  • 顶栏:最左边的 ⌃ —— 收起后只剩状态点与开关(一条细缝);标题、「说明 / 刷新 / 退出」、 「当前模型」那行一起让位。「已跑 X:XX / 本轮 token」不跟着收(它只在跑任务时出现, 收掉等于把功能藏了)。
  • 两个状态都记在手机本地(dshm.tools / dshm.top),刷新、切回 App 都记得,也进备份白名单。
  • ⚠️ 实现上有一条别踩的坑:#tools 里还嵌着「只看不发」「离线发件箱」两条横幅和三张纸 —— 收 #tools 会把它们一起 display:none 掉(祖先隐藏,position:fixed 也救不了)。 所以收起的是新加的那层 #toolKeys(只包工具键)。

备份 / 换手机(2.9.0 起)

🩺 那页里的「💾 备份 / 换手机」:把这台手机上的设置导成一个 JSON 文件,新手机 / 重装 PWA / 清了站点数据之后导回来就还在(置顶、常用、用得多的顺序、只看不发、收起工具键、上次停在哪个会话)。

  • 只备份"这台手机上的"东西:电脑那边的配置(PIN / 端口 / 隧道)不用备份 —— 换手机不动它。
  • 两条故意不备份:网址里的密钥段(dshm.canonical / dshm.addresses —— 备份文件是要发给自己、 存网盘的,把访问密钥抄进去等于给文件配把钥匙),以及离线发件箱(恢复一份几天前的备份不该 替你把过时的消息发出去)。
  • 导入按外部输入对待:只认白名单键、只收字符串、有大小上限;坏文件当场说"没恢复", 白名单外的键跳过并报数。恢复后置顶 / 常用 / 只看不发立刻生效,"上次的会话与模型"刷新一次才生效 (这一点写在界面上,不假装全都立刻好了)。

自己会看病(2.8.0 起)

手机上一个 🩺 键 + 一个 🛡 键,配桌面角标上的报警:

  • 🩺 一键自检:七条只读检查(桥在不在听 / 桌面端在不在听 / 开机自启装没装 / 有没有会话在跑 / 外网隧道通不通 / 上传目录能不能写 / ffmpeg 在不在),每条都写"怎么办"。 它只看不动 —— 不会替你启动任何东西(那是 tools/boot-up.ps1 的活)。不含"publish 目录干净" 这类维护者检查项:插件不该知道你的发布目录在哪。
  • 🛡 安全审计:谁在什么时候从哪个地址登录过、成没成,一行一行列出来(还有"当前还在登录的 设备有几台")。看这份账要现输一次 PIN —— 里面是这台电脑的网络记录(和"看剪贴板 / 截图"同一级)。 新地址第一次成功登录会在桌面角标上报警 —— 提示里写明"手机换 WiFi / 切流量时地址本来就会变, 走外网时那是隧道那端的地址",免得把自己吓一跳。发现不对,页面上「踢掉其它设备」用刚输的 PIN 就能把别的全部下线(不会把你自己这台踢掉)。
  • 📦 诊断包(在 🩺 那一页最下面):下载一份文本 —— 自检结果 + 最近日志 + 登录账 + 审批账 + 崩溃日志。PIN 与网址里的密钥段会被替换成占位符,每份日志只截最近 48KB。 出问题时先下这一份发出去,比描述一百句都准。
  • 实时耗时 / token 跳动:跑任务时顶部显示 ⏱ 已跑 1:05 · 本轮 ↑9.2k ↓340 · 本会话累计 ↑12.3M ↓106k。 计时起点取自 turn/start 事件自己的时间(刷新页面把这一轮重放回来时,计时接着走, 不是从打开那一刻重算);token 来自宿主的 usage(assistant/message 里的每轮用量)与 tokenUsage 投影(会话累计)。读不到就不显示那一段 —— 宿主没报就只显示时间,绝不编数字。
  • 发送失败的「重试」:条幅上有一个能点的键,点一下把刚才那条重发(输入框与附件都还在)。 断网那种失败另走「离线发件箱」(2.7.0 起),两条路不重叠。

会话置顶与常用(2.5.0 起)

任务页顶部原来是一个原生下拉选会话 —— 放不下 📌 / ⭐ 按钮,也没法分组排序。 2.5.0 换成自绘的分组列表:顶部一行只显示"现在在哪个会话"(📌/⭐ 会一起标在标题前), 点开就是三组:

分组谁在这里怎么排
📌 置顶你钉过的会话按钉的顺序,先钉的在前;重启手机也还在
⭐ 常用你打星的 + 用得最多的前 3 个(自动)次数降序 → 平手按最近活动
🕘 最近其余全部最近活动降序
  • 每行右侧两个小按钮:📌 = 置顶开关、⭐ = 打星开关(点一下标上、再点一下取消, 按钮本身会亮起来);点行本身 = 切到这个会话。
  • 「常用 A(按使用频率)」就是上面那条自动排序:切过去 +1、发一条消息再 +1; 「常用 B(手动打星)」是你自己说了算 —— 一次没用过也能进「常用」。
  • 「打不开的会话」(agent 自己派生的子会话)在那一行里写明 ⚠️ 打不开,照样能点进去看原因。
  • 三份数据只存在这台手机的浏览器里(dshm.pins / dshm.stars / dshm.usage): 不写宿主的会话库、不给宿主加字段 —— 所以电脑端完全不受影响,换台手机也不会互相干扰。
  • 触摸上有一条硬规矩:从 📌/⭐ 上起手时拦掉 touchstart 的冒泡 —— 否则按着那个小按钮 横向拖一下就会被当成"翻页"。列表空白处照样能左右滑换页(滑动换页一个字没改)。

左右滑动换页(1.7.0 起,2.0.0 补齐面板)

底下一栏是三个页面:任务 → 新建 → 余额。除了点那三个键,左右滑动也能换页: 往左滑 = 下一页,往右滑 = 上一页(用户举的例子:在「新建」上往左滑就是「余额」)。

「新建」其实是一个盖在当前页上的面板(当初它是页签时,主按钮正好在页签栏上方, 拇指瞄页签会误建会话并立刻产生一次计费回合),但滑动按用户心里的三页顺序走, 不按 DOM 里那两个 view 走。

录音 / 生图 / 背景 / 权限这几个面板上也照样能滑(2.0.0 起,用户报的 「左右滑动时录音界面不行」):划走时那个面板自己关掉、直接翻到下一页。 1.7.0 时这四个"工具抽屉"开着会整个挡掉滑动 —— 用户的感受是"滑动时灵时不灵", 而"时灵时不灵"比"不支持"更难理解。

三条让路的规则(错了就会变成"东西自己动了"这类最难解释的故障):

情况行为
竖着为主的滑动(横向位移不到纵向位移的 1.2 倍)当滚动,绝不翻页 —— 用户在看历史时页面不能自己跳走
从输入框 / 下拉 / 播放器上起手,或从横向能滚的元素(代码块)上起手不翻页(那是在选文字 / 调进度 / 看内容)
说明页、提问卡片、离线整屏开着时背后不换页(这三处必须先处理掉;离线整屏 2.0.0 起也不再被穿透)
正在录音时不翻页,并弹一句"先按「停止」"(面板上就是停止键,划走了够不着它)
生图里写了提示词时先 confirm 一句再走(和「新建」同一套)

另外两条:幅度不到 60px 当点击;左右到头时只提示一句("已经是最左边了(任务)")。 离开「新建」前如果那一页写了东西,会先 confirm 一句再走。翻页时进场动画只是"确实翻了"的 即时反馈,prefers-reduced-motion 的人看不到它。

提示走的是 #toast,它的 z-index 必须高于所有会盖住它的东西(面板 80 / 加载页 90 / 离线整屏 95 → toast 120)。1.7.0 时它是 60,于是这些提示在面板上根本看不见 —— 看不见的提示等于没提示,用户就会以为"在这儿滑动没反应"。

上滑出现「回到底部」的箭头(2.0.0 起)

往上翻着看历史时,屏幕下方会出现一个圆形箭头「↓」:

  • 手指还按着往上滑的过程中就出现(不用等松手);
  • 点它 → 立刻回到底部、恢复"跟着新消息走",箭头自己收起;
  • 已经贴到底部时不出现(不做常驻的无用按钮)。

旧版(1.7.0~1.8.x)这个入口是"12px 灰字小胶囊 + 松手 160ms 后才出现", 用户报的原话是"上滑就会出现的会到底部的箭头" —— 也就是他压根没看见旧的那个。

网页里直接录音(1.5.0 起)

最底下那一栏(任务 / 新建 / 🎤 录音 / 余额)里的「录音」:点开就是一个录音面板(大计时器 + 电平条),录完可以先听一遍, 满意再发。发出去之后会和别的音频一样自动转成文字交给模型。

  • 为什么要在网页里录:手机上"用系统录音 App 录 → 找到那个文件 → 分享给某个应用" 这条路很难走;而且系统录音默认音质一节课 2040 MB,在 2070 KB/s 的上行下要传半小时。 网页里录的是 opus 约 24 kbps —— 一节课约 10 MB,三分之一的体积。
  • 两个前提:① 浏览器只在安全地址下给麦克风 —— 用 https://…ts.net(Funnel) 可以;用 http://100.x.x.x:3081(Tailscale 局域网 IP)浏览器会直接拒绝; ② 录的时候别锁屏、别切到别的 App(iOS 会掐掉网页录音)。
  • 录音走的是和「文件」完全一样的上传路径(分片、可续),所以长录音也发得出去; 音频单文件上限 80 MB。
  • 体积:1 秒约 3 KB。45 分钟的课:
网页录音(opus 24k)系统录音 App
体积约 8 MB约 20~40 MB
在你那条上行上传约 3~7 分钟约 8~30 分钟

手机上生视频(1.4.0 起,用同一个「生图」键)

「生图」面板顶部有 🖼 图片 / 🎬 视频 两个模式。切到视频,写一句描述发出去, agent 就会调电脑上的 video_generate 出片:

  • 走智谱 CogVideoX,cogvideox-flash 免费(文生视频 / 图生视频,最高 4K), 用的是生图那份智谱 key —— 电脑上装过本仓库配套的生图插件、填过 key,就直接能用;
  • 出片是分钟级的(免费模型实测 56~63 秒一段,高峰期排队更久);
  • 出来的 mp4 落在 ~/.dsh/生成视频/,手机上直接显示成能播放的视频,长按可存相册 (手机端本来就会把文本里的 .mp4 路径渲染成播放器);
  • 要更清晰、要带音频,可以让 agent 用 cogvideox-3(按量计费)。

手机桥这边不直连视频后端(和生图同一个取舍):产物必须落进当前会话,才看得见、存得下。

连不上电脑时,看到的是一屏说明(1.6.0 起)

用户原话:「连接不上时不是黑屏就是白屏」。两种成因都真实存在:

  • 黑屏:设了背景图/视频,而它取不到 → 深色底上什么都没有;
  • 白屏:连不上、又没有内容可渲染 → 只剩一个空壳。

现在这两种情况都会换成一整屏说明(不透明、压在最上层、失败时顺便把背景收掉):

屏幕上告诉你的你能做的
哪一类故障:手机自己没网 / 从没连上过 / 用着用着断了 —— 三句不同的话照着第一句排查(多半是电脑睡了)
10 秒后自动重试(倒计时就写在屏幕上)也可以点「立即重试」
先看上次的对话(离线可读)手机本地缓存的那份能读,等连上再发消息
切到当前地址 / 改用局域网地址换过隧道地址之后,旧地址是要手动切走的
一份排查清单:电脑睡没睡 / DSH 还在跑吗 / Tailscale 两端 / 是不是旧地址 / tools\tunnel-status.ps1不用自己猜

两个细节:重试是"原地重跑一次 boot",不是整页刷新 —— 你正在读的东西不会被刷掉; 401(PIN 过期)不会弹这一屏,那时候该出现的是登录页,弹"连不上电脑"只会把人引去查网络。

兼容性

  • 需要 DSH(peerDependency:@deepseek-ai/cordis)
  • 纯 ESM,无构建步骤,无运行时依赖(只用 Node 内置模块)
  • 只在 Windows 上实测过;监听、认证、页面都是平台无关的

量"进插件"要多久

node tools/measure-boot.mjs <手机桥网址> <PIN>          # 老用户(本地记着上次那个会话)
node tools/measure-boot.mjs <手机桥网址> <PIN> --cold   # 首装(没有本地标记,会多拉一次对话)

真浏览器跑一遍,报"界面可见 ms / 对话有内容 ms"、这次打开的请求数,还有一条请求时间线(哪个请求在什么时刻发出去)—— 慢的时候一眼能看出时间被哪个请求吃掉了,以及接口之间是串行还是并行。

本机 127.0.0.1 实测(工具会先按"老用户"给页面塞回 dshm.session,量的才是每次进来的真实路径): 界面可见 327 ms、对话有内容 500 ms,只发 1 次 /api/boot、0 次 /api/transcript。 手机上还要加上隧道往返,但数据量已经靠 gzip 压到 1/4~1/5(首屏 122 KB → 41.7 KB, 对话快照 202 KB → 42 KB),第二次打开还走 service worker 的缓存壳。

⚠️ 隧道慢的时候(tailscale netcheck 显示最近 DERP 在旧金山就是信号)别硬扛: 手机和电脑在同一个 WiFi / 热点下时,直接用 bootstrap 里报的局域网地址 (http://<WLAN IP>:3081/<密钥>/)比走 Funnel 快一个数量级。


手机上直接生图(要额外装一个插件)

「生图」键本身只是个入口:它把「请用 image_generate 生成一张图片…」这句话交给当前会话, 真正出图的是电脑上那个 image_generate 工具 —— 它是一个单独的 DSH 插件,不在本仓库里。

  • 装了(会话里能看到 image_generate 工具)→ 点「生图」就出图,图落在会话里,可点开、长按存相册;
  • 没装 → agent 只会回一句"没有这个工具 / 没有生图模型"。这不是手机桥坏了。

装法(一条命令):

dsh plugin --profile web add github:jxboop/dsh-plugin-image-gen

仓库:https://github.com/jxboop/dsh-plugin-image-gen(自带构建产物,装上即可用)。

装完重启 DSH,再刷新手机页面。验证:在电脑上开一个新会话,直接说"用 image_generate 生成一张图", 能出图就说明插件装好了。

免费生图后端(pollinations / HF Space)不太稳:会被限流(约 15 秒一条)、配额用尽时返回 402, 高峰期可能连续失败。真想稳定出图,去 https://api.together.xyz 免费注册拿 key, 填进插件配置的 together.key 并把 providers 设为 ["together"]。

换手机界面的背景

在手机上点工具栏的「背景」,选一张图片 / 动图(GIF)/ 视频就完事:

  1. 点「背景」→「选图片 / 动图 / 视频」;
  2. 选好的文件会传到电脑、写进配置,当场铺满界面(不用刷新,也不用重载插件);
  3. 想换就再选一张;想关掉点「去掉背景」。

几点说明:

  • 背景是给这台电脑配的:连上来的手机看到的是同一张。
  • 文件存在电脑的 ~/.dsh/mobile-uploads/(只允许这个目录,.. 逃不出去),不占手机空间; 手机第一次打开下一次、之后走手机本地缓存(背景地址带 ?v=<大小>-<修改时间>,换背景必然换缓存)。
  • 支持 .gif/.webp/.png/.jpg 和 .mp4/.mov/.webm;扩展名会骗人,服务端按内容认类型。
  • 手工改 ~/.dsh/mobile-bridge.json 的 backgroundFile 仍然有效(老办法),但现在没必要了。

跑测试(改了代码之后必跑)

cd <这个插件目录>
node test/package.mjs     # 打包清单、文本编码自检(乱码/BOM)
node test/harness.mjs     # 宿主接口与闸门(最多)
node test/qr.mjs          # 二维码编码器(含**往返解码**)
node test/ui.mjs          # 手机页面(jsdom)
node test/security.mjs
node test/crashprobe.mjs
node test/addresses.mjs
node test/offline.mjs
node test/platform.mjs    # 解锁那三种结论的纯函数(跨平台命令形态)
node tools/check-counts.mjs   # 跑上面九个 + 核对文档里的项数
  • test/voice.mjs(33 条,语音草稿)不在上面这串里 —— 语音那一版按用户要求先跳过; 它留在仓库里、可以单独 node test/voice.mjs 跑 ✓,但不进门槛 ✗(别把"没验过"说成"验过了"✓)。

  • 有两条需要仅测试依赖(jsdom 给界面测试 ✓、jsqr 给二维码往返解码 ✓)—— 它们放在 ..\_verify 里,和插件运行时无关:插件本身零运行时依赖 ✓。

  • 缺 jsqr 时 test/qr.mjs 会显眼地 SKIP 那条往返解码 ✓(打印一行 SKIP + 写明怎么装 ✓), 结果是「11 通过 + 1 跳过」、exit 0 ✓ —— 既是"不是失败",也不算验过 ✓。 发版前请在装了 jsqr 的地方跑一次,确认往返解码那行是 PASS ✓(不然等于没验编码器 ✗): npm i --no-save jsqr。

  • 发布目录(publish/)里没有那个 _verify ✓,所以在那里跑会走 SKIP ✓ —— 要真验二维码就回源码目录跑 ✓。

License

MIT

只认本机
诊断包脱敏GET /api/diag 生成的文本里,PIN 与 pathSecret 一律替换成 <PIN> / <密钥段>,另有 Bearer … / sk-… 两条通用规则;读不到配置时在包里写明"没能确认脱敏";每份日志只截最近 48KB —— 它是要发出去的东西
自检的隧道那一条是"现探"state.tunnel.ok 是三态(null = 没探过):点一次 🩺 就现探一次,免费隧道换域名后会重新探 —— 绝不说"没测过"就是"可达"
  • 页面是启动时读一次并缓存的(state.page,配合 page tag 做自重载)。改了 mobile.html 之后必须重载插件才对外生效 —— 只改文件不重载,线上还是旧页面。

  • 模块级函数里用不了 createBridge 内部的日志器。 为了给角标加两行日志,在模块级的 installBadge 里直接写了 record(...) —— 那抛 ReferenceError,而 installBadge 正好是 start() 的最后一行,一抛就打断整个启动:角标从界面上消失、隧道自检永不启动,而当时 236 条测试全绿(角标那条路径没有任何覆盖)。同一个名字第三次咬人。 现在 logger 作为参数传进去,并且 harness 里补了角标的覆盖。

  • 给用户看的消息必须写进用户会看的地方。 隧道断了的告警一开始用的是 log()/warn() —— 它们只到 stdout/stderr,而 DSH 不捕获这些。等于把"隧道断了"说给空气听。 只有 record() 会落进 ~/.dsh/mobile-bridge.log。

  • 成功分支别提前把状态改回默认值,否则"恢复了"那条分支永远不可达(ok 已经被改回去了,ok === false 不可能成立)。

  • 保活轮询必须限速,而且要避开后台。 长连接半死(电脑睡了)时不响 onerror,所以页面有一个 5 秒一跳的哨兵,发现 25 秒没数据就用普通请求补拉对话。可是条件只写了"静置超过 25 秒"——于是静置的手机每 5 秒拉一次对话,一次最多二百多 KB,一小时七百多次,流量、电、日志全烧掉,画面还不会因此更及时。现在补拉最快一分钟一次,页面切到后台(锁屏)时整个哨兵直接返回,回到前台再补一次。

  • 大响应可能被通道缓冲住,不能只赌长连接。 手机上"打开对话一片空白,直到电脑那边发来下一条消息才冲出来"就是这个:SSE 的初始快照是 200 多 KB 的一次性写入。所以初始对话改走 GET /api/transcript(普通请求,bootstrap 能通就证明它一定送达),实时增量仍走 SSE —— 能推就推,推不动也不至于什么都看不见。教训:把"能不能送达"和"能不能推送"分开,别让一个赌注同时决定两件事。

  • 破坏性测试绝不能打真身。 登录限速的额度是全局的、和手机共用;旧版 security.mjs 直接在正在服务的桥上连猜 15 次 PIN,还在真身的令牌文件里留下一串 192.0.2.x 的假令牌 —— 表现就是用户被自己的测试锁在门外。现在它用临时 DSH_HOME + 随机端口另起一台一次性实例来跑这些检查,跑完拆掉,并在结束时清掉临时目录。

  • 异步请求回来时,要认准"我请求的是谁"。 pullTranscript() 取的是 state.sessionId,但 await 期间用户可能已经换了会话 —— 回来无条件贴上去,就变成 B 的标题下面挂着 A 的对话,而且之后的事件全接在错的对话上,自己永远好不了。现在先记住 wanted,回来第一件事是核对。同一个坑还有更狠的变体:那条"这个会话打不开"的判定如果用 state.sessionId,一个迟到的失败回包会把一个健康的新会话永久标成打不开。

  • 会话在不在跑,要以宿主给的 running 为准。 state.running 原本只由流里的 turn/start / turn/end 写,换会话时没人重置 —— 切到一条闲着的会话,按钮还写着「插话」、停止键还亮着,发一条普通消息还会回一句假的"已插话"。切换和列表刷新时都从 running 字段重新取值。

  • 兜底成功了,就要把误报收掉。 长连接把大包缓冲住时顶部会报"好像和电脑失去联系了";而补拉用的普通请求能通,恰恰证明电脑还在。以前补拉成功不改 lastFrameAt/link,于是那条红字在这个接口专门为之存在的场景里一直挂着。现在补拉成功即抹掉 stale。

  • "大"不只有"超长文本"一种。 assistant/message 的 data.stream 里是这条消息已经逐字推过的全部增量 —— 一堆短字符串。按 4000 字截断的逻辑对它完全无效,实测一条这样的记录能让 /api/transcript 回 782 KB,把"别让手机收到大包"的初衷又踩回去。fitSnapshot 现在先过一遍 trimEvent 再压。

  • localStorage 在无痕 / 隐私模式下会【抛异常】(iOS Safari:getItem 照常、setItem 抛 QuotaExceededError),而不只是返回 null。而那句 setItem 恰好夹在 state.sessionId = chosen 和 connect() 之间:一抛,流不开、对话不拉,屏幕全白,用户还看着一切正常(仍然登录着)。现在读写只有 readLocal/writeLocal 两个入口,都不许抛;测试里把 Storage.prototype.setItem 改成抛错来钉住这条。

  • 守卫要放在函数里,不能只放在按钮上。 send() 只靠 sendBtn.disabled 挡重复,而 Ctrl/Cmd+Enter 是直接调 send() 的另一条路 —— 连按两下就是两个 prompt、两轮要花钱的对话。

  • 弹层要给别人让路。 首次说明页是全屏 position:fixed,而提问/审批卡片是流内元素 —— 真有提问时卡片被挡在后面,而说明页里恰好写着"需要你拍板时会弹出卡片"。现在收到提问就先收起说明页。

  • 别把"这个会话打不开"说成"和电脑断开了"。 agent 自己派生的子会话不能用 {kind:'session'} 地址跟随,宿主会回 subagent Sessions require their durable parent address。以前页面把它当普通断线:弹"和电脑断开了 / 你正在用的地址已经失效"(把人引去换地址、重试连接,全是没用的动作),而浏览器还每三秒自动重连一次,连一整晚。现在宿主给这条错误打上 unsupported 标记、把该会话从列表里拿掉,页面据此说人话、关掉那条流、并在下次自动选一个能用的会话。

  • 服务端拒绝必须留下原因。 手机侧永远只显示"发送失败:xxx",所以 prompt 被宿主拒的时候一定要 record() 出哪个会话、什么原因。加这一行之前,用户连点八次只看到 500,日志里也只有一串 -> 500 —— 谁也判断不出是动图、还是子会话。

  • 隧道会掐断"慢而大"的请求,这一条直接决定了上传怎么设计。 实测 Tailscale IPv6 入口:16 MB 的请求回 408、6.7 MB 要 89 秒;手机上行更慢,一整块必然死在边缘,而边缘的错误页不是 JSON,页面只能显示"发送失败 http 403"。所以:图片先在手机上压到长边 1600 + 质量 0.82(几 MB → 几百 KB),视频/大文件切成 384 KB 一片逐片发(每片一个请求,慢链路也就几秒),服务端拼回来再把路径写进任务。顺带:客户端解析响应要先取原文再 JSON.parse,否则永远不知道这个 403 是谁给的。

  • 动图是宿主解不开的那一类。 宿主图片白名单只认 png/jpeg/webp/gif,但多帧图会让它的解码器抛错 → 整条 prompt 被拒。桥的对策是被拒就自动降级:把图存成文件、用路径重发一次(任务照样下去),页面回一句"这张图电脑解不开,已经当文件发过去了"。

  • 背景视频的三件套 + Range。 iOS 上 <video> 要自动播,必须同时 muted + playsinline + autoplay,少一个就是黑屏;而 Safari 会先发 Range: bytes=0-1 试探,服务端只回 200 它可能直接不播 —— 所以背景接口实现了 206 + content-range + ETag(第二次打开走 304,不用在移动网络下把几 MB 再下一遍)。加载失败(格式不认、文件被删)必须整层撤掉:背景是装饰,不能因为它让界面用不了。还有第 11 条那个坑:新配置字段一定要加进 loadConfig() 的重写列表,否则下一次写配置就把它抹了(backgroundFile 就是这么加的)。

  • data: URI 的图片在 iOS 上存不进相册。 用户说"手机看不到电脑给的素材",一半是因为素材只是一行磁盘路径(脚本截的图、image_generate 出的图),页面上就是纯文本;另一半是附件被取成 base64 拼的 data: —— 长按只有"拷贝",没有"存储到照片"。现在:附件走 /api/media(真二进制 URL),磁盘上的图片/视频走 /api/file?path=,页面把路径渲染成缩略图、点开全屏看图,长按即可存相册。

  • 给手机读磁盘文件,白名单要跟着"用户自己的目录"走,而不是"整台电脑"。 /api/file 只发图片/视频,而且必须落在桥认识的根里:工作区注册表、会话 cwd(listSessions() 顺手记下来的那些,比如 D:\自媒体、D:\蹭饭图)、~/.dsh、系统临时目录。别的路径一律 403 —— 这条接口不是"任意文件读取"。实测用户那几个素材目录(D:\自媒体\图片、D:\蹭饭图\...、.zhihu-live\frames)都在白名单内,全部 200。

  • 往折叠区里塞东西等于没塞。 工具卡片的结果区 .tool .body 默认只有 132px 高、超出裁掉(点一下才展开);把素材缩略图放进那个 div,用户依旧什么都看不到。缩略图要放在折叠区的兄弟位置。

  • 排队中的消息在对话里是"不存在"的。 插话走 mode:'queue',要等这一轮跑完才变成一条真正的 user/message —— 中间那几分钟手机上什么都没有,用户以为没发出去。解法是本地回声:发成功后先在对话末尾画一条虚线气泡(带"排队中"和撤回按钮),真消息进来时按文本把回声撤掉(快照里已经有就更要撤,否则重连后会显示两遍)。

  • 撤回靠 rpcId 对齐,不靠文本。 POST /api/prompt 里的 requestId 就是 DSH 在 agent/inbox/spliced 事件里给的 source.rpcId —— 桥把它回给手机,手机据此认出"这条排队项是我刚发的",拿到 inserted[].id 后调 updateQueue({action:{kind:'remove'}}) 真把它拿掉。已经开始处理时宿主回 queue-item-not-found,这时要如实说撤不回来(409 + 人话),别假装成功。

  • 绝不要按名字杀浏览器。 为了清"卡住的旧实例",截图前跑过 Get-Process msedge | Stop-Process -Force —— 那句是按名字杀,把用户正在用的浏览器窗口一起关了;每截一次图就杀一次,现象就是"我的浏览器窗口隔一会儿自己消失",隔壁会话为此盯了一整晚进程树才定位到。规则:只动自己 spawn 出来的那棵树。shot.mjs 现在每次用独立临时 profile + 随机调试端口,收尾 taskkill /PID <自己的 pid> /T /F,跑完删自己那份 profile,从根上不需要杀任何别人的进程。

  • 扩展名会骗人,媒体类型要按内容认。 手机相册里存下来的"动图",文件名可能是 1791380138027.jpeg,内容却是 QuickTime/MP4(头 4 字节是 box 长度、第 5-8 字节是 ftyp)。按扩展名当图片发 → 浏览器解不开 → 背景整层撤掉,用户看到的就是"设了背景却没背景"。mediaTypeOf() 现在先看魔数,认不出才退回扩展名。顺带:ftyp 后面那个 major brand 才是关键(heic/heix/hevc/hevx/heim/heis/mif1/msf1 是图、avif/avis 是图、qt 是视频),只看 ftyp 会把 HEIC 照片当成 video/mp4。

  • "进去慢"是往返次数 × 往返耗时,不是页面复杂。 开机原来是两个请求:/api/bootstrap(列表)再 /api/transcript(对话)。走隧道时一个来回好几秒(实测经旧金山 DERP 8 s),两次就是十几秒 —— 数据量再小也没用,等待是纯等待。现在合成 GET /api/boot?sessionId=...:一次带回列表 + 对话 + 背景 + welcomed。还有第二个来回是自己加回去的:connect() 开长连接时会顺手补拉一次对话(防 SSE 缓冲大包),刚开机那份已经是最新的了 —— 所以 boot 路径记 state.lastPullAt,1.5 s 内不再重复拉。本机 127.0.0.1 实测:可见 327 ms / 内容 500 ms,只发 1 次 /api/boot、0 次 /api/transcript。

  • package.json 不能有 BOM,而 PowerShell 5.1 的 Set-Content -Encoding utf8 会给你加上一个。 改版本号时图快用了一句 PowerShell 写文件,结果 package.json 开头多了 EF BB BF:本机因为 Node 的 JSON.parse 宽容(其实不宽容,是构建工具先读文本再 parse)而没立刻炸,但 tsdown / 加载器解析会直接失败 —— 也就是说推上去的那一版对同学是装不上的。现在版本号这类 JSON 一律用 node 写(fs.readFileSync → 去掉 \uFEFF → JSON.parse 验证 → fs.writeFileSync(..., 'utf8')),插件自己的重载预检也会拦这一条(重载前构建产物预检失败:package.json 带 UTF-8 BOM)。顺带记一笔:.ps1 里写中文必须带 BOM(否则 PowerShell 5.1 按 GBK 读,中文全乱),两条规则正好相反,别记混。

  • "连不上"经常根本不是桥的问题 —— 是 DSH 整个进程没了。 2026-10-08 早上:手机发消息、agent 跑了 51 秒,然后主机上的 DSH 当场退出,手机桥随它一起消失,之后 3 小时 43 分一直连不上(没人重启就永远不会好)。遗言在 ~/.dsh/launcher/server.err.log.prev:ENOENT ... %TEMP%\dsh-subprocess-*\...-stdout.log,来自 @deepseek-ai/dsh-subprocess-local 的 OutputCollector.spillAll —— 命令输出太长要落盘暂存,而那个临时目录被外部清掉了(C 盘只剩 3 GB,系统"清理临时文件"开着),openSync 抛的 ENOENT 发生在 socket 的 data 回调里,没人接得住 → 未捕获异常 → 进程退出。两层都修了:① ~/.dsh/launcher/patches/ 里的补丁让落盘失败降级成"只用内存"(附回归测试);② open-dsh.ps1 把 DSH 的 TEMP 挪到 D:\dsh\tmp,不给外部清理留机会。诊断顺序:手机连不上 → 先看电脑上 DSH 还活着吗(~/.dsh/launcher/server.err.log.prev、dsh-desktop.log)→ 再看桥的日志(~/.dsh/mobile-bridge.log,UTC 时间)→ 最后才怀疑网络/隧道。

  • 编辑带 BOM 的 .ps1 之后要把 BOM 补回去。 改 open-dsh.ps1 加了两行,编辑工具把开头的 EF BB BF 吃掉了 —— 在 PowerShell 7 里看不出问题,但这台机器是 PowerShell 5.1,无 BOM 就按 GBK 读,中文注释里的字节被当成代码,解析器直接报 The Try statement is missing its Catch or Finally block。改完必须验:[System.Management.Automation.Language.Parser]::ParseFile($path,[ref]$null,[ref]$errors) 看 $errors.Count 是不是 0,并确认前 3 字节仍是 EF BB BF。同一次排查还发现 dsh-routing-suite/scripts/install-injector.ps1 本来就没有 BOM,4 个语法错误、根本跑不起来,补上 BOM 后归零。

  • "拖动后又弹回原处、看不到消息"是渲染策略的锅,不是网络。 老 render() 每帧 log.innerHTML = 整段 HTML:① 每次重排都重置滚动位置(手指拖多少抹多少);② 重排把 content-visibility 记住的行高全丢掉 —— 没渲染过的行只能用 contain-intrinsic-size 估算,于是 scrollHeight 是说谎的,滚到那儿行高突然变大,视口内容被顶回去。修法三条:① 增量渲染:每条内容带稳定 key(条目序号 + 类型),比对出"前缀没变"就只重排差异之后那一段(keep > 0 走 renderTail,只有整片换掉才 renderAll);② 拖动期间冻结渲染:touchstart/touchmove 置冻结位,touchend 后还要等滚动事件停下来(每次 scroll 续 160 ms,覆盖 iOS 惯性滚动),松手后补画一次;③ content-visibility 只留给 .tool/.deliver/.think 折叠块,绝不给 .row(消息行正是要滚动浏览的东西)。整段重排时的锚定也从"写回同一个 scrollTop"改成按【离底部多远】锚。真浏览器复核(test/shot.mjs 的 scroll: 行):能滚到顶(top:0)、能到底(bottomGap:0)、行的 contentVisibility 是 visible 而折叠块是 auto、停在中间触发一次真实重取后位置不变(held:true)。

  • 别把"拖动"和"点击"当一回事,也别在拖动中改 DOM。 长按复制(pointerdown 起 550 ms 计时)已经存在,它和"拖动冻结"共用同一批触摸事件:冻结只影响渲染时机,不 preventDefault、不吞事件,所以重排让路和长按复制、点开折叠、点图看大图都不打架。测试里用 new Event('touchstart') 就够了(监听器是 passive 的、不看 touch 数据),这也是 ui.mjs 能覆盖这条路径的原因。

  • "换个对话再回来又要加载"是两次浪费:清空 + 重下整段。 换会话的旧实现是 state.items = [] → 空屏 → 再拉一次整段对话(实测 43 KB gzip,隧道上一个来回好几秒)。两层都改了:

    • 按会话留现场(state.cache,内存里最多 4 份):切走前 rememberSession() 存下 items/tools/cursor,切回来 restoreSession() 立刻铺上 —— 真浏览器实测「切完那一刻画面上就有内容」(immediate=true),不再有空白。
    • 只补新的那几条:客户端把"我看到第几号事件了"(state.cursor)带上,/api/stream?since= 与 /api/transcript?since= 都认这个序号,只回这之后的记录。实测同一次切换 43727 字节 → 84 字节(partial:true 且 0 条记录)。
    • 两条安全底线:① partial:true 的快照只能追加、绝不能清空重建(里面只有新记录,清空就等于把历史抹了)—— 页面走 applyPartialSnapshot();② 服务端 sliceSince() 只在 since 落在本份快照的序号范围内时才裁剪,客户端比能给的还旧(页面放了很久 / 历史被裁过 / 被压缩重排过)就老实回整段(宁可多发,绝不能少发)。乱写的 since(abc、-1)一律当没带。
    • 量法:node test/switch-bench.mjs <url> <pin>(真浏览器 + 真桥)报"切到没打开过的会话多久有内容 / 切回来是不是那一刻就有 / 这次切换下载了多少字节"。本机 127.0.0.1 实测:切回来 immediate=true、0 次整段下载。
  • 排队中的插话有两个"看不见"和一个"改不了",都要治。 ① 手机原来只画自己发的那条回声(靠 rpcId 认领 agent/inbox/spliced 里的 inserted),电脑上插的话手机上完全看不到 —— 要等这一轮跑完、真消息进了对话才突然冒出来,用户以为"电脑上插的没生效"。现在认不出来的那些条目也画出来,并标清「电脑/别处发来的」(remote: true)。⚠️ 同一条 inbox/spliced 会在重连/快照重放时被再喂一遍(快照是重放不是增量),所以按 itemId 去重,否则会冒出好几条一样的。② 「修改」按钮:把原文放回输入框让用户在原文上改,不必重打一遍;顺序必须是先撤回、再把文字放回去 —— 反过来的话用户改完一发,原来那条还在队列里照跑,同一句话发两遍还多花一轮钱。撤不回来(409,已经开始处理)时文字照样放回去,但必须明说"改完再发会多出一条"(recallPending(entry, 'edit') 负责这句话),并且把按钮收掉。③ 事件只给了 removedCount、没给"删的是哪几条":按最老的先删(撤回通常一次一条),剩下的靠"真消息进了对话"和十分钟兜底清掉。

  • "每次从别的窗口点回来都要重新加载"是三层原因,分开治。 iOS 的 PWA 切走之后很可能被系统整个回收,回来就是一次冷启动(内存缓存全丢);于是又走一遍加载页、又重下整段对话;而加载页的进度条是"到一步跳一格",数字长时间不动,看着就是死了。

    • 对话落到手机上:saveTranscriptSnapshot()(IndexedDB,dshm-media 库 version 2 加了 transcripts;写不进去退到 localStorage 的 dshm.transcript.<id>,上限 300 KB、最多 200 条)存 items + cursor;visibilitychange=hidden 时立刻存一次(那正是被回收前的最后一刻)。
    • 回来先铺上:boot 第一件事是读本地那份 → render() → 再发 /api/boot?sessionId=…&since=<cursor>(服务端认这个序号,只回新的那几条,通常 0 条)。真浏览器实测:把网络延迟拉到 2 秒,对话在 77 ms 就出现在屏幕上,界面在 2087 ms(一次小请求)显示出来。
    • ⚠️ 缓存只往 DOM 里铺,不提前显示:能不能看要等 /api/boot 的 200(那就是鉴权),否则捡到手机的人不输 PIN 就能读到上一次的对话。退出登录时 clearTranscriptSnapshots() 抹掉本地那份。
    • 进度条一直在动:bootStep() 改成"目标值 + 缓动 + 慢爬"(每秒约 1.2%,封顶目标 +10 且不超过 96),并且下载走 XHR 的 progress(用 fetch 的流拿的是解压后的字节,content-length 是压缩后的,两个口径算出来的百分比会冲过 100% 再倒退)→ 显示"38 / 90 KB · 42%"。window.__dshBoot() 是给测试/诊断看的读数(目标值、显示值、当前这步)。
    • 量法:node test/reopen-bench.mjs <url> <pin>。人眼看不到"卡住"的复现,这个能。
  • 发完消息后"光标钉住视线、怎么滑都弹回原位",是两个原因叠在一起。 ① "算不算在底部"的阈值原来是 120px,而流式输出每帧都重排一次 —— 手指刚往上滑一点就被判成"仍在底部"、下一帧立刻贴回去:滑多少抹多少。阈值收到 24px(宽容只在担心"差几像素"时有用)。② 输入框还聚焦着:iOS 会不停把 caret 滚进可视区,和用户抢滚动条 —— 一发消息键盘还开着,怎么滑都被拽回去。现在手指一碰对话区就 text.blur()(收键盘,也是聊天类 App 的常规行为:往下翻 = 收键盘)。③ 手指按住期间冻结"是否贴底"的判断(touchstart 直接 state.stick = false;冻结中的 scroll 只续冻结、不重算),松手落在底部才恢复跟随 —— 以前每个 scroll 事件都重算一次 atBottom(),等于每滑一点就重新授权"贴回去"。回归测试用假高度 + 夹取 scrollTop(jsdom 不夹取,真浏览器夹取,不夹的话"贴底"会跑出可滚动范围,测出来的数是假的)钉住这三条。

  • "回弹"的根子在"边滚边改 DOM" —— 所以渲染必须按 key 复用节点。 iOS Safari 在滚动容器里删/建节点会把滚动位置复位到改动之前,而流式输出每帧都在改 DOM。老写法只要变化出现在中间,就会把后面的节点(包括底部的本地回声)整段删掉重建,手指底下的位置因此被拽回去;重建还会丢掉 content-visibility 记住的行高、让图片重新解码。现在 reconcile(entries) 按 data-key 认领既有节点:只有内容真的变了的那一个节点才换 innerHTML,其余节点一个字节都不动,顺序变化只是 insertBefore 移动既有节点。配套三条:① setScrollTop() 给自己设的滚动打标记(selfScrolling),别把自己的滚动误当成"用户刚滚过"把跟随锁死;② 用户滚过之后 400ms 内绝不自动滚(userScrolledRecently()),宁可让他自己滑;③ 渲染最快 80ms 一次(流式每秒几十条增量没必要每条都重画,手机上那是最长掉帧的来源)。真浏览器复现工具:node test/scroll-live.mjs <url> <pin> —— 它会开一个临时会话、让它真的流式输出一千多行,然后一边往上滚一边采样 15 秒,报"回弹了几次 / scrollHeight 变动区间 / 最长掉帧"。修复后实测:scrollHeight 从 1.2 KB 长到 43 KB 的过程中 scrollTop 一动不动,回弹 0 次。

  • 不要跟用户抢滚动条,给一个"回到最新"按钮代替。 自动跟随只在"用户自己贴在底部"时才生效;一旦他往上翻,就不再自动拽回(这正是回弹的观感来源),而是在底部浮出 #tonew("↓ 回到最新"),点了才跳回去并恢复跟随。这样即使某个浏览器还有古怪的滚动行为,用户也有明确的出路,而不是被卡在一个位置上。

  • content-visibility 被彻底拿掉了 —— 它是"拿正确性换性能"的典型,而且连性能都没换到。 它让滚出屏幕的块按 contain-intrinsic-size 的估算高度参与 scrollHeight:估算 132px、真实几百像素,用户往上一滚进历史,行高突然变大,视口内容就被顶回去 —— "拖一下又弹回原处"里就有它一份。按 key 复用节点之后它那点收益也不存在了:真浏览器实测(test/scroll-live.mjs,真正的流式输出)去掉它之后最长掉帧从 108ms 降到 61ms。现在页面里一处都不用,ui.mjs 里有一条断言直接钉住这件事(钉的是根因,不是"阈值等于几像素")。