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
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify-dsh-compat.mjs —— 「官方推荐插件列表按 dsh 版本过滤」(档案 92)的**回归锚点**。
|
||||
*
|
||||
* 为什么需要:这条链路的关键判据**全都不是"跑得通/跑不通"能暴露的** ——
|
||||
* · 少了 `includePrerelease: true`:功能照跑,但会把**在平台上跑得好好的**插件
|
||||
* (`dsh-univer-office`、`dshmarket` …)判成「只兼容更旧版本」而被隐藏。
|
||||
* 2026-09-14 实测:TOP300 默认语义 `match 97 / older 139`;prerelease 容忍 `match 214 / older 22`。
|
||||
* · 少了「伞包 `@deepseek-ai/dsh` 单独解析」:所有"要求 dsh ≥ 0.1.5-rc.2"的插件都判不出来
|
||||
* (伞包**不在自己的 node_modules 里**)—— 而那恰恰是用户要标注的那一类
|
||||
* (实测 TOP300 的 3 条 `newer` **全部**只写在伞包上)。
|
||||
* · 前端少了「隐藏计数/说明」:条目会**静默消失**,正是本库反复强调要避免的。
|
||||
* 以上都会"静默降级",所以钉成断言。
|
||||
*
|
||||
* 用法:node scripts/verify-dsh-compat.mjs 退出码 0=全绿 / 1=有失败
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const read = (p) => readFileSync(join(ROOT, p), 'utf8')
|
||||
|
||||
let failed = 0
|
||||
const ok = (name, cond, extra = '') => {
|
||||
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
|
||||
if (!cond) failed++
|
||||
}
|
||||
|
||||
const compat = read('src/web/plugin-dsh-compat.ts')
|
||||
const route = read('src/web/routes/whitelist.ts')
|
||||
const client = read('poc/business-plugins/lib/client.js')
|
||||
const install = read('src/web/dsh-install.ts')
|
||||
const legacy = read('src/web/plugin-compat.ts')
|
||||
|
||||
// ── ① 后端判据:prerelease 语义 + 伞包解析(两条都是"静默降级"型)──
|
||||
ok('判定用 prerelease 容忍语义(includePrerelease: true)', /includePrerelease:\s*true/.test(compat))
|
||||
ok('伞包解析收口在 dsh-install.platformPackageVersion(单独读 <dsh 包根>/package.json)',
|
||||
/export function platformPackageVersion\(/.test(install) && /full === PKG_NAME \? dshPackageRoot\(\)/.test(install))
|
||||
ok('两处消费方共用同一份版本表(不再各写一遍)',
|
||||
/platformPackageVersion/.test(compat) && /platformPackageVersion/.test(legacy) &&
|
||||
!/versionTable/.test(compat) && !/versionCache/.test(legacy))
|
||||
ok('分类枚举含 newer / older / match / none / unknown',
|
||||
['newer', 'older', 'match', 'none', 'unknown'].every((k) => new RegExp(`'${k}'`).test(compat)))
|
||||
ok('"要求更高"优先于"只兼容更旧"(newer 先返回)', /if \(newer !== null\)[\s\S]{0,200}kind: 'newer'[\s\S]{0,200}if \(older\.length > 0\)/.test(compat))
|
||||
ok('semver 不可用时降级为 unknown(而不是判兼容/不兼容)', /semver === null\)\s*return \{ kind: 'unknown'/.test(compat))
|
||||
// 闸门(判据 A)也必须先按默认语义、再容忍 prerelease —— 否则「声明 `*`」会被判不兼容。
|
||||
// 实测:抽样 300 里这类"prerelease 产物"有 117 条(dshmarket 等能跑的都在其中)。
|
||||
ok('闸门判据 A 有 prerelease 兜底(默认语义不满足时先看 includePrerelease)',
|
||||
/semver\.satisfies\(v, range\)[\s\S]{0,2000}includePrerelease: true/.test(legacy))
|
||||
ok('prerelease 产物只计数、不进 findings(不阻断)',
|
||||
/prereleaseOnly/.test(legacy) && /out\.findings\.push[\s\S]{0,200}prereleaseOnly/.test(legacy))
|
||||
|
||||
// ── ② 路由:过滤 + 标注 + 缓存 + 预热 + 不静默 ──
|
||||
ok('GET 尊重 dshCompat 参数(=all 时只标注不过滤)', /dshCompat !== 'all'/.test(route))
|
||||
ok('只隐藏 older,其余(含 unknown)照常显示', /kind !== 'older'/.test(route))
|
||||
ok('响应带 hiddenByDsh(供前端说明隐藏了多少)', /hiddenByDsh/.test(route))
|
||||
ok('响应带 dshVersion(当前版本)', /dshVersion: platformDshVersion\(\)/.test(route))
|
||||
ok('判定结果落盘缓存(避免每次搜索打 npm)', /npm-dsh-compat\.json/.test(route))
|
||||
ok('有后台预热,避免冷启动全判 unknown', /warmCompat/.test(route))
|
||||
ok('请求内只给预算:取不到判 unknown 并仍显示', /COMPAT_BUDGET_MS/.test(route))
|
||||
|
||||
// ── ③ 前端:徽章 + 说明 + 逃生口 ──
|
||||
ok('前端有兼容徽章函数 wlCompat', /function wlCompat\(/.test(client))
|
||||
ok('「需更高版本」用醒目徽章(pending/橙色)', /cls = "pending"/.test(client))
|
||||
ok('查询串带 dshCompat=all 逃生口', /&dshCompat=all/.test(client))
|
||||
ok('说明行给出隐藏计数(不静默消失)', /pa\.pl\.cmpHidden/.test(client) && /hiddenByDsh/.test(client))
|
||||
ok('表格新增 dsh 版本列', /pa\.pl\.compatCol/.test(client))
|
||||
|
||||
console.log(failed === 0 ? '\n全部通过(dsh 版本兼容过滤:判据 + 过滤 + 标注)' : `\n${failed} 项失败`)
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify-models-dict.mjs —— 「模型设置」页(`poc/business-plugins/lib/client.js` 的 ms.* 分区)
|
||||
* 的**词典完整性 + 版式防线**交叉校验(档案 91)。
|
||||
*
|
||||
* 为什么需要(两个都真踩过):
|
||||
* ① **漏键是静默的**:2026-09-14 重写该页时把 `ms.needUrl` / `ms.needModels` 从词典里删掉、
|
||||
* 却仍被 `submitCustom()` 引用 —— 页面只会在校验失败时把**键名原样**显示给用户,
|
||||
* 构建与语法检查**都不会报错**。zh/en 也容易只改一边。
|
||||
* ② **换行是版式问题、不是逻辑问题**:用户截图报「内置 DeepSeek 换行 + 两个按钮换行」,
|
||||
* 根因是那 4 列表格用了**自动列宽**。改用官方卡片行后,只要 `rowName` / `rowActions`
|
||||
* 的 `white-space:nowrap` 与 `rowActions` 的 `margin-left:auto` 任一被删,缺陷就会复发
|
||||
* —— 而肉眼很难在构建期发现。这里把它钉成断言。
|
||||
*
|
||||
* 用法:node scripts/verify-models-dict.mjs 退出码 0=全绿 / 1=有失败
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const read = (p) => readFileSync(join(ROOT, p), 'utf8')
|
||||
|
||||
let failed = 0
|
||||
const ok = (name, cond, extra = '') => {
|
||||
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
|
||||
if (!cond) failed++
|
||||
}
|
||||
|
||||
const src = read('poc/business-plugins/lib/client.js')
|
||||
|
||||
// ── ① 取出 zh / en 两个词典对象字面量(按花括号配平截取,不用 eval 全文件)──
|
||||
/** 从 `var <name> = {` 起按花括号配平截出对象字面量文本。 */
|
||||
function grabDict(name) {
|
||||
const at = src.indexOf('var ' + name + ' = {')
|
||||
if (at < 0) return null
|
||||
const start = src.indexOf('{', at)
|
||||
let depth = 0
|
||||
for (let i = start; i < src.length; i++) {
|
||||
const c = src[i]
|
||||
if (c === '{') depth++
|
||||
else if (c === '}') {
|
||||
depth--
|
||||
if (depth === 0) return src.slice(start, i + 1)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
const zhText = grabDict('zh')
|
||||
const enText = grabDict('en')
|
||||
ok('取到 zh 词典对象', zhText !== null)
|
||||
ok('取到 en 词典对象', enText !== null)
|
||||
if (zhText === null || enText === null) process.exit(1)
|
||||
|
||||
/** 只取**顶层**键:按缩进深度 1 的行首 `"k":` 判定,避免把嵌套对象里的键也算进来。 */
|
||||
function keysOf(text) {
|
||||
const keys = []
|
||||
const dups = []
|
||||
const seen = new Set()
|
||||
for (const m of text.matchAll(/^\t{3}"([^"]+)":/gm)) {
|
||||
if (seen.has(m[1])) dups.push(m[1])
|
||||
seen.add(m[1])
|
||||
keys.push(m[1])
|
||||
}
|
||||
return { keys, dups }
|
||||
}
|
||||
const zh = keysOf(zhText)
|
||||
const en = keysOf(enText)
|
||||
|
||||
ok('zh 无重复键(重复会让后者静默胜出)', zh.dups.length === 0, zh.dups.length ? '-> ' + zh.dups.join(', ') : '')
|
||||
ok('en 无重复键', en.dups.length === 0, en.dups.length ? '-> ' + en.dups.join(', ') : '')
|
||||
|
||||
const zhSet = new Set(zh.keys)
|
||||
const enSet = new Set(en.keys)
|
||||
const onlyZh = zh.keys.filter((k) => !enSet.has(k))
|
||||
const onlyEn = en.keys.filter((k) => !zhSet.has(k))
|
||||
ok('zh / en 键数一致', zh.keys.length === en.keys.length, `-> zh ${zh.keys.length} / en ${en.keys.length}`)
|
||||
ok('zh ⊆ en', onlyZh.length === 0, onlyZh.length ? '-> 仅 zh 有: ' + onlyZh.join(', ') : '')
|
||||
ok('en ⊆ zh', onlyEn.length === 0, onlyEn.length ? '-> 仅 en 有: ' + onlyEn.join(', ') : '')
|
||||
|
||||
// ── ② 代码里引用的键必须都声明了(本轮真漏过)──
|
||||
// ⚠️ 2026-09-14 扩到**全词典**(原来只查 `ms.*`):`pa.*` 那批一样会漏,
|
||||
// 而漏键在页面上只表现为"把键名原样显示给用户",构建与语法检查都不报错。
|
||||
const used = new Set()
|
||||
for (const m of src.matchAll(/\bt\("([A-Za-z0-9_.]+)"\)/g)) used.add(m[1])
|
||||
// MS_ERR 的值是把后端错误码映射成的词典键
|
||||
{
|
||||
const block = /var MS_ERR = \{([\s\S]*?)\n\t{3}\};/.exec(src)
|
||||
if (block) for (const m of block[1].matchAll(/"(ms\.[A-Za-z0-9_.]+)"/g)) used.add(m[1])
|
||||
}
|
||||
ok('词典引用点非空', used.size > 100, `-> 引用键 ${used.size} 个`)
|
||||
const missing = [...used].filter((k) => !zhSet.has(k))
|
||||
ok('被引用的词典键全部已声明', missing.length === 0, missing.length ? '-> 缺失: ' + missing.join(', ') : '')
|
||||
|
||||
// ── ③ 版式防线:卡片行的「不换行」三件套(少任何一个,用户报的换行就会复发)──
|
||||
const cssHas = (selector, decl) => {
|
||||
const re = new RegExp('\\.' + selector + '\\{[^}]*' + decl + '[^}]*\\}')
|
||||
return re.test(src)
|
||||
}
|
||||
ok('.ms-rowName 有 white-space:nowrap', cssHas('ms-rowName', 'white-space:nowrap'))
|
||||
ok('.ms-rowActions 有 white-space:nowrap', cssHas('ms-rowActions', 'white-space:nowrap'))
|
||||
ok('.ms-rowActions 有 margin-left:auto(动作右对齐,官方 rowActions 同)', cssHas('ms-rowActions', 'margin-left:auto'))
|
||||
ok('.ms-rowHead 是 flex 且 align-items:center', cssHas('ms-rowHead', 'align-items:center'))
|
||||
ok('.ms-addActions 可换行(两个虚线按钮窄屏时上下堆叠)', cssHas('ms-addActions', 'flex-wrap:wrap'))
|
||||
ok('.ms-addBtn 有 flex:1 1 0 + min-width:180px(官方 addButton 同)', cssHas('ms-addBtn', 'min-width:180px'))
|
||||
|
||||
// ── ④ 新的新增流程确实在(两个虚线入口 + 已无 4 列表格)──
|
||||
ok('默认态有「添加提供方」入口', src.includes('ms.addProvider'))
|
||||
ok('默认态有「添加自定义提供方」入口', src.includes('ms.addCustom'))
|
||||
// 描述字符串很长,最容易混进未转义的 `"` 把 JSON 写坏(2026-09-14 实际踩过一次)
|
||||
{
|
||||
let pkgsOk = true
|
||||
for (const p of ['package.json', 'poc/business-plugins/package.json']) {
|
||||
try {
|
||||
JSON.parse(read(p))
|
||||
} catch {
|
||||
pkgsOk = false
|
||||
}
|
||||
}
|
||||
ok('package.json 均为合法 JSON', pkgsOk)
|
||||
}
|
||||
ok('「我已添加的提供方」已从表格改为 ul.ms-rows', /className: "ms-rows"/.test(src))
|
||||
{
|
||||
// 「模型设置」分区内不应再出现表格(其它页面仍可用 pa-tbl)
|
||||
const from = src.indexOf('function ModelSettingsSection()')
|
||||
const to = src.indexOf('ctx.slots.inject("settings.section"')
|
||||
const body = from >= 0 && to > from ? src.slice(from, to) : ''
|
||||
ok('「模型设置」分区内无 pa-tbl 残留', body.includes('pa-tbl') === false)
|
||||
}
|
||||
|
||||
console.log(failed === 0 ? '\n全部通过(模型设置:词典一致性 + 版式防线)' : `\n${failed} 项失败`)
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify-models-render.cjs —— 「模型设置」分区(`poc/business-plugins/lib/client.js`)
|
||||
* 的**真渲染冒烟**:把 `ModelSettingsSection` 抠出来,用桩 React / jsxRuntime 跑一遍,
|
||||
* 覆盖「卡片行 / 添加提供方卡片 / 自定义提供方卡片 / 默认双按钮 / 删除确认弹窗」五个分支。
|
||||
*
|
||||
* 为什么需要(语法检查抓不到的东西):
|
||||
* `node --check` 只保证能解析;`verify-models-dict.mjs` 只保证词典齐全与版式锚点。
|
||||
* 但组件里还有一类**只在渲染时才炸**的错:未定义变量、jsxRuntime 用法错、
|
||||
* 访问了 `undefined.trim()` 之类。这类错在浏览器里表现为「整个设置页白屏」,
|
||||
* 而本仓库没有前端测试运行器。本脚本用最小桩把渲染跑通,把这类错拦在构建期。
|
||||
*
|
||||
* ⚠️ **维护约束**:桩 `useState` 按**调用序号**喂值(见 `STATE_MAP`)。若给组件新增/调换
|
||||
* `useState`,请同步 `STATE_MAP`;脚本会先断言调用数量,数量不符时**明确报错**而不是静默测错。
|
||||
*
|
||||
* 用法:node scripts/verify-models-render.cjs 退出码 0=全绿 / 1=有失败
|
||||
*/
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { join, dirname } = require('node:path')
|
||||
|
||||
const ROOT = join(__dirname, '..')
|
||||
const FILE = join(ROOT, 'poc', 'business-plugins', 'lib', 'client.js')
|
||||
const src = readFileSync(FILE, 'utf8')
|
||||
|
||||
let failed = 0
|
||||
const ok = (name, cond, extra = '') => {
|
||||
console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : ''))
|
||||
if (!cond) failed++
|
||||
}
|
||||
|
||||
/** 从 `function <name>() {` 起按大括号配平截取函数源码(跳过字符串与注释)。 */
|
||||
function extractFn(text, name) {
|
||||
const at = text.indexOf('function ' + name + '()')
|
||||
if (at < 0) return null
|
||||
const start = text.indexOf('{', at)
|
||||
let depth = 0
|
||||
let i = start
|
||||
while (i < text.length) {
|
||||
const c = text[i]
|
||||
if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; continue }
|
||||
if (c === '/' && text[i + 1] === '*') { i = text.indexOf('*/', i) + 2; continue }
|
||||
if (c === '"' || c === "'" || c === '`') {
|
||||
const q = c
|
||||
i++
|
||||
while (i < text.length && text[i] !== q) { if (text[i] === '\\') i++; i++ }
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '{') depth++
|
||||
else if (c === '}') { depth--; if (depth === 0) return text.slice(at, i + 1) }
|
||||
i++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fnSrc = extractFn(src, 'ModelSettingsSection')
|
||||
ok('取到 ModelSettingsSection 源码', fnSrc !== null)
|
||||
if (fnSrc === null) process.exit(1)
|
||||
|
||||
/** 真词典(顺便验证键真的存在)。 */
|
||||
function grabObj(text, name) {
|
||||
const at = text.indexOf('var ' + name + ' = {')
|
||||
const start = text.indexOf('{', at)
|
||||
let depth = 0
|
||||
for (let i = start; i < text.length; i++) {
|
||||
if (text[i] === '{') depth++
|
||||
else if (text[i] === '}') { depth--; if (depth === 0) return text.slice(start, i + 1) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const zh = eval('(' + grabObj(src, 'zh') + ')')
|
||||
|
||||
/**
|
||||
* 桩 `useState` 的**位置→语义**映射。顺序 = 组件里 `var x = useState(...)` 的先后。
|
||||
* 键是调用序号,值是「该序号在真实组件里是什么」——仅用于可读性,实际取值看 `over`。
|
||||
*/
|
||||
const STATE_MAP = [
|
||||
'data', 'providers', 'loading', 'busy', 'msg', 'mode',
|
||||
'fPick', 'fKey', 'fName', 'fRoute', 'fUrl', 'fApi', 'fModels', 'fShow', 'delTarget', 'catalog',
|
||||
]
|
||||
|
||||
const mockedKeys = []
|
||||
const FAKE_DATA = {
|
||||
keys: [
|
||||
{ id: 1, name: '内置 DeepSeek', route: null, enabled: true, baseUrl: null, api: null },
|
||||
{ id: 2, name: 'Moonshot', route: 'moonshot', enabled: false, baseUrl: null, api: null },
|
||||
{ id: 3, name: '我的中转', route: 'my-gw', enabled: true, baseUrl: 'https://gw.example/v1', api: 'openai-completions' },
|
||||
],
|
||||
shared: { available: true, name: '内置 DeepSeek', owner: 'admin', ownerIsMe: true, count: 1 },
|
||||
sharedModelEnabled: true,
|
||||
protocols: ['openai-completions', 'anthropic-messages'],
|
||||
}
|
||||
const FAKE_PROVIDERS = [
|
||||
{ id: 'moonshot', label: '月之暗面', cn: true, modelCount: 3, models: ['kimi-k2', 'kimi-lite', 'kimi-pro'] },
|
||||
{ id: 'openai', label: 'OpenAI', cn: false, modelCount: 2, models: ['gpt-5', 'gpt-5-mini'] },
|
||||
]
|
||||
|
||||
function makeUseState(pass, counter) {
|
||||
return function useState(init) {
|
||||
const k = counter.i++
|
||||
const over = pass.over || {}
|
||||
// 未提供的字段回落到 init(而不是 undefined)—— 免得桩的疏漏被当成组件缺陷
|
||||
const v = Object.prototype.hasOwnProperty.call(over, k) && over[k] !== undefined ? over[k] : init
|
||||
return [v, function () {}]
|
||||
}
|
||||
}
|
||||
|
||||
const React = { useState: null, useEffect: function () {} }
|
||||
const jsxRuntime = {
|
||||
jsx: (type, props) => ({ type, props: props || {} }),
|
||||
jsxs: (type, props) => ({ type, props: props || {} }),
|
||||
Fragment: 'Fragment',
|
||||
}
|
||||
const t = (k) => {
|
||||
if (!(k in zh)) { mockedKeys.push(k); return '<' + k + '>' }
|
||||
return zh[k]
|
||||
}
|
||||
const noopReq = () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
|
||||
const MS_ERR = {}
|
||||
const portalHost = () => ''
|
||||
|
||||
function render(pass) {
|
||||
const counter = { i: 0 }
|
||||
React.useState = makeUseState(pass, counter)
|
||||
const factory = new Function(
|
||||
'React', 'jsxRuntime', 't', 'paReq', 'papostJson', 'portalHost', 'MS_ERR',
|
||||
fnSrc + '\nreturn ModelSettingsSection;',
|
||||
)
|
||||
const tree = factory(React, jsxRuntime, t, noopReq, noopReq, portalHost, MS_ERR)()
|
||||
return { tree, calls: counter.i }
|
||||
}
|
||||
|
||||
function walk(node, out) {
|
||||
if (node == null || typeof node !== 'object') return
|
||||
if (Array.isArray(node)) { for (const n of node) walk(n, out); return }
|
||||
const p = node.props || {}
|
||||
if (p.className) out.classes.push(String(p.className))
|
||||
if (typeof p.children === 'string') out.text.push(p.children)
|
||||
if (Array.isArray(p.children)) {
|
||||
for (const c of p.children) { if (typeof c === 'string') out.text.push(c); else walk(c, out) }
|
||||
} else if (p.children && typeof p.children === 'object') walk(p.children, out)
|
||||
}
|
||||
|
||||
const BASE = { key: 'sk-abc', msg: '', over: {} }
|
||||
const CASES = [
|
||||
['默认态(两个虚线按钮)', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: '', 6: '', 7: BASE.key, 12: [''], 13: false, 14: null, 15: null } },
|
||||
['ms-rows', 'ms-rowCard', 'ms-rowHead', 'ms-rowIdentity', 'ms-rowActions', 'ms-addActions', 'ms-addBtn']],
|
||||
['添加提供方卡片', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: 'add', 6: 'moonshot', 7: BASE.key, 12: [''], 13: false, 14: null, 15: null } },
|
||||
['ms-card', 'ms-field', 'ms-fieldLabel', 'ms-details', 'ms-summary', 'ms-editorActions']],
|
||||
['自定义提供方卡片', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: 'custom', 6: '', 7: BASE.key, 8: '我的网关', 9: 'my-gw', 10: 'https://gw.example/v1', 11: 'openai-completions', 12: ['m1', ''], 13: false, 14: null, 15: null } },
|
||||
['ms-modelList', 'ms-modelEntry', 'ms-modelRow', 'ms-iconBtn']],
|
||||
['删除确认弹窗', { over: { 0: FAKE_DATA, 1: FAKE_PROVIDERS, 2: false, 3: false, 4: '', 5: '', 6: '', 7: BASE.key, 12: [''], 13: false, 14: FAKE_DATA.keys[1], 15: null } },
|
||||
['pa-overlay', 'pa-modal', 'ms-editorActions']],
|
||||
]
|
||||
|
||||
let sawCalls = 0
|
||||
for (const [title, pass, expect] of CASES) {
|
||||
let tree, calls
|
||||
try {
|
||||
const r = render(pass)
|
||||
tree = r.tree
|
||||
calls = r.calls
|
||||
} catch (e) {
|
||||
console.log(' ✗ ' + title + ' → 抛异常: ' + e.message)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
sawCalls = calls
|
||||
const out = { classes: [], text: [] }
|
||||
walk(tree, out)
|
||||
const joined = out.classes.join(' | ')
|
||||
const miss = expect.filter((c) => joined.indexOf(c) < 0)
|
||||
const problems = []
|
||||
if (miss.length) problems.push('缺 class: ' + miss.join(', '))
|
||||
const unrendered = out.text.filter((s) => /^<ms\./.test(s))
|
||||
if (unrendered.length) problems.push('未渲染词典键: ' + unrendered.join(', '))
|
||||
if (problems.length) { console.log(' ✗ ' + title + ' → ' + problems.join(' ; ')); failed++ }
|
||||
else console.log(' ✓ ' + title + ' (class ' + out.classes.length + ' 个, 文本 ' + out.text.length + ' 段)')
|
||||
}
|
||||
|
||||
ok('useState 调用数量与 STATE_MAP 对齐', sawCalls === STATE_MAP.length,
|
||||
sawCalls === STATE_MAP.length ? '-> ' + sawCalls : '-> 实测 ' + sawCalls + ' / 映射 ' + STATE_MAP.length + '(组件加了 state?请同步本脚本 STATE_MAP)')
|
||||
ok('渲染期未出现缺词典键', mockedKeys.length === 0, mockedKeys.length ? '-> ' + [...new Set(mockedKeys)].join(', ') : '')
|
||||
|
||||
console.log(failed === 0 ? '\n渲染冒烟全部通过' : '\n' + failed + ' 项失败')
|
||||
process.exit(failed === 0 ? 0 : 1)
|
||||
@@ -236,13 +236,41 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include
|
||||
tMs.includes("内置 DeepSeek") && tMs.includes("my-gw"));
|
||||
ok("模型设置:自定义条目副行显示 endpoint · 协议",
|
||||
tMs.includes("https://api.example.com/v1") && tMs.includes("openai-completions"));
|
||||
// 档案 87 补做:**按官方页的结构** —— 新增先「选厂家」,目录厂家只需填 API 密钥
|
||||
ok("模型设置:新增有厂家选择框(含内置 / 自定义两个端点选项)",
|
||||
tMs.includes("厂家:") && tMs.includes("内置 DeepSeek(平台共享)") && tMs.includes("自定义厂家(OpenAI 兼容网关)…"));
|
||||
ok("模型设置:选择框按「中国大陆 / 国际」分组,并列出目录厂家(含国内厂家)",
|
||||
tMs.includes("中国大陆") && tMs.includes("国际") && tMs.includes("月之暗面 Moonshot(中国)") && tMs.includes("OpenAI"));
|
||||
ok("模型设置:默认选中「内置 DeepSeek」时给出回落说明",
|
||||
tMs.includes("密钥留空则回落到平台共享密钥"));
|
||||
// ── 档案 91(2026-09-14):「新增」改官方**两步式** ──────────────────────────
|
||||
// 官方口径(`dsh-client-ui-settings-models` README.zh 原文):
|
||||
// · 「「新增」流程是一张承载休眠目录提供方选择框的卡片」
|
||||
// · 「编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名」
|
||||
// 所以默认态**只露两个虚线入口**,点开才出卡片(旧版是常开表单 + 下拉里混一项
|
||||
// 「自定义厂家(OpenAI 兼容网关)…」,两者形态不同 ⇒ 断言必须跟着换)。
|
||||
ok("模型设置:新增默认态只有两个虚线入口(添加提供方 / 添加自定义提供方)",
|
||||
tMs.includes("添加提供方") && tMs.includes("添加自定义提供方"));
|
||||
ok("模型设置:默认态**不再**出现厂家下拉与旧「自定义厂家…」选项",
|
||||
!tMs.includes("厂家:") && !tMs.includes("自定义厂家(OpenAI 兼容网关)"));
|
||||
// 点开「添加提供方」:出现「提供方」下拉(中国大陆 / 国际分组 + 目录厂家)+ 单一「API 密钥」主字段
|
||||
const tAdd = text(await renderWith(ms.Comp, () => { slots[5] = "add"; })).join(" | ");
|
||||
ok("模型设置:点开后有「提供方」下拉,按中国大陆 / 国际分组并列出目录厂家",
|
||||
tAdd.includes("提供方") && tAdd.includes("中国大陆") && tAdd.includes("国际") &&
|
||||
tAdd.includes("月之暗面 Moonshot(中国)") && tAdd.includes("OpenAI"));
|
||||
ok("模型设置:新增卡片主字段是「API 密钥」(内置回落占位符)",
|
||||
tAdd.includes("API 密钥") && tAdd.includes("输入你的 DeepSeek API 密钥"));
|
||||
ok("模型设置:次要字段收进「自定义设置」,并说明其余字段在 settings.yaml",
|
||||
tAdd.includes("自定义设置") && tAdd.includes("显示名称") && tAdd.includes("其余字段在 settings.yaml 中"));
|
||||
// 选中一个目录厂家 ⇒ 给出「端点/模型由目录兜底」的说明(档案 87 的核心口径)
|
||||
const tPick = text(await renderWith(ms.Comp, () => { slots[5] = "add"; slots[6] = "moonshotai-cn"; })).join(" | ");
|
||||
ok("模型设置:选目录厂家后提示「端点与 N 个模型由官方目录提供」",
|
||||
tPick.includes("由官方目录提供") && tPick.includes("12 个模型"));
|
||||
// 点开「添加自定义提供方」:官方字段序 + 模型改**行式编辑器**(旧版是 textarea 一行一个)
|
||||
const treeCustom = await renderWith(ms.Comp, () => { slots[5] = "custom"; });
|
||||
const tCustom = text(treeCustom).join(" | ");
|
||||
const cCustom = classes(treeCustom);
|
||||
ok("模型设置:自定义卡片含 Provider ID / API 地址 / API 协议 / 模型目录",
|
||||
tCustom.includes("Provider ID") && tCustom.includes("API 地址") &&
|
||||
tCustom.includes("API 协议") && tCustom.includes("模型目录"));
|
||||
// 行式编辑器:逐行条目(ms-modelEntry/ms-modelRow)+「添加模型」按钮;
|
||||
// 行尾删除按钮的文案只在 `aria-label`(文本收集器不收属性)⇒ 用词典键 + 图标按钮类来断言
|
||||
ok("模型设置:自定义模型目录是**行式编辑器**(逐行条目 + 添加模型 + 行尾删除)",
|
||||
cCustom.includes("ms-modelEntry") && cCustom.includes("ms-modelRow") && cCustom.includes("ms-iconBtn") &&
|
||||
tCustom.includes("添加模型") && !!ZH["ms.removeModel"]);
|
||||
// 2026-09-14 留痕:目录读不到时要**如实说明**,不能静默给个空列表
|
||||
{
|
||||
const saved = DATA["/api/me/model-providers"];
|
||||
@@ -252,20 +280,28 @@ ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.include
|
||||
};
|
||||
const tBad = text(await render(ms.Comp)).join(" | ");
|
||||
ok("模型设置:目录不可读时如实提示(含目录路径,不静默空列表)",
|
||||
tBad.includes("未能读取官方厂家目录") && tBad.includes("/opt/nope/pi-ai"), tBad.slice(0, 0) + "命中");
|
||||
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("未能读取官方厂家目录"));
|
||||
tEmpty.includes("官方提供方目录为空") && !tEmpty.includes("未能读取官方提供方目录"));
|
||||
DATA["/api/me/model-providers"] = saved;
|
||||
}
|
||||
ok("模型设置:词典备齐「只填密钥」相关文案(目录提示 / 展开模型)",
|
||||
!!ZH["ms.catalogHint"] && !!ZH["ms.showModels"] && !!ZH["ms.hideModels"] && !!ZH["ms.pickProvider"]);
|
||||
ok("模型设置:词典备齐「只填密钥」相关文案(目录提示 / 展开模型 / 两个入口)",
|
||||
!!ZH["ms.keyHintCatalog"] && !!ZH["ms.showModels"] && !!ZH["ms.hideModels"] &&
|
||||
!!ZH["ms.addProvider"] && !!ZH["ms.addCustom"] && !!ZH["ms.customized"]);
|
||||
// 落地在 spawn 时 ⇒ 文案必须说清"重启才生效",否则用户会以为保存即生效
|
||||
ok("模型设置:文案说明「重启实例生效」", tMs.includes("重启实例生效"));
|
||||
// 档案 91:视觉层从「门户表格家族」换成**官方 CSS 模块家族**(`.ms-*`)——
|
||||
// 换掉表格的原因见档案 91:4 列 auto 列宽会把名称/按钮挤到折行。
|
||||
const cMs = classes(await render(ms.Comp));
|
||||
ok("模型设置:复用门户组件类(.pa-box/.pa-tbl/.pa-sm/.pa-badge/.pa-input)",
|
||||
["pa-box", "pa-tbl", "pa-sm", "pa-badge", "pa-input"].every(c => cMs.includes(c)));
|
||||
ok("模型设置:行用官方卡片行类(ms-rows/ms-rowCard/ms-rowHead/ms-rowIdentity/ms-rowActions)",
|
||||
["ms-rows", "ms-rowCard", "ms-rowHead", "ms-rowIdentity", "ms-rowActions"].every(c => cMs.includes(c)));
|
||||
ok("模型设置:新增入口用官方虚线按钮类(ms-addActions/ms-addBtn/ms-btn)",
|
||||
["ms-addActions", "ms-addBtn", "ms-btn"].every(c => cMs.includes(c)));
|
||||
ok("模型设置:**不再**用 4 列表格(pa-tbl / pa-sm / pa-input 已从本分区移除)",
|
||||
!cMs.includes("pa-tbl") && !cMs.includes("pa-sm") && !cMs.includes("pa-input"));
|
||||
ok("模型设置:保留门户 .pa-box 外壳与 .pa-badge 徽章", cMs.includes("pa-box") && cMs.includes("pa-badge"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user