chore(web): 降级留痕 + 收尾 —— 静默失效改为可观测;picker 升 0.1.5 消漂移(档案 88 / T07)

- 【留痕】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
This commit is contained in:
admin committed 2026-09-14 04:52:35 +08:00
1 parent cbaf3ad653
commit 1d72e8f9c8
9 files changed
+127 -6

No files matched your search

+3
View File
@@ -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` | 自动生成 | 每用户密钥库的加密主密钥;未设置时生成并持久化到 `<dataRoot>/secret.key`(0600) |
+25 -3
View File
@@ -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: [
+1 -1
View File
@@ -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.Line truncated
"type": "module",
"main": "lib/index.js",
+1 -1
View File
@@ -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",
+26
View File
@@ -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 // 存在但没有任何 <id>.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) {
+17
View File
@@ -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 时 ⇒ 文案必须说清"重启才生效",否则用户会以为保存即生效
+37
View File
@@ -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<CatalogProvider[]> {
if (!force && cache !== null && Date.now() - cache.at < TTL_MS) return cache.list
@@ -186,6 +214,14 @@ export async function listCatalogProviders(force = false): Promise<CatalogProvid
try {
files = await readdir(dir)
} catch {
diag = { dir, readable: false, count: 0 }
if (!warnedUnreadable) {
warnedUnreadable = true
console.warn(
`[model-catalog] 厂家目录不可读:${dir} —— 「设置 → 模型设置 → 新增厂家」只会显示内置 DeepSeek 与自定义网关。` +
' 检查 DSHS_DSH_BIN / DSH_PACKAGE_DIR 是否正确(见 src/web/dsh-install.ts)。',
)
}
cache = { at: Date.now(), list: [] }
return []
}
@@ -200,6 +236,7 @@ export async function listCatalogProviders(force = false): Promise<CatalogProvid
}
}
out.sort((a, b) => a.label.localeCompare(b.label, 'zh-Hans-CN'))
diag = { dir, readable: true, count: out.length }
cache = { at: Date.now(), list: out }
return out
}
+13
View File
@@ -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
}
}
+4 -1
View File
@@ -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(),
}
})