feat(web): 模型设置页复刻官方交互 + 官方推荐插件按 dsh 版本过滤(档案 91/92/93)
档案 91 ——「模型设置」页折行 + 复刻官方交互(插件 0.3.15) · 折行根因不是"窄":原「我已添加的厂家」是 4 列表格(列宽 auto)⇒ 名称/状态/两按钮三处折行同源; 改官方 dsh-client-ui-settings-models 的**卡片行**(rowHead + rowActions margin-left:auto,两侧 nowrap) · 「新增」改官方**两步式**:默认两个虚线按钮 → 点开才出卡片,主字段只剩「API 密钥」 (官方原文:页面从不询问环境变量名);次要字段收进 <details>「自定义设置」 · CSS 数值逐值照抄官方 CSS 模块;顺修「由 admin 配置」「密钥可留空」两处错文案,补删除二次确认 档案 92 —— 官方推荐插件列表按 dsh 版本过滤(插件 0.3.16) · 官方目录不带 dsh 版本字段 ⇒ 逐包查 npm 声明与平台真实版本比,分类 match/none/newer/older/unknown · 默认隐藏「仅兼容更旧版本」,「需更高版本」橙色标注;勾选框为逃生口;说明行给隐藏计数(不静默消失) · ⚠️ 判 satisfies 必须带 includePrerelease(平台是 prerelease,默认语义会把在跑的 dshmarket 之类判成更旧) 档案 93 —— 兼容判定收口(服务器 CI 由红转绿) · 「单测长期红」真相 = 服务器源码落后于 git(本机 48/0 全绿)⇒ 同步即修,非代码问题 · 伞包 @deepseek-ai/dsh 收口到 dsh-install.platformPackageVersion()(它不在自己的 node_modules 里, 原先恒判 null ⇒ 声明在伞包上的"要求更高版本"全被放行);闸门与列表共用同一份版本表 · 闸门判据 A 加 prerelease 兜底:实测抽样 300 里 prerelease-artifact 117 条(≈39% 假阳性, 含在跑的 dshmarket)⇒ 容忍下能满足则只计数(prereleaseOnly)不阻断 校验 · 新增 scripts/verify-dsh-compat.mjs(21 条回归锚点)与 verify-models-dict.mjs / verify-models-render.cjs · verify-platform-admin-section.mjs:断言从旧 UI(表格/下拉「自定义厂家…」)改成断言新 UI (含模拟点击两个新增入口 + 选中目录厂家) · 两个仓库外的动作:服务器 npm run build + ci.sh(CI OK)+ systemctl restart dshs;插件 0.3.16 已铺发 ⚠️ 本提交未包含 src/supervisor/orchestrator.ts —— 那是别人在途的内存配额改动 (BASE 160→448 / MIN 384→512 / MAX 1024→1536 / mcn 128→256),其对应的客户端常量尚未同步, 本机 verify-mem-model 因此有 4 项红;提交后仓库树两边均为旧值,该断言在 HEAD 上一致。
This commit is contained in:
1 parent
1d72e8f9c8
commit
29c57a8207
11 files changed
+1474
-209
No files matched your search
@@ -137,6 +137,45 @@ export function dshScopeDir(): string {
|
||||
return join(dshPackageRoot(), 'node_modules', '@deepseek-ai')
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 平台包版本查询(**含伞包**)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const SCOPE = '@deepseek-ai/'
|
||||
/** 包名 → 平台版本;平台没有该包 → `null`。进程内缓存。 */
|
||||
const versionCache = new Map<string, string | null>()
|
||||
|
||||
/**
|
||||
* 平台某 `@deepseek-ai/*` 包的真实版本;平台没有该包 → `null`。
|
||||
*
|
||||
* ⚠️ **伞包 `@deepseek-ai/dsh` 必须单独解析**:`dshScopeDir()` = `<dsh 包根>/node_modules/@deepseek-ai`,
|
||||
* 而**伞包不在自己的 `node_modules` 里** ⇒ 只走「scope 目录 + 短名」,对伞包恒为 `null`。
|
||||
* 2026-09-14 实测代价:声明 `@deepseek-ai/dsh: ^0.1.5-rc.2` 的插件
|
||||
* (`dsh-zotero` / `dsh-any-background` / `dsh-md-notes`)在兼容性判定里**一条都认不出来**
|
||||
* —— 会被当成「平台没这个包,不算不兼容」而放行。见 `04-调整方案/92`。
|
||||
*
|
||||
* @param pkg 包名;`@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` / 不带 scope 的短名都认
|
||||
*/
|
||||
export function platformPackageVersion(pkg: string): string | null {
|
||||
const full = pkg.startsWith('@') ? pkg : SCOPE + pkg
|
||||
const hit = versionCache.get(full)
|
||||
if (hit !== undefined) return hit
|
||||
let v: string | null = null
|
||||
try {
|
||||
// 伞包读 `<dsh 包根>/package.json`;子包读 scope 目录下的同名目录
|
||||
const dir = full === PKG_NAME ? dshPackageRoot() : join(dshScopeDir(), full.slice(SCOPE.length))
|
||||
const pj = join(dir, 'package.json')
|
||||
if (existsSync(pj)) {
|
||||
const meta = JSON.parse(readFileSync(pj, 'utf8')) as { version?: unknown }
|
||||
v = typeof meta.version === 'string' ? meta.version : null
|
||||
}
|
||||
} catch {
|
||||
v = null
|
||||
}
|
||||
versionCache.set(full, v)
|
||||
return v
|
||||
}
|
||||
|
||||
/**
|
||||
* `@earendil-works/pi-ai` 的**厂家目录**数据位置。
|
||||
* @returns 绝对路径;**不保证存在**(不存在时返回首选候选,由调用方降级为空目录)。
|
||||
@@ -168,4 +207,5 @@ export function piAiDataDir(): string {
|
||||
/** 清解析缓存(改了 env 之后重新解析;测试用)。 */
|
||||
export function resetInstallPathsCache(): void {
|
||||
cachedRoot = null
|
||||
versionCache.clear()
|
||||
}
|
||||
+36
-19
@@ -33,7 +33,7 @@ import { pathToFileURL } from 'node:url'
|
||||
// 平台内置包的落点**不能写死**:`npm root -g` 随发行版变(`/usr/local/lib` vs `/usr/lib`),
|
||||
// 写死只会在另一种布局上静默退化成「平台包目录不可读」而不报错(测试服实测 = 0)。
|
||||
// 统一走 `dsh-install.ts` 的按序探测。
|
||||
import { dshScopeDir } from './dsh-install.js'
|
||||
import { dshScopeDir, platformPackageVersion } from './dsh-install.js'
|
||||
|
||||
const DS_SCOPE = '@deepseek-ai/'
|
||||
|
||||
@@ -53,6 +53,11 @@ export interface CompatResult {
|
||||
platformPkgs: number
|
||||
/** 是否真的看到了 @deepseek-ai 的依赖声明或 import(区分 ok / unknown 用) */
|
||||
sawPlatformRef: boolean
|
||||
/**
|
||||
* 有几条声明「按 semver 默认语义不满足、按 prerelease 容忍满足」——**平台是 prerelease**
|
||||
* 的语义产物,**不计入 `findings`、不阻断**,只留个可观测计数(2026-09-14,档案 92)。
|
||||
*/
|
||||
prereleaseOnly?: number
|
||||
/** 判定过程出错时的说明(此时 level=unknown) */
|
||||
note?: string
|
||||
}
|
||||
@@ -61,7 +66,6 @@ export interface CompatResult {
|
||||
* 平台侧:版本表(读 package.json)与导出符号(按需 import,带缓存)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const versionCache = new Map<string, string | null>()
|
||||
const exportCache = new Map<string, Set<string> | null>()
|
||||
|
||||
function shortName(pkg: string): string {
|
||||
@@ -94,22 +98,15 @@ export function platformPkgCount(): number {
|
||||
}
|
||||
}
|
||||
|
||||
/** 平台内置某包的版本;平台没有该包 → null。 */
|
||||
/** 平台内置某包的版本;平台没有该包 → `null`。
|
||||
*
|
||||
* 2026-09-14(档案 92):改为走 `dsh-install.platformPackageVersion()` —— 原实现只查
|
||||
* `<scope>/<短名>/package.json`,而**伞包 `@deepseek-ai/dsh` 不在自己的 `node_modules` 里**
|
||||
* ⇒ 对它恒判 `null`,被当作「平台没这个包,不算不兼容」而放行(实测那 3 条"要求更高版本"的
|
||||
* 插件全部只写在伞包上)。收口后该判据**含伞包**,与 `plugin-dsh-compat` 共用同一份版本表。
|
||||
*/
|
||||
function platformVersion(pkg: string): string | null {
|
||||
const hit = versionCache.get(pkg)
|
||||
if (hit !== undefined) return hit
|
||||
let v: string | null = null
|
||||
try {
|
||||
const pj = join(platformPkgDir(pkg), 'package.json')
|
||||
if (existsSync(pj)) {
|
||||
const meta = JSON.parse(readFileSync(pj, 'utf8')) as { version?: unknown }
|
||||
v = typeof meta.version === 'string' ? meta.version : null
|
||||
}
|
||||
} catch {
|
||||
v = null
|
||||
}
|
||||
versionCache.set(pkg, v)
|
||||
return v
|
||||
return platformPackageVersion(pkg)
|
||||
}
|
||||
|
||||
function resolveEntryFile(pkg: string): string | null {
|
||||
@@ -159,7 +156,7 @@ async function platformExports(pkg: string): Promise<Set<string> | null> {
|
||||
return keys
|
||||
}
|
||||
|
||||
type SemverLike = { satisfies(v: string, r: string): boolean }
|
||||
type SemverLike = { satisfies(v: string, r: string, o?: { includePrerelease?: boolean }): boolean }
|
||||
let semverPromise: Promise<SemverLike | null> | null = null
|
||||
|
||||
function loadSemver(): Promise<SemverLike | null> {
|
||||
@@ -303,8 +300,25 @@ export async function checkPluginCompat(root: string): Promise<CompatResult> {
|
||||
continue
|
||||
}
|
||||
let ok: boolean
|
||||
let prereleaseOnly = false
|
||||
try {
|
||||
ok = semver.satisfies(v, range) // **默认语义**:与 pnpm 的实际安装判定一致
|
||||
// ① 先按**默认语义**判(与 pnpm 的实际安装判定一致)
|
||||
ok = semver.satisfies(v, range)
|
||||
if (!ok) {
|
||||
// ② 不满足时再看是不是**prerelease 语义产物**。
|
||||
// 平台版本是 prerelease(`0.1.5-rc.1`、子包 `0.1.5-rc.2`),而 semver 默认语义下
|
||||
// prerelease **不满足** `*`、`^0.1.2` 这类不含 prerelease 的范围 —— 于是
|
||||
// 「声明 `*`(= 任意版本)」会被判成不兼容。
|
||||
// 实测 2026-09-14(抽样 300,见 `04-调整方案/92`):
|
||||
// real-newer 3 | narrow-pin 22 | **prerelease-artifact 117** | match 98
|
||||
// ⇒ 若把这 117 也当"不兼容",闸门会有 ~39% 假阳性(`dshmarket`、`dsh-context`
|
||||
// 这些**在平台上跑得好好的**都会被拦)。所以:**只有 prerelease 容忍下也不满足,
|
||||
// 才算真不兼容**;容忍下能满足的只记一条 note(可观测,不阻断)。
|
||||
if (semver.satisfies(v, range, { includePrerelease: true })) {
|
||||
ok = true
|
||||
prereleaseOnly = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue // 范围写法无法解析:不据此断言
|
||||
}
|
||||
@@ -314,6 +328,9 @@ export async function checkPluginCompat(root: string): Promise<CompatResult> {
|
||||
pkg,
|
||||
detail: `平台为 ${v},插件要求 ${range}(不满足)`,
|
||||
})
|
||||
} else if (prereleaseOnly) {
|
||||
// 保持可观测:这一条是"按 pnpm 语义不满足、按 prerelease 容忍满足"的平台效应
|
||||
out.prereleaseOnly = (out.prereleaseOnly ?? 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* plugin-dsh-compat.ts —— 判「**尚未安装**的 npm 插件按其声明,是否兼容**当前** dsh 版本」。
|
||||
*
|
||||
* 为什么单独一个文件(不复用 `plugin-compat.ts` 的**判定逻辑**):
|
||||
* · 那个模块判的是「**已解包到磁盘**的插件目录」,要扫 bundle 里的 import、要 import 平台包
|
||||
* 取真实导出 —— 对「官方推荐列表里还没下载的一条」根本用不上;
|
||||
* · 两处的**语义口径必须不同**(见下),混在一起会把两边都带偏。
|
||||
* ⚠️ **版本表是共用的**:伞包 `@deepseek-ai/dsh` **不在自己的 `node_modules` 里**,
|
||||
* 只按「scope 目录 + 短名」查会对它恒判 `null` —— 而实测那 3 条「要求更高版本」的声明
|
||||
* **全部只写在伞包上**(不补这一步就一条都认不出来)。该解析已收口到
|
||||
* `dsh-install.platformPackageVersion()`,`plugin-compat` 与本模块**共用同一份**(档案 92)。
|
||||
*
|
||||
* ⚠️ **语义口径(踩过才知道)**:判 `satisfies` 必须带 **`includePrerelease: true`**。
|
||||
* 平台版本是 prerelease(`0.1.5-rc.1` / 子包 `0.1.5-rc.2`),而 semver **默认语义**下
|
||||
* prerelease **不满足** `^0.1.2`、甚至不满足 `*`(除非范围里带同元组 prerelease)。
|
||||
* 实测 2026-09-14:TOP300 用默认语义得 `match 97 / older 139`(`dsh-univer-office`、
|
||||
* `dshmarket` 这些**在平台上跑得好好的**都被判成 "只兼容更旧版本");换成 prerelease
|
||||
* 容忍后是 `match 214 / older 22`。⇒ **此处的"匹配"= prerelease 容忍语义**。
|
||||
* (`plugin-compat.ts` 用默认语义是**有意的**:那里要跟 pnpm 的实际安装判定一致 —— 两处
|
||||
* 语义不同是**正确的**,别"统一"掉。)
|
||||
*
|
||||
* @module dshs/web/plugin-dsh-compat
|
||||
*/
|
||||
|
||||
import { platformPackageVersion } from './dsh-install.js'
|
||||
|
||||
const DS_SCOPE = '@deepseek-ai/'
|
||||
/** 伞包包名:它带版本声明时最值得看(子包版本通常与它同位)。 */
|
||||
const UMBRELLA = '@deepseek-ai/dsh'
|
||||
|
||||
/** 兼容性分类。 */
|
||||
export type DshCompat = 'match' | 'none' | 'newer' | 'older' | 'unknown'
|
||||
|
||||
export interface DshCompatVerdict {
|
||||
/** `match` 声明满足 / `none` 未声明任何平台依赖 / `newer` 要求更高的 dsh / `older` 只允许更旧的 / `unknown` 判不了。 */
|
||||
kind: DshCompat
|
||||
/** `kind === 'newer'` 时给出**它要求的最低 dsh 版本**(能算出来才有)。 */
|
||||
requires?: string
|
||||
/** 判据摘要(排障用,落日志/缓存,不下发给浏览器)。 */
|
||||
why?: string
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 平台版本表(含伞包)—— 唯一实现在 `dsh-install.ts`
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 平台某 `@deepseek-ai/*` 包的真实版本;平台没有 → `null`。
|
||||
* 与本文件的伞包解析需求同源:`dsh-install.platformPackageVersion()` **含伞包**
|
||||
* (伞包不在自己的 `node_modules` 里,那条坑在 `dsh-install.ts` 里有完整记录)。
|
||||
*/
|
||||
export function platformVersionOf(pkg: string): string | null {
|
||||
return platformPackageVersion(pkg)
|
||||
}
|
||||
|
||||
/** 当前平台的 dsh 伞包版本(`0.1.5-rc.1` 这种);读不到 → `null`。 */
|
||||
export function platformDshVersion(): string | null {
|
||||
return platformPackageVersion(UMBRELLA)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* semver(与 plugin-compat 同款"软依赖"载入:拿不到就全部 unknown)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
type SemverLike = {
|
||||
satisfies(v: string, r: string, o?: { includePrerelease?: boolean }): boolean
|
||||
minVersion(r: string): { version: string } | null
|
||||
lt(a: string, b: string): boolean
|
||||
}
|
||||
|
||||
let semverPromise: Promise<SemverLike | null> | null = null
|
||||
|
||||
function loadSemver(): Promise<SemverLike | null> {
|
||||
semverPromise ??= (async () => {
|
||||
try {
|
||||
const m = (await import('semver')) as unknown as { default?: SemverLike } & SemverLike
|
||||
return (m.default ?? m) as SemverLike
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
return semverPromise
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 声明抽取 + 判定
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 从 npm 包的 manifest 里抽出**与平台相关**的声明:`@deepseek-ai/*` 的
|
||||
* peer / optional / 运行依赖,外加 `engines.dsh`(有插件用它写版本要求)。
|
||||
* @param manifest npm registry 返回的 `versions[<ver>]` 对象(字段可能缺失)
|
||||
*/
|
||||
export function platformDeclsOf(manifest: unknown): Map<string, string> {
|
||||
const out = new Map<string, string>()
|
||||
if (typeof manifest !== 'object' || manifest === null) return out
|
||||
const m = manifest as Record<string, Record<string, unknown> | undefined>
|
||||
for (const bucket of ['peerDependencies', 'optionalDependencies', 'dependencies']) {
|
||||
const bag = m[bucket]
|
||||
if (typeof bag !== 'object' || bag === null) continue
|
||||
for (const [k, v] of Object.entries(bag)) {
|
||||
if (k.startsWith(DS_SCOPE) && typeof v === 'string') out.set(k, v)
|
||||
}
|
||||
}
|
||||
const eng = m['engines']
|
||||
if (typeof eng === 'object' && eng !== null && typeof (eng as Record<string, unknown>)['dsh'] === 'string') {
|
||||
out.set(UMBRELLA, (eng as Record<string, string>)['dsh'])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定「按该插件声明的平台依赖范围,能否装在**当前** dsh 上」。
|
||||
* @param manifest npm registry 的版本 manifest(要用 `platformDeclsOf` 能认的那一层)
|
||||
*/
|
||||
export async function judgeDshCompat(manifest: unknown): Promise<DshCompatVerdict> {
|
||||
const decls = platformDeclsOf(manifest)
|
||||
if (decls.size === 0) return { kind: 'none', why: '未声明任何 @deepseek-ai/* 依赖' }
|
||||
|
||||
const semver = await loadSemver()
|
||||
if (semver === null) return { kind: 'unknown', why: 'semver 不可用' }
|
||||
|
||||
let satisfied = 0
|
||||
let unjudged = 0
|
||||
/** 不满足且「要求比平台更高」→ 记下它要求的最低版本。 */
|
||||
let newer: { pkg: string; range: string; min: string } | null = null
|
||||
const older: string[] = []
|
||||
|
||||
for (const [pkg, range] of decls) {
|
||||
const v = platformVersionOf(pkg)
|
||||
// 平台没有这个包 ⇒ 不据此断言(与 plugin-compat 判据 A 同一处理:可能是插件自带)
|
||||
if (v === null) {
|
||||
unjudged++
|
||||
continue
|
||||
}
|
||||
let ok: boolean
|
||||
try {
|
||||
ok = semver.satisfies(v, range, { includePrerelease: true })
|
||||
} catch {
|
||||
unjudged++
|
||||
continue
|
||||
}
|
||||
if (ok) {
|
||||
satisfied++
|
||||
continue
|
||||
}
|
||||
let min: string | null = null
|
||||
try {
|
||||
min = semver.minVersion(range)?.version ?? null
|
||||
} catch {
|
||||
min = null
|
||||
}
|
||||
if (min !== null && semver.lt(v, min)) {
|
||||
// 平台版本低于该范围允许的最低版本 ⇒ 插件要求**更新的** dsh
|
||||
if (newer === null || semver.lt(newer.min, min)) newer = { pkg, range, min }
|
||||
} else {
|
||||
older.push(`${pkg}(${v} ∉ ${range})`)
|
||||
}
|
||||
}
|
||||
|
||||
// 只要有一条"要求更高",这条插件就属于「需要升级 dsh 才能用」—— 比"只兼容更旧"更重要。
|
||||
if (newer !== null) {
|
||||
return { kind: 'newer', requires: newer.min, why: `${newer.pkg} 要求 ${newer.range}` }
|
||||
}
|
||||
if (older.length > 0) return { kind: 'older', why: older.slice(0, 3).join(';') }
|
||||
if (satisfied > 0) return { kind: 'match', why: `满足 ${satisfied} 条声明` }
|
||||
if (unjudged > 0) return { kind: 'unknown', why: `平台无对应包或范围不可解析(${unjudged} 条)` }
|
||||
return { kind: 'none', why: '无可判定的声明' }
|
||||
}
|
||||
+183
-3
@@ -28,6 +28,7 @@ import { mkdir, rename, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { requireAdmin } from '../middleware/authn.js'
|
||||
import { httpError } from '../security-scan.js'
|
||||
import { type DshCompat, type DshCompatVerdict, judgeDshCompat, platformDshVersion } from '../plugin-dsh-compat.js'
|
||||
import { stageTgzArchive } from './business-plugins.js'
|
||||
|
||||
/** 官方目录规范地址(同一份内容亦镜像为 npm 包 `dsh-plugin-catalog`)。 */
|
||||
@@ -208,9 +209,164 @@ export const whitelistRoutes: FastifyPluginAsync = async (app) => {
|
||||
throw httpError(400, `该插件需从源码构建安装${entry.install === '' ? '' : `(${entry.install})`},暂不支持一键导入`)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* dsh 版本兼容判定(档案 92)
|
||||
*
|
||||
* 由来(用户 2026-09-14):「官方推荐插件列表**只能显示匹配当前 dsh 版本 和 超过当前版本**的
|
||||
* 插件(超过的要明确标注)」。官方目录**不带 dsh 版本字段**(只有插件自身 `version`)⇒ 只能
|
||||
* 逐包去 npm 取 `peerDependencies / engines`,与**平台真实版本**比(判定细节见
|
||||
* `src/web/plugin-dsh-compat.ts` 头部注释,含「必须 includePrerelease」的实测缘由)。
|
||||
*
|
||||
* 成本与缓存:一条 = 1 次 `registry.npmjs.org` 的**精简 packument**(十几 KB,不下载 tarball)。
|
||||
* 结果按 `<npm>@<version>` 落盘缓存,TTL 7 天(peer 范围极少变)。首次冷启动靠 `warmCompat`
|
||||
* 在后台预热;请求内只给有限预算,超预算的条目判 `unknown`(**显示并标注"未验证"**,
|
||||
* 绝不因为一次网络抖动就把插件**静默藏掉**)。
|
||||
* ------------------------------------------------------------------ */
|
||||
const compatFile = join(cacheDir, 'npm-dsh-compat.json')
|
||||
/** 缓存结构版本(判定口径变了就 +1,免得旧口径结论被复用)。 */
|
||||
const COMPAT_VERSION = 1
|
||||
const COMPAT_TTL = 7 * 24 * 60 * 60 * 1000
|
||||
/** 请求内判定的预算:超时即判 `unknown`(页面仍可用,后台预热会补上)。 */
|
||||
const COMPAT_BUDGET_MS = 6_000
|
||||
/** 请求内并发(别再高:registry 有软限流)。 */
|
||||
const COMPAT_CONCURRENCY = 12
|
||||
/** 预热并发(更保守:这是后台任务,不赶时间)。 */
|
||||
const WARM_CONCURRENCY = 6
|
||||
/** 每次请求最多判定多少条(列表页上限 300,多留一点余量给"过滤后补位")。 */
|
||||
const COMPAT_WINDOW = 360
|
||||
|
||||
interface CompatRecord {
|
||||
kind: DshCompat
|
||||
requires?: string
|
||||
why?: string
|
||||
at: number
|
||||
}
|
||||
|
||||
let compatMem: Map<string, CompatRecord> | null = null
|
||||
let warmStarted = false
|
||||
|
||||
const compatKey = (entry: WhitelistEntry): string | null =>
|
||||
entry.npm === null || entry.importKind === 'source' ? null : `${entry.npm}@${entry.version ?? ''}`
|
||||
|
||||
const loadCompat = (): Map<string, CompatRecord> => {
|
||||
if (compatMem !== null) return compatMem
|
||||
compatMem = new Map()
|
||||
try {
|
||||
if (existsSync(compatFile)) {
|
||||
const raw = JSON.parse(readFileSync(compatFile, 'utf8')) as { version?: number; items?: Record<string, CompatRecord> }
|
||||
if (raw.version === COMPAT_VERSION && typeof raw.items === 'object' && raw.items !== null) {
|
||||
for (const [k, v] of Object.entries(raw.items)) if (typeof v?.kind === 'string') compatMem.set(k, v)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 缓存坏了就当空表(不影响功能,只是要重判) */
|
||||
}
|
||||
return compatMem
|
||||
}
|
||||
|
||||
let compatDirty = false
|
||||
const saveCompat = (): void => {
|
||||
if (!compatDirty || compatMem === null) return
|
||||
try {
|
||||
mkdirSync(cacheDir, { recursive: true, mode: 0o755 })
|
||||
const items: Record<string, CompatRecord> = {}
|
||||
for (const [k, v] of compatMem) items[k] = v
|
||||
writeFileSync(compatFile, JSON.stringify({ version: COMPAT_VERSION, items }))
|
||||
compatDirty = false
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'whitelist: dsh 兼容缓存写入失败(下次请求重判)')
|
||||
}
|
||||
}
|
||||
|
||||
/** 取单条判定;命中缓存(且未过期)直接返回,否则查 npm registry。 */
|
||||
const compatOf = async (entry: WhitelistEntry): Promise<DshCompatVerdict> => {
|
||||
const key = compatKey(entry)
|
||||
if (key === null) return { kind: 'none', why: '未发 npm 包' }
|
||||
const cache = loadCompat()
|
||||
const hit = cache.get(key)
|
||||
if (hit !== undefined && Date.now() - hit.at < COMPAT_TTL) return { kind: hit.kind, requires: hit.requires, why: hit.why }
|
||||
|
||||
let verdict: DshCompatVerdict
|
||||
try {
|
||||
const name = entry.npm as string
|
||||
// 精简 packument:带 `peerDependencies`,体积远小于完整 packument
|
||||
const res = await fetch(`${REGISTRY}/${encodeURIComponent(name).replace('%40', '@')}`, {
|
||||
headers: { accept: 'application/vnd.npm.install-v1+json' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const meta = (await res.json()) as { 'dist-tags'?: { latest?: string }; versions?: Record<string, unknown> }
|
||||
const latest = meta['dist-tags']?.latest
|
||||
// 优先用「目录声明的版本」;缺了才用 latest(目录版本 = 用户会装到的那个)
|
||||
const manifest = (entry.version !== null ? meta.versions?.[entry.version] : undefined) ?? (latest === undefined ? undefined : meta.versions?.[latest])
|
||||
if (manifest === undefined) throw new Error('包内找不到该版本')
|
||||
verdict = await judgeDshCompat(manifest)
|
||||
} catch (err) {
|
||||
// 网络/元数据问题 ≠ 不兼容:判 `unknown` 并**照常显示**(fixture: 见本文件头部注释)
|
||||
verdict = { kind: 'unknown', why: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
cache.set(key, { ...verdict, at: Date.now() })
|
||||
compatDirty = true
|
||||
return verdict
|
||||
}
|
||||
|
||||
/** 批量判定(限并发 + 限总预算);返回 id → 判定。 */
|
||||
const compatBatch = async (list: WhitelistEntry[], budgetMs: number): Promise<Map<string, DshCompatVerdict>> => {
|
||||
const out = new Map<string, DshCompatVerdict>()
|
||||
const deadline = Date.now() + budgetMs
|
||||
const queue = [...list]
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(COMPAT_CONCURRENCY, queue.length) }, async () => {
|
||||
while (queue.length > 0) {
|
||||
if (Date.now() > deadline) return
|
||||
const e = queue.shift() as WhitelistEntry
|
||||
out.set(e.id, await compatOf(e))
|
||||
}
|
||||
}),
|
||||
)
|
||||
saveCompat()
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台预热:把**全部可导入条目**的判定补进缓存(进程内只跑一次)。
|
||||
* 不 await —— 首次冷启动的列表请求不该等这 1800 次查询;预热完成后列表即为缓存命中。
|
||||
*/
|
||||
const warmCompat = (entries: WhitelistEntry[]): void => {
|
||||
if (warmStarted) return
|
||||
warmStarted = true
|
||||
const queue = entries.filter((e) => compatKey(e) !== null)
|
||||
void (async () => {
|
||||
let done = 0
|
||||
const t0 = Date.now()
|
||||
await Promise.all(
|
||||
Array.from({ length: WARM_CONCURRENCY }, async () => {
|
||||
while (queue.length > 0) {
|
||||
const e = queue.shift() as WhitelistEntry
|
||||
try {
|
||||
await compatOf(e)
|
||||
} catch {
|
||||
/* compatOf 自身不会抛;这里只是双保险 */
|
||||
}
|
||||
done++
|
||||
}
|
||||
}),
|
||||
)
|
||||
saveCompat()
|
||||
app.log.info(`whitelist: dsh 兼容预热完成 ${done} 条,用时 ${Math.round((Date.now() - t0) / 1000)}s`)
|
||||
})()
|
||||
}
|
||||
|
||||
app.get('/api/plugins/whitelist', { preHandler: requireAdmin }, async (request) => {
|
||||
const { q, category, onlyImportable } = request.query as { q?: string; category?: string; onlyImportable?: string }
|
||||
const { q, category, onlyImportable, dshCompat } = request.query as {
|
||||
q?: string
|
||||
category?: string
|
||||
onlyImportable?: string
|
||||
dshCompat?: string
|
||||
}
|
||||
const { fetchedAt, entries, categoryLabels, stale } = await getIndex()
|
||||
// 首次进列表页就把全量判定在后台热起来(不阻塞本次响应)
|
||||
warmCompat(entries)
|
||||
let list = entries
|
||||
if (category !== undefined && category !== '') list = list.filter((e) => e.category === category)
|
||||
if (onlyImportable === '1') list = list.filter((e) => e.importKind !== 'source')
|
||||
@@ -230,19 +386,43 @@ export const whitelistRoutes: FastifyPluginAsync = async (app) => {
|
||||
const categories = [...counts.entries()]
|
||||
.map(([id, count]) => ({ id, label: categoryLabels[id] ?? id, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
|
||||
/** 兼容判定结果(`dshCompat=all` 时只标注、不过滤,便于排障)。 */
|
||||
const onlyCompat = dshCompat !== 'all'
|
||||
// 判定窗口取「比页面上限略大」:过滤掉"只兼容更旧版本"的后仍能把页面填满
|
||||
const window = list.slice(0, COMPAT_WINDOW)
|
||||
const verdicts = await compatBatch(
|
||||
onlyCompat ? window : window.filter((e) => compatKey(e) !== null),
|
||||
COMPAT_BUDGET_MS,
|
||||
)
|
||||
const annotate = (e: WhitelistEntry): WhitelistEntry & { dshCompat: DshCompat; dshRequires?: string } => {
|
||||
const v = verdicts.get(e.id)
|
||||
return v === undefined ? { ...e, dshCompat: 'unknown' as DshCompat } : { ...e, dshCompat: v.kind, ...(v.requires === undefined ? {} : { dshRequires: v.requires }) }
|
||||
}
|
||||
const shownWindow = onlyCompat ? window.filter((e) => verdicts.get(e.id)?.kind !== 'older') : window
|
||||
const hiddenByDsh = onlyCompat ? window.length - shownWindow.length : 0
|
||||
const plugins = shownWindow.slice(0, 300).map(annotate)
|
||||
|
||||
return {
|
||||
fetchedAt,
|
||||
stale,
|
||||
total: entries.length,
|
||||
count: list.length,
|
||||
count: plugins.length,
|
||||
/** 过滤前的可展示条数(本次筛选条件命中数),供前端说明「被隐藏了多少」。 */
|
||||
countAll: window.length,
|
||||
hiddenByDsh,
|
||||
/** 当前平台 dsh 版本 —— 前端据此显示「已按 dsh X 过滤」与「需 ≥ Y」的对比。 */
|
||||
dshVersion: platformDshVersion(),
|
||||
compatFiltered: onlyCompat,
|
||||
importable: entries.filter((e) => e.importKind !== 'source').length,
|
||||
categories,
|
||||
plugins: list.slice(0, 300),
|
||||
plugins,
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/plugins/whitelist/refresh', { preHandler: requireAdmin }, async () => {
|
||||
const { entries, fetchedAt } = await getIndex(true)
|
||||
warmCompat(entries)
|
||||
return { ok: true, total: entries.length, importable: entries.filter((e) => e.importKind !== 'source').length, fetchedAt }
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user