fix(frontend): improve model form validation and unify secret placeholder handling

- block duplicate model aliases when adding a model (with localized error messages)
- share masked secret placeholder logic across model and channel forms
- refresh gateway state after setting the default model
- apply minor UI cleanup to provider icon rendering
This commit is contained in:
wenjie 2026-03-09 16:23:34 +08:00
parent 895b81ec6a
commit 7593526b1f
15 changed files with 78 additions and 26 deletions

View file

@ -1,3 +1,5 @@
import { refreshGatewayState } from "@/store/gateway"
// API client for model list management.
export interface ModelInfo {
@ -76,11 +78,14 @@ export async function deleteModel(index: number): Promise<ModelActionResponse> {
export async function setDefaultModel(
modelName: string,
): Promise<ModelActionResponse> {
return request<ModelActionResponse>("/api/models/default", {
const response = await request<ModelActionResponse>("/api/models/default", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_name: modelName }),
})
void refreshGatewayState()
return response
}
export type { ModelsListResponse, ModelActionResponse }

View file

@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Input } from "@/components/ui/input"

View file

@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { Field, KeyInput } from "@/components/shared-form"
import { Input } from "@/components/ui/input"

View file

@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Input } from "@/components/ui/input"

View file

@ -1,10 +0,0 @@
export function maskedSecretPlaceholder(value: unknown, fallback = ""): string {
const secret = typeof value === "string" ? value.trim() : ""
if (!secret) {
return fallback
}
const prefix = secret.slice(0, Math.min(4, secret.length))
const suffix = secret.slice(-Math.min(3, secret.length))
return `${prefix}***${suffix}`
}

View file

@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { Field, KeyInput } from "@/components/shared-form"
import { Input } from "@/components/ui/input"

View file

@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next"
import type { ChannelConfig } from "@/api/channels"
import { maskedSecretPlaceholder } from "@/components/channels/channel-forms/secret-placeholder"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
import { Input } from "@/components/ui/input"

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { addModel, setDefaultModel } from "@/api/models"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import {
AdvancedSection,
Field,
@ -54,9 +55,15 @@ interface AddModelSheetProps {
open: boolean
onClose: () => void
onSaved: () => void
existingModelNames: string[]
}
export function AddModelSheet({ open, onClose, onSaved }: AddModelSheetProps) {
export function AddModelSheet({
open,
onClose,
onSaved,
existingModelNames,
}: AddModelSheetProps) {
const { t } = useTranslation()
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
const [saving, setSaving] = useState(false)
@ -65,6 +72,10 @@ export function AddModelSheet({ open, onClose, onSaved }: AddModelSheetProps) {
Partial<Record<keyof AddForm, string>>
>({})
const [serverError, setServerError] = useState("")
const apiKeyPlaceholder = maskedSecretPlaceholder(
form.apiKey,
t("models.field.apiKeyPlaceholder"),
)
useEffect(() => {
if (open) {
@ -77,7 +88,12 @@ export function AddModelSheet({ open, onClose, onSaved }: AddModelSheetProps) {
const validate = (): boolean => {
const errors: Partial<Record<keyof AddForm, string>> = {}
if (!form.modelName.trim()) errors.modelName = t("models.add.errorRequired")
const modelName = form.modelName.trim()
if (!modelName) {
errors.modelName = t("models.add.errorRequired")
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
errors.modelName = t("models.add.errorDuplicateModelName")
}
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
setFieldErrors(errors)
return Object.keys(errors).length === 0
@ -178,7 +194,7 @@ export function AddModelSheet({ open, onClose, onSaved }: AddModelSheetProps) {
<KeyInput
value={form.apiKey}
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
placeholder={t("models.field.apiKeyPlaceholder")}
placeholder={apiKeyPlaceholder}
/>
</Field>

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import {
AdvancedSection,
Field,
@ -122,6 +123,12 @@ export function EditModelSheet({
}
const isOAuth = model?.auth_method === "oauth"
const apiKeyPlaceholder = model?.configured
? maskedSecretPlaceholder(
model.api_key,
t("models.field.apiKeyPlaceholderSet"),
)
: t("models.field.apiKeyPlaceholder")
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
@ -150,11 +157,7 @@ export function EditModelSheet({
<KeyInput
value={form.apiKey}
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
placeholder={
model?.configured
? t("models.field.apiKeyPlaceholderSet")
: t("models.field.apiKeyPlaceholder")
}
placeholder={apiKeyPlaceholder}
/>
</Field>
)}

View file

@ -200,6 +200,7 @@ export function ModelsPage() {
open={addOpen}
onClose={() => setAddOpen(false)}
onSaved={fetchModels}
existingModelNames={models.map((model) => model.model_name)}
/>
<DeleteModelDialog

View file

@ -79,7 +79,7 @@ export function ProviderIcon({
<img
src={iconUrl}
alt={`${providerLabel} logo`}
className="size-full object-contain drop-shadow-[0_0_1px_rgba(0,0,0,0.45)]"
className="size-full object-contain"
loading="lazy"
referrerPolicy="no-referrer"
onError={() => {

View file

@ -0,0 +1,16 @@
export function maskedSecretPlaceholder(value: unknown, fallback = ""): string {
const secret = typeof value === "string" ? value.trim() : ""
if (!secret) {
return fallback
}
if (secret.length < 7) {
const first = secret[0]
const last = secret[secret.length - 1]
return `${first}***${last}`
}
const prefix = secret.slice(0, Math.min(3, secret.length))
const suffix = secret.slice(-Math.min(4, secret.length))
return `${prefix}***${suffix}`
}

View file

@ -178,6 +178,7 @@
"modelIdPlaceholder": "e.g. openai/gpt-4o",
"modelIdHint": "Format: protocol/model-id. Supported: openai, anthropic, gemini, groq, …",
"errorRequired": "This field is required.",
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
"saveError": "Failed to add model",
"confirm": "Add Model"
},

View file

@ -178,6 +178,7 @@
"modelIdPlaceholder": "例如 openai/gpt-4o",
"modelIdHint": "格式:协议/模型ID。支持openai、anthropic、gemini、groq 等。",
"errorRequired": "此字段为必填项。",
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
"saveError": "添加模型失败",
"confirm": "添加模型"
},

View file

@ -1,4 +1,6 @@
import { atom } from "jotai"
import { atom, getDefaultStore } from "jotai"
import { type GatewayStatusResponse, getGatewayStatus } from "@/api/gateway"
export type GatewayState =
| "running"
@ -17,3 +19,20 @@ export const gatewayAtom = atom<GatewayStoreState>({
status: "unknown",
canStart: true,
})
function applyGatewayStatusToStore(data: GatewayStatusResponse) {
getDefaultStore().set(gatewayAtom, (prev) => ({
...prev,
status: data.gateway_status ?? "unknown",
canStart: data.gateway_start_allowed ?? true,
}))
}
export async function refreshGatewayState() {
try {
const status = await getGatewayStatus()
applyGatewayStatusToStore(status)
} catch {
// Best-effort refresh only; keep current state on error.
}
}