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:
parent
895b81ec6a
commit
7593526b1f
15 changed files with 78 additions and 26 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ export function ModelsPage() {
|
|||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onSaved={fetchModels}
|
||||
existingModelNames={models.map((model) => model.model_name)}
|
||||
/>
|
||||
|
||||
<DeleteModelDialog
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
16
web/frontend/src/components/secret-placeholder.ts
Normal file
16
web/frontend/src/components/secret-placeholder.ts
Normal 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}`
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@
|
|||
"modelIdPlaceholder": "例如 openai/gpt-4o",
|
||||
"modelIdHint": "格式:协议/模型ID。支持:openai、anthropic、gemini、groq 等。",
|
||||
"errorRequired": "此字段为必填项。",
|
||||
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
|
||||
"saveError": "添加模型失败",
|
||||
"confirm": "添加模型"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue