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:
admin committed 2026-09-14 21:13:32 +08:00
1 parent 1d72e8f9c8
commit 29c57a8207
11 files changed
+1474 -209

No files matched your search

+183 -3
View File
@@ -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 }
})