dsh-mobile-plugin 0.4.4 → 0.4.5
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/lib/impl.js
CHANGED
|
@@ -41,6 +41,9 @@ const { installQuestionBridge } = require('./mobile/questions.js')
|
|
|
41
41
|
// 「手机上最近来过没有」:守卫记、控制面状态读。单独一个模块,装配层只转交。
|
|
42
42
|
const activity = require('./mobile/activity.cjs')
|
|
43
43
|
const { installSessionCleanup } = require('./session-cleanup.cjs')
|
|
44
|
+
// 统计的定期采集:账本在 mobile/stats-ledger.cjs,采集规则在 stats-collect.cjs,定时器在
|
|
45
|
+
// stats-scheduler.cjs —— 装配层只挂一下(按天归属靠的就是这个节奏)。
|
|
46
|
+
const statsScheduler = require('./mobile/stats-scheduler.cjs')
|
|
44
47
|
// 扩展卡的宿主侧:`extension_present` 工具与卡状态账本(见 mobile/extensions.js)。
|
|
45
48
|
const { installExtensions } = require('./mobile/extensions.js')
|
|
46
49
|
const { activeGuard } = require('./host-guard.cjs')
|
|
@@ -345,6 +348,7 @@ function apply(ctx) {
|
|
|
345
348
|
}
|
|
346
349
|
return undefined
|
|
347
350
|
}, `${name}: extension tool`)
|
|
351
|
+
ctx.effect(() => statsScheduler.start(ctx), `${name}: stats collect`)
|
|
348
352
|
|
|
349
353
|
const status = createStatusView({
|
|
350
354
|
state,
|
|
@@ -295,10 +295,23 @@ function projectionState() {
|
|
|
295
295
|
* `next-turn` → `placement: 'queued'`(排队条只认这个值);`next-step` → `'steering'`,
|
|
296
296
|
* 它只是「宿主已经收下了」的回执,不会多画一行。`message.id` 用宿主行自己的 id,App 靠它
|
|
297
297
|
* 与正文去重。
|
|
298
|
+
*
|
|
299
|
+
* **只摊平用户本人说的话。** 宿主把系统注入(后台任务结束、时间/环境上下文、子代理消息、
|
|
300
|
+
* 模型切换提示这些)也当 user 行塞进同一份 inbox,来源标在 `source.kind` 上(不是 `user`)。
|
|
301
|
+
* 那一列在正文里由历史投影按来源丢掉,但队列帧的条目形状**没有 source 这一格**:原样摊平
|
|
302
|
+
* 出去,手机上这些注入就会以「我发出去的气泡」出现(`placement:'steering'` 在 App 侧画的
|
|
303
|
+
* 正是用户气泡)。所以这里按来源筛掉,注入一条都不进帧 —— 它们不需要排队条,也不需要
|
|
304
|
+
* 回执翻转。缺 source 的老宿主按普通用户消息处理,与历史投影同一口径。
|
|
298
305
|
*/
|
|
299
306
|
function queueItems(inbox, ctx) {
|
|
300
307
|
const items = [];
|
|
301
308
|
let dropped = 0;
|
|
309
|
+
/** 这一行是不是用户本人发的(缺 source 视为用户,对齐 history-derive 的口径)。 */
|
|
310
|
+
const fromUser = (message) => {
|
|
311
|
+
const source = message && typeof message === 'object' ? message.source : null;
|
|
312
|
+
if (!source || typeof source !== 'object') return true;
|
|
313
|
+
return source.kind === undefined || source.kind === 'user';
|
|
314
|
+
};
|
|
302
315
|
/**
|
|
303
316
|
* 摊平一组待发行。`placement` 只标语义:`queued` 进排队条,`steering` 只是回执。
|
|
304
317
|
* 没有 id 的行丢弃并计数(理由见下面的注释)。
|
|
@@ -307,6 +320,7 @@ function queueItems(inbox, ctx) {
|
|
|
307
320
|
if (!Array.isArray(rows)) return;
|
|
308
321
|
for (const message of rows) {
|
|
309
322
|
if (!message || typeof message !== 'object') continue;
|
|
323
|
+
if (!fromUser(message)) continue;
|
|
310
324
|
const id = typeof message.id === 'string' && message.id ? message.id : '';
|
|
311
325
|
if (!id) { dropped += 1; continue; } // 没有 id 的行没法被 session.updateQueue 定位,别发出去
|
|
312
326
|
items.push({ id, placement, message: { id, content: message.content } });
|
|
@@ -602,10 +602,16 @@ function createRelayControlHandlers(deps) {
|
|
|
602
602
|
* 一条(时间 + 一句话 + 当时要切过去的地址),不带堆栈 —— 宿主内部路径对手机没用。
|
|
603
603
|
*
|
|
604
604
|
* **不搬堆栈**:台账的 message 只取第一行,截断 300 字。
|
|
605
|
+
*
|
|
606
|
+
* 2026-10-27 起多四格:`host` / `relay` / `sidecar` / `mirror`。手机那个「延迟浮窗」里的
|
|
607
|
+
* 「诊断」块就是把这几格抄成几行人话 —— 用户问「这台电脑到底怎么了」时,光有故障台账
|
|
608
|
+
* 答不了「插件是哪一版、后台服务重启过几回、镜像还推不推得动」。
|
|
609
|
+
* **仍然只读、仍然只进不出**:注册密钥、配对码、`lanSecret`、访问 token 一格都不出现。
|
|
605
610
|
*/
|
|
606
611
|
async function handleRelayDiagnostics(_ctx, _req, res) {
|
|
607
612
|
if (!wired) return unavailable(res);
|
|
608
613
|
const snapshot = typeof status === 'function' ? status() : {};
|
|
614
|
+
const text = (value) => (typeof value === 'string' ? value : '');
|
|
609
615
|
sendJson(res, 200, {
|
|
610
616
|
ok: true,
|
|
611
617
|
faults: faultsView(),
|
|
@@ -620,6 +626,28 @@ function createRelayControlHandlers(deps) {
|
|
|
620
626
|
},
|
|
621
627
|
/** 隧道为什么被拒那几条判断(宿主那边按中转错误文案推出来的)。 */
|
|
622
628
|
tunnel: snapshot?.tunnel ?? null,
|
|
629
|
+
/** 这台电脑上跑着的插件与宿主:版本、平台、网页端口,报障时对方第一句就会问这些。 */
|
|
630
|
+
host: {
|
|
631
|
+
plugin: text(snapshot?.appVersion),
|
|
632
|
+
platform: text(snapshot?.platform),
|
|
633
|
+
arch: text(snapshot?.arch),
|
|
634
|
+
osVersion: text(snapshot?.osVersion),
|
|
635
|
+
webPort: Number.isInteger(snapshot?.localPort) && snapshot.localPort > 0 ? snapshot.localPort : null,
|
|
636
|
+
},
|
|
637
|
+
relay: { enabled: snapshot?.enabled === true },
|
|
638
|
+
/** 后台服务(sidecar):换配置要重启它,重启失败正是「保存成功却连不上」的那一档。 */
|
|
639
|
+
sidecar: {
|
|
640
|
+
state: text(snapshot?.sidecar?.state),
|
|
641
|
+
restarts: Number.isInteger(snapshot?.sidecar?.restarts) ? snapshot.sidecar.restarts : 0,
|
|
642
|
+
restartedAt: Number.isInteger(snapshot?.sidecar?.restartedAt) ? snapshot.sidecar.restartedAt : 0,
|
|
643
|
+
restartError: text(snapshot?.sidecar?.restartError) || null,
|
|
644
|
+
},
|
|
645
|
+
/** 镜像推送:手机上看到的列表为什么停住不动,答案在这三格里。 */
|
|
646
|
+
mirror: {
|
|
647
|
+
pushedAt: Number.isInteger(snapshot?.mirror?.pushedAt) ? snapshot.mirror.pushedAt : 0,
|
|
648
|
+
ok: snapshot?.mirror?.ok === undefined ? null : snapshot.mirror.ok === true,
|
|
649
|
+
error: text(snapshot?.mirror?.error) || null,
|
|
650
|
+
},
|
|
623
651
|
});
|
|
624
652
|
}
|
|
625
653
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「这一发到底进没进」:按 `clientMessageId` 回查会话正文。
|
|
5
|
+
*
|
|
6
|
+
* 为什么要有这一条:手机端有一条**离线发件箱**(见 App 侧 `services/outbox.ts`)——
|
|
7
|
+
* 点发送时没网或者电脑够不着,那条消息先留在手机上、标成「等待网络」,等网回来自动重发。
|
|
8
|
+
* 重发沿用同一个 `clientMessageId`,插件侧本来就会按它去重(`session-cache.js` 的
|
|
9
|
+
* `promptOnceFor`)。但那份去重记录**只在进程内存里**:插件重启过、或者 App 在「宿主
|
|
10
|
+
* 已经收下、还没等到回包」那一刻被杀掉,手机这边就分不清「重发会补上」还是「重发会重复」。
|
|
11
|
+
*
|
|
12
|
+
* 所以这里从**会话日志**里查:日志是持久的,重启也在。查到就是进过了,App 直接把发件箱
|
|
13
|
+
* 那一条划掉;查不到才真的重发。这比让 App 发完再拉一整段历史对账便宜,也比「相信内存里
|
|
14
|
+
* 那份去重表」稳。
|
|
15
|
+
*
|
|
16
|
+
* 只读、只听 token、只看最近这一窗:发件箱里的条目最多躺几天,`maxMessages` 取它够用的
|
|
17
|
+
* 上限;更早的不查 —— 查不到就按「没进」走重发,而重发那一步仍有插件的去重兜着。
|
|
18
|
+
*
|
|
19
|
+
* 不解析附件:这里只认「有没有这条 user 消息」,标题、图片字节都不需要
|
|
20
|
+
* (`deriveMessages` 之外的附件解析是这条端点唯一可能变贵的地方,刻意不走)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const { sendJson } = require('../shared/http-util');
|
|
24
|
+
const { unwrap } = require('./envelope.js');
|
|
25
|
+
const { callSessions } = require('./rpc.js');
|
|
26
|
+
const { deriveMessages } = require('./history-derive.js');
|
|
27
|
+
|
|
28
|
+
/** 一次回查读多少条事件:发件箱的条目只可能出现在最近这几天。 */
|
|
29
|
+
const DELIVERY_HISTORY_WINDOW = 200;
|
|
30
|
+
/** 两个参数的长度上限(与 `clientMessageId` 的生成形状同一个量级)。 */
|
|
31
|
+
const PARAM_MAX = 200;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* GET /api/mobile/v1/session/delivery?sessionId=…&clientMessageId=…(token)
|
|
35
|
+
* → { ok, delivered, seq? }
|
|
36
|
+
*
|
|
37
|
+
* 宿主没有这条会话(被删 / 换了机器)时回 404:那是「查不到」,不是「没送到」——
|
|
38
|
+
* App 据此把发件箱里那条标成失败,而不是永远重试。
|
|
39
|
+
*/
|
|
40
|
+
async function handleSessionDelivery(ctx, req, res) {
|
|
41
|
+
const url = new URL(req.url, 'http://x');
|
|
42
|
+
const sessionId = (url.searchParams.get('sessionId') || '').trim();
|
|
43
|
+
const clientMessageId = (url.searchParams.get('clientMessageId') || '').trim();
|
|
44
|
+
if (!sessionId || !clientMessageId || sessionId.length > PARAM_MAX || clientMessageId.length > PARAM_MAX) {
|
|
45
|
+
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, clientMessageId }' } });
|
|
46
|
+
}
|
|
47
|
+
const { value, error } = unwrap(await callSessions(ctx, 'history', { sessionId, maxMessages: DELIVERY_HISTORY_WINDOW }));
|
|
48
|
+
if (error) {
|
|
49
|
+
const missing = error.code === 'session-not-found';
|
|
50
|
+
return sendJson(res, missing ? 404 : 502, {
|
|
51
|
+
ok: false,
|
|
52
|
+
error: {
|
|
53
|
+
code: typeof error.code === 'string' ? error.code : 'host-call-failed',
|
|
54
|
+
message: typeof error.message === 'string' && error.message ? error.message : '读取会话历史失败',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const events = (value && Array.isArray(value.events)) ? value.events : [];
|
|
59
|
+
const messages = deriveMessages(events);
|
|
60
|
+
const hit = messages.find((message) => message.clientMessageId === clientMessageId);
|
|
61
|
+
sendJson(res, 200, {
|
|
62
|
+
ok: true,
|
|
63
|
+
delivered: Boolean(hit),
|
|
64
|
+
...(hit && typeof hit.seq === 'number' ? { seq: hit.seq } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { handleSessionDelivery, DELIVERY_HISTORY_WINDOW };
|