From 3193d20e00569fc83541deacd625d2dbe58d06d9 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sat, 3 Oct 2026 17:45:29 +0900 Subject: [PATCH] fix(model-config): keep model selection valid and fix admin panel edge cases - Fall back to the default server model when a saved one disappears - Sync model config across tabs - Validation uses the base path and sends the access code - Model ids edited as drafts (no empty, duplicate or padded ids) - Credential changes reset validation; stale validation results are dropped - Admin: generateId over HTTP, env-locked group switches, discard and toggle fixes, clearing a secret field keeps the saved key, first provider not auto-default when .env sets AI_MODEL - Model selector items use unique values --- app/[lang]/admin/models-section.tsx | 16 ++- app/[lang]/admin/page.tsx | 63 ++++++++--- app/[lang]/admin/setting-field.tsx | 15 ++- components/model-config-dialog.tsx | 141 +++++++++++++++++-------- components/model-selector.tsx | 18 ++-- hooks/use-model-config.ts | 86 ++++++++++----- tests/unit/admin-secret-input.test.tsx | 87 +++++++++++++++ tests/unit/use-model-config.test.tsx | 133 +++++++++++++++++++++++ 8 files changed, 461 insertions(+), 98 deletions(-) create mode 100644 tests/unit/admin-secret-input.test.tsx create mode 100644 tests/unit/use-model-config.test.tsx diff --git a/app/[lang]/admin/models-section.tsx b/app/[lang]/admin/models-section.tsx index 4f293c70..b7a16d57 100644 --- a/app/[lang]/admin/models-section.tsx +++ b/app/[lang]/admin/models-section.tsx @@ -35,6 +35,7 @@ import { useDictionary } from "@/hooks/use-dictionary" import { formatMessage } from "@/lib/i18n/utils" import { FIXED_CRED_PROVIDERS, + generateId, PROVIDER_INFO, type ProviderName, SUGGESTED_MODELS, @@ -225,6 +226,7 @@ function ProviderDetail({ {suggestions.length > 0 && ( addProvider(v as ProviderName)} > diff --git a/app/[lang]/admin/page.tsx b/app/[lang]/admin/page.tsx index cbf5bba5..d66924d2 100644 --- a/app/[lang]/admin/page.tsx +++ b/app/[lang]/admin/page.tsx @@ -37,6 +37,19 @@ import { SettingField } from "./setting-field" const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)] +// For each toggleable group, whether any of its settings has a value (from +// the settings file or the environment) +function groupsWithValues(map: SettingsMap): Record { + const result: Record = {} + for (const group of SETTING_GROUPS) { + if (!group.toggleable) continue + result[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some( + (d) => map[d.key]?.source !== "default", + ) + } + return result +} + export default function AdminPage() { const dict = useDictionary() // Localized group title/description, keyed by group id @@ -62,6 +75,8 @@ export default function AdminPage() { // Models section state const [providers, setProviders] = useState([]) const [envProviders, setEnvProviders] = useState([]) + // Whether .env itself sets AI_MODEL (a default the panel would override) + const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false) const [savedProviders, setSavedProviders] = useState("[]") const providersDirty = JSON.stringify(providers) !== savedProviders @@ -88,15 +103,13 @@ export default function AdminPage() { 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 + // A group stays on while it still has values (e.g. from env vars + // that saving can't remove); a user's explicit "on" for a group + // with no values yet is kept across 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", - ) + const next = groupsWithValues(map) + for (const id of Object.keys(next)) { + next[id] = next[id] || !!prev[id] } return next }) @@ -108,10 +121,12 @@ export default function AdminPage() { (data: { providers: AdminProvider[] envProviders?: EnvProvider[] + envHasDefaultModel?: boolean }) => { setProviders(data.providers) setSavedProviders(JSON.stringify(data.providers)) setEnvProviders(data.envProviders ?? []) + setEnvHasDefaultModel(!!data.envHasDefaultModel) }, [], ) @@ -181,8 +196,9 @@ export default function AdminPage() { return () => observer.disconnect() }, [authedPassword]) + // value undefined drops the pending change (back to the saved value) const handleChange = useCallback( - (key: string, value: string | null) => { + (key: string, value: string | null | undefined) => { setSaveMessage(null) setErrors((prev) => { if (!(key in prev)) return prev @@ -201,7 +217,7 @@ export default function AdminPage() { value === "" && (!state || state.source !== "file") && !isSecretValue(state?.value) - if (isRevert || isNoop) { + if (value === undefined || isRevert || isNoop) { const next = { ...prev } delete next[key] return next @@ -225,9 +241,10 @@ export default function AdminPage() { 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 + // Stage deletion of saved values; drop unsaved input + if (settings[key]?.source === "default") + delete next[key] + else next[key] = null } else if (next[key] === null) { delete next[key] } @@ -447,6 +464,7 @@ export default function AdminPage() { { @@ -462,6 +480,11 @@ export default function AdminPage() { const defs = SETTINGS_BY_GROUP.get(group.id) ?? [] const groupOff = group.toggleable && !enabledGroups[group.id] + // Values from env vars can't be removed here, so the + // group can't be turned off from the panel + const envLocked = defs.some( + (d) => settings[d.key]?.source === "env", + ) const fieldsDisabled = !writable || saving || !!groupOff const gt = groupText(group.id) const title = gt?.title ?? group.title @@ -480,6 +503,11 @@ export default function AdminPage() { {group.toggleable && (