feat(ui): i18n for molecules filter, model columns, and notifications manager

This commit is contained in:
吹雪 2026-06-10 11:43:41 +09:00
parent a851b90d4b
commit 019dfcbe23
5 changed files with 257 additions and 89 deletions

View file

@ -2,6 +2,7 @@ import { FilterIcon } from "@heroicons/react/outline";
import { Button, Input, Select } from "antd";
import debounce from "lodash/debounce";
import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
export interface FilterOptionCustomComponentProps {
value?: string;
@ -36,8 +37,9 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
onApplyFilters,
onResetFilters,
initialValues = {},
buttonLabel = "Filters",
buttonLabel,
}) => {
const { t } = useTranslation();
const [showFilters, setShowFilters] = useState<boolean>(false);
const [tempValues, setTempValues] = useState<FilterValues>(initialValues);
const [searchOptionsMap, setSearchOptionsMap] = useState<{
@ -137,9 +139,9 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
{buttonLabel}
{buttonLabel ?? t("molecules.filter.filters")}
</Button>
<Button onClick={resetFilters}>Reset Filters</Button>
<Button onClick={resetFilters}>{t("molecules.filter.resetFilters")}</Button>
</div>
{showFilters && (
@ -152,7 +154,7 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
<Select
showSearch
className="w-full"
placeholder={`Search ${option.label || option.name}...`}
placeholder={t("molecules.filter.searchPlaceholder", { label: option.label || option.name })}
value={tempValues[option.name] || undefined}
onChange={(value) => handleFilterChange(option.name, value)}
onOpenChange={(open) => handleDropdownVisibleChange(open, option)}
@ -169,12 +171,12 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
loading={searchLoadingMap[option.name]}
options={searchOptionsMap[option.name] || []}
allowClear
notFoundContent={searchLoadingMap[option.name] ? "Loading..." : "No results found"}
notFoundContent={searchLoadingMap[option.name] ? t("common.loading") : t("common.noResults")}
/>
) : option.options ? (
<Select
className="w-full"
placeholder={`Select ${option.label || option.name}...`}
placeholder={t("molecules.filter.selectPlaceholder", { label: option.label || option.name })}
value={tempValues[option.name] || undefined}
onChange={(value) => handleFilterChange(option.name, value)}
allowClear
@ -192,7 +194,7 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
<CustomComponent
value={tempValues[option.name] || undefined}
onChange={(value) => handleFilterChange(option.name, value ?? "")}
placeholder={`Select ${option.label || option.name}...`}
placeholder={t("molecules.filter.selectPlaceholder", { label: option.label || option.name })}
allFilters={tempValues}
/>
);
@ -200,7 +202,7 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
) : (
<Input
className="w-full"
placeholder={`Enter ${option.label || option.name}...`}
placeholder={t("molecules.filter.enterPlaceholder", { label: option.label || option.name })}
value={tempValues[option.name] || ""}
onChange={(e) => handleFilterChange(option.name, e.target.value)}
allowClear

View file

@ -5,13 +5,14 @@ import { Badge, Button, Icon } from "@tremor/react";
import { Divider, Flex, Popover, Space, Switch, Tooltip, Typography } from "antd";
import { ModelData } from "../../model_dashboard/types";
import { ProviderLogo } from "./ProviderLogo";
import i18n from "@/lib/i18n";
const { Text, Title } = Typography;
const credentialsInfoPopoverContent = (
const getCredentialsInfoPopoverContent = () => (
<Space direction="vertical" size={12}>
<Text strong style={{ fontSize: 13 }}>
Credential types
{i18n.t("molecules.modelsColumns.credentialTypes")}
</Text>
<Space direction="vertical" size={8}>
<Flex align="center" gap={8}>
@ -19,10 +20,10 @@ const credentialsInfoPopoverContent = (
<Flex align="center" gap={8}>
<SyncOutlined style={{ color: "#1890ff" }} />
<Title level={5} style={{ margin: 0, color: "#1890ff" }}>
Reusable
{i18n.t("molecules.modelsColumns.reusable")}
</Title>
</Flex>
<Text type="secondary">Credentials saved in LiteLLM that can be added to models repeatedly.</Text>
<Text type="secondary">{i18n.t("molecules.modelsColumns.reusableDescription")}</Text>
</Space>
</Flex>
<Divider size="small" />
@ -31,10 +32,10 @@ const credentialsInfoPopoverContent = (
<Flex align="center" gap={8}>
<EditOutlined style={{ color: "#8c8c8c", fontSize: 14, flexShrink: 0 }} />
<Title level={5} style={{ margin: 0 }}>
Manual
{i18n.t("molecules.modelsColumns.manual")}
</Title>
</Flex>
<Text type="secondary">Credentials added directly during model creation or defined in the config file.</Text>
<Text type="secondary">{i18n.t("molecules.modelsColumns.manualDescription")}</Text>
</Space>
</Flex>
</Space>
@ -57,7 +58,7 @@ export const columns = (
pausingModelId?: string | null,
): ColumnDef<ModelData>[] => [
{
header: () => <span className="text-sm font-semibold">Model ID</span>,
header: () => <span className="text-sm font-semibold">{i18n.t("molecules.modelsColumns.modelId")}</span>,
accessorKey: "model_info.id",
enableSorting: false,
size: 130,
@ -82,7 +83,7 @@ export const columns = (
},
},
{
header: () => <span className="text-sm font-semibold">Model Information</span>,
header: () => <span className="text-sm font-semibold">{i18n.t("molecules.modelsColumns.modelInformation")}</span>,
accessorKey: "model_name",
size: 250,
minSize: 120,
@ -94,14 +95,14 @@ export const columns = (
<Flex align="center" gap={8}>
<ProviderLogo provider={model.provider} />
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{model.provider || "Unknown provider"}
{model.provider || i18n.t("molecules.modelsColumns.unknownProvider")}
</Text>
</Flex>
<Space direction="vertical" size={6}>
<Space direction="vertical" size={2} style={{ width: "100%" }}>
<Text type="secondary" style={{ fontSize: 11 }}>
Public Model Name
{i18n.t("molecules.modelsColumns.publicModelName")}
</Text>
<Text strong style={{ fontSize: 13, maxWidth: 480 }} ellipsis title={displayName}>
{displayName}
@ -110,7 +111,7 @@ export const columns = (
<Space direction="vertical" size={2}>
<Text type="secondary" style={{ fontSize: 11 }}>
LiteLLM Model Name
{i18n.t("molecules.modelsColumns.litellmModelName")}
</Text>
<Text
style={{ fontSize: 13 }}
@ -161,8 +162,8 @@ export const columns = (
{
header: () => (
<span className="flex items-center gap-1">
<span className="text-sm font-semibold">Credentials</span>
<Popover content={credentialsInfoPopoverContent} placement="bottom" arrow={{ pointAtCenter: true }}>
<span className="text-sm font-semibold">{i18n.t("molecules.modelsColumns.credentials")}</span>
<Popover content={getCredentialsInfoPopoverContent()} placement="bottom" arrow={{ pointAtCenter: true }}>
<InfoCircleOutlined className="cursor-pointer text-gray-400 hover:text-gray-600" style={{ fontSize: 12 }} />
</Popover>
</span>
@ -188,7 +189,7 @@ export const columns = (
) : (
<>
<EditOutlined className="flex-shrink-0" style={{ color: "#8c8c8c", fontSize: 14 }} />
<span className="text-xs text-gray-500">Manual</span>
<span className="text-xs text-gray-500">{i18n.t("molecules.modelsColumns.manual")}</span>
</>
)}
</div>
@ -196,7 +197,7 @@ export const columns = (
},
},
{
header: () => <span className="text-sm font-semibold">Created By</span>,
header: () => <span className="text-sm font-semibold">{i18n.t("molecules.modelsColumns.createdBy")}</span>,
accessorKey: "model_info.created_by",
sortingFn: "datetime",
size: 160,
@ -212,23 +213,25 @@ export const columns = (
{/* Created By - Primary */}
<div
className="text-xs font-medium text-gray-900 truncate"
title={isConfigModel ? "Defined in config" : createdBy || "Unknown"}
title={
isConfigModel ? i18n.t("molecules.modelsColumns.definedInConfig") : createdBy || i18n.t("common.unknown")
}
>
{isConfigModel ? "Defined in config" : createdBy || "Unknown"}
{isConfigModel ? i18n.t("molecules.modelsColumns.definedInConfig") : createdBy || i18n.t("common.unknown")}
</div>
{/* Created At - Secondary */}
<div
className="text-xs text-gray-500 truncate mt-0.5"
title={isConfigModel ? "Config file" : createdAt || "Unknown date"}
title={isConfigModel ? "-" : createdAt || i18n.t("molecules.modelsColumns.unknownDate")}
>
{isConfigModel ? "-" : createdAt || "Unknown date"}
{isConfigModel ? "-" : createdAt || i18n.t("molecules.modelsColumns.unknownDate")}
</div>
</div>
);
},
},
{
header: () => <span className="text-sm font-semibold">Updated At</span>,
header: () => <span className="text-sm font-semibold">{i18n.t("common.updatedAt")}</span>,
accessorKey: "model_info.updated_at",
sortingFn: "datetime",
size: 120,
@ -243,7 +246,7 @@ export const columns = (
},
},
{
header: () => <span className="text-sm font-semibold">Costs</span>,
header: () => <span className="text-sm font-semibold">{i18n.t("molecules.modelsColumns.costs")}</span>,
accessorKey: "input_cost",
size: 120,
minSize: 80,
@ -262,19 +265,27 @@ export const columns = (
}
return (
<Tooltip title="Cost per 1M tokens">
<Tooltip title={i18n.t("molecules.modelsColumns.costPerMTokens")}>
<div className="flex flex-col min-w-0 w-full">
{/* Input Cost - Primary */}
{inputCost != null && <div className="text-xs font-medium text-gray-900 truncate">In: ${inputCost}</div>}
{inputCost != null && (
<div className="text-xs font-medium text-gray-900 truncate">
{i18n.t("molecules.modelsColumns.inputCost", { cost: inputCost })}
</div>
)}
{/* Output Cost - Secondary */}
{outputCost != null && <div className="text-xs text-gray-500 truncate mt-0.5">Out: ${outputCost}</div>}
{outputCost != null && (
<div className="text-xs text-gray-500 truncate mt-0.5">
{i18n.t("molecules.modelsColumns.outputCost", { cost: outputCost })}
</div>
)}
</div>
</Tooltip>
);
},
},
{
header: () => <span className="text-sm font-semibold">Team ID</span>,
header: () => <span className="text-sm font-semibold">{i18n.t("molecules.modelsColumns.teamId")}</span>,
accessorKey: "model_info.team_id",
enableSorting: false,
size: 130,
@ -303,7 +314,7 @@ export const columns = (
},
},
{
header: () => <span className="text-sm font-semibold">Model Access Group</span>,
header: () => <span className="text-sm font-semibold">{i18n.t("molecules.modelsColumns.modelAccessGroup")}</span>,
accessorKey: "model_info.model_access_group",
enableSorting: false,
size: 180,
@ -364,7 +375,7 @@ export const columns = (
},
},
{
header: () => <span className="text-sm font-semibold">Status</span>,
header: () => <span className="text-sm font-semibold">{i18n.t("common.status")}</span>,
accessorKey: "model_info.db_model",
size: 120,
minSize: 80,
@ -377,14 +388,16 @@ export const columns = (
${model.model_info.db_model ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-600"}
`}
>
{model.model_info.db_model ? "DB Model" : "Config Model"}
{model.model_info.db_model
? i18n.t("molecules.modelsColumns.dbModel")
: i18n.t("molecules.modelsColumns.configModel")}
</div>
);
},
},
{
id: "actions",
header: () => <span className="text-sm font-semibold">Actions</span>,
header: () => <span className="text-sm font-semibold">{i18n.t("common.actions")}</span>,
size: 100,
minSize: 80,
enableResizing: false,
@ -396,12 +409,12 @@ export const columns = (
const isBlocked = model.model_info?.blocked === true;
const isPauseToggleable = !isConfigModel && isAdmin && Boolean(onTogglePauseClick);
const pauseTooltip = isConfigModel
? "Config models cannot be paused from the dashboard. Pause is DB-backed."
? i18n.t("molecules.modelsColumns.configNoPause")
: !isAdmin
? "Only proxy admins can pause or resume a model."
? i18n.t("molecules.modelsColumns.nonAdminNoPause")
: isBlocked
? "Resume model — restore normal routing."
: "Pause model — stop routing requests until resumed.";
? i18n.t("molecules.modelsColumns.resumeTooltip")
: i18n.t("molecules.modelsColumns.pauseTooltip");
// antd's `loading` prop on Switch is purely cosmetic — it does not block
// clicks. Pair `loading` with `disabled` derived from the same condition
// so a double-click during a pending PATCH cannot send a second,
@ -415,7 +428,9 @@ export const columns = (
checked={!isBlocked}
disabled={!isPauseToggleable || isPausing}
loading={isPausing}
aria-label={isBlocked ? "Resume model" : "Pause model"}
aria-label={
isBlocked ? i18n.t("molecules.modelsColumns.resumeModel") : i18n.t("molecules.modelsColumns.pauseModel")
}
onClick={(_, e) => {
e.stopPropagation();
}}
@ -428,11 +443,11 @@ export const columns = (
/>
</Tooltip>
{isConfigModel ? (
<Tooltip title="Config model cannot be deleted on the dashboard. Please delete it from the config file.">
<Tooltip title={i18n.t("molecules.modelsColumns.configNoDelete")}>
<Icon icon={TrashIcon} size="sm" className="opacity-50 cursor-not-allowed" />
</Tooltip>
) : (
<Tooltip title="Delete model">
<Tooltip title={i18n.t("molecules.modelsColumns.deleteModel")}>
<Icon
icon={TrashIcon}
size="sm"

View file

@ -3,6 +3,7 @@ import { notification as staticNotification } from "antd";
import type { NotificationInstance } from "antd/es/notification/interface";
import { parseErrorMessage } from "../shared/errorUtils";
import { ArgsProps } from "antd/es/notification";
import i18n from "@/lib/i18n";
let notificationInstance: NotificationInstance | null = null;
@ -146,24 +147,42 @@ const CLOUDZERO_MATCH = [
"cloudzero settings not found",
];
function titleFor(status?: number, desc?: string): string {
type ErrorCategory =
| "authenticationError"
| "accessDenied"
| "serviceUnavailable"
| "budgetExceeded"
| "featureUnavailable"
| "routingError"
| "alreadyExists"
| "contentBlocked"
| "validationError"
| "integrationError"
| "notFound"
| "rateLimitExceeded"
| "serverError"
| "requestError"
| "info"
| "error";
function categoryFor(status?: number, desc?: string): ErrorCategory {
const d = (desc || "").toLowerCase();
if (AUTH_MATCH.some((s) => d.includes(s))) return "Authentication Error";
if (FORBIDDEN_MATCH.some((s) => d.includes(s))) return "Access Denied";
if (DB_MATCH?.some?.((s: string) => d.includes(s)) || status === 503) return "Service Unavailable";
if (BUDGET_MATCH?.some?.((s: string) => d.includes(s))) return "Budget Exceeded";
if (ENTERPRISE_MATCH?.some?.((s: string) => d.includes(s))) return "Feature Unavailable";
if (ROUTER_MATCH?.some?.((s: string) => d.includes(s))) return "Routing Error";
if (AUTH_MATCH.some((s) => d.includes(s))) return "authenticationError";
if (FORBIDDEN_MATCH.some((s) => d.includes(s))) return "accessDenied";
if (DB_MATCH?.some?.((s: string) => d.includes(s)) || status === 503) return "serviceUnavailable";
if (BUDGET_MATCH?.some?.((s: string) => d.includes(s))) return "budgetExceeded";
if (ENTERPRISE_MATCH?.some?.((s: string) => d.includes(s))) return "featureUnavailable";
if (ROUTER_MATCH?.some?.((s: string) => d.includes(s))) return "routingError";
if (EXISTS_MATCH.some((s) => d.includes(s))) return "Already Exists";
if (GUARDRAIL_MATCH.some((s) => d.includes(s))) return "Content Blocked";
if (EXISTS_MATCH.some((s) => d.includes(s))) return "alreadyExists";
if (GUARDRAIL_MATCH.some((s) => d.includes(s))) return "contentBlocked";
if (FILE_UPLOAD_MATCH.some((s) => d.includes(s))) return "Validation Error";
if (CLOUDZERO_MATCH.some((s) => d.includes(s))) return "Integration Error";
if (FILE_UPLOAD_MATCH.some((s) => d.includes(s))) return "validationError";
if (CLOUDZERO_MATCH.some((s) => d.includes(s))) return "integrationError";
if (VALIDATION_MATCH.some((s) => d.includes(s))) return "Validation Error";
if (status === 404 || d.includes("not found") || NOT_FOUND_MATCH.some((s) => d.includes(s))) return "Not Found";
if (VALIDATION_MATCH.some((s) => d.includes(s))) return "validationError";
if (status === 404 || d.includes("not found") || NOT_FOUND_MATCH.some((s) => d.includes(s))) return "notFound";
if (
status === 429 ||
d.includes("rate limit") ||
@ -171,13 +190,13 @@ function titleFor(status?: number, desc?: string): string {
d.includes("rpm") ||
RATE_LIMIT_EXTRA?.some?.((s: string) => d.includes(s))
)
return "Rate Limit Exceeded";
if (status && status >= 500) return "Server Error";
if (status === 401) return "Authentication Error";
if (status === 403) return "Access Denied";
if (d.includes("enterprise") || d.includes("premium")) return "Info";
if (status && status >= 400) return "Request Error";
return "Error";
return "rateLimitExceeded";
if (status && status >= 500) return "serverError";
if (status === 401) return "authenticationError";
if (status === 403) return "accessDenied";
if (d.includes("enterprise") || d.includes("premium")) return "info";
if (status && status >= 400) return "requestError";
return "error";
}
const SUCCESS_MATCH = [
@ -221,10 +240,14 @@ const CONFIG_WARN_MATCH = [
function classifyGeneralMessage(desc?: string): { kind: "success" | "info" | "warning"; title: string } | null {
const d = (desc || "").toLowerCase();
if (SUCCESS_MATCH.some((s) => d.includes(s))) return { kind: "success", title: "Success" };
if (DEPRECATION_FEATURE_WARN_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Feature Notice" };
if (CONFIG_WARN_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Configuration Warning" };
if (INFO_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Rate Limit" }; // show as warning for visibility
if (SUCCESS_MATCH.some((s) => d.includes(s)))
return { kind: "success", title: i18n.t("molecules.notificationsManager.success") };
if (DEPRECATION_FEATURE_WARN_MATCH.some((s) => d.includes(s)))
return { kind: "warning", title: i18n.t("molecules.notificationsManager.featureNotice") };
if (CONFIG_WARN_MATCH.some((s) => d.includes(s)))
return { kind: "warning", title: i18n.t("molecules.notificationsManager.configurationWarning") };
if (INFO_MATCH.some((s) => d.includes(s)))
return { kind: "warning", title: i18n.t("molecules.notificationsManager.rateLimit") };
return null;
}
@ -260,7 +283,7 @@ function looksErrorPayload(input: any, status?: number): boolean {
const NotificationManager = {
error(input: string | NotificationConfig) {
const cfg = normalize(input, "Error");
const cfg = normalize(input, i18n.t("molecules.notificationsManager.error"));
getNotification().error({
...COMMON_NOTIFICATION_PROPS,
...cfg,
@ -270,7 +293,7 @@ const NotificationManager = {
},
warning(input: string | NotificationConfig) {
const cfg = normalize(input, "Warning");
const cfg = normalize(input, i18n.t("molecules.notificationsManager.warning"));
getNotification().warning({
...COMMON_NOTIFICATION_PROPS,
...cfg,
@ -280,7 +303,7 @@ const NotificationManager = {
},
info(input: string | NotificationConfig) {
const cfg = normalize(input, "Info");
const cfg = normalize(input, i18n.t("molecules.notificationsManager.info"));
getNotification().info({
...COMMON_NOTIFICATION_PROPS,
...cfg,
@ -293,14 +316,14 @@ const NotificationManager = {
if (React.isValidElement(input)) {
getNotification().success({
...COMMON_NOTIFICATION_PROPS,
message: "Success",
message: i18n.t("molecules.notificationsManager.success"),
description: input,
placement: defaultPlacement(),
duration: 3.5,
});
return;
}
const cfg = normalize(input as string | NotificationConfig, "Success");
const cfg = normalize(input as string | NotificationConfig, i18n.t("molecules.notificationsManager.success"));
getNotification().success({
...COMMON_NOTIFICATION_PROPS,
...cfg,
@ -315,31 +338,31 @@ const NotificationManager = {
const base = { ...(extra ?? {}), description, placement: extra?.placement ?? defaultPlacement() };
if (looksErrorPayload(input, status)) {
const title = titleFor(status, description);
const payload = { ...base, message: title };
const category = categoryFor(status, description);
const payload = { ...base, message: i18n.t(`molecules.notificationsManager.${category}`) };
if (
title === "Rate Limit Exceeded" ||
title === "Info" ||
title === "Budget Exceeded" ||
title === "Feature Unavailable" ||
title === "Content Blocked" ||
title === "Integration Error"
category === "rateLimitExceeded" ||
category === "info" ||
category === "budgetExceeded" ||
category === "featureUnavailable" ||
category === "contentBlocked" ||
category === "integrationError"
) {
getNotification().warning({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 7 });
return;
}
if (title === "Server Error") {
if (category === "serverError") {
getNotification().error({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 8 });
return;
}
if (
title === "Request Error" ||
title === "Authentication Error" ||
title === "Access Denied" ||
title === "Not Found" ||
title === "Error" ||
title === "Already Exists"
category === "requestError" ||
category === "authenticationError" ||
category === "accessDenied" ||
category === "notFound" ||
category === "error" ||
category === "alreadyExists"
) {
getNotification().error({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 6 });
return;
@ -350,7 +373,7 @@ const NotificationManager = {
// Non-error: success/info/warning classifier
const cls = classifyGeneralMessage(description);
const payload = { ...base, message: cls?.title ?? "Info" };
const payload = { ...base, message: cls?.title ?? i18n.t("molecules.notificationsManager.info") };
if (cls?.kind === "success") {
getNotification().success({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 3.5 });

View file

@ -133,6 +133,70 @@
"selected": "Selected"
}
},
"molecules": {
"filter": {
"resetFilters": "Reset Filters",
"filters": "Filters",
"searchPlaceholder": "Search {{label}}...",
"selectPlaceholder": "Select {{label}}...",
"enterPlaceholder": "Enter {{label}}..."
},
"modelsColumns": {
"modelId": "Model ID",
"modelInformation": "Model Information",
"credentials": "Credentials",
"credentialTypes": "Credential types",
"reusable": "Reusable",
"reusableDescription": "Credentials saved in LiteLLM that can be added to models repeatedly.",
"manual": "Manual",
"manualDescription": "Credentials added directly during model creation or defined in the config file.",
"createdBy": "Created By",
"costs": "Costs",
"teamId": "Team ID",
"modelAccessGroup": "Model Access Group",
"unknownProvider": "Unknown provider",
"publicModelName": "Public Model Name",
"litellmModelName": "LiteLLM Model Name",
"definedInConfig": "Defined in config",
"unknownDate": "Unknown date",
"costPerMTokens": "Cost per 1M tokens",
"inputCost": "In: ${{cost}}",
"outputCost": "Out: ${{cost}}",
"dbModel": "DB Model",
"configModel": "Config Model",
"resumeModel": "Resume model",
"pauseModel": "Pause model",
"configNoPause": "Config models cannot be paused from the dashboard. Pause is DB-backed.",
"nonAdminNoPause": "Only proxy admins can pause or resume a model.",
"resumeTooltip": "Resume model — restore normal routing.",
"pauseTooltip": "Pause model — stop routing requests until resumed.",
"configNoDelete": "Config model cannot be deleted on the dashboard. Please delete it from the config file.",
"deleteModel": "Delete model"
},
"notificationsManager": {
"authenticationError": "Authentication Error",
"accessDenied": "Access Denied",
"serviceUnavailable": "Service Unavailable",
"budgetExceeded": "Budget Exceeded",
"featureUnavailable": "Feature Unavailable",
"routingError": "Routing Error",
"alreadyExists": "Already Exists",
"contentBlocked": "Content Blocked",
"validationError": "Validation Error",
"integrationError": "Integration Error",
"notFound": "Not Found",
"rateLimitExceeded": "Rate Limit Exceeded",
"serverError": "Server Error",
"requestError": "Request Error",
"info": "Info",
"error": "Error",
"success": "Success",
"warning": "Warning",
"featureNotice": "Feature Notice",
"configurationWarning": "Configuration Warning",
"rateLimit": "Rate Limit"
}
},
"common": {
"save": "Save",
"cancel": "Cancel",

View file

@ -133,6 +133,70 @@
"selected": "已选择"
}
},
"molecules": {
"filter": {
"resetFilters": "重置筛选",
"filters": "筛选",
"searchPlaceholder": "搜索 {{label}}...",
"selectPlaceholder": "选择 {{label}}...",
"enterPlaceholder": "输入 {{label}}..."
},
"modelsColumns": {
"modelId": "模型 ID",
"modelInformation": "模型信息",
"credentials": "凭据",
"credentialTypes": "凭据类型",
"reusable": "可复用",
"reusableDescription": "保存在 LiteLLM 中可被多次添加到模型的凭据。",
"manual": "手动",
"manualDescription": "在创建模型时直接添加或在配置文件中定义的凭据。",
"createdBy": "创建者",
"costs": "费用",
"teamId": "团队 ID",
"modelAccessGroup": "模型访问组",
"unknownProvider": "未知提供商",
"publicModelName": "公开模型名称",
"litellmModelName": "LiteLLM 模型名称",
"definedInConfig": "在配置文件中定义",
"unknownDate": "未知日期",
"costPerMTokens": "每百万 Token 费用",
"inputCost": "输入:${{cost}}",
"outputCost": "输出:${{cost}}",
"dbModel": "数据库模型",
"configModel": "配置模型",
"resumeModel": "恢复模型",
"pauseModel": "暂停模型",
"configNoPause": "配置模型无法从仪表盘暂停。暂停功能依赖数据库。",
"nonAdminNoPause": "只有代理管理员可以暂停或恢复模型。",
"resumeTooltip": "恢复模型 — 恢复正常路由。",
"pauseTooltip": "暂停模型 — 停止路由请求直到恢复。",
"configNoDelete": "配置模型无法在仪表盘上删除。请从配置文件中删除。",
"deleteModel": "删除模型"
},
"notificationsManager": {
"authenticationError": "认证错误",
"accessDenied": "访问被拒绝",
"serviceUnavailable": "服务不可用",
"budgetExceeded": "预算超限",
"featureUnavailable": "功能不可用",
"routingError": "路由错误",
"alreadyExists": "已存在",
"contentBlocked": "内容已屏蔽",
"validationError": "验证错误",
"integrationError": "集成错误",
"notFound": "未找到",
"rateLimitExceeded": "超出速率限制",
"serverError": "服务器错误",
"requestError": "请求错误",
"info": "信息",
"error": "错误",
"success": "成功",
"warning": "警告",
"featureNotice": "功能通知",
"configurationWarning": "配置警告",
"rateLimit": "速率限制"
}
},
"common": {
"save": "保存",
"cancel": "取消",