dsh-m 0.6.1 → 0.8.4
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 +79 -10
- package/README.md +78 -9
- package/docs/DESIGN.md +27 -4
- package/lib/cli.js +39 -8
- package/lib/client.js +1728 -471
- package/lib/core/community-adapter.js +16 -0
- package/lib/core/community.js +3 -2
- package/lib/core/host-api.js +39 -5
- package/lib/core/market.js +141 -24
- package/lib/core/registry-controller.js +27 -0
- package/lib/core/search-relevance.js +110 -0
- package/lib/tools.js +44 -9
- package/package.json +2 -2
- package/registry.json +23 -4
|
@@ -29,6 +29,13 @@ function strArray(v) {
|
|
|
29
29
|
function numOrNull(v) {
|
|
30
30
|
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
31
31
|
}
|
|
32
|
+
/** 非空字符串透传(trim 后),否则 undefined(键不产生)——0.7.0 Task 1 bypass 字段统一口径。 */
|
|
33
|
+
function strOrUndef(v) {
|
|
34
|
+
if (typeof v !== 'string')
|
|
35
|
+
return undefined;
|
|
36
|
+
const s = v.trim();
|
|
37
|
+
return s === '' ? undefined : s;
|
|
38
|
+
}
|
|
32
39
|
/** npm 形状成立(非空字符串且满足 npm 包名形状)→ source: npm。 */
|
|
33
40
|
function npmShape(raw) {
|
|
34
41
|
if (typeof raw.npm !== 'string')
|
|
@@ -167,6 +174,15 @@ export function adaptCommunityCatalog(catalog) {
|
|
|
167
174
|
capabilityRedLines: strArray(raw.capabilityRedLines),
|
|
168
175
|
screenshots: strArray(raw.screenshots),
|
|
169
176
|
descriptionEn: en,
|
|
177
|
+
owner,
|
|
178
|
+
...(strOrUndef(raw.added) !== undefined ? { added: strOrUndef(raw.added) } : {}),
|
|
179
|
+
...(raw.deprecated === true ? { deprecated: true } : {}),
|
|
180
|
+
...(strOrUndef(raw.replacement) !== undefined ? { replacement: strOrUndef(raw.replacement) } : {}),
|
|
181
|
+
...(strOrUndef(raw.install) !== undefined ? { install: strOrUndef(raw.install) } : {}),
|
|
182
|
+
...(strOrUndef(raw.downloadsStart) !== undefined ? { downloadsStart: strOrUndef(raw.downloadsStart) } : {}),
|
|
183
|
+
...(strOrUndef(raw.downloadsEnd) !== undefined ? { downloadsEnd: strOrUndef(raw.downloadsEnd) } : {}),
|
|
184
|
+
...(strOrUndef(raw.downloadsCheckedAt) !== undefined ? { downloadsCheckedAt: strOrUndef(raw.downloadsCheckedAt) } : {}),
|
|
185
|
+
...(strOrUndef(raw.version) !== undefined ? { version: strOrUndef(raw.version) } : {}),
|
|
170
186
|
});
|
|
171
187
|
}
|
|
172
188
|
if (fallbackEn > 0)
|
package/lib/core/community.js
CHANGED
|
@@ -19,9 +19,10 @@ export const CATALOG_BODY_TIMEOUT_MS = 15_000;
|
|
|
19
19
|
export const COMMUNITY_CHAIN_BUDGET_MS = 30_000;
|
|
20
20
|
/**
|
|
21
21
|
* 上游 23 个分类 id 的全量中文标签(以 fixture `categories` 键为准逐条核对)。
|
|
22
|
-
*
|
|
22
|
+
* 0.7.0 Task 4 改名导出 + 作为标签单一事实源:summary 层附带(communitySummary)、
|
|
23
|
+
* GUI zoneChips 消费服务端数据,客户端内嵌副本(market-state.js)随 Task 8 删除。
|
|
23
24
|
*/
|
|
24
|
-
export const
|
|
25
|
+
export const COMMUNITY_CATEGORY_LABELS = {
|
|
25
26
|
agi: 'AGI 架构探索',
|
|
26
27
|
ui: 'UI 增强',
|
|
27
28
|
usage: '用量与计费',
|
package/lib/core/host-api.js
CHANGED
|
@@ -227,10 +227,13 @@ export function createApiDispatcher(ctx) {
|
|
|
227
227
|
case 'self-check': {
|
|
228
228
|
try {
|
|
229
229
|
const latest = await d.npmLatest(ctx.pkg.name, cfg().timeoutMs ?? 20_000);
|
|
230
|
-
|
|
230
|
+
const outdated = isNewerVersion(latest.version, ctx.pkg.version);
|
|
231
|
+
// ahead(0.8.0):本地 dev 版领先 npm 发布——设置页改显「本地开发版」而非误导性的旧「npm 最新」
|
|
232
|
+
const ahead = !outdated && latest.version !== ctx.pkg.version;
|
|
233
|
+
payload = { current: ctx.pkg.version, latest: latest.version, outdated, ahead };
|
|
231
234
|
}
|
|
232
235
|
catch (err) {
|
|
233
|
-
payload = { current: ctx.pkg.version, latest: null, outdated: false, error: err instanceof Error ? err.message : String(err) };
|
|
236
|
+
payload = { current: ctx.pkg.version, latest: null, outdated: false, ahead: false, error: err instanceof Error ? err.message : String(err) };
|
|
234
237
|
}
|
|
235
238
|
break;
|
|
236
239
|
}
|
|
@@ -261,9 +264,11 @@ export function createApiDispatcher(ctx) {
|
|
|
261
264
|
}
|
|
262
265
|
case 'market': {
|
|
263
266
|
await ctx.controller.ensureReady();
|
|
264
|
-
// GUI policy:忽略客户端 withLatest,固定 true;limit clamp 1..
|
|
267
|
+
// GUI policy:忽略客户端 withLatest,固定 true;limit clamp 1..96、缺省 24
|
|
268
|
+
// (0.7.0 Task 7 探测预算决策:96 为 opt-in 页大小,默认 24 低于 0.6.x 默认 50 的探测负载;
|
|
269
|
+
// 最坏情况被 core 60s deadline 框死为 latestError,不阻塞列表,Q46 不动)
|
|
265
270
|
const limitRaw = Number(body.limit);
|
|
266
|
-
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(
|
|
271
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(96, Math.max(1, Math.floor(limitRaw))) : 24;
|
|
267
272
|
const offsetRaw = Number(body.offset);
|
|
268
273
|
const offset = Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0;
|
|
269
274
|
// category:精选 5 + 社区开放 slug(非法 slug → 400,不静默吞)
|
|
@@ -277,12 +282,33 @@ export function createApiDispatcher(ctx) {
|
|
|
277
282
|
throw new ApiProtocolError(400, `非法分类: ${categoryRaw}(需精选分类或 [a-z0-9-]{1,32} slug)`);
|
|
278
283
|
}
|
|
279
284
|
}
|
|
285
|
+
// source 分区 + sort(0.7.0 Task 7):非法值 400(不静默吞);primaryOnly 已删除——显式拒绝而非静默忽略(审计 #8)
|
|
286
|
+
if (body.primaryOnly !== undefined) {
|
|
287
|
+
throw new ApiProtocolError(400, '参数 primaryOnly 已删除:改用 source(primary/community/all)');
|
|
288
|
+
}
|
|
289
|
+
const sourceRaw = typeof body.source === 'string' ? body.source.trim() : '';
|
|
290
|
+
if (sourceRaw !== '' && sourceRaw !== 'primary' && sourceRaw !== 'community' && sourceRaw !== 'all') {
|
|
291
|
+
throw new ApiProtocolError(400, `非法 source: ${sourceRaw}(需 primary/community/all)`);
|
|
292
|
+
}
|
|
293
|
+
const source = sourceRaw === '' ? 'all' : sourceRaw;
|
|
294
|
+
let sort;
|
|
295
|
+
if (body.sort !== undefined && body.sort !== null) {
|
|
296
|
+
const s = body.sort;
|
|
297
|
+
if ((s.field === 'downloads' || s.field === 'stars' || s.field === 'added') &&
|
|
298
|
+
(s.dir === 'asc' || s.dir === 'desc')) {
|
|
299
|
+
sort = { field: s.field, dir: s.dir };
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
throw new ApiProtocolError(400, '非法 sort: 需 { field: downloads|stars|added, dir: asc|desc }');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
280
305
|
const result = await d.listMarket(cfg(), {
|
|
281
306
|
query: strArg(body, 'query'),
|
|
282
307
|
category,
|
|
308
|
+
source,
|
|
309
|
+
sort,
|
|
283
310
|
offset,
|
|
284
311
|
limit,
|
|
285
|
-
primaryOnly: boolArg(body.primaryOnly),
|
|
286
312
|
force: boolArg(body.force),
|
|
287
313
|
withLatest: true,
|
|
288
314
|
namespace: 'host',
|
|
@@ -369,6 +395,14 @@ export function createApiDispatcher(ctx) {
|
|
|
369
395
|
payload = { applied: true, ...snapshotPayload(snap, cfg()) };
|
|
370
396
|
break;
|
|
371
397
|
}
|
|
398
|
+
case 'set-community': {
|
|
399
|
+
// 0.8.0 设置页社区开关:live 生效(内存)+ store 持久化;无需主清单重载
|
|
400
|
+
if (typeof body.enabled !== 'boolean')
|
|
401
|
+
throw new ApiProtocolError(400, '缺少 enabled(必须为 boolean)');
|
|
402
|
+
const snap = await ctx.controller.setCommunity(body.enabled);
|
|
403
|
+
payload = { applied: true, ...snapshotPayload(snap, cfg()) };
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
372
406
|
case 'registry-default-download': {
|
|
373
407
|
const loaded = await ctx.controller.loadDefault({ force: true, signal });
|
|
374
408
|
payload = { registry: loaded.registry, registryState: loaded };
|
package/lib/core/market.js
CHANGED
|
@@ -18,8 +18,9 @@ import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
|
|
|
18
18
|
import { PROTECTED_MODULES, composeEnablement } from './enablement.js';
|
|
19
19
|
import { readProfileOverrides } from './patch-yaml.js';
|
|
20
20
|
import { loadRegistry as defaultLoadRegistry, } from './registry.js';
|
|
21
|
-
import { fetchCommunityCatalog as defaultFetchCommunityCatalog, } from './community.js';
|
|
21
|
+
import { COMMUNITY_CATEGORY_LABELS, fetchCommunityCatalog as defaultFetchCommunityCatalog, } from './community.js';
|
|
22
22
|
import { adaptCommunityCatalog } from './community-adapter.js';
|
|
23
|
+
import { normalizeSearchText, relevanceScore, tokenizeSearchText } from './search-relevance.js';
|
|
23
24
|
import { GithubBudgetExhaustedError, createGithubRequestBudget, isExactVersion } from './versions.js';
|
|
24
25
|
import { HttpError } from './httpx.js';
|
|
25
26
|
import { verifyInstalledAdditions } from './install-guard.js';
|
|
@@ -59,7 +60,10 @@ function loaderEntriesOrNull() {
|
|
|
59
60
|
}
|
|
60
61
|
// ---------- 通用工具 ----------
|
|
61
62
|
const DEFAULT_DEADLINE_MS = 60_000;
|
|
62
|
-
|
|
63
|
+
/** withLatest 上限 = 96(0.7.0 Task 7:50→96,opt-in 页大小;默认页 24 的探测负载低于 0.6.x 默认 50——
|
|
64
|
+
* 96/页冷缓存最坏情况被 60s deadline 框死为 latestError 不阻塞列表,Q46「探测对象=页面条目」不动;
|
|
65
|
+
* 人工验收阈值:96/页冷缓存 latestError > 20% 即回退默认页大小并重议)。 */
|
|
66
|
+
const WITH_LATEST_MAX = 96;
|
|
63
67
|
const METADATA_ONLY_MAX = 80;
|
|
64
68
|
const LATEST_WORKERS = 8;
|
|
65
69
|
export function abortError() {
|
|
@@ -256,7 +260,7 @@ export function mergeRegistries(primary, community) {
|
|
|
256
260
|
const warnings = displaced.length > 0 ? [`${displaced.length} 条社区条目与主清单重复,已让位(主清单恒优先)`] : [];
|
|
257
261
|
return { items: [...primary, ...sorted], displaced: displaced.length, warnings };
|
|
258
262
|
}
|
|
259
|
-
function communitySummary(state, counts, extraWarnings) {
|
|
263
|
+
function communitySummary(state, counts, extraWarnings, categoryLabelsEn) {
|
|
260
264
|
const unavailableLike = state.status === 'disabled' || state.status === 'unavailable';
|
|
261
265
|
return {
|
|
262
266
|
enabled: state.enabled,
|
|
@@ -272,23 +276,38 @@ function communitySummary(state, counts, extraWarnings) {
|
|
|
272
276
|
skippedSubpathNoNpm: unavailableLike ? 0 : (counts.skippedSubpathNoNpm ?? 0),
|
|
273
277
|
errors: [...state.errors],
|
|
274
278
|
warnings: [...state.warnings, ...extraWarnings],
|
|
279
|
+
// 标签单一事实源(0.7.0 Task 4):disabled/skipped 下无社区数据语义,不携带;
|
|
280
|
+
// EN 标签(i18n)随行携带,仅在调用方传入非空映射时出现(缺省 = 上游无 categories 数据)
|
|
281
|
+
...(state.status !== 'disabled' && state.status !== 'skipped'
|
|
282
|
+
? {
|
|
283
|
+
categoryLabels: { ...COMMUNITY_CATEGORY_LABELS },
|
|
284
|
+
...(categoryLabelsEn && Object.keys(categoryLabelsEn).length > 0
|
|
285
|
+
? { categoryLabelsEn: { ...categoryLabelsEn } }
|
|
286
|
+
: {}),
|
|
287
|
+
}
|
|
288
|
+
: {}),
|
|
275
289
|
};
|
|
276
290
|
}
|
|
277
291
|
function disabledCommunitySummary() {
|
|
278
292
|
return communitySummary({ enabled: false, status: 'disabled', version: null, checkedAt: null, fetchedAt: null, route: null, count: 0, errors: [], warnings: [] }, {}, []);
|
|
279
293
|
}
|
|
294
|
+
/** 查询层主动跳过(0.7.0 Task 2:source='primary'):本次未加载社区层,非配置关闭、非故障。 */
|
|
295
|
+
function skippedCommunitySummary() {
|
|
296
|
+
return communitySummary({ enabled: true, status: 'skipped', version: null, checkedAt: null, fetchedAt: null, route: null, count: 0, errors: [], warnings: [] }, {}, []);
|
|
297
|
+
}
|
|
280
298
|
function communityTimeoutSummary() {
|
|
281
299
|
return communitySummary({ enabled: true, status: 'unavailable', version: null, checkedAt: null, fetchedAt: null, route: null, count: 0, errors: ['社区目录状态获取超时,可稍后刷新'], warnings: [] }, {}, []);
|
|
282
300
|
}
|
|
283
301
|
/**
|
|
284
302
|
* 社区 loader waiter 收敛(v9/v10 waiter-scoped 契约):共享 flight 不接收调用者 deadline,
|
|
285
303
|
* 本函数作为 waiter 用剩余 deadline race 自己的等待;到点只结束本 waiter(summary 标超时),
|
|
286
|
-
* 共享 flight 照常继续。
|
|
304
|
+
* 共享 flight 照常继续。source='primary'/未启用 → loader 零调用(task 传 null → skipped;
|
|
305
|
+
* 配置关闭走真任务的 disabled 分支,与跳过语义分离)。
|
|
287
306
|
* 导出供 community.ts getCommunitySummary 复用(summary 组装单一产地)。
|
|
288
307
|
*/
|
|
289
308
|
export async function communityOutcome(task, deadlineAt, primary) {
|
|
290
309
|
if (!task)
|
|
291
|
-
return { summary:
|
|
310
|
+
return { summary: skippedCommunitySummary(), merged: primary };
|
|
292
311
|
let loaded;
|
|
293
312
|
try {
|
|
294
313
|
loaded = await deadlineRace(task, deadlineAt - Date.now());
|
|
@@ -310,18 +329,65 @@ export async function communityOutcome(task, deadlineAt, primary) {
|
|
|
310
329
|
}
|
|
311
330
|
const adapted = adaptCommunityCatalog(loaded.catalog);
|
|
312
331
|
const merge = mergeRegistries(primary, adapted.entries);
|
|
332
|
+
// EN 分类标签(i18n):直接取上游目录 categories.en——上游新增分类自动跟进,
|
|
333
|
+
// 缺 en 的 id 不进映射(客户端按语言取用并回退中文标签,不在此处手养第二张表)
|
|
334
|
+
const categoryLabelsEn = Object.fromEntries(Object.entries(loaded.catalog.categories)
|
|
335
|
+
.map(([id, c]) => [id, typeof c?.en === 'string' && c.en !== '' ? c.en : ''])
|
|
336
|
+
.filter(([, en]) => en !== ''));
|
|
313
337
|
const summary = communitySummary(state, {
|
|
314
338
|
acceptedCount: adapted.entries.length,
|
|
315
339
|
upstreamCount: loaded.catalog.plugins.length,
|
|
316
340
|
displaced: merge.displaced,
|
|
317
341
|
skippedDirty: adapted.skippedDirty,
|
|
318
342
|
skippedSubpathNoNpm: adapted.skippedSubpathNoNpm,
|
|
319
|
-
}, [...adapted.warnings, ...merge.warnings]);
|
|
343
|
+
}, [...adapted.warnings, ...merge.warnings], categoryLabelsEn);
|
|
320
344
|
return { summary, merged: merge.items };
|
|
321
345
|
}
|
|
322
346
|
function isCommunityEntry(entry) {
|
|
323
347
|
return entry.descriptionEn !== undefined;
|
|
324
348
|
}
|
|
349
|
+
/** 旁路数值/日期读取(RegistryEntry 无这些键 → null/'';0.7.0 Task 2 排序用)。 */
|
|
350
|
+
function dlOf(e) {
|
|
351
|
+
const v = e.downloads;
|
|
352
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
353
|
+
}
|
|
354
|
+
function starsOf(e) {
|
|
355
|
+
const v = e.stars;
|
|
356
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : -1;
|
|
357
|
+
}
|
|
358
|
+
function addedOf(e) {
|
|
359
|
+
const v = e.added;
|
|
360
|
+
return typeof v === 'string' ? v : '';
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* 分区排序(0.7.0 Task 2):`dir` 只翻转有数据的比较,**不翻转「无数据垫底」语义**——
|
|
364
|
+
* downloads 无计数 ≠ 0(无数据者恒排有数据者之后,组内按 stars 降序再 name);
|
|
365
|
+
* stars 缺失视为 -1、added 缺失视为最旧(空串日期),两者参与正常比较随 dir 翻转。
|
|
366
|
+
*/
|
|
367
|
+
function sortEntries(entries, sort) {
|
|
368
|
+
const dirMul = sort.dir === 'asc' ? 1 : -1;
|
|
369
|
+
const nameCmp = (a, b) => String(a.name).localeCompare(String(b.name));
|
|
370
|
+
return entries.slice().sort((a, b) => {
|
|
371
|
+
if (sort.field === 'downloads') {
|
|
372
|
+
const da = dlOf(a);
|
|
373
|
+
const db = dlOf(b);
|
|
374
|
+
if (da !== null && db !== null)
|
|
375
|
+
return (da - db) * dirMul || nameCmp(a, b);
|
|
376
|
+
if (da !== null)
|
|
377
|
+
return -1;
|
|
378
|
+
if (db !== null)
|
|
379
|
+
return 1;
|
|
380
|
+
return starsOf(b) - starsOf(a) || nameCmp(a, b);
|
|
381
|
+
}
|
|
382
|
+
if (sort.field === 'stars') {
|
|
383
|
+
const diff = starsOf(a) - starsOf(b);
|
|
384
|
+
return diff * dirMul || nameCmp(a, b);
|
|
385
|
+
}
|
|
386
|
+
const aa = addedOf(a);
|
|
387
|
+
const ab = addedOf(b);
|
|
388
|
+
return (aa < ab ? -1 : aa > ab ? 1 : 0) * dirMul || nameCmp(a, b);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
325
391
|
/** 合并条目 → MarketItem:社区条目带 community 标记与旁路字段。 */
|
|
326
392
|
function toMarketItem(entry, installedItems) {
|
|
327
393
|
const inst = matchInstalledByEntry(entry, installedItems);
|
|
@@ -337,14 +403,26 @@ function toMarketItem(entry, installedItems) {
|
|
|
337
403
|
item.capabilities = entry.capabilities;
|
|
338
404
|
item.capabilityRedLines = entry.capabilityRedLines;
|
|
339
405
|
item.screenshots = entry.screenshots;
|
|
406
|
+
item.owner = entry.owner;
|
|
407
|
+
if (entry.added !== undefined)
|
|
408
|
+
item.added = entry.added;
|
|
409
|
+
if (entry.deprecated !== undefined)
|
|
410
|
+
item.deprecated = entry.deprecated;
|
|
411
|
+
if (entry.replacement !== undefined)
|
|
412
|
+
item.replacement = entry.replacement;
|
|
413
|
+
if (entry.install !== undefined)
|
|
414
|
+
item.install = entry.install;
|
|
415
|
+
if (entry.downloadsStart !== undefined)
|
|
416
|
+
item.downloadsStart = entry.downloadsStart;
|
|
417
|
+
if (entry.downloadsEnd !== undefined)
|
|
418
|
+
item.downloadsEnd = entry.downloadsEnd;
|
|
419
|
+
if (entry.downloadsCheckedAt !== undefined)
|
|
420
|
+
item.downloadsCheckedAt = entry.downloadsCheckedAt;
|
|
421
|
+
if (entry.version !== undefined)
|
|
422
|
+
item.version = entry.version;
|
|
340
423
|
}
|
|
341
424
|
return item;
|
|
342
425
|
}
|
|
343
|
-
/** 合并条目的搜索串:社区条目附英文描述原文(Q45 搜索同时匹配中英文)。 */
|
|
344
|
-
function searchableText(entry) {
|
|
345
|
-
const base = `${entry.id} ${entry.name} ${entry.description} ${entry.tags.join(' ')}`;
|
|
346
|
-
return isCommunityEntry(entry) ? `${base} ${entry.descriptionEn}` : base;
|
|
347
|
-
}
|
|
348
426
|
// ---------- 市场列表 ----------
|
|
349
427
|
function marketDeps() {
|
|
350
428
|
return {
|
|
@@ -363,15 +441,17 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
363
441
|
: DEFAULT_DEADLINE_MS;
|
|
364
442
|
const deadlineAt = startedAt + deadlineMs;
|
|
365
443
|
const namespace = opts.namespace ?? 'host';
|
|
444
|
+
// 分区归一(0.7.0 Task 2):非法值一律按 'all'
|
|
445
|
+
const source = opts.source === 'primary' || opts.source === 'community' ? opts.source : 'all';
|
|
366
446
|
const withLatest = opts.withLatest !== false;
|
|
367
447
|
const maxLimit = withLatest ? WITH_LATEST_MAX : METADATA_ONLY_MAX;
|
|
368
448
|
const signal = opts.signal;
|
|
369
449
|
const remaining = () => deadlineAt - Date.now();
|
|
370
450
|
const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
|
|
371
451
|
const installedTask = d.listInstalledPlugins().catch(() => null);
|
|
372
|
-
// 社区 flight 并发启动(primaryOnly
|
|
452
|
+
// 社区 flight 并发启动(source=primary 零调用;0.7.0 Task 7:primaryOnly 字段已删除);共享 loader 不接收调用者 deadline——
|
|
373
453
|
// listMarket 作为 waiter 在 communityOutcome 内 race 自己的剩余 deadline/signal(v10 契约)
|
|
374
|
-
const communityTask =
|
|
454
|
+
const communityTask = source === 'primary' ? null : d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force });
|
|
375
455
|
let loaded;
|
|
376
456
|
try {
|
|
377
457
|
loaded = await deadlineRace(registryTask, remaining());
|
|
@@ -403,26 +483,43 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
|
|
|
403
483
|
const installedItems = installed?.items ?? [];
|
|
404
484
|
// 社区 waiter 收敛:主 unavailable 时 merged = 存活社区条目(Q42 出页不返空);两层皆不可用 → 空页契约
|
|
405
485
|
const community = await communityOutcome(communityTask, deadlineAt, loaded.registry.plugins);
|
|
406
|
-
// 全量统计 + query/category 过滤 +
|
|
407
|
-
|
|
486
|
+
// 分区过滤 + 全量统计 + query/category 过滤 + 分页(同步,极轻;0.7.0 Task 2):
|
|
487
|
+
// categoryCounts = 分区集合(不含 query/category 过滤——chips 需要全区计数);
|
|
488
|
+
// total = 分区 ∩ query ∩ category。
|
|
489
|
+
const merged = community.merged;
|
|
490
|
+
const zoned = source === 'community'
|
|
491
|
+
? merged.filter(isCommunityEntry)
|
|
492
|
+
: source === 'primary'
|
|
493
|
+
? merged.filter((entry) => !isCommunityEntry(entry))
|
|
494
|
+
: merged;
|
|
408
495
|
const counts = zeroCounts();
|
|
409
|
-
for (const entry of
|
|
496
|
+
for (const entry of zoned)
|
|
410
497
|
counts[entry.category] = (counts[entry.category] ?? 0) + 1;
|
|
411
|
-
const q = (opts.query ?? '').trim().toLowerCase();
|
|
412
498
|
const cat = opts.category ?? null;
|
|
413
|
-
|
|
499
|
+
// 相关性搜索(0.7.0 Task 3):归一化分词 + 字段加权评分;0 分不返回;
|
|
500
|
+
// id 整串精确匹配保证命中(收藏 stale 检测依赖)。
|
|
501
|
+
const terms = tokenizeSearchText(normalizeSearchText(opts.query ?? ''));
|
|
502
|
+
const filtered = zoned.filter((entry) => {
|
|
414
503
|
if (cat && entry.category !== cat)
|
|
415
504
|
return false;
|
|
416
|
-
if (
|
|
505
|
+
if (terms.length === 0)
|
|
417
506
|
return true;
|
|
418
|
-
return
|
|
507
|
+
return relevanceScore(entry, terms) > 0;
|
|
419
508
|
});
|
|
420
|
-
|
|
509
|
+
// 排序:显式 sort 先行;query 命中时相关性优先(稳定 tie-break 回到既有序——merged 现序或用户排序)
|
|
510
|
+
const afterSort = opts.sort ? sortEntries(filtered, opts.sort) : filtered;
|
|
511
|
+
const ordered = terms.length > 0
|
|
512
|
+
? afterSort
|
|
513
|
+
.map((entry, idx) => ({ entry, score: relevanceScore(entry, terms), idx }))
|
|
514
|
+
.sort((a, b) => b.score - a.score || a.idx - b.idx)
|
|
515
|
+
.map((s) => s.entry)
|
|
516
|
+
: afterSort;
|
|
517
|
+
const total = ordered.length;
|
|
421
518
|
const limit = clampLimit(opts.limit, maxLimit);
|
|
422
519
|
let offset = normalizeOffset(opts.offset);
|
|
423
520
|
if (total > 0 && offset >= total)
|
|
424
521
|
offset = Math.floor((total - 1) / limit) * limit;
|
|
425
|
-
const items =
|
|
522
|
+
const items = ordered.slice(offset, offset + limit).map((entry) => toMarketItem(entry, installedItems));
|
|
426
523
|
let latestComplete = true;
|
|
427
524
|
let latestTimedOut = false;
|
|
428
525
|
if (withLatest && items.length > 0) {
|
|
@@ -617,13 +714,17 @@ async function probeLatest(items, ctx, d, rt) {
|
|
|
617
714
|
item.outdated = !item.spec.includes(item.latestSha);
|
|
618
715
|
});
|
|
619
716
|
}
|
|
620
|
-
/** 从 registry 收录条目安装(npm → 精确锁定最新版;github → 锁 HEAD SHA)。
|
|
717
|
+
/** 从 registry 收录条目安装(npm → 精确锁定最新版;github → 锁 HEAD SHA)。
|
|
718
|
+
* 0.7.1 修复:合并市场展示的社区条目 id 不在主清单(loadRegistry 只装载主清单,
|
|
719
|
+
* 社区层只合入 listMarket 展示)——主清单 miss 时按收录 id 查社区目录再装,
|
|
720
|
+
* 与 0.5.1 升级路径(findCommunityUpgradeEntry)同构。 */
|
|
621
721
|
export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
|
|
622
722
|
const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host' });
|
|
623
723
|
if (loaded.status === 'unavailable') {
|
|
624
724
|
throw new Error(`收录清单不可用,无法安装 ${id};请检查 registry 配置或网络后重试`);
|
|
625
725
|
}
|
|
626
|
-
const entry = loaded.registry.plugins.find((e) => e.id === id)
|
|
726
|
+
const entry = loaded.registry.plugins.find((e) => e.id === id) ??
|
|
727
|
+
(await findCommunityInstallEntry(id, cfg, opts, deps));
|
|
627
728
|
if (!entry)
|
|
628
729
|
throw new Error(`registry 中没有该条目: ${id}`);
|
|
629
730
|
return installEntry(entry, cfg, opts, deps);
|
|
@@ -1185,6 +1286,22 @@ async function upgradePluginLocked(pkg, cfg = {}, opts = {}, deps) {
|
|
|
1185
1286
|
const result = await installEntryLocked(entry, cfg, opts, deps);
|
|
1186
1287
|
return { ...result, fromVersion: target.version };
|
|
1187
1288
|
}
|
|
1289
|
+
/**
|
|
1290
|
+
* 社区条目按收录 id 查找(0.7.1 安装修复):合并市场展示的社区条目 id = 社区目录合成 id
|
|
1291
|
+
* (owner--name 等),主清单 miss 时按同一 id 查社区目录。社区清单未启用/不可用/加载
|
|
1292
|
+
* 异常/未命中 → undefined,由调用方统一报「registry 中没有该条目」。
|
|
1293
|
+
*/
|
|
1294
|
+
async function findCommunityInstallEntry(id, cfg, opts, deps) {
|
|
1295
|
+
try {
|
|
1296
|
+
const loaded = await (deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal });
|
|
1297
|
+
if (!loaded.catalog)
|
|
1298
|
+
return undefined;
|
|
1299
|
+
return adaptCommunityCatalog(loaded.catalog).entries.find((e) => e.id === id);
|
|
1300
|
+
}
|
|
1301
|
+
catch {
|
|
1302
|
+
return undefined;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1188
1305
|
/**
|
|
1189
1306
|
* 社区条目升级查找(0.5.1):主清单 miss 时按同一 matchInstalledByEntry 语义查社区目录。
|
|
1190
1307
|
* 社区清单未启用/不可用/加载异常/未命中 → undefined,由调用方统一报「不是经 dsh-m 收录」。
|
|
@@ -310,6 +310,32 @@ export function createRegistryController(initial = {}) {
|
|
|
310
310
|
return snapshotInternal();
|
|
311
311
|
});
|
|
312
312
|
}
|
|
313
|
+
function setCommunity(enabled) {
|
|
314
|
+
// 同步先启动 bootstrap(只创建不等待):它先于本任务入队,任务内再 await 即安全——
|
|
315
|
+
// 若在任务内 await startBootstrap() 会自我死锁(bootstrap 排在本任务之后永远轮不到)
|
|
316
|
+
if (!bootstrapPromise)
|
|
317
|
+
startBootstrap();
|
|
318
|
+
return enqueue(async (gen) => {
|
|
319
|
+
// 等初始同步完成:bootstrap 的 syncCommunityFields 会按 source 重写社区字段,
|
|
320
|
+
// 开关若抢在它前面写入会被覆盖(时序隐患,0.7.9 测试暴露于 0.8.0)
|
|
321
|
+
await bootstrapPromise;
|
|
322
|
+
const prev = config.communityCatalog;
|
|
323
|
+
// 内存先行(live 生效:listMarket 立即跳过社区层);持久化失败回滚内存值
|
|
324
|
+
config.communityCatalog = enabled;
|
|
325
|
+
if (store) {
|
|
326
|
+
try {
|
|
327
|
+
await store.update({ communityCatalog: enabled });
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
if (disposed || gen !== generation)
|
|
331
|
+
return snapshotInternal();
|
|
332
|
+
config.communityCatalog = prev;
|
|
333
|
+
throw new RegistryConfigError(`写入设置失败:${errorMessage(err)}`, [errorMessage(err)]);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return snapshotInternal();
|
|
337
|
+
});
|
|
338
|
+
}
|
|
313
339
|
function dispose() {
|
|
314
340
|
disposed = true;
|
|
315
341
|
unwatch?.();
|
|
@@ -323,6 +349,7 @@ export function createRegistryController(initial = {}) {
|
|
|
323
349
|
snapshot,
|
|
324
350
|
loadDefault,
|
|
325
351
|
apply,
|
|
352
|
+
setCommunity,
|
|
326
353
|
dispose,
|
|
327
354
|
};
|
|
328
355
|
return controller;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 搜索相关性管线(0.7.0 Task 3 / DESIGN.md §2.6「搜索:相关性加权管线」):
|
|
3
|
+
* NFKC 归一化 + 中西文边界插空格 + 标点归空格 → 分词 → 字段加权评分。
|
|
4
|
+
*
|
|
5
|
+
* - id 为单列最高优先:normalized id 与整条 query 精确相等 → ID_EXACT_SCORE(收藏 stale 检测
|
|
6
|
+
* 依赖此语义,豁免「多词同字段全命中」约束);
|
|
7
|
+
* - 字段权重:name/npm 700 · owner 400 · 首选描述 280 · 次选描述 240 · 分类 180 · tags 150
|
|
8
|
+
* (tags 为 dsh-m 主清单召回保留——dsh-market 目录无 tags 概念,主清单 tags 有文案标准);
|
|
9
|
+
* - 命中类型加分:精确 +300 / 前缀 +250 / 包含 +200 / 多词全命中 +150;
|
|
10
|
+
* 多词要求**同字段全命中**,否则该字段 0 分;
|
|
11
|
+
* - 按条目 WeakMap memoize 归一化字段串——4,400+ 条 × 每次搜索请求全量归一化的性能护栏
|
|
12
|
+
* (条目对象随每次 adapt 重建,缓存随条目代际自然失效,无陈旧风险)。
|
|
13
|
+
*/
|
|
14
|
+
/** id 整串精确匹配得分:恒高于一切字段组合分,保证排首且必命中。 */
|
|
15
|
+
export const ID_EXACT_SCORE = 10_000;
|
|
16
|
+
const W_NAME = 700;
|
|
17
|
+
const W_OWNER = 400;
|
|
18
|
+
const W_DESC = 280;
|
|
19
|
+
const W_DESC_EN = 240;
|
|
20
|
+
const W_CATEGORY = 180;
|
|
21
|
+
const W_TAGS = 150;
|
|
22
|
+
const B_EXACT = 300;
|
|
23
|
+
const B_PREFIX = 250;
|
|
24
|
+
const B_CONTAINS = 200;
|
|
25
|
+
const B_MULTI = 150;
|
|
26
|
+
const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
|
|
27
|
+
const WORD_RE = /[\p{L}\p{N}]/u;
|
|
28
|
+
/** 归一化:NFKC + 小写 + 中西文边界插空格(`MCP管理` ≈ `MCP 管理`)+ 标点/符号/空白归单空格。 */
|
|
29
|
+
export function normalizeSearchText(input) {
|
|
30
|
+
const nfk = input.normalize('NFKC').toLowerCase();
|
|
31
|
+
let out = '';
|
|
32
|
+
for (let i = 0; i < nfk.length; i++) {
|
|
33
|
+
const ch = nfk[i];
|
|
34
|
+
if (i > 0) {
|
|
35
|
+
const prev = nfk[i - 1];
|
|
36
|
+
if (WORD_RE.test(ch) && WORD_RE.test(prev) && CJK_RE.test(ch) !== CJK_RE.test(prev))
|
|
37
|
+
out += ' ';
|
|
38
|
+
}
|
|
39
|
+
out += ch;
|
|
40
|
+
}
|
|
41
|
+
return out.replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
42
|
+
}
|
|
43
|
+
/** 分词(输入应为 normalizeSearchText 产物)。 */
|
|
44
|
+
export function tokenizeSearchText(normalized) {
|
|
45
|
+
return normalized.split(/\s+/).filter((t) => t !== '');
|
|
46
|
+
}
|
|
47
|
+
/** 多段字段(如 tags)归一化为单空格连接的 token 串。 */
|
|
48
|
+
function fieldText(parts) {
|
|
49
|
+
return parts
|
|
50
|
+
.filter((p) => typeof p === 'string' && p.trim() !== '')
|
|
51
|
+
.map((p) => normalizeSearchText(p))
|
|
52
|
+
.filter((p) => p !== '')
|
|
53
|
+
.join(' ');
|
|
54
|
+
}
|
|
55
|
+
const fieldsCache = new WeakMap();
|
|
56
|
+
function fieldsOf(entry) {
|
|
57
|
+
const cached = fieldsCache.get(entry);
|
|
58
|
+
if (cached)
|
|
59
|
+
return cached;
|
|
60
|
+
const fields = [
|
|
61
|
+
{ text: fieldText([entry.name]), weight: W_NAME },
|
|
62
|
+
...(entry.npm !== undefined ? [{ text: fieldText([entry.npm]), weight: W_NAME }] : []),
|
|
63
|
+
...(entry.owner !== undefined ? [{ text: fieldText([entry.owner]), weight: W_OWNER }] : []),
|
|
64
|
+
{ text: fieldText([entry.description]), weight: W_DESC },
|
|
65
|
+
...(entry.descriptionEn !== undefined ? [{ text: fieldText([entry.descriptionEn]), weight: W_DESC_EN }] : []),
|
|
66
|
+
{ text: fieldText([entry.category]), weight: W_CATEGORY },
|
|
67
|
+
...(entry.tags !== undefined && entry.tags.length > 0 ? [{ text: fieldText(entry.tags), weight: W_TAGS }] : []),
|
|
68
|
+
];
|
|
69
|
+
const value = { id: fieldText([entry.id]), fields };
|
|
70
|
+
fieldsCache.set(entry, value);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function hitBonus(field, term) {
|
|
74
|
+
if (field === term)
|
|
75
|
+
return B_EXACT;
|
|
76
|
+
if (field.startsWith(term))
|
|
77
|
+
return B_PREFIX;
|
|
78
|
+
if (field.includes(term))
|
|
79
|
+
return B_CONTAINS;
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
/** 单字段评分:全部词命中才得分(取最弱命中类型 + 多词加成);任一词未命中 → 0。 */
|
|
83
|
+
function scoreField(text, weight, terms) {
|
|
84
|
+
if (text === '')
|
|
85
|
+
return 0;
|
|
86
|
+
let min = Number.POSITIVE_INFINITY;
|
|
87
|
+
for (const term of terms) {
|
|
88
|
+
const b = hitBonus(text, term);
|
|
89
|
+
if (b === 0)
|
|
90
|
+
return 0;
|
|
91
|
+
if (b < min)
|
|
92
|
+
min = b;
|
|
93
|
+
}
|
|
94
|
+
return weight + min + (terms.length > 1 ? B_MULTI : 0);
|
|
95
|
+
}
|
|
96
|
+
/** 相关性评分(>0 = 命中):id 整串精确匹配最高优先且豁免多词约束;否则取各字段最大分。 */
|
|
97
|
+
export function relevanceScore(entry, terms) {
|
|
98
|
+
if (terms.length === 0)
|
|
99
|
+
return 0;
|
|
100
|
+
const f = fieldsOf(entry);
|
|
101
|
+
if (f.id !== '' && f.id === terms.join(' '))
|
|
102
|
+
return ID_EXACT_SCORE;
|
|
103
|
+
let best = 0;
|
|
104
|
+
for (const field of f.fields) {
|
|
105
|
+
const s = scoreField(field.text, field.weight, terms);
|
|
106
|
+
if (s > best)
|
|
107
|
+
best = s;
|
|
108
|
+
}
|
|
109
|
+
return best;
|
|
110
|
+
}
|