From cbaf3ad653a5913859913f2910d0826c379426c7 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Mon, 14 Sep 2026 04:38:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E5=86=85=E7=BD=AE=20dsh=20?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E8=B7=AF=E5=BE=84=E6=94=B9=E4=B8=BA=E6=8C=89?= =?UTF-8?q?=E5=BA=8F=E6=8E=A2=E6=B5=8B=20=E2=80=94=E2=80=94=20=E4=BF=AE?= =?UTF-8?q?=E5=8E=82=E5=AE=B6=E7=9B=AE=E5=BD=95/=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=8C=85=E7=9B=AE=E5=BD=95=E5=9C=A8=20/usr/lib=20=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E4=B8=8B=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:npm root -g 的落点随发行版变(Debian 系 /usr/local/lib;发行版包管理器装的 Node 常见 /usr/lib),而三处把它写死 ⇒ 定位失败**不报错**、只静默降级: - src/web/model-catalog.ts:30 → 厂家目录读成空 ⇒ GET /api/me/model-providers 返回 {"providers":[]} - src/web/plugin-compat.ts:36 → platformPkgCount() = 0 ⇒ 预检退化成「平台包目录不可读」 - poc/workspace-scoped-picker/lib/index.js:34-35(**交接单漏掉的第 3 处**)→ 本模块 import 即抛错 ⇒ 实例内目录选择器不可用 改动: - 新增 src/web/dsh-install.ts(全平台唯一入口;只依赖 node 内建,可单独在目标机验证):env → 平台配置的 dsh 可执行文件解软链反推包根(最可靠)→ 常见全局根 → npm root -g → 历史默认值 ⚠️ env 名两侧都认:源仓 DSHS_DSH_BIN / 导出侧 DSH_USERS_PLATFORM_DSH_BIN(交接件只用了后者,照抄会让该分支在源仓侧永不生效) - 三处调用点改走该模块;插件侧跑在实例进程内、拿不到平台代码,自带同思路一份(两处需同步改) - 新增 scripts/verify-dsh-install.mjs(15 项断言)并接入 npm run verify 验证:本机 tsc 零错误|verify 全套绿|单测 16/0;服务器回归 platformPkgCount=223、厂家目录 39;**测试服 test106(/usr/lib 布局 = 原故障机)在「无 env / DSHS_DSH_BIN / DSH_USERS_PLATFORM_DSH_BIN」三种姿势下均解析到 /usr/lib 且 39 个厂家文件** --- package.json | 2 +- poc/workspace-scoped-picker/README.md | 13 +- poc/workspace-scoped-picker/lib/index.js | 84 ++++++++++- scripts/verify-dsh-install.mjs | 122 ++++++++++++++++ src/web/dsh-install.ts | 171 +++++++++++++++++++++++ src/web/model-catalog.ts | 18 +-- src/web/plugin-compat.ts | 14 +- 7 files changed, 401 insertions(+), 23 deletions(-) create mode 100644 scripts/verify-dsh-install.mjs create mode 100644 src/web/dsh-install.ts diff --git a/package.json b/package.json index cd61ff3..d36c139 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prepare": "npm run build", "dev": "node lib/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", - "verify": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs", + "verify": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs", "test": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js", "smoke": "node scripts/smoke.mjs", "smoke:admin": "node scripts/smoke-admin.mjs", diff --git a/poc/workspace-scoped-picker/README.md b/poc/workspace-scoped-picker/README.md index 7900bb9..c24f349 100644 --- a/poc/workspace-scoped-picker/README.md +++ b/poc/workspace-scoped-picker/README.md @@ -33,8 +33,17 @@ HOME=/ws pnpm add file:/ws/workspace-scoped-picker-0.1.0.tgz ## 依赖解析 唯一外部物是官方 seam 基类,用**绝对路径动态 import** 取得(不复制/不改官方包): -`/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js`, -可用 `DSH_SEAM_DIRECTORY_PICKER` 覆盖。 +路径**按序探测**而成 —— env(`DSH_SEAM_DIRECTORY_PICKER` 显式指定,或 `DSH_PACKAGE_DIR` / +`DSHS_PACKAGE_DIR` / `DSH_COMPAT_ROOT` / `DSHS_COMPAT_ROOT` 给包根)→ 由平台配置的 dsh +可执行文件解软链反推包根(`DSHS_DSH_BIN` / `DSH_USERS_PLATFORM_DSH_BIN`)→ 常见全局根 +(`/usr/local/lib/node_modules`、`/usr/lib/node_modules`)各取「嵌套 / 扁平」两种布局 → +最后兜底问一次 `npm root -g`。 + +⚠️ 2026-09-14 修:此前是**写死** `/usr/local/lib/node_modules/...` 两个字面量;在 +`npm root -g` 落到 `/usr/lib/node_modules` 的机器(发行版包管理器装的 Node)上两个候选 +**都不存在** ⇒ 本模块 import 即抛错 ⇒ 实例内目录选择器不可用(测试服已实测复现)。 +平台侧同思路的另一份实现在 `src/web/dsh-install.ts`(本插件跑在实例进程内、拿不到平台代码, +故自带一份;**改一侧记得同步另一侧**)。 ## 自检 diff --git a/poc/workspace-scoped-picker/lib/index.js b/poc/workspace-scoped-picker/lib/index.js index ac208fd..714a1f5 100644 --- a/poc/workspace-scoped-picker/lib/index.js +++ b/poc/workspace-scoped-picker/lib/index.js @@ -22,6 +22,8 @@ * @module @dsh-local/workspace-scoped-picker */ +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { execFileSync } from 'node:child_process' import { mkdir, opendir, realpath, stat } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' @@ -29,11 +31,83 @@ import { pathToFileURL } from 'node:url' /** 单个列举层级的行数上限(与官方后端同为 GitHub 风格 1000)。 */ const MAX_ENTRIES = 1000 -/** 官方 seam 基类的候选绝对路径(按序尝试首个可导入者)。 */ -const DEFAULT_SEAM_CANDIDATES = [ - '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js', - '/usr/local/lib/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js', -] +/** 平台内置 dsh 的包名(用来确认"这个目录确实是 dsh 包根")。 */ +const PKG_NAME = '@deepseek-ai/dsh' +/** 要解析的官方包:目录选择器的 seam 基类。 */ +const SEAM_PKG = 'dsh-host-directory-picker' + +/** + * 平台内置 dsh 的**包根候选**(按序探测,命中即用)。 + * + * ⚠️ 2026-09-14 修:原先把两个 seam 候选写成 `/usr/local/lib/node_modules/...` 的**绝对字面量** + * —— 而 `npm i -g` 的落点随发行版 / npm prefix 而变(以发行版包管理器装的 Node 常见 + * `/usr/lib/node_modules`;本平台测试服 `test106` 就是这种)。写死 ⇒ 两个候选**都不存在** + * ⇒ 本模块 `import` 即抛错 ⇒ **实例内目录选择器不可用**(已实测复现)。 + * + * 平台侧 `src/web/dsh-install.ts` 是**同思路的另一份实现** —— 本插件跑在**实例进程**内、 + * 拿不到平台代码,所以必须自带一份。改一侧时记得同步另一侧。 + */ +function dshPackageRootCandidates() { + const out = [] + for (const k of ['DSH_PACKAGE_DIR', 'DSHS_PACKAGE_DIR', 'DSH_COMPAT_ROOT', 'DSHS_COMPAT_ROOT']) { + const v = process.env[k] + if (typeof v === 'string' && v !== '') out.push(v) + } + // 由平台配置的 dsh 可执行文件解软链反推包根(最可靠的一路;实例内一般拿不到该变量,故非必中) + for (const k of ['DSHS_DSH_BIN', 'DSH_USERS_PLATFORM_DSH_BIN']) { + const bin = process.env[k] + if (typeof bin !== 'string' || bin === '') continue + try { + let cur = resolve(realpathSync(bin)) + for (let i = 0; i < 6; i += 1) { + const pj = join(cur, 'package.json') + if (existsSync(pj)) { + const pkg = JSON.parse(readFileSync(pj, 'utf8')) + if (pkg !== null && typeof pkg === 'object' && pkg.name === PKG_NAME) { + out.push(cur) + break + } + } + const up = dirname(cur) + if (up === cur) break + cur = up + } + } catch { + /* 忽略:继续用下面的常见全局根 */ + } + } + for (const r of ['/usr/local/lib/node_modules', '/usr/lib/node_modules']) out.push(join(r, PKG_NAME)) + return [...new Set(out)] +} + +/** + * 官方 seam 基类的候选绝对路径(按序尝试首个可导入者)。 + * 每个包根各出两种布局:dsh 自己的 `node_modules` 下(嵌套)与全局 scope 下(扁平)。 + */ +const DEFAULT_SEAM_CANDIDATES = dshPackageRootCandidates().flatMap((root) => { + const globalRoot = dirname(dirname(root)) // /@deepseek-ai/dsh → + return [ + join(root, 'node_modules', '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'), + join(globalRoot, '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'), + ] +}) + +// 常见根全落空时,再问一次 npm(慢,故只在真落空时跑;失败/超时一律当作没有)。 +if (!DEFAULT_SEAM_CANDIDATES.some((c) => existsSync(c))) { + try { + const npmRoot = execFileSync('npm', ['root', '-g'], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + if (npmRoot !== '') { + DEFAULT_SEAM_CANDIDATES.push(join(npmRoot, '@deepseek-ai', PKG_NAME, 'node_modules', '@deepseek-ai', SEAM_PKG, 'lib', 'index.js')) + DEFAULT_SEAM_CANDIDATES.push(join(npmRoot, '@deepseek-ai', SEAM_PKG, 'lib', 'index.js')) + } + } catch { + /* 忽略 */ + } +} /** 解析并导入官方 seam 基类。 */ async function loadSeam() { diff --git a/scripts/verify-dsh-install.mjs b/scripts/verify-dsh-install.mjs new file mode 100644 index 0000000..045fe35 --- /dev/null +++ b/scripts/verify-dsh-install.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * verify-dsh-install.mjs —— 「平台内置 dsh 安装位置解析」回归(2026-09-14 · 修 P1) + * + * 为什么必须有:定位失败**不报错** —— 厂家目录读成空 ⇒ 前端「新增模型条目」只剩内置 + * DeepSeek;平台包计数为 0 ⇒ 兼容性预检退化成「平台包目录不可读」。两者都只在**另一种 + * 安装布局**(`npm root -g` = `/usr/lib/node_modules`)上出现,本机 `/usr/local` 布局 + * **永远测不出来** ⇒ 只有把解析规则钉死成断言,才能防回归。 + * + * 用法:node scripts/verify-dsh-install.mjs 退出码 0=全绿 / 1=有失败 + */ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const M = await import(pathToFileURL(join(ROOT, 'lib/web/dsh-install.js')).href) +const { dshPackageRoot, dshScopeDir, piAiDataDir, resetInstallPathsCache } = M + +let failed = 0 +const ok = (name, cond, extra = '') => { + console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : '')) + if (!cond) failed++ +} + +/** 清掉所有相关 env,保证用例互不干扰。 */ +const ENVS = [ + 'DSH_PACKAGE_DIR', + 'DSHS_PACKAGE_DIR', + 'DSH_COMPAT_ROOT', + 'DSHS_COMPAT_ROOT', + 'DSHS_DSH_BIN', + 'DSH_USERS_PLATFORM_DSH_BIN', + 'PI_AI_DATA_DIR', + 'DSHS_PI_AI_DATA_DIR', +] +const clearEnv = () => { + for (const k of ENVS) delete process.env[k] + resetInstallPathsCache() +} + +console.log('verify-dsh-install(P1:安装位置解析)\n') + +// ── 1. env 覆盖(无条件优先,含本仓前缀别名与导出侧旧名)──────────────────────── +console.log('[1] env 显式覆盖') +for (const k of ['DSH_PACKAGE_DIR', 'DSHS_PACKAGE_DIR', 'DSH_COMPAT_ROOT', 'DSHS_COMPAT_ROOT']) { + clearEnv() + process.env[k] = '/tmp/fake-root-' + k + ok(`${k} 生效`, dshPackageRoot() === '/tmp/fake-root-' + k, dshPackageRoot()) +} + +// ── 2. 由「配置的 dsh 可执行文件」向上解出包根(**最可靠的一路**)──────────────── +console.log('\n[2] 由 dsh 可执行文件解出包根(模拟 /usr/lib 布局)') +const T = mkdtempSync(join(tmpdir(), 'dsh-install-')) +const pkgRoot = join(T, 'node_modules', '@deepseek-ai', 'dsh') +mkdirSync(join(pkgRoot, 'lib'), { recursive: true }) +writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.0.0-test' })) +const binPath = join(pkgRoot, 'lib', 'bin.js') +writeFileSync(binPath, '// stub\n') + +for (const k of ['DSHS_DSH_BIN', 'DSH_USERS_PLATFORM_DSH_BIN']) { + clearEnv() + process.env[k] = binPath + ok(`${k} → 解出包根`, dshPackageRoot() === pkgRoot, dshPackageRoot().replace(T, '')) +} + +// 反例:bin 指向一个**不是 dsh 包**的目录 ⇒ 不许误判 +clearEnv() +const otherRoot = join(T, 'node_modules', '@other', 'thing') +mkdirSync(join(otherRoot, 'lib'), { recursive: true }) +writeFileSync(join(otherRoot, 'package.json'), JSON.stringify({ name: '@other/thing' })) +const otherBin = join(otherRoot, 'lib', 'bin.js') +writeFileSync(otherBin, '// stub\n') +process.env.DSHS_DSH_BIN = otherBin +ok('包名不匹配时不误认(回落到默认值)', !dshPackageRoot().startsWith(otherRoot), dshPackageRoot().replace(T, '')) + +// ── 3. 派生量 ───────────────────────────────────────────────────────────────── +console.log('\n[3] 派生:scope 目录 / pi-ai 数据目录') +clearEnv() +process.env.DSH_PACKAGE_DIR = '/opt/whatever/dsh' +ok( + 'dshScopeDir = /node_modules/@deepseek-ai', + dshScopeDir() === join('/opt/whatever/dsh', 'node_modules', '@deepseek-ai'), + dshScopeDir(), +) +ok( + 'piAiDataDir 落在 /node_modules/@earendil-works/pi-ai/dist/providers/data', + piAiDataDir() === join('/opt/whatever/dsh', 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data'), + piAiDataDir(), +) +for (const k of ['PI_AI_DATA_DIR', 'DSHS_PI_AI_DATA_DIR']) { + clearEnv() + process.env.DSH_PACKAGE_DIR = '/opt/whatever/dsh' + process.env[k] = '/tmp/data-' + k + ok(`${k} 覆盖 piAiDataDir`, piAiDataDir() === '/tmp/data-' + k, piAiDataDir()) +} + +// ── 4. 缓存语义(env 改了不 reset 就用旧值;reset 后重新解析)──────────────────── +console.log('\n[4] 缓存语义') +clearEnv() +process.env.DSH_PACKAGE_DIR = '/first' +const first = dshPackageRoot() +process.env.DSH_PACKAGE_DIR = '/second' +ok('缓存生效(不 reset 仍是旧值)', dshPackageRoot() === first && first === '/first', dshPackageRoot()) +resetInstallPathsCache() +ok('reset 后重新解析', dshPackageRoot() === '/second', dshPackageRoot()) + +// ── 5. 本机实况:必须返回可用的绝对路径(不抛错)─────────────────────────────── +console.log('\n[5] 本机实况') +clearEnv() +const real = dshPackageRoot() +ok('返回非空绝对路径', typeof real === 'string' && real.length > 0 && real.startsWith('/'), real) +ok('dshScopeDir 形状正确', dshScopeDir().endsWith(join('node_modules', '@deepseek-ai')), dshScopeDir()) + +rmSync(T, { recursive: true, force: true }) +console.log('') +if (failed > 0) { + console.log(`✗ verify-dsh-install 失败 ${failed} 项`) + process.exit(1) +} +console.log('✓ verify-dsh-install 全绿') diff --git a/src/web/dsh-install.ts b/src/web/dsh-install.ts new file mode 100644 index 0000000..91e6d22 --- /dev/null +++ b/src/web/dsh-install.ts @@ -0,0 +1,171 @@ +/** + * 「平台内置 dsh」安装位置解析 —— **全平台唯一入口**(档案 87 补做 · 2026-09-14)。 + * + * 为什么必须有这一层:**`npm i -g` 的落点随发行版 / npm prefix 而变** —— 实测 + * · Debian 系常见 `/usr/local/lib/node_modules`(npm 默认 `prefix=/usr/local`) + * · 以发行版包管理器装的 Node(OpenCloudOS / CentOS / 宝塔 等)常见 `/usr/lib/node_modules` + * (本平台测试服 `test106` 就是这种:`/usr/bin/dsh` → `/usr/lib/node_modules/@deepseek-ai/dsh/lib/bin.js`) + * 而定位失败**不会报错**:调用方只会静默降级(厂家目录读成空、平台包计数为 0), + * 表现成「这个功能没做」,排查成本极高(实测:`/api/me/model-providers` 返回 `{"providers":[]}`)。 + * ⇒ 不能写死单一路径,必须**按序探测**。 + * + * 解析顺序(命中即停): + * 1. env `DSH_PACKAGE_DIR` / `DSH_COMPAT_ROOT`(+ 本仓前缀别名 `DSHS_PACKAGE_DIR` / + * `DSHS_COMPAT_ROOT`)—— 显式覆盖,也是测试注入点,**无条件生效**; + * 2. 平台配置的 dsh 可执行文件(`DSHS_DSH_BIN`,同时接受导出侧改名后的 + * `DSH_USERS_PLATFORM_DSH_BIN`)→ `realpath` 后向上找含 `name=@deepseek-ai/dsh` + * 的 package.json。**最可靠的一路**:它就是实例真正在跑的那个包; + * 3. 常见全局根逐一探测(`/usr/local/lib/node_modules`、`/usr/lib/node_modules`); + * 4. `npm root -g`(慢,只在前面全落空时跑一次,5s 超时); + * 5. 全落空 ⇒ 返回历史默认值(**保持旧行为**,由上层继续按「目录不可读」降级)。 + * + * 判定用 `package.json` 的 `name` 而非「目录存在」,避免撞上同名空目录。结果缓存。 + * + * ⚠️ `poc/workspace-scoped-picker/lib/index.js` 里有一份**同思路的独立实现** + * (它跑在**实例进程**内、拿不到本模块 —— 实例侧不加载平台代码),改这里时别忘了那一处。 + * + * ⚠️ 本模块**只依赖 node 内建**(无内部 import)⇒ 可单独编译、单独在目标机上验证。 + * + * @module dshs/web/dsh-install + */ + +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { dirname, join, resolve } from 'node:path' + +/** 平台内建包的包名(`package.json.name`)。 */ +const PKG_NAME = '@deepseek-ai/dsh' + +/** 历史默认值(也是最后兜底);仅在探测全落空时使用。 */ +const LEGACY_DEFAULT_ROOT = '/usr/local/lib/node_modules/' + PKG_NAME + +/** 常见的全局 `node_modules` 落点(候选,非假设)。 */ +const COMMON_ROOTS = ['/usr/local/lib/node_modules', '/usr/lib/node_modules'] + +/** 第一个非空的 env 值。 */ +function firstEnv(names: readonly string[]): string | null { + for (const n of names) { + const v = process.env[n] + if (v !== undefined && v !== '') return v + } + return null +} + +let cachedRoot: string | null = null + +/** 该目录是不是 dsh 包根(看 `package.json.name`,避免撞上同名空目录)。 */ +function isDshPackageDir(dir: string): boolean { + try { + if (!statSync(join(dir, 'package.json')).isFile()) return false + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name?: unknown } + return pkg.name === PKG_NAME + } catch { + return false + } +} + +/** 从某个路径向上找包根(最多 6 层,够到 `/lib/bin.js` 这种深度)。 */ +function findPackageRootUpwards(from: string): string | null { + let cur = resolve(from) + for (let i = 0; i < 6; i += 1) { + if (isDshPackageDir(cur)) return cur + const up = dirname(cur) + if (up === cur) break + cur = up + } + return null +} + +/** 由平台配置的 dsh 可执行文件反推包根(解软链)。 */ +function rootFromConfiguredBin(): string | null { + const bin = firstEnv(['DSHS_DSH_BIN', 'DSH_USERS_PLATFORM_DSH_BIN']) + if (bin === null) return null + try { + return findPackageRootUpwards(realpathSync(bin)) + } catch { + return null + } +} + +/** 由 `npm root -g` 反推包根(兜底;失败 / 超时一律当作没有)。 */ +function rootFromNpm(): string | null { + try { + const root = execFileSync('npm', ['root', '-g'], { + encoding: 'utf8', + timeout: 5000, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + if (root === '') return null + const candidate = join(root, PKG_NAME) + return isDshPackageDir(candidate) ? candidate : null + } catch { + return null + } +} + +/** + * 平台内置 dsh 的包根目录。 + * @returns 绝对路径;**不保证存在**(全落空时返回历史默认值,由调用方按「目录不可读」降级)。 + */ +export function dshPackageRoot(): string { + if (cachedRoot !== null) return cachedRoot + + const envRoot = firstEnv(['DSH_PACKAGE_DIR', 'DSHS_PACKAGE_DIR', 'DSH_COMPAT_ROOT', 'DSHS_COMPAT_ROOT']) + if (envRoot !== null) { + cachedRoot = envRoot + return envRoot + } + + const probes: Array = [ + rootFromConfiguredBin(), + ...COMMON_ROOTS.map((r) => join(r, PKG_NAME)), + rootFromNpm(), + ] + for (const p of probes) { + if (p !== null && isDshPackageDir(p)) { + cachedRoot = p + return p + } + } + + cachedRoot = LEGACY_DEFAULT_ROOT + return LEGACY_DEFAULT_ROOT +} + +/** 平台内建包所在 scope 目录(`/node_modules/@deepseek-ai`)。 */ +export function dshScopeDir(): string { + return join(dshPackageRoot(), 'node_modules', '@deepseek-ai') +} + +/** + * `@earendil-works/pi-ai` 的**厂家目录**数据位置。 + * @returns 绝对路径;**不保证存在**(不存在时返回首选候选,由调用方降级为空目录)。 + */ +export function piAiDataDir(): string { + const envDir = firstEnv(['PI_AI_DATA_DIR', 'DSHS_PI_AI_DATA_DIR']) + if (envDir !== null) return envDir + + const root = dshPackageRoot() + const first = join(root, 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data') + // 另一种合法布局:pi-ai 被提升到 `dsh-llm-pi-ai` 自己的 node_modules 下。 + const nested = join( + root, + 'node_modules', + '@deepseek-ai', + 'dsh-llm-pi-ai', + 'node_modules', + '@earendil-works', + 'pi-ai', + 'dist', + 'providers', + 'data', + ) + if (existsSync(first)) return first + if (existsSync(nested)) return nested + return first +} + +/** 清解析缓存(改了 env 之后重新解析;测试用)。 */ +export function resetInstallPathsCache(): void { + cachedRoot = null +} diff --git a/src/web/model-catalog.ts b/src/web/model-catalog.ts index 2faed24..faa477c 100644 --- a/src/web/model-catalog.ts +++ b/src/web/model-catalog.ts @@ -25,16 +25,18 @@ import { readdir, readFile } from 'node:fs/promises' import { join } from 'node:path' +import { piAiDataDir } from './dsh-install.js' -/** dsh 包安装目录(可用 env 覆盖,便于测试与其它部署形态)。 */ -const DSH_PACKAGE_DIR = process.env.DSH_PACKAGE_DIR ?? '/usr/local/lib/node_modules/@deepseek-ai/dsh' - -/** 目录数据目录。 */ +/** + * 厂家目录数据目录。 + * + * ⚠️ 2026-09-14 修:原先写死 `/usr/local/lib/node_modules/...`,在 `npm root -g` 落到 + * `/usr/lib/node_modules` 的机器上会**静默返回空目录**(测试服实测 + * `GET /api/me/model-providers` → `{"providers":[]}`,而真实位置有 39 个厂家文件)。 + * 改由 `dsh-install.ts` 按序探测(env → dsh 可执行文件解软链 → 常见全局根 → `npm root -g`)。 + */ export function catalogDir(): string { - return ( - process.env.PI_AI_DATA_DIR ?? - join(DSH_PACKAGE_DIR, 'node_modules', '@earendil-works', 'pi-ai', 'dist', 'providers', 'data') - ) + return piAiDataDir() } /** 目录里一个厂家。 */ diff --git a/src/web/plugin-compat.ts b/src/web/plugin-compat.ts index cb1d7f4..ddbba9f 100644 --- a/src/web/plugin-compat.ts +++ b/src/web/plugin-compat.ts @@ -30,11 +30,11 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { join, extname } from 'node:path' import { pathToFileURL } from 'node:url' +// 平台内置包的落点**不能写死**:`npm root -g` 随发行版变(`/usr/local/lib` vs `/usr/lib`), +// 写死只会在另一种布局上静默退化成「平台包目录不可读」而不报错(测试服实测 = 0)。 +// 统一走 `dsh-install.ts` 的按序探测。 +import { dshScopeDir } from './dsh-install.js' -/** 平台内置包根目录(本部署事实;env 可覆盖,便于测试)。 */ -const PLATFORM_DSH_ROOT = - process.env.DSH_COMPAT_ROOT ?? '/usr/local/lib/node_modules/@deepseek-ai/dsh' -const SCOPE_DIR = join(PLATFORM_DSH_ROOT, 'node_modules', '@deepseek-ai') const DS_SCOPE = '@deepseek-ai/' export interface CompatFinding { @@ -69,13 +69,13 @@ function shortName(pkg: string): string { } function platformPkgDir(pkg: string): string { - return join(SCOPE_DIR, shortName(pkg)) + return join(dshScopeDir(), shortName(pkg)) } /** 平台内置 @deepseek-ai 包的数量(目录级,低成本)。 */ export function platformPkgCount(): number { try { - return readdirSync(SCOPE_DIR).filter((n) => !n.startsWith('.')).length + return readdirSync(dshScopeDir()).filter((n) => !n.startsWith('.')).length } catch { return 0 } @@ -269,7 +269,7 @@ export async function checkPluginCompat(root: string): Promise { } try { if (out.platformPkgs === 0) { - out.note = `平台包目录不可读:${SCOPE_DIR}` + out.note = `平台包目录不可读:${dshScopeDir()}` return out } const pkgPath = findPackageJson(root)