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:
admin committed 2026-09-14 00:00:15 +08:00
1 parent eb50ca2d5b
commit 918f1d3a51
20 files changed
+2073 -276

No files matched your search

+160 -27
View File
@@ -21,6 +21,8 @@ import {
toWorkspace,
type BusinessPlugin,
type CredentialKey,
type CredentialKeyMeta,
type CredentialLandingRow,
type CreateSessionInput,
type CreateUserInput,
type Domain,
@@ -211,57 +213,188 @@ export function upsertDomain(db: Database, userId: string, domain: string, nginx
return findDomainByUser(db, userId)!
}
/**
* 两个凭据列表共用的列清单与行映射(档案 87)—— 抽出来是为了**两处不可能漂**:
* 之前 `listCredentialKeys` 与 `listEnabledCredentialKeys` 各写一份 SELECT,
* 加一次列就要改两处,漏一处就是"一个列表有 route、另一个没有"。
*/
const CREDENTIAL_COLS = 'id, key_name, enabled, updated_at, route, base_url, api, models'
interface CredentialRow {
id: string
key_name: string
enabled: number
updated_at: number
route: string | null
base_url: string | null
api: string | null
models: string | null
}
function toCredentialKey(r: CredentialRow): CredentialKey {
return {
id: r.id,
name: r.key_name,
enabled: r.enabled === 1,
updatedAt: r.updated_at,
route: r.route,
baseUrl: r.base_url,
api: r.api,
models: r.models,
}
}
/** List a user's named credential keys (metadata only). */
export function listCredentialKeys(db: Database, userId: string): CredentialKey[] {
const rows = prepare(
db,
'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC',
).all(userId) as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>
return rows.map((r) => ({ id: r.id, name: r.key_name, enabled: r.enabled === 1, updatedAt: r.updated_at }))
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC`,
).all(userId) as CredentialRow[]
return rows.map(toCredentialKey)
}
/** The enabled key's encrypted ref for a user (decrypt with the deployment secret). */
/**
* **内置 DeepSeek 条目**的 encrypted ref(档案 87 的**语义重定义**,必须读这一条)。
*
* 老语义是「那一把启用的 key」—— 当时 `setCredentialKey` 会先 `SET enabled = 0` 全关,
* 所以 `enabled = 1` **最多一行**,不带 ORDER BY 也唯一。
* 用户口径改成「条目各自开关、都能同时启用」后,互斥被删(见 `setCredentialKey`)⇒
* `WHERE enabled = 1` 可能命中**多行**,而**没有 ORDER BY 就是"任取一条"** ——
* `auth.ts` 的 `keySourceOf()` 与 `server.ts` 的 `resolveApiKey()` 会因此**随机飘**。
*
* ⇒ 这里把语义钉死为:**已启用、且是内置 DeepSeek(`base_url` 为空)的最新一条**。
* 自定义厂家**不算**"自己的 DeepSeek key"(它们各自有自己的 ref 与 settings 段)。
* @returns 该 ref,或 `null`(用户没有任何启用的内置条目)。
*/
export function getEnabledCredentialKeyRef(db: Database, userId: string): string | null {
const row = prepare(db, 'SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1').get(userId) as
| { secret_ref: string }
| undefined
const row = prepare(
db,
"SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1 AND (base_url IS NULL OR base_url = '') ORDER BY updated_at DESC, id DESC LIMIT 1",
).get(userId) as { secret_ref: string } | undefined
return row?.secret_ref ?? null
}
/** Upsert a named key, disable the others, and enable this one. */
export function setCredentialKey(db: Database, userId: string, name: string, encryptedRef: string): CredentialKey {
/**
* Upsert a named key by `name` and enable it —— **不动**其它条目(档案 87)。
*
* ⚠️ 老行为是「先 `SET enabled = 0` 全关,再开这一个」(单选)。用户口径已改为
* 「条目各自开关、都能同时启用」,所以那一行**已删**:否则用户每加一把 key,
* 别的厂家就被静默关掉。要"只开这一个"请显式先关别的(`toggleCredentialKey`)。
* @param meta - 模型厂家元数据(route / endpoint / 协议 / 模型清单),见 {@link CredentialKeyMeta}。
*/
export function setCredentialKey(
db: Database,
userId: string,
name: string,
encryptedRef: string,
meta: CredentialKeyMeta = {},
): CredentialKey {
const route = meta.route ?? null
const baseUrl = meta.baseUrl ?? null
const api = meta.api ?? null
const models = meta.models ?? null
const id = db.transaction(() => {
prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId)
const existing = prepare(db, 'SELECT id FROM credential_vault WHERE user_id = ? AND key_name = ?').get(
userId,
name,
) as { id: string } | undefined
if (existing !== undefined) {
prepare(db, 'UPDATE credential_vault SET secret_ref = ?, enabled = 1, updated_at = ? WHERE id = ?').run(
encryptedRef,
Date.now(),
existing.id,
)
prepare(
db,
'UPDATE credential_vault SET secret_ref = ?, route = ?, base_url = ?, api = ?, models = ?, enabled = 1, updated_at = ? WHERE id = ?',
).run(encryptedRef, route, baseUrl, api, models, Date.now(), existing.id)
return existing.id
}
const id = randomUUID()
const newId = randomUUID()
prepare(
db,
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES (?, ?, ?, ?, 1, ?)',
).run(id, userId, name, encryptedRef, Date.now())
return id
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, route, base_url, api, models, enabled, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
).run(newId, userId, name, encryptedRef, route, baseUrl, api, models, Date.now())
return newId
})()
return { id, name, enabled: true, updatedAt: Date.now() }
return { id, name, enabled: true, updatedAt: Date.now(), route, baseUrl, api, models }
}
/** Enable one of a user's named keys (disabling the others). */
/**
* 启用某一个条目(档案 87:**改为非互斥**)。
*
* 老语义是「单选」:先 `SET enabled = 0` 把该用户所有条目关掉,再开这一个。
* 用户口径改成「各自开关、可同时启用」之后,那一步会**悄悄关掉别的已启用条目**
* (用户看不出为什么换了个厂家另一个就不生效了),所以这里退化成
* `toggleCredentialKey(id, true)` 的同义实现 —— **保留函数名只为接口兼容**。
*/
export function selectCredentialKey(db: Database, userId: string, id: string): boolean {
const ok = db.transaction(() => {
prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId)
const info = prepare(db, 'UPDATE credential_vault SET enabled = 1 WHERE id = ? AND user_id = ?').run(id, userId)
return info.changes > 0
})()
return ok as boolean
return toggleCredentialKey(db, userId, id, true)
}
/**
* 打开/关闭**单个**条目(档案 87;用户口径:条目各自开关、可同时启用)。
* 与 `selectCredentialKey`(名字带"单选"但已改为非互斥)的差别:这里**只碰这一行**。
* @returns 是否命中了该用户下的这一行(false = 不存在或不属于他)。
*/
export function toggleCredentialKey(db: Database, userId: string, id: string, enabled: boolean): boolean {
const info = prepare(db, 'UPDATE credential_vault SET enabled = ?, updated_at = ? WHERE id = ? AND user_id = ?').run(
enabled ? 1 : 0,
Date.now(),
id,
userId,
)
return info.changes > 0
}
/** 该用户**所有已启用**的条目(含 route / base_url / api / models)—— spawn 时按它们写实例配置。 */
export function listEnabledCredentialKeys(db: Database, userId: string): CredentialKey[] {
const rows = prepare(
db,
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = ? AND enabled = 1 ORDER BY updated_at DESC`,
).all(userId) as CredentialRow[]
return rows.map(toCredentialKey)
}
/**
* 该用户**所有已启用**的条目 + 各自 encrypted ref —— **仅供落地层**(档案 87)。
* 与 `listEnabledCredentialKeys` 的差别只有一个:多返回 `secret_ref`。
* 单独一个函数是为了让"密文"这件事**不可能**顺着 `/api/me/keys` 漏出去。
*/
export function listCredentialLandingRows(db: Database, userId: string): CredentialLandingRow[] {
const rows = prepare(
db,
'SELECT key_name, route, base_url, api, models, secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1 ORDER BY updated_at DESC',
).all(userId) as Array<{
key_name: string
route: string | null
base_url: string | null
api: string | null
models: string | null
secret_ref: string
}>
return rows.map((r) => ({
name: r.key_name,
route: r.route,
baseUrl: r.base_url,
api: r.api,
models: r.models,
encryptedRef: r.secret_ref,
}))
}
/**
* 该用户是否启用「平台共享模型」(档案 87)—— **用户侧偏好**,开关它**不动** admin 的配置。
*
* 缺失行一律按 `true` 处理:V6 迁移给所有老用户填了默认 1,而"查不到这个人"时
* 也不该因为一个开关把共享 key 断掉(宁可多给,不可少给)。
*/
export function getSharedModelEnabled(db: Database, userId: string): boolean {
const row = prepare(db, 'SELECT shared_model_enabled FROM users WHERE id = ?').get(userId) as
| { shared_model_enabled: number }
| undefined
return row === undefined ? true : row.shared_model_enabled !== 0
}
/** 打开/关闭「平台共享模型」(档案 87)。@returns 是否命中该用户。 */
export function setSharedModelEnabled(db: Database, userId: string, enabled: boolean): boolean {
const info = prepare(db, 'UPDATE users SET shared_model_enabled = ? WHERE id = ?').run(enabled ? 1 : 0, userId)
return info.changes > 0
}
/** Delete a named key (by id, scoped to the user). */