diff --git a/.gitignore b/.gitignore index 7427ecb..0e8010a 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ ai-models.json # local backups *.bak .gstack/ + +# admin panel settings (contains secrets) +data/ diff --git a/Dockerfile b/Dockerfile index 7d5f640..037b48c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,6 +61,9 @@ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# Writable dir for admin panel settings (data/settings.json) +RUN mkdir -p /app/data && chown nextjs:nodejs /app/data + USER nextjs EXPOSE 3000 diff --git a/README.md b/README.md index 699b2bc..61a1222 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ https://github.com/user-attachments/assets/9d60a3e8-4a1c-4b5e-acbb-26af2d3eabd1 - [Deploy on Vercel](#deploy-on-vercel) - [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers) - [Multi-Provider Support](#multi-provider-support) + - [Server-Side Multi-Model Configuration](#server-side-multi-model-configuration) + - [Admin Panel](#admin-panel) - [How It Works](#how-it-works) - [Support \& Contact](#support--contact) - [FAQ](#faq) @@ -224,6 +226,12 @@ All providers except AWS Bedrock and OpenRouter support custom endpoints. Administrators can configure multiple server-side models that are available to all users without requiring personal API keys. Configure via `AI_MODELS_CONFIG` environment variable (JSON string) or `ai-models.json` file. +### Admin Panel + +Set the `ADMIN_PASSWORD` environment variable and visit `/admin` to manage server settings (models, access codes, features, observability, quota) from a web panel instead of hand-editing `.env`. + +📖 **[Admin Panel Guide](./docs/en/admin-panel.md)** — setup, precedence rules, and notes. + **Model Requirements**: This task requires strong model capabilities for generating long-form text with strict formatting constraints (draw.io XML). Recommended models include Claude Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and DeepSeek V3.2/R1. Note that the `claude` series has been trained on draw.io diagrams with cloud architecture logos like AWS, Azure, GCP. So if you want to create cloud architecture diagrams, this is the best choice. diff --git a/app/[lang]/admin/admin-shared.ts b/app/[lang]/admin/admin-shared.ts new file mode 100644 index 0000000..fc93310 --- /dev/null +++ b/app/[lang]/admin/admin-shared.ts @@ -0,0 +1,65 @@ +import { getApiEndpoint } from "@/lib/base-path" +import type { ProviderName } from "@/lib/types/model-config" + +export const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password" + +// ── Shared types ───────────────────────────────────────────────────── + +export type SecretValue = { isSet: true; hint: string } + +export function isSecretValue(v: unknown): v is SecretValue { + return typeof v === "object" && v !== null && "isSet" in v +} + +export interface SettingState { + key: string + source: "file" | "env" | "default" + value: string | SecretValue | null +} + +export type SettingsMap = Record + +// Editable text of a saved setting; secrets have none (write-only) +export 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). +export 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 +} + +// Provider defined in AI_MODELS_CONFIG / ai-models.json — shown read-only +export interface EnvProvider { + name: string + provider: ProviderName + models: string[] + isDefault: boolean +} + +export 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 +} diff --git a/app/[lang]/admin/models-section.tsx b/app/[lang]/admin/models-section.tsx new file mode 100644 index 0000000..4f293c7 --- /dev/null +++ b/app/[lang]/admin/models-section.tsx @@ -0,0 +1,609 @@ +import { + AlertCircle, + Check, + Loader2, + Plus, + Star, + Trash2, + X, + Zap, +} from "lucide-react" +import { useState } from "react" +import { ProviderCredentialsFields } from "@/components/provider-credentials-fields" +import { ProviderLogo } from "@/components/provider-logo" +import { + 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { useDictionary } from "@/hooks/use-dictionary" +import { formatMessage } from "@/lib/i18n/utils" +import { + FIXED_CRED_PROVIDERS, + PROVIDER_INFO, + type ProviderName, + SUGGESTED_MODELS, +} from "@/lib/types/model-config" +import { cn } from "@/lib/utils" +import { + type AdminProvider, + adminFetch, + type EnvProvider, +} from "./admin-shared" +import { SecretInput } from "./setting-field" + +// ── Models section (mirrors the user ModelConfigDialog) ────────────── + +function ProviderDetail({ + provider, + disabled, + password, + onUpdate, + onDelete, +}: { + provider: AdminProvider + disabled: boolean + password: string + onUpdate: (patch: Partial) => void + onDelete: () => void +}) { + const dict = useDictionary() + const [modelInput, setModelInput] = useState("") + const [deleteOpen, setDeleteOpen] = useState(false) + const [testing, setTesting] = useState(null) + const [testResults, setTestResults] = useState< + Record + >({}) + + const info = PROVIDER_INFO[provider.provider] + const suggestions = (SUGGESTED_MODELS[provider.provider] || []).filter( + (m) => !provider.models.includes(m), + ) + + const addModel = (modelId: string) => { + const trimmed = modelId.trim() + if (!trimmed || provider.models.includes(trimmed)) return + onUpdate({ models: [...provider.models, trimmed] }) + setModelInput("") + } + + const testModel = async (modelId: string) => { + setTesting(modelId) + try { + const data = await adminFetch("/api/admin/test-model", password, { + method: "POST", + body: JSON.stringify({ provider, modelId }), + }) + setTestResults((prev) => ({ + ...prev, + [modelId]: data.valid + ? { + ok: true, + message: formatMessage(dict.admin.testOk, { + ms: data.responseTime, + }), + } + : { + ok: false, + message: data.error || dict.admin.testFailed, + }, + })) + } catch (err) { + setTestResults((prev) => ({ + ...prev, + [modelId]: { + ok: false, + message: + err instanceof Error + ? err.message + : dict.admin.testFailed, + }, + })) + } finally { + setTesting(null) + } + } + + return ( +
+
+
+ +
+
+

{info.label}

+

+ {provider.models.length === 0 + ? dict.admin.noModelsConfigured + : formatMessage( + provider.models.length === 1 + ? dict.admin.modelCount + : dict.admin.modelCountPlural, + { count: provider.models.length }, + )} +

+
+ + +
+ + {/* Credentials (shared with the user ModelConfigDialog) */} + onUpdate({ [field]: value })} + renderSecret={({ field, id }) => ( + // Bare id keeps the shared component's
+ ) +} + +export function ModelsSection({ + providers, + envProviders, + disabled, + password, + onChange, +}: { + providers: AdminProvider[] + envProviders: EnvProvider[] + disabled: boolean + password: string + onChange: (providers: AdminProvider[]) => void +}) { + const dict = useDictionary() + const [selectedId, setSelectedId] = useState( + providers[0]?.id ?? null, + ) + const selected = providers.find((p) => p.id === selectedId) + const selectedEnv = envProviders.find((p) => `env:${p.name}` === selectedId) + + const addProvider = (provider: ProviderName) => { + const newProvider: AdminProvider = { + id: crypto.randomUUID(), + provider, + models: [], + isDefault: providers.length === 0, + } + onChange([...providers, newProvider]) + setSelectedId(newProvider.id) + } + + const updateProvider = (id: string, patch: Partial) => { + onChange( + providers.map((p) => { + if (p.id !== id) { + // Only one default at a time + return patch.isDefault ? { ...p, isDefault: false } : p + } + return { ...p, ...patch } + }), + ) + } + + const deleteProvider = (id: string) => { + const next = providers.filter((p) => p.id !== id) + onChange(next) + setSelectedId(next[0]?.id ?? null) + } + + return ( +
+ {/* Provider list */} +
+
+ {providers.length === 0 && envProviders.length === 0 && ( +

+ {dict.admin.addProviderHint} +

+ )} + {envProviders.map((p) => ( + + ))} + {providers.map((p) => ( + + ))} +
+
+ +
+
+ + {/* Detail */} +
+ {selected ? ( + updateProvider(selected.id, patch)} + onDelete={() => deleteProvider(selected.id)} + /> + ) : selectedEnv ? ( +
+
+
+ +
+
+

+ {selectedEnv.name} +

+

+ {dict.admin.envReadOnly} +

+
+
+
+
    + {selectedEnv.models.map((modelId, index) => ( +
  • + + {modelId} + {selectedEnv.isDefault && + index === 0 && ( + + { + dict.admin + .defaultModel + } + + )} + +
  • + ))} +
+
+
+ ) : ( +

+ {dict.admin.selectProviderHint} +

+ )} +
+
+ ) +} diff --git a/app/[lang]/admin/page.tsx b/app/[lang]/admin/page.tsx new file mode 100644 index 0000000..cbf5bba --- /dev/null +++ b/app/[lang]/admin/page.tsx @@ -0,0 +1,610 @@ +"use client" + +import { + AlertTriangle, + Check, + Loader2, + LockKeyhole, + ShieldCheck, +} from "lucide-react" +import { useCallback, useEffect, useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { useDictionary } from "@/hooks/use-dictionary" +import { + SETTING_GROUPS, + SETTINGS_BY_GROUP, +} from "@/lib/admin/settings-registry" +import { getApiEndpoint } from "@/lib/base-path" +import { formatMessage } from "@/lib/i18n/utils" +import { cn } from "@/lib/utils" +import { + type AdminProvider, + adminFetch, + type EnvProvider, + isSecretValue, + SESSION_PASSWORD_KEY, + type SettingState, + type SettingsMap, + savedTextOf, +} from "./admin-shared" +import { ModelsSection } from "./models-section" +import { SettingField } from "./setting-field" + +// ── Page ───────────────────────────────────────────────────────────── + +const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)] + +export default function AdminPage() { + const dict = useDictionary() + // Localized group title/description, keyed by group id + const groupText = (id: string) => + ( + dict.admin.groups as Record< + string, + { title: string; description: string } | undefined + > + )[id] + const navItems = NAV_GROUP_IDS.map((id) => ({ + id, + title: + id === "models" ? dict.admin.models : (groupText(id)?.title ?? id), + })) + const [password, setPassword] = useState("") + const [authedPassword, setAuthedPassword] = useState(null) + const [authError, setAuthError] = useState("") + const [authLoading, setAuthLoading] = useState(false) + + const [writable, setWritable] = useState(true) + + // Models section state + const [providers, setProviders] = useState([]) + const [envProviders, setEnvProviders] = useState([]) + const [savedProviders, setSavedProviders] = useState("[]") + const providersDirty = JSON.stringify(providers) !== savedProviders + + // General settings state + const [settings, setSettings] = useState({}) + const [pending, setPending] = useState>({}) + const [errors, setErrors] = useState>({}) + const [enabledGroups, setEnabledGroups] = useState>( + {}, + ) + + const [saving, setSaving] = useState(false) + const [saveMessage, setSaveMessage] = useState<{ + ok: boolean + text: string + } | null>(null) + const [activeGroup, setActiveGroup] = useState("models") + + const dirtyCount = Object.keys(pending).length + (providersDirty ? 1 : 0) + + const applySettingsResponse = useCallback( + (data: { writable: boolean; settings: SettingState[] }) => { + setWritable(data.writable) + const map: SettingsMap = {} + for (const s of data.settings) map[s.key] = s + setSettings(map) + // Seed each toggle once from whether the group has configured + // values; don't stomp a user's explicit toggle on later saves + setEnabledGroups((prev) => { + const next = { ...prev } + for (const group of SETTING_GROUPS) { + if (!group.toggleable || group.id in next) continue + next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some( + (d) => map[d.key]?.source !== "default", + ) + } + return next + }) + }, + [], + ) + + const applyProvidersResponse = useCallback( + (data: { + providers: AdminProvider[] + envProviders?: EnvProvider[] + }) => { + setProviders(data.providers) + setSavedProviders(JSON.stringify(data.providers)) + setEnvProviders(data.envProviders ?? []) + }, + [], + ) + + const login = useCallback( + async (pw: string) => { + setAuthLoading(true) + setAuthError("") + try { + const [settingsData, providersData] = await Promise.all([ + adminFetch("/api/admin/settings", pw), + adminFetch("/api/admin/providers", pw), + ]) + applySettingsResponse(settingsData) + applyProvidersResponse(providersData) + setAuthedPassword(pw) + sessionStorage.setItem(SESSION_PASSWORD_KEY, pw) + } catch (err) { + setAuthError( + err instanceof Error ? err.message : dict.admin.loginFailed, + ) + } finally { + setAuthLoading(false) + } + }, + [applySettingsResponse, applyProvidersResponse, dict], + ) + + // Restore session on mount + useEffect(() => { + const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY) + if (stored) void login(stored) + }, [login]) + + // Warn before leaving with unsaved changes + const hasDirty = dirtyCount > 0 + useEffect(() => { + if (!hasDirty) return + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault() + // Some browsers only show the prompt when returnValue is set + e.returnValue = "" + } + window.addEventListener("beforeunload", handler) + return () => window.removeEventListener("beforeunload", handler) + }, [hasDirty]) + + // Highlight the section currently in view in the sidebar + useEffect(() => { + if (!authedPassword) return + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((e) => e.isIntersecting) + .sort( + (a, b) => + a.boundingClientRect.top - b.boundingClientRect.top, + ) + if (visible[0]) setActiveGroup(visible[0].target.id) + }, + { rootMargin: "-10% 0px -50% 0px" }, + ) + for (const id of NAV_GROUP_IDS) { + const el = document.getElementById(id) + if (el) observer.observe(el) + } + return () => observer.disconnect() + }, [authedPassword]) + + const handleChange = useCallback( + (key: string, value: string | null) => { + setSaveMessage(null) + setErrors((prev) => { + if (!(key in prev)) return prev + const next = { ...prev } + delete next[key] + return next + }) + setPending((prev) => { + const state = settings[key] + const isRevert = + value !== null && + state?.source === "file" && + !isSecretValue(state?.value) && + value === savedTextOf(state) + const isNoop = + value === "" && + (!state || state.source !== "file") && + !isSecretValue(state?.value) + if (isRevert || isNoop) { + const next = { ...prev } + delete next[key] + return next + } + return { ...prev, [key]: value === "" ? null : value } + }) + }, + [settings], + ) + + // Toggling a group off stages deletion of its saved values so the + // feature actually turns off on save; toggling on drops those deletions. + const handleGroupToggle = useCallback( + (groupId: string, enabled: boolean) => { + setSaveMessage(null) + setEnabledGroups((prev) => ({ ...prev, [groupId]: enabled })) + const keys = (SETTINGS_BY_GROUP.get(groupId) ?? []).map( + (d) => d.key, + ) + setPending((prev) => { + const next = { ...prev } + for (const key of keys) { + if (!enabled) { + // Stage deletion only for values currently set + if (settings[key]?.source !== "default") + next[key] = null + } else if (next[key] === null) { + delete next[key] + } + } + return next + }) + }, + [settings], + ) + + const handleSave = useCallback(async () => { + if (!authedPassword || dirtyCount === 0) return + setSaving(true) + setSaveMessage(null) + setErrors({}) + try { + if (providersDirty) { + const data = await adminFetch( + "/api/admin/providers", + authedPassword, + { method: "PUT", body: JSON.stringify({ providers }) }, + ) + applyProvidersResponse(data) + } + if (Object.keys(pending).length > 0) { + const res = await fetch(getApiEndpoint("/api/admin/settings"), { + method: "PUT", + headers: { + "Content-Type": "application/json", + "x-admin-password": authedPassword, + }, + body: JSON.stringify({ values: pending }), + }) + const data = await res.json().catch(() => ({})) + if (!res.ok) { + // Per-field validation errors come back as {errors: {...}} + if (data.errors) { + setErrors(data.errors) + const firstKey = Object.keys(data.errors)[0] + document.getElementById(`setting-${firstKey}`)?.focus() + throw new Error(dict.admin.invalidSettings) + } + throw new Error( + data.error || `Request failed (${res.status})`, + ) + } + applySettingsResponse(data) + setPending({}) + } + setSaveMessage({ + ok: true, + text: dict.admin.saved, + }) + setTimeout(() => setSaveMessage(null), 4000) + } catch (err) { + setSaveMessage({ + ok: false, + text: + err instanceof Error ? err.message : dict.admin.saveFailed, + }) + } finally { + setSaving(false) + } + }, [ + authedPassword, + pending, + providers, + providersDirty, + dirtyCount, + applySettingsResponse, + applyProvidersResponse, + dict, + ]) + + // ── Login screen ───────────────────────────────────────────────── + if (!authedPassword) { + return ( +
+
{ + e.preventDefault() + void login(password) + }} + > +
+
+

+ {dict.admin.loginPrompt} +

+
+ + setPassword(e.target.value)} + /> +
+

+ {authError} +

+ +
+
+ ) + } + + // ── Settings screen ────────────────────────────────────────────── + return ( +
+
+
+
+
+

