Files
dsh_ai1net_server/src/web/model-landing.ts
T
admin 918f1d3a51 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)。
2026-09-14 00:00:15 +08:00

366 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 模型条目的**落地层**(档案 87):把平台凭据库里「已启用」的条目写进实例的两个配置文件。
*
* 为什么必须有这一层:官方「设置 → 模型」页在平台环境**必然报错** —— 该页要求 Host settings
* 镜像,而 `dsh-client-ui-settings` 的持久化判定是
* `isLoopback = transport.ownsHost || pageLocation === undefined || isLoopbackHostname(page)`,
* 平台是「浏览器经域名访问远程服务器」⇒ 三条皆不成立 ⇒ persistence 降级为 `memory`
* ⇒ `ensure()` 直接返回不读 ⇒ 页面必报「加载提供方目录失败」。详见
* `ensure-role-profile-patch.cjs` 的 `DISABLE_MODELS_BLOCK` / `ADMIN_MODELS_BLOCK` 注释。
* ⇒ 用户自配模型只能由**平台自己写文件**。
*
* 落点两处(字段名均为 2026-09-13 读官方包 `[email protected]` 实测,勿凭记忆改):
*
* 1. `$DSH_HOME/.credentials.yaml` → `refs.<REF>: '<key>'`
* (REF 须匹配 `@deepseek-ai/dsh-credentials` 的 `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`)
* 2. `$DSH_HOME/settings.yaml` → 顶层 `llm-pi-ai:` → `providers.<route>` →
* `apiKeyEnv` / `baseURL` / `api` / `models`
* ⚠️ 是 **`api`**(不是 `protocol`)、是 **`apiKeyEnv`**(不是 `apiKey`);
* 该 profile **不接受** `provider` / `maxRetries` / `maxRetryDelayMs`(会直接抛错);
* `api` 的合法值只有 `openai-completions` / `openai-responses` / `anthropic-messages`。
*
* 本模块**只做纯文本变换**(无 IO、无 db、无 crypto)⇒ 可以被
* `scripts/verify-model-landing.mjs` 用固定样例逐条断言。这类"改写别人家配置文件"的逻辑
* 最怕没有回归网:它错了不会报错,只会让实例静默少一个厂家。
*
* 安全约束(与既有 `ensureRefInCredentials` 同族,每条都是踩出来的):
* · 只认 `version: 1` 的凭据文档;认不出的布局**宁可不动**;
* · 平台**只管自己写过的**(`managed` 清单):用户自己放的 ref / 厂家段**绝不覆盖、绝不删**;
* · 要"删掉"时只删平台自己的:凭据=我们自己写的那一行,settings=我们自己那对标记之间;
* · 内联样式(`baseURL: ...`)而不是 JSON 块,避免把用户的其它字段卷进重排。
*
* @module dshs/web/model-landing
*/
/** 官方凭据 ref 名的语法(`dsh-credentials` 的 `REF_PATTERN`)。 */
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
/** 内置 DeepSeek 条目的 ref(官方与平台既有实现共同约定的名字)。 */
export const BUILTIN_REF = 'DEEPSEEK_API_KEY'
/** `settings.yaml` 里那个用户设置分区的名字(官方 `const NS = "llm-pi-ai"`)。 */
export const PI_AI_NS = 'llm-pi-ai'
/** 官方支持的线协议(`dsh-llm-pi-ai` 的 `PROTOCOLS` 键,顺序即默认优先级)。 */
export const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages'] as const
export type Protocol = (typeof PROTOCOLS)[number]
/** 一条「已启用」条目落地所需的全部信息(`value` 已是解密后的明文 key)。 */
export interface LandingEntry {
/** 展示名(只用于注释与日志)。 */
name: string
/** settings.yaml 的 `providers` dict 键;内置条目可为空。 */
route: string | null
/** 自定义厂家 endpoint;空 = 内置 DeepSeek。 */
baseUrl: string | null
/** 线协议;空 = `openai-completions`。 */
api: string | null
/** 模型 id 清单。 */
models: string[]
/** 解密后的 key 明文。 */
value: string
}
/** `reconcile*` 的返回:新文本 + 这一轮之后仍归平台管的键。 */
export interface ReconcileResult {
text: string
managed: string[]
}
/**
* 自定义厂家的 ref 名:`<ROUTE 大写、非字母数字折成 _>_API_KEY`。
* 结果**一定**匹配 `REF_PATTERN`(首字符强制成字母)—— 否则 dsh 解析凭据时会直接不认,
* 而且是静默的"这条 ref 不存在",排查成本极高。
*/
export function routeRef(route: string): string {
let up = (route ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '_').replace(/^_+|_+$/g, '')
if (up === '' || !/^[A-Z]/.test(up)) up = 'X' + up
return `${up}_API_KEY`
}
/** 该条目用哪个 ref:内置 ⇒ `DEEPSEEK_API_KEY`;自定义 ⇒ `routeRef(route)`。 */
export function refForEntry(entry: Pick<LandingEntry, 'route' | 'baseUrl'>): string {
return entry.baseUrl === null || entry.baseUrl === '' ? BUILTIN_REF : routeRef(entry.route ?? 'custom')
}
/** 协议取值规范化:认不出的一律回落官方默认(第一个),不抛错、不写坏配置。 */
export function normalizeProtocol(api: string | null | undefined): Protocol {
return (PROTOCOLS as readonly string[]).includes(api ?? '') ? (api as Protocol) : PROTOCOLS[0]
}
/** YAML 单引号标量转义(`'` → `''`)—— 避免 key 里的引号把文档弄坏。 */
function yamlSingle(value: string): string {
return `'${value.replace(/'/g, "''")}'`
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* 把「已启用条目」对账进 `.credentials.yaml`。
*
* @param text - 现有文件内容(空串 = 文件不存在)。
* @param desired - 期望存在的 `{ ref, value }`。
* @param managed - **平台自己写过**的 ref 清单(上一次的返回值)。只有这里的 ref 允许被改写/删除。
* @returns 新文本 + 新的 managed 清单。认不出的布局会原样返回(宁可不动)。
*/
export function reconcileCredentials(
text: string,
desired: readonly { ref: string; value: string }[],
managed: readonly string[] = [],
): ReconcileResult {
const owned = new Set(managed)
const wanted = new Map(desired.map((d) => [d.ref, d.value]))
// 文件不存在 / 空 ⇒ 从零建一个最小合法文档(与既有 ensureRefInCredentials 同款)。
if (text.trim() === '') {
if (desired.length === 0) return { text, managed: [] }
const body = desired.map((d) => ` ${d.ref}: ${yamlSingle(d.value)}`).join('\n')
return { text: `version: 1\nrefs:\n${body}\n`, managed: desired.map((d) => d.ref) }
}
const lines = text.split('\n')
const insideRefs: boolean[] = []
let refsAt = -1
let versionAt = -1
let inside = false
for (let i = 0; i < lines.length; i++) {
const raw = lines[i]
const indented = /^[ \t]/.test(raw)
inside = indented ? inside : raw.trim() === 'refs:'
if (!indented && raw.trim() === 'refs:' && refsAt < 0) refsAt = i
if (!indented && versionAt < 0 && /^version:[ \t]*1[ \t]*$/.test(raw.trim())) versionAt = i
insideRefs.push(inside && indented)
}
const refAt = new Map<string, number>()
for (let i = 0; i < lines.length; i++) {
if (!insideRefs[i]) continue
const m = /^[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*:/.exec(lines[i])
if (m !== null) refAt.set(m[1], i)
}
const drop = new Set<number>()
const kept: string[] = []
const setLine: Array<{ at: number; ref: string; value: string }> = []
const add: Array<{ ref: string; value: string }> = []
for (const ref of owned) {
if (wanted.has(ref)) {
kept.push(ref)
continue
}
const at = refAt.get(ref)
if (at !== undefined) drop.add(at) // 用户关掉了 ⇒ 把平台自己写的那行撤掉
}
for (const [ref, value] of wanted) {
const at = refAt.get(ref)
if (at === undefined) {
add.push({ ref, value })
kept.push(ref)
continue
}
if (!owned.has(ref)) continue // 文件里有、但不是平台写的 ⇒ 用户自己的,绝不碰
setLine.push({ at, ref, value })
kept.push(ref)
}
const inserted = new Map<number, string[]>()
if (add.length > 0) {
const anchor = refsAt >= 0 ? refsAt : versionAt
if (anchor < 0) return { text, managed: [...owned] } // 认不出布局 ⇒ 宁可不动
const body = add.map((d) => ` ${d.ref}: ${yamlSingle(d.value)}`)
inserted.set(anchor, refsAt >= 0 ? body : ['refs:', ...body])
}
const out: string[] = []
for (let i = 0; i < lines.length; i++) {
if (!drop.has(i)) {
const s = setLine.find((d) => d.at === i)
out.push(s === undefined ? lines[i] : ` ${s.ref}: ${yamlSingle(s.value)}`)
}
const extra = inserted.get(i)
if (extra !== undefined) out.push(...extra)
}
return { text: out.join('\n'), managed: [...new Set(kept)] }
}
/** 一条要写进 `settings.yaml` 的厂家声明。 */
export interface SettingsEntry {
route: string
apiKeyEnv: string
baseURL: string
api: Protocol
models: string[]
}
const markBegin = (route: string): string => ` # dshs:model-route ${route} begin`
const markEnd = (route: string): string => ` # dshs:model-route ${route} end`
/**
* 把厂家声明对账进 `settings.yaml`。
*
* 用**成对标记**夹住平台自己写的那个 route 段:① 不解析别人的 YAML(不重排、不丢注释);
* ② "关掉某个厂家"就是删掉自己那对标记之间的内容 ⇒ 精确可控。
* 代价:若 dsh 或用户某次把文件整体重写、标记丢了,就再也删不掉那个 route
* (但"已存在"检查仍会拦住重复写入,所以最坏是留一条模型清单里的僵尸厂家,不会写坏配置)。
*
* @param text - 现有文件内容(空串 = 文件不存在)。
* @param desired - 期望存在的厂家声明。
* @param managed - 平台自己写过的 route 清单。
*/
export function reconcileSettings(
text: string,
desired: readonly SettingsEntry[],
managed: readonly string[] = [],
): ReconcileResult {
const owned = new Set(managed)
const wanted = new Map(desired.map((d) => [d.route, d]))
// ① 先删:不再需要的、且是我们自己写的块。
const lines = text === '' ? [] : text.split('\n')
const kept: string[] = []
const present = new Set<string>()
for (let i = 0; i < lines.length; i++) {
const b = /^[ \t]*# dshs:model-route ([^\s]+) begin[ \t]*$/.exec(lines[i])
if (b === null) {
// 非标记行里如果已经有 ` <route>:`(可能是用户/官方自己写的)⇒ 记为"已存在",不重复写。
const k = /^[ \t]{4}([^\s:#][^\s:]*)[ \t]*:[ \t]*$/.exec(lines[i])
if (k !== null) present.add(k[1])
kept.push(lines[i])
continue
}
const route = b[1]
const endRe = new RegExp(`^[ \\t]*# dshs:model-route ${escapeRe(route)} end[ \\t]*$`)
let end = -1
for (let j = i + 1; j < lines.length; j++) {
if (endRe.test(lines[j])) {
end = j
break
}
}
if (wanted.has(route) || !owned.has(route)) {
// 还要留着(或不是我们的,不该动)⇒ 原样搬过去。
present.add(route)
if (end < 0) kept.push(lines[i])
else {
kept.push(...lines.slice(i, end + 1))
i = end
}
continue
}
// 用户已关掉这个厂家 ⇒ 整块丢掉(这就是"删")。
i = end < 0 ? i : end
}
const add = desired.filter((d) => !present.has(d.route))
const newOwned = [...new Set([...owned].filter((r) => wanted.has(r) || present.has(r)))]
if (add.length === 0) return { text: kept.join('\n'), managed: newOwned }
// ② 找 `llm-pi-ai:` → `providers:` 链,缺什么补什么,然后在 `providers:` 之后插入。
// ⚠️ 这里**不能**用"只看顶层行"的循环:`providers:` 本身就是缩进 2 的(它是
// `llm-pi-ai:` 的子键)。第一版就是这么写的 ⇒ 找不到既有 providers ⇒ 又补一行
// ` providers:` ⇒ 文档里出现**两个**同键(回归 [8]/[9] 当场抓到)。
let nsAt = -1
for (let i = 0; i < kept.length; i++) {
if (/^[ \t]/.test(kept[i])) continue
if (kept[i].trim() === `${PI_AI_NS}:`) {
nsAt = i
break
}
}
let provAt = -1
if (nsAt >= 0) {
for (let i = nsAt + 1; i < kept.length; i++) {
const raw = kept[i]
if (!/^[ \t]/.test(raw)) {
if (raw.trim() !== '') break // 撞到下一个顶层键 ⇒ 该分区到此为止
continue // 分区里的空行不算结束
}
if (raw.trim() === 'providers:') {
provAt = i
break
}
}
}
const blocks = add.flatMap((d) => [
markBegin(d.route),
` ${d.route}:`,
` apiKeyEnv: ${d.apiKeyEnv}`,
` baseURL: ${d.baseURL}`,
` api: ${d.api}`,
' models:',
...d.models.map((m) => ` - id: ${m}`),
markEnd(d.route),
])
const out = [...kept]
if (provAt >= 0) {
out.splice(provAt + 1, 0, ...blocks)
} else if (nsAt >= 0) {
out.splice(nsAt + 1, 0, ' providers:', ...blocks)
} else {
// 整个 `llm-pi-ai:` 分区都不在 ⇒ 追加到文件末尾(顶层键,顺序无关)。
if (out.length > 0 && out[out.length - 1] === '') out.splice(out.length - 1, 0, `${PI_AI_NS}:`, ' providers:', ...blocks, '')
else out.push(`${PI_AI_NS}:`, ' providers:', ...blocks)
}
return { text: out.join('\n'), managed: [...new Set([...newOwned, ...add.map((d) => d.route)])] }
}
/**
* 读出 `.credentials.yaml` 里某个 ref 的**当前值**(没有该 ref 时 `null`)。
*
* 用途只有一个:**一次性交接**(档案 87)。老实现把平台共享 key 直接写进
* `refs.DEEPSEEK_API_KEY`,但那时没有托管清单 ⇒ 新逻辑会把它当成"用户自己写的"而永不清理
* ⇒ 用户关掉共享开关后那个 key 仍然留在文件里("关掉即生效"就不成立)。
* 所以首次运行时要**认领**该 ref,但只在那行确实等于平台共享 key 的明文时才认领
* —— 否则就是用户自己配的,绝不碰。
* @param text - `.credentials.yaml` 内容。
* @param ref - 要读的 ref 名。
* @returns 去掉引号后的值,或 `null`。
*/
export function readRefValue(text: string, ref: string): string | null {
if (text === '') return null
const lines = text.split('\n')
let inside = false
for (const raw of lines) {
const indented = /^[ \t]/.test(raw)
inside = indented ? inside : raw.trim() === 'refs:'
if (!inside || !indented) continue
const m = new RegExp(`^[ \\t]+${ref}[ \\t]*:[ \\t]*(.*)$`).exec(raw)
if (m === null) continue
const v = m[1].trim()
if (v.length >= 2 && ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"')))) {
return v.slice(1, -1).replace(/''/g, "'")
}
return v
}
return null
}
/**
* 把 `models` 列(JSON 数组字符串)解析成 id 清单。
* 宽容:非数组、非字符串项、超量一律丢弃 —— 这条路上宁可少写一个模型,
* 也不能让一个坏值把整个 `settings.yaml` 变成 dsh 拒绝加载的文档。
*/
export function parseModels(json: string | null | undefined, limit = 50): string[] {
if (json === null || json === undefined) return []
try {
const raw: unknown = JSON.parse(json)
if (!Array.isArray(raw)) return []
const out: string[] = []
for (const item of raw) {
if (typeof item !== 'string') continue
const v = item.trim()
if (v === '' || v.length > 128) continue
if (!out.includes(v)) out.push(v)
if (out.length >= limit) break
}
return out
} catch {
return []
}
}