mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
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:
File diff suppressed because it is too large
Load Diff
71
app/api/admin/providers/route.ts
Normal file
71
app/api/admin/providers/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import {
|
||||
AdminProvidersSchema,
|
||||
deriveEnvUpdates,
|
||||
loadAdminProviders,
|
||||
maskAdminProviders,
|
||||
mergeSecrets,
|
||||
validateAdminProviders,
|
||||
} from "@/lib/admin/providers"
|
||||
import { isSettingsWritable, saveSettings } from "@/lib/admin/settings"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
writable: isSettingsWritable(),
|
||||
providers: maskAdminProviders(loadAdminProviders()),
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
return Response.json(payload())
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
if (!isSettingsWritable()) {
|
||||
return Response.json(
|
||||
{
|
||||
error: "Settings file is not writable on this deployment. Configure via environment variables instead.",
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = AdminProvidersSchema.safeParse(
|
||||
(body as { providers?: unknown })?.providers,
|
||||
)
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `Invalid providers: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const stored = loadAdminProviders()
|
||||
const merged = mergeSecrets(parsed.data, stored)
|
||||
|
||||
const validationError = validateAdminProviders(merged)
|
||||
if (validationError) {
|
||||
return Response.json({ error: validationError }, { status: 400 })
|
||||
}
|
||||
|
||||
saveSettings(deriveEnvUpdates(merged, stored))
|
||||
|
||||
return Response.json(payload())
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
46
app/api/admin/test-model/route.ts
Normal file
46
app/api/admin/test-model/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { POST as validateModel } from "@/app/api/validate-model/route"
|
||||
import { checkAdminAuth } from "@/lib/admin/auth"
|
||||
import { loadAdminProviders } from "@/lib/admin/providers"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
// Test a stored admin provider's model. The client only has masked
|
||||
// secrets, so credentials are filled in server-side from settings.json
|
||||
// and passed to the existing validate-model handler.
|
||||
export async function POST(req: Request) {
|
||||
const authError = checkAdminAuth(req)
|
||||
if (authError) return authError
|
||||
|
||||
let body: { providerId?: string; modelId?: string }
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON body" }, { status: 400 })
|
||||
}
|
||||
|
||||
const stored = loadAdminProviders().find((p) => p.id === body.providerId)
|
||||
if (!stored || !body.modelId) {
|
||||
return Response.json(
|
||||
{ valid: false, error: "Unknown provider or model" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
return validateModel(
|
||||
new Request(new URL("/api/validate-model", req.url), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: stored.provider,
|
||||
apiKey: stored.apiKey,
|
||||
baseUrl: stored.baseUrl,
|
||||
modelId: body.modelId,
|
||||
awsAccessKeyId: stored.awsAccessKeyId,
|
||||
awsSecretAccessKey: stored.awsSecretAccessKey,
|
||||
awsRegion: stored.awsRegion,
|
||||
vertexApiKey: stored.vertexApiKey,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user