From e4a14c3628a36314e9cb3ea5abac3c44803827c1 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Wed, 10 Jun 2026 23:15:18 +0900 Subject: [PATCH] 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). --- README.md | 5 +- app/[lang]/admin/page.tsx | 987 ++++++++++++++++++++++------- app/api/admin/providers/route.ts | 71 +++ app/api/admin/settings/route.ts | 58 +- app/api/admin/test-model/route.ts | 46 ++ docs/cn/README_CN.md | 5 +- docs/ja/README_JA.md | 5 +- lib/admin/auth.ts | 37 ++ lib/admin/providers.ts | 276 ++++++++ lib/admin/settings-registry.ts | 419 +----------- lib/ai-providers.ts | 2 +- tests/unit/admin-providers.test.ts | 182 ++++++ 12 files changed, 1392 insertions(+), 701 deletions(-) create mode 100644 app/api/admin/providers/route.ts create mode 100644 app/api/admin/test-model/route.ts create mode 100644 lib/admin/auth.ts create mode 100644 lib/admin/providers.ts create mode 100644 tests/unit/admin-providers.test.ts diff --git a/README.md b/README.md index 7dd607f..fe82103 100644 --- a/README.md +++ b/README.md @@ -228,11 +228,12 @@ Administrators can configure multiple server-side models that are available to a ### Admin Panel -Instead of hand-editing `.env`, you can manage most server settings (provider keys, model behavior, access codes, quota, features) in a web admin panel: +Instead of hand-editing `.env`, you can manage server settings in a web admin panel: 1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel). 2. Visit `/admin` and sign in. -3. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required"). +3. In the Models section, add providers with their API keys and model lists — the same UI as the in-app model settings. Saved models become server-side models available to all users, and credentials plus `AI_MODELS_CONFIG` are generated automatically. +4. Other sections cover access codes, generation parameters, features, observability, and quota. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required"). Precedence: settings saved in the panel override environment variables, which override built-in defaults. Removing a saved value falls back to the environment variable. diff --git a/app/[lang]/admin/page.tsx b/app/[lang]/admin/page.tsx index 38f0cfc..091bd57 100644 --- a/app/[lang]/admin/page.tsx +++ b/app/[lang]/admin/page.tsx @@ -1,24 +1,33 @@ "use client" import { + AlertCircle, AlertTriangle, Check, - ChevronRight, Eye, EyeOff, Loader2, LockKeyhole, - RotateCcw, + Plus, ShieldCheck, + Star, + Trash2, + X, + Zap, } from "lucide-react" import { useCallback, useEffect, useState } from "react" import { ProviderLogo } from "@/components/provider-logo" -import { Button } from "@/components/ui/button" import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { @@ -29,35 +38,29 @@ import { SelectValue, } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" -import { Textarea } from "@/components/ui/textarea" import { - PROVIDER_SUBGROUPS, SETTING_GROUPS, SETTINGS_BY_GROUP, type SettingDef, } from "@/lib/admin/settings-registry" import { getApiEndpoint } from "@/lib/base-path" -import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" +import { + PROVIDER_INFO, + type ProviderName, + SUGGESTED_MODELS, +} from "@/lib/types/model-config" import { cn } from "@/lib/utils" const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password" -async function fetchSettings(pw: string) { - const res = await fetch(getApiEndpoint("/api/admin/settings"), { - headers: { "x-admin-password": pw }, - }) - if (!res.ok) { - const data = await res.json().catch(() => ({})) - throw new Error(data.error || `Request failed (${res.status})`) - } - return res.json() as Promise<{ - writable: boolean - settings: SettingState[] - }> -} +// ── Shared types ───────────────────────────────────────────────────── type SecretValue = { isSet: true; hint: string } +function isSecretValue(v: unknown): v is SecretValue { + return typeof v === "object" && v !== null && "isSet" in v +} + interface SettingState { key: string source: "file" | "env" | "default" @@ -66,15 +69,45 @@ interface SettingState { type SettingsMap = Record -function isSecretValue(v: unknown): v is SecretValue { - return typeof v === "object" && v !== null && "isSet" in v -} - // Editable text of a saved setting; secrets have none (write-only) function savedTextOf(state: SettingState | undefined): string { return state && !isSecretValue(state.value) ? (state.value ?? "") : "" } +// Admin provider in client state. Secret fields hold either a masked +// marker (unchanged) or a plaintext string (new value). +interface AdminProvider { + id: string + provider: ProviderName + name?: string + apiKey?: string | SecretValue + baseUrl?: string + awsAccessKeyId?: string | SecretValue + awsSecretAccessKey?: string | SecretValue + awsRegion?: string + vertexApiKey?: string | SecretValue + models: string[] + isDefault?: boolean +} + +async function adminFetch(path: string, pw: string, init?: RequestInit) { + const res = await fetch(getApiEndpoint(path), { + ...init, + headers: { + ...init?.headers, + "x-admin-password": pw, + ...(init?.body ? { "Content-Type": "application/json" } : {}), + }, + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + throw new Error(data.error || `Request failed (${res.status})`) + } + return data +} + +// ── Small shared UI bits ───────────────────────────────────────────── + function SourceChip({ source }: { source: "file" | "env" | "default" }) { if (source === "default") return null return ( @@ -104,6 +137,56 @@ function RestartBadge() { ) } +// Secret input: shows masked hint as placeholder, typing replaces +function SecretInput({ + id, + value, + disabled, + onChange, +}: { + id: string + value: string | SecretValue | undefined + disabled?: boolean + onChange: (value: string) => void +}) { + const [show, setShow] = useState(false) + const text = typeof value === "string" ? value : "" + const placeholder = isSecretValue(value) + ? `Saved (${value.hint}) — type to replace` + : "Not set" + return ( +
+ onChange(e.target.value)} + /> + +
+ ) +} + +// ── General settings field (registry-driven) ───────────────────────── + function SettingField({ def, state, @@ -119,15 +202,10 @@ function SettingField({ disabled: boolean onChange: (value: string | null) => void }) { - const [showSecret, setShowSecret] = useState(false) const isDirty = pendingValue !== undefined const source = state?.source ?? "default" - const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state) - - const secretIsSet = state ? isSecretValue(state.value) : false - const secretHint = - state && isSecretValue(state.value) ? state.value.hint : "" + const secretState = state && isSecretValue(state.value) ? state.value : null const inputId = `setting-${def.key}` const errorId = `${inputId}-error` @@ -166,55 +244,19 @@ function SettingField({ ) break - case "json": - control = ( -