mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
feat(ui): i18n for common_components batch 2
This commit is contained in:
parent
77fbc36e94
commit
c084e10de9
12 changed files with 342 additions and 103 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { Select, Typography } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Organization } from "../networking";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
|
@ -21,10 +22,12 @@ const OrganizationDropdown: React.FC<OrganizationDropdownProps> = ({
|
|||
loading,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="All Organizations"
|
||||
placeholder={t("commonComponents.organizationDropdown.placeholder")}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react";
|
|||
import { Card, Title, Subtitle } from "@tremor/react";
|
||||
import { Form, Select, Tooltip, Alert } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import GuardrailSelector from "../guardrails/GuardrailSelector";
|
||||
|
||||
interface PassThroughGuardrailsSectionProps {
|
||||
|
|
@ -17,6 +18,7 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
onChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedGuardrails, setSelectedGuardrails] = useState<string[]>(Object.keys(value));
|
||||
const [guardrailSettings, setGuardrailSettings] =
|
||||
useState<Record<string, { request_fields?: string[]; response_fields?: string[] } | null>>(value);
|
||||
|
|
@ -70,42 +72,41 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<Title className="text-lg font-semibold text-gray-900 mb-2">Guardrails</Title>
|
||||
<Subtitle className="text-gray-600 mb-6">
|
||||
Configure guardrails to enforce policies on requests and responses. Guardrails are opt-in for passthrough
|
||||
endpoints.
|
||||
</Subtitle>
|
||||
<Title className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{t("commonComponents.passThroughGuardrailsSection.title")}
|
||||
</Title>
|
||||
<Subtitle className="text-gray-600 mb-6">{t("commonComponents.passThroughGuardrailsSection.subtitle")}</Subtitle>
|
||||
|
||||
<Alert
|
||||
message={
|
||||
<span>
|
||||
Field-Level Targeting{" "}
|
||||
{t("commonComponents.passThroughGuardrailsSection.fieldLevelTargeting")}{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/pass_through_guardrails#field-level-targeting"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
(Learn More)
|
||||
({t("common.learnMore")})
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
Optionally specify which fields to check. If left empty, the entire request/response is sent to the
|
||||
guardrail.
|
||||
</div>
|
||||
<div>{t("commonComponents.passThroughGuardrailsSection.fieldLevelDesc")}</div>
|
||||
<div className="text-xs space-y-1 mt-2">
|
||||
<div className="font-medium">Common Examples:</div>
|
||||
<div className="font-medium">{t("commonComponents.passThroughGuardrailsSection.commonExamples")}</div>
|
||||
<div>
|
||||
• <code className="bg-gray-100 px-1 rounded">query</code> - Single field
|
||||
• <code className="bg-gray-100 px-1 rounded">query</code> -{" "}
|
||||
{t("commonComponents.passThroughGuardrailsSection.singleField")}
|
||||
</div>
|
||||
<div>
|
||||
• <code className="bg-gray-100 px-1 rounded">documents[*].text</code> - All text in documents array
|
||||
• <code className="bg-gray-100 px-1 rounded">documents[*].text</code> -{" "}
|
||||
{t("commonComponents.passThroughGuardrailsSection.allTextInDocuments")}
|
||||
</div>
|
||||
<div>
|
||||
• <code className="bg-gray-100 px-1 rounded">messages[*].content</code> - All message contents
|
||||
• <code className="bg-gray-100 px-1 rounded">messages[*].content</code> -{" "}
|
||||
{t("commonComponents.passThroughGuardrailsSection.allMessageContents")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -118,8 +119,8 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Select Guardrails
|
||||
<Tooltip title="Choose which guardrails should run on this endpoint. Org/team/key level guardrails will also be included.">
|
||||
{t("commonComponents.passThroughGuardrailsSection.selectGuardrails")}
|
||||
<Tooltip title={t("commonComponents.passThroughGuardrailsSection.selectGuardrailsTooltip")}>
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
@ -136,8 +137,12 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
{selectedGuardrails.length > 0 && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-sm font-medium text-gray-700">Field Targeting (Optional)</div>
|
||||
<div className="text-xs text-gray-500">💡 Tip: Leave empty to check entire payload</div>
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
{t("commonComponents.passThroughGuardrailsSection.fieldTargetingOptional")}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{t("commonComponents.passThroughGuardrailsSection.leaveEmptyTip")}
|
||||
</div>
|
||||
</div>
|
||||
{selectedGuardrails.map((guardrailName) => (
|
||||
<Card key={guardrailName} className="p-4 bg-gray-50">
|
||||
|
|
@ -146,13 +151,15 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs text-gray-600 flex items-center">
|
||||
Request Fields (pre_call)
|
||||
{t("commonComponents.passThroughGuardrailsSection.requestFields")}
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div className="font-medium mb-1">Specify which request fields to check</div>
|
||||
<div className="font-medium mb-1">
|
||||
{t("commonComponents.passThroughGuardrailsSection.requestFieldsTooltipTitle")}
|
||||
</div>
|
||||
<div className="text-xs space-y-1">
|
||||
<div>Examples:</div>
|
||||
<div>{t("commonComponents.passThroughGuardrailsSection.examples")}</div>
|
||||
<div>• query</div>
|
||||
<div>• documents[*].text</div>
|
||||
<div>• messages[*].content</div>
|
||||
|
|
@ -191,7 +198,7 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Type field name or use + buttons above (e.g., query, documents[*].text)"
|
||||
placeholder={t("commonComponents.passThroughGuardrailsSection.requestFieldsPlaceholder")}
|
||||
value={guardrailSettings[guardrailName]?.request_fields || []}
|
||||
onChange={(fields) => handleFieldChange(guardrailName, "request_fields", fields)}
|
||||
disabled={disabled}
|
||||
|
|
@ -201,13 +208,15 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs text-gray-600 flex items-center">
|
||||
Response Fields (post_call)
|
||||
{t("commonComponents.passThroughGuardrailsSection.responseFields")}
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div className="font-medium mb-1">Specify which response fields to check</div>
|
||||
<div className="font-medium mb-1">
|
||||
{t("commonComponents.passThroughGuardrailsSection.responseFieldsTooltipTitle")}
|
||||
</div>
|
||||
<div className="text-xs space-y-1">
|
||||
<div>Examples:</div>
|
||||
<div>{t("commonComponents.passThroughGuardrailsSection.examples")}</div>
|
||||
<div>• results[*].text</div>
|
||||
<div>• choices[*].message.content</div>
|
||||
</div>
|
||||
|
|
@ -234,7 +243,7 @@ const PassThroughGuardrailsSection: React.FC<PassThroughGuardrailsSectionProps>
|
|||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Type field name or use + buttons above (e.g., results[*].text)"
|
||||
placeholder={t("commonComponents.passThroughGuardrailsSection.responseFieldsPlaceholder")}
|
||||
value={guardrailSettings[guardrailName]?.response_fields || []}
|
||||
onChange={(fields) => handleFieldChange(guardrailName, "response_fields", fields)}
|
||||
disabled={disabled}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { Card, Title, Subtitle, Text } from "@tremor/react";
|
||||
import { Form, Switch } from "antd";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
export interface PassThroughSecuritySectionProps {
|
||||
premiumUser: boolean;
|
||||
|
|
@ -17,12 +18,14 @@ const PassThroughSecuritySection: React.FC<PassThroughSecuritySectionProps> = ({
|
|||
authEnabled,
|
||||
onAuthChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<Title className="text-lg font-semibold text-gray-900 mb-2">Security</Title>
|
||||
<Subtitle className="text-gray-600 mb-4">
|
||||
When enabled, requests to this endpoint will require a valid LiteLLM Virtual Key
|
||||
</Subtitle>
|
||||
<Title className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{t("commonComponents.passThroughSecuritySection.title")}
|
||||
</Title>
|
||||
<Subtitle className="text-gray-600 mb-4">{t("commonComponents.passThroughSecuritySection.subtitle")}</Subtitle>
|
||||
{premiumUser ? (
|
||||
<Form.Item name="auth" valuePropName="checked" className="mb-0">
|
||||
<Switch
|
||||
|
|
@ -36,15 +39,25 @@ const PassThroughSecuritySection: React.FC<PassThroughSecuritySectionProps> = ({
|
|||
<div>
|
||||
<div className="flex items-center mb-3">
|
||||
<Switch disabled checked={false} style={{ outline: "2px solid #d1d5db", outlineOffset: "2px" }} />
|
||||
<span className="ml-2 text-sm text-gray-400">Authentication (Premium)</span>
|
||||
<span className="ml-2 text-sm text-gray-400">
|
||||
{t("commonComponents.passThroughSecuritySection.authenticationPremium")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<Text className="text-sm text-yellow-800">
|
||||
Setting authentication for pass-through endpoints is a LiteLLM Enterprise feature. Get a trial key{" "}
|
||||
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer" className="underline">
|
||||
here
|
||||
</a>
|
||||
.
|
||||
<Trans
|
||||
i18nKey="commonComponents.passThroughSecuritySection.enterpriseNotice"
|
||||
components={{
|
||||
pricingLink: (
|
||||
<a
|
||||
href="https://www.litellm.ai/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { Text } from "@tremor/react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import LoggingSettings from "../team/LoggingSettings";
|
||||
|
||||
interface PremiumLoggingSettingsProps {
|
||||
|
|
@ -17,6 +18,8 @@ export function PremiumLoggingSettings({
|
|||
disabledCallbacks = [],
|
||||
onDisabledCallbacksChange,
|
||||
}: PremiumLoggingSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!premiumUser) {
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -30,12 +33,19 @@ export function PremiumLoggingSettings({
|
|||
</div>
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<Text className="text-sm text-yellow-800">
|
||||
Setting Key/Team logging settings is a LiteLLM Enterprise feature. Global Logging Settings are available for
|
||||
all free users. Get a trial key{" "}
|
||||
<a href="https://www.litellm.ai/#pricing" target="_blank" rel="noopener noreferrer" className="underline">
|
||||
here
|
||||
</a>
|
||||
.
|
||||
<Trans
|
||||
i18nKey="commonComponents.premiumLoggingSettings.enterpriseNotice"
|
||||
components={{
|
||||
pricingLink: (
|
||||
<a
|
||||
href="https://www.litellm.ai/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { Select, Spin } from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
|
||||
|
||||
interface ProjectDropdownProps {
|
||||
|
|
@ -14,12 +15,13 @@ interface ProjectDropdownProps {
|
|||
}
|
||||
|
||||
const ProjectDropdown: React.FC<ProjectDropdownProps> = ({ projects, value, onChange, disabled, loading, teamId }) => {
|
||||
const { t } = useTranslation();
|
||||
const filtered = teamId ? projects?.filter((p) => p.team_id === teamId) : projects;
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Search or select a project"
|
||||
placeholder={t("commonComponents.projectDropdown.placeholder")}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { Form, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
|
|||
form,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const limitTypeUpper = type.toUpperCase();
|
||||
const limitTypeLower = type.toLowerCase();
|
||||
|
||||
|
|
@ -42,13 +44,15 @@ export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const tooltipTitle = `Select 'guaranteed_throughput' to prevent overallocating ${limitTypeUpper} limit when the key belongs to a Team with specific ${limitTypeUpper} limits.`;
|
||||
const tooltipTitle = t("commonComponents.rateLimitTypeFormItem.tooltip", {
|
||||
limitTypeUpper,
|
||||
});
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
{limitTypeUpper} Rate Limit Type{" "}
|
||||
{t("commonComponents.rateLimitTypeFormItem.label", { limitTypeUpper })}{" "}
|
||||
<Tooltip title={tooltipTitle}>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
|
|
@ -60,46 +64,52 @@ export const RateLimitTypeFormItem: React.FC<RateLimitTypeFormItemProps> = ({
|
|||
>
|
||||
<Select
|
||||
defaultValue={showDetailedDescriptions ? "default" : undefined}
|
||||
placeholder="Select rate limit type"
|
||||
placeholder={t("commonComponents.rateLimitTypeFormItem.selectPlaceholder")}
|
||||
style={{ width: "100%" }}
|
||||
optionLabelProp={showDetailedDescriptions ? "label" : undefined}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{showDetailedDescriptions ? (
|
||||
<>
|
||||
<Option value="best_effort_throughput" label="Default">
|
||||
<Option value="best_effort_throughput" label={t("common.default")}>
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<div style={{ fontWeight: 500 }}>Default</div>
|
||||
<div style={{ fontWeight: 500 }}>{t("common.default")}</div>
|
||||
<div style={{ fontSize: "11px", color: "#6b7280", marginTop: "2px" }}>
|
||||
Best effort throughput - no error if we're overallocating {limitTypeLower} (Team/Key Limits
|
||||
checked at runtime).
|
||||
{t("commonComponents.rateLimitTypeFormItem.bestEffortDesc", { limitTypeLower })}
|
||||
</div>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="guaranteed_throughput" label="Guaranteed throughput">
|
||||
<Option
|
||||
value="guaranteed_throughput"
|
||||
label={t("commonComponents.rateLimitTypeFormItem.guaranteedThroughput")}
|
||||
>
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<div style={{ fontWeight: 500 }}>Guaranteed throughput</div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{t("commonComponents.rateLimitTypeFormItem.guaranteedThroughput")}
|
||||
</div>
|
||||
<div style={{ fontSize: "11px", color: "#6b7280", marginTop: "2px" }}>
|
||||
Guaranteed throughput - raise an error if we're overallocating {limitTypeLower} (also checks
|
||||
model-specific limits)
|
||||
{t("commonComponents.rateLimitTypeFormItem.guaranteedThroughputDesc", { limitTypeLower })}
|
||||
</div>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="dynamic" label="Dynamic">
|
||||
<Option value="dynamic" label={t("commonComponents.rateLimitTypeFormItem.dynamic")}>
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<div style={{ fontWeight: 500 }}>Dynamic</div>
|
||||
<div style={{ fontWeight: 500 }}>{t("commonComponents.rateLimitTypeFormItem.dynamic")}</div>
|
||||
<div style={{ fontSize: "11px", color: "#6b7280", marginTop: "2px" }}>
|
||||
If the key has a set {limitTypeUpper} (e.g. 2 {limitTypeUpper}) and there are no 429 errors, it can
|
||||
dynamically exceed the limit when the model being called is not erroring.
|
||||
{t("commonComponents.rateLimitTypeFormItem.dynamicDesc", { limitTypeUpper })}
|
||||
</div>
|
||||
</div>
|
||||
</Option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Option value="best_effort_throughput">Best effort throughput</Option>
|
||||
<Option value="guaranteed_throughput">Guaranteed throughput</Option>
|
||||
<Option value="dynamic">Dynamic</Option>
|
||||
<Option value="best_effort_throughput">
|
||||
{t("commonComponents.rateLimitTypeFormItem.bestEffortThroughput")}
|
||||
</Option>
|
||||
<Option value="guaranteed_throughput">
|
||||
{t("commonComponents.rateLimitTypeFormItem.guaranteedThroughput")}
|
||||
</Option>
|
||||
<Option value="dynamic">{t("commonComponents.rateLimitTypeFormItem.dynamic")}</Option>
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
|
||||
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getRouterSettingsCall } from "../networking";
|
||||
import RouterSettingsForm, { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm";
|
||||
import { Fallbacks } from "../Settings/RouterSettings/Fallbacks/AddFallbacks";
|
||||
|
|
@ -37,6 +38,7 @@ export interface RouterSettingsAccordionRef {
|
|||
|
||||
const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSettingsAccordionProps>(
|
||||
({ accessToken, value, onChange, modelData }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [formValue, setFormValue] = useState<RouterSettingsFormValue>({
|
||||
routerSettings: {},
|
||||
selectedStrategy: null,
|
||||
|
|
@ -347,8 +349,8 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
|
|||
<div className="w-full">
|
||||
<TabGroup className="w-full">
|
||||
<TabList variant="line" defaultValue="1" className="px-8 pt-4">
|
||||
<Tab value="1">Loadbalancing</Tab>
|
||||
<Tab value="2">Fallbacks</Tab>
|
||||
<Tab value="1">{t("commonComponents.routerSettingsAccordion.loadbalancing")}</Tab>
|
||||
<Tab value="2">{t("commonComponents.routerSettingsAccordion.fallbacks")}</Tab>
|
||||
</TabList>
|
||||
<TabPanels className="px-8 py-6">
|
||||
<TabPanel>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { Button, Dropdown, MenuProps } from "antd";
|
||||
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type SortState = "asc" | "desc" | false;
|
||||
|
||||
|
|
@ -21,6 +22,8 @@ interface TableHeaderSortDropdownProps {
|
|||
}
|
||||
|
||||
export const TableHeaderSortDropdown: React.FC<TableHeaderSortDropdownProps> = ({ sortState, onSortChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
|
||||
if (key === "asc") {
|
||||
onSortChange("asc");
|
||||
|
|
@ -34,17 +37,17 @@ export const TableHeaderSortDropdown: React.FC<TableHeaderSortDropdownProps> = (
|
|||
const menuItems: MenuProps["items"] = [
|
||||
{
|
||||
key: "asc",
|
||||
label: "Ascending",
|
||||
label: t("commonComponents.tableHeaderSortDropdown.ascending"),
|
||||
icon: <ChevronUpIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
key: "desc",
|
||||
label: "Descending",
|
||||
label: t("commonComponents.tableHeaderSortDropdown.descending"),
|
||||
icon: <ChevronDownIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
label: "Reset",
|
||||
label: t("common.reset"),
|
||||
icon: <XIcon className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { Form, Input, InputNumber, Select } from "antd";
|
|||
import { TextInput } from "@tremor/react";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Tooltip } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TFunction } from "i18next";
|
||||
import { getOpenAPISchema } from "../networking";
|
||||
import { formatLabel } from "@/utils/textUtils";
|
||||
|
||||
|
|
@ -53,44 +55,42 @@ const validateJSON = (value: string): boolean => {
|
|||
}
|
||||
};
|
||||
|
||||
const getFieldHelp = (key: string, property: SchemaProperty, type: string): string => {
|
||||
// Default help text based on type
|
||||
const getFieldHelp = (key: string, property: SchemaProperty, type: string, t: TFunction): string => {
|
||||
const defaultHelp =
|
||||
{
|
||||
string: "Text input",
|
||||
number: "Numeric input",
|
||||
integer: "Whole number input",
|
||||
boolean: "True/False value",
|
||||
}[type] || "Text input";
|
||||
string: t("commonComponents.checkOpenapiSchema.helpText"),
|
||||
number: t("commonComponents.checkOpenapiSchema.helpNumber"),
|
||||
integer: t("commonComponents.checkOpenapiSchema.helpInteger"),
|
||||
boolean: t("commonComponents.checkOpenapiSchema.helpBoolean"),
|
||||
}[type] || t("commonComponents.checkOpenapiSchema.helpText");
|
||||
|
||||
// Specific field help text
|
||||
const specificHelp: { [key: string]: string } = {
|
||||
max_budget: "Enter maximum budget in USD (e.g., 100.50)",
|
||||
budget_duration: "Select a time period for budget reset",
|
||||
tpm_limit: "Enter maximum tokens per minute (whole number)",
|
||||
rpm_limit: "Enter maximum requests per minute (whole number)",
|
||||
duration: "Enter duration (e.g., 30s, 24h, 7d)",
|
||||
metadata: 'Enter JSON object with key-value pairs\nExample: {"team": "research", "project": "nlp"}',
|
||||
config: 'Enter configuration as JSON object\nExample: {"setting": "value"}',
|
||||
permissions: "Enter comma-separated permission strings",
|
||||
enforced_params: 'Enter parameters as JSON object\nExample: {"param": "value"}',
|
||||
blocked: "Enter true/false or specific block conditions",
|
||||
aliases: 'Enter aliases as JSON object\nExample: {"alias1": "value1", "alias2": "value2"}',
|
||||
models: "Select one or more model names",
|
||||
key_alias: "Enter a unique identifier for this key",
|
||||
tags: "Enter comma-separated tag strings",
|
||||
max_budget: t("commonComponents.checkOpenapiSchema.helpMaxBudget"),
|
||||
budget_duration: t("commonComponents.checkOpenapiSchema.helpBudgetDuration"),
|
||||
tpm_limit: t("commonComponents.checkOpenapiSchema.helpTpmLimit"),
|
||||
rpm_limit: t("commonComponents.checkOpenapiSchema.helpRpmLimit"),
|
||||
duration: t("commonComponents.checkOpenapiSchema.helpDuration"),
|
||||
metadata: t("commonComponents.checkOpenapiSchema.helpMetadata"),
|
||||
config: t("commonComponents.checkOpenapiSchema.helpConfig"),
|
||||
permissions: t("commonComponents.checkOpenapiSchema.helpPermissions"),
|
||||
enforced_params: t("commonComponents.checkOpenapiSchema.helpEnforcedParams"),
|
||||
blocked: t("commonComponents.checkOpenapiSchema.helpBlocked"),
|
||||
aliases: t("commonComponents.checkOpenapiSchema.helpAliases"),
|
||||
models: t("commonComponents.checkOpenapiSchema.helpModels"),
|
||||
key_alias: t("commonComponents.checkOpenapiSchema.helpKeyAlias"),
|
||||
tags: t("commonComponents.checkOpenapiSchema.helpTags"),
|
||||
};
|
||||
|
||||
// Get specific help text or use default based on type
|
||||
const helpText = specificHelp[key] || defaultHelp;
|
||||
|
||||
// Add format requirements for special cases
|
||||
if (isJSONField(key, property)) {
|
||||
return `${helpText}\nMust be valid JSON format`;
|
||||
return `${helpText}\n${t("commonComponents.checkOpenapiSchema.mustBeValidJson")}`;
|
||||
}
|
||||
|
||||
if (property.enum) {
|
||||
return `Select from available options\nAllowed values: ${property.enum.join(", ")}`;
|
||||
return t("commonComponents.checkOpenapiSchema.selectFromOptions", {
|
||||
values: property.enum.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
return helpText;
|
||||
|
|
@ -105,6 +105,7 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
customValidation = {},
|
||||
defaultValues = {},
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [schemaProperties, setSchemaProperties] = useState<OpenAPISchema | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -130,7 +131,7 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
form.setFieldsValue(defaultFormValues);
|
||||
} catch (error) {
|
||||
console.error("Schema fetch error:", error);
|
||||
setError(error instanceof Error ? error.message : "Failed to fetch schema");
|
||||
setError(error instanceof Error ? error.message : t("commonComponents.checkOpenapiSchema.fetchFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -158,7 +159,7 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
|
||||
const rules = [];
|
||||
if (isRequired) {
|
||||
rules.push({ required: true, message: `${label} is required` });
|
||||
rules.push({ required: true, message: t("commonComponents.checkOpenapiSchema.fieldRequired", { label }) });
|
||||
}
|
||||
if (customValidation[key]) {
|
||||
rules.push({ validator: customValidation[key] });
|
||||
|
|
@ -167,7 +168,7 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
rules.push({
|
||||
validator: async (_: any, value: string) => {
|
||||
if (value && !validateJSON(value)) {
|
||||
throw new Error("Please enter valid JSON");
|
||||
throw new Error(t("commonComponents.checkOpenapiSchema.invalidJson"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -186,7 +187,13 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
|
||||
let inputComponent;
|
||||
if (isJSONField(key, property)) {
|
||||
inputComponent = <Input.TextArea rows={4} placeholder="Enter as JSON" className="font-mono" />;
|
||||
inputComponent = (
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={t("commonComponents.checkOpenapiSchema.enterAsJson")}
|
||||
className="font-mono"
|
||||
/>
|
||||
);
|
||||
} else if (property.enum) {
|
||||
inputComponent = (
|
||||
<Select>
|
||||
|
|
@ -200,7 +207,7 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
} else if (type === "number" || type === "integer") {
|
||||
inputComponent = <InputNumber style={{ width: "100%" }} precision={type === "integer" ? 0 : undefined} />;
|
||||
} else if (key === "duration") {
|
||||
inputComponent = <TextInput placeholder="eg: 30s, 30h, 30d" />;
|
||||
inputComponent = <TextInput placeholder={t("commonComponents.checkOpenapiSchema.durationPlaceholder")} />;
|
||||
} else {
|
||||
inputComponent = <TextInput placeholder={tooltip || ""} />;
|
||||
}
|
||||
|
|
@ -213,7 +220,7 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
className="mt-8"
|
||||
rules={rules}
|
||||
initialValue={defaultValues[key]}
|
||||
help={<div className="text-xs text-gray-500">{getFieldHelp(key, property, type)}</div>}
|
||||
help={<div className="text-xs text-gray-500">{getFieldHelp(key, property, type, t)}</div>}
|
||||
>
|
||||
{inputComponent}
|
||||
</Form.Item>
|
||||
|
|
@ -221,7 +228,11 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
};
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">Error: {error}</div>;
|
||||
return (
|
||||
<div className="text-red-500">
|
||||
{t("commonComponents.checkOpenapiSchema.errorPrefix")} {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schemaProperties?.properties) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useMemo, useState, type UIEvent } from "react";
|
||||
import { Select, Typography } from "antd";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
|
||||
import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
|
|
@ -29,6 +30,7 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
|
|||
organizationId,
|
||||
pageSize = 20,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", {
|
||||
wait: DEBOUNCE_MS,
|
||||
|
|
@ -78,7 +80,7 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
|
|||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Search or select a team"
|
||||
placeholder={t("commonComponents.teamDropdown.placeholder")}
|
||||
value={value || undefined}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
|
|
@ -88,7 +90,7 @@ const TeamDropdown: React.FC<TeamDropdownProps> = ({
|
|||
searchValue={searchInput}
|
||||
onPopupScroll={handlePopupScroll}
|
||||
loading={isLoading}
|
||||
notFoundContent={isLoading ? <LoadingOutlined spin /> : "No teams found"}
|
||||
notFoundContent={isLoading ? <LoadingOutlined spin /> : t("commonComponents.teamDropdown.notFound")}
|
||||
data-testid="team-dropdown"
|
||||
popupRender={(menu) => (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -278,6 +278,93 @@
|
|||
"labelText": "Select Model",
|
||||
"enterCustomModel": "Enter custom model",
|
||||
"customModelPlaceholder": "Enter custom model name"
|
||||
},
|
||||
"organizationDropdown": {
|
||||
"placeholder": "All Organizations"
|
||||
},
|
||||
"passThroughGuardrailsSection": {
|
||||
"title": "Guardrails",
|
||||
"subtitle": "Configure guardrails to enforce policies on requests and responses. Guardrails are opt-in for passthrough endpoints.",
|
||||
"fieldLevelTargeting": "Field-Level Targeting",
|
||||
"fieldLevelDesc": "Optionally specify which fields to check. If left empty, the entire request/response is sent to the guardrail.",
|
||||
"commonExamples": "Common Examples:",
|
||||
"singleField": "Single field",
|
||||
"allTextInDocuments": "All text in documents array",
|
||||
"allMessageContents": "All message contents",
|
||||
"selectGuardrails": "Select Guardrails",
|
||||
"selectGuardrailsTooltip": "Choose which guardrails should run on this endpoint. Org/team/key level guardrails will also be included.",
|
||||
"fieldTargetingOptional": "Field Targeting (Optional)",
|
||||
"leaveEmptyTip": "Tip: Leave empty to check entire payload",
|
||||
"requestFields": "Request Fields (pre_call)",
|
||||
"requestFieldsTooltipTitle": "Specify which request fields to check",
|
||||
"responseFields": "Response Fields (post_call)",
|
||||
"responseFieldsTooltipTitle": "Specify which response fields to check",
|
||||
"examples": "Examples:",
|
||||
"requestFieldsPlaceholder": "Type field name or use + buttons above (e.g., query, documents[*].text)",
|
||||
"responseFieldsPlaceholder": "Type field name or use + buttons above (e.g., results[*].text)"
|
||||
},
|
||||
"passThroughSecuritySection": {
|
||||
"title": "Security",
|
||||
"subtitle": "When enabled, requests to this endpoint will require a valid LiteLLM Virtual Key",
|
||||
"authenticationPremium": "Authentication (Premium)",
|
||||
"enterpriseNotice": "Setting authentication for pass-through endpoints is a LiteLLM Enterprise feature. Get a trial key <pricingLink>here</pricingLink>."
|
||||
},
|
||||
"premiumLoggingSettings": {
|
||||
"enterpriseNotice": "Setting Key/Team logging settings is a LiteLLM Enterprise feature. Global Logging Settings are available for all free users. Get a trial key <pricingLink>here</pricingLink>."
|
||||
},
|
||||
"projectDropdown": {
|
||||
"placeholder": "Search or select a project"
|
||||
},
|
||||
"rateLimitTypeFormItem": {
|
||||
"label": "{{limitTypeUpper}} Rate Limit Type",
|
||||
"tooltip": "Select 'guaranteed_throughput' to prevent overallocating {{limitTypeUpper}} limit when the key belongs to a Team with specific {{limitTypeUpper}} limits.",
|
||||
"selectPlaceholder": "Select rate limit type",
|
||||
"bestEffortThroughput": "Best effort throughput",
|
||||
"bestEffortDesc": "Best effort throughput - no error if we're overallocating {{limitTypeLower}} (Team/Key Limits checked at runtime).",
|
||||
"guaranteedThroughput": "Guaranteed throughput",
|
||||
"guaranteedThroughputDesc": "Guaranteed throughput - raise an error if we're overallocating {{limitTypeLower}} (also checks model-specific limits)",
|
||||
"dynamic": "Dynamic",
|
||||
"dynamicDesc": "If the key has a set {{limitTypeUpper}} (e.g. 2 {{limitTypeUpper}}) and there are no 429 errors, it can dynamically exceed the limit when the model being called is not erroring."
|
||||
},
|
||||
"routerSettingsAccordion": {
|
||||
"loadbalancing": "Loadbalancing",
|
||||
"fallbacks": "Fallbacks"
|
||||
},
|
||||
"tableHeaderSortDropdown": {
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending"
|
||||
},
|
||||
"checkOpenapiSchema": {
|
||||
"helpText": "Text input",
|
||||
"helpNumber": "Numeric input",
|
||||
"helpInteger": "Whole number input",
|
||||
"helpBoolean": "True/False value",
|
||||
"helpMaxBudget": "Enter maximum budget in USD (e.g., 100.50)",
|
||||
"helpBudgetDuration": "Select a time period for budget reset",
|
||||
"helpTpmLimit": "Enter maximum tokens per minute (whole number)",
|
||||
"helpRpmLimit": "Enter maximum requests per minute (whole number)",
|
||||
"helpDuration": "Enter duration (e.g., 30s, 24h, 7d)",
|
||||
"helpMetadata": "Enter JSON object with key-value pairs\nExample: {\"team\": \"research\", \"project\": \"nlp\"}",
|
||||
"helpConfig": "Enter configuration as JSON object\nExample: {\"setting\": \"value\"}",
|
||||
"helpPermissions": "Enter comma-separated permission strings",
|
||||
"helpEnforcedParams": "Enter parameters as JSON object\nExample: {\"param\": \"value\"}",
|
||||
"helpBlocked": "Enter true/false or specific block conditions",
|
||||
"helpAliases": "Enter aliases as JSON object\nExample: {\"alias1\": \"value1\", \"alias2\": \"value2\"}",
|
||||
"helpModels": "Select one or more model names",
|
||||
"helpKeyAlias": "Enter a unique identifier for this key",
|
||||
"helpTags": "Enter comma-separated tag strings",
|
||||
"mustBeValidJson": "Must be valid JSON format",
|
||||
"selectFromOptions": "Select from available options\nAllowed values: {{values}}",
|
||||
"fieldRequired": "{{label}} is required",
|
||||
"invalidJson": "Please enter valid JSON",
|
||||
"enterAsJson": "Enter as JSON",
|
||||
"durationPlaceholder": "eg: 30s, 30h, 30d",
|
||||
"fetchFailed": "Failed to fetch schema",
|
||||
"errorPrefix": "Error:"
|
||||
},
|
||||
"teamDropdown": {
|
||||
"placeholder": "Search or select a team",
|
||||
"notFound": "No teams found"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
|
|
|||
|
|
@ -278,6 +278,93 @@
|
|||
"labelText": "选择模型",
|
||||
"enterCustomModel": "输入自定义模型",
|
||||
"customModelPlaceholder": "输入自定义模型名称"
|
||||
},
|
||||
"organizationDropdown": {
|
||||
"placeholder": "所有组织"
|
||||
},
|
||||
"passThroughGuardrailsSection": {
|
||||
"title": "护栏",
|
||||
"subtitle": "配置护栏以对请求和响应执行策略。护栏对透传端点为按需启用。",
|
||||
"fieldLevelTargeting": "字段级定向",
|
||||
"fieldLevelDesc": "可选择性指定要检查的字段。若留空,整个请求/响应将发送给护栏。",
|
||||
"commonExamples": "常见示例:",
|
||||
"singleField": "单个字段",
|
||||
"allTextInDocuments": "文档数组中的所有文本",
|
||||
"allMessageContents": "所有消息内容",
|
||||
"selectGuardrails": "选择护栏",
|
||||
"selectGuardrailsTooltip": "选择应在此端点运行的护栏。组织/团队/密钥级别的护栏也将被包含。",
|
||||
"fieldTargetingOptional": "字段定向(可选)",
|
||||
"leaveEmptyTip": "提示:留空则检查整个载荷",
|
||||
"requestFields": "请求字段 (pre_call)",
|
||||
"requestFieldsTooltipTitle": "指定要检查的请求字段",
|
||||
"responseFields": "响应字段 (post_call)",
|
||||
"responseFieldsTooltipTitle": "指定要检查的响应字段",
|
||||
"examples": "示例:",
|
||||
"requestFieldsPlaceholder": "输入字段名或使用上方 + 按钮(如:query、documents[*].text)",
|
||||
"responseFieldsPlaceholder": "输入字段名或使用上方 + 按钮(如:results[*].text)"
|
||||
},
|
||||
"passThroughSecuritySection": {
|
||||
"title": "安全",
|
||||
"subtitle": "启用后,发往此端点的请求将需要有效的 LiteLLM 虚拟密钥",
|
||||
"authenticationPremium": "认证(高级版)",
|
||||
"enterpriseNotice": "为透传端点设置认证是 LiteLLM 企业版功能。获取试用密钥请访问<pricingLink>此处</pricingLink>。"
|
||||
},
|
||||
"premiumLoggingSettings": {
|
||||
"enterpriseNotice": "设置密钥/团队日志配置是 LiteLLM 企业版功能。所有免费用户均可使用全局日志设置。获取试用密钥请访问<pricingLink>此处</pricingLink>。"
|
||||
},
|
||||
"projectDropdown": {
|
||||
"placeholder": "搜索或选择项目"
|
||||
},
|
||||
"rateLimitTypeFormItem": {
|
||||
"label": "{{limitTypeUpper}} 速率限制类型",
|
||||
"tooltip": "选择 guaranteed_throughput 可防止在密钥属于具有特定 {{limitTypeUpper}} 限制的团队时过度分配 {{limitTypeUpper}} 限额。",
|
||||
"selectPlaceholder": "选择速率限制类型",
|
||||
"bestEffortThroughput": "尽力吞吐",
|
||||
"bestEffortDesc": "尽力吞吐 — 过度分配 {{limitTypeLower}} 时不报错(运行时检查团队/密钥限制)。",
|
||||
"guaranteedThroughput": "保障吞吐",
|
||||
"guaranteedThroughputDesc": "保障吞吐 — 过度分配 {{limitTypeLower}} 时报错(同时检查模型专属限制)",
|
||||
"dynamic": "动态",
|
||||
"dynamicDesc": "若密钥设置了 {{limitTypeUpper}}(例如 2 {{limitTypeUpper}})且无 429 错误,则当被调用的模型未报错时可动态超出限制。"
|
||||
},
|
||||
"routerSettingsAccordion": {
|
||||
"loadbalancing": "负载均衡",
|
||||
"fallbacks": "回退"
|
||||
},
|
||||
"tableHeaderSortDropdown": {
|
||||
"ascending": "升序",
|
||||
"descending": "降序"
|
||||
},
|
||||
"checkOpenapiSchema": {
|
||||
"helpText": "文本输入",
|
||||
"helpNumber": "数值输入",
|
||||
"helpInteger": "整数输入",
|
||||
"helpBoolean": "True/False 值",
|
||||
"helpMaxBudget": "输入最大预算(美元,例如:100.50)",
|
||||
"helpBudgetDuration": "选择预算重置的时间周期",
|
||||
"helpTpmLimit": "输入每分钟最大 Token 数(整数)",
|
||||
"helpRpmLimit": "输入每分钟最大请求数(整数)",
|
||||
"helpDuration": "输入时长(例如:30s、24h、7d)",
|
||||
"helpMetadata": "输入键值对 JSON 对象\n示例:{\"team\": \"research\", \"project\": \"nlp\"}",
|
||||
"helpConfig": "以 JSON 对象输入配置\n示例:{\"setting\": \"value\"}",
|
||||
"helpPermissions": "输入逗号分隔的权限字符串",
|
||||
"helpEnforcedParams": "以 JSON 对象输入参数\n示例:{\"param\": \"value\"}",
|
||||
"helpBlocked": "输入 true/false 或具体的屏蔽条件",
|
||||
"helpAliases": "以 JSON 对象输入别名\n示例:{\"alias1\": \"value1\", \"alias2\": \"value2\"}",
|
||||
"helpModels": "选择一个或多个模型名称",
|
||||
"helpKeyAlias": "为此密钥输入唯一标识符",
|
||||
"helpTags": "输入逗号分隔的标签字符串",
|
||||
"mustBeValidJson": "必须为有效的 JSON 格式",
|
||||
"selectFromOptions": "从可用选项中选择\n允许的值:{{values}}",
|
||||
"fieldRequired": "{{label}} 为必填项",
|
||||
"invalidJson": "请输入有效的 JSON",
|
||||
"enterAsJson": "以 JSON 格式输入",
|
||||
"durationPlaceholder": "例如:30s、30h、30d",
|
||||
"fetchFailed": "获取 schema 失败",
|
||||
"errorPrefix": "错误:"
|
||||
},
|
||||
"teamDropdown": {
|
||||
"placeholder": "搜索或选择团队",
|
||||
"notFound": "未找到团队"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue