mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
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
This commit is contained in:
@@ -35,6 +35,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
|
|||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
import {
|
import {
|
||||||
FIXED_CRED_PROVIDERS,
|
FIXED_CRED_PROVIDERS,
|
||||||
|
generateId,
|
||||||
PROVIDER_INFO,
|
PROVIDER_INFO,
|
||||||
type ProviderName,
|
type ProviderName,
|
||||||
SUGGESTED_MODELS,
|
SUGGESTED_MODELS,
|
||||||
@@ -225,6 +226,7 @@ function ProviderDetail({
|
|||||||
</Button>
|
</Button>
|
||||||
{suggestions.length > 0 && (
|
{suggestions.length > 0 && (
|
||||||
<Select
|
<Select
|
||||||
|
value=""
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onValueChange={(v) => addModel(v)}
|
onValueChange={(v) => addModel(v)}
|
||||||
>
|
>
|
||||||
@@ -390,12 +392,14 @@ function ProviderDetail({
|
|||||||
export function ModelsSection({
|
export function ModelsSection({
|
||||||
providers,
|
providers,
|
||||||
envProviders,
|
envProviders,
|
||||||
|
envHasDefaultModel,
|
||||||
disabled,
|
disabled,
|
||||||
password,
|
password,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
providers: AdminProvider[]
|
providers: AdminProvider[]
|
||||||
envProviders: EnvProvider[]
|
envProviders: EnvProvider[]
|
||||||
|
envHasDefaultModel: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
password: string
|
password: string
|
||||||
onChange: (providers: AdminProvider[]) => void
|
onChange: (providers: AdminProvider[]) => void
|
||||||
@@ -409,10 +413,16 @@ export function ModelsSection({
|
|||||||
|
|
||||||
const addProvider = (provider: ProviderName) => {
|
const addProvider = (provider: ProviderName) => {
|
||||||
const newProvider: AdminProvider = {
|
const newProvider: AdminProvider = {
|
||||||
id: crypto.randomUUID(),
|
// generateId works over plain HTTP; crypto.randomUUID needs HTTPS
|
||||||
|
id: generateId(),
|
||||||
provider,
|
provider,
|
||||||
models: [],
|
models: [],
|
||||||
isDefault: providers.length === 0,
|
// Only the very first provider becomes the default, and only when
|
||||||
|
// the env config has no default that it would replace on save
|
||||||
|
isDefault:
|
||||||
|
providers.length === 0 &&
|
||||||
|
!envProviders.some((p) => p.isDefault) &&
|
||||||
|
!envHasDefaultModel,
|
||||||
}
|
}
|
||||||
onChange([...providers, newProvider])
|
onChange([...providers, newProvider])
|
||||||
setSelectedId(newProvider.id)
|
setSelectedId(newProvider.id)
|
||||||
@@ -496,7 +506,9 @@ export function ModelsSection({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t p-2">
|
<div className="border-t p-2">
|
||||||
|
{/* Always empty so picking the same type again still fires */}
|
||||||
<Select
|
<Select
|
||||||
|
value=""
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onValueChange={(v) => addProvider(v as ProviderName)}
|
onValueChange={(v) => addProvider(v as ProviderName)}
|
||||||
>
|
>
|
||||||
|
|||||||
+49
-14
@@ -37,6 +37,19 @@ import { SettingField } from "./setting-field"
|
|||||||
|
|
||||||
const NAV_GROUP_IDS = ["models", ...SETTING_GROUPS.map((g) => g.id)]
|
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<string, boolean> {
|
||||||
|
const result: Record<string, boolean> = {}
|
||||||
|
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() {
|
export default function AdminPage() {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
// Localized group title/description, keyed by group id
|
// Localized group title/description, keyed by group id
|
||||||
@@ -62,6 +75,8 @@ export default function AdminPage() {
|
|||||||
// Models section state
|
// Models section state
|
||||||
const [providers, setProviders] = useState<AdminProvider[]>([])
|
const [providers, setProviders] = useState<AdminProvider[]>([])
|
||||||
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
|
const [envProviders, setEnvProviders] = useState<EnvProvider[]>([])
|
||||||
|
// Whether .env itself sets AI_MODEL (a default the panel would override)
|
||||||
|
const [envHasDefaultModel, setEnvHasDefaultModel] = useState(false)
|
||||||
const [savedProviders, setSavedProviders] = useState<string>("[]")
|
const [savedProviders, setSavedProviders] = useState<string>("[]")
|
||||||
const providersDirty = JSON.stringify(providers) !== savedProviders
|
const providersDirty = JSON.stringify(providers) !== savedProviders
|
||||||
|
|
||||||
@@ -88,15 +103,13 @@ export default function AdminPage() {
|
|||||||
const map: SettingsMap = {}
|
const map: SettingsMap = {}
|
||||||
for (const s of data.settings) map[s.key] = s
|
for (const s of data.settings) map[s.key] = s
|
||||||
setSettings(map)
|
setSettings(map)
|
||||||
// Seed each toggle once from whether the group has configured
|
// A group stays on while it still has values (e.g. from env vars
|
||||||
// values; don't stomp a user's explicit toggle on later saves
|
// that saving can't remove); a user's explicit "on" for a group
|
||||||
|
// with no values yet is kept across saves
|
||||||
setEnabledGroups((prev) => {
|
setEnabledGroups((prev) => {
|
||||||
const next = { ...prev }
|
const next = groupsWithValues(map)
|
||||||
for (const group of SETTING_GROUPS) {
|
for (const id of Object.keys(next)) {
|
||||||
if (!group.toggleable || group.id in next) continue
|
next[id] = next[id] || !!prev[id]
|
||||||
next[group.id] = !!SETTINGS_BY_GROUP.get(group.id)?.some(
|
|
||||||
(d) => map[d.key]?.source !== "default",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
@@ -108,10 +121,12 @@ export default function AdminPage() {
|
|||||||
(data: {
|
(data: {
|
||||||
providers: AdminProvider[]
|
providers: AdminProvider[]
|
||||||
envProviders?: EnvProvider[]
|
envProviders?: EnvProvider[]
|
||||||
|
envHasDefaultModel?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
setProviders(data.providers)
|
setProviders(data.providers)
|
||||||
setSavedProviders(JSON.stringify(data.providers))
|
setSavedProviders(JSON.stringify(data.providers))
|
||||||
setEnvProviders(data.envProviders ?? [])
|
setEnvProviders(data.envProviders ?? [])
|
||||||
|
setEnvHasDefaultModel(!!data.envHasDefaultModel)
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -181,8 +196,9 @@ export default function AdminPage() {
|
|||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [authedPassword])
|
}, [authedPassword])
|
||||||
|
|
||||||
|
// value undefined drops the pending change (back to the saved value)
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(key: string, value: string | null) => {
|
(key: string, value: string | null | undefined) => {
|
||||||
setSaveMessage(null)
|
setSaveMessage(null)
|
||||||
setErrors((prev) => {
|
setErrors((prev) => {
|
||||||
if (!(key in prev)) return prev
|
if (!(key in prev)) return prev
|
||||||
@@ -201,7 +217,7 @@ export default function AdminPage() {
|
|||||||
value === "" &&
|
value === "" &&
|
||||||
(!state || state.source !== "file") &&
|
(!state || state.source !== "file") &&
|
||||||
!isSecretValue(state?.value)
|
!isSecretValue(state?.value)
|
||||||
if (isRevert || isNoop) {
|
if (value === undefined || isRevert || isNoop) {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
delete next[key]
|
delete next[key]
|
||||||
return next
|
return next
|
||||||
@@ -225,9 +241,10 @@ export default function AdminPage() {
|
|||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
// Stage deletion only for values currently set
|
// Stage deletion of saved values; drop unsaved input
|
||||||
if (settings[key]?.source !== "default")
|
if (settings[key]?.source === "default")
|
||||||
next[key] = null
|
delete next[key]
|
||||||
|
else next[key] = null
|
||||||
} else if (next[key] === null) {
|
} else if (next[key] === null) {
|
||||||
delete next[key]
|
delete next[key]
|
||||||
}
|
}
|
||||||
@@ -447,6 +464,7 @@ export default function AdminPage() {
|
|||||||
<ModelsSection
|
<ModelsSection
|
||||||
providers={providers}
|
providers={providers}
|
||||||
envProviders={envProviders}
|
envProviders={envProviders}
|
||||||
|
envHasDefaultModel={envHasDefaultModel}
|
||||||
disabled={!writable || saving}
|
disabled={!writable || saving}
|
||||||
password={authedPassword}
|
password={authedPassword}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
@@ -462,6 +480,11 @@ export default function AdminPage() {
|
|||||||
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
|
const defs = SETTINGS_BY_GROUP.get(group.id) ?? []
|
||||||
const groupOff =
|
const groupOff =
|
||||||
group.toggleable && !enabledGroups[group.id]
|
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 fieldsDisabled = !writable || saving || !!groupOff
|
||||||
const gt = groupText(group.id)
|
const gt = groupText(group.id)
|
||||||
const title = gt?.title ?? group.title
|
const title = gt?.title ?? group.title
|
||||||
@@ -480,6 +503,11 @@ export default function AdminPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
{group.toggleable && (
|
{group.toggleable && (
|
||||||
<label
|
<label
|
||||||
|
title={
|
||||||
|
envLocked
|
||||||
|
? dict.admin.sourceEnvTitle
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
|
"flex cursor-pointer items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors motion-reduce:transition-none",
|
||||||
enabledGroups[group.id]
|
enabledGroups[group.id]
|
||||||
@@ -494,7 +522,11 @@ export default function AdminPage() {
|
|||||||
checked={
|
checked={
|
||||||
!!enabledGroups[group.id]
|
!!enabledGroups[group.id]
|
||||||
}
|
}
|
||||||
disabled={!writable || saving}
|
disabled={
|
||||||
|
!writable ||
|
||||||
|
saving ||
|
||||||
|
envLocked
|
||||||
|
}
|
||||||
aria-label={formatMessage(
|
aria-label={formatMessage(
|
||||||
dict.admin.enableGroup,
|
dict.admin.enableGroup,
|
||||||
{ group: title },
|
{ group: title },
|
||||||
@@ -579,6 +611,9 @@ export default function AdminPage() {
|
|||||||
setPending({})
|
setPending({})
|
||||||
setErrors({})
|
setErrors({})
|
||||||
setProviders(JSON.parse(savedProviders))
|
setProviders(JSON.parse(savedProviders))
|
||||||
|
setEnabledGroups(
|
||||||
|
groupsWithValues(settings),
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{dict.admin.discard}
|
{dict.admin.discard}
|
||||||
|
|||||||
@@ -73,8 +73,10 @@ export function SecretInput({
|
|||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const [show, setShow] = useState(false)
|
const [show, setShow] = useState(false)
|
||||||
// The stored marker as it was at mount, to revert to on empty
|
// The stored marker to revert to on empty. Refreshed whenever the parent
|
||||||
const [original] = useState(value)
|
// passes server state (a marker or nothing), e.g. after a save.
|
||||||
|
const [original, setOriginal] = useState(value)
|
||||||
|
if (typeof value !== "string" && value !== original) setOriginal(value)
|
||||||
const hadStored = isSecretValue(original)
|
const hadStored = isSecretValue(original)
|
||||||
const text = typeof value === "string" ? value : ""
|
const text = typeof value === "string" ? value : ""
|
||||||
const placeholder = isSecretValue(value)
|
const placeholder = isSecretValue(value)
|
||||||
@@ -146,7 +148,8 @@ export function SettingField({
|
|||||||
pendingValue: string | null | undefined
|
pendingValue: string | null | undefined
|
||||||
error?: string
|
error?: string
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
onChange: (value: string | null) => void
|
// undefined drops the pending change (back to the saved value)
|
||||||
|
onChange: (value: string | null | undefined) => void
|
||||||
}) {
|
}) {
|
||||||
const dict = useDictionary()
|
const dict = useDictionary()
|
||||||
const isDirty = pendingValue !== undefined
|
const isDirty = pendingValue !== undefined
|
||||||
@@ -226,16 +229,18 @@ export function SettingField({
|
|||||||
case "secret":
|
case "secret":
|
||||||
control = (
|
control = (
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
|
{/* Clearing a saved secret reverts to it; the X button deletes */}
|
||||||
<SecretInput
|
<SecretInput
|
||||||
id={inputId}
|
id={inputId}
|
||||||
|
keepOnEmpty={source === "file"}
|
||||||
value={
|
value={
|
||||||
isDirty
|
isDirty
|
||||||
? (pendingValue ?? "")
|
? (pendingValue ?? "")
|
||||||
: (secretState ?? currentValue)
|
: (secretState ?? undefined)
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
onChange(typeof v === "string" ? v : "")
|
onChange(typeof v === "string" ? v : undefined)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { useDictionary } from "@/hooks/use-dictionary"
|
|||||||
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
|
import type { UseModelConfigReturn } from "@/hooks/use-model-config"
|
||||||
import { getApiEndpoint } from "@/lib/base-path"
|
import { getApiEndpoint } from "@/lib/base-path"
|
||||||
import { formatMessage } from "@/lib/i18n/utils"
|
import { formatMessage } from "@/lib/i18n/utils"
|
||||||
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
|
import type { ProviderConfig, ProviderName } from "@/lib/types/model-config"
|
||||||
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
import { PROVIDER_INFO, SUGGESTED_MODELS } from "@/lib/types/model-config"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -133,6 +134,14 @@ export function ModelConfigDialog({
|
|||||||
modelId: string
|
modelId: string
|
||||||
message: string
|
message: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
// Model ID being typed; written to the config only when valid on blur
|
||||||
|
const [modelIdDraft, setModelIdDraft] = useState<{
|
||||||
|
id: string
|
||||||
|
value: string
|
||||||
|
} | null>(null)
|
||||||
|
// Bumped on every credential edit so a running test can tell that its
|
||||||
|
// results belong to the old credentials
|
||||||
|
const credentialsVersionRef = useRef(0)
|
||||||
const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState<
|
const [dynamicSuggestedModels, setDynamicSuggestedModels] = useState<
|
||||||
Partial<Record<ProviderName, string[]>>
|
Partial<Record<ProviderName, string[]>>
|
||||||
>({})
|
>({})
|
||||||
@@ -157,6 +166,11 @@ export function ModelConfigDialog({
|
|||||||
(p) => p.id === selectedProviderId,
|
(p) => p.id === selectedProviderId,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Discard an unfinished model ID edit when the dialog closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setModelIdDraft(null)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
// Cleanup validation reset timeout on unmount
|
// Cleanup validation reset timeout on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -253,9 +267,9 @@ export function ModelConfigDialog({
|
|||||||
field: keyof ProviderConfig,
|
field: keyof ProviderConfig,
|
||||||
value: string | boolean,
|
value: string | boolean,
|
||||||
) => {
|
) => {
|
||||||
if (!selectedProviderId) return
|
if (!selectedProviderId || !selectedProvider) return
|
||||||
updateProvider(selectedProviderId, { [field]: value })
|
const updates: Partial<ProviderConfig> = { [field]: value }
|
||||||
// Reset validation when credentials change
|
// Reset validation of the provider and its models when credentials change
|
||||||
const credentialFields = [
|
const credentialFields = [
|
||||||
"apiKey",
|
"apiKey",
|
||||||
"baseUrl",
|
"baseUrl",
|
||||||
@@ -265,9 +279,17 @@ export function ModelConfigDialog({
|
|||||||
"vertexApiKey",
|
"vertexApiKey",
|
||||||
]
|
]
|
||||||
if (credentialFields.includes(field)) {
|
if (credentialFields.includes(field)) {
|
||||||
|
credentialsVersionRef.current++
|
||||||
setValidationStatus("idle")
|
setValidationStatus("idle")
|
||||||
updateProvider(selectedProviderId, { validated: false })
|
setValidatingModelIndex(null)
|
||||||
|
updates.validated = false
|
||||||
|
updates.models = selectedProvider.models.map((m) => ({
|
||||||
|
...m,
|
||||||
|
validated: undefined,
|
||||||
|
validationError: undefined,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
updateProvider(selectedProviderId, updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle adding a model to current provider
|
// Handle adding a model to current provider
|
||||||
@@ -337,6 +359,7 @@ export function ModelConfigDialog({
|
|||||||
|
|
||||||
let allValid = true
|
let allValid = true
|
||||||
let errorCount = 0
|
let errorCount = 0
|
||||||
|
const credentialsVersion = credentialsVersionRef.current
|
||||||
|
|
||||||
// Validate each model
|
// Validate each model
|
||||||
for (let i = 0; i < selectedProvider.models.length; i++) {
|
for (let i = 0; i < selectedProvider.models.length; i++) {
|
||||||
@@ -346,26 +369,37 @@ export function ModelConfigDialog({
|
|||||||
try {
|
try {
|
||||||
// For EdgeOne, construct baseUrl from current origin
|
// For EdgeOne, construct baseUrl from current origin
|
||||||
const baseUrl = isEdgeOne
|
const baseUrl = isEdgeOne
|
||||||
? `${window.location.origin}/api/edgeai`
|
? `${window.location.origin}${getApiEndpoint("/api/edgeai")}`
|
||||||
: selectedProvider.baseUrl
|
: selectedProvider.baseUrl
|
||||||
|
|
||||||
const response = await fetch("/api/validate-model", {
|
const response = await fetch(
|
||||||
method: "POST",
|
getApiEndpoint("/api/validate-model"),
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({
|
method: "POST",
|
||||||
provider: selectedProvider.provider,
|
headers: {
|
||||||
apiKey: selectedProvider.apiKey,
|
"Content-Type": "application/json",
|
||||||
baseUrl,
|
"x-access-code":
|
||||||
modelId: model.modelId,
|
localStorage.getItem(STORAGE_KEYS.accessCode) ||
|
||||||
// AWS Bedrock credentials
|
"",
|
||||||
awsAccessKeyId: selectedProvider.awsAccessKeyId,
|
},
|
||||||
awsSecretAccessKey: selectedProvider.awsSecretAccessKey,
|
body: JSON.stringify({
|
||||||
awsRegion: selectedProvider.awsRegion,
|
provider: selectedProvider.provider,
|
||||||
// Vertex AI credentials (Express Mode)
|
apiKey: selectedProvider.apiKey,
|
||||||
vertexApiKey: selectedProvider.vertexApiKey,
|
baseUrl,
|
||||||
}),
|
modelId: model.modelId,
|
||||||
})
|
// AWS Bedrock credentials
|
||||||
const data = await response.json()
|
awsAccessKeyId: selectedProvider.awsAccessKeyId,
|
||||||
|
awsSecretAccessKey:
|
||||||
|
selectedProvider.awsSecretAccessKey,
|
||||||
|
awsRegion: selectedProvider.awsRegion,
|
||||||
|
// Vertex AI credentials (Express Mode)
|
||||||
|
vertexApiKey: selectedProvider.vertexApiKey,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = await response.json().catch(() => ({}))
|
||||||
|
// Credentials changed during the test: drop the results
|
||||||
|
if (credentialsVersionRef.current !== credentialsVersion) return
|
||||||
|
|
||||||
if (data.valid) {
|
if (data.valid) {
|
||||||
updateModel(selectedProviderId, model.id, {
|
updateModel(selectedProviderId, model.id, {
|
||||||
@@ -377,10 +411,15 @@ export function ModelConfigDialog({
|
|||||||
errorCount++
|
errorCount++
|
||||||
updateModel(selectedProviderId, model.id, {
|
updateModel(selectedProviderId, model.id, {
|
||||||
validated: false,
|
validated: false,
|
||||||
validationError: data.error || "Validation failed",
|
validationError:
|
||||||
|
data.error ||
|
||||||
|
(response.ok
|
||||||
|
? "Validation failed"
|
||||||
|
: `Request failed (${response.status})`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if (credentialsVersionRef.current !== credentialsVersion) return
|
||||||
allValid = false
|
allValid = false
|
||||||
errorCount++
|
errorCount++
|
||||||
updateModel(selectedProviderId, model.id, {
|
updateModel(selectedProviderId, model.id, {
|
||||||
@@ -615,7 +654,9 @@ export function ModelConfigDialog({
|
|||||||
|
|
||||||
{/* Add Provider */}
|
{/* Add Provider */}
|
||||||
<div className="p-3 border-t border-border-subtle">
|
<div className="p-3 border-t border-border-subtle">
|
||||||
|
{/* Always empty so picking the same type again still fires */}
|
||||||
<Select
|
<Select
|
||||||
|
value=""
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
handleAddProvider(v as ProviderName)
|
handleAddProvider(v as ProviderName)
|
||||||
}
|
}
|
||||||
@@ -837,6 +878,7 @@ export function ModelConfigDialog({
|
|||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Select
|
<Select
|
||||||
|
value=""
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
handleAddModel(
|
handleAddModel(
|
||||||
@@ -989,7 +1031,10 @@ export function ModelConfigDialog({
|
|||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
value={
|
value={
|
||||||
model.modelId
|
modelIdDraft?.id ===
|
||||||
|
model.id
|
||||||
|
? modelIdDraft.value
|
||||||
|
: model.modelId
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
model.modelId
|
model.modelId
|
||||||
@@ -1007,24 +1052,14 @@ export function ModelConfigDialog({
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (
|
setModelIdDraft(
|
||||||
selectedProviderId
|
{
|
||||||
) {
|
id: model.id,
|
||||||
updateModel(
|
value: e
|
||||||
selectedProviderId,
|
.target
|
||||||
model.id,
|
.value,
|
||||||
{
|
},
|
||||||
modelId:
|
)
|
||||||
e
|
|
||||||
.target
|
|
||||||
.value,
|
|
||||||
validated:
|
|
||||||
undefined,
|
|
||||||
validationError:
|
|
||||||
undefined,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={(
|
onKeyDown={(
|
||||||
e,
|
e,
|
||||||
@@ -1041,6 +1076,10 @@ export function ModelConfigDialog({
|
|||||||
) => {
|
) => {
|
||||||
const newModelId =
|
const newModelId =
|
||||||
e.target.value.trim()
|
e.target.value.trim()
|
||||||
|
// Drop the draft; an invalid ID falls back to the saved one
|
||||||
|
setModelIdDraft(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
// Helper to show error with shake
|
// Helper to show error with shake
|
||||||
const showError =
|
const showError =
|
||||||
@@ -1135,6 +1174,24 @@ export function ModelConfigDialog({
|
|||||||
setEditError(
|
setEditError(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
selectedProviderId &&
|
||||||
|
newModelId !==
|
||||||
|
model.modelId
|
||||||
|
) {
|
||||||
|
updateModel(
|
||||||
|
selectedProviderId,
|
||||||
|
model.id,
|
||||||
|
{
|
||||||
|
modelId:
|
||||||
|
newModelId,
|
||||||
|
validated:
|
||||||
|
undefined,
|
||||||
|
validationError:
|
||||||
|
undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1"
|
className="flex-1 min-w-0 font-mono text-sm h-8 border-0 bg-transparent focus-visible:bg-background focus-visible:ring-1"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -264,9 +264,13 @@ export function ModelSelector({
|
|||||||
(model) => (
|
(model) => (
|
||||||
<ModelSelectorItem
|
<ModelSelectorItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
value={
|
// Unique value so same-named models highlight
|
||||||
model.modelId
|
// separately; keywords keep search by name
|
||||||
}
|
value={model.id}
|
||||||
|
keywords={[
|
||||||
|
model.modelId,
|
||||||
|
providerLabel,
|
||||||
|
]}
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleSelect(
|
handleSelect(
|
||||||
model.id,
|
model.id,
|
||||||
@@ -351,9 +355,11 @@ export function ModelSelector({
|
|||||||
(model) => (
|
(model) => (
|
||||||
<ModelSelectorItem
|
<ModelSelectorItem
|
||||||
key={model.id}
|
key={model.id}
|
||||||
value={
|
value={model.id}
|
||||||
model.modelId
|
keywords={[
|
||||||
}
|
model.modelId,
|
||||||
|
providerLabel,
|
||||||
|
]}
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleSelect(
|
handleSelect(
|
||||||
model.id,
|
model.id,
|
||||||
|
|||||||
+57
-29
@@ -101,6 +101,15 @@ function saveConfig(config: MultiModelConfig): void {
|
|||||||
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server model to fall back to: the one marked default, else the first one
|
||||||
|
*/
|
||||||
|
function defaultServerModelId(
|
||||||
|
serverModels: FlattenedServerModel[],
|
||||||
|
): string | undefined {
|
||||||
|
return (serverModels.find((m) => m.isDefault) ?? serverModels[0])?.id
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseModelConfigReturn {
|
export interface UseModelConfigReturn {
|
||||||
// State
|
// State
|
||||||
config: MultiModelConfig
|
config: MultiModelConfig
|
||||||
@@ -144,6 +153,16 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Pick up config changes saved by other tabs, so this tab neither shows a
|
||||||
|
// stale model nor overwrites their changes on its next save
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === STORAGE_KEYS.modelConfigs) setConfig(loadConfig())
|
||||||
|
}
|
||||||
|
window.addEventListener("storage", handleStorage)
|
||||||
|
return () => window.removeEventListener("storage", handleStorage)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Load server models on mount (if any)
|
// Load server models on mount (if any)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
@@ -165,17 +184,18 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
setServerModels(raw)
|
setServerModels(raw)
|
||||||
setServerLoaded(true)
|
setServerLoaded(true)
|
||||||
|
|
||||||
// Auto-select default server model if no model is currently selected
|
// Auto-select the default server model if no model is selected,
|
||||||
|
// or if the saved server model is gone (renamed or removed)
|
||||||
setConfig((prev) => {
|
setConfig((prev) => {
|
||||||
if (!prev.selectedModelId && raw.length > 0) {
|
const id = prev.selectedModelId
|
||||||
const defaultModel = raw.find((m) => m.isDefault)
|
const isStale =
|
||||||
if (defaultModel) {
|
id?.startsWith("server:") &&
|
||||||
return { ...prev, selectedModelId: defaultModel.id }
|
!raw.some((m) => m.id === id)
|
||||||
}
|
if (id && !isStale) return prev
|
||||||
// If no default marked, use first server model
|
const fallback = defaultServerModelId(raw)
|
||||||
return { ...prev, selectedModelId: raw[0].id }
|
return fallback === id
|
||||||
}
|
? prev
|
||||||
return prev
|
: { ...prev, selectedModelId: fallback }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -260,24 +280,31 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteProvider = useCallback((providerId: string) => {
|
const deleteProvider = useCallback(
|
||||||
setConfig((prev) => {
|
(providerId: string) => {
|
||||||
const provider = prev.providers.find((p) => p.id === providerId)
|
setConfig((prev) => {
|
||||||
const modelIds = provider?.models.map((m) => m.id) || []
|
const provider = prev.providers.find((p) => p.id === providerId)
|
||||||
|
const modelIds = provider?.models.map((m) => m.id) || []
|
||||||
|
|
||||||
// Clear selected model if it belongs to deleted provider
|
// Fall back to the default server model if the selected model
|
||||||
const newSelectedId =
|
// belongs to the deleted provider
|
||||||
prev.selectedModelId && modelIds.includes(prev.selectedModelId)
|
const newSelectedId =
|
||||||
? undefined
|
prev.selectedModelId &&
|
||||||
: prev.selectedModelId
|
modelIds.includes(prev.selectedModelId)
|
||||||
|
? defaultServerModelId(serverModels)
|
||||||
|
: prev.selectedModelId
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
providers: prev.providers.filter((p) => p.id !== providerId),
|
providers: prev.providers.filter(
|
||||||
selectedModelId: newSelectedId,
|
(p) => p.id !== providerId,
|
||||||
}
|
),
|
||||||
})
|
selectedModelId: newSelectedId,
|
||||||
}, [])
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[serverModels],
|
||||||
|
)
|
||||||
|
|
||||||
const addModel = useCallback(
|
const addModel = useCallback(
|
||||||
(providerId: string, modelId: string): ModelConfig => {
|
(providerId: string, modelId: string): ModelConfig => {
|
||||||
@@ -334,14 +361,15 @@ export function useModelConfig(): UseModelConfigReturn {
|
|||||||
}
|
}
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
// Clear selected model if it was deleted
|
// Fall back to the default server model if the selected model
|
||||||
|
// was deleted
|
||||||
selectedModelId:
|
selectedModelId:
|
||||||
prev.selectedModelId === modelConfigId
|
prev.selectedModelId === modelConfigId
|
||||||
? undefined
|
? defaultServerModelId(serverModels)
|
||||||
: prev.selectedModelId,
|
: prev.selectedModelId,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
[],
|
[serverModels],
|
||||||
)
|
)
|
||||||
|
|
||||||
const resetConfig = useCallback(() => {
|
const resetConfig = useCallback(() => {
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { SecretInput, SettingField } from "@/app/[lang]/admin/setting-field"
|
||||||
|
import { DictionaryProvider } from "@/hooks/use-dictionary"
|
||||||
|
import type { SettingDef } from "@/lib/admin/settings-registry"
|
||||||
|
import type { Dictionary } from "@/lib/i18n/dictionaries"
|
||||||
|
import en from "@/lib/i18n/dictionaries/en.json"
|
||||||
|
|
||||||
|
const STORED = { isSet: true as const, hint: "…abcd" }
|
||||||
|
|
||||||
|
function withDict(node: ReactNode) {
|
||||||
|
return (
|
||||||
|
<DictionaryProvider dictionary={en as unknown as Dictionary}>
|
||||||
|
{node}
|
||||||
|
</DictionaryProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeInto(label: string, text: string) {
|
||||||
|
fireEvent.change(screen.getByLabelText(label, { selector: "input" }), {
|
||||||
|
target: { value: text },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(cleanup)
|
||||||
|
|
||||||
|
describe("SecretInput", () => {
|
||||||
|
it("reverts to a key saved after mount instead of deleting it", () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const props = { id: "secret", keepOnEmpty: true, onChange }
|
||||||
|
// New provider: nothing stored at mount, then saved
|
||||||
|
const { rerender } = render(
|
||||||
|
withDict(
|
||||||
|
<>
|
||||||
|
<label htmlFor="secret">secret</label>
|
||||||
|
<SecretInput {...props} value={undefined} />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rerender(
|
||||||
|
withDict(
|
||||||
|
<>
|
||||||
|
<label htmlFor="secret">secret</label>
|
||||||
|
<SecretInput {...props} value={STORED} />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rerender(
|
||||||
|
withDict(
|
||||||
|
<>
|
||||||
|
<label htmlFor="secret">secret</label>
|
||||||
|
<SecretInput {...props} value="abc" />
|
||||||
|
</>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
typeInto("secret", "")
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(STORED)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("SettingField secret", () => {
|
||||||
|
const def: SettingDef = {
|
||||||
|
key: "LANGFUSE_SECRET_KEY",
|
||||||
|
group: "observability",
|
||||||
|
type: "secret",
|
||||||
|
label: "Langfuse Secret Key",
|
||||||
|
}
|
||||||
|
|
||||||
|
it("drops the pending change when a saved secret is typed over and cleared", () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const props = {
|
||||||
|
def,
|
||||||
|
state: { key: def.key, source: "file" as const, value: STORED },
|
||||||
|
disabled: false,
|
||||||
|
onChange,
|
||||||
|
}
|
||||||
|
const { rerender } = render(
|
||||||
|
withDict(<SettingField {...props} pendingValue={undefined} />),
|
||||||
|
)
|
||||||
|
rerender(withDict(<SettingField {...props} pendingValue="a" />))
|
||||||
|
|
||||||
|
typeInto(en.admin.settings.LANGFUSE_SECRET_KEY.label, "")
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { act, cleanup, renderHook, waitFor } from "@testing-library/react"
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { useModelConfig } from "@/hooks/use-model-config"
|
||||||
|
import type { FlattenedServerModel } from "@/lib/server-model-config"
|
||||||
|
import { STORAGE_KEYS } from "@/lib/storage"
|
||||||
|
import type { MultiModelConfig } from "@/lib/types/model-config"
|
||||||
|
|
||||||
|
const SERVER_MODELS: FlattenedServerModel[] = [
|
||||||
|
{
|
||||||
|
id: "server:openai-main:gpt-4o-mini",
|
||||||
|
modelId: "gpt-4o-mini",
|
||||||
|
provider: "openai",
|
||||||
|
providerLabel: "OpenAI Main",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "server:openai-main:gpt-4o",
|
||||||
|
modelId: "gpt-4o",
|
||||||
|
provider: "openai",
|
||||||
|
providerLabel: "OpenAI Main",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const USER_CONFIG: MultiModelConfig = {
|
||||||
|
version: 1,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
provider: "openai",
|
||||||
|
apiKey: "sk-test",
|
||||||
|
models: [{ id: "m1", modelId: "gpt-4o" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeConfig(config: MultiModelConfig) {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.modelConfigs, JSON.stringify(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderLoaded() {
|
||||||
|
const hook = renderHook(() => useModelConfig())
|
||||||
|
await waitFor(() => expect(hook.result.current.isLoaded).toBe(true))
|
||||||
|
return hook
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ models: SERVER_MODELS }),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("useModelConfig server model selection", () => {
|
||||||
|
it("replaces a saved server model that no longer exists", async () => {
|
||||||
|
storeConfig({
|
||||||
|
...USER_CONFIG,
|
||||||
|
selectedModelId: "server:openai-production:gpt-4o",
|
||||||
|
})
|
||||||
|
const { result } = await renderLoaded()
|
||||||
|
expect(result.current.selectedModelId).toBe("server:openai-main:gpt-4o")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps a saved server model that still exists", async () => {
|
||||||
|
storeConfig({
|
||||||
|
...USER_CONFIG,
|
||||||
|
selectedModelId: "server:openai-main:gpt-4o-mini",
|
||||||
|
})
|
||||||
|
const { result } = await renderLoaded()
|
||||||
|
expect(result.current.selectedModelId).toBe(
|
||||||
|
"server:openai-main:gpt-4o-mini",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps a selected user model", async () => {
|
||||||
|
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
|
||||||
|
const { result } = await renderLoaded()
|
||||||
|
expect(result.current.selectedModelId).toBe("m1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to the default server model when the selected model is deleted", async () => {
|
||||||
|
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
|
||||||
|
const { result } = await renderLoaded()
|
||||||
|
act(() => result.current.deleteModel("p1", "m1"))
|
||||||
|
expect(result.current.selectedModelId).toBe("server:openai-main:gpt-4o")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to the default server model when the selected provider is deleted", async () => {
|
||||||
|
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
|
||||||
|
const { result } = await renderLoaded()
|
||||||
|
act(() => result.current.deleteProvider("p1"))
|
||||||
|
expect(result.current.selectedModelId).toBe("server:openai-main:gpt-4o")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("useModelConfig across tabs", () => {
|
||||||
|
it("reloads the config when another tab saves it", async () => {
|
||||||
|
storeConfig({ ...USER_CONFIG, selectedModelId: "m1" })
|
||||||
|
const { result } = await renderLoaded()
|
||||||
|
|
||||||
|
const fromOtherTab: MultiModelConfig = {
|
||||||
|
...USER_CONFIG,
|
||||||
|
providers: [
|
||||||
|
...USER_CONFIG.providers,
|
||||||
|
{
|
||||||
|
id: "p2",
|
||||||
|
provider: "anthropic",
|
||||||
|
apiKey: "sk-ant",
|
||||||
|
models: [{ id: "m2", modelId: "claude-sonnet-4-5" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedModelId: "m2",
|
||||||
|
}
|
||||||
|
act(() => {
|
||||||
|
storeConfig(fromOtherTab)
|
||||||
|
window.dispatchEvent(
|
||||||
|
new StorageEvent("storage", { key: STORAGE_KEYS.modelConfigs }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.selectedModelId).toBe("m2")
|
||||||
|
expect(result.current.config.providers).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user