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
+120
-28
@@ -20,6 +20,8 @@ import {
|
||||
toWorkspace,
|
||||
type BusinessPlugin,
|
||||
type CredentialKey,
|
||||
type CredentialKeyMeta,
|
||||
type CredentialLandingRow,
|
||||
type CreateSessionInput,
|
||||
type CreateUserInput,
|
||||
type Domain,
|
||||
@@ -64,6 +66,38 @@ export async function withTx<T>(pool: Pool, fn: (client: PoolClient) => Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭据行的列清单与映射(档案 87)—— 与 `repo.ts` 里同名的一组**逐字对应**:
|
||||
* 两个后端必须选出同一批列,否则就是"SQLite 上好好的、k8s 上少字段"这种
|
||||
* 只在生产才出现的偏差。(**不**从 `repo.ts` 导入:那会把 better-sqlite3 原生依赖
|
||||
* 拖进 pg 模式。)
|
||||
*/
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
export class PgAdapter implements DbAdapter {
|
||||
constructor(private readonly pool: Pool, private readonly baseUid: number) {}
|
||||
|
||||
@@ -336,65 +370,123 @@ export class PgAdapter implements DbAdapter {
|
||||
|
||||
async listCredentialKeys(userId: string): Promise<CredentialKey[]> {
|
||||
const { rows } = await this.pool.query(
|
||||
'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = $1 ORDER BY updated_at DESC',
|
||||
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = $1 ORDER BY updated_at DESC`,
|
||||
[userId],
|
||||
)
|
||||
return (rows as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>).map((r) => ({
|
||||
id: r.id,
|
||||
return (rows as CredentialRow[]).map(toCredentialKey)
|
||||
}
|
||||
|
||||
async listEnabledCredentialKeys(userId: string): Promise<CredentialKey[]> {
|
||||
const { rows } = await this.pool.query(
|
||||
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = $1 AND enabled = 1 ORDER BY updated_at DESC`,
|
||||
[userId],
|
||||
)
|
||||
return (rows as CredentialRow[]).map(toCredentialKey)
|
||||
}
|
||||
|
||||
/** 落地层专用:多返回 `secret_ref`(密文)—— 与 `listEnabledCredentialKeys` 的唯一差别。 */
|
||||
async listCredentialLandingRows(userId: string): Promise<CredentialLandingRow[]> {
|
||||
const { rows } = await this.pool.query(
|
||||
'SELECT key_name, route, base_url, api, models, secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1 ORDER BY updated_at DESC',
|
||||
[userId],
|
||||
)
|
||||
return (
|
||||
rows as Array<{
|
||||
key_name: string
|
||||
route: string | null
|
||||
base_url: string | null
|
||||
api: string | null
|
||||
models: string | null
|
||||
secret_ref: string
|
||||
}>
|
||||
).map((r) => ({
|
||||
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,
|
||||
encryptedRef: r.secret_ref,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* **内置 DeepSeek 条目**的 encrypted ref(档案 87 的语义重定义)。
|
||||
* 互斥被删之后 `enabled = 1` 可能命中多行 ⇒ 必须钉死"内置 + 最新一条",
|
||||
* 否则调用方会随机拿到某一个厂家的 key(详见 `repo.ts` 同名函数的注释)。
|
||||
*/
|
||||
async getEnabledCredentialKeyRef(userId: string): Promise<string | null> {
|
||||
const { rows } = await this.pool.query(
|
||||
'SELECT secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1',
|
||||
"SELECT secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1 AND (base_url IS NULL OR base_url = '') ORDER BY updated_at DESC, id DESC LIMIT 1",
|
||||
[userId],
|
||||
)
|
||||
const row = rows[0] as { secret_ref: string } | undefined
|
||||
return row?.secret_ref ?? null
|
||||
}
|
||||
|
||||
async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> {
|
||||
/** Upsert by `name` 并启用它 —— **不动**其它条目(档案 87,互斥已删)。 */
|
||||
async setCredentialKey(
|
||||
userId: string,
|
||||
name: string,
|
||||
encryptedRef: string,
|
||||
meta?: CredentialKeyMeta,
|
||||
): Promise<CredentialKey> {
|
||||
const route = meta?.route ?? null
|
||||
const baseUrl = meta?.baseUrl ?? null
|
||||
const api = meta?.api ?? null
|
||||
const models = meta?.models ?? null
|
||||
try {
|
||||
return await withTx(this.pool, async (client) => {
|
||||
await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId])
|
||||
const existing = await client.query(
|
||||
'SELECT id FROM credential_vault WHERE user_id = $1 AND key_name = $2',
|
||||
[userId, name],
|
||||
)
|
||||
const existing = await client.query('SELECT id FROM credential_vault WHERE user_id = $1 AND key_name = $2', [
|
||||
userId,
|
||||
name,
|
||||
])
|
||||
let id: string
|
||||
if (existing.rows.length > 0) {
|
||||
id = (existing.rows[0] as { id: string }).id
|
||||
await client.query('UPDATE credential_vault SET secret_ref = $1, enabled = 1, updated_at = $2 WHERE id = $3', [
|
||||
encryptedRef,
|
||||
Date.now(),
|
||||
id,
|
||||
])
|
||||
await client.query(
|
||||
'UPDATE credential_vault SET secret_ref = $1, route = $2, base_url = $3, api = $4, models = $5, enabled = 1, updated_at = $6 WHERE id = $7',
|
||||
[encryptedRef, route, baseUrl, api, models, Date.now(), id],
|
||||
)
|
||||
} else {
|
||||
id = randomUUID()
|
||||
await client.query(
|
||||
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES ($1, $2, $3, $4, 1, $5)',
|
||||
[id, userId, name, encryptedRef, Date.now()],
|
||||
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, route, base_url, api, models, enabled, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, $9)',
|
||||
[id, userId, name, encryptedRef, route, baseUrl, api, models, Date.now()],
|
||||
)
|
||||
}
|
||||
return { id, name, enabled: true, updatedAt: Date.now() }
|
||||
return { id, name, enabled: true, updatedAt: Date.now(), route, baseUrl, api, models }
|
||||
})
|
||||
} catch (e) {
|
||||
mapPgError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 启用一个条目(档案 87:**非互斥**,不再先全关)。 */
|
||||
async selectCredentialKey(userId: string, id: string): Promise<boolean> {
|
||||
return await withTx(this.pool, async (client) => {
|
||||
await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId])
|
||||
const result = await client.query('UPDATE credential_vault SET enabled = 1 WHERE id = $1 AND user_id = $2', [
|
||||
id,
|
||||
userId,
|
||||
])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
})
|
||||
return await this.toggleCredentialKey(userId, id, true)
|
||||
}
|
||||
|
||||
async toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise<boolean> {
|
||||
const result = await this.pool.query(
|
||||
'UPDATE credential_vault SET enabled = $1, updated_at = $2 WHERE id = $3 AND user_id = $4',
|
||||
[enabled ? 1 : 0, Date.now(), id, userId],
|
||||
)
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
/** 该用户是否启用「平台共享模型」(档案 87)—— 缺失行按 `true`(默认值)。 */
|
||||
async getSharedModelEnabled(userId: string): Promise<boolean> {
|
||||
const { rows } = await this.pool.query('SELECT shared_model_enabled FROM users WHERE id = $1', [userId])
|
||||
const row = rows[0] as { shared_model_enabled: number } | undefined
|
||||
return row === undefined ? true : Number(row.shared_model_enabled) !== 0
|
||||
}
|
||||
|
||||
async setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean> {
|
||||
const result = await this.pool.query('UPDATE users SET shared_model_enabled = $1 WHERE id = $2', [
|
||||
enabled ? 1 : 0,
|
||||
userId,
|
||||
])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
|
||||
|
||||
Reference in new issue
Block a user