diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index e83b0b255..6a4544c65 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -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 { export async function setDefaultModel( modelName: string, ): Promise { - return request("/api/models/default", { + const response = await request("/api/models/default", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model_name: modelName }), }) + + void refreshGatewayState() + return response } export type { ModelsListResponse, ModelActionResponse } diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx index ec77beaa5..300175e20 100644 --- a/web/frontend/src/components/channels/channel-forms/discord-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -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" diff --git a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx index 2262b7ea5..a834a65f9 100644 --- a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx @@ -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" diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx index b120fa612..fc5a0a7fd 100644 --- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -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" diff --git a/web/frontend/src/components/channels/channel-forms/secret-placeholder.ts b/web/frontend/src/components/channels/channel-forms/secret-placeholder.ts deleted file mode 100644 index a2d716b3c..000000000 --- a/web/frontend/src/components/channels/channel-forms/secret-placeholder.ts +++ /dev/null @@ -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}` -} diff --git a/web/frontend/src/components/channels/channel-forms/slack-form.tsx b/web/frontend/src/components/channels/channel-forms/slack-form.tsx index b2ad7ce48..54650e842 100644 --- a/web/frontend/src/components/channels/channel-forms/slack-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -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" diff --git a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx index d01e7d3eb..169ddec63 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -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" diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index c61b59463..c760bc672 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -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(EMPTY_ADD_FORM) const [saving, setSaving] = useState(false) @@ -65,6 +72,10 @@ export function AddModelSheet({ open, onClose, onSaved }: AddModelSheetProps) { Partial> >({}) 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> = {} - 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) { setForm((f) => ({ ...f, apiKey: v }))} - placeholder={t("models.field.apiKeyPlaceholder")} + placeholder={apiKeyPlaceholder} /> diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index cd8d2a41c..4c77944a9 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -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 ( !v && onClose()}> @@ -150,11 +157,7 @@ export function EditModelSheet({ setForm((f) => ({ ...f, apiKey: v }))} - placeholder={ - model?.configured - ? t("models.field.apiKeyPlaceholderSet") - : t("models.field.apiKeyPlaceholder") - } + placeholder={apiKeyPlaceholder} /> )} diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index 839212d0f..6b207fab1 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -200,6 +200,7 @@ export function ModelsPage() { open={addOpen} onClose={() => setAddOpen(false)} onSaved={fetchModels} + existingModelNames={models.map((model) => model.model_name)} /> { diff --git a/web/frontend/src/components/secret-placeholder.ts b/web/frontend/src/components/secret-placeholder.ts new file mode 100644 index 000000000..c6167d78e --- /dev/null +++ b/web/frontend/src/components/secret-placeholder.ts @@ -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}` +} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 402a4ca85..117abd022 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -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" }, diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 6d6851321..d311eb114 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -178,6 +178,7 @@ "modelIdPlaceholder": "例如 openai/gpt-4o", "modelIdHint": "格式:协议/模型ID。支持:openai、anthropic、gemini、groq 等。", "errorRequired": "此字段为必填项。", + "errorDuplicateModelName": "模型别名已存在,请使用其他名称。", "saveError": "添加模型失败", "confirm": "添加模型" }, diff --git a/web/frontend/src/store/gateway.ts b/web/frontend/src/store/gateway.ts index ebe132746..89da9d7fd 100644 --- a/web/frontend/src/store/gateway.ts +++ b/web/frontend/src/store/gateway.ts @@ -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({ 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. + } +}