From 1d72e8f9c8d853c879c63af6a475e0692991d68f Mon Sep 17 00:00:00 2001 From: maogeigei Date: Mon, 14 Sep 2026 04:52:35 +0800 Subject: [PATCH] =?UTF-8?q?chore(web):=20=E9=99=8D=E7=BA=A7=E7=95=99?= =?UTF-8?q?=E7=97=95=20+=20=E6=94=B6=E5=B0=BE=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88=E6=94=B9=E4=B8=BA=E5=8F=AF?= =?UTF-8?q?=E8=A7=82=E6=B5=8B=EF=BC=9Bpicker=20=E5=8D=87=200.1.5=20?= =?UTF-8?q?=E6=B6=88=E6=BC=82=E7=A7=BB=EF=BC=88=E6=A1=A3=E6=A1=88=2088=20/?= =?UTF-8?q?=20T07=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 【留痕】model-catalog 读不到目录:console.warn(每进程一次)+ 新增 catalogDiagnostics(),并把结果透出到 GET /api/me/model-providers 的 catalog 字段;plugin-compat 的 platformPkgCount()=0 同样告警一次 ⇒「功能没做」与「做了但读不到目录」从此**可区分**(这正是那个 P1 长期潜伏的原因) - 【前端】插件 0.3.13:厂家目录不可读 / 为空时**如实提示**(含目录路径),不再静默给空列表 - 【消漂移】poc/workspace-scoped-picker 升 0.1.5 并重打包铺发 —— 两实例已装 0.1.5 且含按序探测的新代码(此前只改了源码、线上还是旧候选) - 【文档】README 补 DSHS_PACKAGE_DIR / DSHS_COMPAT_ROOT / DSHS_PI_AI_DATA_DIR(逃生口此前**未在任何地方暴露**) - 【回归】verify-dsh-install 扩到 6 组(含「读不到 ≠ 目录为空」的区分);verify-platform-admin-section 加 2 条断言(词典 284/284) - 真环境回归:picker 在 /usr/local 仍 import OK(没把本来能用的改坏)|providers 39(端点回 38)|platformPkgCount 223 --- README.md | 3 ++ poc/business-plugins/lib/client.js | 28 +++++++++++++++-- poc/business-plugins/package.json | 2 +- poc/workspace-scoped-picker/package.json | 2 +- scripts/verify-dsh-install.mjs | 26 ++++++++++++++++ scripts/verify-platform-admin-section.mjs | 17 +++++++++++ src/web/model-catalog.ts | 37 +++++++++++++++++++++++ src/web/plugin-compat.ts | 13 ++++++++ src/web/routes/auth.ts | 5 ++- 9 files changed, 127 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1cc81ed..8bc5c53 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,9 @@ node lib/cli.js --port 3080 --db ./dev.local.db | `DSHS_COOKIE_DOMAIN` | 空 | 会话 cookie 的 `Domain`(如 `.dsh.example.com`,注意前导点);空 = host-only | | `DSHS_SESSION_TTL` | `604800` | 会话有效期(秒,默认 7 天) | | `DSHS_MAX_UPLOAD` | `25MB` | 上传请求体上限(base64 JSON) | +| `DSHS_PACKAGE_DIR` | (自动探测) | **平台内置 dsh 的包根**。默认**按序探测**:`DSHS_DSH_BIN` 解软链反推 → `/usr/local/lib/node_modules`、`/usr/lib/node_modules` → `npm root -g`。⚠️ `npm i -g` 的落点随发行版变(发行版包管理器装的 Node 常落 `/usr/lib/node_modules`),**写死会让「模型设置」的厂家目录与「插件兼容性预检」静默失效**(无报错、只剩降级行为)⇒ 探测失败时才需要显式设它。 | +| `DSHS_COMPAT_ROOT` | (= `DSHS_PACKAGE_DIR`) | 同上的旧名(兼容);同时接受 `DSH_PACKAGE_DIR` / `DSH_COMPAT_ROOT`(导出侧命名) | +| `DSHS_PI_AI_DATA_DIR` | (自动探测) | `@earendil-works/pi-ai` 的**厂家目录**数据位置(`<包根>/node_modules/@earendil-works/pi-ai/dist/providers/data`);一般无需设置 | | `DSHS_RESTART_BACKOFF` | `1000` | 子 DSH 崩溃后的自动重启延迟(毫秒) | | `DSHS_ENABLE_PATCH` | `false` | 是否向子 DSH 注入 `--patch`(运行时插件 + 每文件夹插件;旧版 dsh 不支持时可关) | | `DSHS_SECRET` | 自动生成 | 每用户密钥库的加密主密钥;未设置时生成并持久化到 `/secret.key`(0600) | diff --git a/poc/business-plugins/lib/client.js b/poc/business-plugins/lib/client.js index ecda252..670492e 100644 --- a/poc/business-plugins/lib/client.js +++ b/poc/business-plugins/lib/client.js @@ -436,7 +436,11 @@ window.__ModuleLoader__.load({ "ms.e.api": "不支持该协议", "ms.e.models": "请至少填一个模型 id", "ms.e.taken": "该厂家标识已被占用,换一个", - "ms.e.provider": "官方目录里没有这个厂家" + "ms.e.provider": "官方目录里没有这个厂家", + + // 目录可读性(2026-09-14):读不到时必须**如实说明**,别让用户以为是"功能没做" + "ms.catUnreadable": "⚠️ 平台未能读取官方厂家目录({dir})—— 当前只能使用内置 DeepSeek 或自定义厂家。请联系管理员检查安装路径配置。", + "ms.catEmpty": "官方厂家目录为空(该部署可能未随附 pi-ai 厂家数据)—— 当前只能使用内置 DeepSeek 或自定义厂家。" }; /** English dictionary, key-set complete against zh. */ var en = { @@ -722,7 +726,9 @@ window.__ModuleLoader__.load({ "ms.e.api": "Unsupported protocol", "ms.e.models": "Add at least one model id", "ms.e.taken": "That provider id is taken — pick another", - "ms.e.provider": "That provider is not in the official catalog" + "ms.e.provider": "That provider is not in the official catalog", + "ms.catUnreadable": "⚠️ The platform could not read the official provider catalog ({dir}) — only the built-in DeepSeek and custom providers are available. Ask your administrator to check the install-path configuration.", + "ms.catEmpty": "The official provider catalog is empty (this deployment may not ship the pi-ai provider data) — only the built-in DeepSeek and custom providers are available." }; /** @@ -1616,6 +1622,11 @@ window.__ModuleLoader__.load({ var fApi = useState(""); var fModels = useState(""); var fShow = useState(false); + // 后端给的**厂家目录可读性**诊断:读不到时要说清"为什么只剩两项", + // 而不是静默给个空列表(2026-09-14:这个静默曾让一个 P1 布局缺陷长期不可见)。 + var catState = useState(null); + var catalog = catState[0]; + var setCatalog = catState[1]; function load() { setLoading(true); @@ -1625,7 +1636,10 @@ window.__ModuleLoader__.load({ ]) .then(function (res) { if (res[0]) setData(res[0]); - if (res[1] && res[1].providers) setProviders(res[1].providers); + if (res[1]) { + if (res[1].providers) setProviders(res[1].providers); + setCatalog(res[1].catalog || null); + } setLoading(false); }) .catch(function () { setLoading(false); setMsg(t("ms.loadFailed")); }); @@ -1761,6 +1775,14 @@ window.__ModuleLoader__.load({ // ── 新增厂家:**先选厂家**,目录厂家只需填 API 密钥(官方「新增」卡片同构)── jsxRuntime.jsxs("div", { className: "pa-box", children: [ jsxRuntime.jsx("div", { className: "pa-card-h", children: jsxRuntime.jsx("span", { children: t("ms.add") }) }), + providers.length === 0 + ? jsxRuntime.jsx("p", { + className: "pa-hint", + children: catalog && catalog.readable === false + ? t("ms.catUnreadable").replace("{dir}", catalog.dir || "?") + : t("ms.catEmpty") + }) + : null, jsxRuntime.jsxs("div", { className: "pa-row", children: [ jsxRuntime.jsx("span", { className: "pa-hint", style: { margin: 0 }, children: t("ms.pickProvider") }), jsxRuntime.jsx("select", { className: "pa-input", value: pick, onChange: function (e) { fPick[1](e.target.value); setShow(false); }, children: [ diff --git a/poc/business-plugins/package.json b/poc/business-plugins/package.json index b290a2a..3e12e7f 100644 --- a/poc/business-plugins/package.json +++ b/poc/business-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@dsh-local/business-plugins", - "version": "0.3.12", + "version": "0.3.13", "description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.10(2026-09-13):① **「服务管理」改名「实例管理」**、**「密钥管理」改名「模型管理」**(用户要求;门户导航/卡片同步改名);② **「实例管理」页可管任意用户**(用户要求「admin 要能管所有用户的服务」)—— 工具条新增「用户」下拉,切换后文件树/启停/打开全部针对所选用户,走新开的 `/api/admin/users/:id/{fs,dsh}`(requireAdmin)|v0.3.8(2026-09-13):**撤掉「我的密钥」分区** —— 模型配置统一走**官方「设置 → 模型」页**(用户要求「界面交互和官方一模一样」⇒ 不仿制、直接放开官方页,见档案 86)。平台侧同步两处:① `ensure-role-profile-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。", "type": "module", "main": "lib/index.js", diff --git a/poc/workspace-scoped-picker/package.json b/poc/workspace-scoped-picker/package.json index c31939d..69aed1b 100644 --- a/poc/workspace-scoped-picker/package.json +++ b/poc/workspace-scoped-picker/package.json @@ -1,6 +1,6 @@ { "name": "@dsh-local/workspace-scoped-picker", - "version": "0.1.4", + "version": "0.1.5", "description": "会话内工作区目录选择器:列举/建目录的根固定在当前用户自有目录(档案 18;所有用户含 admin)", "type": "module", "main": "lib/index.js", diff --git a/scripts/verify-dsh-install.mjs b/scripts/verify-dsh-install.mjs index 045fe35..51e3dfe 100644 --- a/scripts/verify-dsh-install.mjs +++ b/scripts/verify-dsh-install.mjs @@ -113,6 +113,32 @@ const real = dshPackageRoot() ok('返回非空绝对路径', typeof real === 'string' && real.length > 0 && real.startsWith('/'), real) ok('dshScopeDir 形状正确', dshScopeDir().endsWith(join('node_modules', '@deepseek-ai')), dshScopeDir()) +// ── 6. 目录可读性诊断:**「读不到」必须与「目录为空」可区分**(2026-09-14 留痕)───── +console.log('\n[6] 目录可读性诊断(静默降级 → 可观测)') +const cat = await import(pathToFileURL(join(ROOT, 'lib/web/model-catalog.js')).href) +const warnings = [] +const realWarn = console.warn +console.warn = (...a) => warnings.push(a.join(' ')) + +clearEnv() +process.env.PI_AI_DATA_DIR = join(T, 'no-such-data-dir') // 不存在 ⇒ 读不到 +process.env.DSH_PACKAGE_DIR = '/nonexistent' +await cat.listCatalogProviders(true) +const d1 = cat.catalogDiagnostics() +ok('读不到时 readable=false', d1 !== null && d1.readable === false, JSON.stringify(d1)) +ok('读不到时 count=0', d1 !== null && d1.count === 0) +ok('读不到时**告警过一次**(不再静默)', warnings.some((w) => w.includes('厂家目录不可读')), warnings[0]?.slice(0, 60) ?? '(无)') + +clearEnv() +const emptyDir = join(T, 'empty-data') +mkdirSync(emptyDir, { recursive: true }) +process.env.PI_AI_DATA_DIR = emptyDir // 存在但没有任何 .json ⇒ "目录为空" +await cat.listCatalogProviders(true) +const d2 = cat.catalogDiagnostics() +ok('目录可读但为空时 readable=true(与"读不到"可区分)', d2 !== null && d2.readable === true && d2.count === 0, JSON.stringify(d2)) +ok('已告警过就不再刷日志', warnings.filter((w) => w.includes('厂家目录不可读')).length === 1, String(warnings.length)) +console.warn = realWarn + rmSync(T, { recursive: true, force: true }) console.log('') if (failed > 0) { diff --git a/scripts/verify-platform-admin-section.mjs b/scripts/verify-platform-admin-section.mjs index 5a0afc0..142b4f4 100644 --- a/scripts/verify-platform-admin-section.mjs +++ b/scripts/verify-platform-admin-section.mjs @@ -71,6 +71,7 @@ const DATA = { { id: "moonshotai-cn", label: "月之暗面 Moonshot(中国)", api: "openai-completions", baseURL: "https://api.moonshot.cn/v1", cn: true, modelCount: 12, models: ["kimi-k2", "moonshot-v1-8k"] }, { id: "openai", label: "OpenAI", api: "openai-responses", baseURL: null, cn: false, modelCount: 20, models: ["gpt-4o"] }, ], + catalog: { dir: "/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@earendil-works/pi-ai/dist/providers/data", readable: true, count: 2 }, }, }; const sandbox = { @@ -242,6 +243,22 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include tMs.includes("中国大陆") && tMs.includes("国际") && tMs.includes("月之暗面 Moonshot(中国)") && tMs.includes("OpenAI")); ok("模型设置:默认选中「内置 DeepSeek」时给出回落说明", tMs.includes("密钥留空则回落到平台共享密钥")); + // 2026-09-14 留痕:目录读不到时要**如实说明**,不能静默给个空列表 + { + const saved = DATA["/api/me/model-providers"]; + DATA["/api/me/model-providers"] = { + providers: [], + catalog: { dir: "/opt/nope/pi-ai/dist/providers/data", readable: false, count: 0 }, + }; + const tBad = text(await render(ms.Comp)).join(" | "); + ok("模型设置:目录不可读时如实提示(含目录路径,不静默空列表)", + tBad.includes("未能读取官方厂家目录") && tBad.includes("/opt/nope/pi-ai"), tBad.slice(0, 0) + "命中"); + DATA["/api/me/model-providers"] = { providers: [], catalog: { dir: "/x", readable: true, count: 0 } }; + const tEmpty = text(await render(ms.Comp)).join(" | "); + ok("模型设置:目录为空的文案与「读不到」不同", + tEmpty.includes("官方厂家目录为空") && !tEmpty.includes("未能读取官方厂家目录")); + DATA["/api/me/model-providers"] = saved; + } ok("模型设置:词典备齐「只填密钥」相关文案(目录提示 / 展开模型)", !!ZH["ms.catalogHint"] && !!ZH["ms.showModels"] && !!ZH["ms.hideModels"] && !!ZH["ms.pickProvider"]); // 落地在 spawn 时 ⇒ 文案必须说清"重启才生效",否则用户会以为保存即生效 diff --git a/src/web/model-catalog.ts b/src/web/model-catalog.ts index faa477c..ca2a015 100644 --- a/src/web/model-catalog.ts +++ b/src/web/model-catalog.ts @@ -175,9 +175,37 @@ function parseProvider(id: string, raw: string): CatalogProvider | null { let cache: { at: number; list: CatalogProvider[] } | null = null const TTL_MS = 10 * 60 * 1000 +/** 读取结果的可读性诊断。 */ +export interface CatalogDiagnostics { + /** 实际使用的目录(`dsh-install.ts` 探测出来的)。 */ + dir: string + /** 能否读到(`false` = 探测/读取失败 ⇒ 界面只剩内置 DeepSeek + 自定义网关)。 */ + readable: boolean + /** 读到了几个厂家。 */ + count: number +} + +/** + * 目录可读性诊断(2026-09-14 加,见 `04-调整方案/88`)。 + * + * 为什么必须加:定位/读取失败原本**完全静默**(catch 后返回 `[]`),于是 + * 「功能没做」与「功能做了但读不到目录」在界面上**一模一样** —— 一个 P1 缺陷就这样在 + * 另一种安装布局(`npm root -g` = `/usr/lib/node_modules`)上潜伏了很久,最后靠人工 + * 比对才看出来。⇒ ① 读不到时 `console.warn`(每进程一次,不刷日志); + * ② 把结果透出到 `/api/me/model-providers`,让「到底读没读到」可观测。 + */ +let diag: CatalogDiagnostics | null = null +let warnedUnreadable = false + +/** 最近一次读取的可读性诊断(尚未读过时返回 `null`)。 */ +export function catalogDiagnostics(): CatalogDiagnostics | null { + return diag +} + /** * 读出目录里的全部厂家(按中文名/官方名排序)。 * 目录不存在(例如本地开发机)⇒ 返回空数组,**不抛错**:调用方据此降级为"只有内置 + 自定义"。 + * ⚠️ 降级**不是无声的** —— 见 `catalogDiagnostics()` 与上方的 `console.warn`。 */ export async function listCatalogProviders(force = false): Promise { if (!force && cache !== null && Date.now() - cache.at < TTL_MS) return cache.list @@ -186,6 +214,14 @@ export async function listCatalogProviders(force = false): Promise a.label.localeCompare(b.label, 'zh-Hans-CN')) + diag = { dir, readable: true, count: out.length } cache = { at: Date.now(), list: out } return out } diff --git a/src/web/plugin-compat.ts b/src/web/plugin-compat.ts index ddbba9f..01c067e 100644 --- a/src/web/plugin-compat.ts +++ b/src/web/plugin-compat.ts @@ -72,11 +72,24 @@ function platformPkgDir(pkg: string): string { return join(dshScopeDir(), shortName(pkg)) } +/** 是否已经就「平台包目录不可读」告过一次警(避免刷日志)。 */ +let warnedScopeUnreadable = false + /** 平台内置 @deepseek-ai 包的数量(目录级,低成本)。 */ export function platformPkgCount(): number { try { return readdirSync(dshScopeDir()).filter((n) => !n.startsWith('.')).length } catch { + // ⚠️ 这里**不能只返回 0**:调用方会把它当成"没有平台包"⇒ 预检直接退化成 + // 「平台包目录不可读」——**安全网静默失效**(上传不兼容插件不再被拦)。 + // 2026-09-14:加一次告警,让"读不到"这件事可观测(见 `04-调整方案/88`)。 + if (!warnedScopeUnreadable) { + warnedScopeUnreadable = true + console.warn( + `[plugin-compat] 平台包目录不可读:${dshScopeDir()} —— 插件兼容性预检将退化为「不检查」。` + + ' 检查 DSHS_DSH_BIN / DSH_COMPAT_ROOT 是否正确(见 src/web/dsh-install.ts)。', + ) + } return 0 } } diff --git a/src/web/routes/auth.ts b/src/web/routes/auth.ts index 9bbb3d1..e09b9e9 100644 --- a/src/web/routes/auth.ts +++ b/src/web/routes/auth.ts @@ -10,7 +10,7 @@ import { requireAuth } from '../middleware/authn.js' import { homeRoot, userRoot } from '../../fs/workspace.js' import { deriveKey, encrypt } from '../../crypto.js' import { toPublicUser } from '../../db/types.js' -import { isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js' +import { catalogDiagnostics, isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js' import { PROTOCOLS } from '../model-landing.js' import { clearSessionCookie, @@ -243,6 +243,9 @@ export const authRoutes: FastifyPluginAsync = async (app) => { // 只回前 60 个模型名给界面展示(足量的"看到它自带什么"),不整份下发。 models: p.models.slice(0, 60).map((m) => m.name ?? m.id), })), + // 目录**可读性**:读不到时前端要如实说明"为什么只剩两项",而不是静默给个空列表 + // (2026-09-14:这个静默曾让一个 P1 布局缺陷长期不可见,见 `04-调整方案/88`)。 + catalog: catalogDiagnostics(), } })