dsh-armory 2.1.0 → 2.2.0
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.en.md +9 -5
- package/README.md +14 -7
- package/lib/client.js +1100 -80
- package/lib/index.js +47 -8
- package/lib/mcp-oauth.js +1 -1
- package/lib/skill-remove.js +125 -0
- package/lib/suites-registry.js +179 -0
- package/lib/tool-ledger.js +180 -11
- package/lib/typert.js +9 -0
- package/lib/wb-awesome-market.js +1 -1
- package/lib/wb-install-state.js +1 -1
- package/lib/wb-market.js +44 -21
- package/lib/wb-registry.js +1 -1
- package/lib/whats-new.js +29 -0
- package/lib/workspace-runtime.js +1 -1
- package/package.json +2 -2
- package/docs/chunk-split-feasibility.md +0 -153
- package/docs/host-proposal-mcp-teardown.md +0 -184
- package/docs/host-proposal-skill-toggle.md +0 -166
- package/docs/prd-awesome-dsh-plugins.md +0 -105
- package/docs/prd-workbuddy-parity.md +0 -128
- package/docs/system-design-awesome.md +0 -269
- package/docs/system_design.md +0 -530
- package/docs/workbuddy-market-gap-analysis.md +0 -197
package/lib/tool-ledger.js
CHANGED
|
@@ -8,10 +8,17 @@
|
|
|
8
8
|
// dsh-forget-session 里踩过的坑),所以必须按 magic 切帧逐帧解压。
|
|
9
9
|
//
|
|
10
10
|
// 两路数据在这里合并成一张账:
|
|
11
|
-
// 1. 会话日志扫描 → 每个工具的调用次数 / 错误数 /
|
|
11
|
+
// 1. 会话日志扫描 → 每个工具的调用次数 / 错误数 / 最后使用时间(窗口内);
|
|
12
12
|
// 2. 当前注册表的 Schema(ctx.tools.schemas())→ 每个工具每次请求占用的上下文 token 估算。
|
|
13
13
|
// 窗口内零调用的 MCP 工具就是「裁缝建议」:它们既不干活,还每轮请求白收上下文税。
|
|
14
14
|
//
|
|
15
|
+
// §6.1 第三路(同一趟扫描,不额外遍历):`request/header` 事件的 `data.header.tools` 里存着
|
|
16
|
+
// **该次请求真实下发给模型的工具清单**。上下文税此前只有「注册表当前快照」这一个口径,它是
|
|
17
|
+
// Schema 长度的纯函数,与时间完全无关 —— 于是用户连着几天看到同一个数字,既无法判断裁缝是否
|
|
18
|
+
// 还在工作,也无法判断「我改注册表之后到底降了没有」。真实下发清单补上的是时间维度:按天采样
|
|
19
|
+
// 就能回答「这份清单这几天变过没有、什么时候变的」,并且和注册表快照互为对照(不一致 = 宿主没
|
|
20
|
+
// 重启 / 新工具尚未生效,这类状态此前完全不可见)。
|
|
21
|
+
//
|
|
15
22
|
// 全程只读:不写任何文件、不碰会话目录内容。
|
|
16
23
|
|
|
17
24
|
import zlib from "node:zlib";
|
|
@@ -45,6 +52,15 @@ const MAX_LOG_BYTES = 64 * 1024 * 1024;
|
|
|
45
52
|
const TEXT_CACHE_MAX_BYTES = 128 * 1024 * 1024;
|
|
46
53
|
const TEXT_CACHE_MAX_ENTRIES = 32;
|
|
47
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 真实下发工具清单的**种类上限**(§6.1)。每种只在首次出现时算一次全量税,超出后不再新增、
|
|
57
|
+
* 只把 `wireTaxTruncated` 置真并交给界面上屏。设这道闸是因为「每次请求清单都不一样」这种病态
|
|
58
|
+
* 日志会让 wireTaxByKey 无界增长,而那恰恰是要靠趋势线暴露的场景——宁可少报几种并明确说「已
|
|
59
|
+
* 截断」,也不能让只读账本把宿主主进程的内存吃掉。
|
|
60
|
+
*/
|
|
61
|
+
const WIRE_TAX_MAX_KEYS = 64;
|
|
62
|
+
|
|
63
|
+
|
|
48
64
|
/** 把字节数格式化成 MB 文案,只用于诊断信息。 */
|
|
49
65
|
function formatMB(bytes) {
|
|
50
66
|
return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
|
@@ -162,6 +178,59 @@ function estimateTokens(text) {
|
|
|
162
178
|
return Math.ceil(text.length / 4);
|
|
163
179
|
}
|
|
164
180
|
|
|
181
|
+
/**
|
|
182
|
+
* 单个工具 Schema 投影成「要下发的那段 JSON」后的上下文税。
|
|
183
|
+
*
|
|
184
|
+
* 注册表侧与日志真实下发侧**必须共用这一个投影**:两处各写一遍 `{name, description,
|
|
185
|
+
* parameters}` 时,任何一侧改了键集合或兜底写法,「注册表快照 vs 真实下发」的偏差里就会
|
|
186
|
+
* 混进口径差而不是真差异,那个偏差卡就成了长期误报的假告警。
|
|
187
|
+
* @param {{name: string, description?: string, parameters?: unknown}} tool
|
|
188
|
+
* @returns {number} 估算 token 数。
|
|
189
|
+
*/
|
|
190
|
+
function schemaTokensOf(tool) {
|
|
191
|
+
return estimateTokens(JSON.stringify({ name: tool.name, description: tool.description || "", parameters: tool.parameters ?? null }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 一次请求真实下发的整份工具清单的税(§6.1,口径与 {@link schemaTokensOf} 逐工具一致)。
|
|
196
|
+
* @param {Array<{name?: unknown, description?: unknown, parameters?: unknown}>} tools
|
|
197
|
+
* @returns {{ tokens: number, toolCount: number }}
|
|
198
|
+
*/
|
|
199
|
+
function wireToolsSummary(tools) {
|
|
200
|
+
let tokens = 0;
|
|
201
|
+
let counted = 0;
|
|
202
|
+
for (const tool of tools) {
|
|
203
|
+
if (!tool || typeof tool.name !== "string") continue;
|
|
204
|
+
tokens += schemaTokensOf(tool);
|
|
205
|
+
counted += 1;
|
|
206
|
+
}
|
|
207
|
+
return { tokens, toolCount: counted };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 工具清单的**廉价**内容指纹(§6.1):只读长度与工具名,不做 stringify,所以每个请求事件都算
|
|
212
|
+
* 得起;真正的税计算只在指纹第一次出现时做一次(见 WIRE_TAX_MAX_KEYS)。
|
|
213
|
+
*
|
|
214
|
+
* 精度取舍要说清楚:工具数相同、工具名集合相同、描述总长相同,但**参数结构**不同的两份清单
|
|
215
|
+
* 会撞成同一个指纹,此时后一份沿用前一份的税值。真实改动几乎不可能同时满足这三个条件(改参数
|
|
216
|
+
* 一般会带动描述或增删工具),而撞车时的失真只影响趋势线的颗粒度与 `wireTaxChanges` 的次数,
|
|
217
|
+
* 主口径 `taxTotalTokens` 来自注册表快照、完全不经这里。
|
|
218
|
+
* @param {Array<{name?: unknown, description?: unknown}>} tools
|
|
219
|
+
* @returns {string}
|
|
220
|
+
*/
|
|
221
|
+
function wireToolsFingerprint(tools) {
|
|
222
|
+
let count = 0;
|
|
223
|
+
let descLen = 0;
|
|
224
|
+
let hash = 5381;
|
|
225
|
+
for (const tool of tools) {
|
|
226
|
+
if (!tool || typeof tool.name !== "string") continue;
|
|
227
|
+
count += 1;
|
|
228
|
+
descLen += typeof tool.description === "string" ? tool.description.length : 0;
|
|
229
|
+
for (let i = 0; i < tool.name.length; i++) hash = ((hash * 33) + tool.name.charCodeAt(i)) >>> 0;
|
|
230
|
+
}
|
|
231
|
+
return `${count}:${descLen}:${hash >>> 0}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
165
234
|
function emptyToolRecord() {
|
|
166
235
|
return { calls: 0, results: 0, errors: 0, seenCallIds: new Set(), firstUsedMs: null, lastUsedMs: null };
|
|
167
236
|
}
|
|
@@ -212,10 +281,29 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
212
281
|
let totalErrors = 0;
|
|
213
282
|
let orphanResults = 0;
|
|
214
283
|
let failedFrames = 0;
|
|
284
|
+
// §6.2 无 time 的调用事件:窗口过滤写的是「time 是数字且早于 cutoff 才丢」,所以缺 time
|
|
285
|
+
// 的事件会**无条件计入任何窗口**。当前宿主日志 100% 带数字 time(实测 6542/6542),但格式
|
|
286
|
+
// 一变就会静默发生,而且症状恰好是「切窗口数字不动」——与本次排查的表象一模一样。宁可多一个
|
|
287
|
+
// 恒为 0 的计数器,也不能再让这类失配无声无息。
|
|
288
|
+
let undatedCalls = 0;
|
|
289
|
+
// §6.3 本次真正读到的调用事件时间跨度(**含**早于窗口、因此在下面被 `continue` 掉的那些)。
|
|
290
|
+
// 用它才能解释「近 90 天与近 30 天输出一字不差」:机器上只有 14 天日志时,加长窗口本来
|
|
291
|
+
// 就无从改变结果。别用日志文件 mtime 代替:追加写的 mtime 是「最后写入」而不是「最早内容」。
|
|
292
|
+
let scannedEventsOldestMs = null;
|
|
293
|
+
let scannedEventsNewestMs = null;
|
|
215
294
|
// §5.4 逐日趋势:扫描时顺手按天分桶,不额外遍历日志。
|
|
216
295
|
// 桶只存「窗口内的相对天序号」(0 = 最早一天,windowDays-1 = 今天),
|
|
217
296
|
// 由界面负责渲染;返回绝对日期反而让前端多一次时区换算的机会出错。
|
|
218
297
|
const dayBuckets = new Array(windowDays).fill(0);
|
|
298
|
+
// §6.1 真实下发税的按天采样。`dayTaxKeys` 每个日桶只存**当天最后一次**请求的指纹(数字与
|
|
299
|
+
// 短串,不持有工具数组引用 —— 365 个日桶各留一份 234 项清单会常驻几十 MB,宿主主进程里不
|
|
300
|
+
// 可接受);`wireTaxByKey` 存每个不同清单的税,只在指纹首次出现时算一次,上限 WIRE_TAX_MAX_KEYS
|
|
301
|
+
// 种,超出后置 `wireTaxTruncated` 而不是无界增长。成本与「清单种类数」同阶,与请求数无关。
|
|
302
|
+
const dayTaxKeys = new Array(windowDays); // idx -> { time, fingerprint } | undefined
|
|
303
|
+
const wireTaxByKey = new Map(); // fingerprint -> { firstMs, lastMs, tokens, toolCount }
|
|
304
|
+
let wireTaxTruncated = false;
|
|
305
|
+
|
|
306
|
+
|
|
219
307
|
const dayIndex = (time) => {
|
|
220
308
|
if (typeof time !== "number") return null;
|
|
221
309
|
const offset = now - time;
|
|
@@ -298,11 +386,21 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
298
386
|
continue;
|
|
299
387
|
}
|
|
300
388
|
const time = typeof event.time === "number" ? event.time : null;
|
|
389
|
+
// §6.3 先记时间跨度,再做窗口过滤:被窗口丢掉的事件同样是「本机日志里真实存在过的
|
|
390
|
+
// 调用记录」,界面要用它解释「把窗口从 30 天加到 90 天为什么什么都没变」。
|
|
391
|
+
if (event.type === "tool/call" && time !== null) {
|
|
392
|
+
if (scannedEventsOldestMs === null || time < scannedEventsOldestMs) scannedEventsOldestMs = time;
|
|
393
|
+
if (scannedEventsNewestMs === null || time > scannedEventsNewestMs) scannedEventsNewestMs = time;
|
|
394
|
+
}
|
|
301
395
|
if (time !== null && time < cutoffMs) continue;
|
|
302
396
|
if (event.type === "tool/call") {
|
|
303
397
|
const name = typeof event.data?.name === "string" ? event.data.name : null;
|
|
304
398
|
const callId = typeof event.data?.callId === "string" ? event.data.callId : null;
|
|
305
399
|
if (!name) continue;
|
|
400
|
+
// §6.2 计到账上了但没有 time → 这条落在哪个窗口都无法判定,它已经在**任何**窗口
|
|
401
|
+
// 里被计数(含下面的 1 天窗口)。这是口径失配,必须单独报出去。
|
|
402
|
+
if (time === null) undatedCalls += 1;
|
|
403
|
+
|
|
306
404
|
let record = usage.get(name);
|
|
307
405
|
if (!record) {
|
|
308
406
|
record = emptyToolRecord();
|
|
@@ -342,6 +440,27 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
342
440
|
owner.errors += 1;
|
|
343
441
|
totalErrors += 1;
|
|
344
442
|
}
|
|
443
|
+
} else if (event.type === "request/header") {
|
|
444
|
+
// §6.1 该次请求真实下发给模型的整份工具清单。宿主把 `data.header.tools` 原样
|
|
445
|
+
// 落盘,所以「注册表快照」有了一个可对照的真值:两者不一致就是「改了没生效」。
|
|
446
|
+
const tools = event.data?.header?.tools;
|
|
447
|
+
if (!Array.isArray(tools) || tools.length === 0) continue;
|
|
448
|
+
// 没有 time 的清单快照无法归入日桶,直接丢;这类事件的存在由 undatedCalls
|
|
449
|
+
// 那一路负责显形,两条口径不要混在一起。
|
|
450
|
+
const taxBucket = dayIndex(time);
|
|
451
|
+
if (taxBucket === null) continue;
|
|
452
|
+
const fingerprint = wireToolsFingerprint(tools);
|
|
453
|
+
const known = wireTaxByKey.get(fingerprint);
|
|
454
|
+
if (!known) {
|
|
455
|
+
if (wireTaxByKey.size >= WIRE_TAX_MAX_KEYS) {
|
|
456
|
+
wireTaxTruncated = true;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const summary = wireToolsSummary(tools);
|
|
460
|
+
wireTaxByKey.set(fingerprint, { fingerprint, firstMs: time, lastMs: time, ...summary });
|
|
461
|
+
} else if (time > known.lastMs) known.lastMs = time;
|
|
462
|
+
const dayBest = dayTaxKeys[taxBucket];
|
|
463
|
+
if (!dayBest || time > dayBest.time) dayTaxKeys[taxBucket] = { time, fingerprint };
|
|
345
464
|
}
|
|
346
465
|
}
|
|
347
466
|
if (dirHadCall) sessionsWithCalls += 1;
|
|
@@ -392,15 +511,14 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
392
511
|
errors: 0,
|
|
393
512
|
errorRate: 0,
|
|
394
513
|
lastUsedMs: null,
|
|
395
|
-
schemaTokens:
|
|
514
|
+
schemaTokens: schemaTokensOf(schema),
|
|
396
515
|
serverDisabled: classification.server !== null && disabled.has(classification.server),
|
|
397
516
|
});
|
|
398
517
|
}
|
|
399
518
|
// 有调用记录的工具也补 Schema 税(有 Schema 的才有值)。
|
|
400
519
|
for (const tool of tools) {
|
|
401
520
|
if (tool.schemaTokens === null && schemaByName.has(tool.name)) {
|
|
402
|
-
|
|
403
|
-
tool.schemaTokens = estimateTokens(JSON.stringify({ name: schema.name, description: schema.description || "", parameters: schema.parameters ?? null }));
|
|
521
|
+
tool.schemaTokens = schemaTokensOf(schemaByName.get(tool.name));
|
|
404
522
|
}
|
|
405
523
|
}
|
|
406
524
|
|
|
@@ -410,6 +528,23 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
410
528
|
const taxTotalTokens = tools.reduce((sum, tool) => sum + (tool.schemaTokens ?? 0), 0);
|
|
411
529
|
const deadMcpTaxTokens = deadMcpTools.reduce((sum, tool) => sum + (tool.schemaTokens ?? 0), 0);
|
|
412
530
|
|
|
531
|
+
// §6.1 收尾:把按天采到的指纹换成税值,并挑出「窗口内出现过的不同清单」按首次出现时间排序。
|
|
532
|
+
// 界面要回答的是「这份清单这几天动过没有」,所以清单种类(通常 1~5 种)远比请求条数有意义。
|
|
533
|
+
const wireTaxDaily = new Array(windowDays).fill(0);
|
|
534
|
+
for (let i = 0; i < windowDays; i++) {
|
|
535
|
+
const dayBest = dayTaxKeys[i];
|
|
536
|
+
if (!dayBest) continue;
|
|
537
|
+
const entry = wireTaxByKey.get(dayBest.fingerprint);
|
|
538
|
+
if (entry) wireTaxDaily[i] = entry.tokens;
|
|
539
|
+
}
|
|
540
|
+
const wireTaxSnapshots = [...wireTaxByKey.values()].sort((a, b) => a.firstMs - b.firstMs);
|
|
541
|
+
// latest = 时间最晚那次请求所用的清单(不是「最后一个非空日桶」:日桶按天存当天最后一次,
|
|
542
|
+
// 但同一天里先后的两份清单都记在 wireTaxSnapshots 的 firstMs/lastMs 上,这里取 lastMs 最大者)。
|
|
543
|
+
let wireTaxLatest = null;
|
|
544
|
+
for (const entry of wireTaxSnapshots) {
|
|
545
|
+
if (wireTaxLatest === null || entry.lastMs > wireTaxLatest.atMs) wireTaxLatest = { atMs: entry.lastMs, tokens: entry.tokens, toolCount: entry.toolCount };
|
|
546
|
+
}
|
|
547
|
+
|
|
413
548
|
return {
|
|
414
549
|
windowDays,
|
|
415
550
|
generatedAt: now,
|
|
@@ -427,13 +562,28 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
427
562
|
// 否则那句提示等于没说。取宿主进程自己的版本(不是 package.json 的 engines 声明)。
|
|
428
563
|
nodeVersion: process.versions.node,
|
|
429
564
|
// OPT-19:解压文本缓存的占用快照(见 textCacheStats)。128 MB 常驻占用此前对用户
|
|
430
|
-
//
|
|
431
|
-
//
|
|
565
|
+
// 完全不可见——报告带上它,面板才有数据展示「缓存占了多少 / 装了多少条」。面板那一行
|
|
566
|
+
// 现在是**只读**的:「手动清空」整条动作链已于 2026-10-23 删除(反馈恒错 + 占用不降 +
|
|
567
|
+
// 本机命中率 0%;理由记在 client.js 的 cacheLine 上方与 index.js 的 toolTailor 里,
|
|
568
|
+
// 护栏是 test/tailor-cache-line.mjs)。utilization 仍照常产出,只是界面不再显示——它把
|
|
569
|
+
// 字节占比与条数占比混成一个数,本机恒为 1,摆上屏只会把人推向那个已经不存在的按钮。
|
|
432
570
|
textCache: textCacheStats(),
|
|
433
571
|
totalCalls,
|
|
434
572
|
totalResults,
|
|
435
573
|
totalErrors,
|
|
436
574
|
orphanResults,
|
|
575
|
+
// §6.2 被计入任何窗口但无法定位时间的调用(当前宿主恒为 0;一旦变红就是日志格式失配)。
|
|
576
|
+
undatedCalls,
|
|
577
|
+
// §6.3 本次读到的调用记录跨度。**按窗口变化**:更长的窗口会打开更多日志文件,读到更早的
|
|
578
|
+
// 事件。界面用「spanDays < windowDays」解释为什么加长窗口什么都没改变。
|
|
579
|
+
scannedEventsOldestMs,
|
|
580
|
+
scannedEventsNewestMs,
|
|
581
|
+
// 取整方式由 test/tailor-timeline.mjs §6 钉住:这里**必须**是 round 而不是 ceil。任何事
|
|
582
|
+
// 件的时间戳都严格早于本次扫描的 now(哪怕只早几毫秒),ceil 于是恒定时把「2 天前」印成
|
|
583
|
+
// 「跨 3 天」——正是本功能要消灭的那类「看起来没错、实际误导」的数字。round 的另一半好处
|
|
584
|
+
// 在触发条件:ceil 下 2.0 天前的数据算成 3 天,3 天窗口 `3 > 3` 不成立,归因提示漏报;
|
|
585
|
+
// round 给 2 天,提示正常出现。误差半天的漏报阈值远小于面板的语义颗粒(天)。
|
|
586
|
+
scannedSpanDays: scannedEventsOldestMs === null ? 0 : Math.max(1, Math.round((now - scannedEventsOldestMs) / 86_400_000)),
|
|
437
587
|
toolCount: tools.length,
|
|
438
588
|
mcpToolCount: mcpTools.length,
|
|
439
589
|
deadCount: deadTools.length,
|
|
@@ -443,14 +593,27 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
443
593
|
// §5.4 趋势数据:逐日调用量(下标 0 = 窗口最早一天)。
|
|
444
594
|
// 界面只在「确实有调用」时画 sparkline,全 0 从这里就能看出来,不必前端猜。
|
|
445
595
|
dailyCalls: dayBuckets,
|
|
596
|
+
// §6.1 真实下发税时间线:税值第一次有了「按天」的形状,用户不必再靠「这个数字看着没变」
|
|
597
|
+
// 来猜注册表动没动。0 = 那天没有 request/header 记录(税不可能算成 0,所以 0 就是缺数据)。
|
|
598
|
+
wireTaxDaily,
|
|
599
|
+
wireTaxSnapshots: wireTaxSnapshots.map((entry) => ({ firstMs: entry.firstMs, lastMs: entry.lastMs, tokens: entry.tokens, toolCount: entry.toolCount })),
|
|
600
|
+
wireTaxLatest,
|
|
601
|
+
wireTaxChangeCount: wireTaxByKey.size,
|
|
602
|
+
wireTaxTruncated,
|
|
603
|
+
// 注册表快照与最近一次真实下发的差额(正数 = 注册表比线上更大,例如加了服务器还没生效)。
|
|
604
|
+
// 没有真实下发记录时为 null —— 界面必须把「不知道」和「差 0」分开呈现。
|
|
605
|
+
wireTaxDivergenceTokens: wireTaxLatest === null ? null : taxTotalTokens - wireTaxLatest.tokens,
|
|
446
606
|
tools,
|
|
447
607
|
};
|
|
448
608
|
}
|
|
449
609
|
|
|
450
|
-
// OPT-19:解压文本缓存(OPT-03
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
610
|
+
// OPT-19:解压文本缓存(OPT-03)的**可观测性**。128 MB 上界在长时间运行的宿主里是笔实实在在的
|
|
611
|
+
// 常驻占用,但本模块此前只导出 scanSessionLedger,条数/字节数/新鲜度全关在模块私有作用域里:
|
|
612
|
+
// 用户既看不到占用,也没有任何回收手段,出问题只能重启宿主。下面两个导出只读写模块私有状态——
|
|
613
|
+
// 不读盘、不解压、不新增 RPC 契约。可观测性已经接上面板(cacheLine 那一行,只读);「手动回收」
|
|
614
|
+
// 也接过一次,但因那条链的反馈恒错且实测不降占用,已于 2026-10-23 连界面入口一起删除,
|
|
615
|
+
// clearTextCache 因此暂时没有调用方——它仍是将来改缓存策略(槽位按文件数自适应、或只缓存最近
|
|
616
|
+
// 若干会话)的落点,所以留着。
|
|
454
617
|
|
|
455
618
|
/** utilization 的量化倍数:10^4,即保留 4 位小数(0.0001 精度),避免浮点长尾进入面板文案。 */
|
|
456
619
|
const UTILIZATION_SCALE = 10_000;
|
|
@@ -518,7 +681,13 @@ export function textCacheStats() {
|
|
|
518
681
|
}
|
|
519
682
|
|
|
520
683
|
/**
|
|
521
|
-
*
|
|
684
|
+
* 清空解压文本缓存并复位计数,返回被清掉的内容概要(条数与字节数)。
|
|
685
|
+
*
|
|
686
|
+
* **当前没有调用方**:面板的「清空缓存」按钮与 index.js 的 toolTailor action 分支已于
|
|
687
|
+
* 2026-10-23 一并删除(理由记在 client.js cacheLine 上方)。要把这类动作接回界面,光调这个
|
|
688
|
+
* 函数不够——`textCacheStats()` 里没有 `clearedBytes` 键,宿主必须把本函数的返回值原样放进
|
|
689
|
+
* 报告,否则面板拿不到「释放了多少」,只能落到兜底文案,看上去永远在忙;那正是当初删掉它的
|
|
690
|
+
* 三个理由之一。护栏见 test/tailor-cache-line.mjs。
|
|
522
691
|
*
|
|
523
692
|
* 实现顺序是有意为之——**先取 size 快照 → 再 clear → 最后把计数显式归零**:
|
|
524
693
|
* 1. 不能先 `textCache.clear()` 再取 size / 遍历求和:那时 Map 已空,clearedEntries 与
|
package/lib/typert.js
CHANGED
|
@@ -78,6 +78,15 @@ export const TYPERT = {
|
|
|
78
78
|
mv('listSkills', [payloadParam], 'SkillListResult'),
|
|
79
79
|
mv('skillDetail', [payloadParam], 'SkillDetailResult'),
|
|
80
80
|
mv('revealSkill', [payloadParam], 'NoteResult'),
|
|
81
|
+
// 卸载已安装 skill:永久删除用户级/项目级 skills 根目录内的条目。三处契约缺一即 404,
|
|
82
|
+
// 删除规则全在 lib/skill-remove.js(来源白名单 / 根目录包含性 / 符号链接只删链接)。
|
|
83
|
+
mv('removeSkill', [payloadParam], 'NoteResult'),
|
|
84
|
+
// 自定义套件(Skill 看板的「一组相关 skill 打包引用」):读 = 全量集合,
|
|
85
|
+
// 存 = 整集合覆盖(UI 一次提交右侧全部套件,不做增量 upsert)。数据层在
|
|
86
|
+
// lib/suites-registry.js,id 分配 / 成员去重保序 / 脏数据拒绝都在那边,
|
|
87
|
+
// 契约层与 listSkills 同一宽容度(payload $any)。
|
|
88
|
+
mv('listSkillSuites', [], 'SkillSuiteListResult'),
|
|
89
|
+
mv('saveSkillSuites', [payloadParam], 'SkillSuiteSaveResult'),
|
|
81
90
|
// 工具裁缝(1.7.0 起的宿主方法):此前两端契约双双缺失 = 客户端永远调不到,
|
|
82
91
|
// 面板上一直是 404 ——「工具裁缝无法正常使用」的根因。payload 形状由
|
|
83
92
|
// lib/tool-ledger.js 校验,与 wb* 系列同一宽容度。
|
package/lib/wb-awesome-market.js
CHANGED
package/lib/wb-install-state.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 本地安装状态 + 来源锁(R8)—— ~/.dsh/armory/install-state.json 的读写层。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 职责(对应设计 §8.3,类图 InstallStateStore):
|
|
4
4
|
// - record(item):按 kind+key 定位,已存在则覆盖并刷新 lastUpdated;
|
|
5
5
|
// 首次写入补 installedAt。connector 不记 filesDigest,plugin/skill 记关键文件摘要。
|
|
6
6
|
// - forget(kind, key):删除一条登记,不存在时为无害空操作。
|
package/lib/wb-market.js
CHANGED
|
@@ -420,8 +420,23 @@ export async function wbConnectorCachedCount() {
|
|
|
420
420
|
// 文件:/api/v1/skills/:slug/files?namespace=<handle>[&version=];/file 同形 + &path=
|
|
421
421
|
// namespace 用 namespace.handle(列表项里是对象), 不是 canonicalName。
|
|
422
422
|
const SKILLHUB_SORTS = new Set(["downloads", "installs", "stars", "score"]);
|
|
423
|
-
|
|
424
|
-
|
|
423
|
+
// 2026-10 实测(api.skillhub.cn):`sortBy=downloads` 已不再是可用值——TCP 能连上、
|
|
424
|
+
// 但服务端不回任何字节,一直挂到客户端 AbortSignal 超时;installs / stars / score /
|
|
425
|
+
// updated 仍正常,非法值(download、hot、new…)会立刻 400 而不是挂起。
|
|
426
|
+
// 所以下载量排序不再发给上游,改成本地按当前页排序(见 wbSkillCatalog 尾部)。
|
|
427
|
+
const SKILLHUB_REMOTE_SORTS = new Set(["installs", "stars", "score"]);
|
|
428
|
+
async function skillhubJson(path, { timeoutMs = 15_000 } = {}) {
|
|
429
|
+
let buffer;
|
|
430
|
+
try {
|
|
431
|
+
buffer = await fetchWithLimits(`${SKILLHUB_ORIGIN}${path}`, { timeoutMs });
|
|
432
|
+
} catch (e) {
|
|
433
|
+
// 超时是上游最常见的一种失败(挂起而非 4xx),把 Node 的英文原文换成能看懂的中文。
|
|
434
|
+
const name = e && e.name;
|
|
435
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
436
|
+
throw new Error(`SkillHub 在 ${Math.round(timeoutMs / 1000)} 秒内没有响应,已放弃本次请求;稍后重试即可。`);
|
|
437
|
+
}
|
|
438
|
+
throw e;
|
|
439
|
+
}
|
|
425
440
|
const body = JSON.parse(buffer.toString("utf8"));
|
|
426
441
|
// SkillHub 的业务错误也在 200 里(code !== 0)。
|
|
427
442
|
if (body && typeof body.code === "number" && body.code !== 0) throw new Error(`SkillHub 返回错误:${body.message || body.code}`);
|
|
@@ -431,13 +446,38 @@ export async function wbSkillCatalog(payload = {}) {
|
|
|
431
446
|
const page = Math.max(1, Math.min(200, Number(payload.page) || 1));
|
|
432
447
|
const pageSize = Math.max(1, Math.min(60, Number(payload.pageSize) || 20));
|
|
433
448
|
const sort = SKILLHUB_SORTS.has(payload.sort) ? payload.sort : "downloads";
|
|
434
|
-
const
|
|
449
|
+
const remoteSort = SKILLHUB_REMOTE_SORTS.has(sort) ? sort : "";
|
|
450
|
+
// 只把上游确认可用的排序发出去:`sortBy=downloads` 会挂起(见 SKILLHUB_REMOTE_SORTS)。
|
|
451
|
+
// 不带 sortBy 时上游按自己的默认序返回,参数缺失不会 400(已实测)。
|
|
452
|
+
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
|
453
|
+
if (remoteSort) params.set("sortBy", remoteSort);
|
|
435
454
|
const keyword = String(payload.keyword || "").trim();
|
|
436
455
|
if (keyword) params.set("keyword", keyword.slice(0, 64));
|
|
437
456
|
const category = String(payload.category || "").trim();
|
|
438
457
|
if (category) params.set("category", category.slice(0, 64));
|
|
439
458
|
const body = await skillhubJson(`/api/skills?${params.toString()}`);
|
|
440
459
|
const skills = Array.isArray(body?.data?.skills) ? body.data.skills : [];
|
|
460
|
+
const rows = skills.map((skill) => ({
|
|
461
|
+
slug: String(skill.slug || ""),
|
|
462
|
+
name: String(skill.name || skill.slug || ""),
|
|
463
|
+
namespace: String(skill.namespace?.handle || ""),
|
|
464
|
+
canonical: String(skill.namespace?.canonicalName || ""),
|
|
465
|
+
description: String(skill.description_zh || skill.description || ""),
|
|
466
|
+
descriptionEn: String(skill.description_en || ""),
|
|
467
|
+
downloads: Number(skill.downloads) || 0,
|
|
468
|
+
installs: Number(skill.installs) || 0,
|
|
469
|
+
stars: Number(skill.stars) || 0,
|
|
470
|
+
score: Number(skill.score) || 0,
|
|
471
|
+
version: String(skill.version || ""),
|
|
472
|
+
verified: !!skill.verified,
|
|
473
|
+
updatedAt: Number(skill.updated_at) || 0,
|
|
474
|
+
category: String(skill.category || ""),
|
|
475
|
+
tags: Array.isArray(skill.tags) ? skill.tags.slice(0, 6).map(String) : [],
|
|
476
|
+
upstreamUrl: String(skill.upstream_url || skill.homepage || ""),
|
|
477
|
+
})).filter((skill) => skill.slug && skill.namespace);
|
|
478
|
+
// 下载量排序上游已不可用,退化为「当前页内按下载量降序」:跨页顺序仍是上游默认序,
|
|
479
|
+
// 所以这是页内排序而不是全库排序(UI 侧「最多下载」的含义随之收窄,见下)。
|
|
480
|
+
if (!remoteSort) rows.sort((a, b) => b.downloads - a.downloads);
|
|
441
481
|
return {
|
|
442
482
|
page,
|
|
443
483
|
pageSize,
|
|
@@ -445,24 +485,7 @@ export async function wbSkillCatalog(payload = {}) {
|
|
|
445
485
|
keyword,
|
|
446
486
|
category,
|
|
447
487
|
total: Number(body?.data?.total) || 0,
|
|
448
|
-
skills:
|
|
449
|
-
slug: String(skill.slug || ""),
|
|
450
|
-
name: String(skill.name || skill.slug || ""),
|
|
451
|
-
namespace: String(skill.namespace?.handle || ""),
|
|
452
|
-
canonical: String(skill.namespace?.canonicalName || ""),
|
|
453
|
-
description: String(skill.description_zh || skill.description || ""),
|
|
454
|
-
descriptionEn: String(skill.description_en || ""),
|
|
455
|
-
downloads: Number(skill.downloads) || 0,
|
|
456
|
-
installs: Number(skill.installs) || 0,
|
|
457
|
-
stars: Number(skill.stars) || 0,
|
|
458
|
-
score: Number(skill.score) || 0,
|
|
459
|
-
version: String(skill.version || ""),
|
|
460
|
-
verified: !!skill.verified,
|
|
461
|
-
updatedAt: Number(skill.updated_at) || 0,
|
|
462
|
-
category: String(skill.category || ""),
|
|
463
|
-
tags: Array.isArray(skill.tags) ? skill.tags.slice(0, 6).map(String) : [],
|
|
464
|
-
upstreamUrl: String(skill.upstream_url || skill.homepage || ""),
|
|
465
|
-
})).filter((skill) => skill.slug && skill.namespace),
|
|
488
|
+
skills: rows,
|
|
466
489
|
};
|
|
467
490
|
}
|
|
468
491
|
export async function wbSkillDetail(payload = {}) {
|
package/lib/wb-registry.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 多市场注册表(R4)—— ~/.dsh/armory/marketplaces.json 的读写层。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 职责(对应设计 §8.3,类图 MarketRegistry):
|
|
4
4
|
// - 注册表文件不存在或损坏时,回落到**内置默认两条**:连接器源(与 lib/wb-market.js:27
|
|
5
5
|
// 的 CONNECTOR_ZIP_URL 保持同一现值,禁止在此发明第二处硬编码真相)与
|
|
6
6
|
// codebuddy-plugins-official 插件源;首读时把默认项落盘。
|
package/lib/whats-new.js
CHANGED
|
@@ -387,4 +387,33 @@ export const WHATS_NEW = [
|
|
|
387
387
|
},
|
|
388
388
|
],
|
|
389
389
|
},
|
|
390
|
+
{
|
|
391
|
+
version: '2.2.0',
|
|
392
|
+
items: [
|
|
393
|
+
{
|
|
394
|
+
text: '**自定义套件**:把常一起用的 skill 打包成一套,召唤菜单里点一次,就按成员顺序展开成多个 `/name` 插进草稿。Skill 看板工具栏「套件」里新建 / 编辑:点 + 或把技能拖进来添加,拖拽排序,删除要就地二次确认;保存是整集合覆盖,落 `~/.dsh/armory/suites.json`(staging + rename 原子写)。上限 100 个套件 / 每套 40 个成员',
|
|
395
|
+
where: '技能 →「套件」;输入框召唤菜单 →「技能」→ 套件分组',
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
text: '**技能看板能卸载了**:用户级 / 项目级目录里的 skill 可在详情里直接卸载(就地二次确认,永久删除、不进回收站);随 DSH 分发、运行时注册与自定义目录的 skill 仍只读。卸载走本插件的 `removeSkill`,带来源白名单、根目录包含性、符号链接只删链接等校验',
|
|
399
|
+
where: '技能 → 任一本机 skill → 详情',
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
text: '**装 / 卸即时刷新**:在市场里安装或卸载技能后,顶部 **Skill** 数量角标与技能看板列表立即对齐新集合,不用切页或重开面板;异步重取带序号守卫,快速连点也不会被旧回包回滚',
|
|
403
|
+
where: '市场 ↔ 技能',
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
text: '**工具裁缝补上时间维度**:按天采样 `request/header` 里**真实下发**的工具清单,算出线上真实税,并给出「窗口内清单变过几次」「最近一次变化时间」;与注册表快照不一致时直接说明是「改了没生效」还是「线上多发了工具」。本机日志跨度短于所选窗口、或有调用缺时间戳时,也会在说明栏明示',
|
|
407
|
+
where: '工具裁缝 → 指标卡下方的税趋势',
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
text: '**SkillHub「下载量」排序不再挂起**:上游 `sortBy=downloads` 现在收下请求却不回包,插件改为不把它发给上游、在当前页内按下载量降序;其余排序照常走远程。请求 15 秒无响应即放弃,并把 Node 的英文超时换成中文说明',
|
|
411
|
+
where: '市场 → 技能 → 排序',
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
text: '工具裁缝账本缓存行改为只读:删掉「清空缓存」按钮(实测既不回收空间、也不修任何事,还会一直停在「正在清空缓存…」)',
|
|
415
|
+
where: '工具裁缝 → 账本信息',
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
},
|
|
390
419
|
]
|
package/lib/workspace-runtime.js
CHANGED
|
@@ -310,7 +310,7 @@ function releaseSharedConnection(ctx, entry) {
|
|
|
310
310
|
// 上游 @modelcontextprotocol/client 的 stdio transport 里,`close()` 等的是子进程的 `'close'`
|
|
311
311
|
// 事件;孙进程(npx/uvx 包装器、server 内部的 helper)继承并持有 stdio 管道时,`'close'`
|
|
312
312
|
// 永不触发,回收就永久挂在等待上。既无法从这侧打断,就只能把「已经等了多久、有没有超阈、
|
|
313
|
-
//
|
|
313
|
+
// 上次花了多久」变成可见事实,让人自己决定重启宿主。上游提案的结论见 .github/release-notes/v1.10.0.md。
|
|
314
314
|
//
|
|
315
315
|
// OPT-15:每次 teardown 的耗时落档,按 app root 分桶,桶内按 connKey 只留最近一条。
|
|
316
316
|
// 只留一条的理由:面板要回答的是「这条连接现在还卡不卡」,不是审计历史;留全量会让
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-armory",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & CodeBuddy plugin & awesome-dsh-plugins market installs, MCP OAuth authorization, tool-usage tailor, and profile-backed Host Remote management.",
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — custom skill suites, skill install/uninstall with live refresh, global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & CodeBuddy plugin & awesome-dsh-plugins market installs, MCP OAuth authorization, tool-usage tailor with wire-tax trends, and profile-backed Host Remote management.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/2026heshao/dsh-armory.git"
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
# 6.5 客户端拆包(chunk)可行性核实报告
|
|
2
|
-
|
|
3
|
-
> 结论先行:**技术上可行,已由上游源码验证;但按当前体积测算,收益应重新定义为"降低并行开发冲突面",而不是"减小首屏体积"。**
|
|
4
|
-
> 核实人:主理人 Qi | 时间:2026-10-19 | 对应清单项:§6.5(OPT-23)
|
|
5
|
-
|
|
6
|
-
## 1. 上游协议核实(源码级,非推测)
|
|
7
|
-
|
|
8
|
-
核实对象:`@deepseek-ai/dsh-client-modules` 的浏览器半侧实现
|
|
9
|
-
路径:`D:\桌面\AI Port\DSH plugins\_research\asar-extract\out\dsh\node_modules\@deepseek-ai\dsh-client-modules\lib\client.js`(40247 B)
|
|
10
|
-
|
|
11
|
-
### 1.1 chunk 文件名的硬约束
|
|
12
|
-
|
|
13
|
-
```js
|
|
14
|
-
const CLIENT_CHUNK = /^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/;
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
注册时校验(源码原文):
|
|
18
|
-
|
|
19
|
-
```js
|
|
20
|
-
if (registration.chunk !== void 0 && !CLIENT_CHUNK.test(registration.chunk))
|
|
21
|
-
throw new Error(`client-modules: invalid package-local chunk ${JSON.stringify(registration.chunk)}`);
|
|
22
|
-
const id = registration.chunk === void 0 ? ownerId : chunkId(ownerId, registration.chunk);
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
**含义**:文件名必须形如 `client.<name>.js`,`<name>` 首字符须为字母或数字,其后可含 `[A-Za-z0-9._-]`。
|
|
26
|
-
→ 本插件可用:`client.tailor.js`、`client.expert.js`、`client.market.js`、`client.skill-board.js`、`client.mcp-manager.js`、`client.whats-new.js`、`client.i18n-en.js`、`client.styles.js`。
|
|
27
|
-
|
|
28
|
-
### 1.2 chunk URL 的派生规则
|
|
29
|
-
|
|
30
|
-
```js
|
|
31
|
-
function chunkUrl(row, fileName, rev) {
|
|
32
|
-
const url = atRevision(row.url, rev);
|
|
33
|
-
const resourceStart = url.indexOf("/??");
|
|
34
|
-
const revisionStart = url.indexOf("&rev=", resourceStart + 3);
|
|
35
|
-
if ((resourceStart < 0 || revisionStart < 0 ? void 0
|
|
36
|
-
: url.slice(resourceStart + 3, revisionStart)) !== `${row.id}/client.js`)
|
|
37
|
-
throw new Error(`client-modules: cannot resolve chunk ${JSON.stringify(fileName)} from bundle URL ${url}`);
|
|
38
|
-
return `${url.slice(0, resourceStart)}/${row.id}/${fileName}?${url.slice(revisionStart + 1)}`;
|
|
39
|
-
}
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
**含义**:chunk 从 owner bundle 的 URL 派生,路径为 `<base>/<pluginId>/<chunkFile>?rev=<rev>`。
|
|
43
|
-
chunk 必须与 owner **同目录、同 revision**,没有独立的版本协商。这是"同一次发布"的硬约束。
|
|
44
|
-
|
|
45
|
-
### 1.3 加载与注册流程
|
|
46
|
-
|
|
47
|
-
```js
|
|
48
|
-
async importChunk(ownerId, fileName) {
|
|
49
|
-
const id = chunkId(ownerId, fileName);
|
|
50
|
-
const existing = this.loadCache.get(id);
|
|
51
|
-
if (existing !== void 0) return existing.exports;
|
|
52
|
-
if (!this.factories.has(id)) {
|
|
53
|
-
const generation = this.generations.get(ownerId) ?? 0;
|
|
54
|
-
const row = this.graphRows.get(ownerId);
|
|
55
|
-
if (row === void 0) throw new Error(`client-modules: chunk owner "${ownerId}" is not a boot graph entry`);
|
|
56
|
-
const url = chunkUrl(row, fileName, ...);
|
|
57
|
-
let transport = this.pendingArrival.get(url);
|
|
58
|
-
if (transport === void 0) {
|
|
59
|
-
transport = this.loadBundle(url).finally(() => { this.pendingArrival.delete(url); });
|
|
60
|
-
this.pendingArrival.set(url, transport);
|
|
61
|
-
}
|
|
62
|
-
await transport;
|
|
63
|
-
// generation 变了 = 期间发生过 HMR,丢弃这次结果重来
|
|
64
|
-
if ((this.generations.get(ownerId) ?? 0) !== generation) {
|
|
65
|
-
this.factories.delete(id);
|
|
66
|
-
this.loadCache.delete(id);
|
|
67
|
-
return await this.importChunk(ownerId, fileName);
|
|
68
|
-
}
|
|
69
|
-
if (!this.factories.has(id))
|
|
70
|
-
throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`);
|
|
71
|
-
}
|
|
72
|
-
return this.materialize(id, ownerId).exports;
|
|
73
|
-
}
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
**要点**:
|
|
77
|
-
- 同一个 chunk 的并发请求共享一次传输(`pendingArrival`)。
|
|
78
|
-
- HMR 期间(generation 变化)会丢弃结果并自动重试——**对开发友好**。
|
|
79
|
-
- chunk 必须自己调用 `window.__ModuleLoader__.load({ id: '<ownerId>', chunk: '<fileName>', factory })` 完成注册,否则抛错。
|
|
80
|
-
|
|
81
|
-
### 1.4 是否能在 chunk 里 require 别的相对文件
|
|
82
|
-
|
|
83
|
-
**不能。** 上游 README(中文版)原文:
|
|
84
|
-
|
|
85
|
-
> 经 tsdown 拆分的源码 `import()` 会编译为 `require.async("./client.<name>.js")`;只有执行该表达式时,对应的带版本同级脚本才会到达。
|
|
86
|
-
|
|
87
|
-
且 chunk 只能从 owner 派生 URL,`require.async` 的目标必须是**同级 chunk**,不能是任意相对路径的 ESM 模块。
|
|
88
|
-
→ 这印证了 `lib/client.js:43` 原注释的判断"不能相对 import"对 ES import 成立;但 chunk 通道本身是可行的。
|
|
89
|
-
|
|
90
|
-
### 1.5 同级 chunk 之间能否互相 require
|
|
91
|
-
|
|
92
|
-
从 `importChunk(ownerId, fileName)` 的实现看,`require.async` 由 owner 的 factory 闭包提供(ownerId 固定),因此 **chunk 之间可以互相 `require.async`**,只要都注册在同一 ownerId 下。
|
|
93
|
-
但为避免"chunk 依赖 chunk"的加载顺序地狱,建议仍遵循架构师的不变式:**共享代码只由基座注入**(通过 `mount(surface)` 的参数传递)。
|
|
94
|
-
|
|
95
|
-
## 2. 本插件当前体积实测
|
|
96
|
-
|
|
97
|
-
文件:`lib/client.js`,**506177 B / 7229 行**
|
|
98
|
-
|
|
99
|
-
| 分区 | 字节 | 占比 |
|
|
100
|
-
|---|---|---|
|
|
101
|
-
| `EN_US_MESSAGES` 字典(行 58 起) | 34901 | 6.9% |
|
|
102
|
-
| `MCP_CSS` 样式表 | 94956 | 18.8% |
|
|
103
|
-
| 服务器详情 | 12968 | 2.6% |
|
|
104
|
-
| 新增/编辑弹窗 | 14686 | 2.9% |
|
|
105
|
-
| JSON 导入弹窗 | 11465 | 2.3% |
|
|
106
|
-
| MCP 主面板(McpTab) | 27304 | 5.4% |
|
|
107
|
-
| 工具裁缝(Tool Tailor) | 18818 | 3.7% |
|
|
108
|
-
| Skill 看板 | 11510 | 2.3% |
|
|
109
|
-
| 专家 | 33316 | 6.6% |
|
|
110
|
-
| 输入框召唤菜单 | 22112 | 4.4% |
|
|
111
|
-
| 市场面板 | 32073 | 6.3% |
|
|
112
|
-
| 命令面板(Ctrl+K) | 18193 | 3.6% |
|
|
113
|
-
| RPC 契约表 | 14845 | 2.9% |
|
|
114
|
-
|
|
115
|
-
**七个面板合计约 163326 B(32.3%)**;CSS + 字典合计 129857 B(25.7%)。
|
|
116
|
-
|
|
117
|
-
### 2.1 「100KB 基座」目标的重算
|
|
118
|
-
|
|
119
|
-
若拆出七个面板 chunk(163326 B)+ CSS(94956 B)+ 字典(34901 B),基座剩余约 **213KB**(含契约表、i18n 骨架、共享原子件、入口装配)。距离「100KB」仍差一倍。
|
|
120
|
-
|
|
121
|
-
**这不是拆分方式的问题,而是本插件确实有这么多必要的装配与契约代码。** 强行压到 100KB 只能靠再拆契约表与共享原子件,收益递减且会破坏 chunk 的独立性。
|
|
122
|
-
|
|
123
|
-
### 2.2 拆分真正带来的收益
|
|
124
|
-
|
|
125
|
-
1. **首屏只加载基座**:面板 chunk 按需拉取。用户打开看板时通常只看一个 tab,其余六个面板的 163KB 不必传输与解析。
|
|
126
|
-
2. **CSS 随面板走**:面板私有样式跟着 chunk 加载,基座只留主题令牌与共用原子件。当前 94956 B 的 CSS 大部分是面板私有规则。
|
|
127
|
-
3. **并行开发冲突面**(这是本次实施的真正动机):拆开后,不同面板的改动落在不同文件,不再排队等同一个 500KB 文件的写锁。
|
|
128
|
-
|
|
129
|
-
### 2.3 拆分的主要风险
|
|
130
|
-
|
|
131
|
-
| 风险 | 说明 | 缓解 |
|
|
132
|
-
|---|---|---|
|
|
133
|
-
| chunk 顶层不能有副作用 | 上游明确"模块副作用在物化时运行";CSS 注入若放 chunk 顶层,注册时不会执行 | 每个 chunk 导出 `{ mount, css }`,由基座统一注入 css |
|
|
134
|
-
| 基座必须提供全部共享符号 | chunk 无法 require 别处,只能从 `mount(surface)` 拿 | 在 `docs/chunks.md` 里钉死 surface 契约 |
|
|
135
|
-
| 无 git,回退成本高 | 一次拆错要手工还原 | 每个面板搬迁前建 `_archive` 快照 + 跑一次 run-all |
|
|
136
|
-
| 测试锚点会失效 | `client-i18n.mjs` 词法扫描假定单文件 | 先把扫描器改成多文件聚合,再动拆分 |
|
|
137
|
-
| HMR 期间反复加载 | generation 变化会重试 | 上游已处理,无需额外逻辑 |
|
|
138
|
-
|
|
139
|
-
## 3. 建议的实施顺序(若决定动手)
|
|
140
|
-
|
|
141
|
-
1. **先扩测试**:`test/client-i18n.mjs` 改成多文件扫描(glob `lib/client*.js`),`test/client-chunks.mjs` 新建用于校验 chunk 协议(文件名正则、注册形状、顶层无副作用)。
|
|
142
|
-
2. **提取基座 surface**:把 `React2`/`h`/`t`/`call`/`injectCss`/UI 原子件/常量/hooks 收进一个显式的 surface 对象,主 bundle 先按"自己用自己"跑通(不改任何面板,只改装配方式)。
|
|
143
|
-
3. **先搬一个低风险面板验证通道**:建议 `client.whats-new.js`(约 5KB,依赖最少)。跑通后 run-all 全绿,再继续。
|
|
144
|
-
4. **逐面板搬迁**,每搬一个建快照 + 跑测试。
|
|
145
|
-
5. **最后搬 CSS**:面板私有样式随各自 chunk 走,基座只留令牌与共用件。
|
|
146
|
-
|
|
147
|
-
## 4. 结论与建议
|
|
148
|
-
|
|
149
|
-
- **可行性**:确认可行,协议已由上游源码验证,无未知风险。
|
|
150
|
-
- **是否本次做**:这是一次**纯结构性重构**,不改任何用户可见行为。当前 18 项功能优化已全部落地且测试全绿,此时动拆包会把已验证的稳定状态重新置于风险中。
|
|
151
|
-
- **建议**:作为**独立的 v1.11.0 版本**推进,不与本次 v1.10.0 混在一起发。本次发版在清单里把 6.5 标注为「已核实可行,排入 v1.11.0」。
|
|
152
|
-
|
|
153
|
-
若决定本次就做,最小可行路径是第 3 节的第 1–3 步(测试改造 + surface 提取 + 搬一个面板),可在不动其余面板的前提下验证通道,风险可控。
|