feat(web): refine model availability states and preserve API key preview placeholder (#2226)

* feat(web): clarify model availability and status display

- Rename model availability field from configured to available across backend API and frontend usage

- Keep status as reason classification (configured/unconfigured/unreachable) and show unreachable in UI

- Preserve API key preview even when local service is unreachable

- Update backend tests to assert both availability and status semantics

* fix(web): clarify unreachable model status and wording

- Show unreachable status in model cards instead of API key preview when service is down

- Keep API key placeholder preview in model settings whenever an API key is already saved

- Rename model status wording from configured to available across backend, frontend, and i18n

- Update backend model status tests to match renamed status semantics

* style(web): standardize formatting in handleListModels function

* refactor(web): enforce status field as required to follow backend behavior
This commit is contained in:
LC
2026-03-31 22:52:04 +08:00
committed by GitHub
parent 2bf842e460
commit 3b3f95c44c
12 changed files with 161 additions and 69 deletions
@@ -133,9 +133,10 @@ export function EditModelSheet({
}
const isOAuth = model?.auth_method === "oauth"
const apiKeyPlaceholder = model?.configured
const hasSavedAPIKey = Boolean(model?.api_key)
const apiKeyPlaceholder = hasSavedAPIKey
? maskedSecretPlaceholder(
model.api_key,
model?.api_key ?? "",
t("models.field.apiKeyPlaceholderSet"),
)
: t("models.field.apiKeyPlaceholder")
@@ -161,7 +162,7 @@ export function EditModelSheet({
<Field
label={t("models.field.apiKey")}
hint={
model?.configured ? t("models.edit.apiKeyHint") : undefined
hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined
}
>
<KeyInput
@@ -28,14 +28,16 @@ export function ModelCard({
}: ModelCardProps) {
const { t } = useTranslation()
const isOAuth = model.auth_method === "oauth"
const status = model.status
const statusLabel = t(`models.status.${status}`)
const canSetDefault =
model.configured && !model.is_default && !model.is_virtual
model.available && !model.is_default && !model.is_virtual
return (
<div
className={[
"group/card hover:bg-muted/30 relative flex w-full max-w-[36rem] flex-col gap-3 justify-self-start rounded-xl border p-4 transition-colors hover:shadow-xs",
model.configured
model.available
? "border-border/60 bg-card"
: "border-border/50 bg-card/60",
].join(" ")}
@@ -47,15 +49,13 @@ export function ModelCard({
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
model.is_default
? "bg-green-400 shadow-[0_0_0_2px_rgba(74,222,128,0.35)]"
: model.configured
: status === "available"
? "bg-green-500"
: status === "unreachable"
? "bg-amber-500"
: "bg-muted-foreground/25",
].join(" ")}
title={
model.configured
? t("models.status.configured")
: t("models.status.unconfigured")
}
title={statusLabel}
/>
<span className="text-foreground truncate text-sm font-semibold">
{model.model_name}
@@ -127,14 +127,14 @@ export function ModelCard({
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium">
OAuth
</span>
) : model.configured && model.api_key ? (
) : status === "available" && model.api_key ? (
<span className="text-muted-foreground/70 flex items-center gap-1 font-mono text-[11px]">
<IconKey className="size-3" />
{model.api_key}
</span>
) : (
<span className="text-muted-foreground/50 text-[11px]">
{t("models.status.unconfigured")}
{statusLabel}
</span>
)}
</div>
@@ -40,7 +40,7 @@ interface ProviderGroup {
label: string
models: ModelInfo[]
hasDefault: boolean
configuredCount: number
availableCount: number
}
export function ModelsPage() {
@@ -62,8 +62,8 @@ export function ModelsPage() {
const sorted = [...data.models].sort((a, b) => {
if (a.is_default && !b.is_default) return -1
if (!a.is_default && b.is_default) return 1
if (a.configured && !b.configured) return -1
if (!a.configured && b.configured) return 1
if (a.available && !b.available) return -1
if (!a.available && b.available) return 1
return a.model_name.localeCompare(b.model_name)
})
setModels(sorted)
@@ -107,23 +107,23 @@ export function ModelsPage() {
const providerGroups: ProviderGroup[] = Object.entries(grouped)
.map(([key, group]) => {
const configuredCount = group.models.filter(
(model) => model.configured,
const availableCount = group.models.filter(
(model) => model.available,
).length
return {
key,
label: group.label,
models: group.models,
hasDefault: group.models.some((model) => model.is_default),
configuredCount,
availableCount,
}
})
.sort((a, b) => {
if (a.hasDefault && !b.hasDefault) return -1
if (!a.hasDefault && b.hasDefault) return 1
if (a.configuredCount !== b.configuredCount) {
return b.configuredCount - a.configuredCount
if (a.availableCount !== b.availableCount) {
return b.availableCount - a.availableCount
}
const aPriority = PROVIDER_PRIORITY[a.key] ?? Number.MAX_SAFE_INTEGER