feat(ui): i18n for settings, route preview, transform request, and skill hub columns

This commit is contained in:
吹雪 2026-06-10 14:15:51 +09:00
parent 3fd97f0fcc
commit 30a4169b08
9 changed files with 308 additions and 108 deletions

View file

@ -4,7 +4,8 @@ import { SearchOutlined } from "@ant-design/icons";
import { Input, Select } from "antd";
import { Plugin } from "@/components/claude_code_plugins/types";
import { ModelDataTable } from "@/components/model_dashboard/table";
import { skillHubColumns } from "@/components/skill_hub_table_columns";
import { useTranslation } from "react-i18next";
import { getSkillHubColumns } from "@/components/skill_hub_table_columns";
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
interface SkillHubDashboardProps {
@ -24,6 +25,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
publicPage = false,
onPublishSuccess,
}) => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [domainFilter, setDomainFilter] = useState<string | undefined>(undefined);
const [selectedSkill, setSelectedSkill] = useState<Plugin | null>(null);
@ -115,7 +117,7 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
</div>
</div>
<ModelDataTable
columns={skillHubColumns((skill) => setSelectedSkill(skill), copyToClipboard, publicPage)}
columns={getSkillHubColumns(t, (skill) => setSelectedSkill(skill), copyToClipboard, publicPage)}
data={filteredSkills}
isLoading={false}
defaultSorting={[{ id: "name", desc: false }]}

View file

