feat(models): 平台自建「模型设置」—— 用户自配厂家 / 条目各自开关 / 共享模型开关(档案 87)
- 官方「设置 → 模型」页在平台环境**必然报错**(判据在**浏览器页面**的 loopback 判定;官方 README 原文 Non-loopback pages get no durable settings)⇒ 该分区对全角色(含 admin)隐藏,用户自配改走平台自建页(插件 0.3.11)
- DB 迁移 V6:credential_vault.route/base_url/api/models + users.shared_model_enabled;并**重定义 getEnabledCredentialKeyRef**(互斥删除后原实现无 ORDER BY ⇒ 「任取一条」)
- 新落地层 src/web/model-landing.ts:spawn 时把「已启用条目」写进实例 .credentials.yaml 与 settings.yaml 的 llm-pi-ai.providers.<route>;字段名与官方包实测对齐(apiKeyEnv / baseURL / api,**不是** protocol);只碰自己写过的 + 一次性交接
- 接口 /api/me/keys、/api/me/keys/:id/toggle、/api/me/models/shared;前端新增「设置 → 模型设置」分区(settings.section id=model-settings / order 100 / 全角色)
- 顺手修两处:ensure-role-profile-patch.cjs 的 --force 整文件覆盖会抹掉 admin 的 disable-hmr 与 workspace-scoped-picker 两个平台块(改为 stripManagedBlock 只替换自己那段);verify-mem-model.mjs 因档案 86 重构而长期失败的陈旧断言
⚠️ 本提交同时包含**档案 86(admin 跨用户实例管理 + 两处改名)**的代码改动 —— 该部分已上线并端到端验证;其 import/register 与本次改动同处 src/web/server.ts、poc/business-plugins/lib/client.js 等文件,按**文件粒度无法拆分**,且不带它会让仓库 tsc 直接失败(缺 src/web/routes/admin-user-ops.ts)。
This commit is contained in:
1 parent
eb50ca2d5b
commit
918f1d3a51
20 files changed
+2073
-276
No files matched your search
+103
-20
@@ -5,12 +5,20 @@
|
||||
* 背景:
|
||||
* · 2026-09-09:普通用户在设置面板不应看到「模型」分区(模型 KEY 由管理员经门户统一
|
||||
* 管控,档案 03;且避免普通用户误配自用 key 绕过统一 key)。
|
||||
* · **2026-09-13(档案 86):「模型」分区对普通用户放开** —— 用户要求把配置模型密钥
|
||||
* · 2026-09-13(**档案 85 §九**):「模型」分区对普通用户放开 —— 用户要求把配置模型密钥
|
||||
* 开放给用户自己配,且「界面交互和官方一模一样」(⇒ 直接用官方页,不仿制)。
|
||||
* 实测:官方页在本环境**可用**(`/api/session/modelCatalog` 返回 200,"provider 目录
|
||||
* 不可用"的旧判断已不成立)。配套改平台注入:用户自配 ⇒ 不注入共享 env
|
||||
* (`src/web/server.ts` `userHasOwnKey()`,否则 env 优先级会静默盖掉用户配的 key)。
|
||||
* 仍禁用:plugins / plugin-inventory / cordis(骨架插件禁任一都可能搞坏实例)。
|
||||
* (`src/web/server.ts` 的 `resolveApiKey()` 恒返回 null,否则 env 优先级会静默盖掉
|
||||
* 用户配的 key)。仍禁用:plugins / plugin-inventory / cordis。
|
||||
* · **2026-09-13(档案 87):上面那条被推翻 —— 该分区必须重新禁掉,且这次连 admin 一起禁**。
|
||||
* 实测:官方页要求 Host settings 镜像,而平台是「浏览器经域名访问远程服务器」⇒
|
||||
* `isLoopback` 为 false ⇒ persistence 降级为 `memory` ⇒ 页面**必报**「加载提供方目录失败」
|
||||
* (完整判据见下方 `DISABLE_MODELS_BLOCK` 的注释)。⇒ 放开它只是给用户一个报错页;
|
||||
* 用户自配模型改走**平台自建的「模型设置」分区**(`poc/business-plugins` **0.3.11**:
|
||||
* 界面走 `/api/me/keys` 等接口,落地由 `src/web/server.ts` 在 spawn 时写
|
||||
* `$DSH_HOME/.credentials.yaml` 与 `$DSH_HOME/settings.yaml` —— 字段名与官方包对齐,
|
||||
* 见 `src/web/model-landing.ts` 头注释)。
|
||||
* cordis patch 支持对 client 插件行
|
||||
* `disabled: true`(dsh-app-boot applyEntryPatches:非 insert patch 按 id 合入
|
||||
* overrides)——生效位置 = profile 层 `cordis.patch.yml`(实例启动时打包 client
|
||||
@@ -34,14 +42,21 @@ const MARK = '# dshs role patch'
|
||||
// --force:已由本脚本管理但内容落后(缺新版禁用项)时,整体升级为当前块。
|
||||
const FORCE = process.argv.includes('--force')
|
||||
const DISABLE_MODELS_BLOCK = [
|
||||
'# dshs role patch: 收归核心插件开关(档案 15)',
|
||||
'# dshs role patch: 普通用户隐藏模型分区 + 收归核心插件开关(档案 15 / 87)',
|
||||
'# admin 保留;由 ensure-role-profile-patch.cjs 管理,勿手改',
|
||||
'# 148 个 @deepseek-ai 官方插件均为运行骨架,用户禁用任一都可能搞坏实例,',
|
||||
'# 故插件栏 / 插件清单 / cordis 面板只对 admin 开放;ui-skill、ui-permission 保留给用户。',
|
||||
'#',
|
||||
'# ⚠️ ui-settings-models **自 2026-09-13 起不再禁用**(档案 86):用户要自助配置模型厂家与',
|
||||
'# key(「界面交互和官方一模一样」)。配套:平台注入策略改为「用户自配则不注入共享 env」,',
|
||||
'# 使模型页配的 key 真能生效 —— 见 src/web/server.ts 的 userHasOwnKey()。',
|
||||
'# ⛔ ui-settings-models **必须保持禁用**(2026-09-13 实地查证,档案 87):',
|
||||
'# 官方模型页要求 Host settings 镜像,而 `dsh-client-ui-settings` 的持久化判定是',
|
||||
'# `isLoopback = transport.ownsHost || pageLocation === undefined || isLoopbackHostname(page)`,',
|
||||
'# 平台是「浏览器经域名访问远程服务器」⇒ 三条都不成立 ⇒ persistence 降级为 `memory`',
|
||||
'# ⇒ `ensure()` 直接返回不读 ⇒ 页面必报「加载提供方目录失败: settings are unavailable',
|
||||
'# in this browser」(官方 README 原文:a non-loopback browser cannot use that Host-only namespace)。',
|
||||
'# ⇒ **放开它只会给用户一个报错页**;用户自配模型改走平台自建的「模型设置」分区(档案 87)。',
|
||||
'- id: ui-settings-models',
|
||||
' name: "@deepseek-ai/dsh-client-ui-settings-models"',
|
||||
' disabled: true',
|
||||
'- id: ui-settings-plugins',
|
||||
' name: "@deepseek-ai/dsh-client-ui-settings-plugins"',
|
||||
' disabled: true',
|
||||
@@ -54,6 +69,26 @@ const DISABLE_MODELS_BLOCK = [
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* admin 的块:**只隐藏「模型」分区**(档案 87)。
|
||||
*
|
||||
* 为什么连 admin 也要隐藏:该页要求 Host settings 镜像,而平台是「浏览器经域名访问远程服务器」
|
||||
* ⇒ `isLoopback` 为 false ⇒ persistence 降级为 `memory` ⇒ 页面必报
|
||||
* 「加载提供方目录失败: settings are unavailable in this browser」。
|
||||
* **对 admin 同样如此** —— 之前这个补丁只写非 admin,所以 admin 一直能看到并点进报错页。
|
||||
* admin 的其余能力(插件栏 / cordis 面板)保持官方默认,**不**跟着禁。
|
||||
*/
|
||||
const ADMIN_MODELS_BLOCK = [
|
||||
'# dshs role patch: admin 仅隐藏「模型」分区(档案 87)',
|
||||
'# 由 ensure-role-profile-patch.cjs 管理,勿手改',
|
||||
'# 该页在平台环境必然报错(non-loopback 页面拿不到 Host settings),故对 admin 一并隐藏。',
|
||||
'# 用户自配模型改走平台自建页(实例内「设置 → 模型设置」)。',
|
||||
'- id: ui-settings-models',
|
||||
' name: "@deepseek-ai/dsh-client-ui-settings-models"',
|
||||
' disabled: true',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
function isEmptyPatch(content) {
|
||||
const body = content
|
||||
.split('\n')
|
||||
@@ -62,26 +97,72 @@ function isEmptyPatch(content) {
|
||||
return body.length === 0 || body.join('') === '[]'
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文本里摘掉本脚本**上一次写的那个块**(从 `MARK` 行起,到下一个平台块 `# >>>` 或文件尾),
|
||||
* 其余内容原样保留。
|
||||
*
|
||||
* ⚠️ 为什么不能直接 `writeFileSync(patchPath, block)`(老实现就是那么写的,是个**雷**):
|
||||
* admin 的 `cordis.patch.yml` 里同时住着三个平台块 —— `disable-hmr`、
|
||||
* `workspace-scoped-picker`、本脚本的 role patch。整文件覆盖 ⇒ **另两个块被静默抹掉**:
|
||||
* disable-hmr 丢掉 = 生产实例重新连 HMR;picker 块丢掉 = 目录选择器回到「可改任意路径」的
|
||||
* 无限制版(档案 18 v3 的收敛形同作废)。所以升级只准替换**自己那一段**。
|
||||
* @param text - 现有 patch 文本。
|
||||
* @returns 去掉本脚本那个块之后的文本(找不到 MARK 时原样返回)。
|
||||
*/
|
||||
function stripManagedBlock(text) {
|
||||
const lines = text.split('\n')
|
||||
const start = lines.findIndex((l) => l.startsWith(MARK))
|
||||
if (start < 0) return text
|
||||
let end = lines.length
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('# >>>')) {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
while (end > start && lines[end - 1].trim() === '') end-- // 吃掉块尾空行,避免越删越空
|
||||
return [...lines.slice(0, start), ...lines.slice(end)].join('\n').replace(/\n{3,}/g, '\n\n')
|
||||
}
|
||||
|
||||
function ensureUserPatch(user) {
|
||||
const patchPath = join(user.home_dir, 'profiles', PROFILE, 'cordis.patch.yml')
|
||||
if (!existsSync(patchPath)) {
|
||||
return { user: user.username, action: 'NO_PROFILE', detail: 'profile 尚未创建(用户未首登 spawn);请先登录一次再跑' }
|
||||
}
|
||||
const current = readFileSync(patchPath, 'utf8')
|
||||
if (current.includes(MARK)) {
|
||||
// 需要升级的两种旧态:① 缺「插件开关收归」;② **还禁着 ui-settings-models**
|
||||
// (2026-09-13 之前写入的块 —— 那一版把「模型」分区也对用户藏了,现已放开,见档案 86)。
|
||||
const legacy =
|
||||
!current.includes('ui-settings-plugins') || /^-\s*id:\s*ui-settings-models\s*$/m.test(current)
|
||||
if (FORCE && legacy) {
|
||||
writeFileSync(patchPath, DISABLE_MODELS_BLOCK, 'utf8')
|
||||
return { user: user.username, action: 'upgraded', detail: '已升级(放开「模型」分区 + 保留核心插件开关收归)' }
|
||||
const isAdmin = user.role === 'admin'
|
||||
const block = isAdmin ? ADMIN_MODELS_BLOCK : DISABLE_MODELS_BLOCK
|
||||
// 需要升级的三种旧态:① 还没写上本轮的「档案 87」标记(注释是判据的**唯一落盘处**,
|
||||
// 陈旧就等于判据丢失);② 缺核心插件开关收归;③ 还禁着 ui-settings-models 的旧版(已放开)。
|
||||
const stale = !current.includes('档案 87')
|
||||
const legacy = isAdmin
|
||||
? stale || !/^-\s*id:\s*ui-settings-models\s*$/m.test(current)
|
||||
: stale || !current.includes('ui-settings-plugins') || !/^-\s*id:\s*ui-settings-models\s*$/m.test(current)
|
||||
if (current.includes(MARK)) {
|
||||
if (FORCE && legacy) {
|
||||
const rest = stripManagedBlock(current).replace(/^\s*\n+/, '')
|
||||
writeFileSync(patchPath, (rest.trim() === '' ? '' : rest.replace(/\s*$/, '') + '\n\n') + block, 'utf8')
|
||||
return {
|
||||
user: user.username,
|
||||
action: 'upgraded',
|
||||
detail: isAdmin
|
||||
? '已替换 admin 块(只隐藏「模型」分区;disable-hmr / picker 两个平台块原样保留)'
|
||||
: '已升级(收回「模型」分区 + 核心插件开关收归)',
|
||||
}
|
||||
return { user: user.username, action: 'skip', detail: '已由本脚本管理' }
|
||||
}
|
||||
if (!isEmptyPatch(current)) return { user: user.username, action: 'skip', detail: '用户已定制 cordis.patch.yml,不覆盖' }
|
||||
writeFileSync(patchPath, DISABLE_MODELS_BLOCK, 'utf8')
|
||||
return { user: user.username, action: 'wrote', detail: '已写入 disable models patch' }
|
||||
return { user: user.username, action: 'skip', detail: '已由本脚本管理' }
|
||||
}
|
||||
if (!isEmptyPatch(current)) {
|
||||
// 档案 87:admin 的 patch 里已经有**别的平台块**(disable-hmr / workspace-scoped-picker),
|
||||
// 整文件覆盖会丢掉它们 ⇒ **追加**(幂等:无 MARK 才追加;有 MARK 走上面的 upgrade 分支)。
|
||||
if (isAdmin) {
|
||||
writeFileSync(patchPath, current.replace(/\s*$/, '') + '\n\n' + block, 'utf8')
|
||||
return { user: user.username, action: 'wrote', detail: '已**追加** admin 块(只隐藏「模型」分区,保留既有平台块)' }
|
||||
}
|
||||
return { user: user.username, action: 'skip', detail: '用户已定制 cordis.patch.yml,不覆盖' }
|
||||
}
|
||||
writeFileSync(patchPath, block, 'utf8')
|
||||
return { user: user.username, action: 'wrote', detail: isAdmin ? '已写 admin 块(只隐藏「模型」分区)' : '已写入 role patch' }
|
||||
}
|
||||
|
||||
function restartUserInstance(user) {
|
||||
@@ -118,7 +199,9 @@ function main() {
|
||||
if (usernames.length > 0) {
|
||||
rows = usernames.map((u) => db.prepare('SELECT id, username, role, home_dir, uid FROM users WHERE username=?').get(u)).filter(Boolean)
|
||||
} else {
|
||||
rows = db.prepare("SELECT id, username, role, home_dir, uid FROM users WHERE role != 'admin'").all()
|
||||
// 档案 87:**含 admin** —— admin 也要隐藏「模型」分区(该页在平台环境必然报错)。
|
||||
// admin 的块只禁 models 一项(插件栏 / cordis 面板保持官方默认,admin 需要它们)。
|
||||
rows = db.prepare("SELECT id, username, role, home_dir, uid FROM users WHERE role IN ('admin','active')").all()
|
||||
}
|
||||
db.close()
|
||||
if (rows.length === 0) {
|
||||
|
||||
Reference in new issue
Block a user