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
+17
-1
@@ -8,6 +8,8 @@
|
||||
import type {
|
||||
BusinessPlugin,
|
||||
CredentialKey,
|
||||
CredentialKeyMeta,
|
||||
CredentialLandingRow,
|
||||
CreateSessionInput,
|
||||
CreateUserInput,
|
||||
Domain,
|
||||
@@ -71,9 +73,23 @@ export interface DbAdapter {
|
||||
setDomainVerified(id: string, verified: boolean): Promise<boolean>
|
||||
// credential vault
|
||||
listCredentialKeys(userId: string): Promise<CredentialKey[]>
|
||||
/** 档案 86:该用户**全部已启用**的条目(spawn 时按它们写实例配置)。 */
|
||||
listEnabledCredentialKeys(userId: string): Promise<CredentialKey[]>
|
||||
/** 档案 87:同上 + **encrypted ref** —— **仅供 `server.ts` 的落地层**,绝不经 API 返回。 */
|
||||
listCredentialLandingRows(userId: string): Promise<CredentialLandingRow[]>
|
||||
getEnabledCredentialKeyRef(userId: string): Promise<string | null>
|
||||
setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey>
|
||||
setCredentialKey(
|
||||
userId: string,
|
||||
name: string,
|
||||
encryptedRef: string,
|
||||
meta?: CredentialKeyMeta,
|
||||
): Promise<CredentialKey>
|
||||
selectCredentialKey(userId: string, id: string): Promise<boolean>
|
||||
/** 档案 86:开/关**单个**条目(不动其它条目 —— 用户口径:可同时启用多个)。 */
|
||||
toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise<boolean>
|
||||
/** 档案 86:用户是否使用「平台共享模型」(admin 配的那把)—— 用户侧偏好。 */
|
||||
getSharedModelEnabled(userId: string): Promise<boolean>
|
||||
setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean>
|
||||
deleteCredentialKey(userId: string, id: string): Promise<boolean>
|
||||
// instances (desired state the k8s controller reconciles against — docs/k8s.md §5.7)
|
||||
upsertInstance(input: UpsertDshInstanceInput): Promise<void>
|
||||
|
||||
+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> {
|
||||
|
||||
+160
-27
@@ -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). */
|
||||
|
||||
@@ -258,6 +258,40 @@ CREATE TABLE IF NOT EXISTS business_plugins (
|
||||
);
|
||||
`
|
||||
|
||||
// v6: 用户自配「模型厂家」支持(档案 87)。
|
||||
// 原先 `credential_vault` 只存一把 key(`key_name` 兼作展示名);用户要按**厂家**配模型,
|
||||
// 平台还需要知道:**route**(= settings.yaml 里 `llm-pi-ai.providers` 的 dict 键)、
|
||||
// **endpoint**(`baseURL`)、**协议**(`api`)、**模型清单**(`models`)—— spawn 时按这四样写
|
||||
// `$DSH_HOME/settings.yaml` 的 `llm-pi-ai.providers.<route>` 与
|
||||
// `$DSH_HOME/.credentials.yaml` 的 `refs.<REF>`。
|
||||
// · `base_url` 为空 = **内置 DeepSeek**(只写 refs,不写 settings.yaml)。
|
||||
// · REF 命名:内置 = `DEEPSEEK_API_KEY`;自定义 = `<ROUTE 大写化>_API_KEY`
|
||||
// (须匹配 `@deepseek-ai/dsh-credentials` 的 `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`)。
|
||||
// · `api` 的合法值只有三个(`dsh-llm-pi-ai` 的 `PROTOCOLS` 键,顺序即默认优先级):
|
||||
// `openai-completions` / `openai-responses` / `anthropic-messages`。
|
||||
// · ⚠️ 字段名是 **`api` 不是 `protocol`**;`apiKeyEnv`(不是 `apiKey`);且该 profile
|
||||
// **不接受** `provider` / `maxRetries` / `maxRetryDelayMs`(会直接抛错)。
|
||||
// 以上全部为 2026-09-13 读官方包 `[email protected]` 的 `lib/index.js`
|
||||
// (`const NS = "llm-pi-ai"` / `profile` schema / `PROTOCOLS`)实测结论。
|
||||
// 另:`users.shared_model_enabled` = 用户**要不要用平台共享模型**(admin 配的那把)——
|
||||
// 属于用户侧偏好,开关它**不动** admin 的配置(用户口径:条目各自开关,都能同时启用;
|
||||
// 具体用哪个模型是在 dsh 对话框的模型选择器里挑)。
|
||||
const SQLITE_V6 = `
|
||||
ALTER TABLE credential_vault ADD COLUMN route TEXT;
|
||||
ALTER TABLE credential_vault ADD COLUMN base_url TEXT;
|
||||
ALTER TABLE credential_vault ADD COLUMN api TEXT;
|
||||
ALTER TABLE credential_vault ADD COLUMN models TEXT;
|
||||
ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1;
|
||||
`
|
||||
|
||||
const PG_V6 = `
|
||||
ALTER TABLE credential_vault ADD COLUMN route TEXT;
|
||||
ALTER TABLE credential_vault ADD COLUMN base_url TEXT;
|
||||
ALTER TABLE credential_vault ADD COLUMN api TEXT;
|
||||
ALTER TABLE credential_vault ADD COLUMN models TEXT;
|
||||
ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1;
|
||||
`
|
||||
|
||||
interface Migration {
|
||||
version: number
|
||||
name: string
|
||||
@@ -271,6 +305,7 @@ const MIGRATIONS: readonly Migration[] = [
|
||||
{ version: 3, name: 'per-user uid', sqlite: SQLITE_V3, pg: PG_V3 },
|
||||
{ version: 4, name: 'instance desired state', sqlite: SQLITE_V4, pg: PG_V4 },
|
||||
{ version: 5, name: 'business plugin candidate pool', sqlite: SQLITE_V5, pg: PG_V5 },
|
||||
{ version: 6, name: 'user model providers', sqlite: SQLITE_V6, pg: PG_V6 },
|
||||
]
|
||||
|
||||
/** Apply unapplied SQLite migrations inside a single transaction. */
|
||||
|
||||
+34
-2
@@ -39,9 +39,12 @@ import {
|
||||
getEnabledCredentialKeyRef as getEnabledCredentialKeyRefSync,
|
||||
getEnabledPluginIds as getEnabledPluginIdsSync,
|
||||
getOrCreateWorkspace as getOrCreateWorkspaceSync,
|
||||
getSharedModelEnabled as getSharedModelEnabledSync,
|
||||
listBusinessPlugins as listBusinessPluginsSync,
|
||||
listCredentialKeys as listCredentialKeysSync,
|
||||
listCredentialLandingRows as listCredentialLandingRowsSync,
|
||||
listDomains as listDomainsSync,
|
||||
listEnabledCredentialKeys as listEnabledCredentialKeysSync,
|
||||
listInstancesByRole as listInstancesByRoleSync,
|
||||
listPublicUsers as listPublicUsersSync,
|
||||
listUsersWithoutUid as listUsersWithoutUidSync,
|
||||
@@ -50,8 +53,10 @@ import {
|
||||
setDomainVerified as setDomainVerifiedSync,
|
||||
setFolderPlugins as setFolderPluginsSync,
|
||||
setInstanceStatus as setInstanceStatusSync,
|
||||
setSharedModelEnabled as setSharedModelEnabledSync,
|
||||
setUserRole as setUserRoleSync,
|
||||
setUserUid as setUserUidSync,
|
||||
toggleCredentialKey as toggleCredentialKeySync,
|
||||
upsertBusinessPlugin as upsertBusinessPluginSync,
|
||||
upsertDomain as upsertDomainSync,
|
||||
upsertInstance as upsertInstanceSync,
|
||||
@@ -59,6 +64,8 @@ import {
|
||||
import type {
|
||||
BusinessPlugin,
|
||||
CredentialKey,
|
||||
CredentialKeyMeta,
|
||||
CredentialLandingRow,
|
||||
CreateSessionInput,
|
||||
CreateUserInput,
|
||||
Domain,
|
||||
@@ -229,13 +236,26 @@ export class SqliteAdapter implements DbAdapter {
|
||||
return listCredentialKeysSync(this.db, userId)
|
||||
}
|
||||
|
||||
async listEnabledCredentialKeys(userId: string): Promise<CredentialKey[]> {
|
||||
return listEnabledCredentialKeysSync(this.db, userId)
|
||||
}
|
||||
|
||||
async listCredentialLandingRows(userId: string): Promise<CredentialLandingRow[]> {
|
||||
return listCredentialLandingRowsSync(this.db, userId)
|
||||
}
|
||||
|
||||
async getEnabledCredentialKeyRef(userId: string): Promise<string | null> {
|
||||
return getEnabledCredentialKeyRefSync(this.db, userId)
|
||||
}
|
||||
|
||||
async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> {
|
||||
async setCredentialKey(
|
||||
userId: string,
|
||||
name: string,
|
||||
encryptedRef: string,
|
||||
meta?: CredentialKeyMeta,
|
||||
): Promise<CredentialKey> {
|
||||
try {
|
||||
return setCredentialKeySync(this.db, userId, name, encryptedRef)
|
||||
return setCredentialKeySync(this.db, userId, name, encryptedRef, meta)
|
||||
} catch (e) {
|
||||
mapSqliteError(e)
|
||||
}
|
||||
@@ -245,6 +265,18 @@ export class SqliteAdapter implements DbAdapter {
|
||||
return selectCredentialKeySync(this.db, userId, id)
|
||||
}
|
||||
|
||||
async toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise<boolean> {
|
||||
return toggleCredentialKeySync(this.db, userId, id, enabled)
|
||||
}
|
||||
|
||||
async getSharedModelEnabled(userId: string): Promise<boolean> {
|
||||
return getSharedModelEnabledSync(this.db, userId)
|
||||
}
|
||||
|
||||
async setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean> {
|
||||
return setSharedModelEnabledSync(this.db, userId, enabled)
|
||||
}
|
||||
|
||||
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
|
||||
return deleteCredentialKeySync(this.db, userId, id)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,58 @@ export interface CredentialKey {
|
||||
name: string
|
||||
enabled: boolean
|
||||
updatedAt: number
|
||||
/**
|
||||
* settings.yaml 里 `llm-pi-ai.providers` 的 **dict 键**(档案 87)。内置 DeepSeek 条目
|
||||
* 可用 `deepseek`;自定义厂家由用户给(平台按名字生成 slug 作为默认值)。
|
||||
* ⚠️ 不是「厂家名」,而是**寻址键** —— 改名不影响它,所以平台把它显式存下来。
|
||||
*/
|
||||
route?: string | null
|
||||
/**
|
||||
* 自定义厂家的 endpoint(档案 87)。`null` = **内置 DeepSeek**(此时不写 settings.yaml,
|
||||
* 只把 key 落到 `refs.DEEPSEEK_API_KEY`);非空 = 用户声明的 OpenAI 兼容网关,
|
||||
* 平台会写 `settings.yaml` 的 `llm-pi-ai.providers.<route>`(`baseURL` + `api` + `models`)。
|
||||
*/
|
||||
baseUrl?: string | null
|
||||
/**
|
||||
* 该 route 的**线协议**(档案 87)—— settings.yaml 里字段名是 **`api`**。
|
||||
* 合法值只有 `openai-completions` / `openai-responses` / `anthropic-messages`
|
||||
* (官方 `dsh-llm-pi-ai` 的 `PROTOCOLS` 键;空 = 取默认 `openai-completions`)。
|
||||
*/
|
||||
api?: string | null
|
||||
/** 该厂家下的模型 id 清单(JSON 数组字符串;自定义厂家必填,内置厂家可为空)。 */
|
||||
models?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入一条凭据时可带的**模型厂家元数据**(档案 87)。
|
||||
* 全部可空:只给 `name` + `encryptedRef` 时就是老语义的「内置 DeepSeek 那一把 key」。
|
||||
*/
|
||||
export interface CredentialKeyMeta {
|
||||
/** settings.yaml 里 `llm-pi-ai.providers` 的 dict 键;空 = 内置 DeepSeek。 */
|
||||
route?: string | null
|
||||
/** 自定义厂家的 endpoint;空 = 内置(只写 `.credentials.yaml`,不写 settings.yaml)。 */
|
||||
baseUrl?: string | null
|
||||
/** 线协议(settings.yaml 的 `api`);空 = 官方默认 `openai-completions`。 */
|
||||
api?: string | null
|
||||
/** 模型 id 的 JSON 数组字符串。 */
|
||||
models?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* **落地层专用**:一条已启用条目 + 它的 encrypted ref(档案 87)。
|
||||
*
|
||||
* 为什么单独一个类型:{@link CredentialKey} 会被 `/api/me/keys` **原样返回给浏览器**,
|
||||
* 所以它刻意不含密文;而 `server.ts` 要把 key 写进实例的 `.credentials.yaml`,
|
||||
* 必须要密文(用部署密钥解出来)。两件事的受众不同 ⇒ 两个类型,别合并。
|
||||
*/
|
||||
export interface CredentialLandingRow {
|
||||
name: string
|
||||
route: string | null
|
||||
baseUrl: string | null
|
||||
api: string | null
|
||||
models: string | null
|
||||
/** 部署密钥加密后的 ref(`decrypt()` 之后才是明文 key)。 */
|
||||
encryptedRef: string
|
||||
}
|
||||
|
||||
/** A business-plugin (系统外插件) candidate-pool row. `id` = bundle package name. */
|
||||
|
||||
Reference in new issue
Block a user