feat(models): 「模型设置」接官方厂家目录 + 按官方页结构重做界面(档案 87 补做)

用户实测反馈:新增条目看不懂、厂家选择太少且国内一家都没有。根因是**第一版只做了手填表单**,没接官方 pi-ai 自带目录(实测 38 家,国内 11 家:蚂蚁 / 通义千问 / 小米 / 月之暗面 / 智谱 / MiniMax)。
- 新文件 src/web/model-catalog.ts:只读官方 data/*.json(10 分钟缓存,不导入 pi-ai 运行时);中文名走显示用静态表,选取范围以目录为准
- 新增 GET /api/me/model-providers(38 家 + cn 分组 + modelCount + 模型名;排除 deepseek —— 平台已有内置入口)
- POST /api/me/keys 新增 provider 参数:**目录厂家只需 API 密钥**(端点/协议/模型全由官方目录兜底 —— 官方 config.d.ts 明示 route 命中即继承默认)
- refForEntry 判据由「baseUrl 为空」改为「**没有 route**」:否则目录厂家的 key 会被写进 DEEPSEEK_API_KEY
- SettingsEntry 的 baseURL/api/models 改为可选:目录厂家只写 apiKeyEnv(写得越少越不容易漂)
- 前端按官方页结构重写:提供方行(厂家/说明/状态/操作)+ 新增**先选厂家**(中国大陆 / 国际分组 / 自定义)+ 保留并强化 admin 的「平台共享模型」区块
- 真环境验收:目录端点 38 家 / 国内 11 家;加 moonshotai-cn 只给 key → settings.yaml 仅 apiKeyEnv;删后整块消失
This commit is contained in:
admin committed 2026-09-14 00:15:17 +08:00
1 parent 918f1d3a51
commit 022b1f770c
7 files changed
+489 -121

No files matched your search

+54 -5
View File
@@ -10,6 +10,7 @@ import { requireAuth } from '../middleware/authn.js'
import { homeRoot, userRoot } from '../../fs/workspace.js'
import { deriveKey, encrypt } from '../../crypto.js'
import { toPublicUser } from '../../db/types.js'
import { isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js'
import { PROTOCOLS } from '../model-landing.js'
import {
clearSessionCookie,
@@ -129,6 +130,8 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
properties: {
name: { type: 'string', minLength: 1, maxLength: 32 },
apiKey: { type: 'string', minLength: 1, maxLength: 256 },
// 档案 87 补做:**目录厂家 id**(选了它只需 apiKey;endpoint/协议/模型由官方目录兜底)。
provider: { type: 'string', minLength: 1, maxLength: 40 },
// 档案 87:自定义厂家三件套 —— **都不给**就是老语义的「内置 DeepSeek 那一把 key」。
route: { type: 'string', minLength: 1, maxLength: 40 },
baseUrl: { type: 'string', maxLength: 300 },
@@ -216,10 +219,38 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
protocols: [...PROTOCOLS],
}))
/**
* 官方 **pi-ai 厂家目录**(档案 87 补做)——「新增模型条目」的选择框数据源。
*
* 为什么由后端给:目录是**安装期冻结**在官方包里的(`@earendil-works/pi-ai/dist/providers/data/`),
* 前端拿不到也不该硬编码;后端读一次缓存 10 分钟。选中的厂家**只要填 API Key** ——
* endpoint / 协议 / 模型目录全由目录提供(见 `model-catalog.ts` 头注释)。
* ⚠️ 目录里的 `deepseek` 被**排除**:平台已有「内置 DeepSeek」入口(走 `dsh-llm-deepseek` +
* 平台共享 key),再列一个同名选项只会让用户分不清哪个生效。
*/
app.get('/api/me/model-providers', { preHandler: requireAuth }, async () => {
const all = await listCatalogProviders()
return {
providers: all
.filter((p) => p.id !== 'deepseek')
.map((p) => ({
id: p.id,
label: p.label,
api: p.api,
baseURL: p.baseURL,
cn: isCnProvider(p.id),
modelCount: p.models.length,
// 只回前 60 个模型名给界面展示(足量的"看到它自带什么"),不整份下发。
models: p.models.slice(0, 60).map((m) => m.name ?? m.id),
})),
}
})
app.post('/api/me/keys', { preHandler: requireAuth, schema: keyAddSchema }, async (request, reply) => {
const body = request.body as {
name: string
apiKey: string
provider?: string
route?: string
baseUrl?: string
api?: string
@@ -235,6 +266,28 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(body.apiKey)) {
return reply.code(400).send({ error: 'invalid_api_key' })
}
const existingKeys = await app.db.listCredentialKeys(request.user!.id)
/** route 是 settings.yaml 里的 dict 键 ⇒ 同一用户下撞键 = 后写的覆盖前者、静默失效。 */
const routeTaken = (r: string): boolean => existingKeys.some((k) => k.route === r && k.name !== cleanName)
// ── 情形 A:**目录厂家**(用户只填了 API Key)────────────────────────────────
const providerId = (body.provider ?? '').trim()
if (providerId !== '') {
if (!(await isCatalogProvider(providerId))) return reply.code(400).send({ error: 'unknown_provider' })
if (routeTaken(providerId)) return reply.code(409).send({ error: 'route_taken' })
const key = await app.db.setCredentialKey(
request.user!.id,
cleanName,
encrypt(body.apiKey, deriveKey(app.config.encryptionSecret)),
// 只记 route:endpoint / 协议 / 模型清单**都不写**,交给官方目录兜底。
{ route: providerId, baseUrl: null, api: null, models: null },
)
await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName, provider: providerId }))
await refreshAfterKeyChange(request.user!.id, request.user!.role)
return { key }
}
// ── 情形 B:内置 DeepSeek(不带 baseUrl)/自定义网关(带 baseUrl)────────────
const baseUrl = (body.baseUrl ?? '').trim()
const isCustom = baseUrl !== ''
let route: string | null = null
@@ -251,11 +304,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const ids = (body.models ?? []).map((m) => m.trim()).filter((m) => m !== '')
if (ids.length === 0) return reply.code(400).send({ error: 'models_required' })
models = JSON.stringify(ids.slice(0, 50))
// route 是 settings.yaml 里的 dict 键 ⇒ 同一个用户下撞键 = 后写入的会覆盖前者,静默失效。
const existing = await app.db.listCredentialKeys(request.user!.id)
if (existing.some((k) => k.route === route && k.name !== cleanName)) {
return reply.code(409).send({ error: 'route_taken' })
}
if (routeTaken(route)) return reply.code(409).send({ error: 'route_taken' })
}
const key = await app.db.setCredentialKey(
request.user!.id,