feat: graphical model management in admin panel

Replace the provider credential fields and raw AI_MODELS_CONFIG JSON
textarea with a Models section mirroring the in-app model settings UI:
provider instance list with logos, credential fields per provider type,
model add/remove with suggestions, per-model connectivity test, and a
default-provider star.

On save the server derives everything the runtime needs into
settings.json: credential env vars (with _2 suffixes for multiple
instances of one provider), AI_MODELS_CONFIG, and AI_PROVIDER/AI_MODEL
for the default. Secrets round-trip as masked markers and are never
sent back to the browser. The general settings registry now only
covers non-provider settings (generation, access, features,
observability, quota).
This commit is contained in:
dayuan.jiang
2026-06-10 23:15:18 +09:00
parent 9feab38a0b
commit e4a14c3628
12 changed files with 1392 additions and 701 deletions

View File

@@ -1,4 +1,4 @@
import { timingSafeEqual } from "crypto"
import { checkAdminAuth, maskSecret } from "@/lib/admin/auth"
import {
getEnvFallback,
getValueSource,
@@ -11,46 +11,10 @@ import {
SETTINGS_REGISTRY,
type SettingDef,
} from "@/lib/admin/settings-registry"
import { ServerModelsConfigSchema } from "@/lib/server-model-config"
// Zod schemas for json-type settings (kept here, server-side only — the
// registry is imported by the client and must stay free of server deps)
const JSON_VALIDATORS: Record<string, typeof ServerModelsConfigSchema> = {
AI_MODELS_CONFIG: ServerModelsConfigSchema,
}
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
function checkAuth(req: Request): Response | null {
const password = process.env.ADMIN_PASSWORD
if (!password) {
return Response.json(
{
error: "Admin panel is disabled. Set the ADMIN_PASSWORD environment variable to enable it.",
},
{ status: 403 },
)
}
const provided = req.headers.get("x-admin-password") || ""
const a = Buffer.from(provided)
const b = Buffer.from(password)
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return Response.json(
{ error: "Invalid admin password" },
{ status: 401 },
)
}
return null
}
function maskSecret(value: string): { isSet: true; hint: string } {
return {
isSet: true,
hint: value.length > 8 ? `${value.slice(-4)}` : "••••",
}
}
function serializeSettings() {
const fileValues = loadSettings()
return SETTINGS_REGISTRY.map((def) => {
@@ -65,7 +29,7 @@ function serializeSettings() {
}
export async function GET(req: Request) {
const authError = checkAuth(req)
const authError = checkAdminAuth(req)
if (authError) return authError
return Response.json({
@@ -93,29 +57,13 @@ function validateValue(def: SettingDef, value: string): string | null {
return def.options?.includes(value)
? null
: `Must be one of: ${def.options?.join(", ")}`
case "json": {
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
return "Invalid JSON"
}
const schema = JSON_VALIDATORS[def.key]
if (schema) {
const result = schema.safeParse(parsed)
if (!result.success) {
return `Invalid value: ${result.error.issues[0]?.message ?? "schema mismatch"}`
}
}
return null
}
default:
return null
}
}
export async function PUT(req: Request) {
const authError = checkAuth(req)
const authError = checkAdminAuth(req)
if (authError) return authError
if (!isSettingsWritable()) {