+ {dict.admin.precedence} +

+
+
+ + {!writable && ( +
+
+
+
+ )} + +
+ + +
+ {/* Models section */} +
+

+ {dict.admin.models} +

+

+ {dict.admin.modelsDescription} +

+
+ { + setSaveMessage(null) + setProviders(next) + }} + /> +
+
+ + {/* Registry-driven groups */} + {SETTING_GROUPS.map((group) => { + const defs = SETTINGS_BY_GROUP.get(group.id) ?? [] + const groupOff = + group.toggleable && !enabledGroups[group.id] + const fieldsDisabled = !writable || saving || !!groupOff + const gt = groupText(group.id) + const title = gt?.title ?? group.title + return ( +
+
+

+ {title} +

+ {group.toggleable && ( + + )} +
+

+ {gt?.description ?? group.description} +

+
+ {defs.map((def) => ( + + handleChange(def.key, v) + } + /> + ))} +
+
+ ) + })} +
+
+ + {/* Always-mounted live region so save results are announced */} +

+ {saveMessage?.text ?? ""} +

+ + {(dirtyCount > 0 || saveMessage) && ( +
+
+

+ {saveMessage?.ok && ( +

+ {dirtyCount > 0 && ( +
+ + +
+ )} +
+
+ )} +
+ ) +} diff --git a/app/[lang]/admin/setting-field.tsx b/app/[lang]/admin/setting-field.tsx new file mode 100644 index 0000000..2676530 --- /dev/null +++ b/app/[lang]/admin/setting-field.tsx @@ -0,0 +1,312 @@ +import { Eye, EyeOff, X } from "lucide-react" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { useDictionary } from "@/hooks/use-dictionary" +import type { SettingDef } from "@/lib/admin/settings-registry" +import { formatMessage } from "@/lib/i18n/utils" +import { cn } from "@/lib/utils" +import { + isSecretValue, + type SecretValue, + type SettingState, + savedTextOf, +} from "./admin-shared" + +// ── Small shared UI bits ───────────────────────────────────────────── + +export function SourceChip({ source }: { source: "file" | "env" | "default" }) { + const dict = useDictionary() + if (source === "default") return null + return ( + + {source === "file" ? dict.admin.sourceSaved : dict.admin.sourceEnv} + + ) +} + +export function RestartBadge() { + const dict = useDictionary() + return ( + + {dict.admin.restartRequired} + + ) +} + +// Secret input: shows masked hint as placeholder, typing replaces. +// With keepOnEmpty, clearing the field reverts to the stored value +// ("keep") instead of deleting it — explicit deletion is via the X button. +export function SecretInput({ + id, + value, + disabled, + keepOnEmpty, + onChange, +}: { + id: string + value: string | SecretValue | undefined + disabled?: boolean + keepOnEmpty?: boolean + onChange: (value: string | SecretValue) => void +}) { + const dict = useDictionary() + const [show, setShow] = useState(false) + // The stored marker as it was at mount, to revert to on empty + const [original] = useState(value) + const hadStored = isSecretValue(original) + const text = typeof value === "string" ? value : "" + const placeholder = isSecretValue(value) + ? formatMessage(dict.admin.savedReplace, { hint: value.hint }) + : dict.admin.notSet + const handleText = (t: string) => { + if (t === "" && keepOnEmpty && hadStored && original) { + onChange(original) + } else { + onChange(t) + } + } + return ( +
+ handleText(e.target.value)} + /> + + {keepOnEmpty && (hadStored || text) && !disabled && ( + + )} +
+ ) +} + +// ── General settings field (registry-driven) ───────────────────────── + +export function SettingField({ + def, + state, + pendingValue, + error, + disabled, + onChange, +}: { + def: SettingDef + state: SettingState | undefined + pendingValue: string | null | undefined + error?: string + disabled: boolean + onChange: (value: string | null) => void +}) { + const dict = useDictionary() + const isDirty = pendingValue !== undefined + const source = state?.source ?? "default" + const currentValue = isDirty ? (pendingValue ?? "") : savedTextOf(state) + const secretState = state && isSecretValue(state.value) ? state.value : null + + // Localized label/description keyed by env var name, falling back to the + // registry's English (the registry stays canonical for the server). + const t = ( + dict.admin.settings as Record< + string, + { label?: string; description?: string } | undefined + > + )[def.key] + const label = t?.label ?? def.label + const description = t?.description ?? def.description + + const inputId = `setting-${def.key}` + const errorId = `${inputId}-error` + + let control: React.ReactNode + switch (def.type) { + case "boolean": { + // When unset, reflect the built-in runtime default so the toggle + // matches actual behavior (e.g. ALLOW_PRIVATE_URLS defaults on). + const effective = + currentValue !== "" ? currentValue : (def.default ?? "false") + // A saved boolean can be cleared back to its env/default value. + const canClear = + (isDirty && pendingValue !== null) || source === "file" + control = ( +
+ + onChange(checked ? "true" : "false") + } + /> + {canClear && !disabled && ( + + )} +
+ ) + break + } + case "enum": + control = ( + + ) + break + case "secret": + control = ( +
+ + onChange(typeof v === "string" ? v : "") + } + /> +
+ ) + break + case "number": + control = ( + onChange(e.target.value)} + /> + ) + break + default: + control = ( + onChange(e.target.value)} + /> + ) + } + + return ( +
+
+ + + {def.restartRequired && } + {isDirty && ( + + {dict.admin.modified} + + )} +
+ {description && ( +

+ {description} +

+ )} + {control} +

+ {error ?? ""} +

+
+ ) +} diff --git a/app/api/admin/providers/route.ts b/app/api/admin/providers/route.ts new file mode 100644 index 0000000..216f08c --- /dev/null +++ b/app/api/admin/providers/route.ts @@ -0,0 +1,89 @@ +import { checkAdminAuth } from "@/lib/admin/auth" +import { + AdminProvidersSchema, + deriveEnvUpdates, + loadAdminProviders, + maskAdminProviders, + mergeSecrets, + validateAdminProviders, +} from "@/lib/admin/providers" +import { isSettingsWritable, saveSettings } from "@/lib/admin/settings" +import { loadEnvServerModelsConfig } from "@/lib/server-model-config" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +async function payload() { + // Env-based providers (AI_MODELS_CONFIG / ai-models.json) are shown + // read-only in the panel; their credentials live in the environment + const envConfig = await loadEnvServerModelsConfig() + const adminProviders = loadAdminProviders() + // A panel default overrides any env default (matches the merge in + // loadRawServerModelsConfig), so env stars must reflect that + const adminHasDefault = adminProviders.some( + (p) => p.isDefault && p.models.length > 0, + ) + return { + writable: isSettingsWritable(), + providers: maskAdminProviders(adminProviders), + envProviders: + envConfig?.providers.map((p) => ({ + name: p.name, + provider: p.provider, + models: p.models, + isDefault: !!p.default && !adminHasDefault, + })) ?? [], + } +} + +export async function GET(req: Request) { + const authError = checkAdminAuth(req) + if (authError) return authError + return Response.json(await 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 envConfig = await loadEnvServerModelsConfig() + const validationError = validateAdminProviders(merged, envConfig) + if (validationError) { + return Response.json({ error: validationError }, { status: 400 }) + } + + saveSettings(deriveEnvUpdates(merged, stored)) + + return Response.json(await payload()) +} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts new file mode 100644 index 0000000..d4d0975 --- /dev/null +++ b/app/api/admin/settings/route.ts @@ -0,0 +1,126 @@ +import { checkAdminAuth, maskSecret } from "@/lib/admin/auth" +import { + getEnvFallback, + getValueSource, + isSettingsWritable, + loadSettings, + saveSettings, +} from "@/lib/admin/settings" +import { + SETTINGS_BY_KEY, + SETTINGS_REGISTRY, + type SettingDef, +} from "@/lib/admin/settings-registry" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +function serializeSettings() { + const fileValues = loadSettings() + return SETTINGS_REGISTRY.map((def) => { + const source = getValueSource(def.key) + const raw = + source === "file" + ? fileValues[def.key] + : (getEnvFallback(def.key) ?? null) + const value = def.type === "secret" && raw ? maskSecret(raw) : raw + return { key: def.key, source, value } + }) +} + +export async function GET(req: Request) { + const authError = checkAdminAuth(req) + if (authError) return authError + + return Response.json({ + writable: isSettingsWritable(), + settings: serializeSettings(), + }) +} + +function validateValue(def: SettingDef, value: string): string | null { + switch (def.type) { + case "number": { + const num = Number(value) + if (!Number.isFinite(num)) return "Must be a number" + if (def.min !== undefined && num < def.min) + return `Must be at least ${def.min}` + if (def.max !== undefined && num > def.max) + return `Must be at most ${def.max}` + return null + } + case "boolean": + return value === "true" || value === "false" + ? null + : 'Must be "true" or "false"' + case "enum": + return def.options?.includes(value) + ? null + : `Must be one of: ${def.options?.join(", ")}` + default: + return null + } +} + +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: { values?: Record } + try { + body = await req.json() + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }) + } + if (!body.values || typeof body.values !== "object") { + return Response.json( + { error: "Body must contain a values object" }, + { status: 400 }, + ) + } + + const updates: Record = {} + const errors: Record = {} + + for (const [key, value] of Object.entries(body.values)) { + const def = SETTINGS_BY_KEY.get(key) + if (!def) { + errors[key] = "Unknown setting" + continue + } + if (value === null || value === "") { + updates[key] = null + continue + } + if (typeof value !== "string") { + errors[key] = "Value must be a string" + continue + } + const error = validateValue(def, value) + if (error) { + errors[key] = error + continue + } + updates[key] = value + } + + if (Object.keys(errors).length > 0) { + return Response.json({ errors }, { status: 400 }) + } + + saveSettings(updates) + + return Response.json({ + writable: true, + settings: serializeSettings(), + }) +} diff --git a/app/api/admin/test-model/route.ts b/app/api/admin/test-model/route.ts new file mode 100644 index 0000000..7ab08ee --- /dev/null +++ b/app/api/admin/test-model/route.ts @@ -0,0 +1,66 @@ +import { POST as validateModel } from "@/app/api/validate-model/route" +import { checkAdminAuth } from "@/lib/admin/auth" +import { + AdminProviderSchema, + loadAdminProviders, + mergeSecrets, +} from "@/lib/admin/providers" + +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +// Test a model with the client's CURRENT provider state (which may be +// unsaved). Secret fields arrive either as plaintext (newly typed) or as +// masked {isSet} markers, which are resolved against settings.json — so +// testing works both before and after saving. +export async function POST(req: Request) { + const authError = checkAdminAuth(req) + if (authError) return authError + + let body: { provider?: unknown; modelId?: string } + try { + body = await req.json() + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }) + } + + const parsed = AdminProviderSchema.safeParse(body.provider) + if (!parsed.success || !body.modelId) { + return Response.json( + { valid: false, error: "Invalid provider or model" }, + { status: 400 }, + ) + } + + // SECURITY: a stored secret is only resolved from an {isSet} marker if + // the endpoint it would be sent to (provider + baseUrl) still matches + // the stored entry. Otherwise a tampered baseUrl could exfiltrate the + // stored key to an arbitrary host. Mismatches must re-supply plaintext. + const stored = loadAdminProviders().find((p) => p.id === parsed.data.id) + const sameEndpoint = + stored && + stored.provider === parsed.data.provider && + (stored.baseUrl ?? "") === (parsed.data.baseUrl ?? "") && + (stored.awsRegion ?? "") === (parsed.data.awsRegion ?? "") + const [resolved] = mergeSecrets( + [parsed.data], + sameEndpoint && stored ? [stored] : [], + ) + + return validateModel( + new Request(new URL("/api/validate-model", req.url), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: resolved.provider, + apiKey: resolved.apiKey, + baseUrl: resolved.baseUrl, + modelId: body.modelId, + awsAccessKeyId: resolved.awsAccessKeyId, + awsSecretAccessKey: resolved.awsSecretAccessKey, + awsRegion: resolved.awsRegion, + vertexApiKey: resolved.vertexApiKey, + }), + }), + ) +} diff --git a/app/api/validate-model/route.ts b/app/api/validate-model/route.ts index d996467..34534cb 100644 --- a/app/api/validate-model/route.ts +++ b/app/api/validate-model/route.ts @@ -51,7 +51,7 @@ export async function POST(req: Request) { } // SECURITY: Block SSRF attacks via custom baseUrl - if (baseUrl && !allowPrivateUrls && isPrivateUrl(baseUrl)) { + if (baseUrl && !allowPrivateUrls() && isPrivateUrl(baseUrl)) { return NextResponse.json( { valid: false, error: "Invalid base URL" }, { status: 400 }, diff --git a/components/model-config-dialog.tsx b/components/model-config-dialog.tsx index d381761..747125e 100644 --- a/components/model-config-dialog.tsx +++ b/components/model-config-dialog.tsx @@ -5,22 +5,24 @@ import { Check, ChevronRight, Clock, - Cloud, Eye, EyeOff, Key, - Link2, Loader2, Plus, Server, Settings2, Sparkles, - Tag, Trash2, X, Zap, } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" +import { + ProviderCredentialsFields, + type SecretField, +} from "@/components/provider-credentials-fields" +import { ProviderLogo } from "@/components/provider-logo" import { AlertDialog, AlertDialogAction, @@ -54,11 +56,7 @@ import { useDictionary } from "@/hooks/use-dictionary" import type { UseModelConfigReturn } from "@/hooks/use-model-config" import { formatMessage } from "@/lib/i18n/utils" import type { ProviderConfig, ProviderName } from "@/lib/types/model-config" -import { - PROVIDER_INFO, - PROVIDER_LOGO_MAP, - SUGGESTED_MODELS, -} from "@/lib/types/model-config" +import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config" import { cn } from "@/lib/utils" interface ModelConfigDialogProps { @@ -69,38 +67,6 @@ interface ModelConfigDialogProps { type ValidationStatus = "idle" | "validating" | "success" | "error" -// Provider logo component -function ProviderLogo({ - provider, - className, -}: { - provider: ProviderName - className?: string -}) { - // Use Lucide icons for providers without models.dev logos - if (provider === "bedrock") { - return - } - if (provider === "sglang") { - return - } - if (provider === "doubao") { - return - } - - const logoName = PROVIDER_LOGO_MAP[provider] || provider - return ( - // biome-ignore lint/performance/noImgElement: External URL from models.dev - {`${provider} - ) -} - // Configuration section with title and optional action function ConfigSection({ title, @@ -379,6 +345,104 @@ export function ModelConfigDialog({ return provider.name || PROVIDER_INFO[provider.provider].label } + // Inline Test button + error, shared across credential layouts. Disabled + // until the relevant credentials are present. + const renderTestButton = (canValidate: boolean) => ( +
+ + {validationStatus === "error" && validationError && ( +

+ + {validationError} +

+ )} +
+ ) + + // Plaintext secret input with show/hide toggle (the user dialog stores + // keys client-side, so values are shown directly — unlike the masked + // admin panel). The primary key field carries the inline Test button. + const renderProviderSecret = (field: SecretField, id: string) => { + if (!selectedProvider) return null + const value = (selectedProvider[field] as string | undefined) ?? "" + // The "primary" credential sits beside the Test button; for Bedrock + // the test lives below the region, so its inputs have no inline test. + const isBedrock = selectedProvider.provider === "bedrock" + const withInlineTest = + !isBedrock && (field === "apiKey" || field === "vertexApiKey") + const canValidate = + field === "vertexApiKey" + ? !!selectedProvider.vertexApiKey + : selectedProvider.provider === "ollama" || + !!selectedProvider.apiKey + const input = ( +
+ + handleProviderUpdate(field, e.target.value) + } + placeholder={ + field === "awsSecretAccessKey" + ? dict.modelConfig.enterSecretKey + : field === "awsAccessKeyId" + ? "AKIA..." + : dict.modelConfig.enterApiKey + } + className="h-9 pr-10 font-mono text-xs" + /> + +
+ ) + if (!withInlineTest) return input + return ( +
+
+ {input} + {renderTestButton(canValidate)} +
+
+ ) + } + return ( @@ -579,681 +643,45 @@ export function ModelConfigDialog({ icon={Settings2} > - {/* Display Name */} -
- - - handleProviderUpdate( - "name", - e.target.value, - ) - } - placeholder={ - PROVIDER_INFO[ - selectedProvider - .provider - ].label - } - className="h-9" - /> -
- - {/* Credentials - different for Bedrock vs other providers */} - {selectedProvider.provider === - "bedrock" ? ( - <> - {/* AWS Access Key ID */} -
- - - handleProviderUpdate( - "awsAccessKeyId", - e.target - .value, - ) - } - placeholder="AKIA..." - className="h-9 font-mono text-xs" - /> -
- - {/* AWS Secret Access Key */} -
- -
- - handleProviderUpdate( - "awsSecretAccessKey", - e.target - .value, - ) - } - placeholder={ - dict - .modelConfig - .enterSecretKey - } - className="h-9 pr-10 font-mono text-xs" - /> - -
-
- - {/* AWS Region */} -
- - -
- - {/* Test Button for Bedrock */} -
- - {validationStatus === - "error" && - validationError && ( -

- - { - validationError - } -

- )} -
- - ) : selectedProvider.provider === - "vertexai" ? ( - <> - {/* Vertex AI API Key */} -
- -
-
- - handleProviderUpdate( - "vertexApiKey", - e - .target - .value, - ) - } - placeholder="Enter your Vertex AI API key" - className="h-9 pr-10 font-mono text-xs" - /> - -
- -
- {validationStatus === - "error" && - validationError && ( -

- - { - validationError - } -

- )} -
- - {/* Base URL (optional) */} -
- - - handleProviderUpdate( - "baseUrl", - e.target - .value, - ) - } - placeholder="Custom endpoint URL" - className="h-9 font-mono text-xs" - /> -
- - ) : selectedProvider.provider === - "edgeone" ? ( -
-
- - {validationStatus === - "error" && - validationError && ( -

- - { - validationError - } -

- )} -
-
- ) : ( - <> - {/* API Key */} -
- -
-
- - handleProviderUpdate( - "apiKey", - e - .target - .value, - ) - } - placeholder={ - dict - .modelConfig - .enterApiKey - } - className="h-9 pr-10 font-mono text-xs" - /> - -
- -
- {validationStatus === - "error" && - validationError && ( -

- - { - validationError - } -

- )} -
- - {/* Base URL */} -
- - - handleProviderUpdate( - "baseUrl", - e.target - .value, - ) - } - placeholder={ - PROVIDER_INFO[ - selectedProvider - .provider - ] - .defaultBaseUrl || - dict.modelConfig - .customEndpoint - } - className="h-9 rounded-xl font-mono text-xs" - /> - {selectedProvider.provider === - "minimax" && ( -

- { - dict - .modelConfig - .minimaxBaseUrlHint - } -

- )} -
- - )} + + handleProviderUpdate( + field, + value, + ) + } + renderSecret={({ field, id }) => + renderProviderSecret( + field, + id, + ) + } + footer={ + selectedProvider.provider === + "bedrock" + ? renderTestButton( + !!selectedProvider.awsAccessKeyId && + !!selectedProvider.awsSecretAccessKey && + !!selectedProvider.awsRegion, + ) + : selectedProvider.provider === + "edgeone" + ? renderTestButton( + true, + ) + : undefined + } + />
diff --git a/components/provider-credentials-fields.tsx b/components/provider-credentials-fields.tsx new file mode 100644 index 0000000..9a83020 --- /dev/null +++ b/components/provider-credentials-fields.tsx @@ -0,0 +1,259 @@ +"use client" + +import { Key, Link2, Tag } from "lucide-react" +import type { ReactNode } from "react" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useDictionary } from "@/hooks/use-dictionary" +import { formatMessage } from "@/lib/i18n/utils" +import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" + +// Logical secret field. The caller owns the actual input — plaintext for the +// user dialog, write-only masked for the admin panel — supplied via +// renderSecret. That (and the optional test action) are the only genuine +// differences between the two screens; the field structure is shared here. +export type SecretField = + | "apiKey" + | "awsAccessKeyId" + | "awsSecretAccessKey" + | "vertexApiKey" + +// AWS regions offered for Bedrock (shared by both screens) +const AWS_REGIONS: Array<[string, string]> = [ + ["us-east-1", "N. Virginia"], + ["us-east-2", "Ohio"], + ["us-west-2", "Oregon"], + ["eu-west-1", "Ireland"], + ["eu-west-2", "London"], + ["eu-west-3", "Paris"], + ["eu-central-1", "Frankfurt"], + ["ap-south-1", "Mumbai"], + ["ap-northeast-1", "Tokyo"], + ["ap-northeast-2", "Seoul"], + ["ap-southeast-1", "Singapore"], + ["ap-southeast-2", "Sydney"], + ["sa-east-1", "São Paulo"], +] + +interface ProviderCredentialsFieldsProps { + provider: ProviderName + // Plain (non-secret) field values — secrets are owned by renderSecret + name?: string + baseUrl?: string + awsRegion?: string + disabled?: boolean + // Update a plain text field + onChange: (field: "name" | "baseUrl" | "awsRegion", value: string) => void + // Render the control for a secret field. The caller may include trailing + // UI (e.g. the user dialog's inline Test button + validation error); the + // shared component only supplies the label above it. + renderSecret: (opts: { field: SecretField; id: string }) => ReactNode + // Extra content after the fields — used for the Bedrock test row and the + // EdgeOne test button, which aren't beside a credential input. + footer?: ReactNode +} + +// Display name + per-provider credential inputs, shared by the user +// ModelConfigDialog and the admin Models panel. +export function ProviderCredentialsFields({ + provider, + name, + baseUrl, + awsRegion, + disabled, + onChange, + renderSecret, + footer, +}: ProviderCredentialsFieldsProps) { + const dict = useDictionary() + const info = PROVIDER_INFO[provider] + const baseUrlLabel = formatMessage(dict.modelConfig.baseUrlWithExample, { + example: info.defaultBaseUrl || "https://api.example.com/v1", + }) + + // EdgeOne needs no credentials — the caller supplies just a test button + if (provider === "edgeone") { + return
{footer}
+ } + + return ( +
+ {/* Display Name */} +
+ + onChange("name", e.target.value)} + placeholder={info.label} + className="h-9" + /> +
+ + {provider === "bedrock" ? ( + <> + {/* AWS Access Key ID */} +
+ + {renderSecret({ + field: "awsAccessKeyId", + id: "aws-access-key-id", + })} +
+ + {/* AWS Secret Access Key */} +
+ + {renderSecret({ + field: "awsSecretAccessKey", + id: "aws-secret-access-key", + })} +
+ + {/* AWS Region */} +
+ + +
+ + ) : provider === "vertexai" ? ( + <> + {/* Vertex AI API Key (Express Mode) */} +
+ + {renderSecret({ + field: "vertexApiKey", + id: "vertex-api-key", + })} +
+ + {/* Base URL (optional) */} +
+ + + onChange("baseUrl", e.target.value) + } + placeholder={dict.modelConfig.customEndpoint} + className="h-9 font-mono text-xs" + /> +
+ + ) : ( + <> + {/* API Key */} +
+ + {renderSecret({ field: "apiKey", id: "api-key" })} +
+ + {/* Base URL */} +
+ + + onChange("baseUrl", e.target.value) + } + placeholder={ + info.defaultBaseUrl || + dict.modelConfig.customEndpoint + } + className="h-9 rounded-xl font-mono text-xs" + /> + {provider === "minimax" && ( +

+ {dict.modelConfig.minimaxBaseUrlHint} +

+ )} +
+ + )} + + {footer} +
+ ) +} diff --git a/components/provider-logo.tsx b/components/provider-logo.tsx new file mode 100644 index 0000000..0b04b0c --- /dev/null +++ b/components/provider-logo.tsx @@ -0,0 +1,36 @@ +import { Cloud, Server, Sparkles } from "lucide-react" +import { PROVIDER_LOGO_MAP, type ProviderName } from "@/lib/types/model-config" +import { cn } from "@/lib/utils" + +// Provider logo from models.dev, with Lucide fallbacks for providers +// that have no logo there +export function ProviderLogo({ + provider, + className, +}: { + provider: ProviderName + className?: string +}) { + if (provider === "bedrock") { + return + } + if (provider === "sglang") { + return + } + if (provider === "doubao") { + return + } + + const logoName = PROVIDER_LOGO_MAP[provider] || provider + return ( + // biome-ignore lint/performance/noImgElement: External URL from models.dev + + ) +} diff --git a/docker-compose.yml b/docker-compose.yml index cfd611f..55eb131 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,9 @@ services: # - NEXT_PUBLIC_BASE_PATH=/nextaidrawio ports: ["3000:3000"] env_file: .env + volumes: + # Persists admin panel settings (data/settings.json) + - ./data:/app/data # environment: # # For subdirectory deployment, uncomment and set your path: # NEXT_PUBLIC_BASE_PATH: /nextaidrawio diff --git a/docs/cn/README_CN.md b/docs/cn/README_CN.md index 969208e..be2e4cb 100644 --- a/docs/cn/README_CN.md +++ b/docs/cn/README_CN.md @@ -222,6 +222,12 @@ npm run dev 注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。 +### 管理面板 + +设置 `ADMIN_PASSWORD` 环境变量并访问 `/admin`,即可在 Web 面板中管理服务端设置(模型、访问码、功能开关、可观测性、配额),无需手动编辑 `.env`。 + +📖 **[管理面板指南](./admin-panel.md)** — 启用方法、优先级规则和注意事项。 + ## 工作原理 diff --git a/docs/cn/admin-panel.md b/docs/cn/admin-panel.md new file mode 100644 index 0000000..64fb6b5 --- /dev/null +++ b/docs/cn/admin-panel.md @@ -0,0 +1,24 @@ +# 管理面板 + +无需手动编辑 `.env`,您可以在 Web 管理面板中管理服务端设置。 + +## 启用面板 + +1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。 +2. 访问 `/admin` 并登录。 + +## 可配置内容 + +1. **Models(模型)** — 添加提供商及其 API Key 和模型列表,交互与应用内的模型设置相同。保存后这些模型成为所有用户可用的服务端模型,并在请求时与环境中的 `AI_MODELS_CONFIG` / `ai-models.json` 合并(面板不会修改这些环境文件)。 +2. **其余区块** — 访问码、生成参数、功能开关、可观测性和配额。保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。 + +## 优先级 + +面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。 + +## 注意事项 + +- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600),请妥善保管该文件。 +- 在无服务器平台(Vercel、Cloudflare Workers)上没有持久化磁盘,面板为只读 — 请改用环境变量配置。 +- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。 +- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。 diff --git a/docs/en/admin-panel.md b/docs/en/admin-panel.md new file mode 100644 index 0000000..5980e1c --- /dev/null +++ b/docs/en/admin-panel.md @@ -0,0 +1,24 @@ +# Admin Panel + +Instead of hand-editing `.env`, you can manage server settings in a web admin panel. + +## Enabling the panel + +1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel). +2. Visit `/admin` and sign in. + +## What you can configure + +1. **Models** — add providers with their API keys and model lists, using the same UI as the in-app model settings. Saved models become server-side models available to all users, merged with any `AI_MODELS_CONFIG` / `ai-models.json` from your environment at request time (the panel does not modify those env files). +2. **Other sections** — 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. + +## Notes + +- Secrets are stored in plaintext in `data/settings.json` (file mode 600). Keep the file private. +- On serverless platforms (Vercel, Cloudflare Workers) there is no persistent disk, so the panel is read-only — configure via environment variables there. +- With Docker, the `data/` directory is persisted via the volume in `docker-compose.yml`. +- `NEXT_PUBLIC_*` variables are baked in at build time and cannot be changed in the panel. diff --git a/docs/ja/README_JA.md b/docs/ja/README_JA.md index a8720cf..be2f66f 100644 --- a/docs/ja/README_JA.md +++ b/docs/ja/README_JA.md @@ -221,6 +221,12 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム 注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。 +### 管理パネル + +`ADMIN_PASSWORD` 環境変数を設定して `/admin` にアクセスすると、`.env` を手動で編集する代わりに Web パネルでサーバー設定(モデル、アクセスコード、機能、可観測性、クォータ)を管理できます。 + +📖 **[管理パネルガイド](./admin-panel.md)** — 有効化の方法、優先順位ルール、注意事項。 + ## 仕組み diff --git a/docs/ja/admin-panel.md b/docs/ja/admin-panel.md new file mode 100644 index 0000000..ff6b88b --- /dev/null +++ b/docs/ja/admin-panel.md @@ -0,0 +1,24 @@ +# 管理パネル + +`.env` を手動で編集する代わりに、Web 管理パネルでサーバー設定を管理できます。 + +## パネルの有効化 + +1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。 +2. `/admin` にアクセスしてサインインします。 + +## 設定できる項目 + +1. **Models(モデル)** — アプリ内のモデル設定と同じ UI で、プロバイダーの API キーとモデルリストを追加します。保存するとそれらは全ユーザーが利用できるサーバーサイドモデルになり、リクエスト時に環境の `AI_MODELS_CONFIG` / `ai-models.json` とマージされます(パネルがこれらの環境ファイルを変更することはありません)。 +2. **その他のセクション** — アクセスコード、生成パラメータ、機能、可観測性、クォータ。保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。 + +## 優先順位 + +パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。 + +## 注意事項 + +- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600)。このファイルは非公開に保ってください。 +- サーバーレスプラットフォーム(Vercel、Cloudflare Workers)には永続ディスクがないため、パネルは読み取り専用です — その環境では環境変数で設定してください。 +- Docker 使用時は、`data/` ディレクトリが `docker-compose.yml` のボリュームで永続化されます。 +- `NEXT_PUBLIC_*` 変数はビルド時に固定され、パネルでは変更できません。 diff --git a/env.example b/env.example index 47a500d..9109d65 100644 --- a/env.example +++ b/env.example @@ -116,6 +116,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0 # Access Control (Optional) # ACCESS_CODE_LIST=your-secret-code,another-code +# Admin Panel (Optional) +# Set a password to enable the web admin panel at /admin, where most of the +# settings in this file can be edited at runtime (stored in data/settings.json, +# which takes precedence over environment variables). +# Leave unset to disable the admin panel entirely. +# ADMIN_PASSWORD=your-admin-password +# SETTINGS_FILE=./data/settings.json # Optional: custom settings file location + # Draw.io Configuration (Optional) # NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net # Use this to point to a self-hosted draw.io instance diff --git a/instrumentation.ts b/instrumentation.ts index d6d4506..abd1f71 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,7 +1,17 @@ import { LangfuseSpanProcessor } from "@langfuse/otel" import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node" -export function register() { +export async function register() { + // Overlay admin settings file onto process.env before anything reads config + if (process.env.NEXT_RUNTIME === "nodejs") { + try { + const { applyToEnv } = await import("@/lib/admin/settings") + applyToEnv() + } catch (err) { + console.error("[admin-settings] Failed to apply settings:", err) + } + } + // Skip telemetry if Langfuse env vars are not configured if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) { console.warn( diff --git a/lib/admin/auth.ts b/lib/admin/auth.ts new file mode 100644 index 0000000..eb8373f --- /dev/null +++ b/lib/admin/auth.ts @@ -0,0 +1,37 @@ +import { timingSafeEqual } from "crypto" + +// Shared auth for admin API routes: compares x-admin-password header +// against the ADMIN_PASSWORD env var. Unset password = panel disabled. +export function checkAdminAuth(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 +} + +export interface MaskedSecret { + isSet: true + hint: string +} + +export function maskSecret(value: string): MaskedSecret { + return { + isSet: true, + hint: value.length > 8 ? `…${value.slice(-4)}` : "••••", + } +} diff --git a/lib/admin/providers.ts b/lib/admin/providers.ts new file mode 100644 index 0000000..67d5034 --- /dev/null +++ b/lib/admin/providers.ts @@ -0,0 +1,303 @@ +import { z } from "zod" +import { + ProviderNameSchema, + type ServerModelsConfig, +} from "@/lib/server-model-config" +import { + FIXED_CRED_PROVIDERS, + PROVIDER_INFO, + type ProviderName, +} from "@/lib/types/model-config" +import { type MaskedSecret, maskSecret } from "./auth" +import { loadSettings } from "./settings" + +// Admin-configured providers, mirroring the user ModelConfigDialog's data +// model but stored server-side (settings.json, ADMIN_PROVIDERS key). +// +// They COEXIST with an env-based AI_MODELS_CONFIG / ai-models.json: +// loadRawServerModelsConfig() merges the env baseline with the panel's +// providers at read time, so .env stays authoritative for its own entries. +// Panel credentials are written to ADMIN_-prefixed env vars (wired up via +// apiKeyEnv/baseUrlEnv) so they never shadow standard vars like +// OPENAI_API_KEY that env-based entries may rely on. + +export const ADMIN_PROVIDERS_KEY = "ADMIN_PROVIDERS" + +// A secret field in transit: plaintext string (new value) or an +// {isSet} marker meaning "keep the stored value". +const SecretInputSchema = z + .union([z.string(), z.object({ isSet: z.literal(true), hint: z.string() })]) + .optional() + +export const AdminProviderSchema = z.object({ + id: z.string().min(1), + provider: ProviderNameSchema, + name: z.string().optional(), + apiKey: SecretInputSchema, + baseUrl: z.string().optional(), + awsAccessKeyId: SecretInputSchema, + awsSecretAccessKey: SecretInputSchema, + awsRegion: z.string().optional(), + vertexApiKey: SecretInputSchema, + models: z.array(z.string().min(1)), + isDefault: z.boolean().optional(), +}) + +export const AdminProvidersSchema = z.array(AdminProviderSchema) + +// Stored shape: secrets are plain strings (never {isSet} markers, which +// only exist in transit). Used to validate ADMIN_PROVIDERS on load so a +// hand-edited/corrupted value can't slip a marker object past maskSecret. +const StoredAdminProviderSchema = AdminProviderSchema.extend({ + apiKey: z.string().optional(), + awsAccessKeyId: z.string().optional(), + awsSecretAccessKey: z.string().optional(), + vertexApiKey: z.string().optional(), +}) + +export type AdminProviderInput = z.infer + +// Stored form: secrets are plain strings +export interface StoredAdminProvider { + id: string + provider: ProviderName + name?: string + apiKey?: string + baseUrl?: string + awsAccessKeyId?: string + awsSecretAccessKey?: string + awsRegion?: string + vertexApiKey?: string + models: string[] + isDefault?: boolean +} + +const SECRET_FIELDS = [ + "apiKey", + "awsAccessKeyId", + "awsSecretAccessKey", + "vertexApiKey", +] as const + +// ADMIN_-prefixed env var names for instance `index` (0-based) of a provider +function credEnvNames( + provider: ProviderName, + index: number, +): { key?: string; url?: string } { + if (FIXED_CRED_PROVIDERS.includes(provider) || provider === "edgeone") { + return {} + } + const prefix = + provider === "gateway" ? "AI_GATEWAY" : provider.toUpperCase() + const suffix = index === 0 ? "" : `_${index + 1}` + return { + key: `ADMIN_${prefix}_API_KEY${suffix}`, + url: `ADMIN_${prefix}_BASE_URL${suffix}`, + } +} + +export function loadAdminProviders(): StoredAdminProvider[] { + const raw = loadSettings()[ADMIN_PROVIDERS_KEY] + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + // Validate each entry's shape — a malformed/hand-edited value must + // not reach runtime code that assumes provider/models exist. + return parsed.flatMap((entry) => { + const result = StoredAdminProviderSchema.safeParse(entry) + return result.success ? [result.data as StoredAdminProvider] : [] + }) + } catch { + console.error("[admin-providers] Failed to parse stored providers") + return [] + } +} + +export type MaskedAdminProvider = Omit< + StoredAdminProvider, + (typeof SECRET_FIELDS)[number] +> & { + apiKey?: MaskedSecret + awsAccessKeyId?: MaskedSecret + awsSecretAccessKey?: MaskedSecret + vertexApiKey?: MaskedSecret +} + +export function maskAdminProviders( + list: StoredAdminProvider[], +): MaskedAdminProvider[] { + return list.map((p) => { + const masked: MaskedAdminProvider = { ...p } as MaskedAdminProvider + for (const field of SECRET_FIELDS) { + const value = p[field] + masked[field] = value ? maskSecret(value) : undefined + } + return masked + }) +} + +// Resolve {isSet} markers in incoming secrets against the stored list +export function mergeSecrets( + incoming: AdminProviderInput[], + stored: StoredAdminProvider[], +): StoredAdminProvider[] { + const storedById = new Map(stored.map((p) => [p.id, p])) + return incoming.map((p) => { + const prev = storedById.get(p.id) + const merged = { ...p } as StoredAdminProvider + for (const field of SECRET_FIELDS) { + const value = p[field] + if (typeof value === "string") { + merged[field] = value || undefined + } else if (value?.isSet) { + merged[field] = prev?.[field] + } else { + merged[field] = undefined + } + } + return merged + }) +} + +function displayName(p: StoredAdminProvider): string { + return p.name?.trim() || PROVIDER_INFO[p.provider].label +} + +export function validateAdminProviders( + list: StoredAdminProvider[], + envConfig: ServerModelsConfig | null = null, +): string | null { + const envProviders = envConfig?.providers ?? [] + for (const single of FIXED_CRED_PROVIDERS) { + if (list.filter((p) => p.provider === single).length > 1) { + return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).` + } + // Its credentials are global; a panel instance would silently + // override the credentials env-configured models rely on + if ( + list.some((p) => p.provider === single) && + envProviders.some((p) => p.provider === single) + ) { + return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.` + } + } + const names = list.map((p) => displayName(p)) + if (new Set(names).size !== names.length) { + return "Provider display names must be unique." + } + const envNames = new Set(envProviders.map((p) => p.name)) + const clash = names.find((n) => envNames.has(n)) + if (clash) { + return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.` + } + if (list.filter((p) => p.isDefault).length > 1) { + return "Only one provider can be the default." + } + return null +} + +// The panel's contribution to the server models config, derived at read +// time and merged with the env baseline by loadRawServerModelsConfig(). +export function adminProvidersToConfig( + list: StoredAdminProvider[], +): ServerModelsConfig { + const config: ServerModelsConfig = { providers: [] } + const indexByProvider = new Map() + for (const p of list) { + const index = indexByProvider.get(p.provider) ?? 0 + indexByProvider.set(p.provider, index + 1) + if (p.models.length === 0) continue + const env = credEnvNames(p.provider, index) + config.providers.push({ + name: displayName(p), + provider: p.provider, + models: p.models, + ...(env.key && p.apiKey ? { apiKeyEnv: env.key } : {}), + ...(env.url && p.baseUrl ? { baseUrlEnv: env.url } : {}), + ...(p.isDefault ? { default: true } : {}), + }) + } + return config +} + +// Settings updates derived from the provider list: credential env vars, +// the stored list itself, and AI_PROVIDER/AI_MODEL when a default is set. +// Keys derived from `previous` but absent now are set to null (removed, +// falling back to the environment). +export function deriveEnvUpdates( + list: StoredAdminProvider[], + previous: StoredAdminProvider[], +): Record { + const updates: Record = {} + + // Clear everything the previous list owned, then overwrite below + for (const key of derivedEnvKeys(previous)) updates[key] = null + + const indexByProvider = new Map() + for (const p of list) { + const index = indexByProvider.get(p.provider) ?? 0 + indexByProvider.set(p.provider, index + 1) + + if (p.provider === "bedrock") { + if (p.awsAccessKeyId) updates.AWS_ACCESS_KEY_ID = p.awsAccessKeyId + if (p.awsSecretAccessKey) + updates.AWS_SECRET_ACCESS_KEY = p.awsSecretAccessKey + if (p.awsRegion) updates.AWS_REGION = p.awsRegion + } else if (p.provider === "vertexai") { + if (p.vertexApiKey) updates.GOOGLE_VERTEX_API_KEY = p.vertexApiKey + if (p.baseUrl) updates.GOOGLE_VERTEX_BASE_URL = p.baseUrl + } else if (p.provider === "ollama") { + if (p.apiKey) updates.OLLAMA_API_KEY = p.apiKey + if (p.baseUrl) updates.OLLAMA_BASE_URL = p.baseUrl + } else { + const env = credEnvNames(p.provider, index) + if (env.key && p.apiKey) updates[env.key] = p.apiKey + if (env.url && p.baseUrl) updates[env.url] = p.baseUrl + } + } + + updates[ADMIN_PROVIDERS_KEY] = list.length > 0 ? JSON.stringify(list) : null + + // The panel's default also becomes the server-wide default model; + // without one, the env-configured default applies. + const defaultEntry = list.find((p) => p.isDefault && p.models.length > 0) + if (defaultEntry) { + updates.AI_PROVIDER = defaultEntry.provider + updates.AI_MODEL = defaultEntry.models[0] + } + + return updates +} + +// Every settings key the panel may have written for a given list. +// AI_MODELS_CONFIG is included to clean up values written by older +// versions of the panel (it is no longer written). +function derivedEnvKeys(list: StoredAdminProvider[]): string[] { + const keys = new Set([ + "AI_MODELS_CONFIG", + "AI_PROVIDER", + "AI_MODEL", + ]) + const indexByProvider = new Map() + for (const p of list) { + const index = indexByProvider.get(p.provider) ?? 0 + indexByProvider.set(p.provider, index + 1) + if (p.provider === "bedrock") { + keys.add("AWS_ACCESS_KEY_ID") + keys.add("AWS_SECRET_ACCESS_KEY") + keys.add("AWS_REGION") + } else if (p.provider === "vertexai") { + keys.add("GOOGLE_VERTEX_API_KEY") + keys.add("GOOGLE_VERTEX_BASE_URL") + } else if (p.provider === "ollama") { + keys.add("OLLAMA_API_KEY") + keys.add("OLLAMA_BASE_URL") + } else { + const env = credEnvNames(p.provider, index) + if (env.key) keys.add(env.key) + if (env.url) keys.add(env.url) + } + } + return [...keys] +} diff --git a/lib/admin/settings-registry.ts b/lib/admin/settings-registry.ts new file mode 100644 index 0000000..6419b3c --- /dev/null +++ b/lib/admin/settings-registry.ts @@ -0,0 +1,229 @@ +// Declarative registry of the general env vars editable in the admin panel. +// Drives both server-side validation (app/api/admin/settings) and UI +// rendering (app/[lang]/admin). Keys are exactly the env var names. +// +// AI providers and models are managed separately in the panel's Models +// section (lib/admin/providers.ts), not here. +// +// Not listed here (and therefore rejected by the API): +// - NEXT_PUBLIC_* vars: baked into the client bundle at build time +// - ADMIN_PASSWORD / SETTINGS_FILE: bootstrap values, env-only to avoid lockout +// - Per-provider reasoning/thinking tuning vars: env-only (see env.example) + +export type SettingType = "string" | "secret" | "number" | "boolean" | "enum" + +export interface SettingDef { + key: string + group: string + type: SettingType + label: string + description?: string + options?: string[] + min?: number + max?: number + placeholder?: string + // Built-in default applied at runtime when the value is unset, so the UI + // can reflect actual behavior (e.g. ALLOW_PRIVATE_URLS defaults to "true"). + default?: string + // Value is only picked up at process start (module-load readers) + restartRequired?: boolean +} + +export interface SettingGroup { + id: string + title: string + description: string + // Optional sections gated by an on/off switch in the panel; fields are + // grayed out until enabled. Starts on when any field is already set. + toggleable?: boolean +} + +export const SETTING_GROUPS: SettingGroup[] = [ + { + id: "generation", + title: "Generation", + description: "Output parameters applied to all chat requests.", + }, + { + id: "access", + title: "Access Control", + description: "Restrict who can use this deployment.", + }, + { + id: "features", + title: "Features", + description: "Optional features and security toggles.", + }, + { + id: "observability", + title: "Observability", + description: "Langfuse tracing for LLM calls.", + toggleable: true, + }, + { + id: "quota", + title: "Quota & Rate Limits", + description: + "Per-IP usage limits. Enforcement requires a DynamoDB table.", + toggleable: true, + }, +] + +export const SETTINGS_REGISTRY: SettingDef[] = [ + // ── Generation ─────────────────────────────────────────────────── + { + key: "TEMPERATURE", + group: "generation", + type: "number", + label: "Temperature", + description: + "Leave unset for reasoning models that reject temperature.", + min: 0, + max: 2, + }, + { + key: "MAX_OUTPUT_TOKENS", + group: "generation", + type: "number", + label: "Max Output Tokens", + min: 1, + }, + + // ── Access Control ─────────────────────────────────────────────── + { + key: "ACCESS_CODE_LIST", + group: "access", + type: "string", + label: "Access Codes", + description: + "Comma-separated list. Users must enter one to chat. Empty = open access.", + placeholder: "code1,code2", + }, + + // ── Features ───────────────────────────────────────────────────── + { + key: "ENABLE_VLM_VALIDATION", + group: "features", + type: "boolean", + label: "VLM Diagram Validation", + description: + "Visually validate generated diagrams with a vision model.", + }, + { + key: "VALIDATION_MODEL", + group: "features", + type: "string", + label: "Validation Model", + description: "Falls back to the default AI model when empty.", + }, + { + key: "VALIDATION_TIMEOUT", + group: "features", + type: "number", + label: "Validation Timeout (ms)", + min: 1000, + }, + { + key: "ENABLE_HISTORY_XML_REPLACE", + group: "features", + type: "boolean", + label: "History XML Compression", + description: "Replace old diagram XML in history with placeholders.", + }, + { + key: "ALLOW_PRIVATE_URLS", + group: "features", + type: "boolean", + label: "Allow Private URLs", + description: + "Turn off to block requests to private IPs and internal hostnames (SSRF protection).", + // Unset means allowed at runtime (ssrf-protection: !== "false") + default: "true", + }, + + // ── Observability ──────────────────────────────────────────────── + { + key: "LANGFUSE_PUBLIC_KEY", + group: "observability", + type: "string", + label: "Langfuse Public Key", + placeholder: "pk-lf-…", + restartRequired: true, + }, + { + key: "LANGFUSE_SECRET_KEY", + group: "observability", + type: "secret", + label: "Langfuse Secret Key", + restartRequired: true, + }, + { + key: "LANGFUSE_BASEURL", + group: "observability", + type: "string", + label: "Langfuse Base URL", + placeholder: "https://cloud.langfuse.com", + restartRequired: true, + }, + + // ── Quota ──────────────────────────────────────────────────────── + { + key: "DAILY_REQUEST_LIMIT", + group: "quota", + type: "number", + label: "Daily Request Limit", + description: "Per IP per day.", + min: 1, + }, + { + key: "DAILY_TOKEN_LIMIT", + group: "quota", + type: "number", + label: "Daily Token Limit", + description: "Per IP per day.", + min: 1, + }, + { + key: "TPM_LIMIT", + group: "quota", + type: "number", + label: "Tokens Per Minute", + min: 1, + }, + { + key: "DYNAMODB_QUOTA_TABLE", + group: "quota", + type: "string", + label: "DynamoDB Table", + description: "Quota enforcement is disabled when empty.", + restartRequired: true, + }, + { + key: "DYNAMODB_REGION", + group: "quota", + type: "string", + label: "DynamoDB Region", + placeholder: "ap-northeast-1", + restartRequired: true, + }, + { + key: "QUOTA_TIMEZONE", + group: "quota", + type: "string", + label: "Quota Timezone", + description: "Timezone for the daily reset boundary.", + placeholder: "UTC", + restartRequired: true, + }, +] + +export const SETTINGS_BY_KEY: Map = new Map( + SETTINGS_REGISTRY.map((def) => [def.key, def]), +) + +export const SETTINGS_BY_GROUP: Map = new Map( + SETTING_GROUPS.map((g) => [ + g.id, + SETTINGS_REGISTRY.filter((d) => d.group === g.id), + ]), +) diff --git a/lib/admin/settings.ts b/lib/admin/settings.ts new file mode 100644 index 0000000..664c370 --- /dev/null +++ b/lib/admin/settings.ts @@ -0,0 +1,134 @@ +import fs from "fs" +import path from "path" + +// File-based admin settings, overlaid onto process.env (dotenv-style). +// Precedence: settings file > env var > built-in default. +// Keys are exactly the env var names. + +interface SettingsFile { + version: 1 + values: Record +} + +// Original env values snapshotted before the first overlay, so removing a +// key from the settings file restores the env default. null = was unset. +const originalEnv: Record = {} +// Keys currently overlaid, so we can restore ones removed from the file. +let overlaidKeys = new Set() + +let cachedSettings: Record | null = null + +export function getSettingsPath(): string { + const custom = process.env.SETTINGS_FILE + if (custom && custom.trim().length > 0) return custom + return path.join(process.cwd(), "data", "settings.json") +} + +export function loadSettings(): Record { + if (cachedSettings) return cachedSettings + try { + const raw = fs.readFileSync(getSettingsPath(), "utf8") + const parsed = JSON.parse(raw) as SettingsFile + // Keep only string values — a hand-edited or corrupted file could + // hold null/arrays/numbers that would otherwise be overlaid onto + // process.env and coerce to junk like "[object Object]". + const values: Record = {} + const rawValues = + parsed && + typeof parsed.values === "object" && + parsed.values && + !Array.isArray(parsed.values) + ? parsed.values + : {} + for (const [key, value] of Object.entries(rawValues)) { + if (typeof value === "string") values[key] = value + } + cachedSettings = values + } catch (err: any) { + if (err?.code !== "ENOENT") { + console.error("[admin-settings] Failed to read settings file:", err) + } + cachedSettings = {} + } + return cachedSettings +} + +export function applyToEnv(): void { + const values = loadSettings() + + // Restore env for keys that were overlaid before but are now gone + for (const key of overlaidKeys) { + if (!(key in values)) { + const original = originalEnv[key] + if (original === null) delete process.env[key] + else process.env[key] = original + } + } + + for (const [key, value] of Object.entries(values)) { + if (!(key in originalEnv)) { + originalEnv[key] = process.env[key] ?? null + } + process.env[key] = value + } + + overlaidKeys = new Set(Object.keys(values)) +} + +// The effective env value if the file entry were removed (for fallback display) +export function getEnvFallback(key: string): string | null { + if (overlaidKeys.has(key)) return originalEnv[key] ?? null + return process.env[key] ?? null +} + +// Whether a key's current value comes from the file, the environment, or is unset +export function getValueSource(key: string): "file" | "env" | "default" { + if (key in loadSettings()) return "file" + return getEnvFallback(key) !== null ? "env" : "default" +} + +export function saveSettings(updates: Record): void { + const current = { ...loadSettings() } + for (const [key, value] of Object.entries(updates)) { + if (value === null) delete current[key] + else current[key] = value + } + + const filePath = getSettingsPath() + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + const tmpPath = `${filePath}.tmp` + const data: SettingsFile = { version: 1, values: current } + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 }) + fs.renameSync(tmpPath, filePath) + + cachedSettings = current + applyToEnv() +} + +let writableCache: boolean | null = null + +export function isSettingsWritable(): boolean { + if (writableCache !== null) return writableCache + try { + const dir = path.dirname(getSettingsPath()) + fs.mkdirSync(dir, { recursive: true }) + fs.accessSync(dir, fs.constants.W_OK) + writableCache = true + } catch { + writableCache = false + } + return writableCache +} + +// Test-only: reset module state +export function _resetForTests(): void { + cachedSettings = null + writableCache = null + for (const key of overlaidKeys) { + const original = originalEnv[key] + if (original === null) delete process.env[key] + else if (original !== undefined) process.env[key] = original + } + overlaidKeys = new Set() + for (const key of Object.keys(originalEnv)) delete originalEnv[key] +} diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index 9cdd90f..d352ab2 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -537,7 +537,7 @@ function buildProviderOptions( } // Map of provider to required environment variable -const PROVIDER_ENV_VARS: Record = { +export const PROVIDER_ENV_VARS: Record = { bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally openai: "OPENAI_API_KEY", anthropic: "ANTHROPIC_API_KEY", diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index 607981a..0de5b7b 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -402,6 +402,152 @@ "showUnvalidatedModels": "Show unvalidated models", "allModelsShown": "All models are shown (including unvalidated)", "unvalidatedModelWarning": "This model has not been validated", - "serverDefaultModel": "Server default model" + "serverDefaultModel": "Server default model", + "showValue": "Show value", + "hideValue": "Hide value" + }, + "admin": { + "title": "Admin Settings", + "loginPrompt": "Enter the admin password (the ADMIN_PASSWORD environment variable) to manage server settings.", + "password": "Password", + "signIn": "Sign In", + "signingIn": "Signing In…", + "loginFailed": "Login failed", + "precedence": "File overrides env · env overrides defaults", + "notWritable": "The settings file is not writable on this deployment (serverless platforms have no persistent disk). Settings are shown read-only — configure via environment variables instead.", + "settingGroups": "Setting groups", + "enabled": "Enabled", + "disabled": "Disabled", + "enableGroup": "Enable {group}", + "unsavedChanges": "Unsaved changes", + "saved": "Settings saved. Changes apply immediately.", + "saveFailed": "Save failed. Check your connection and try again.", + "invalidSettings": "Some settings are invalid.", + "discard": "Discard", + "saveChanges": "Save Changes", + "saving": "Saving…", + "sourceSaved": "Saved", + "sourceEnv": "Env", + "sourceSavedTitle": "Set in the admin settings file", + "sourceEnvTitle": "Set by an environment variable", + "restartRequired": "Restart Required", + "modified": "Modified", + "notSet": "Not set", + "savedReplace": "Saved ({hint}) — type to replace", + "showValue": "Show value", + "hideValue": "Hide value", + "removeValue": "Remove value", + "removeValueTitle": "Remove the stored value", + "resetToDefault": "Reset to default", + "models": "Models", + "modelsDescription": "Server-side providers and models available to all users — no personal API key needed. The default provider's first model is used when users don't pick one.", + "addProviderHint": "Add a provider to offer server-side models to all users.", + "selectProviderHint": "Select or add a provider to configure its credentials and models.", + "addProviderToOfferModels": "Add at least one model to expose this provider to users.", + "managedViaEnv": "(managed via env)", + "envReadOnly": "Defined in AI_MODELS_CONFIG / ai-models.json — read-only here. Edit the environment configuration to change it.", + "defaultModel": "Default Model", + "noModelsConfigured": "No models configured", + "modelCount": "{count} model", + "modelCountPlural": "{count} models", + "default": "Default", + "setAsDefault": "Set as default provider", + "defaultProvider": "Default provider", + "modelIdPlaceholder": "Model ID…", + "addModel": "Add model", + "suggested": "Suggested", + "test": "Test", + "testOk": "OK ({ms}ms)", + "testFailed": "Failed", + "removeModel": "Remove {model}", + "deleteProviderTitle": "Delete {name}?", + "deleteProviderDesc": "Its credentials and models will be removed from the server after you save.", + "cancel": "Cancel", + "delete": "Delete", + "groups": { + "generation": { + "title": "Generation", + "description": "Output parameters applied to all chat requests." + }, + "access": { + "title": "Access Control", + "description": "Restrict who can use this deployment." + }, + "features": { + "title": "Features", + "description": "Optional features and security toggles." + }, + "observability": { + "title": "Observability", + "description": "Langfuse tracing for LLM calls." + }, + "quota": { + "title": "Quota & Rate Limits", + "description": "Per-IP usage limits. Enforcement requires a DynamoDB table." + } + }, + "settings": { + "TEMPERATURE": { + "label": "Temperature", + "description": "Leave unset for reasoning models that reject temperature." + }, + "MAX_OUTPUT_TOKENS": { + "label": "Max Output Tokens" + }, + "ACCESS_CODE_LIST": { + "label": "Access Codes", + "description": "Comma-separated list. Users must enter one to chat. Empty = open access." + }, + "ENABLE_VLM_VALIDATION": { + "label": "VLM Diagram Validation", + "description": "Visually validate generated diagrams with a vision model." + }, + "VALIDATION_MODEL": { + "label": "Validation Model", + "description": "Falls back to the default AI model when empty." + }, + "VALIDATION_TIMEOUT": { + "label": "Validation Timeout (ms)" + }, + "ENABLE_HISTORY_XML_REPLACE": { + "label": "History XML Compression", + "description": "Replace old diagram XML in history with placeholders." + }, + "ALLOW_PRIVATE_URLS": { + "label": "Allow Private URLs", + "description": "Turn off to block requests to private IPs and internal hostnames (SSRF protection)." + }, + "LANGFUSE_PUBLIC_KEY": { + "label": "Langfuse Public Key" + }, + "LANGFUSE_SECRET_KEY": { + "label": "Langfuse Secret Key" + }, + "LANGFUSE_BASEURL": { + "label": "Langfuse Base URL" + }, + "DAILY_REQUEST_LIMIT": { + "label": "Daily Request Limit", + "description": "Per IP per day." + }, + "DAILY_TOKEN_LIMIT": { + "label": "Daily Token Limit", + "description": "Per IP per day." + }, + "TPM_LIMIT": { + "label": "Tokens Per Minute" + }, + "DYNAMODB_QUOTA_TABLE": { + "label": "DynamoDB Table", + "description": "Quota enforcement is disabled when empty." + }, + "DYNAMODB_REGION": { + "label": "DynamoDB Region" + }, + "QUOTA_TIMEZONE": { + "label": "Quota Timezone", + "description": "Timezone for the daily reset boundary." + } + } } } diff --git a/lib/i18n/dictionaries/ja.json b/lib/i18n/dictionaries/ja.json index 7992b84..262d7c6 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -356,7 +356,9 @@ "showUnvalidatedModels": "未検証のモデルを表示", "allModelsShown": "すべてのモデルを表示(未検証を含む)", "unvalidatedModelWarning": "このモデルは検証されていません", - "serverDefaultModel": "サーバーデフォルトモデル" + "serverDefaultModel": "サーバーデフォルトモデル", + "showValue": "値を表示", + "hideValue": "値を非表示" }, "templates": { "title": "マイテンプレート", @@ -403,5 +405,149 @@ "importNoFile": "JSON ファイルを選択してください", "importFailed": "インポートに失敗しました:{error}", "importSuccess": "{imported} 件インポート、{skipped} 件の重複をスキップしました" + }, + "admin": { + "title": "管理者設定", + "loginPrompt": "サーバー設定を管理するには、管理者パスワード(ADMIN_PASSWORD 環境変数)を入力してください。", + "password": "パスワード", + "signIn": "ログイン", + "signingIn": "ログイン中…", + "loginFailed": "ログインに失敗しました", + "precedence": "ファイルが環境変数を上書き · 環境変数がデフォルトを上書き", + "notWritable": "このデプロイ環境では設定ファイルに書き込めません(サーバーレス環境には永続ディスクがありません)。設定は読み取り専用で表示されます——代わりに環境変数で構成してください。", + "settingGroups": "設定グループ", + "enabled": "有効", + "disabled": "無効", + "enableGroup": "{group} を有効化", + "unsavedChanges": "未保存の変更があります", + "saved": "設定を保存しました。変更は即座に反映されます。", + "saveFailed": "保存に失敗しました。接続を確認して再試行してください。", + "invalidSettings": "一部の設定が無効です。", + "discard": "破棄", + "saveChanges": "変更を保存", + "saving": "保存中…", + "sourceSaved": "保存済み", + "sourceEnv": "環境変数", + "sourceSavedTitle": "管理者設定ファイルで設定", + "sourceEnvTitle": "環境変数で設定", + "restartRequired": "再起動が必要", + "modified": "変更済み", + "notSet": "未設定", + "savedReplace": "保存済み({hint})——入力して置き換え", + "showValue": "値を表示", + "hideValue": "値を非表示", + "removeValue": "値を削除", + "removeValueTitle": "保存された値を削除", + "resetToDefault": "デフォルトに戻す", + "models": "モデル", + "modelsDescription": "全ユーザーが利用できるサーバー側のプロバイダーとモデル——個人の API キーは不要です。ユーザーがモデルを選択しない場合、デフォルトプロバイダーの最初のモデルが使用されます。", + "addProviderHint": "プロバイダーを追加して、全ユーザーにサーバー側モデルを提供します。", + "selectProviderHint": "プロバイダーを選択または追加して、その資格情報とモデルを構成します。", + "addProviderToOfferModels": "ユーザーにこのプロバイダーを公開するには、モデルを少なくとも 1 つ追加してください。", + "managedViaEnv": "(環境変数で管理)", + "envReadOnly": "AI_MODELS_CONFIG / ai-models.json で定義——ここでは読み取り専用です。変更するには環境構成を編集してください。", + "defaultModel": "デフォルトモデル", + "noModelsConfigured": "モデルが構成されていません", + "modelCount": "{count} 個のモデル", + "modelCountPlural": "{count} 個のモデル", + "default": "デフォルト", + "setAsDefault": "デフォルトプロバイダーに設定", + "defaultProvider": "デフォルトプロバイダー", + "modelIdPlaceholder": "モデル ID…", + "addModel": "モデルを追加", + "suggested": "おすすめ", + "test": "テスト", + "testOk": "正常({ms}ms)", + "testFailed": "失敗", + "removeModel": "{model} を削除", + "deleteProviderTitle": "{name} を削除しますか?", + "deleteProviderDesc": "保存後、その資格情報とモデルはサーバーから削除されます。", + "cancel": "キャンセル", + "delete": "削除", + "groups": { + "generation": { + "title": "生成", + "description": "すべてのチャットリクエストに適用される出力パラメーター。" + }, + "access": { + "title": "アクセス制御", + "description": "このデプロイを使用できるユーザーを制限します。" + }, + "features": { + "title": "機能", + "description": "オプション機能とセキュリティの切り替え。" + }, + "observability": { + "title": "オブザーバビリティ", + "description": "LLM 呼び出しの Langfuse トレース。" + }, + "quota": { + "title": "クォータとレート制限", + "description": "IP ごとの使用制限。強制には DynamoDB テーブルが必要です。" + } + }, + "settings": { + "TEMPERATURE": { + "label": "温度", + "description": "温度を受け付けない推論モデルの場合は未設定のままにしてください。" + }, + "MAX_OUTPUT_TOKENS": { + "label": "最大出力トークン数" + }, + "ACCESS_CODE_LIST": { + "label": "アクセスコード", + "description": "カンマ区切りのリスト。チャットにはいずれかの入力が必要です。空 = オープンアクセス。" + }, + "ENABLE_VLM_VALIDATION": { + "label": "VLM 図検証", + "description": "ビジョンモデルで生成された図を視覚的に検証します。" + }, + "VALIDATION_MODEL": { + "label": "検証モデル", + "description": "空の場合はデフォルトの AI モデルにフォールバックします。" + }, + "VALIDATION_TIMEOUT": { + "label": "検証タイムアウト(ms)" + }, + "ENABLE_HISTORY_XML_REPLACE": { + "label": "履歴 XML 圧縮", + "description": "履歴内の古い図 XML をプレースホルダーで置き換えます。" + }, + "ALLOW_PRIVATE_URLS": { + "label": "プライベート URL を許可", + "description": "オフにすると、プライベート IP や内部ホスト名へのリクエストをブロックします(SSRF 保護)。" + }, + "LANGFUSE_PUBLIC_KEY": { + "label": "Langfuse Public Key" + }, + "LANGFUSE_SECRET_KEY": { + "label": "Langfuse Secret Key" + }, + "LANGFUSE_BASEURL": { + "label": "Langfuse Base URL" + }, + "DAILY_REQUEST_LIMIT": { + "label": "1 日あたりのリクエスト上限", + "description": "IP ごと 1 日あたり。" + }, + "DAILY_TOKEN_LIMIT": { + "label": "1 日あたりのトークン上限", + "description": "IP ごと 1 日あたり。" + }, + "TPM_LIMIT": { + "label": "1 分あたりのトークン数" + }, + "DYNAMODB_QUOTA_TABLE": { + "label": "DynamoDB テーブル", + "description": "空の場合、クォータの強制は無効になります。" + }, + "DYNAMODB_REGION": { + "label": "DynamoDB リージョン" + }, + "QUOTA_TIMEZONE": { + "label": "クォータタイムゾーン", + "description": "1 日のリセット境界に使用するタイムゾーン。" + } + } } } diff --git a/lib/i18n/dictionaries/zh-Hant.json b/lib/i18n/dictionaries/zh-Hant.json index 8c3d9c3..586dcc4 100644 --- a/lib/i18n/dictionaries/zh-Hant.json +++ b/lib/i18n/dictionaries/zh-Hant.json @@ -402,6 +402,152 @@ "showUnvalidatedModels": "顯示未驗證的模型", "allModelsShown": "顯示所有模型(包括未驗證的)", "unvalidatedModelWarning": "此模型尚未驗證", - "serverDefaultModel": "伺服器預設模型" + "serverDefaultModel": "伺服器預設模型", + "showValue": "顯示值", + "hideValue": "隱藏值" + }, + "admin": { + "title": "管理員設定", + "loginPrompt": "輸入管理員密碼(即 ADMIN_PASSWORD 環境變數)以管理伺服器設定。", + "password": "密碼", + "signIn": "登入", + "signingIn": "正在登入…", + "loginFailed": "登入失敗", + "precedence": "檔案覆蓋環境變數 · 環境變數覆蓋預設值", + "notWritable": "此部署環境下設定檔不可寫入(無伺服器平台沒有持久化磁碟)。設定以唯讀方式顯示——請改用環境變數進行設定。", + "settingGroups": "設定分組", + "enabled": "已啟用", + "disabled": "已停用", + "enableGroup": "啟用 {group}", + "unsavedChanges": "有未儲存的變更", + "saved": "設定已儲存,變更立即生效。", + "saveFailed": "儲存失敗。請檢查網路連線後重試。", + "invalidSettings": "部分設定無效。", + "discard": "捨棄", + "saveChanges": "儲存變更", + "saving": "正在儲存…", + "sourceSaved": "已儲存", + "sourceEnv": "環境變數", + "sourceSavedTitle": "在管理員設定檔中設定", + "sourceEnvTitle": "透過環境變數設定", + "restartRequired": "需要重新啟動", + "modified": "已修改", + "notSet": "未設定", + "savedReplace": "已儲存({hint})——輸入以取代", + "showValue": "顯示值", + "hideValue": "隱藏值", + "removeValue": "移除值", + "removeValueTitle": "移除已儲存的值", + "resetToDefault": "重設為預設", + "models": "模型", + "modelsDescription": "面向所有使用者的伺服器端 provider 與模型——無需個人 API 金鑰。當使用者未選擇模型時,使用預設 provider 的第一個模型。", + "addProviderHint": "新增一個 provider,為所有使用者提供伺服器端模型。", + "selectProviderHint": "選擇或新增一個 provider 以設定其憑證和模型。", + "addProviderToOfferModels": "至少新增一個模型,才能向使用者開放此 provider。", + "managedViaEnv": "(透過環境變數管理)", + "envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定義——此處唯讀。請編輯環境設定以變更。", + "defaultModel": "預設模型", + "noModelsConfigured": "未設定模型", + "modelCount": "{count} 個模型", + "modelCountPlural": "{count} 個模型", + "default": "預設", + "setAsDefault": "設為預設 provider", + "defaultProvider": "預設 provider", + "modelIdPlaceholder": "模型 ID…", + "addModel": "新增模型", + "suggested": "推薦", + "test": "測試", + "testOk": "正常({ms} 毫秒)", + "testFailed": "失敗", + "removeModel": "移除 {model}", + "deleteProviderTitle": "刪除 {name}?", + "deleteProviderDesc": "儲存後,其憑證和模型將從伺服器上移除。", + "cancel": "取消", + "delete": "刪除", + "groups": { + "generation": { + "title": "生成", + "description": "套用於所有聊天請求的輸出參數。" + }, + "access": { + "title": "存取控制", + "description": "限制誰可以使用此部署。" + }, + "features": { + "title": "功能", + "description": "選用功能和安全開關。" + }, + "observability": { + "title": "可觀測性", + "description": "對 LLM 呼叫進行 Langfuse 追蹤。" + }, + "quota": { + "title": "配額與速率限制", + "description": "按 IP 的用量限制。強制執行需要 DynamoDB 表。" + } + }, + "settings": { + "TEMPERATURE": { + "label": "溫度", + "description": "對於拒絕溫度參數的推理模型,請留空。" + }, + "MAX_OUTPUT_TOKENS": { + "label": "最大輸出 token 數" + }, + "ACCESS_CODE_LIST": { + "label": "存取碼", + "description": "以逗號分隔的清單。使用者需輸入其中之一才能聊天。留空 = 開放存取。" + }, + "ENABLE_VLM_VALIDATION": { + "label": "VLM 圖表驗證", + "description": "使用視覺模型對產生的圖表進行視覺化驗證。" + }, + "VALIDATION_MODEL": { + "label": "驗證模型", + "description": "留空時回退到預設 AI 模型。" + }, + "VALIDATION_TIMEOUT": { + "label": "驗證逾時(毫秒)" + }, + "ENABLE_HISTORY_XML_REPLACE": { + "label": "歷史 XML 壓縮", + "description": "用占位符取代歷史記錄中的舊圖表 XML。" + }, + "ALLOW_PRIVATE_URLS": { + "label": "允許私有 URL", + "description": "關閉以阻擋對私有 IP 和內部主機名的請求(SSRF 防護)。" + }, + "LANGFUSE_PUBLIC_KEY": { + "label": "Langfuse Public Key" + }, + "LANGFUSE_SECRET_KEY": { + "label": "Langfuse Secret Key" + }, + "LANGFUSE_BASEURL": { + "label": "Langfuse Base URL" + }, + "DAILY_REQUEST_LIMIT": { + "label": "每日請求上限", + "description": "每個 IP 每天。" + }, + "DAILY_TOKEN_LIMIT": { + "label": "每日 token 上限", + "description": "每個 IP 每天。" + }, + "TPM_LIMIT": { + "label": "每分鐘 token 數" + }, + "DYNAMODB_QUOTA_TABLE": { + "label": "DynamoDB 表", + "description": "留空時配額強制執行被停用。" + }, + "DYNAMODB_REGION": { + "label": "DynamoDB 區域" + }, + "QUOTA_TIMEZONE": { + "label": "配額時區", + "description": "每日重置邊界所用的時區。" + } + } } } diff --git a/lib/i18n/dictionaries/zh.json b/lib/i18n/dictionaries/zh.json index 0ee9f81..38c6404 100644 --- a/lib/i18n/dictionaries/zh.json +++ b/lib/i18n/dictionaries/zh.json @@ -402,6 +402,152 @@ "showUnvalidatedModels": "显示未验证的模型", "allModelsShown": "显示所有模型(包括未验证的)", "unvalidatedModelWarning": "此模型尚未验证", - "serverDefaultModel": "服务器默认模型" + "serverDefaultModel": "服务器默认模型", + "showValue": "显示值", + "hideValue": "隐藏值" + }, + "admin": { + "title": "管理员设置", + "loginPrompt": "输入管理员密码(即 ADMIN_PASSWORD 环境变量)以管理服务器设置。", + "password": "密码", + "signIn": "登录", + "signingIn": "正在登录…", + "loginFailed": "登录失败", + "precedence": "文件覆盖环境变量 · 环境变量覆盖默认值", + "notWritable": "此部署环境下设置文件不可写(无服务器平台没有持久化磁盘)。设置以只读方式显示——请改用环境变量进行配置。", + "settingGroups": "设置分组", + "enabled": "已启用", + "disabled": "已禁用", + "enableGroup": "启用 {group}", + "unsavedChanges": "有未保存的更改", + "saved": "设置已保存,更改立即生效。", + "saveFailed": "保存失败。请检查网络连接后重试。", + "invalidSettings": "部分设置无效。", + "discard": "放弃", + "saveChanges": "保存更改", + "saving": "正在保存…", + "sourceSaved": "已保存", + "sourceEnv": "环境变量", + "sourceSavedTitle": "在管理员设置文件中设置", + "sourceEnvTitle": "通过环境变量设置", + "restartRequired": "需要重启", + "modified": "已修改", + "notSet": "未设置", + "savedReplace": "已保存({hint})——输入以替换", + "showValue": "显示值", + "hideValue": "隐藏值", + "removeValue": "移除值", + "removeValueTitle": "移除已保存的值", + "resetToDefault": "恢复默认", + "models": "模型", + "modelsDescription": "面向所有用户的服务端 provider 和模型——无需个人 API 密钥。当用户未选择模型时,使用默认 provider 的第一个模型。", + "addProviderHint": "添加一个 provider,为所有用户提供服务端模型。", + "selectProviderHint": "选择或添加一个 provider 以配置其凭证和模型。", + "addProviderToOfferModels": "至少添加一个模型,才能向用户开放此 provider。", + "managedViaEnv": "(通过环境变量管理)", + "envReadOnly": "在 AI_MODELS_CONFIG / ai-models.json 中定义——此处只读。请编辑环境配置以更改。", + "defaultModel": "默认模型", + "noModelsConfigured": "未配置模型", + "modelCount": "{count} 个模型", + "modelCountPlural": "{count} 个模型", + "default": "默认", + "setAsDefault": "设为默认 provider", + "defaultProvider": "默认 provider", + "modelIdPlaceholder": "模型 ID…", + "addModel": "添加模型", + "suggested": "推荐", + "test": "测试", + "testOk": "正常({ms} 毫秒)", + "testFailed": "失败", + "removeModel": "移除 {model}", + "deleteProviderTitle": "删除 {name}?", + "deleteProviderDesc": "保存后,其凭证和模型将从服务器上移除。", + "cancel": "取消", + "delete": "删除", + "groups": { + "generation": { + "title": "生成", + "description": "应用于所有聊天请求的输出参数。" + }, + "access": { + "title": "访问控制", + "description": "限制谁可以使用此部署。" + }, + "features": { + "title": "功能", + "description": "可选功能和安全开关。" + }, + "observability": { + "title": "可观测性", + "description": "对 LLM 调用进行 Langfuse 追踪。" + }, + "quota": { + "title": "配额与速率限制", + "description": "按 IP 的用量限制。强制执行需要 DynamoDB 表。" + } + }, + "settings": { + "TEMPERATURE": { + "label": "温度", + "description": "对于拒绝温度参数的推理模型,请留空。" + }, + "MAX_OUTPUT_TOKENS": { + "label": "最大输出 token 数" + }, + "ACCESS_CODE_LIST": { + "label": "访问码", + "description": "以逗号分隔的列表。用户需输入其中之一才能聊天。留空 = 开放访问。" + }, + "ENABLE_VLM_VALIDATION": { + "label": "VLM 图表验证", + "description": "使用视觉模型对生成的图表进行可视化验证。" + }, + "VALIDATION_MODEL": { + "label": "验证模型", + "description": "留空时回退到默认 AI 模型。" + }, + "VALIDATION_TIMEOUT": { + "label": "验证超时(毫秒)" + }, + "ENABLE_HISTORY_XML_REPLACE": { + "label": "历史 XML 压缩", + "description": "用占位符替换历史记录中的旧图表 XML。" + }, + "ALLOW_PRIVATE_URLS": { + "label": "允许私有 URL", + "description": "关闭以阻止对私有 IP 和内部主机名的请求(SSRF 防护)。" + }, + "LANGFUSE_PUBLIC_KEY": { + "label": "Langfuse Public Key" + }, + "LANGFUSE_SECRET_KEY": { + "label": "Langfuse Secret Key" + }, + "LANGFUSE_BASEURL": { + "label": "Langfuse Base URL" + }, + "DAILY_REQUEST_LIMIT": { + "label": "每日请求上限", + "description": "每个 IP 每天。" + }, + "DAILY_TOKEN_LIMIT": { + "label": "每日 token 上限", + "description": "每个 IP 每天。" + }, + "TPM_LIMIT": { + "label": "每分钟 token 数" + }, + "DYNAMODB_QUOTA_TABLE": { + "label": "DynamoDB 表", + "description": "留空时配额强制执行被禁用。" + }, + "DYNAMODB_REGION": { + "label": "DynamoDB 区域" + }, + "QUOTA_TIMEZONE": { + "label": "配额时区", + "description": "每日重置边界所用的时区。" + } + } } } diff --git a/lib/server-model-config.ts b/lib/server-model-config.ts index a2800d4..65ff0cc 100644 --- a/lib/server-model-config.ts +++ b/lib/server-model-config.ts @@ -62,7 +62,7 @@ function getConfigPath(): string { return path.join(process.cwd(), "ai-models.json") } -export async function loadRawServerModelsConfig(): Promise { +export async function loadEnvServerModelsConfig(): Promise { // Priority 1: AI_MODELS_CONFIG env var (JSON string) - for cloud deployments const envConfig = process.env.AI_MODELS_CONFIG if (envConfig && envConfig.trim().length > 0) { @@ -96,6 +96,40 @@ export async function loadRawServerModelsConfig(): Promise { + const envConfig = await loadEnvServerModelsConfig() + + // Merge in providers managed via the admin panel (settings.json). + // Dynamic import to avoid a module-init cycle with lib/admin/providers. + let adminConfig: ServerModelsConfig | null = null + try { + const { adminProvidersToConfig, loadAdminProviders } = await import( + "./admin/providers" + ) + const adminProviders = loadAdminProviders() + if (adminProviders.length > 0) { + adminConfig = adminProvidersToConfig(adminProviders) + } + } catch (err) { + console.error( + "[server-model-config] Failed to load admin providers:", + err, + ) + } + + if (!adminConfig || adminConfig.providers.length === 0) return envConfig + if (!envConfig) return adminConfig + + // A panel default overrides an env default + const adminHasDefault = adminConfig.providers.some((p) => p.default) + const envProviders = adminHasDefault + ? envConfig.providers.map((p) => + p.default ? { ...p, default: undefined } : p, + ) + : envConfig.providers + return { providers: [...envProviders, ...adminConfig.providers] } +} + export async function loadFlattenedServerModels(): Promise< FlattenedServerModel[] > { diff --git a/lib/ssrf-protection.ts b/lib/ssrf-protection.ts index 25e5bb2..30593a6 100644 --- a/lib/ssrf-protection.ts +++ b/lib/ssrf-protection.ts @@ -82,5 +82,8 @@ export function isPrivateUrl(urlString: string): boolean { /** * Whether private URLs are allowed (defaults to true) * Set ALLOW_PRIVATE_URLS=false to block private URLs + * Read per call so admin-panel changes apply without restart */ -export const allowPrivateUrls = process.env.ALLOW_PRIVATE_URLS !== "false" +export function allowPrivateUrls(): boolean { + return process.env.ALLOW_PRIVATE_URLS !== "false" +} diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts index fb24557..4b089eb 100644 --- a/lib/types/model-config.ts +++ b/lib/types/model-config.ts @@ -85,6 +85,15 @@ export interface FlattenedModel { baseUrlEnv?: string } +// Providers whose server credentials live in fixed env vars +// (AWS_ACCESS_KEY_ID, GOOGLE_VERTEX_API_KEY, OLLAMA_API_KEY) with no +// apiKeyEnv redirection support — their credentials are global +export const FIXED_CRED_PROVIDERS: ProviderName[] = [ + "bedrock", + "vertexai", + "ollama", +] + // Map provider names to models.dev logo names export const PROVIDER_LOGO_MAP: Record = { openai: "openai", diff --git a/tests/unit/admin-providers.test.ts b/tests/unit/admin-providers.test.ts new file mode 100644 index 0000000..9a552d4 --- /dev/null +++ b/tests/unit/admin-providers.test.ts @@ -0,0 +1,398 @@ +import fs from "fs" +import os from "os" +import path from "path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { + ADMIN_PROVIDERS_KEY, + adminProvidersToConfig, + deriveEnvUpdates, + loadAdminProviders, + maskAdminProviders, + mergeSecrets, + type StoredAdminProvider, + validateAdminProviders, +} from "@/lib/admin/providers" +import { _resetForTests, saveSettings } from "@/lib/admin/settings" +import { loadRawServerModelsConfig } from "@/lib/server-model-config" + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-providers-")) + process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json") + _resetForTests() +}) + +afterEach(() => { + _resetForTests() + delete process.env.SETTINGS_FILE + delete process.env.AI_MODELS_CONFIG + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +function provider( + overrides: Partial = {}, +): StoredAdminProvider { + return { + id: "p1", + provider: "openai", + apiKey: "sk-test", + models: ["gpt-5.2"], + ...overrides, + } +} + +describe("deriveEnvUpdates", () => { + it("writes credentials to ADMIN_-prefixed env vars (never shadows standard vars)", () => { + const updates = deriveEnvUpdates([provider()], []) + expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test") + expect(updates.OPENAI_API_KEY).toBeUndefined() + expect(JSON.parse(updates.ADMIN_PROVIDERS as string)).toHaveLength(1) + // AI_MODELS_CONFIG is no longer written (merged at read time) + expect(updates.AI_MODELS_CONFIG).toBeNull() + }) + + it("suffixes env vars for a second instance of the same provider", () => { + const updates = deriveEnvUpdates( + [ + provider({ id: "p1", name: "First" }), + provider({ + id: "p2", + name: "Second", + apiKey: "sk-second", + models: ["gpt-5-mini"], + }), + ], + [], + ) + expect(updates.ADMIN_OPENAI_API_KEY).toBe("sk-test") + expect(updates.ADMIN_OPENAI_API_KEY_2).toBe("sk-second") + }) + + it("maps bedrock credentials to AWS env vars", () => { + const updates = deriveEnvUpdates( + [ + provider({ + provider: "bedrock", + apiKey: undefined, + awsAccessKeyId: "AKIA123", + awsSecretAccessKey: "secret", + awsRegion: "us-west-2", + models: ["claude-x"], + }), + ], + [], + ) + expect(updates.AWS_ACCESS_KEY_ID).toBe("AKIA123") + expect(updates.AWS_SECRET_ACCESS_KEY).toBe("secret") + expect(updates.AWS_REGION).toBe("us-west-2") + }) + + it("clears keys owned by the previous list when providers are removed", () => { + const prev = [provider()] + const updates = deriveEnvUpdates([], prev) + expect(updates.ADMIN_OPENAI_API_KEY).toBeNull() + expect(updates.AI_MODELS_CONFIG).toBeNull() + expect(updates.ADMIN_PROVIDERS).toBeNull() + }) + + it("sets AI_PROVIDER/AI_MODEL only when a default is flagged", () => { + const noDefault = deriveEnvUpdates([provider()], []) + expect(noDefault.AI_PROVIDER).toBeNull() + expect(noDefault.AI_MODEL).toBeNull() + + const updates = deriveEnvUpdates( + [ + provider({ id: "p1" }), + provider({ + id: "p2", + provider: "deepseek", + models: ["deepseek-chat"], + isDefault: true, + }), + ], + [], + ) + expect(updates.AI_PROVIDER).toBe("deepseek") + expect(updates.AI_MODEL).toBe("deepseek-chat") + }) +}) + +describe("adminProvidersToConfig", () => { + it("builds a config with ADMIN_-prefixed apiKeyEnv wiring", () => { + const config = adminProvidersToConfig([provider()]) + expect(config.providers).toHaveLength(1) + expect(config.providers[0].models).toEqual(["gpt-5.2"]) + expect(config.providers[0].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY") + }) + + it("wires suffixed env vars for a second instance", () => { + const config = adminProvidersToConfig([ + provider({ id: "p1", name: "First" }), + provider({ + id: "p2", + name: "Second", + apiKey: "sk-second", + models: ["gpt-5-mini"], + }), + ]) + expect(config.providers[1].apiKeyEnv).toBe("ADMIN_OPENAI_API_KEY_2") + }) + + it("skips providers without models and carries the default flag", () => { + const config = adminProvidersToConfig([ + provider({ id: "p1", models: [] }), + provider({ id: "p2", name: "D", isDefault: true }), + ]) + expect(config.providers).toHaveLength(1) + expect(config.providers[0].default).toBe(true) + }) +}) + +describe("mergeSecrets", () => { + it("keeps stored secret when client sends an isSet marker", () => { + const stored = [provider({ apiKey: "sk-original" })] + const merged = mergeSecrets( + [ + { + ...provider(), + apiKey: { isSet: true, hint: "…test" }, + }, + ], + stored, + ) + expect(merged[0].apiKey).toBe("sk-original") + }) + + it("replaces secret when client sends a plaintext string", () => { + const stored = [provider({ apiKey: "sk-original" })] + const merged = mergeSecrets( + [{ ...provider(), apiKey: "sk-new" }], + stored, + ) + expect(merged[0].apiKey).toBe("sk-new") + }) + + it("clears secret when client sends undefined", () => { + const stored = [provider({ apiKey: "sk-original" })] + const merged = mergeSecrets( + [{ ...provider(), apiKey: undefined }], + stored, + ) + expect(merged[0].apiKey).toBeUndefined() + }) +}) + +describe("loadRawServerModelsConfig merge", () => { + it("combines env AI_MODELS_CONFIG with panel providers", async () => { + process.env.AI_MODELS_CONFIG = JSON.stringify({ + providers: [ + { + name: "Env OpenAI", + provider: "openai", + models: ["gpt-from-env"], + default: true, + }, + ], + }) + saveSettings(deriveEnvUpdates([provider({ name: "Panel" })], [])) + + const merged = await loadRawServerModelsConfig() + expect(merged?.providers.map((p) => p.name)).toEqual([ + "Env OpenAI", + "Panel", + ]) + // Env default kept because panel set none + expect(merged?.providers[0].default).toBe(true) + }) + + it("panel default overrides the env default", async () => { + process.env.AI_MODELS_CONFIG = JSON.stringify({ + providers: [ + { + name: "Env OpenAI", + provider: "openai", + models: ["gpt-from-env"], + default: true, + }, + ], + }) + saveSettings( + deriveEnvUpdates( + [provider({ name: "Panel", isDefault: true })], + [], + ), + ) + + const merged = await loadRawServerModelsConfig() + expect(merged?.providers[0].default).toBeFalsy() + expect(merged?.providers[1].default).toBe(true) + }) + + it("returns only env config when the panel has no providers", async () => { + process.env.AI_MODELS_CONFIG = JSON.stringify({ + providers: [ + { + name: "Env Only", + provider: "openai", + models: ["gpt-from-env"], + }, + ], + }) + const merged = await loadRawServerModelsConfig() + expect(merged?.providers.map((p) => p.name)).toEqual(["Env Only"]) + }) +}) + +describe("validateAdminProviders", () => { + it("rejects names clashing with env-configured providers", () => { + expect( + validateAdminProviders([provider({ name: "Env OpenAI" })], { + providers: [ + { + name: "Env OpenAI", + provider: "openai", + models: ["gpt-x"], + }, + ], + }), + ).toMatch(/already defined/) + }) + + it("rejects a global-credential provider already in the env config", () => { + expect( + validateAdminProviders( + [ + provider({ + provider: "bedrock", + apiKey: undefined, + awsAccessKeyId: "AKIA-panel", + awsSecretAccessKey: "panel-secret", + awsRegion: "us-east-1", + models: ["claude-x"], + }), + ], + { + providers: [ + { + name: "Env Bedrock", + provider: "bedrock", + models: ["claude-env"], + }, + ], + }, + ), + ).toMatch(/shares global credentials/) + }) + + it("allows a normal provider type alongside the same env type", () => { + expect( + validateAdminProviders([provider({ name: "Panel OpenAI" })], { + providers: [ + { + name: "Env OpenAI", + provider: "openai", + models: ["gpt-x"], + }, + ], + }), + ).toBeNull() + }) + + it("rejects two bedrock instances", () => { + const list = [ + provider({ id: "p1", provider: "bedrock" }), + provider({ id: "p2", provider: "bedrock" }), + ] + expect(validateAdminProviders(list)).toMatch(/Only one/) + }) + + it("rejects duplicate display names", () => { + const list = [ + provider({ id: "p1", name: "Same" }), + provider({ id: "p2", name: "Same" }), + ] + expect(validateAdminProviders(list)).toMatch(/unique/) + }) + + it("rejects multiple defaults", () => { + const list = [ + provider({ id: "p1", isDefault: true }), + provider({ id: "p2", name: "Other", isDefault: true }), + ] + expect(validateAdminProviders(list)).toMatch(/default/) + }) + + it("accepts a valid list", () => { + const list = [ + provider({ id: "p1", isDefault: true }), + provider({ id: "p2", name: "Backup" }), + ] + expect(validateAdminProviders(list)).toBeNull() + }) +}) + +describe("loadAdminProviders", () => { + it("returns [] when nothing is stored", () => { + expect(loadAdminProviders()).toEqual([]) + }) + + it("loads valid stored providers", () => { + saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([provider()]) }) + expect(loadAdminProviders()).toHaveLength(1) + }) + + it("round-trips a bedrock provider with multiple string secrets", () => { + const bedrock = provider({ + provider: "bedrock", + apiKey: undefined, + awsAccessKeyId: "AKIA123", + awsSecretAccessKey: "secret", + awsRegion: "us-west-2", + models: ["claude-x"], + }) + saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([bedrock]) }) + const loaded = loadAdminProviders() + expect(loaded).toHaveLength(1) + expect(loaded[0].awsAccessKeyId).toBe("AKIA123") + expect(loaded[0].awsSecretAccessKey).toBe("secret") + }) + + it("drops malformed entries and keeps valid ones", () => { + saveSettings({ + [ADMIN_PROVIDERS_KEY]: JSON.stringify([ + provider({ id: "good" }), + { id: "missing-fields" }, // no provider/models + { provider: "openai", models: ["x"] }, // no id + "not-an-object", + ]), + }) + const loaded = loadAdminProviders() + expect(loaded).toHaveLength(1) + expect(loaded[0].id).toBe("good") + }) + + it("returns [] when the stored value is not an array", () => { + saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify({ nope: true }) }) + expect(loadAdminProviders()).toEqual([]) + }) + + it("returns [] on invalid JSON", () => { + saveSettings({ [ADMIN_PROVIDERS_KEY]: "{ broken" }) + expect(loadAdminProviders()).toEqual([]) + }) + + it("drops entries whose secret is an {isSet} marker, not a string", () => { + // A hand-edited file could hold a transit-only marker object; if it + // slipped through, maskSecret() would throw on a non-string value. + saveSettings({ + [ADMIN_PROVIDERS_KEY]: JSON.stringify([ + { ...provider(), apiKey: { isSet: true, hint: "…1234" } }, + ]), + }) + const loaded = loadAdminProviders() + expect(loaded).toEqual([]) + // Masking the loaded list must not throw + expect(() => maskAdminProviders(loaded)).not.toThrow() + }) +}) diff --git a/tests/unit/admin-settings.test.ts b/tests/unit/admin-settings.test.ts new file mode 100644 index 0000000..8610751 --- /dev/null +++ b/tests/unit/admin-settings.test.ts @@ -0,0 +1,158 @@ +import fs from "fs" +import os from "os" +import path from "path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { + _resetForTests, + applyToEnv, + getEnvFallback, + getValueSource, + isSettingsWritable, + loadSettings, + saveSettings, +} from "@/lib/admin/settings" + +let tmpDir: string + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "admin-settings-")) + process.env.SETTINGS_FILE = path.join(tmpDir, "settings.json") + _resetForTests() +}) + +afterEach(() => { + _resetForTests() + delete process.env.SETTINGS_FILE + fs.rmSync(tmpDir, { recursive: true, force: true }) + delete process.env.TEST_ADMIN_VAR +}) + +describe("loadSettings", () => { + it("returns empty object when file does not exist", () => { + expect(loadSettings()).toEqual({}) + }) + + it("reads values from the settings file", () => { + fs.writeFileSync( + process.env.SETTINGS_FILE!, + JSON.stringify({ version: 1, values: { TEST_ADMIN_VAR: "abc" } }), + ) + expect(loadSettings()).toEqual({ TEST_ADMIN_VAR: "abc" }) + }) + + it("drops non-string values from a corrupted file", () => { + fs.writeFileSync( + process.env.SETTINGS_FILE!, + JSON.stringify({ + version: 1, + values: { + GOOD: "ok", + NUM: 5, + OBJ: { nested: true }, + ARR: [1, 2], + NULL: null, + }, + }), + ) + expect(loadSettings()).toEqual({ GOOD: "ok" }) + }) + + it("returns empty object when values is null", () => { + fs.writeFileSync( + process.env.SETTINGS_FILE!, + JSON.stringify({ version: 1, values: null }), + ) + expect(loadSettings()).toEqual({}) + }) + + it("returns empty object when values is an array (no numeric keys)", () => { + fs.writeFileSync( + process.env.SETTINGS_FILE!, + JSON.stringify({ version: 1, values: ["a", "b"] }), + ) + // Without the Array.isArray guard this would yield { "0": "a", ... } + expect(loadSettings()).toEqual({}) + }) +}) + +describe("applyToEnv / saveSettings", () => { + it("overlays file values onto process.env", () => { + saveSettings({ TEST_ADMIN_VAR: "from-file" }) + expect(process.env.TEST_ADMIN_VAR).toBe("from-file") + }) + + it("file value wins over pre-existing env value", () => { + process.env.TEST_ADMIN_VAR = "from-env" + saveSettings({ TEST_ADMIN_VAR: "from-file" }) + expect(process.env.TEST_ADMIN_VAR).toBe("from-file") + }) + + it("deleting a key restores the original env value", () => { + process.env.TEST_ADMIN_VAR = "from-env" + saveSettings({ TEST_ADMIN_VAR: "from-file" }) + saveSettings({ TEST_ADMIN_VAR: null }) + expect(process.env.TEST_ADMIN_VAR).toBe("from-env") + }) + + it("deleting a key unsets env when there was no original value", () => { + saveSettings({ TEST_ADMIN_VAR: "from-file" }) + saveSettings({ TEST_ADMIN_VAR: null }) + expect(process.env.TEST_ADMIN_VAR).toBeUndefined() + }) + + it("persists across cache reset (file round-trip)", () => { + saveSettings({ TEST_ADMIN_VAR: "persisted" }) + _resetForTests() + applyToEnv() + expect(process.env.TEST_ADMIN_VAR).toBe("persisted") + }) +}) + +describe("getValueSource / getEnvFallback", () => { + it("reports file source when key is in settings", () => { + saveSettings({ TEST_ADMIN_VAR: "x" }) + expect(getValueSource("TEST_ADMIN_VAR")).toBe("file") + }) + + it("reports env source when only env is set", () => { + process.env.TEST_ADMIN_VAR = "from-env" + applyToEnv() + expect(getValueSource("TEST_ADMIN_VAR")).toBe("env") + }) + + it("reports default when neither is set", () => { + expect(getValueSource("TEST_ADMIN_VAR")).toBe("default") + }) + + it("returns the shadowed env value as fallback", () => { + process.env.TEST_ADMIN_VAR = "from-env" + saveSettings({ TEST_ADMIN_VAR: "from-file" }) + expect(getEnvFallback("TEST_ADMIN_VAR")).toBe("from-env") + }) +}) + +describe("isSettingsWritable", () => { + it("returns true for a writable temp dir", () => { + expect(isSettingsWritable()).toBe(true) + }) + + it("returns false for an unwritable path", () => { + _resetForTests() + process.env.SETTINGS_FILE = "/nonexistent-root-dir/settings.json" + expect(isSettingsWritable()).toBe(false) + }) +}) + +describe("settings file on disk", () => { + it("writes valid JSON with restrictive permissions", () => { + saveSettings({ TEST_ADMIN_VAR: "secret" }) + const filePath = process.env.SETTINGS_FILE! + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) + expect(parsed).toEqual({ + version: 1, + values: { TEST_ADMIN_VAR: "secret" }, + }) + const mode = fs.statSync(filePath).mode & 0o777 + expect(mode).toBe(0o600) + }) +}) diff --git a/tests/unit/server-model-config.test.ts b/tests/unit/server-model-config.test.ts index 1d17d31..febdd20 100644 --- a/tests/unit/server-model-config.test.ts +++ b/tests/unit/server-model-config.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from "vitest" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { _resetForTests } from "@/lib/admin/settings" import { loadFlattenedServerModels, type ServerModelsConfig, @@ -7,11 +8,20 @@ import { const ORIGINAL_ENV = { ...process.env } +beforeEach(() => { + // Isolate from any local data/settings.json (admin panel providers + // are merged into the server models config) + process.env.SETTINGS_FILE = "/nonexistent/settings.json" + _resetForTests() +}) + afterEach(() => { + _resetForTests() process.env.AI_PROVIDER = ORIGINAL_ENV.AI_PROVIDER process.env.AI_MODEL = ORIGINAL_ENV.AI_MODEL process.env.AI_MODELS_CONFIG_PATH = ORIGINAL_ENV.AI_MODELS_CONFIG_PATH process.env.AI_MODELS_CONFIG = ORIGINAL_ENV.AI_MODELS_CONFIG + delete process.env.SETTINGS_FILE }) describe("ServerModelsConfigSchema", () => {