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.
|
// API client for model list management.
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
|
|
@ -76,11 +78,14 @@ export async function deleteModel(index: number): Promise<ModelActionResponse> {
|
||||||
export async function setDefaultModel(
|
export async function setDefaultModel(
|
||||||
modelName: string,
|
modelName: string,
|
||||||
): Promise<ModelActionResponse> {
|
): Promise<ModelActionResponse> {
|
||||||
return request<ModelActionResponse>("/api/models/default", {
|
const response = await request<ModelActionResponse>("/api/models/default", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ model_name: modelName }),
|
body: JSON.stringify({ model_name: modelName }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
void refreshGatewayState()
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ModelsListResponse, ModelActionResponse }
|
export type { ModelsListResponse, ModelActionResponse }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
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 { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
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 { Field, KeyInput } from "@/components/shared-form"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
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 { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
import { Input } from "@/components/ui/input"
|
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 { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
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 { Field, KeyInput } from "@/components/shared-form"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { ChannelConfig } from "@/api/channels"
|
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 { Field, KeyInput, SwitchCardField } from "@/components/shared-form"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import { addModel, setDefaultModel } from "@/api/models"
|
import { addModel, setDefaultModel } from "@/api/models"
|
||||||
|
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||||
import {
|
import {
|
||||||
AdvancedSection,
|
AdvancedSection,
|
||||||
Field,
|
Field,
|
||||||
|
|
@ -54,9 +55,15 @@ interface AddModelSheetProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
|
existingModelNames: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddModelSheet({ open, onClose, onSaved }: AddModelSheetProps) {
|
export function AddModelSheet({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
existingModelNames,
|
||||||
|
}: AddModelSheetProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
|
const [form, setForm] = useState<AddForm>(EMPTY_ADD_FORM)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -65,6 +72,10 @@ export function AddModelSheet({ open, onClose, onSaved }: AddModelSheetProps) {
|
||||||
Partial<Record<keyof AddForm, string>>
|
Partial<Record<keyof AddForm, string>>
|
||||||
>({})
|
>({})
|
||||||
const [serverError, setServerError] = useState("")
|
const [serverError, setServerError] = useState("")
|
||||||
|
const apiKeyPlaceholder = maskedSecretPlaceholder(
|
||||||
|
form.apiKey,
|
||||||
|
t("models.field.apiKeyPlaceholder"),
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -77,7 +88,12 @@ export function AddModelSheet({ open, onClose, onSaved }: AddModelSheetProps) {
|
||||||
|
|
||||||
const validate = (): boolean => {
|
const validate = (): boolean => {
|
||||||
const errors: Partial<Record<keyof AddForm, string>> = {}
|
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")
|
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
|
||||||
setFieldErrors(errors)
|
setFieldErrors(errors)
|
||||||
return Object.keys(errors).length === 0
|
return Object.keys(errors).length === 0
|
||||||
|
|
@ -178,7 +194,7 @@ export function AddModelSheet({ open, onClose, onSaved }: AddModelSheetProps) {
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||||
placeholder={t("models.field.apiKeyPlaceholder")}
|
placeholder={apiKeyPlaceholder}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models"
|
import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models"
|
||||||
|
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||||
import {
|
import {
|
||||||
AdvancedSection,
|
AdvancedSection,
|
||||||
Field,
|
Field,
|
||||||
|
|
@ -122,6 +123,12 @@ export function EditModelSheet({
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOAuth = model?.auth_method === "oauth"
|
const isOAuth = model?.auth_method === "oauth"
|
||||||
|
const apiKeyPlaceholder = model?.configured
|
||||||
|
? maskedSecretPlaceholder(
|
||||||
|
model.api_key,
|
||||||
|
t("models.field.apiKeyPlaceholderSet"),
|
||||||
|
)
|
||||||
|
: t("models.field.apiKeyPlaceholder")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
|
@ -150,11 +157,7 @@ export function EditModelSheet({
|
||||||
<KeyInput
|
<KeyInput
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
|
||||||
placeholder={
|
placeholder={apiKeyPlaceholder}
|
||||||
model?.configured
|
|
||||||
? t("models.field.apiKeyPlaceholderSet")
|
|
||||||
: t("models.field.apiKeyPlaceholder")
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,7 @@ export function ModelsPage() {
|
||||||
open={addOpen}
|
open={addOpen}
|
||||||
onClose={() => setAddOpen(false)}
|
onClose={() => setAddOpen(false)}
|
||||||
onSaved={fetchModels}
|
onSaved={fetchModels}
|
||||||
|
existingModelNames={models.map((model) => model.model_name)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DeleteModelDialog
|
<DeleteModelDialog
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ export function ProviderIcon({
|
||||||
<img
|
<img
|
||||||
src={iconUrl}
|
src={iconUrl}
|
||||||
alt={`${providerLabel} logo`}
|
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"
|
loading="lazy"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
onError={() => {
|
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",
|
"modelIdPlaceholder": "e.g. openai/gpt-4o",
|
||||||
"modelIdHint": "Format: protocol/model-id. Supported: openai, anthropic, gemini, groq, …",
|
"modelIdHint": "Format: protocol/model-id. Supported: openai, anthropic, gemini, groq, …",
|
||||||
"errorRequired": "This field is required.",
|
"errorRequired": "This field is required.",
|
||||||
|
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
||||||
"saveError": "Failed to add model",
|
"saveError": "Failed to add model",
|
||||||
"confirm": "Add Model"
|
"confirm": "Add Model"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,7 @@
|
||||||
"modelIdPlaceholder": "例如 openai/gpt-4o",
|
"modelIdPlaceholder": "例如 openai/gpt-4o",
|
||||||
"modelIdHint": "格式:协议/模型ID。支持:openai、anthropic、gemini、groq 等。",
|
"modelIdHint": "格式:协议/模型ID。支持:openai、anthropic、gemini、groq 等。",
|
||||||
"errorRequired": "此字段为必填项。",
|
"errorRequired": "此字段为必填项。",
|
||||||
|
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
|
||||||
"saveError": "添加模型失败",
|
"saveError": "添加模型失败",
|
||||||
"confirm": "添加模型"
|
"confirm": "添加模型"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { atom } from "jotai"
|
import { atom, getDefaultStore } from "jotai"
|
||||||
|
|
||||||
|
import { type GatewayStatusResponse, getGatewayStatus } from "@/api/gateway"
|
||||||
|
|
||||||
export type GatewayState =
|
export type GatewayState =
|
||||||
| "running"
|
| "running"
|
||||||
|
|
@ -17,3 +19,20 @@ export const gatewayAtom = atom<GatewayStoreState>({
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
canStart: true,
|
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