dsh-mobile-plugin 0.5.19 → 0.5.21
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/canvas-app.html +441 -0
- package/lib/client.js +358 -28
- package/lib/mobile/attachment-store.js +111 -15
- package/lib/mobile/attachments.js +6 -2
- package/lib/mobile/host-bridge/endpoints.js +7 -1
- package/lib/mobile/local-images.js +1 -1
- package/lib/mobile/magpie-web.js +425 -0
- package/lib/mobile/monitor.js +5 -2
- package/lib/mobile/session-flags.js +61 -8
- package/lib/mobile/session-send.js +13 -13
- package/lib/mobile/stats-ledger.cjs +21 -6
- package/lib/mobile/stats.js +7 -3
- package/lib/panel/index.html +12 -11
- package/lib/routes/canvas-desktop.js +196 -0
- package/lib/routes/control-plane.js +49 -0
- package/lib/routes/mobile-api.js +6 -0
- package/lib/session-cleanup.cjs +22 -7
- package/package.json +1 -1
|
@@ -24,22 +24,69 @@ const questions = require('./questions.js');
|
|
|
24
24
|
* - `pinnedSessionIds`:已置顶的会话。摘要里没有置顶字段,不读这份快照就画不出置顶标记、
|
|
25
25
|
* 也排不到工作区最前。
|
|
26
26
|
*
|
|
27
|
-
* 两个集合合并成一次 RPC:同源同一次快照,而会话列表每 10
|
|
28
|
-
*
|
|
27
|
+
* 两个集合合并成一次 RPC:同源同一次快照,而会话列表每 10 秒重建一次。
|
|
28
|
+
*
|
|
29
|
+
* 取不到时两个集合都回空,另外带一个 `known: false`。调用方据此分两种口径:
|
|
30
|
+
*
|
|
31
|
+
* - **列表**(`/sessions`)按空集合画:宁可多显示几行,也不能因为一次 RPC 抖动让整个侧栏变空。
|
|
32
|
+
* - **提醒**(`/monitor`)不许把「不知道」当成「没有归档」:那样一次抖动就会让已归档的会话
|
|
33
|
+
* 重新进监控视图,被当成刚跑完再响一遍。所以那条路由另外用一本账([archivedSessionIds]),
|
|
34
|
+
* 记着最近一次真读到的归档集合,读不到就沿用它。
|
|
29
35
|
*/
|
|
30
36
|
async function workspaceSessionFlags(ctx) {
|
|
31
37
|
try {
|
|
32
38
|
const resp = await ctx.apiProxy.workspace.list({ rpcId: 'mobile', method: 'workspace.list', payload: {} });
|
|
33
39
|
const value = unwrap(resp).value;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
37
|
-
|
|
40
|
+
// 集合被人当成 Set 传过(宿主与本仓库两个来源形状不一样,dsh-devmap 专门归一化过),
|
|
41
|
+
// 所以数组与 Set 都收:只认 `Array.isArray` 会让那一类宿主上的归档过滤恒失效。
|
|
42
|
+
const idSetOf = (field) => {
|
|
43
|
+
const raw = value && value[field];
|
|
44
|
+
const list = Array.isArray(raw) ? raw : (raw instanceof Set ? [...raw] : null);
|
|
45
|
+
return list ? new Set(list.filter(id => typeof id === 'string')) : null;
|
|
46
|
+
};
|
|
47
|
+
const archived = idSetOf('archivedSessionIds');
|
|
48
|
+
// 判据只看归档那一格:置顶是画图用的,缺了顶多个图钉,不值得让它把归档判断一起拖死。
|
|
49
|
+
const pinned = idSetOf('pinnedSessionIds') ?? new Set();
|
|
50
|
+
if (!archived) return { archived: new Set(), pinned, known: false };
|
|
51
|
+
return { archived, pinned, known: true };
|
|
38
52
|
} catch {
|
|
39
|
-
return { archived: new Set(), pinned: new Set() };
|
|
53
|
+
return { archived: new Set(), pinned: new Set(), known: false };
|
|
40
54
|
}
|
|
41
55
|
}
|
|
42
56
|
|
|
57
|
+
/**
|
|
58
|
+
* `/monitor` 用的归档集合:**只认读到的**,读不到就沿用上一次读到的那一份。
|
|
59
|
+
*
|
|
60
|
+
* 为什么不跟列表一样 fail-open:这条视图的作用是决定「要不要在后台叫醒人」,而它的判据是
|
|
61
|
+
* 「这个会话还在不在清单里」。一次读不到就按空集合画,已归档的会话会重新进清单 —— 原生那边
|
|
62
|
+
* 刚因为它消失而清了基线,于是它成了「第一次见到」,只要那一轮结束在十分钟内就再响一遍
|
|
63
|
+
* (延迟多久取决于宿主的 RPC 超时,反复抖动就会反复响)。列表那边空一下只是多显示几行,
|
|
64
|
+
* 这边空一下是半夜多叮一声,两种代价不是一个量级。
|
|
65
|
+
*
|
|
66
|
+
* 也刻意**不**回 5xx:README 那条规矩是「一拍什么都没取到就退回完整清单」,而这条 RPC
|
|
67
|
+
* 在 0.2.0 宿主要现开 `workspace.follow` 取首帧(8 秒预算),失败率不低 —— 每失败一拍就
|
|
68
|
+
* 让整个后台提醒哑掉,代价比偶尔多滤错一条大得多。沿用旧账的最坏结果是「刚归档的会话还在
|
|
69
|
+
* 清单里挂一会儿」,而它挂着的那些拍,这条会话的 `completed` 还会按已读水位判,不会凭空响。
|
|
70
|
+
*
|
|
71
|
+
* 一本账都没有(进程刚起来就问不到)时退回空集合 —— 那时候原生手上也没有基线,
|
|
72
|
+
* 首拍见到什么都只建基线、不提醒,所以空集合不会凭空造出一次提醒。
|
|
73
|
+
*/
|
|
74
|
+
const archivedSessionIds = { ids: null };
|
|
75
|
+
|
|
76
|
+
async function archivedSessionIdsForMonitor(ctx) {
|
|
77
|
+
const { archived, known } = await workspaceSessionFlags(ctx);
|
|
78
|
+
if (known) {
|
|
79
|
+
archivedSessionIds.ids = archived;
|
|
80
|
+
return archived;
|
|
81
|
+
}
|
|
82
|
+
return archivedSessionIds.ids ?? archived;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 仅供守卫重置进程内那本归档账。 */
|
|
86
|
+
function resetArchivedSessionIdsForTest() {
|
|
87
|
+
archivedSessionIds.ids = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
43
90
|
/**
|
|
44
91
|
* 「这条会话是不是在等人回话」以及等的是什么。
|
|
45
92
|
*
|
|
@@ -72,4 +119,10 @@ function pendingCardOf(sessionId, summary) {
|
|
|
72
119
|
return approvals.pendingCardOf(sessionId) || questions.pendingCardOf(sessionId);
|
|
73
120
|
}
|
|
74
121
|
|
|
75
|
-
module.exports = {
|
|
122
|
+
module.exports = {
|
|
123
|
+
workspaceSessionFlags,
|
|
124
|
+
archivedSessionIdsForMonitor,
|
|
125
|
+
resetArchivedSessionIdsForTest,
|
|
126
|
+
pendingInteractionOf,
|
|
127
|
+
pendingCardOf,
|
|
128
|
+
};
|
|
@@ -283,25 +283,25 @@ async function handleSessionAttachment(ctx, req, res) {
|
|
|
283
283
|
|| typeof body.attachmentId !== 'string' || !body.attachmentId) {
|
|
284
284
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, attachmentId }' } });
|
|
285
285
|
}
|
|
286
|
-
// 直读宿主附件库(2026-10-31):登记过的 id
|
|
287
|
-
//
|
|
288
|
-
// `attachment-store.js` 的 `streamIfRemembered`:不满足就往下走原来的宿主接口。
|
|
286
|
+
// 直读宿主附件库(2026-10-31):登记过的 id 直接从本地盘流给手机,省掉「等宿主把整张读成
|
|
287
|
+
// base64 再返回」那段(宿主主线程忙时手机一个字节都收不到)。回退见 `streamIfRemembered`。
|
|
289
288
|
if (body.raw === true && await attachmentStore.streamIfRemembered(req, res, body.sessionId, body.attachmentId)) return;
|
|
290
|
-
|
|
289
|
+
// 要递一份**和盘上字节对得上**的引用:宿主会逐字段比对类型 / 字节数 / 宽高,占位值会被
|
|
290
|
+
// 挡回 `Stored attachment metadata does not match its reference.`。拼不出来(对象不在)回 404。
|
|
291
|
+
const ref = attachmentStore.refFor(body.sessionId, body.attachmentId);
|
|
292
|
+
if (!ref) return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '附件已不可读' } });
|
|
293
|
+
const { value, error } = unwrap(await callSessions(ctx, 'attachment', { sessionId: body.sessionId, attachment: ref }));
|
|
291
294
|
if (error) return sendJson(res, 400, { ok: false, error });
|
|
292
295
|
const data = value && typeof value.data === 'string' ? value.data : '';
|
|
293
|
-
//
|
|
294
|
-
// 让 App 那边把重试按钮的失败说清楚,而不是转圈到超时。
|
|
296
|
+
// 宿主回空字节=附件已被会话清理掉:如实回 404,别让 App 那边的重试按钮转圈到超时。
|
|
295
297
|
if (!data) return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '附件已不可读' } });
|
|
296
298
|
const attachment = (value && value.attachment) || {};
|
|
297
299
|
const mediaType = typeof attachment.mediaType === 'string' && attachment.mediaType ? attachment.mediaType : 'image/png';
|
|
298
|
-
// `dims: true` =
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
// base64 不进 JS 堆 —— 一张两兆的截图在手机上原本要摊着两兆七的字符串加一份解析副本。
|
|
304
|
-
// **不封包不许走这一档**:那就成了明文传输,正好把这条路本来的意义抹掉,如实回 400。
|
|
300
|
+
// `dims: true` = 只要原始宽高,不要字节:老会话里宿主没存宽高,手机得先按兜底盒占位、等字节
|
|
301
|
+
// 到了再变高(那一行一跳,上面整截就被推走)。回给它的只有两格数字,base64 不出这台电脑。
|
|
302
|
+
// `raw: true` = 要原始字节(2026-10-30):宿主给的仍是 base64,这里解成 Buffer 后按分块信封
|
|
303
|
+
// 流出去,手机那边靠原生解盘口边解边写,整张图的 base64 不进 JS 堆。
|
|
304
|
+
// **不封包不许走这一档**:那就成了明文传输,如实回 400。
|
|
305
305
|
if (body.raw === true) {
|
|
306
306
|
const opened = req && req.__dshE2ee;
|
|
307
307
|
if (!opened || !opened.keys || !opened.req) {
|
|
@@ -170,11 +170,25 @@ function addDays(day, delta) {
|
|
|
170
170
|
/** 范围起点(含);`all` 返回 null,表示不设下限。 */
|
|
171
171
|
function startDayOf(range, now) {
|
|
172
172
|
const today = dayKeyOf(now);
|
|
173
|
+
if (range === 'today') return today;
|
|
174
|
+
if (range === 'yesterday') return addDays(today, -1);
|
|
175
|
+
if (range === '3d') return addDays(today, -2);
|
|
173
176
|
if (range === '7d') return addDays(today, -6);
|
|
174
177
|
if (range === '30d') return addDays(today, -29);
|
|
175
178
|
return null;
|
|
176
179
|
}
|
|
177
180
|
|
|
181
|
+
/**
|
|
182
|
+
* 范围终点(含):只有「昨天」不看今天。
|
|
183
|
+
*
|
|
184
|
+
* 逐日补零、合计、`usageInRange` 都走它。单看昨天却把今天的量算进去,是这一档最容易错的
|
|
185
|
+
* 地方 —— 用户多半正是用着这台机器的时候打开统计页。
|
|
186
|
+
*/
|
|
187
|
+
function endDayOf(range, now) {
|
|
188
|
+
const today = dayKeyOf(now);
|
|
189
|
+
return range === 'yesterday' ? addDays(today, -1) : today;
|
|
190
|
+
}
|
|
191
|
+
|
|
178
192
|
/**
|
|
179
193
|
* `YYYY-MM-DD` → 那天本地零点的毫秒数。
|
|
180
194
|
*
|
|
@@ -501,18 +515,18 @@ function moneyOf(value) {
|
|
|
501
515
|
}
|
|
502
516
|
|
|
503
517
|
/**
|
|
504
|
-
* 一条会话在 [startDay,
|
|
518
|
+
* 一条会话在 [startDay, endDay] 里的用量。
|
|
505
519
|
*
|
|
506
520
|
* `startDay` 为 null(`all`)时直接用记录里的累计值 —— 累计值是权威,而它那份按天用量
|
|
507
521
|
* 只保留最近 `MAX_DAILY_DAYS` 天。
|
|
508
522
|
*/
|
|
509
|
-
function usageInRange(record, startDay,
|
|
523
|
+
function usageInRange(record, startDay, endDay) {
|
|
510
524
|
const billed = record.billed || emptyUsage();
|
|
511
525
|
if (!startDay) return sanitizeUsage(billed);
|
|
512
526
|
const usage = emptyUsage();
|
|
513
527
|
const daily = record.daily || {};
|
|
514
528
|
for (const [day, value] of Object.entries(daily)) {
|
|
515
|
-
if (day < startDay || day >
|
|
529
|
+
if (day < startDay || day > endDay) continue;
|
|
516
530
|
for (const key of USAGE_KEYS) usage[key] += num(value[key]);
|
|
517
531
|
}
|
|
518
532
|
return usage;
|
|
@@ -529,6 +543,7 @@ function usageInRange(record, startDay, today) {
|
|
|
529
543
|
function summarize(ledger, range, now) {
|
|
530
544
|
const today = dayKeyOf(now);
|
|
531
545
|
const startDay = startDayOf(range, now);
|
|
546
|
+
const endDay = endDayOf(range, now);
|
|
532
547
|
const allDays = Object.keys(ledger.days).sort();
|
|
533
548
|
const firstDay = startDay || allDays[0] || today;
|
|
534
549
|
|
|
@@ -544,7 +559,7 @@ function summarize(ledger, range, now) {
|
|
|
544
559
|
requests: num(source.requests),
|
|
545
560
|
turns: num(source.turns),
|
|
546
561
|
});
|
|
547
|
-
if (cursor >=
|
|
562
|
+
if (cursor >= endDay) break;
|
|
548
563
|
}
|
|
549
564
|
|
|
550
565
|
const kpi = {
|
|
@@ -589,7 +604,7 @@ function summarize(ledger, range, now) {
|
|
|
589
604
|
let composition = null;
|
|
590
605
|
let compositionSession = null;
|
|
591
606
|
for (const [sessionId, record] of Object.entries(ledger.sessions)) {
|
|
592
|
-
const usage = usageInRange(record, startDay,
|
|
607
|
+
const usage = usageInRange(record, startDay, endDay);
|
|
593
608
|
if (record.composition && num(record.lastAt) > num(compositionSession && compositionSession.lastAt)) {
|
|
594
609
|
composition = {
|
|
595
610
|
systemTokens: num(record.composition.systemTokens),
|
|
@@ -643,7 +658,7 @@ function summarize(ledger, range, now) {
|
|
|
643
658
|
let costPricedRows = 0;
|
|
644
659
|
for (const bucket of Object.values(ledger.models || {})) {
|
|
645
660
|
if (!bucket || typeof bucket !== 'object') continue;
|
|
646
|
-
const usage = usageInRange(bucket, startDay,
|
|
661
|
+
const usage = usageInRange(bucket, startDay, endDay);
|
|
647
662
|
const billedTokens = billedOf(usage);
|
|
648
663
|
const turns = num(usage.turns);
|
|
649
664
|
// 与卡片同一条规矩:范围内一个 token、一轮都没有的组合不上卡。
|
package/lib/mobile/stats.js
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* GET /api/mobile/v1/stats/overview?range=7d|30d|all[&fresh=1](token)
|
|
4
|
+
* GET /api/mobile/v1/stats/overview?range=today|yesterday|3d|7d|30d|all[&fresh=1](token)
|
|
5
5
|
*
|
|
6
6
|
* 统计页的唯一接口:先采一轮(水位差记账),再把账本按范围折算成页面要的那份。
|
|
7
7
|
*
|
|
8
8
|
* 采集失败**不**让整页报错:账本里已有的数字照样是真的,宁可让用户看到一份偏旧的统计,
|
|
9
9
|
* 也不要为了「这次没采上」把整页变成错误页。要重新采就用 `fresh=1`(下拉刷新那条)。
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* 范围词是 App 与这里共用的一份词汇表(`app/src/types/stats.ts` 的 `StatsRange`):
|
|
12
|
+
* 「今天 / 昨天」是单天窗口,`3d` / `7d` / `30d` 是含今天往前数的天窗,`all` 不设下限。
|
|
13
|
+
* 日历口径只有 `stats-ledger.cjs` 的 `startDayOf` / `endDayOf` 一份,App 不自己算。
|
|
14
|
+
* 认不出来的词一律按 `7d`(老 App 照旧发那三档);**新范围词遇到老插件就会落到这一条**,
|
|
15
|
+
* 所以 App 拿回包里的 `range` 与请求的对不对得上当作「插件较旧」的判据(见 A2-60)。
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
const { sendJson } = require('../http/routing.js');
|
|
15
19
|
const { collectStats, DEFAULT_BUDGET_MS } = require('./stats-collect.cjs');
|
|
16
20
|
const ledger = require('./stats-ledger.cjs');
|
|
17
21
|
|
|
18
|
-
const RANGES = new Set(['7d', '30d', 'all']);
|
|
22
|
+
const RANGES = new Set(['today', 'yesterday', '3d', '7d', '30d', 'all']);
|
|
19
23
|
|
|
20
24
|
function normalizeRange(value) {
|
|
21
25
|
return typeof value === 'string' && RANGES.has(value) ? value : '7d';
|