@ -2,6 +2,7 @@ import React, { useState } from "react";
import { Button, Space } from "antd";
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { TextInput } from "@tremor/react";
import { useTranslation } from "react-i18next";
interface QueryParamInputProps {
value?: Record<string, string>;
@ -9,6 +10,7 @@ interface QueryParamInputProps {
}
const QueryParamInput: React.FC<QueryParamInputProps> = ({ value = {}, onChange }) => {
const { t } = useTranslation();
const [pairs, setPairs] = useState<[string, string][]>(Object.entries(value));
const handleAdd = () => {
@ -33,12 +35,12 @@ const QueryParamInput: React.FC<QueryParamInputProps> = ({ value = {}, onChange
{pairs.map(([key, val], index) => (
<Space key={index} style={{ display: "flex", marginBottom: 8 }} align="center">
<TextInput
placeholder="Parameter Name (e.g., version)"
placeholder={t("queryParamInput.paramNamePlaceholder")}
value={key}
onChange={(e) => handleChange(index, e.target.value, val)}
/>
<TextInput
placeholder="Parameter Value (e.g., v1)"
placeholder={t("queryParamInput.paramValuePlaceholder")}
value={val}
onChange={(e) => handleChange(index, key, e.target.value)}
/>
@ -48,7 +50,7 @@ const QueryParamInput: React.FC<QueryParamInputProps> = ({ value = {}, onChange
</Space>
))}
<Button type="dashed" onClick={handleAdd} icon={<PlusOutlined />}>
Add Query Parameter
{t("queryParamInput.addButton")}
</Button>
</div>
);

View file

@ -1,6 +1,7 @@
import React from "react";
import { Card, Typography } from "antd";
import { RightOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { Trans, useTranslation } from "react-i18next";
const { Title, Text } = Typography;
import { getProxyBaseUrl } from "./networking";
@ -12,6 +13,7 @@ interface RoutePreviewProps {
}
const RoutePreview: React.FC<RoutePreviewProps> = ({ pathValue, targetValue, includeSubpath }) => {
const { t } = useTranslation();
const proxyBaseUrl = getProxyBaseUrl();
const getLiteLLMProxyUrl = () => {
@ -26,20 +28,20 @@ const RoutePreview: React.FC<RoutePreviewProps> = ({ pathValue, targetValue, inc
return (
<Card className="p-5">
<Title level={5} className="text-lg font-semibold text-gray-900 mb-2">
Route Preview
{t("routePreview.title")}
</Title>
<Text type="secondary" className="text-gray-600 mb-5" style={{ display: "block" }}>
How your requests will be routed
{t("routePreview.subtitle")}
</Text>
<div className="space-y-5">
{/* Basic routing */}
<div>
<div className="text-base font-semibold text-gray-900 mb-3">Basic routing:</div>
<div className="text-base font-semibold text-gray-900 mb-3">{t("routePreview.basicRouting")}</div>
<div className="flex items-center gap-4">
{/* Your endpoint */}
<div className="flex-1 bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-sm text-gray-600 mb-2">Your endpoint</div>
<div className="text-sm text-gray-600 mb-2">{t("routePreview.yourEndpoint")}</div>
<code className="font-mono text-sm text-gray-900">{getLiteLLMProxyUrl()}</code>
</div>
@ -50,7 +52,7 @@ const RoutePreview: React.FC<RoutePreviewProps> = ({ pathValue, targetValue, inc
{/* Forwards to */}
<div className="flex-1 bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-sm text-gray-600 mb-2">Forwards to</div>
<div className="text-sm text-gray-600 mb-2">{t("routePreview.forwardsTo")}</div>
<code className="font-mono text-sm text-gray-900">{targetValue}</code>
</div>
</div>
@ -60,11 +62,11 @@ const RoutePreview: React.FC<RoutePreviewProps> = ({ pathValue, targetValue, inc
<>
{/* With subpaths */}
<div>
<div className="text-base font-semibold text-gray-900 mb-3">With subpaths:</div>
<div className="text-base font-semibold text-gray-900 mb-3">{t("routePreview.withSubpaths")}</div>
<div className="flex items-center gap-4">
{/* Your endpoint + subpath */}
<div className="flex-1 bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-sm text-gray-600 mb-2">Your endpoint + subpath</div>
<div className="text-sm text-gray-600 mb-2">{t("routePreview.yourEndpointSubpath")}</div>
<code className="font-mono text-sm text-gray-900">
{pathValue && `${proxyBaseUrl}${pathValue}`}
<span className="text-blue-600">/v1/text-to-image/base/model</span>
@ -78,7 +80,7 @@ const RoutePreview: React.FC<RoutePreviewProps> = ({ pathValue, targetValue, inc
{/* Forwards to with subpath */}
<div className="flex-1 bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-sm text-gray-600 mb-2">Forwards to</div>
<div className="text-sm text-gray-600 mb-2">{t("routePreview.forwardsTo")}</div>
<code className="font-mono text-sm text-gray-900">
{targetValue}
<span className="text-blue-600">/v1/text-to-image/base/model</span>
@ -87,9 +89,7 @@ const RoutePreview: React.FC<RoutePreviewProps> = ({ pathValue, targetValue, inc
</div>
{/* Note */}
<div className="mt-3 text-sm text-gray-600">
Any path after {pathValue} will be appended to the target URL
</div>
<div className="mt-3 text-sm text-gray-600">{t("routePreview.subpathNote", { pathValue })}</div>
</div>
</>
)}
@ -99,10 +99,13 @@ const RoutePreview: React.FC<RoutePreviewProps> = ({ pathValue, targetValue, inc
<div className="flex items-start">
<InfoCircleOutlined className="text-blue-500 mt-0.5 mr-2 flex-shrink-0" />
<div className="text-sm text-blue-700">
<span className="font-medium">Not seeing the routing you wanted?</span> Try enabling - Include Subpaths
- above - this allows subroutes like{" "}
<code className="bg-blue-100 px-1 py-0.5 rounded font-mono text-xs">/api/v1/models</code> to be
forwarded automatically.
<Trans
i18nKey="routePreview.subpathHint"
components={{
bold: <span className="font-medium" />,
code: <code className="bg-blue-100 px-1 py-0.5 rounded font-mono text-xs" />,
}}
/>
</div>
</div>
</div>

View file

@ -19,6 +19,7 @@ import {
TextInput,
} from "@tremor/react";
import React, { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button as Button2, Form, Input, Modal, Select, Typography } from "antd";
import EmailSettings from "./email_settings";
@ -62,6 +63,8 @@ interface DynamicParamsFieldsProps {
}
const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callbackConfigs, selectedCallback }) => {
const { t } = useTranslation();
if (!params || params.length === 0) {
return null;
}
@ -86,7 +89,7 @@ const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callb
? [
{
required: true,
message: `Please enter the ${fieldLabel.toLowerCase()}`,
message: t("settings.dynamicParamRequired", { fieldLabel: fieldLabel.toLowerCase() }),
},
]
: undefined
@ -95,14 +98,14 @@ const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callb
{paramType === "password" ? (
<Input.Password
size="large"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
placeholder={t("settings.dynamicParamEnterYour", { fieldLabel: fieldLabel.toLowerCase() })}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
) : paramType === "number" ? (
<Input
type="number"
size="large"
placeholder={`Enter ${fieldLabel.toLowerCase()}`}
placeholder={t("settings.dynamicParamEnter", { fieldLabel: fieldLabel.toLowerCase() })}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
min={0}
max={1}
@ -111,7 +114,7 @@ const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callb
) : (
<Input
size="large"
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
placeholder={t("settings.dynamicParamEnterYour", { fieldLabel: fieldLabel.toLowerCase() })}
className="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
/>
)}
@ -136,14 +139,16 @@ const CallbackSelector: React.FC<CallbackSelectorProps> = ({
onCallbackChange,
disabled = false,
}) => {
const { t } = useTranslation();
return (
<FormItem
label="Callback"
label={t("settings.callbackLabel")}
name="callback"
rules={disabled ? undefined : [{ required: true, message: "Please select a callback" }]}
rules={disabled ? undefined : [{ required: true, message: t("settings.callbackRequired") }]}
>
<Select
placeholder="Choose a logging callback..."
placeholder={t("settings.callbackPlaceholder")}
size="large"
className="w-full"
showSearch
@ -214,6 +219,7 @@ const buildCallbackPayload = (formValues: Record<string, any>, callbackName: str
};
const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, premiumUser }) => {
const { t } = useTranslation();
const [callbacks, setCallbacks] = useState<AlertingObject[]>([]);
const [alerts, setAlerts] = useState<any[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false);
@ -256,7 +262,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
setCallbackConfigs(data || []);
})
.catch((error) => {
NotificationsManager.fromBackend("Failed to load callback configs: " + parseErrorMessage(error));
NotificationsManager.fromBackend(t("settings.loadCallbackConfigsFailed", { error: parseErrorMessage(error) }));
});
}, [accessToken]);
@ -337,7 +343,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
try {
await setCallbacksCall(accessToken, payload);
NotificationsManager.success(
isEdit ? "Callback updated successfully" : `Callback ${callbackName} added successfully`,
isEdit ? t("settings.callbackUpdatedSuccess") : t("settings.callbackAddedSuccess", { callbackName }),
);
if (isEdit) {
@ -412,7 +418,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
} catch (error) {
NotificationsManager.fromBackend(error);
}
NotificationsManager.success("Alerts updated successfully");
NotificationsManager.success(t("settings.alertsUpdatedSuccess"));
};
const handleSaveChanges = (callback: any) => {
if (!accessToken) {
@ -438,7 +444,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
} catch (error) {
NotificationsManager.fromBackend(error);
}
NotificationsManager.success("Callback updated successfully");
NotificationsManager.success(t("settings.callbackUpdatedSuccess"));
};
const handleOk = () => {
@ -541,7 +547,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
try {
setIsDeletingCallback(true);
await deleteCallback(accessToken, callbackToDelete.name);
NotificationsManager.success(`Callback ${callbackToDelete.name} deleted successfully`);
NotificationsManager.success(t("settings.callbackDeletedSuccess", { name: callbackToDelete.name }));
// Refresh the callbacks list
if (userID && userRole) {
@ -568,11 +574,11 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
<TabGroup>
<TabList variant="line" defaultValue="1">
<Tab value="1">Logging Callbacks</Tab>
<Tab value="2">CloudZero Cost Tracking</Tab>
<Tab value="2">Alerting Types</Tab>
<Tab value="3">Alerting Settings</Tab>
<Tab value="4">Email Alerts</Tab>
<Tab value="1">{t("settings.tabLoggingCallbacks")}</Tab>
<Tab value="2">{t("settings.tabCloudZero")}</Tab>
<Tab value="2">{t("settings.tabAlertingTypes")}</Tab>
<Tab value="3">{t("settings.tabAlertingSettings")}</Tab>
<Tab value="4">{t("settings.tabEmailAlerts")}</Tab>
</TabList>
<TabPanels>
<TabPanel>
@ -588,7 +594,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
onTest={async (cb) => {
try {
await serviceHealthCheck(accessToken, cb.name);
NotificationsManager.success("Health check triggered");
NotificationsManager.success(t("settings.healthCheckTriggered"));
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
@ -603,17 +609,21 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
<TabPanel>
<Card>
<Text className="my-2">
Alerts are only supported for Slack Webhook URLs. Get your webhook urls from{" "}
<a href="https://api.slack.com/messaging/webhooks" target="_blank" style={{ color: "blue" }}>
here
</a>
<Trans
i18nKey="settings.alertsSlackOnly"
components={{
a: (
<a href="https://api.slack.com/messaging/webhooks" target="_blank" style={{ color: "blue" }} />
),
}}
/>
</Text>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell></TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
<TableHeaderCell>Slack Webhook URL</TableHeaderCell>
<TableHeaderCell>{t("settings.slackWebhookUrlColumn")}</TableHeaderCell>
</TableRow>
</TableHead>
@ -632,7 +642,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
) : (
<Button className="flex items-center justify-center">
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
✨ Enterprise Feature
{t("settings.enterpriseFeature")}
</a>
</Button>
)
@ -664,23 +674,21 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
</TableBody>
</Table>
<Button size="xs" className="mt-2" onClick={handleSaveAlerts}>
Save Changes
{t("common.save")}
</Button>
<Button
onClick={async () => {
try {
await serviceHealthCheck(accessToken, "slack");
NotificationsManager.success(
"Alert test triggered. Test request to slack made - check logs/alerts on slack to verify",
);
NotificationsManager.success(t("settings.alertTestTriggered"));
} catch (error) {
NotificationsManager.fromBackend(parseErrorMessage(error));
}
}}
className="mx-2"
>
Test Alerts
{t("settings.testAlerts")}
</Button>
</Card>
</TabPanel>
@ -695,7 +703,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
</Grid>
<Modal
title="Add Logging Callback"
title={t("settings.addCallbackModalTitle")}
open={showAddCallbacksModal}
width={800}
onCancel={() => {
@ -712,7 +720,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
style={{ color: "blue" }}
>
{" "}
LiteLLM Docs: Logging
{t("settings.litellmDocsLogging")}
</a>
<Form
@ -744,10 +752,10 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
}}
disabled={isAddingCallback}
>
Cancel
{t("common.cancel")}
</Button2>
<Button2 htmlType="submit" loading={isAddingCallback} disabled={isAddingCallback}>
{isAddingCallback ? "Adding..." : "Add Callback"}
{isAddingCallback ? t("settings.adding") : t("settings.addCallback")}
</Button2>
</div>
</Form>
@ -756,7 +764,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
<Modal
open={showEditCallback}
width={800}
title={"Edit Callback Settings"}
title={t("settings.editCallbackModalTitle")}
onCancel={() => {
setShowEditCallback(false);
setSelectedEditCallback(null);
@ -801,7 +809,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
}}
disabled={isUpdatingCallback}
>
Cancel
{t("common.cancel")}
</Button2>
<Button2
onClick={() => {
@ -810,7 +818,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
loading={isUpdatingCallback}
disabled={isUpdatingCallback}
>
{isUpdatingCallback ? "Saving..." : "Save Changes"}
{isUpdatingCallback ? t("common.saving") : t("common.save")}
</Button2>
</div>
</Form>
@ -818,12 +826,12 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
<DeleteResourceModal
isOpen={showDeleteConfirmModal}
title="Delete Callback"
message="Are you sure you want to delete this callback? This action cannot be undone."
resourceInformationTitle="Callback Information"
title={t("settings.deleteCallbackTitle")}
message={t("settings.deleteCallbackMessage")}
resourceInformationTitle={t("settings.callbackInformationTitle")}
resourceInformation={[
{ label: "Callback Name", value: callbackToDelete?.name },
{ label: "Mode", value: callbackToDelete?.mode || "success" },
{ label: t("settings.callbackNameLabel"), value: callbackToDelete?.name },
{ label: t("settings.callbackModeLabel"), value: callbackToDelete?.mode || "success" },
]}
onCancel={() => {
setShowDeleteConfirmModal(false);

View file

@ -2,15 +2,17 @@ import { ColumnDef } from "@tanstack/react-table";
import { Badge, Text } from "@tremor/react";
import { Tooltip } from "antd";
import { CopyOutlined, LinkOutlined } from "@ant-design/icons";
import { TFunction } from "i18next";
import { Plugin } from "./claude_code_plugins/types";
export const skillHubColumns = (
export const getSkillHubColumns = (
t: TFunction,
showModal: (skill: Plugin) => void,
copyToClipboard: (text: string) => void,
publicPage: boolean = false,
): ColumnDef<Plugin>[] => [
{
header: "Skill Name",
header: t("skillHubTableColumns.skillName"),
accessorKey: "name",
enableSorting: true,
sortingFn: "alphanumeric",
@ -26,7 +28,7 @@ export const skillHubColumns = (
>
{skill.name}
</button>
<Tooltip title="Copy skill name">
<Tooltip title={t("skillHubTableColumns.copySkillName")}>
<CopyOutlined
onClick={() => copyToClipboard(skill.name)}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
@ -41,13 +43,13 @@ export const skillHubColumns = (
},
},
{
header: "Description",
header: t("skillHubTableColumns.description"),
accessorKey: "description",
enableSorting: false,
cell: ({ row }) => <Text className="text-xs line-clamp-2">{row.original.description || "-"}</Text>,
},
{
header: "Category",
header: t("skillHubTableColumns.category"),
accessorKey: "category",
enableSorting: true,
cell: ({ row }) => {
@ -61,13 +63,13 @@ export const skillHubColumns = (
},
},
{
header: "Domain",
header: t("skillHubTableColumns.domain"),
accessorKey: "domain",
enableSorting: true,
cell: ({ row }) => <Text className="text-xs">{row.original.domain || "-"}</Text>,
},
{
header: "Source",
header: t("skillHubTableColumns.source"),
accessorKey: "source",
enableSorting: false,
cell: ({ row }) => {
@ -100,12 +102,12 @@ export const skillHubColumns = (
},
},
{
header: "Status",
header: t("common.status"),
accessorKey: "enabled",
enableSorting: true,
cell: ({ row }) => (
<Badge color={row.original.enabled ? "green" : "gray"} size="xs">
{row.original.enabled ? "Public" : "Draft"}
{row.original.enabled ? t("skillHubTableColumns.statusPublic") : t("skillHubTableColumns.statusDraft")}
</Badge>
),
},

View file

@ -4,6 +4,7 @@ import { CopyOutlined } from "@ant-design/icons";
import { Title } from "@tremor/react";
import { transformRequestCall } from "./networking";
import NotificationsManager from "./molecules/notifications_manager";
import { Trans, useTranslation } from "react-i18next";
interface TransformRequestPanelProps {
accessToken: string | null;
}
@ -15,6 +16,7 @@ interface TransformResponse {
}
const TransformRequestPanel: React.FC<TransformRequestPanelProps> = ({ accessToken }) => {
const { t } = useTranslation();
const [originalRequestJSON, setOriginalRequestJSON] = useState(`{
"model": "openai/gpt-4o",
"messages": [
@ -72,7 +74,7 @@ ${formattedBody}
try {
requestBody = JSON.parse(originalRequestJSON);
} catch (e) {
NotificationsManager.fromBackend("Invalid JSON in request body");
NotificationsManager.fromBackend(t("transformRequest.invalidJson"));
setIsLoading(false);
return;
}
@ -85,7 +87,7 @@ ${formattedBody}
// Make the API call using fetch
if (!accessToken) {
NotificationsManager.fromBackend("No access token found");
NotificationsManager.fromBackend(t("transformRequest.noAccessToken"));
setIsLoading(false);
return;
}
@ -103,17 +105,17 @@ ${formattedBody}
// Update state with the formatted curl command
setTransformedResponse(formattedCurl);
NotificationsManager.success("Request transformed successfully");
NotificationsManager.success(t("transformRequest.transformedSuccess"));
} else {
// Handle the case where the API returns a different format
// Try to extract the parts from a string response if needed
const rawText = typeof data === "string" ? data : JSON.stringify(data);
setTransformedResponse(rawText);
NotificationsManager.info("Transformed request received in unexpected format");
NotificationsManager.info(t("transformRequest.unexpectedFormat"));
}
} catch (err) {
console.error("Error transforming request:", err);
NotificationsManager.fromBackend("Failed to transform request");
NotificationsManager.fromBackend(t("transformRequest.transformFailed"));
} finally {
setIsLoading(false);
}
@ -129,8 +131,8 @@ ${formattedBody}
return (
<div className="w-full m-2" style={{ overflow: "hidden" }}>
<Title>Playground</Title>
<p className="text-sm text-gray-500">See how LiteLLM transforms your request for the specified provider.</p>
<Title>{t("transformRequest.title")}</Title>
<p className="text-sm text-gray-500">{t("transformRequest.subtitle")}</p>
<div
style={{
display: "flex",
@ -156,10 +158,10 @@ ${formattedBody}
}}
>
<div style={{ marginBottom: "24px" }}>
<h2 style={{ fontSize: "24px", fontWeight: "bold", margin: "0 0 4px 0" }}>Original Request</h2>
<p style={{ color: "#666", margin: 0 }}>
The request you would send to LiteLLM /chat/completions endpoint.
</p>
<h2 style={{ fontSize: "24px", fontWeight: "bold", margin: "0 0 4px 0" }}>
{t("transformRequest.originalRequestTitle")}
</h2>
<p style={{ color: "#666", margin: 0 }}>{t("transformRequest.originalRequestDesc")}</p>
</div>
<textarea
@ -179,7 +181,7 @@ ${formattedBody}
value={originalRequestJSON}
onChange={(e) => setOriginalRequestJSON(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Press Cmd/Ctrl + Enter to transform"
placeholder={t("transformRequest.keyboardShortcutHint")}
/>
<div
@ -200,7 +202,7 @@ ${formattedBody}
onClick={handleTransform}
loading={isLoading}
>
<span>Transform</span>
<span>{t("transformRequest.transformButton")}</span>
<span>→</span>
</Button>
</div>
@ -221,11 +223,13 @@ ${formattedBody}
}}
>
<div style={{ marginBottom: "24px" }}>
<h2 style={{ fontSize: "24px", fontWeight: "bold", margin: "0 0 4px 0" }}>Transformed Request</h2>
<p style={{ color: "#666", margin: 0 }}>How LiteLLM transforms your request for the specified provider.</p>
<h2 style={{ fontSize: "24px", fontWeight: "bold", margin: "0 0 4px 0" }}>
{t("transformRequest.transformedRequestTitle")}
</h2>
<p style={{ color: "#666", margin: 0 }}>{t("transformRequest.transformedRequestDesc")}</p>
<br />
<p style={{ color: "#666", margin: 0 }} className="text-xs">
Note: Sensitive headers are not shown.
{t("transformRequest.sensitiveHeadersNote")}
</p>
</div>
@ -278,7 +282,7 @@ ${formattedBody}
size="small"
onClick={() => {
navigator.clipboard.writeText(transformedResponse || "");
NotificationsManager.success("Copied to clipboard");
NotificationsManager.success(t("common.copied"));
}}
/>
</div>
@ -286,11 +290,12 @@ ${formattedBody}
</div>
<div className="mt-4 text-right w-full">
<p className="text-sm text-gray-500">
Found an error? File an issue{" "}
<a href="https://github.com/BerriAI/litellm/issues" target="_blank" rel="noopener noreferrer">
here
</a>
.
<Trans
i18nKey="transformRequest.foundAnError"
components={{
a: <a href="https://github.com/BerriAI/litellm/issues" target="_blank" rel="noopener noreferrer" />,
}}
/>
</p>
</div>
</div>

View file

@ -3,6 +3,7 @@ import { Card, Title, Text, TextInput, Button } from "@tremor/react";
import { useTheme } from "@/contexts/ThemeContext";
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import NotificationsManager from "./molecules/notifications_manager";
import { useTranslation } from "react-i18next";
interface UIThemeSettingsProps {
userID: string | null;
@ -11,6 +12,7 @@ interface UIThemeSettingsProps {
}
const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, accessToken }) => {
const { t } = useTranslation();
const { logoUrl, setLogoUrl, faviconUrl, setFaviconUrl } = useTheme();
const [logoUrlInput, setLogoUrlInput] = useState<string>("");
const [faviconUrlInput, setFaviconUrlInput] = useState<string>("");
@ -62,7 +64,7 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
}),
});
if (response.ok) {
NotificationsManager.success("Theme settings updated successfully!");
NotificationsManager.success(t("uiThemeSettings.saveSuccess"));
setLogoUrl(logoUrlInput || null);
setFaviconUrl(faviconUrlInput || null);
} else {
@ -70,7 +72,7 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
}
} catch (error) {
console.error("Error updating theme settings:", error);
NotificationsManager.fromBackend("Failed to update theme settings");
NotificationsManager.fromBackend(t("uiThemeSettings.saveFailed"));
} finally {
setLoading(false);
}
@ -94,13 +96,13 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
body: JSON.stringify({ logo_url: null, favicon_url: null }),
});
if (response.ok) {
NotificationsManager.success("Theme settings reset to default!");
NotificationsManager.success(t("uiThemeSettings.resetSuccess"));
} else {
throw new Error("Failed to reset");
}
} catch (error) {
console.error("Error resetting theme settings:", error);
NotificationsManager.fromBackend("Failed to reset theme settings");
NotificationsManager.fromBackend(t("uiThemeSettings.resetFailed"));
} finally {
setLoading(false);
}
@ -113,13 +115,13 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
return (
<div className="w-full mx-auto max-w-4xl px-6 py-8">
<div className="mb-8">
<Title className="text-2xl font-bold mb-2">UI Theme Customization</Title>
<Text className="text-gray-600">Customize your LiteLLM admin dashboard with a custom logo and favicon.</Text>
<Title className="text-2xl font-bold mb-2">{t("uiThemeSettings.title")}</Title>
<Text className="text-gray-600">{t("uiThemeSettings.subtitle")}</Text>
</div>
<Card className="shadow-sm p-6">
<div className="space-y-6">
<div>
<Text className="text-sm font-medium text-gray-700 mb-2 block">Custom Logo URL</Text>
<Text className="text-sm font-medium text-gray-700 mb-2 block">{t("uiThemeSettings.logoUrlLabel")}</Text>
<TextInput
placeholder="https://example.com/logo.png"
value={logoUrlInput}
@ -129,12 +131,10 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
}}
className="w-full"
/>
<Text className="text-xs text-gray-500 mt-1">
Enter a URL for your custom logo or leave empty for default
</Text>
<Text className="text-xs text-gray-500 mt-1">{t("uiThemeSettings.logoUrlHint")}</Text>
</div>
<div>
<Text className="text-sm font-medium text-gray-700 mb-2 block">Custom Favicon URL</Text>
<Text className="text-sm font-medium text-gray-700 mb-2 block">{t("uiThemeSettings.faviconUrlLabel")}</Text>
<TextInput
placeholder="https://example.com/favicon.ico"
value={faviconUrlInput}
@ -144,16 +144,14 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
}}
className="w-full"
/>
<Text className="text-xs text-gray-500 mt-1">
Enter a URL for your custom favicon (.ico, .png, or .svg) or leave empty for default
</Text>
<Text className="text-xs text-gray-500 mt-1">{t("uiThemeSettings.faviconUrlHint")}</Text>
</div>
<div className="flex gap-3 pt-4">
<Button onClick={handleSave} loading={loading} disabled={loading} color="indigo">
Save Changes
{t("common.save")}
</Button>
<Button onClick={handleReset} loading={loading} disabled={loading} variant="secondary" color="gray">
Reset to Default
{t("uiThemeSettings.resetToDefault")}
</Button>
</div>
</div>

View file

@ -1773,5 +1773,95 @@
"updateFailed": "Failed to update pass through endpoint",
"deleteSuccess": "Pass through endpoint deleted successfully",
"deleteFailed": "Failed to delete pass through endpoint"
},
"queryParamInput": {
"paramNamePlaceholder": "Parameter Name (e.g., version)",
"paramValuePlaceholder": "Parameter Value (e.g., v1)",
"addButton": "Add Query Parameter"
},
"routePreview": {
"title": "Route Preview",
"subtitle": "How your requests will be routed",
"basicRouting": "Basic routing:",
"yourEndpoint": "Your endpoint",
"forwardsTo": "Forwards to",
"withSubpaths": "With subpaths:",
"yourEndpointSubpath": "Your endpoint + subpath",
"subpathNote": "Any path after {{pathValue}} will be appended to the target URL",
"subpathHint": "<bold>Not seeing the routing you wanted?</bold> Try enabling - Include Subpaths - above - this allows subroutes like <code>/api/v1/models</code> to be forwarded automatically."
},
"settings": {
"tabLoggingCallbacks": "Logging Callbacks",
"tabCloudZero": "CloudZero Cost Tracking",
"tabAlertingTypes": "Alerting Types",
"tabAlertingSettings": "Alerting Settings",
"tabEmailAlerts": "Email Alerts",
"callbackLabel": "Callback",
"callbackRequired": "Please select a callback",
"callbackPlaceholder": "Choose a logging callback...",
"dynamicParamRequired": "Please enter the {{fieldLabel}}",
"dynamicParamEnterYour": "Enter your {{fieldLabel}}",
"dynamicParamEnter": "Enter {{fieldLabel}}",
"loadCallbackConfigsFailed": "Failed to load callback configs: {{error}}",
"callbackUpdatedSuccess": "Callback updated successfully",
"callbackAddedSuccess": "Callback {{callbackName}} added successfully",
"callbackDeletedSuccess": "Callback {{name}} deleted successfully",
"alertsUpdatedSuccess": "Alerts updated successfully",
"healthCheckTriggered": "Health check triggered",
"alertTestTriggered": "Alert test triggered. Test request to slack made - check logs/alerts on slack to verify",
"alertsSlackOnly": "Alerts are only supported for Slack Webhook URLs. Get your webhook urls from <a>here</a>",
"slackWebhookUrlColumn": "Slack Webhook URL",
"enterpriseFeature": "✨ Enterprise Feature",
"testAlerts": "Test Alerts",
"addCallbackModalTitle": "Add Logging Callback",
"litellmDocsLogging": "LiteLLM Docs: Logging",
"adding": "Adding...",
"addCallback": "Add Callback",
"editCallbackModalTitle": "Edit Callback Settings",
"deleteCallbackTitle": "Delete Callback",
"deleteCallbackMessage": "Are you sure you want to delete this callback? This action cannot be undone.",
"callbackInformationTitle": "Callback Information",
"callbackNameLabel": "Callback Name",
"callbackModeLabel": "Mode"
},
"skillHubTableColumns": {
"skillName": "Skill Name",
"copySkillName": "Copy skill name",
"description": "Description",
"category": "Category",
"domain": "Domain",
"source": "Source",
"statusPublic": "Public",
"statusDraft": "Draft"
},
"transformRequest": {
"title": "Playground",
"subtitle": "See how LiteLLM transforms your request for the specified provider.",
"invalidJson": "Invalid JSON in request body",
"noAccessToken": "No access token found",
"transformedSuccess": "Request transformed successfully",
"unexpectedFormat": "Transformed request received in unexpected format",
"transformFailed": "Failed to transform request",
"originalRequestTitle": "Original Request",
"originalRequestDesc": "The request you would send to LiteLLM /chat/completions endpoint.",
"keyboardShortcutHint": "Press Cmd/Ctrl + Enter to transform",
"transformButton": "Transform",
"transformedRequestTitle": "Transformed Request",
"transformedRequestDesc": "How LiteLLM transforms your request for the specified provider.",
"sensitiveHeadersNote": "Note: Sensitive headers are not shown.",
"foundAnError": "Found an error? File an issue <a>here</a>."
},
"uiThemeSettings": {
"title": "UI Theme Customization",
"subtitle": "Customize your LiteLLM admin dashboard with a custom logo and favicon.",
"logoUrlLabel": "Custom Logo URL",
"logoUrlHint": "Enter a URL for your custom logo or leave empty for default",
"faviconUrlLabel": "Custom Favicon URL",
"faviconUrlHint": "Enter a URL for your custom favicon (.ico, .png, or .svg) or leave empty for default",
"saveSuccess": "Theme settings updated successfully!",
"saveFailed": "Failed to update theme settings",
"resetSuccess": "Theme settings reset to default!",
"resetFailed": "Failed to reset theme settings",
"resetToDefault": "Reset to Default"
}
}

View file

@ -1773,5 +1773,95 @@
"updateFailed": "更新透传端点失败",
"deleteSuccess": "透传端点已成功删除",
"deleteFailed": "删除透传端点失败"
},
"queryParamInput": {
"paramNamePlaceholder": "参数名称(如:version)",
"paramValuePlaceholder": "参数值(如:v1)",
"addButton": "添加查询参数"
},
"routePreview": {
"title": "路由预览",
"subtitle": "您的请求将如何被路由",
"basicRouting": "基本路由:",
"yourEndpoint": "您的端点",
"forwardsTo": "转发至",
"withSubpaths": "包含子路径:",
"yourEndpointSubpath": "您的端点 + 子路径",
"subpathNote": "{{pathValue}} 之后的任何路径都将追加到目标 URL",
"subpathHint": "<bold>未看到预期的路由?</bold>请尝试启用上方的「包含子路径」选项,这将允许子路由(如 <code>/api/v1/models</code>)自动转发。"
},
"settings": {
"tabLoggingCallbacks": "日志回调",
"tabCloudZero": "CloudZero 成本追踪",
"tabAlertingTypes": "告警类型",
"tabAlertingSettings": "告警设置",
"tabEmailAlerts": "邮件告警",
"callbackLabel": "回调",
"callbackRequired": "请选择一个回调",
"callbackPlaceholder": "选择日志回调...",
"dynamicParamRequired": "请输入 {{fieldLabel}}",
"dynamicParamEnterYour": "输入您的 {{fieldLabel}}",
"dynamicParamEnter": "输入 {{fieldLabel}}",
"loadCallbackConfigsFailed": "加载回调配置失败:{{error}}",
"callbackUpdatedSuccess": "回调更新成功",
"callbackAddedSuccess": "回调 {{callbackName}} 添加成功",
"callbackDeletedSuccess": "回调 {{name}} 删除成功",
"alertsUpdatedSuccess": "告警更新成功",
"healthCheckTriggered": "健康检查已触发",
"alertTestTriggered": "告警测试已触发,已向 Slack 发送测试请求,请检查 Slack 上的日志/告警进行验证",
"alertsSlackOnly": "告警仅支持 Slack Webhook URL。从<a>此处</a>获取您的 Webhook URL",
"slackWebhookUrlColumn": "Slack Webhook URL",
"enterpriseFeature": "✨ 企业版功能",
"testAlerts": "测试告警",
"addCallbackModalTitle": "添加日志回调",
"litellmDocsLogging": "LiteLLM 文档:日志",
"adding": "添加中...",
"addCallback": "添加回调",
"editCallbackModalTitle": "编辑回调设置",
"deleteCallbackTitle": "删除回调",
"deleteCallbackMessage": "确定要删除此回调吗?此操作无法撤销。",
"callbackInformationTitle": "回调信息",
"callbackNameLabel": "回调名称",
"callbackModeLabel": "模式"
},
"skillHubTableColumns": {
"skillName": "技能名称",
"copySkillName": "复制技能名称",
"description": "描述",
"category": "分类",
"domain": "域",
"source": "来源",
"statusPublic": "公开",
"statusDraft": "草稿"
},
"transformRequest": {
"title": "Playground",
"subtitle": "查看 LiteLLM 如何为指定提供商转换您的请求。",
"invalidJson": "请求体中的 JSON 无效",
"noAccessToken": "未找到访问令牌",
"transformedSuccess": "请求转换成功",
"unexpectedFormat": "收到格式异常的转换请求",
"transformFailed": "请求转换失败",
"originalRequestTitle": "原始请求",
"originalRequestDesc": "您向 LiteLLM /chat/completions 端点发送的请求。",
"keyboardShortcutHint": "按 Cmd/Ctrl + Enter 进行转换",
"transformButton": "转换",
"transformedRequestTitle": "转换后的请求",
"transformedRequestDesc": "LiteLLM 如何为指定提供商转换您的请求。",
"sensitiveHeadersNote": "注意:敏感请求头不会显示。",
"foundAnError": "发现错误?请在<a>此处</a>提交 issue。"
},
"uiThemeSettings": {
"title": "界面主题自定义",
"subtitle": "为您的 LiteLLM 管理仪表盘自定义 Logo 和 Favicon。",
"logoUrlLabel": "自定义 Logo URL",
"logoUrlHint": "输入自定义 Logo 的 URL,留空则使用默认图标",
"faviconUrlLabel": "自定义 Favicon URL",
"faviconUrlHint": "输入自定义 Favicon 的 URL(.ico、.png 或 .svg),留空则使用默认图标",
"saveSuccess": "主题设置更新成功!",
"saveFailed": "更新主题设置失败",
"resetSuccess": "主题设置已重置为默认!",
"resetFailed": "重置主题设置失败",
"resetToDefault": "重置为默认"
}
}