feat(ui): i18n for router fallbacks settings

This commit is contained in:
吹雪 2026-06-10 22:42:52 +09:00
parent 07ec61d295
commit 06e934a367
5 changed files with 133 additions and 47 deletions

View file

@ -6,6 +6,7 @@
import { Select, Tooltip } from "antd";
import { AlertCircle, ArrowDown, X } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
export interface FallbackGroup {
id: string;
@ -21,6 +22,7 @@ interface FallbackGroupConfigProps {
}
export function FallbackGroupConfig({ group, onChange, availableModels, maxFallbacks }: FallbackGroupConfigProps) {
const { t } = useTranslation();
// Filter available options for fallbacks (exclude primary only, allow already selected to be shown for deselection)
const availableFallbackOptions = availableModels.filter((m) => m !== group.primaryModel);
@ -62,12 +64,12 @@ export function FallbackGroupConfig({ group, onChange, availableModels, maxFallb
{/* Primary Model Section */}
<div className="relative">
<label className="block text-sm font-semibold text-gray-700 mb-2">
Primary Model <span className="text-red-500">*</span>
{t("settingsPages.fallbackGroupConfig.primaryModelLabel")} <span className="text-red-500">*</span>
</label>
<Select
className="w-full h-12"
size="large"
placeholder="Select primary model"
placeholder={t("settingsPages.fallbackGroupConfig.selectPrimaryModelPlaceholder")}
value={group.primaryModel}
onChange={handlePrimaryChange}
showSearch
@ -78,7 +80,7 @@ export function FallbackGroupConfig({ group, onChange, availableModels, maxFallb
{!group.primaryModel && (
<div className="mt-2 flex items-center gap-2 text-amber-600 text-xs bg-amber-50 p-2 rounded">
<AlertCircle className="w-4 h-4" />
<span>Select a model to begin configuring fallbacks</span>
<span>{t("settingsPages.fallbackGroupConfig.selectModelHint")}</span>
</div>
)}
</div>
@ -87,7 +89,7 @@ export function FallbackGroupConfig({ group, onChange, availableModels, maxFallb
<div className="flex items-center justify-center -my-4 z-10">
<div className="bg-indigo-50 text-indigo-500 px-4 py-1 rounded-full text-xs font-bold border border-indigo-100 flex items-center gap-2 shadow-sm">
<ArrowDown className="w-4 h-4" />
IF FAILS, TRY...
{t("settingsPages.fallbackGroupConfig.ifFailsTry")}
</div>
</div>
@ -96,8 +98,10 @@ export function FallbackGroupConfig({ group, onChange, availableModels, maxFallb
className={`transition-opacity duration-300 ${!group.primaryModel ? "opacity-50 pointer-events-none" : "opacity-100"}`}
>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Fallback Chain <span className="text-red-500">*</span>
<span className="text-xs text-gray-500 font-normal ml-2">(Max {maxFallbacks} fallbacks at a time)</span>
{t("settingsPages.fallbackGroupConfig.fallbackChainLabel")} <span className="text-red-500">*</span>
<span className="text-xs text-gray-500 font-normal ml-2">
{t("settingsPages.fallbackGroupConfig.maxFallbacksHint", { maxFallbacks })}
</span>
</label>
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
@ -108,7 +112,9 @@ export function FallbackGroupConfig({ group, onChange, availableModels, maxFallb
className="w-full"
size="large"
placeholder={
canAddMoreFallbacks ? "Select fallback models to add..." : `Maximum ${maxFallbacks} fallbacks reached`
canAddMoreFallbacks
? t("settingsPages.fallbackGroupConfig.selectFallbackPlaceholder")
: t("settingsPages.fallbackGroupConfig.maxFallbacksReached", { maxFallbacks })
}
value={group.fallbackModels}
onChange={handleFallbackSelect}
@ -146,8 +152,11 @@ export function FallbackGroupConfig({ group, onChange, availableModels, maxFallb
/>
<p className="text-xs text-gray-500 mt-1 ml-1">
{canAddMoreFallbacks
? `Search and select multiple models. Selected models will appear below in order. (${group.fallbackModels.length}/${maxFallbacks} used)`
: `Maximum ${maxFallbacks} fallbacks reached. Remove some to add more.`}
? t("settingsPages.fallbackGroupConfig.selectHint", {
used: group.fallbackModels.length,
max: maxFallbacks,
})
: t("settingsPages.fallbackGroupConfig.maxReachedHint", { maxFallbacks })}
</p>
</div>
@ -155,8 +164,8 @@ export function FallbackGroupConfig({ group, onChange, availableModels, maxFallb
<div className="space-y-2 min-h-[100px]">
{group.fallbackModels.length === 0 ? (
<div className="h-32 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center text-gray-400">
<span className="text-sm">No fallback models selected</span>
<span className="text-xs mt-1">Add models from the dropdown above</span>
<span className="text-sm">{t("settingsPages.fallbackGroupConfig.noFallbacksSelected")}</span>
<span className="text-xs mt-1">{t("settingsPages.fallbackGroupConfig.addFromDropdown")}</span>
</div>
) : (
group.fallbackModels.map((modelValue, index) => {

View file

@ -8,6 +8,7 @@ import { Button } from "@tremor/react";
import { Tabs } from "antd";
import { Plus } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import MessageManager from "@/components/molecules/message_manager";
import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig";
@ -26,6 +27,7 @@ export function FallbackSelectionForm({
maxFallbacks = 10,
maxGroups = 5,
}: FallbackSelectionFormProps) {
const { t } = useTranslation();
const [activeKey, setActiveKey] = useState(groups.length > 0 ? groups[0].id : "1");
// Reset activeKey when groups change (e.g., when modal reopens)
@ -61,7 +63,7 @@ export function FallbackSelectionForm({
const handleRemoveGroup = (targetId: string) => {
if (groups.length === 1) {
MessageManager.warning("At least one group is required");
MessageManager.warning(t("settingsPages.fallbackSelectionForm.atLeastOneGroup"));
return;
}
const newGroups = groups.filter((g) => g.id !== targetId);
@ -78,7 +80,9 @@ export function FallbackSelectionForm({
// Generate tab items
const items = groups.map((group, index) => {
const label = group.primaryModel ? group.primaryModel : `Group ${index + 1}`;
const label = group.primaryModel
? group.primaryModel
: t("settingsPages.fallbackSelectionForm.groupLabel", { number: index + 1 });
return {
key: group.id,
label: label,
@ -97,9 +101,9 @@ export function FallbackSelectionForm({
if (groups.length === 0) {
return (
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed border-gray-300">
<p className="text-gray-500 mb-4">No fallback groups configured</p>
<p className="text-gray-500 mb-4">{t("settingsPages.fallbackSelectionForm.noGroupsConfigured")}</p>
<Button variant="primary" onClick={handleAddGroup} icon={() => <Plus className="w-4 h-4" />}>
Create First Group
{t("settingsPages.fallbackSelectionForm.createFirstGroup")}
</Button>
</div>
);

View file

@ -4,6 +4,8 @@ import { Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow
import { Tooltip, Typography } from "antd";
import openai from "openai";
import React, { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { TFunction } from "i18next";
import DeleteResourceModal from "../../../common_components/DeleteResourceModal";
import { ProviderLogo } from "../../../molecules/models/ProviderLogo";
import NotificationsManager from "../../../molecules/notifications_manager";
@ -68,7 +70,7 @@ interface FallbacksProps {
modelData: any;
}
async function testFallbackModelResponse(selectedModel: string, accessToken: string) {
async function testFallbackModelResponse(selectedModel: string, accessToken: string, t: TFunction) {
const isLocal = process.env.NODE_ENV === "development";
if (isLocal != true) {
console.log = function () {};
@ -81,7 +83,7 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str
});
try {
NotificationsManager.info("Testing fallback model response...");
NotificationsManager.info(t("settingsPages.fallbacks.testingFallback"));
const response = await client.chat.completions.create({
model: selectedModel,
@ -96,26 +98,29 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str
});
NotificationsManager.success(
<span>
Test model=<strong>{selectedModel}</strong>, received model=
<strong>{response.model}</strong>. See{" "}
<a
href="#"
onClick={() => window.open("https://docs.litellm.ai/docs/proxy/reliability", "_blank")}
style={{ textDecoration: "underline", color: "blue" }}
>
curl
</a>
</span>,
<Trans
i18nKey="settingsPages.fallbacks.testSuccess"
values={{ selectedModel, responseModel: response.model }}
components={{
testModel: <strong />,
receivedModel: <strong />,
curlLink: (
<a
href="#"
onClick={() => window.open("https://docs.litellm.ai/docs/proxy/reliability", "_blank")}
style={{ textDecoration: "underline", color: "blue" }}
/>
),
}}
/>,
);
} catch (error) {
NotificationsManager.fromBackend(
`Error occurred while generating model response. Please try again. Error: ${error}`,
);
NotificationsManager.fromBackend(t("settingsPages.fallbacks.testError", { error }));
}
}
const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, modelData }) => {
const { t } = useTranslation();
const [routerSettings, setRouterSettings] = useState<{ [key: string]: any }>({});
const [isDeleting, setIsDeleting] = useState(false);
const [fallbackToDelete, setFallbackToDelete] = useState<FallbackEntry | null>(null);
@ -181,9 +186,9 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
try {
await setCallbacksCall(accessToken, payload);
setRouterSettings(updatedSettings);
NotificationsManager.success("Router settings updated successfully");
NotificationsManager.success(t("settingsPages.fallbacks.updateSuccess"));
} catch (error) {
NotificationsManager.fromBackend("Failed to update router settings: " + error);
NotificationsManager.fromBackend(t("settingsPages.fallbacks.updateFailed", { error }));
} finally {
setIsDeleting(false);
setIsDeleteModalOpen(false);
@ -220,7 +225,7 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
setRouterSettings(updatedSettings);
} catch (error) {
// Revert on error by refetching from server
NotificationsManager.fromBackend("Failed to update router settings: " + error);
NotificationsManager.fromBackend(t("settingsPages.fallbacks.updateFailed", { error }));
if (accessToken && userRole && userID) {
getCallbacksCall(accessToken, userID, userRole).then((data) => {
let router_settings = data.router_settings;
@ -251,17 +256,15 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
)}
{!hasFallbacks ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-6 text-center">
<Typography.Text type="secondary">
No fallbacks configured. Add fallbacks to automatically try another model when the primary fails.
</Typography.Text>
<Typography.Text type="secondary">{t("settingsPages.fallbacks.noFallbacksConfigured")}</Typography.Text>
</div>
) : (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Model Name</TableHeaderCell>
<TableHeaderCell>Fallbacks</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
<TableHeaderCell>{t("settingsPages.fallbacks.modelNameColumn")}</TableHeaderCell>
<TableHeaderCell>{t("settingsPages.fallbacks.fallbacksColumn")}</TableHeaderCell>
<TableHeaderCell>{t("common.actions")}</TableHeaderCell>
</TableRow>
</TableHead>
@ -276,15 +279,15 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
<TableCell className="align-top">
{canModify && (
<>
<Tooltip title="Test fallback">
<Tooltip title={t("settingsPages.fallbacks.testFallbackTooltip")}>
<Icon
icon={PlayIcon}
size="sm"
onClick={() => testFallbackModelResponse(Object.keys(item)[0], accessToken || "")}
onClick={() => testFallbackModelResponse(Object.keys(item)[0], accessToken || "", t)}
className="cursor-pointer hover:text-blue-600"
/>
</Tooltip>
<Tooltip title="Delete fallback">
<Tooltip title={t("settingsPages.fallbacks.deleteFallbackTooltip")}>
<span
data-testid="delete-fallback-button"
role="button"
@ -307,12 +310,12 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
)}
<DeleteResourceModal
isOpen={isDeleteModalOpen}
title="Delete Fallback?"
message="Are you sure you want to delete this fallback? This action cannot be undone."
resourceInformationTitle="Fallback Information"
title={t("settingsPages.fallbacks.deleteModalTitle")}
message={t("settingsPages.fallbacks.deleteModalMessage")}
resourceInformationTitle={t("settingsPages.fallbacks.deleteModalResourceTitle")}
resourceInformation={[
{
label: "Model Name",
label: t("settingsPages.fallbacks.modelNameColumn"),
value: fallbackToDelete ? Object.keys(fallbackToDelete)[0] : "",
code: true,
},

View file

@ -4450,6 +4450,41 @@
"addFallbacksModal": {
"title": "Configure Model Fallbacks",
"subtitle": "Manage multiple fallback chains for different models (up to 5 groups at a time)"
},
"fallbackGroupConfig": {
"primaryModelLabel": "Primary Model",
"selectPrimaryModelPlaceholder": "Select primary model",
"selectModelHint": "Select a model to begin configuring fallbacks",
"ifFailsTry": "IF FAILS, TRY...",
"fallbackChainLabel": "Fallback Chain",
"maxFallbacksHint": "(Max {{maxFallbacks}} fallbacks at a time)",
"selectFallbackPlaceholder": "Select fallback models to add...",
"maxFallbacksReached": "Maximum {{maxFallbacks}} fallbacks reached",
"selectHint": "Search and select multiple models. Selected models will appear below in order. ({{used}}/{{max}} used)",
"maxReachedHint": "Maximum {{maxFallbacks}} fallbacks reached. Remove some to add more.",
"noFallbacksSelected": "No fallback models selected",
"addFromDropdown": "Add models from the dropdown above"
},
"fallbackSelectionForm": {
"atLeastOneGroup": "At least one group is required",
"groupLabel": "Group {{number}}",
"noGroupsConfigured": "No fallback groups configured",
"createFirstGroup": "Create First Group"
},
"fallbacks": {
"testingFallback": "Testing fallback model response...",
"testSuccess": "Test model=<testModel>{{selectedModel}}</testModel>, received model=<receivedModel>{{responseModel}}</receivedModel>. See <curlLink>curl</curlLink>",
"testError": "Error occurred while generating model response. Please try again. Error: {{error}}",
"updateSuccess": "Router settings updated successfully",
"updateFailed": "Failed to update router settings: {{error}}",
"noFallbacksConfigured": "No fallbacks configured. Add fallbacks to automatically try another model when the primary fails.",
"modelNameColumn": "Model Name",
"fallbacksColumn": "Fallbacks",
"testFallbackTooltip": "Test fallback",
"deleteFallbackTooltip": "Delete fallback",
"deleteModalTitle": "Delete Fallback?",
"deleteModalMessage": "Are you sure you want to delete this fallback? This action cannot be undone.",
"deleteModalResourceTitle": "Fallback Information"
}
}
}

View file

@ -4450,6 +4450,41 @@
"addFallbacksModal": {
"title": "配置模型回退",
"subtitle": "管理不同模型的多个回退链(每次最多 5 组)"
},
"fallbackGroupConfig": {
"primaryModelLabel": "主模型",
"selectPrimaryModelPlaceholder": "选择主模型",
"selectModelHint": "选择一个模型以开始配置回退",
"ifFailsTry": "若失败,尝试...",
"fallbackChainLabel": "回退链",
"maxFallbacksHint": "(每次最多 {{maxFallbacks}} 个回退)",
"selectFallbackPlaceholder": "选择要添加的回退模型...",
"maxFallbacksReached": "已达到最大 {{maxFallbacks}} 个回退",
"selectHint": "搜索并选择多个模型,已选模型将按顺序显示在下方。(已用 {{used}}/{{max}})",
"maxReachedHint": "已达到最大 {{maxFallbacks}} 个回退,请先移除一些再添加。",
"noFallbacksSelected": "未选择回退模型",
"addFromDropdown": "从上方下拉菜单添加模型"
},
"fallbackSelectionForm": {
"atLeastOneGroup": "至少需要一个组",
"groupLabel": "组 {{number}}",
"noGroupsConfigured": "未配置回退组",
"createFirstGroup": "创建第一个组"
},
"fallbacks": {
"testingFallback": "正在测试回退模型响应...",
"testSuccess": "测试模型=<testModel>{{selectedModel}}</testModel>,收到模型=<receivedModel>{{responseModel}}</receivedModel>。查看 <curlLink>curl</curlLink>",
"testError": "生成模型响应时出错,请重试。错误:{{error}}",
"updateSuccess": "路由器设置更新成功",
"updateFailed": "更新路由器设置失败:{{error}}",
"noFallbacksConfigured": "未配置回退。添加回退可在主模型失败时自动尝试其他模型。",
"modelNameColumn": "模型名称",
"fallbacksColumn": "回退",
"testFallbackTooltip": "测试回退",
"deleteFallbackTooltip": "删除回退",
"deleteModalTitle": "删除回退?",
"deleteModalMessage": "确定要删除此回退吗?此操作无法撤销。",
"deleteModalResourceTitle": "回退信息"
}
}
}