feat: add file-based admin settings panel at /admin

Settings saved in the panel are written to data/settings.json and
overlaid onto process.env, taking precedence over environment
variables and applying immediately without restart. Enable by setting
ADMIN_PASSWORD; on serverless platforms without persistent disk the
panel degrades to read-only.
This commit is contained in:
dayuan.jiang
2026-06-10 20:41:17 +09:00
parent 54ff8d982c
commit e2e499e5c7
12 changed files with 1928 additions and 1 deletions

View File

@@ -0,0 +1,684 @@
// Declarative registry of all 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.
//
// 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
export type SettingType =
| "string"
| "secret"
| "number"
| "boolean"
| "enum"
| "json"
export interface SettingDef {
key: string
group: string
type: SettingType
label: string
description?: string
options?: string[]
min?: number
max?: number
placeholder?: string
// Value is only picked up at process start (module-load readers)
restartRequired?: boolean
// Collapsible subsection within a group (used for the per-provider lists)
subgroup?: string
}
export interface SettingGroup {
id: string
title: string
description: string
}
export const SETTING_GROUPS: SettingGroup[] = [
{
id: "general",
title: "General",
description: "Default AI provider and model used by the server.",
},
{
id: "providers",
title: "Provider Credentials",
description:
"API keys, endpoints, and tuning for each supported provider.",
},
{
id: "models",
title: "Model Behavior",
description: "Generation parameters and the multi-model registry.",
},
{
id: "access",
title: "Access Control",
description: "Restrict who can use this deployment.",
},
{
id: "quota",
title: "Quota & Rate Limits",
description:
"Per-IP usage limits. Enforcement requires a DynamoDB table.",
},
{
id: "features",
title: "Features",
description: "Optional features and security toggles.",
},
{
id: "observability",
title: "Observability",
description: "Langfuse tracing for LLM calls.",
},
]
const PROVIDER_OPTIONS = [
"bedrock",
"openai",
"anthropic",
"google",
"vertexai",
"azure",
"ollama",
"openrouter",
"deepseek",
"siliconflow",
"sglang",
"gateway",
"edgeone",
"doubao",
"modelscope",
"glm",
"qwen",
"kimi",
"qiniu",
"minimax",
"novita",
]
// Simple API-key + base-URL providers, rendered as one subgroup each
const SIMPLE_PROVIDERS: Array<{
subgroup: string
keyVar: string
urlVar: string
urlPlaceholder?: string
}> = [
{
subgroup: "OpenRouter",
keyVar: "OPENROUTER_API_KEY",
urlVar: "OPENROUTER_BASE_URL",
urlPlaceholder: "https://openrouter.ai/api/v1",
},
{
subgroup: "DeepSeek",
keyVar: "DEEPSEEK_API_KEY",
urlVar: "DEEPSEEK_BASE_URL",
urlPlaceholder: "https://api.deepseek.com/v1",
},
{
subgroup: "SiliconFlow",
keyVar: "SILICONFLOW_API_KEY",
urlVar: "SILICONFLOW_BASE_URL",
urlPlaceholder: "https://api.siliconflow.com/v1",
},
{
subgroup: "SGLang",
keyVar: "SGLANG_API_KEY",
urlVar: "SGLANG_BASE_URL",
urlPlaceholder: "http://127.0.0.1:8000/v1",
},
{
subgroup: "Vercel AI Gateway",
keyVar: "AI_GATEWAY_API_KEY",
urlVar: "AI_GATEWAY_BASE_URL",
urlPlaceholder: "https://ai-gateway.vercel.sh/v1/ai",
},
{
subgroup: "Doubao",
keyVar: "DOUBAO_API_KEY",
urlVar: "DOUBAO_BASE_URL",
urlPlaceholder: "https://ark.cn-beijing.volces.com/api/v3",
},
{
subgroup: "ModelScope",
keyVar: "MODELSCOPE_API_KEY",
urlVar: "MODELSCOPE_BASE_URL",
urlPlaceholder: "https://api-inference.modelscope.cn/v1",
},
{
subgroup: "GLM",
keyVar: "GLM_API_KEY",
urlVar: "GLM_BASE_URL",
urlPlaceholder: "https://open.bigmodel.cn/api/paas/v4",
},
{
subgroup: "Qwen",
keyVar: "QWEN_API_KEY",
urlVar: "QWEN_BASE_URL",
urlPlaceholder: "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
{
subgroup: "Kimi",
keyVar: "KIMI_API_KEY",
urlVar: "KIMI_BASE_URL",
urlPlaceholder: "https://api.moonshot.cn/v1",
},
{
subgroup: "Qiniu",
keyVar: "QINIU_API_KEY",
urlVar: "QINIU_BASE_URL",
urlPlaceholder: "https://api.qnaigc.com/v1",
},
{
subgroup: "MiniMax",
keyVar: "MINIMAX_API_KEY",
urlVar: "MINIMAX_BASE_URL",
urlPlaceholder: "https://api.minimaxi.com/anthropic",
},
{
subgroup: "Novita",
keyVar: "NOVITA_API_KEY",
urlVar: "NOVITA_BASE_URL",
urlPlaceholder: "https://api.novita.ai/openai",
},
]
function simpleProviderSettings(): SettingDef[] {
return SIMPLE_PROVIDERS.flatMap((p) => [
{
key: p.keyVar,
group: "providers",
subgroup: p.subgroup,
type: "secret" as const,
label: "API Key",
},
{
key: p.urlVar,
group: "providers",
subgroup: p.subgroup,
type: "string" as const,
label: "Base URL",
placeholder: p.urlPlaceholder,
},
])
}
export const SETTINGS_REGISTRY: SettingDef[] = [
// ── General ──────────────────────────────────────────────────────
{
key: "AI_PROVIDER",
group: "general",
type: "enum",
label: "AI Provider",
description: "Default provider for chat requests.",
options: PROVIDER_OPTIONS,
},
{
key: "AI_MODEL",
group: "general",
type: "string",
label: "AI Model",
description: "Model ID for the chosen provider.",
placeholder: "e.g. gpt-5.2 or global.anthropic.claude-sonnet-4-5…",
},
// ── Providers: AWS Bedrock ───────────────────────────────────────
{
key: "AWS_REGION",
group: "providers",
subgroup: "AWS Bedrock",
type: "string",
label: "AWS Region",
placeholder: "us-west-2",
},
{
key: "AWS_ACCESS_KEY_ID",
group: "providers",
subgroup: "AWS Bedrock",
type: "secret",
label: "Access Key ID",
},
{
key: "AWS_SECRET_ACCESS_KEY",
group: "providers",
subgroup: "AWS Bedrock",
type: "secret",
label: "Secret Access Key",
},
{
key: "BEDROCK_REASONING_BUDGET_TOKENS",
group: "providers",
subgroup: "AWS Bedrock",
type: "number",
label: "Reasoning Budget Tokens",
description: "Claude extended-thinking budget (102464000).",
min: 1024,
max: 64000,
},
{
key: "BEDROCK_REASONING_EFFORT",
group: "providers",
subgroup: "AWS Bedrock",
type: "enum",
label: "Reasoning Effort",
description: "For Nova models.",
options: ["low", "medium", "high"],
},
// ── Providers: OpenAI ────────────────────────────────────────────
{
key: "OPENAI_API_KEY",
group: "providers",
subgroup: "OpenAI",
type: "secret",
label: "API Key",
},
{
key: "OPENAI_BASE_URL",
group: "providers",
subgroup: "OpenAI",
type: "string",
label: "Base URL",
description: "Custom OpenAI-compatible endpoint.",
placeholder: "https://api.openai.com/v1",
},
{
key: "OPENAI_REASONING_EFFORT",
group: "providers",
subgroup: "OpenAI",
type: "enum",
label: "Reasoning Effort",
options: ["minimal", "low", "medium", "high"],
},
{
key: "OPENAI_REASONING_SUMMARY",
group: "providers",
subgroup: "OpenAI",
type: "enum",
label: "Reasoning Summary",
options: ["none", "brief", "detailed"],
},
// ── Providers: Anthropic ─────────────────────────────────────────
{
key: "ANTHROPIC_API_KEY",
group: "providers",
subgroup: "Anthropic",
type: "secret",
label: "API Key",
description: "Sent as x-api-key header.",
},
{
key: "ANTHROPIC_AUTH_TOKEN",
group: "providers",
subgroup: "Anthropic",
type: "secret",
label: "Auth Token",
description:
"Alternative to the API key; sent as Authorization: Bearer.",
},
{
key: "ANTHROPIC_BASE_URL",
group: "providers",
subgroup: "Anthropic",
type: "string",
label: "Base URL",
placeholder: "https://api.anthropic.com/v1",
},
{
key: "ANTHROPIC_THINKING_TYPE",
group: "providers",
subgroup: "Anthropic",
type: "enum",
label: "Extended Thinking",
options: ["enabled"],
},
{
key: "ANTHROPIC_THINKING_BUDGET_TOKENS",
group: "providers",
subgroup: "Anthropic",
type: "number",
label: "Thinking Budget Tokens",
min: 1024,
max: 64000,
},
// ── Providers: Google ────────────────────────────────────────────
{
key: "GOOGLE_GENERATIVE_AI_API_KEY",
group: "providers",
subgroup: "Google",
type: "secret",
label: "API Key",
},
{
key: "GOOGLE_BASE_URL",
group: "providers",
subgroup: "Google",
type: "string",
label: "Base URL",
placeholder: "https://generativelanguage.googleapis.com/v1beta",
},
{
key: "GOOGLE_THINKING_BUDGET",
group: "providers",
subgroup: "Google",
type: "number",
label: "Thinking Budget (Gemini 2.5)",
min: 1024,
max: 100000,
},
{
key: "GOOGLE_THINKING_LEVEL",
group: "providers",
subgroup: "Google",
type: "enum",
label: "Thinking Level (Gemini 3)",
options: ["low", "high"],
},
{
key: "GOOGLE_REASONING_EFFORT",
group: "providers",
subgroup: "Google",
type: "enum",
label: "Reasoning Effort",
options: ["low", "medium", "high"],
},
{
key: "GOOGLE_CANDIDATE_COUNT",
group: "providers",
subgroup: "Google",
type: "number",
label: "Candidate Count",
min: 1,
max: 8,
},
{
key: "GOOGLE_TOP_K",
group: "providers",
subgroup: "Google",
type: "number",
label: "Top K",
min: 1,
max: 100,
},
{
key: "GOOGLE_TOP_P",
group: "providers",
subgroup: "Google",
type: "number",
label: "Top P",
min: 0,
max: 1,
},
// ── Providers: Vertex AI ─────────────────────────────────────────
{
key: "GOOGLE_VERTEX_API_KEY",
group: "providers",
subgroup: "Vertex AI",
type: "secret",
label: "API Key",
description: "Express Mode API key.",
},
{
key: "GOOGLE_VERTEX_BASE_URL",
group: "providers",
subgroup: "Vertex AI",
type: "string",
label: "Base URL",
},
{
key: "GOOGLE_VERTEX_THINKING_BUDGET",
group: "providers",
subgroup: "Vertex AI",
type: "number",
label: "Thinking Budget (Gemini 2.5)",
min: 1024,
max: 100000,
},
{
key: "GOOGLE_VERTEX_THINKING_LEVEL",
group: "providers",
subgroup: "Vertex AI",
type: "enum",
label: "Thinking Level (Gemini 3)",
options: ["minimal", "low", "medium", "high"],
},
// ── Providers: Azure OpenAI ──────────────────────────────────────
{
key: "AZURE_API_KEY",
group: "providers",
subgroup: "Azure OpenAI",
type: "secret",
label: "API Key",
},
{
key: "AZURE_RESOURCE_NAME",
group: "providers",
subgroup: "Azure OpenAI",
type: "string",
label: "Resource Name",
description: "Endpoint becomes https://{name}.openai.azure.com.",
},
{
key: "AZURE_BASE_URL",
group: "providers",
subgroup: "Azure OpenAI",
type: "string",
label: "Base URL",
description: "Alternative to resource name; takes precedence.",
},
{
key: "AZURE_REASONING_EFFORT",
group: "providers",
subgroup: "Azure OpenAI",
type: "enum",
label: "Reasoning Effort",
options: ["low", "medium", "high"],
},
{
key: "AZURE_REASONING_SUMMARY",
group: "providers",
subgroup: "Azure OpenAI",
type: "enum",
label: "Reasoning Summary",
options: ["none", "brief", "detailed"],
},
// ── Providers: Ollama ────────────────────────────────────────────
{
key: "OLLAMA_BASE_URL",
group: "providers",
subgroup: "Ollama",
type: "string",
label: "Base URL",
placeholder: "https://ollama.com/api",
},
{
key: "OLLAMA_API_KEY",
group: "providers",
subgroup: "Ollama",
type: "secret",
label: "API Key",
description: "For Ollama Cloud or authenticated remote instances.",
},
{
key: "OLLAMA_ENABLE_THINKING",
group: "providers",
subgroup: "Ollama",
type: "boolean",
label: "Enable Thinking",
},
...simpleProviderSettings(),
// ── Model Behavior ───────────────────────────────────────────────
{
key: "TEMPERATURE",
group: "models",
type: "number",
label: "Temperature",
description:
"Leave unset for reasoning models that reject temperature.",
min: 0,
max: 2,
},
{
key: "MAX_OUTPUT_TOKENS",
group: "models",
type: "number",
label: "Max Output Tokens",
min: 1,
},
{
key: "AI_MODELS_CONFIG",
group: "models",
type: "json",
label: "Multi-Model Registry (JSON)",
description:
'Server model list shown to all users. Schema: {"providers":[{"name":"…","provider":"openai","models":["…"]}]}.',
},
{
key: "AI_MODELS_CONFIG_PATH",
group: "models",
type: "string",
label: "Model Registry File Path",
description: "Used only when the JSON registry above is empty.",
placeholder: "./ai-models.json",
},
// ── 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",
},
// ── 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,
},
// ── 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).",
},
// ── 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,
},
]
export const SETTINGS_BY_KEY: Map<string, SettingDef> = new Map(
SETTINGS_REGISTRY.map((def) => [def.key, def]),
)

125
lib/admin/settings.ts Normal file
View File

@@ -0,0 +1,125 @@
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<string, string>
}
// 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<string, string | null> = {}
// Keys currently overlaid, so we can restore ones removed from the file.
let overlaidKeys = new Set<string>()
let cachedSettings: Record<string, string> | 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<string, string> {
if (cachedSettings) return cachedSettings
try {
const raw = fs.readFileSync(getSettingsPath(), "utf8")
const parsed = JSON.parse(raw) as SettingsFile
cachedSettings =
parsed && typeof parsed.values === "object" ? parsed.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))
}
// Whether a key's current value comes from the file, the environment, or is unset
export function getValueSource(key: string): "file" | "env" | "default" {
const values = loadSettings()
if (key in values) return "file"
const envValue = overlaidKeys.has(key)
? originalEnv[key]
: (process.env[key] ?? null)
return envValue !== null && envValue !== undefined ? "env" : "default"
}
// 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
}
export function saveSettings(updates: Record<string, string | null>): 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]
}