dsh-mobile-plugin 0.5.23 → 0.5.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/knowledge/apps/alipay.md +30 -0
- package/knowledge/apps/android-settings.md +30 -0
- package/knowledge/apps/bilibili.md +30 -0
- package/knowledge/apps/boss-zhipin.md +32 -0
- package/knowledge/apps/douyin.md +32 -0
- package/knowledge/apps/taobao.md +33 -0
- package/knowledge/apps/wechat.md +33 -0
- package/knowledge/apps/weibo.md +31 -0
- package/knowledge/apps/xiaohongshu.md +29 -0
- package/knowledge/apps/zhihu.md +30 -0
- package/knowledge/sites/bilibili.com.md +27 -0
- package/knowledge/sites/github.com.md +27 -0
- package/knowledge/sites/x.com.md +28 -0
- package/knowledge/sites/xiaohongshu.com.md +27 -0
- package/knowledge/sites/youtube.com.md +28 -0
- package/knowledge/sites/zhihu.com.md +27 -0
- package/lib/canvas-app.html +73 -72
- package/lib/client.js +18 -19
- package/lib/impl.js +11 -2
- package/lib/mobile/cli/acp.js +3 -2
- package/lib/mobile/cli/mobile.js +4 -28
- package/lib/mobile/pairing.js +44 -4
- package/lib/mobile/phone/confirm.js +189 -0
- package/lib/mobile/phone/index.js +21 -1
- package/lib/mobile/phone/irreversible.js +146 -0
- package/lib/mobile/phone/pacing.js +173 -0
- package/lib/mobile/phone/route-hint.js +164 -0
- package/lib/mobile/phone/routes-memory.js +133 -0
- package/lib/mobile/phone/routes.js +84 -0
- package/lib/mobile/phone/service.js +163 -13
- package/lib/mobile/phone/tools.js +1190 -62
- package/lib/mobile/phone-knowledge.js +301 -0
- package/lib/mobile/promo-receiver.cjs +77 -0
- package/lib/mobile/schedule.js +64 -7
- package/lib/mobile/sessions.js +40 -5
- package/lib/mobile/sync-inbox-store.js +181 -0
- package/lib/mobile/sync.js +124 -0
- package/lib/mobile/transfer-tickets.js +107 -0
- package/lib/mobile/wechat.js +26 -1
- package/lib/p2p/rendezvous.cjs +106 -0
- package/lib/p2p/session.cjs +455 -0
- package/lib/p2p/wiring.cjs +93 -0
- package/lib/panel/index.html +15 -14
- package/lib/relay/connection-check.js +55 -0
- package/lib/relay/status.js +4 -2
- package/lib/routes/control-plane.js +28 -13
- package/lib/routes/mobile-api.js +55 -1
- package/lib/shared/e2ee-request.js +27 -7
- package/lib/tailcat/host-process.cjs +37 -0
- package/lib/tailcat/session.cjs +42 -5
- package/lib/tailcat/wiring.cjs +25 -4
- package/lib/update-install.cjs +4 -38
- package/package.json +4 -2
package/lib/client.js
CHANGED
|
@@ -104,7 +104,7 @@ window.__ModuleLoader__.load({
|
|
|
104
104
|
* 浏览器侧的应用路由一律写成**文档相对**形式(没有前导斜杠),与 DSH 自己的客户端
|
|
105
105
|
* 一致:宿主给 index 注入了 `<base href="./">`,页面挂在哪个前缀下,请求就跟着哪个
|
|
106
106
|
* 前缀走。写成 `/api/...` 这种站点根绝对地址,DSH 被反向代理挂到子路径(例如
|
|
107
|
-
* `/dsh/`)时会直接打到站点根、拿回 404
|
|
107
|
+
* `/dsh/`)时会直接打到站点根、拿回 404。
|
|
108
108
|
*/
|
|
109
109
|
const DESKTOP_VIEW_ENDPOINT = 'api/relay/desktop-view';
|
|
110
110
|
|
|
@@ -739,8 +739,7 @@ window.__ModuleLoader__.load({
|
|
|
739
739
|
}
|
|
740
740
|
if (spec.title) overlay.setAttribute('aria-label', spec.title);
|
|
741
741
|
// 每次重画:读数与按钮都是这一轮的现场,别留上一轮的字。
|
|
742
|
-
// **`children` 是 HTMLCollection,没有 `slice()
|
|
743
|
-
// 没有,控制台一条 TypeError 就把它吞了 —— 假元素的 `children` 是数组,测试里从没红过)。
|
|
742
|
+
// **`children` 是 HTMLCollection,没有 `slice()`**(假元素是数组,测试覆盖不到);
|
|
744
743
|
// 先摊成数组再边删边走。
|
|
745
744
|
for (const child of Array.from(overlay.children || [])) child.remove();
|
|
746
745
|
const card = spec.wide
|
|
@@ -931,8 +930,8 @@ window.__ModuleLoader__.load({
|
|
|
931
930
|
*
|
|
932
931
|
* 为什么不能只读一次 `/status`:那条接口自己也在窗口门后面(`WEB_ACCESS_ROUTES`),
|
|
933
932
|
* 窗口关着时**不是本机**的浏览器只拿得到 423。而客户端原先规定「没地址就不去开窗口」,
|
|
934
|
-
* 两边互相锁死 ——
|
|
935
|
-
*
|
|
933
|
+
* 两边互相锁死 —— 远程用户点了只会看到「网页地址还没就绪」,没有任何顺序能走通。
|
|
934
|
+
* 所以读不到地址时正确的动作不是放弃,而是**先开窗**:
|
|
936
935
|
* 那条开关接口本来就对同源页面豁免,开完再读一次,地址与类别就都有了。
|
|
937
936
|
*
|
|
938
937
|
* 地址优先用宿主给的 `panelUrl`,拿不到再用页面自己的同源地址(见 `sameOriginPanelUrl`)。
|
|
@@ -1043,7 +1042,7 @@ window.__ModuleLoader__.load({
|
|
|
1043
1042
|
/** 还没问到网页地址时的重试间隔。取到就停,不再问。 */
|
|
1044
1043
|
const FOOTER_URL_RETRY_MS = 20000;
|
|
1045
1044
|
/**
|
|
1046
|
-
*
|
|
1045
|
+
* 手机图标(「刘海 + 底条」那一款):24 画布描边,
|
|
1047
1046
|
* 机身 6.2→17.8、圆角 2.8,顶上一条刘海、底下一条家条。比早先那版机身更满一点,
|
|
1048
1047
|
* 因为窄高的形状按名义尺寸画出来会比旁边的方形图标显小。
|
|
1049
1048
|
*/
|
|
@@ -1053,7 +1052,7 @@ window.__ModuleLoader__.load({
|
|
|
1053
1052
|
'M11 19.4h2',
|
|
1054
1053
|
];
|
|
1055
1054
|
/**
|
|
1056
|
-
*
|
|
1055
|
+
* 告警态那颗红点(「整颗转红 + 右上角一颗点」那一款)。
|
|
1057
1056
|
*
|
|
1058
1057
|
* 圆心在机身右上角外侧:左缘压住右上那圈圆角(机身右边到 17.8),两块同色连成一整块,
|
|
1059
1058
|
* 所以不用再抠一圈背景色的缝隙。24 画布内放得下——最右到 22,不越界。
|
|
@@ -1062,11 +1061,11 @@ window.__ModuleLoader__.load({
|
|
|
1062
1061
|
*/
|
|
1063
1062
|
const PHONE_ALERT_DOT = { cx: 18.9, cy: 5, r: 3.1 };
|
|
1064
1063
|
/**
|
|
1065
|
-
* 图标边长:展开那一行 20,窄条里的圆钮 22
|
|
1064
|
+
* 图标边长:展开那一行 20,窄条里的圆钮 22。
|
|
1066
1065
|
*
|
|
1067
1066
|
* 那一行的账号按钮高 44、里面真正画出来的只有 24 的头像,同行其它图标是 16。20 是
|
|
1068
1067
|
* 28 的按钮里还能四边各留 4 像素的上限;窄条那颗放进 36 的圆钮里,用 22。
|
|
1069
|
-
* 别再往回调 16 / 18
|
|
1068
|
+
* 别再往回调 16 / 18:手机图形窄,按名义尺寸画出来比同行的方形图标显小。
|
|
1070
1069
|
* 真要再大,得连按钮一起撑到 30 以上——那一行其它按钮都是 28,会开始抢。
|
|
1071
1070
|
*/
|
|
1072
1071
|
const PHONE_GLYPH_WIDE = 20;
|
|
@@ -1116,8 +1115,8 @@ window.__ModuleLoader__.load({
|
|
|
1116
1115
|
* 把「打开哪一页 / 现在能不能点」同步到元素上。
|
|
1117
1116
|
*
|
|
1118
1117
|
* 没有地址时**不写 href**(没有地址可写,别挂死链),但**点击处理照样挂上**:那颗图标的
|
|
1119
|
-
* 点击会自己去把两小时窗口开出来、再读一次地址(见 `openConfigurationPage
|
|
1120
|
-
*
|
|
1118
|
+
* 点击会自己去把两小时窗口开出来、再读一次地址(见 `openConfigurationPage`)。别把
|
|
1119
|
+
* `onclick` 一起摘掉 —— 摘了就是「点了完全没反应」。
|
|
1121
1120
|
*
|
|
1122
1121
|
* 地址、网络类别和窗口状态都由 effect 往上同步,组件重渲染不会把它丢掉。
|
|
1123
1122
|
*/
|
|
@@ -1172,7 +1171,7 @@ window.__ModuleLoader__.load({
|
|
|
1172
1171
|
* 就画在 `sidebar.footer.action` 自己那一行(居中摞在设置上方)——这一条是物理限制,
|
|
1173
1172
|
* 不是退路。认不出那一行的时候也走这条路,入口不会消失。
|
|
1174
1173
|
*
|
|
1175
|
-
*
|
|
1174
|
+
* 点下去做什么:宿主在 `/status` 上按请求 Host 报一个
|
|
1176
1175
|
* `panelScope`,图标据此分流:
|
|
1177
1176
|
* - `loopback`:人就在这台机器的 DSH 里,控制面本来就认它 —— 普通链接跳转;
|
|
1178
1177
|
* - `private` / `public`:浏览器是从别的机器来的,控制面不认它、面板会回 423,
|
|
@@ -1253,9 +1252,9 @@ window.__ModuleLoader__.load({
|
|
|
1253
1252
|
* 只有**网页**(`http(s)`)才需要这一手:桌面壳里那个函数一定被拒,见 `isWebPage`。
|
|
1254
1253
|
*/
|
|
1255
1254
|
/**
|
|
1256
|
-
*
|
|
1255
|
+
* 点下去做什么。
|
|
1257
1256
|
*
|
|
1258
|
-
*
|
|
1257
|
+
* **点了先在这一页弹一屏**(不是直接跳浏览器):用户遇到问题时最缺的不是
|
|
1259
1258
|
* 跳过去,而是「它现在到底哪一环断了」——启动、装载、隧道、旁挂进程、自检全都在那一屏
|
|
1260
1259
|
* 里,比一个打不开的页面有用。跳转本身没取消,挪进了那一屏里的一颗按钮。
|
|
1261
1260
|
*
|
|
@@ -1344,9 +1343,9 @@ window.__ModuleLoader__.load({
|
|
|
1344
1343
|
};
|
|
1345
1344
|
|
|
1346
1345
|
/**
|
|
1347
|
-
*
|
|
1346
|
+
* 第一层:**只有一个简单入口**。
|
|
1348
1347
|
*
|
|
1349
|
-
*
|
|
1348
|
+
* 一上来就整屏读数太重 —— 绝大多数时候人只是想去配置页,或者去看
|
|
1350
1349
|
* 手机为什么连不上。所以这一层只摆两块:左边「进入控制台」(= 原来那个跳转),
|
|
1351
1350
|
* 右边「报错调试」(才进第二层那一屏)。
|
|
1352
1351
|
*/
|
|
@@ -1796,9 +1795,9 @@ window.__ModuleLoader__.load({
|
|
|
1796
1795
|
}
|
|
1797
1796
|
|
|
1798
1797
|
/* ── 设置里的那一项「移动端」───────────────────────────────────────────
|
|
1799
|
-
*
|
|
1800
|
-
*
|
|
1801
|
-
*
|
|
1798
|
+
* 设置里的「移动端」与侧边栏那颗图标进的是同一个页面、走同一条拨号链路(图标是快捷方式,
|
|
1799
|
+
* 这一张是设置里的正规入口):介绍产品时说的入口就是「设置 → 移动端」,只留图标会让从
|
|
1800
|
+
* 网页版进来的人不知道配置在哪。
|
|
1802
1801
|
*
|
|
1803
1802
|
* 刻意只有一张薄卡:界面本体全在网页版里(A3-37),在这里再画一套等于长期维护第二套 UI,
|
|
1804
1803
|
* 而两套迟早会打架。卡片只做两件事——说清配置在哪、把那一页开出来;开不出来就在卡上写原因。
|
package/lib/impl.js
CHANGED
|
@@ -32,7 +32,8 @@ const { lanUrls, advertisedCandidates, phoneCandidates, ipv6Candidates, withManu
|
|
|
32
32
|
// 配对二维码的载荷:面板与终端命令共用一份,拼装在 `./relay/pairing-payload.cjs`。
|
|
33
33
|
const { pairingPayload, pairingText } = require('./relay/pairing-payload.cjs')
|
|
34
34
|
const { resolvePort } = require('./relay/port-allocator.cjs')
|
|
35
|
-
const { mountTailcat } = require('./tailcat/wiring.cjs') // 直连隧道:入口 + 助手会话 + 启停
|
|
35
|
+
const { mountTailcat, chooseTailcatTarget } = require('./tailcat/wiring.cjs') // 直连隧道:入口 + 助手会话 + 启停
|
|
36
|
+
const { mountP2P } = require('./p2p/wiring.cjs') // 点对点隧道:只有助手会话(连接从隧道里进来,不必开回环门)
|
|
36
37
|
const { createPreviewService } = require('./relay/preview.js')
|
|
37
38
|
const { mountTerminal, mountCli } = require('./mobile/services.js')
|
|
38
39
|
// 手机打开「电脑上的网页版」时用的那枚一次性登录令牌(见 mobile/host-web.js)。
|
|
@@ -304,7 +305,9 @@ function apply(ctx) {
|
|
|
304
305
|
const lanPortInUse = () => (state.lanEnabled && effectiveLanPort > 0 ? effectiveLanPort : state.lanPort)
|
|
305
306
|
const advertisedLanPort = () => (state.lanEnabled ? sidecarLifecycle.getAdvertisedLanPort() : state.lanPort) // 对外报地址用实绑端口,见 lifecycle
|
|
306
307
|
// 直连隧道:不配中转也能连的兜底通道;挂不上就只是没这条通道,中转与局域网照旧。
|
|
307
|
-
const tailcat = mountTailcat({ mount, warn, state, saveState: core.saveState, lanPortInUse, hostName: () => state.name, ctx, name })
|
|
308
|
+
const tailcat = mountTailcat({ mount, warn, state, saveState: core.saveState, targetPort: chooseTailcatTarget({ lanEnabled: () => state.lanEnabled, lanPort: lanPortInUse, webPort: () => ctx.webServer.port }), hostName: () => state.name, ctx, name })
|
|
309
|
+
// 点对点隧道:两端打洞直连、数据不经过任何服务器。挂不上就只是没这条通道。
|
|
310
|
+
const p2p = mountP2P({ mount, warn, state, saveState: core.saveState, localPort: lanPortInUse, ctx, name })
|
|
308
311
|
// 每次同步前都跑一次探测,而不是只在启动时探一次:`/api/relay/config` 改了
|
|
309
312
|
// lanPort 之后同样要走这条路(否则新端口被占时又会退回死地址)。并发调用复用
|
|
310
313
|
// 在飞的那次——探测要占一个监听句柄,重复触发没有意义。
|
|
@@ -392,6 +395,8 @@ function apply(ctx) {
|
|
|
392
395
|
getMirrorPush: mirror.getMirrorPush,
|
|
393
396
|
// 直连隧道那一格:手机轮询 `/status` 时据此自己把通道加进来。
|
|
394
397
|
getTailcat: () => (tailcat ? tailcat.status() : null),
|
|
398
|
+
// 点对点隧道那一格:手机轮询 `/status` 时据此把这条通道也算进来。
|
|
399
|
+
getP2P: () => (p2p ? p2p.status() : null),
|
|
395
400
|
})
|
|
396
401
|
function applyServices() {
|
|
397
402
|
void syncSidecar().catch(() => undefined)
|
|
@@ -438,6 +443,8 @@ function apply(ctx) {
|
|
|
438
443
|
pairingPayload,
|
|
439
444
|
// 直连隧道:二维码要多带一段「去这个一次性节点换正式地址」,状态接口要多一行。
|
|
440
445
|
tailcat,
|
|
446
|
+
// 点对点隧道:二维码要多带一段「电脑的公钥与打洞候选」,配对后两端各持一半。
|
|
447
|
+
p2p,
|
|
441
448
|
restartSidecar,
|
|
442
449
|
// 「手机上最近来过没有」——控制面状态里补给面板用(见 ./mobile/activity.cjs)。
|
|
443
450
|
mobileActivity: activity.snapshot,
|
|
@@ -507,6 +514,8 @@ function apply(ctx) {
|
|
|
507
514
|
registerRoute,
|
|
508
515
|
// 直连隧道:已配对的手机走这条拿到自己的入口地址,不用重新扫码。
|
|
509
516
|
tailcat,
|
|
517
|
+
// 点对点隧道:会合那一步(手机报上候选、这里把它拉起来并回自己的候选)。
|
|
518
|
+
p2p,
|
|
510
519
|
// 预览是 WebSocket 升级路由,走的是 webServer 的另一套名字空间;与 registerRoute
|
|
511
520
|
// 一样必须包在 ctx.effect 里,否则插件重载时重复注册会直接抛错。
|
|
512
521
|
registerUpgradeRoute: (route) => mountRoute(route, true),
|
package/lib/mobile/cli/acp.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* 那正是这次不做「逐家适配」的关键 —— 四家 CLI 的差别全被适配器吃掉了,这里只认一种协议。
|
|
8
8
|
*
|
|
9
9
|
* 为什么用动态 `import()`:插件这一层是 CJS(`lib/mobile/package.json`),而 SDK 是纯 ESM。
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* 稳定入口的运行时要 zod(`dist/v1/schema/zod.gen.js` 里 `import * as z from "zod/v4"`),
|
|
11
|
+
* 所以 zod 是我们的**直接依赖**,不是可选 peer —— 靠宿主或 profile 里碰巧有一份,
|
|
12
|
+
* 装到关了自动补 peer 的环境里就是「zod 落在安装闭包之外」。
|
|
12
13
|
*
|
|
13
14
|
* `session/update` 一律走 `onNotification` 自己接,**不用** SDK 的 `ActiveSession`:后者会把
|
|
14
15
|
* 每条更新再排进一个内部队列供 `nextUpdate()` 取,我们从不取,等于给每个会话攒一个只涨不
|
package/lib/mobile/cli/mobile.js
CHANGED
|
@@ -3,34 +3,10 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* `cli-*` 会话的移动端适配层:让一条 CLI 对话走宿主普通会话那一整套接口。
|
|
5
5
|
*
|
|
6
|
-
* CLI
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 1. **请求方向**:`sessionId` 以 `cli-` 开头的,路由层先看一眼再交给这里(见
|
|
11
|
-
* `lib/routes/mobile-api.js`)。`sessionId` 在查询串(GET)或请求体(POST)里,所以
|
|
12
|
-
* 分派只发生一次,不抄任何 handler。
|
|
13
|
-
* 2. **帧方向**:ACP 的块表 → 宿主那套 `session/event` 帧。App 认哪几种、缺哪个就转圈,
|
|
14
|
-
* 是按 `app/src/hooks/streamFrame.ts` 与 `app/src/utils/frameWatermark.ts` 逐条对出来的:
|
|
15
|
-
* `turn/start` / `turn/end` / `user/message` / `assistant/chunk` / `tool/result` /
|
|
16
|
-
* `approval/*` / `session/title` / `session/heartbeat`。
|
|
17
|
-
*
|
|
18
|
-
* 三条刻意的减法(都比着「App 到底靠什么落地」做的,不是省事):
|
|
19
|
-
*
|
|
20
|
-
* - **正文与工具的过程用草稿帧,落定的内容一律走历史。** 草稿(`assistant/chunk`)不需要
|
|
21
|
-
* seq、不会被序门丢掉;一轮结束先发 `turn/end`(reason=completed),再把这一轮每个块
|
|
22
|
-
* 以稳定 id 发一条 `assistant/message`。id 与 `/session/messages` 的行 id 是同一个
|
|
23
|
-
* (块的 id),所以重连 / resync 按 id 合并,不会出现两遍。`assistant/message` 放在
|
|
24
|
-
* `turn/end` **之后**:App 在「当前回合」收尾时会把这条消息当成终局消息再收一次尾,
|
|
25
|
-
* 先发 turn/end 就只是就地更新,不会提前把这一轮判成结束。
|
|
26
|
-
* - **审批卡照抄宿主那一种形状**(帧级 `rpcId` + payload `approvalId`,见 `approvals.js`)。
|
|
27
|
-
* App 只认这种卡;新造一种等于卡片永远不出现。回答仍走 `/approval/respond`,在这一层
|
|
28
|
-
* 按 rpcId 找回 ACP 的挂起项。
|
|
29
|
-
* - **拿不到的字段给合理缺省,不报错。** 模型目录拿不到就回只有一项的清单;统计、队列、
|
|
30
|
-
* 搜索这类一律回空;这条会话在每种接口上都可用,比「主链路通了、点别的报错」重要。
|
|
31
|
-
*
|
|
32
|
-
* 会话本身仍归 `cli/service.js` 托管(进程、块表、审批挂起项都在那儿),这里只拿它的
|
|
33
|
-
* 只读门面。
|
|
6
|
+
* CLI 会话不是宿主会话,而是一条 ACP 子进程(块表在 `cli/blocks.js`),但对手机必须长得跟普通会话一样:侧栏有它、点进去有历史、能发能停能选模型会弹审批卡。所以这一层只做两个方向的翻译、不新造接口:
|
|
7
|
+
* 请求方向:`sessionId` 以 `cli-` 开头时路由层先看一眼再交给这里(见 `lib/routes/mobile-api.js`),分派只发生一次。
|
|
8
|
+
* 帧方向:ACP 块表 → 宿主 `session/event` 帧(帧种类按 App 实际认的对齐)。
|
|
9
|
+
* 三条刻意减法:过程走草稿帧(`assistant/chunk`)、落定内容一律走历史(`assistant/message` 放在 `turn/end` 之后);审批卡照抄宿主形状(帧级 `rpcId` + payload `approvalId`,见 `approvals.js`);拿不到的字段给合理缺省不报错。会话本身仍归 `cli/service.js` 托管。
|
|
34
10
|
*/
|
|
35
11
|
|
|
36
12
|
const fs = require('node:fs')
|
package/lib/mobile/pairing.js
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* 请求体两种形状都认:老 App 的 `{ instanceId?, code, device }` 明文照旧;协议 v2 的
|
|
13
13
|
* `{ instanceId, sealed }` 没有配对码,票据本身就是资格证明,回包同样是密文 —— 里面是
|
|
14
14
|
* token / cookie / 设备凭据,明文回包等于让中转拿到这台设备的长期通行证。
|
|
15
|
+
*
|
|
16
|
+
* 协议 v2 的请求体还多了个可选 `ticketId`(批量同步导入用,见 docs/device-batch-sync.md):
|
|
17
|
+
* 有它就从一次性券表(`mobile/transfer-tickets.js`)取那张券当票据,配成功后 `consume` 作废;
|
|
18
|
+
* 没有就沿用 `state.pairingTicket`(电脑面板二维码那条路,行为不变)。券由导出方手机向目标
|
|
19
|
+
* 电脑申领、塞进二维码,所以票据不再只能是面板上那一张 —— 这是本文件相对旧签名的唯一变化。
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
const { sendJson } = require('../shared/http-util');
|
|
@@ -103,10 +108,11 @@ function readBody(req) {
|
|
|
103
108
|
* @param {(port: number) => Array<object>} [deps.lanCandidates] 直连候选(与 `/connection-info` 同一份)
|
|
104
109
|
* @param {(port: number) => Array<object>} [deps.ipv6Candidates] IPv6 直连候选
|
|
105
110
|
* @param {() => number} [deps.lanPort] 宿主实绑的直连端口(配置端口被占时会换)
|
|
111
|
+
* @param {object} [deps.transferTickets] 一次性券表(transfer-tickets.js);有它才认 `ticketId`
|
|
106
112
|
* @param {(message: string) => void} deps.log
|
|
107
113
|
*/
|
|
108
114
|
function createPairHandler(deps) {
|
|
109
|
-
const { state, core, credentials, pairingApproval, lanCandidates, ipv6Candidates, lanPort, log } = deps;
|
|
115
|
+
const { state, core, credentials, pairingApproval, lanCandidates, ipv6Candidates, lanPort, transferTickets, log } = deps;
|
|
110
116
|
/**
|
|
111
117
|
* 配对成功之后换掉配对码与票据(§5.1)。
|
|
112
118
|
*
|
|
@@ -168,9 +174,34 @@ function createPairHandler(deps) {
|
|
|
168
174
|
let supplied;
|
|
169
175
|
let issuedExtra;
|
|
170
176
|
let pairKey = null;
|
|
177
|
+
// 这次配对若用的是一次性券,记下它的 ticketId,配成功后立刻作废;面板票据那条路留 null
|
|
178
|
+
// (它靠 rotatePairing 换码换票据,不是靠这里 consume)。
|
|
179
|
+
let ticketIdToConsume = null;
|
|
171
180
|
if (isSealedShape) {
|
|
181
|
+
// 票据来源:带 ticketId 就从一次性券表取(批量同步导入);否则沿用面板那张 state 票据。
|
|
182
|
+
const ticketId = typeof body.ticketId === 'string' ? body.ticketId.trim() : '';
|
|
183
|
+
let ticket = state.pairingTicket;
|
|
184
|
+
if (ticketId) {
|
|
185
|
+
ticket = transferTickets && typeof transferTickets.resolve === 'function'
|
|
186
|
+
? transferTickets.resolve(instanceId, ticketId)
|
|
187
|
+
: null;
|
|
188
|
+
// 券不存在 / 过期 / 已用:与「配对码错误」同形拒绝,不给探测空间。
|
|
189
|
+
// 券不存在 / 过期 / 已用:与「配对码错误」不同,这里可以点明 —— 券是不可猜的随机串
|
|
190
|
+
// (不像六位配对码能逐位试),明说「用过或过期,请另一台手机重新导出」不给出探测空间,
|
|
191
|
+
// 又能让批量导入方一眼知道该干什么,而不是对着一句「配对码错误」去重扫。
|
|
192
|
+
if (!ticket) {
|
|
193
|
+
throttle.noteFail();
|
|
194
|
+
sendJson(res, 401, {
|
|
195
|
+
ok: false,
|
|
196
|
+
code: 'transfer-ticket-invalid',
|
|
197
|
+
error: '这个二维码已被使用或已过期,请让导出方那台手机重新导出后再扫',
|
|
198
|
+
});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
ticketIdToConsume = ticketId;
|
|
202
|
+
}
|
|
172
203
|
const opened = openSealedPairRequest({
|
|
173
|
-
ticket
|
|
204
|
+
ticket,
|
|
174
205
|
instanceId,
|
|
175
206
|
sealed,
|
|
176
207
|
claimedInstanceId: typeof body.instanceId === 'string' ? body.instanceId : '',
|
|
@@ -225,8 +256,17 @@ function createPairHandler(deps) {
|
|
|
225
256
|
return;
|
|
226
257
|
}
|
|
227
258
|
|
|
228
|
-
//
|
|
229
|
-
|
|
259
|
+
// 成功签发之后立刻作废资格:
|
|
260
|
+
// · 用一次性券配的 → consume 那张券(同一张配不了第二台手机);面板的码与票据不动,
|
|
261
|
+
// 因为这次配对根本没用到它们、也不该让别人的面板二维码跟着失效。
|
|
262
|
+
// · 面板二维码那条老路 → 换码 + 换票据(谁截了那张图,从这一刻起就没有用了)。
|
|
263
|
+
if (ticketIdToConsume && transferTickets && typeof transferTickets.consume === 'function') {
|
|
264
|
+
try {
|
|
265
|
+
transferTickets.consume(instanceId, ticketIdToConsume);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
log('pair ticket consume failed:', error && error.message);
|
|
268
|
+
}
|
|
269
|
+
} else if (rotatePairing) {
|
|
230
270
|
try {
|
|
231
271
|
rotatePairing();
|
|
232
272
|
} catch (error) {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 付款确认:把「这一下点下去要花钱」送去宿主已有的审批通道,等人点了「同意」再放行。
|
|
5
|
+
*
|
|
6
|
+
* ## 为什么复用审批通道,而不是自己推一张卡
|
|
7
|
+
*
|
|
8
|
+
* 手机上那张确认卡、用户点按钮、结论交回来,这条链在 0.2.x 上已经由审批桥
|
|
9
|
+
* (`mobile/approvals.js`)铺好了:它 `prepend` 了宿主的 `approval/request` 钩子,钩子一响
|
|
10
|
+
* 就把卡推给正在看这条会话的手机,用户点了同意再 `settle` 回钩子的 promise。我们自己再造
|
|
11
|
+
* 一条推卡的路,等于让手机上同时存在两套确认卡、两套回答接口,谁先答都说不清。
|
|
12
|
+
*
|
|
13
|
+
* 所以这里只做一件事:以 `ctx.get('approval')` 的身份发起一次真正的审批请求。宿主的
|
|
14
|
+
* `ApprovalService.request()` 会把这次的 `approval/asked` / `approval/decided` 记进会话日志
|
|
15
|
+
* (审计里就是这么写的),再走一遍 `approval/request` waterfall —— 审批桥在那一步把卡交给
|
|
16
|
+
* 手机。拿回 `allowed-once` 才等于「用户就这一下点了同意」。
|
|
17
|
+
*
|
|
18
|
+
* ## 为什么绝对不会挂死
|
|
19
|
+
*
|
|
20
|
+
* 三处都可能「永远没人答」:手机没在看(卡没有去处)、用户在别处把这一轮取消了、钩子上
|
|
21
|
+
* 下游是个不回答的转发器。前两处各自有出路(下面判 unavailable / 跟着 signal 取消),
|
|
22
|
+
* 第三处靠一道自己的硬期限兜底:到点就回 `unavailable`,且先 `abort()` 让宿主那头把
|
|
23
|
+
* 挂起的审批一起收掉。工具调用挂在这里,模型的整轮就会一直转下去 —— 那比「没确认」
|
|
24
|
+
* 严重得多,所以宁可超时也不要等。
|
|
25
|
+
*
|
|
26
|
+
* ## 三个返回值
|
|
27
|
+
*
|
|
28
|
+
* `allowed-once`(就这一次可以) / `rejected`(问过了,答案是拒绝或取消) /
|
|
29
|
+
* `unavailable`(压根没能问到人)。调用点只认 `allowed-once`,别的一律不执行。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 一次确认最多等多久。
|
|
34
|
+
*
|
|
35
|
+
* 够用户拿起手机看一眼再点同意,又不至于让模型的这一发停在那里。之所以不设「等到有人答
|
|
36
|
+
* 为止」:卡没推到人手上(手机没在看这条会话、用户把 App 切走了)时,等的是一个永远不来
|
|
37
|
+
* 的答案;而一次工具调用挂着一分钟以上,在模型那边就是「卡住了」。
|
|
38
|
+
*/
|
|
39
|
+
const APPROVAL_TIMEOUT_MS = 90_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 这一步要不要人确认。判定仍由 `irreversible.js` 给,这里只挑出付款 / 下单那一类 ——
|
|
43
|
+
* 发送、提交、删除虽然也收不回来,但它们是这条任务本来就该做的事,每一步都拦会让
|
|
44
|
+
* 「让它发条消息」变成一连串确认框。
|
|
45
|
+
*/
|
|
46
|
+
function needsConfirmation(judged) {
|
|
47
|
+
return Boolean(judged && judged.irreversible === true && judged.kind === 'payment');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 宿主的审批服务。`ctx.get()` 而不是写进 inject:这个服务的装配顺序跟手机那几条路由无关,
|
|
52
|
+
* 注入一个当前宿主版本里没有的服务会把整个插件装载拖垮(与 `mobile/host-web.js` 同一口径)。
|
|
53
|
+
*/
|
|
54
|
+
function approvalOf(ctx) {
|
|
55
|
+
if (!ctx) return null;
|
|
56
|
+
try {
|
|
57
|
+
if (typeof ctx.get === 'function') {
|
|
58
|
+
const service = ctx.get('approval');
|
|
59
|
+
if (service && typeof service.request === 'function') return service;
|
|
60
|
+
}
|
|
61
|
+
} catch { /* 服务没装就往下试属性那一种写法 */ }
|
|
62
|
+
const direct = ctx.approval;
|
|
63
|
+
return direct && typeof direct.request === 'function' ? direct : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 这条会话的 agent。审批请求要求请求方带着自己的会话(`ApprovalService.request()` 要往
|
|
68
|
+
* 那条会话里记审计事件,还要看它的 `approval/policy`),拿不到就等于没人能答。
|
|
69
|
+
*
|
|
70
|
+
* 工具层手上一般已经握着这次调用的 agent,优先用它 —— 比按 id 去服务里捞更准,
|
|
71
|
+
* 子代理那条路径上 `agents.get(childSessionId)` 本来就取不到。
|
|
72
|
+
*/
|
|
73
|
+
function agentOf(ctx, sessionId, hint) {
|
|
74
|
+
if (hint && hint.session) return hint;
|
|
75
|
+
if (!ctx || typeof ctx.get !== 'function' || !sessionId) return null;
|
|
76
|
+
try {
|
|
77
|
+
const agents = ctx.get('agents');
|
|
78
|
+
if (agents && typeof agents.get === 'function') return agents.get(sessionId) || null;
|
|
79
|
+
} catch { /* 没这个服务就没有别的路可走 */ }
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 卡片上那句话:做了什么都写进去,用户看着手机就能判断这一下该不该点。 */
|
|
84
|
+
function summaryText(summary, amountText) {
|
|
85
|
+
const parts = [];
|
|
86
|
+
const head = typeof summary === 'string' ? summary.trim().slice(0, 200) : '';
|
|
87
|
+
const amount = typeof amountText === 'string' ? amountText.trim().slice(0, 60) : '';
|
|
88
|
+
if (head) parts.push(head);
|
|
89
|
+
if (amount) parts.push(`金额 ${amount}`);
|
|
90
|
+
return parts.join(';') || '操作手机:这一步涉及付款 / 下单,需要你确认';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 宿主那套结论词表收成一个三选一。取消与拒绝对调用点是一回事:都别执行。 */
|
|
94
|
+
function normalizeOutcome(outcome) {
|
|
95
|
+
if (outcome === 'allowed-once') return 'allowed-once';
|
|
96
|
+
if (outcome === 'rejected' || outcome === 'cancelled') return 'rejected';
|
|
97
|
+
return 'unavailable';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 发起一次确认并等结论。
|
|
102
|
+
*
|
|
103
|
+
* @param {object} ctx 插件上下文(要能 `get('approval')`)
|
|
104
|
+
* @param {object} options
|
|
105
|
+
* @param {string} options.sessionId 这条对话
|
|
106
|
+
* @param {string} [options.summary] 卡片摘要:这一步在做什么
|
|
107
|
+
* @param {string} [options.amountText] 金额一类的字样,有就一起上卡
|
|
108
|
+
* @param {object} [options.agent] 工具层手上这次调用的 agent(有就用,没有按会话捞)
|
|
109
|
+
* @param {string} [options.toolName] 卡片上那个「工具 X」的名字
|
|
110
|
+
* @param {string} [options.callId] 与这一步的工具调用对上,便于手机与桌面对同一张卡
|
|
111
|
+
* @param {AbortSignal} [options.signal] 这一轮工具的取消信号(用户取消整轮时跟着结束)
|
|
112
|
+
* @param {boolean} [options.watching] 有没有手机在看着这条会话;明确为 false 就不必问了
|
|
113
|
+
* @param {number} [options.timeoutMs] 覆盖默认期限:调用方自己的预算比三分钟还短时用它,
|
|
114
|
+
* 确认不该比整条调用活得久
|
|
115
|
+
* @returns {Promise<'allowed-once'|'rejected'|'unavailable'>}
|
|
116
|
+
*/
|
|
117
|
+
async function requestConfirmation(ctx, options) {
|
|
118
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
119
|
+
const sessionId = typeof opts.sessionId === 'string' ? opts.sessionId.trim() : '';
|
|
120
|
+
// 认不出是哪条对话:卡片没有归属,宿主也会因为「没有开着的轮次」拒绝记账。
|
|
121
|
+
if (!sessionId) return 'unavailable';
|
|
122
|
+
// 没有手机在看:卡推不出去,问也白问。如实说「没问到」,别把这一步挂在那儿。
|
|
123
|
+
if (opts.watching === false) return 'unavailable';
|
|
124
|
+
|
|
125
|
+
const approval = approvalOf(ctx);
|
|
126
|
+
if (!approval) return 'unavailable';
|
|
127
|
+
const agent = agentOf(ctx, sessionId, opts.agent);
|
|
128
|
+
if (!agent || !agent.session) return 'unavailable';
|
|
129
|
+
|
|
130
|
+
const reason = summaryText(opts.summary, opts.amountText);
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
const parent = opts.signal;
|
|
133
|
+
const onAbort = () => controller.abort();
|
|
134
|
+
if (parent && typeof parent.addEventListener === 'function') {
|
|
135
|
+
// 工具已经被取消了就别再发一张卡出来:那一轮已经没有人在等这个答案。
|
|
136
|
+
if (parent.aborted) return 'unavailable';
|
|
137
|
+
parent.addEventListener('abort', onAbort, { once: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const waitMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
|
|
141
|
+
? Math.min(APPROVAL_TIMEOUT_MS, Math.round(opts.timeoutMs))
|
|
142
|
+
: APPROVAL_TIMEOUT_MS;
|
|
143
|
+
let timer = null;
|
|
144
|
+
// 硬期限与请求本身赛跑:即便宿主那头不理会 abort,这里也一定在期限内交出结论。
|
|
145
|
+
const deadline = new Promise((resolve) => {
|
|
146
|
+
timer = setTimeout(() => {
|
|
147
|
+
controller.abort();
|
|
148
|
+
resolve('unavailable');
|
|
149
|
+
}, waitMs);
|
|
150
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const asked = Promise.resolve()
|
|
155
|
+
.then(() => approval.request({
|
|
156
|
+
agent,
|
|
157
|
+
...(typeof opts.toolName === 'string' && opts.toolName ? { toolName: opts.toolName } : {}),
|
|
158
|
+
...(typeof opts.callId === 'string' && opts.callId ? { callId: opts.callId } : {}),
|
|
159
|
+
reason,
|
|
160
|
+
// 桌面与手机上的卡片都优先用这句(审批桥的 `displayReasonOf` 认 zh)。
|
|
161
|
+
displayReason: { zh: reason },
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
}))
|
|
164
|
+
// 宿主在「没有开着的轮次」「策略是 never」这些情况下会抛,一律折算成没问到。
|
|
165
|
+
.then((outcome) => normalizeOutcome(outcome), () => 'unavailable');
|
|
166
|
+
return await Promise.race([asked, deadline]);
|
|
167
|
+
} catch {
|
|
168
|
+
return 'unavailable';
|
|
169
|
+
} finally {
|
|
170
|
+
if (timer) clearTimeout(timer);
|
|
171
|
+
if (parent && typeof parent.removeEventListener === 'function') parent.removeEventListener('abort', onAbort);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 确认没拿到时给模型的那句话。两种没拿到的原因要分开说,模型据此决定要不要请用户回来。 */
|
|
176
|
+
function blockedMessage(outcome, what) {
|
|
177
|
+
const step = typeof what === 'string' && what ? what : '这一步';
|
|
178
|
+
if (outcome === 'rejected') {
|
|
179
|
+
return `${step}是付款 / 下单,需要用户确认;用户没有同意(或者这一轮被取消了),所以没有执行。把这件事如实告诉用户,别自己重试。`;
|
|
180
|
+
}
|
|
181
|
+
return `${step}是付款 / 下单,需要用户确认,但刚才没能把确认卡送到用户手上(手机没有在看这条对话,或者确认通道不可用),所以没有执行。请用户回到这条对话、在手机上看着屏幕再试一次;也可以让用户自己点这一下。`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = {
|
|
185
|
+
needsConfirmation,
|
|
186
|
+
requestConfirmation,
|
|
187
|
+
blockedMessage,
|
|
188
|
+
APPROVAL_TIMEOUT_MS,
|
|
189
|
+
};
|
|
@@ -15,8 +15,12 @@
|
|
|
15
15
|
|
|
16
16
|
const { createPhoneService } = require('./service.js');
|
|
17
17
|
const { installPhoneTools } = require('./tools.js');
|
|
18
|
+
const { installRouteHint } = require('./route-hint.js');
|
|
18
19
|
const { listDevices, describeDevice } = require('./routes.js');
|
|
19
20
|
const binding = require('./binding.js');
|
|
21
|
+
const pacing = require('./pacing.js');
|
|
22
|
+
const irreversible = require('./irreversible.js');
|
|
23
|
+
const routesMemory = require('./routes-memory.js');
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* @param {object} ctx 插件上下文
|
|
@@ -52,12 +56,28 @@ function mountPhoneControl(ctx, deps) {
|
|
|
52
56
|
if (!message.includes('without inject')) throw error;
|
|
53
57
|
}
|
|
54
58
|
|
|
59
|
+
// 只读的历史路线查询工具:单独一路注册,不经过 tools.js 那套步数/死循环守卫(它不发动作)。
|
|
60
|
+
// 同一口径:缺 tools 注入的 profile 只少这一条,不要把整块装配掀掉。
|
|
61
|
+
try {
|
|
62
|
+
installRouteHint(ctx);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
const message = error && error.message ? error.message : String(error);
|
|
65
|
+
if (!message.includes('without inject')) throw error;
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
ctx.effect(() => () => service.close(), `${deps.name || 'dsh-mobile-plugin'}: phone control`);
|
|
56
69
|
// 路由表那边要凭据库与实例号来报设备名,而它只拿得到这个服务(装配层只转交一个对象)。
|
|
57
70
|
// 挂在服务上而不是再走一遍依赖注入:多一处注入就多一处能不同步的地方。
|
|
58
71
|
service.credentials = deps.credentials;
|
|
59
72
|
service.instanceId = deps.instanceId;
|
|
73
|
+
// 节奏与不可逆判定是纯函数模块,挂到服务上,工具层拿同一个服务就能顺手取到,
|
|
74
|
+
// 省得再开一路依赖注入 —— 少一处注入就少一处能不同步的地方。
|
|
75
|
+
service.pacing = pacing;
|
|
76
|
+
service.irreversible = irreversible;
|
|
77
|
+
// 成功路线记忆:工具层在结局判定为成功后调 `service.routesMemory.record(...)`,查询走
|
|
78
|
+
// `phone_route_hint`。挂到服务上,与 pacing / irreversible 同一口径,少一路依赖注入。
|
|
79
|
+
service.routesMemory = routesMemory;
|
|
60
80
|
return service;
|
|
61
81
|
}
|
|
62
82
|
|
|
63
|
-
module.exports = { mountPhoneControl, binding };
|
|
83
|
+
module.exports = { mountPhoneControl, binding, pacing, irreversible, routesMemory };
|