mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-02 01:20:23 +08:00
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:
@@ -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/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
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
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
19
README.md
19
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 Vercel](#deploy-on-vercel)
|
||||||
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
- [Deploy on Cloudflare Workers](#deploy-on-cloudflare-workers)
|
||||||
- [Multi-Provider Support](#multi-provider-support)
|
- [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)
|
- [How It Works](#how-it-works)
|
||||||
- [Support \& Contact](#support--contact)
|
- [Support \& Contact](#support--contact)
|
||||||
- [FAQ](#faq)
|
- [FAQ](#faq)
|
||||||
@@ -224,6 +226,23 @@ 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.
|
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
|
||||||
|
|
||||||
|
Instead of hand-editing `.env`, you can manage most server settings (provider keys, model behavior, access codes, quota, features) in a web admin panel:
|
||||||
|
|
||||||
|
1. Set the `ADMIN_PASSWORD` environment variable (leave unset to disable the panel).
|
||||||
|
2. Visit `/admin` and sign in.
|
||||||
|
3. Saved settings are written to `data/settings.json` and apply immediately — no restart needed (a few settings such as Langfuse and DynamoDB are marked "Restart Required").
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
**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.
|
**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.
|
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.
|
||||||
|
|||||||
746
app/[lang]/admin/page.tsx
Normal file
746
app/[lang]/admin/page.tsx
Normal file
@@ -0,0 +1,746 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Loader2,
|
||||||
|
LockKeyhole,
|
||||||
|
RotateCcw,
|
||||||
|
ShieldCheck,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible"
|
||||||
|
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 { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
SETTING_GROUPS,
|
||||||
|
SETTINGS_REGISTRY,
|
||||||
|
type SettingDef,
|
||||||
|
} from "@/lib/admin/settings-registry"
|
||||||
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const SESSION_PASSWORD_KEY = "next-ai-draw-io-admin-password"
|
||||||
|
|
||||||
|
type SecretValue = { isSet: true; hint: string }
|
||||||
|
|
||||||
|
interface SettingState {
|
||||||
|
key: string
|
||||||
|
source: "file" | "env" | "default"
|
||||||
|
value: string | SecretValue | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsMap = Record<string, SettingState>
|
||||||
|
|
||||||
|
function isSecretValue(v: unknown): v is SecretValue {
|
||||||
|
return typeof v === "object" && v !== null && "isSet" in v
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceChip({ source }: { source: "file" | "env" | "default" }) {
|
||||||
|
if (source === "default") return null
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide",
|
||||||
|
source === "file"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
source === "file"
|
||||||
|
? "Set in the admin settings file"
|
||||||
|
: "Set by an environment variable"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{source === "file" ? "Saved" : "Env"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RestartBadge() {
|
||||||
|
return (
|
||||||
|
<span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
|
||||||
|
Restart Required
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [showSecret, setShowSecret] = useState(false)
|
||||||
|
const isDirty = pendingValue !== undefined
|
||||||
|
const source = state?.source ?? "default"
|
||||||
|
|
||||||
|
const savedText =
|
||||||
|
state && !isSecretValue(state.value) ? (state.value ?? "") : ""
|
||||||
|
const currentValue = isDirty ? (pendingValue ?? "") : savedText
|
||||||
|
|
||||||
|
const secretIsSet = state ? isSecretValue(state.value) : false
|
||||||
|
const secretHint =
|
||||||
|
state && isSecretValue(state.value) ? state.value.hint : ""
|
||||||
|
|
||||||
|
const inputId = `setting-${def.key}`
|
||||||
|
const errorId = `${inputId}-error`
|
||||||
|
|
||||||
|
let control: React.ReactNode
|
||||||
|
switch (def.type) {
|
||||||
|
case "boolean":
|
||||||
|
control = (
|
||||||
|
<Switch
|
||||||
|
id={inputId}
|
||||||
|
checked={currentValue === "true"}
|
||||||
|
disabled={disabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange(checked ? "true" : "false")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "enum":
|
||||||
|
control = (
|
||||||
|
<Select
|
||||||
|
value={currentValue || undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={onChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={inputId} className="w-full max-w-xs">
|
||||||
|
<SelectValue placeholder="Not set" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{def.options?.map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "json":
|
||||||
|
control = (
|
||||||
|
<Textarea
|
||||||
|
id={inputId}
|
||||||
|
value={currentValue}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
rows={6}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
placeholder={def.placeholder ?? "{ … }"}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "secret":
|
||||||
|
control = (
|
||||||
|
<div className="flex w-full max-w-md items-center gap-1">
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
type={showSecret ? "text" : "password"}
|
||||||
|
value={currentValue}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={
|
||||||
|
secretIsSet && !isDirty
|
||||||
|
? `Saved (${secretHint}) — type to replace`
|
||||||
|
: "Not set"
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label={showSecret ? "Hide value" : "Show value"}
|
||||||
|
onClick={() => setShowSecret((s) => !s)}
|
||||||
|
>
|
||||||
|
{showSecret ? (
|
||||||
|
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "number":
|
||||||
|
control = (
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={def.min}
|
||||||
|
max={def.max}
|
||||||
|
value={currentValue}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={def.placeholder ?? "Not set"}
|
||||||
|
className="w-full max-w-xs tabular-nums"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
control = (
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
type="text"
|
||||||
|
value={currentValue}
|
||||||
|
disabled={disabled}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={def.placeholder ?? "Not set"}
|
||||||
|
className="w-full max-w-md"
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canReset =
|
||||||
|
!disabled && !isDirty && source === "file" && def.type !== "boolean"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border/60 py-4 last:border-b-0">
|
||||||
|
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
<Label htmlFor={inputId} className="text-sm font-medium">
|
||||||
|
{def.label}
|
||||||
|
</Label>
|
||||||
|
<SourceChip source={source} />
|
||||||
|
{def.restartRequired && <RestartBadge />}
|
||||||
|
{isDirty && (
|
||||||
|
<span className="rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-600 dark:text-blue-400">
|
||||||
|
Modified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{def.description && (
|
||||||
|
<p className="mb-2 max-w-prose text-xs text-muted-foreground">
|
||||||
|
{def.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
|
{control}
|
||||||
|
{canReset && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
aria-label={`Reset ${def.label} to environment default`}
|
||||||
|
title="Remove saved value (falls back to env var)"
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isDirty && pendingValue === null && (
|
||||||
|
<p className="mt-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Saved value will be removed; the environment default applies
|
||||||
|
after saving.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
id={errorId}
|
||||||
|
className={cn(
|
||||||
|
"text-xs text-destructive",
|
||||||
|
error ? "mt-1.5" : "sr-only",
|
||||||
|
)}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{error ?? ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderSubgroup({
|
||||||
|
name,
|
||||||
|
defs,
|
||||||
|
settings,
|
||||||
|
pending,
|
||||||
|
errors,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
name: string
|
||||||
|
defs: SettingDef[]
|
||||||
|
settings: SettingsMap
|
||||||
|
pending: Record<string, string | null>
|
||||||
|
errors: Record<string, string>
|
||||||
|
disabled: boolean
|
||||||
|
onChange: (key: string, value: string | null) => void
|
||||||
|
}) {
|
||||||
|
const configured = defs.some((d) => {
|
||||||
|
const s = settings[d.key]
|
||||||
|
return s && s.source !== "default"
|
||||||
|
})
|
||||||
|
const hasDirty = defs.some((d) => d.key in pending)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open || hasDirty} onOpenChange={setOpen}>
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md px-2 py-2.5 text-left text-sm font-medium hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 text-muted-foreground transition-transform motion-reduce:transition-none",
|
||||||
|
(open || hasDirty) && "rotate-90",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
{configured && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||||
|
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
Configured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="px-2 pb-2">
|
||||||
|
{defs.map((def) => (
|
||||||
|
<SettingField
|
||||||
|
key={def.key}
|
||||||
|
def={def}
|
||||||
|
state={settings[def.key]}
|
||||||
|
pendingValue={pending[def.key]}
|
||||||
|
error={errors[def.key]}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(v) => onChange(def.key, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [authedPassword, setAuthedPassword] = useState<string | null>(null)
|
||||||
|
const [authError, setAuthError] = useState("")
|
||||||
|
const [authLoading, setAuthLoading] = useState(false)
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<SettingsMap>({})
|
||||||
|
const [writable, setWritable] = useState(true)
|
||||||
|
const [pending, setPending] = useState<Record<string, string | null>>({})
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveMessage, setSaveMessage] = useState("")
|
||||||
|
const mainRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const dirtyCount = Object.keys(pending).length
|
||||||
|
|
||||||
|
const fetchSettings = useCallback(async (pw: string) => {
|
||||||
|
const res = await fetch(getApiEndpoint("/api/admin/settings"), {
|
||||||
|
headers: { "x-admin-password": pw },
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(data.error || `Request failed (${res.status})`)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{
|
||||||
|
writable: boolean
|
||||||
|
settings: SettingState[]
|
||||||
|
}>
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const applyResponse = useCallback(
|
||||||
|
(data: { writable: boolean; settings: SettingState[] }) => {
|
||||||
|
setWritable(data.writable)
|
||||||
|
const map: SettingsMap = {}
|
||||||
|
for (const s of data.settings) map[s.key] = s
|
||||||
|
setSettings(map)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (pw: string) => {
|
||||||
|
setAuthLoading(true)
|
||||||
|
setAuthError("")
|
||||||
|
try {
|
||||||
|
const data = await fetchSettings(pw)
|
||||||
|
applyResponse(data)
|
||||||
|
setAuthedPassword(pw)
|
||||||
|
sessionStorage.setItem(SESSION_PASSWORD_KEY, pw)
|
||||||
|
} catch (err) {
|
||||||
|
setAuthError(
|
||||||
|
err instanceof Error ? err.message : "Login failed",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setAuthLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchSettings, applyResponse],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restore session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = sessionStorage.getItem(SESSION_PASSWORD_KEY)
|
||||||
|
if (stored) void login(stored)
|
||||||
|
}, [login])
|
||||||
|
|
||||||
|
// Warn before leaving with unsaved changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (dirtyCount === 0) return
|
||||||
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
window.addEventListener("beforeunload", handler)
|
||||||
|
return () => window.removeEventListener("beforeunload", handler)
|
||||||
|
}, [dirtyCount])
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(key: string, value: string | null) => {
|
||||||
|
setSaveMessage("")
|
||||||
|
setErrors((prev) => {
|
||||||
|
if (!(key in prev)) return prev
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setPending((prev) => {
|
||||||
|
const state = settings[key]
|
||||||
|
const savedText =
|
||||||
|
state && !isSecretValue(state.value)
|
||||||
|
? (state.value ?? "")
|
||||||
|
: ""
|
||||||
|
// Typing back the saved value (or clearing an untouched field)
|
||||||
|
// removes it from the dirty set
|
||||||
|
const isRevert =
|
||||||
|
value !== null &&
|
||||||
|
state?.source === "file" &&
|
||||||
|
!isSecretValue(state?.value) &&
|
||||||
|
value === savedText
|
||||||
|
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],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!authedPassword || dirtyCount === 0) return
|
||||||
|
setSaving(true)
|
||||||
|
setSaveMessage("")
|
||||||
|
setErrors({})
|
||||||
|
try {
|
||||||
|
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()
|
||||||
|
if (!res.ok) {
|
||||||
|
if (data.errors) {
|
||||||
|
setErrors(data.errors)
|
||||||
|
const firstKey = Object.keys(data.errors)[0]
|
||||||
|
document.getElementById(`setting-${firstKey}`)?.focus()
|
||||||
|
} else {
|
||||||
|
setSaveMessage(data.error || "Save failed")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyResponse(data)
|
||||||
|
setPending({})
|
||||||
|
setSaveMessage("Settings saved. Changes apply immediately.")
|
||||||
|
} catch {
|
||||||
|
setSaveMessage(
|
||||||
|
"Save failed: network error. Check your connection and try again.",
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [authedPassword, pending, dirtyCount, applyResponse])
|
||||||
|
|
||||||
|
const providerSubgroups = useMemo(() => {
|
||||||
|
const map = new Map<string, SettingDef[]>()
|
||||||
|
for (const def of SETTINGS_REGISTRY) {
|
||||||
|
if (def.group !== "providers" || !def.subgroup) continue
|
||||||
|
const list = map.get(def.subgroup) ?? []
|
||||||
|
list.push(def)
|
||||||
|
map.set(def.subgroup, list)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// ── Login screen ─────────────────────────────────────────────────
|
||||||
|
if (!authedPassword) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
|
<form
|
||||||
|
className="w-full max-w-sm space-y-4 rounded-lg border bg-card p-6 shadow-sm"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void login(password)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LockKeyhole
|
||||||
|
className="h-5 w-5 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
Admin Settings
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Enter the admin password (the ADMIN_PASSWORD environment
|
||||||
|
variable) to manage server settings.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="admin-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="admin-password"
|
||||||
|
name="admin-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-destructive",
|
||||||
|
!authError && "sr-only",
|
||||||
|
)}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{authError}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={authLoading}
|
||||||
|
>
|
||||||
|
{authLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2
|
||||||
|
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Signing In…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Sign In"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings screen ──────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<header className="sticky top-0 z-20 border-b bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck
|
||||||
|
className="h-5 w-5 text-primary"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<h1 className="text-lg font-semibold">
|
||||||
|
Admin Settings
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
File overrides env · env overrides defaults
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!writable && (
|
||||||
|
<div className="border-b bg-amber-500/10">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center gap-2 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
<AlertTriangle
|
||||||
|
className="h-4 w-4 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-auto flex max-w-5xl gap-8 px-4 py-6">
|
||||||
|
<nav
|
||||||
|
aria-label="Setting groups"
|
||||||
|
className="sticky top-20 hidden h-fit w-44 shrink-0 md:block"
|
||||||
|
>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{SETTING_GROUPS.map((group) => (
|
||||||
|
<li key={group.id}>
|
||||||
|
<a
|
||||||
|
href={`#${group.id}`}
|
||||||
|
className="block rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{group.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main ref={mainRef} className="min-w-0 flex-1 pb-24">
|
||||||
|
{SETTING_GROUPS.map((group) => {
|
||||||
|
const defs = SETTINGS_REGISTRY.filter(
|
||||||
|
(d) => d.group === group.id,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={group.id}
|
||||||
|
aria-labelledby={group.id}
|
||||||
|
className="mb-10"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id={group.id}
|
||||||
|
className="scroll-mt-20 text-base font-semibold"
|
||||||
|
>
|
||||||
|
{group.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-3 mt-1 text-sm text-muted-foreground text-pretty">
|
||||||
|
{group.description}
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border bg-card px-4">
|
||||||
|
{group.id === "providers"
|
||||||
|
? [...providerSubgroups.entries()].map(
|
||||||
|
([name, subDefs]) => (
|
||||||
|
<ProviderSubgroup
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
defs={subDefs}
|
||||||
|
settings={settings}
|
||||||
|
pending={pending}
|
||||||
|
errors={errors}
|
||||||
|
disabled={!writable}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: defs.map((def) => (
|
||||||
|
<SettingField
|
||||||
|
key={def.key}
|
||||||
|
def={def}
|
||||||
|
state={settings[def.key]}
|
||||||
|
pendingValue={
|
||||||
|
pending[def.key]
|
||||||
|
}
|
||||||
|
error={errors[def.key]}
|
||||||
|
disabled={!writable}
|
||||||
|
onChange={(v) =>
|
||||||
|
handleChange(def.key, v)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Always-mounted live region so save results are announced */}
|
||||||
|
<p aria-live="polite" className="sr-only">
|
||||||
|
{saveMessage}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(dirtyCount > 0 || saveMessage) && (
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-3">
|
||||||
|
<p className="min-w-0 truncate text-sm text-muted-foreground">
|
||||||
|
{dirtyCount > 0
|
||||||
|
? `${dirtyCount} unsaved ${dirtyCount === 1 ? "change" : "changes"}`
|
||||||
|
: saveMessage}
|
||||||
|
</p>
|
||||||
|
{dirtyCount > 0 && (
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setPending({})
|
||||||
|
setErrors({})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={saving || !writable}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2
|
||||||
|
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save Changes"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
171
app/api/admin/settings/route.ts
Normal file
171
app/api/admin/settings/route.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { timingSafeEqual } from "crypto"
|
||||||
|
import {
|
||||||
|
getEnvFallback,
|
||||||
|
getValueSource,
|
||||||
|
isSettingsWritable,
|
||||||
|
loadSettings,
|
||||||
|
saveSettings,
|
||||||
|
} from "@/lib/admin/settings"
|
||||||
|
import { SETTINGS_BY_KEY, type SettingDef } from "@/lib/admin/settings-registry"
|
||||||
|
import { ServerModelsConfigSchema } from "@/lib/server-model-config"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
function checkAuth(req: Request): Response | null {
|
||||||
|
const password = process.env.ADMIN_PASSWORD
|
||||||
|
if (!password) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error: "Admin panel is disabled. Set the ADMIN_PASSWORD environment variable to enable it.",
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const provided = req.headers.get("x-admin-password") || ""
|
||||||
|
const a = Buffer.from(provided)
|
||||||
|
const b = Buffer.from(password)
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "Invalid admin password" },
|
||||||
|
{ status: 401 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskSecret(value: string): { isSet: true; hint: string } {
|
||||||
|
return {
|
||||||
|
isSet: true,
|
||||||
|
hint: value.length > 8 ? `…${value.slice(-4)}` : "••••",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeSettings() {
|
||||||
|
const fileValues = loadSettings()
|
||||||
|
return [...SETTINGS_BY_KEY.values()].map((def) => {
|
||||||
|
const source = getValueSource(def.key)
|
||||||
|
const raw =
|
||||||
|
source === "file"
|
||||||
|
? fileValues[def.key]
|
||||||
|
: (getEnvFallback(def.key) ?? null)
|
||||||
|
|
||||||
|
let value: unknown = raw
|
||||||
|
if (def.type === "secret" && raw) {
|
||||||
|
value = maskSecret(raw)
|
||||||
|
}
|
||||||
|
return { key: def.key, source, value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const authError = checkAuth(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.isNaN(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(", ")}`
|
||||||
|
case "json": {
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value)
|
||||||
|
} catch {
|
||||||
|
return "Invalid JSON"
|
||||||
|
}
|
||||||
|
if (def.key === "AI_MODELS_CONFIG") {
|
||||||
|
const result = ServerModelsConfigSchema.safeParse(parsed)
|
||||||
|
if (!result.success) {
|
||||||
|
return `Invalid model registry: ${result.error.issues[0]?.message ?? "schema mismatch"}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
const authError = checkAuth(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<string, unknown> }
|
||||||
|
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<string, string | null> = {}
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ services:
|
|||||||
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
# - NEXT_PUBLIC_BASE_PATH=/nextaidrawio
|
||||||
ports: ["3000:3000"]
|
ports: ["3000:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
# Persists admin panel settings (data/settings.json)
|
||||||
|
- ./data:/app/data
|
||||||
# environment:
|
# environment:
|
||||||
# # For subdirectory deployment, uncomment and set your path:
|
# # For subdirectory deployment, uncomment and set your path:
|
||||||
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
# NEXT_PUBLIC_BASE_PATH: /nextaidrawio
|
||||||
|
|||||||
@@ -222,6 +222,23 @@ npm run dev
|
|||||||
|
|
||||||
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
注意:`claude` 系列已在带有 AWS、Azure、GCP 等云架构 Logo 的 draw.io 图表上进行训练,因此如果您想创建云架构图,这是最佳选择。
|
||||||
|
|
||||||
|
### 管理面板
|
||||||
|
|
||||||
|
无需手动编辑 `.env`,您可以在 Web 管理面板中管理大部分服务端设置(提供商密钥、模型行为、访问码、配额、功能开关):
|
||||||
|
|
||||||
|
1. 设置 `ADMIN_PASSWORD` 环境变量(不设置则面板禁用)。
|
||||||
|
2. 访问 `/admin` 并登录。
|
||||||
|
3. 保存的设置会写入 `data/settings.json` 并立即生效,无需重启(少数设置如 Langfuse 和 DynamoDB 标记为"需要重启")。
|
||||||
|
|
||||||
|
优先级:面板中保存的设置覆盖环境变量,环境变量覆盖内置默认值。删除已保存的值会回退到环境变量。
|
||||||
|
|
||||||
|
注意事项:
|
||||||
|
|
||||||
|
- 密钥以明文形式存储在 `data/settings.json` 中(文件权限 600),请妥善保管该文件。
|
||||||
|
- 在无服务器平台(Vercel、Cloudflare Workers)上没有持久化磁盘,面板为只读 — 请改用环境变量配置。
|
||||||
|
- 使用 Docker 时,`data/` 目录通过 `docker-compose.yml` 中的卷持久化。
|
||||||
|
- `NEXT_PUBLIC_*` 变量在构建时固化,无法在面板中修改。
|
||||||
|
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,23 @@ AWS BedrockとOpenRouter以外のすべてのプロバイダーはカスタム
|
|||||||
|
|
||||||
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
注:`claude`シリーズはAWS、Azure、GCPなどのクラウドアーキテクチャロゴ付きのdraw.ioダイアグラムで学習されているため、クラウドアーキテクチャダイアグラムを作成したい場合は最適な選択です。
|
||||||
|
|
||||||
|
### 管理パネル
|
||||||
|
|
||||||
|
`.env` を手動で編集する代わりに、Web 管理パネルでほとんどのサーバー設定(プロバイダーキー、モデル動作、アクセスコード、クォータ、機能)を管理できます:
|
||||||
|
|
||||||
|
1. `ADMIN_PASSWORD` 環境変数を設定します(未設定の場合、パネルは無効になります)。
|
||||||
|
2. `/admin` にアクセスしてサインインします。
|
||||||
|
3. 保存された設定は `data/settings.json` に書き込まれ、即座に反映されます — 再起動は不要です(Langfuse や DynamoDB など一部の設定は「再起動が必要」と表示されます)。
|
||||||
|
|
||||||
|
優先順位:パネルで保存された設定は環境変数を上書きし、環境変数は組み込みのデフォルト値を上書きします。保存した値を削除すると環境変数にフォールバックします。
|
||||||
|
|
||||||
|
注意事項:
|
||||||
|
|
||||||
|
- シークレットは `data/settings.json` に平文で保存されます(ファイルモード 600)。ファイルの管理にはご注意ください。
|
||||||
|
- サーバーレスプラットフォーム(Vercel、Cloudflare Workers)には永続ディスクがないため、パネルは読み取り専用です — 環境変数で設定してください。
|
||||||
|
- Docker では、`data/` ディレクトリは `docker-compose.yml` のボリュームで永続化されます。
|
||||||
|
- `NEXT_PUBLIC_*` 変数はビルド時に固定されるため、パネルでは変更できません。
|
||||||
|
|
||||||
|
|
||||||
## 仕組み
|
## 仕組み
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ AI_MODEL=global.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|||||||
# Access Control (Optional)
|
# Access Control (Optional)
|
||||||
# ACCESS_CODE_LIST=your-secret-code,another-code
|
# 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)
|
# Draw.io Configuration (Optional)
|
||||||
# NEXT_PUBLIC_DRAWIO_BASE_URL=https://embed.diagrams.net # Default: https://embed.diagrams.net
|
# 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
|
# Use this to point to a self-hosted draw.io instance
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
import { LangfuseSpanProcessor } from "@langfuse/otel"
|
||||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
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
|
// Skip telemetry if Langfuse env vars are not configured
|
||||||
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
if (!process.env.LANGFUSE_PUBLIC_KEY || !process.env.LANGFUSE_SECRET_KEY) {
|
||||||
console.warn(
|
console.warn(
|
||||||
|
|||||||
684
lib/admin/settings-registry.ts
Normal file
684
lib/admin/settings-registry.ts
Normal 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 (1024–64000).",
|
||||||
|
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
125
lib/admin/settings.ts
Normal 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]
|
||||||
|
}
|
||||||
124
tests/unit/admin-settings.test.ts
Normal file
124
tests/unit/admin-settings.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
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" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user