mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-07 07:58:09 +00:00
refactor(ui): move remaining Aria and admin text onto i18n (#50242)
Moves the English left in the Aria assistant, the MCP tool permission groups, the shared chat composer, the Beta badge, the user banner and the metadata key fields onto react-i18next. English output is unchanged. Text that Aria sends to the model (system prompt, tool descriptions, tool results and usage chart payloads) stays English byte for byte. Where the same string is also shown on screen, a display map keyed by the operation name or by the chart's English value translates it at render time, and the model copy is left alone.
This commit is contained in:
parent
09b721ab2e
commit
7cdaa73fc0
17 changed files with 400 additions and 103 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { ArrowUp, Code2, Square } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -38,6 +39,7 @@ export function ChatComposer({
|
|||
onSuggestionSelect,
|
||||
className,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const submitIfAllowed = () => {
|
||||
if (!submitDisabled && !isLoading) {
|
||||
onSubmit();
|
||||
|
|
@ -115,7 +117,7 @@ export function ChatComposer({
|
|||
<InputGroupButton
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
aria-label="Stop request"
|
||||
aria-label={t("chatComposer.stopRequest")}
|
||||
data-testid="chat-stop-button"
|
||||
className="size-8 rounded-xl bg-foreground text-background hover:bg-foreground/90"
|
||||
onClick={onCancel}
|
||||
|
|
@ -126,7 +128,7 @@ export function ChatComposer({
|
|||
<InputGroupButton
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
aria-label="Send message"
|
||||
aria-label={t("chatComposer.sendMessage")}
|
||||
data-testid="chat-send-button"
|
||||
disabled={submitDisabled || isLoading}
|
||||
onClick={submitIfAllowed}
|
||||
|
|
@ -153,6 +155,8 @@ interface CodeInterpreterToggleProps {
|
|||
}
|
||||
|
||||
export function CodeInterpreterToggle({ enabled, onToggle }: CodeInterpreterToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const label = enabled ? t("chatComposer.codeInterpreterEnabled") : t("chatComposer.enableCodeInterpreter");
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
@ -167,16 +171,14 @@ export function CodeInterpreterToggle({ enabled, onToggle }: CodeInterpreterTogg
|
|||
? "border-info/20 bg-info/10 text-info hover:bg-info/15"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label={enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"}
|
||||
aria-label={label}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Code2 className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge";
|
||||
|
||||
export default function BetaBadge({ children, dot = false }: { children?: React.ReactNode; dot?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const disableShowNewBadge = useDisableShowNewBadge();
|
||||
|
||||
if (disableShowNewBadge) {
|
||||
return children ? <>{children}</> : null;
|
||||
}
|
||||
|
||||
const badge = dot ? <Badge className="size-1.5 p-0" /> : <Badge>Beta</Badge>;
|
||||
const badge = dot ? <Badge className="size-1.5 p-0" /> : <Badge>{t("betaBadge.label")}</Badge>;
|
||||
|
||||
return children ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { CircleAlert, Info, TriangleAlert, X } from "lucide-react";
|
||||
import { Alert, AlertAction, AlertDescription } from "@/components/shared/Alert";
|
||||
|
|
@ -36,6 +37,7 @@ interface UserBannerProps {
|
|||
}
|
||||
|
||||
export const UserBanner: React.FC<UserBannerProps> = ({ accessToken }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: banner } = useUserBanner(accessToken);
|
||||
const [dismissedSignature, setDismissedSignature] = useState<string | null>(() =>
|
||||
typeof window === "undefined" ? null : localStorage.getItem(DISMISS_STORAGE_KEY),
|
||||
|
|
@ -62,7 +64,7 @@ export const UserBanner: React.FC<UserBannerProps> = ({ accessToken }) => {
|
|||
<UserBannerMarkdown message={banner.message} />
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Dismiss banner" onClick={handleDismiss}>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={t("userBanner.dismiss")} onClick={handleDismiss}>
|
||||
<X />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { z } from "zod";
|
||||
|
||||
import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -24,11 +25,20 @@ export interface MetadataPair {
|
|||
}
|
||||
|
||||
export const metadataPairsSchema = z
|
||||
.array(z.object({ key: z.string().min(1, "Missing key"), value: z.string().optional() }))
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1, { error: () => i18n.t("metadataKeyValueFields.errors.missingKey") }),
|
||||
value: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.superRefine((pairs, ctx) => {
|
||||
pairs.forEach((pair, index) => {
|
||||
if (pair.key && pairs.filter((other) => other.key === pair.key).length > 1) {
|
||||
ctx.addIssue({ code: "custom", message: "Duplicate key", path: [index, "key"] });
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: i18n.t("metadataKeyValueFields.errors.duplicateKey"),
|
||||
path: [index, "key"],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useRef } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChatMessageContent } from "@/components/chat/ChatMessages";
|
||||
import type { ChatMessage } from "@/components/chat/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -18,14 +19,15 @@ export function AriaAnswer({
|
|||
question?: string;
|
||||
chart?: UsageChart;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const answerRef = useRef<HTMLDivElement>(null);
|
||||
const exportAnswer = () => {
|
||||
if (!answerRef.current || !exportAriaAnswer(question ?? "", answerRef.current)) {
|
||||
toast.error("Could not open the PDF print dialog. Please try again.");
|
||||
toast.error(t("aria.answer.exportFailed"));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<article aria-label={`${ARIA_AGENT_LABEL} answer`} className="space-y-3">
|
||||
<article aria-label={t("aria.answer.ariaLabel", { agent: ARIA_AGENT_LABEL })} className="space-y-3">
|
||||
<div ref={answerRef} className="space-y-4">
|
||||
{chart && <UsageAnswerChart chart={chart} />}
|
||||
<ChatMessageContent
|
||||
|
|
@ -39,8 +41,8 @@ export function AriaAnswer({
|
|||
size="icon-sm"
|
||||
onClick={exportAnswer}
|
||||
data-export-exclude
|
||||
aria-label="Export answer"
|
||||
title="Export answer"
|
||||
aria-label={t("aria.answer.export")}
|
||||
title={t("aria.answer.export")}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -1,12 +1,26 @@
|
|||
import type { ReactNode } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AriaAccess } from "./operations";
|
||||
import { ChartNoAxesColumnIncreasing, Coins, TrendingDown } from "lucide-react";
|
||||
import { ChartNoAxesColumnIncreasing, Coins, TrendingDown, type LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const QUESTIONS = [
|
||||
{ label: "Spend overview", question: "How much have I spent this week?", Icon: Coins },
|
||||
{ label: "Cost by model", question: "Which models are costing the most?", Icon: ChartNoAxesColumnIncreasing },
|
||||
{ label: "Find savings", question: "How can I reduce my spend?", Icon: TrendingDown },
|
||||
const QUESTIONS: { label: ParseKeys; question: ParseKeys; Icon: LucideIcon }[] = [
|
||||
{
|
||||
label: "aria.start.questions.spendOverview.label",
|
||||
question: "aria.start.questions.spendOverview.question",
|
||||
Icon: Coins,
|
||||
},
|
||||
{
|
||||
label: "aria.start.questions.costByModel.label",
|
||||
question: "aria.start.questions.costByModel.question",
|
||||
Icon: ChartNoAxesColumnIncreasing,
|
||||
},
|
||||
{
|
||||
label: "aria.start.questions.findSavings.label",
|
||||
question: "aria.start.questions.findSavings.question",
|
||||
Icon: TrendingDown,
|
||||
},
|
||||
];
|
||||
|
||||
export function AriaStart({
|
||||
|
|
@ -20,31 +34,34 @@ export function AriaStart({
|
|||
disabled: boolean;
|
||||
access: AriaAccess;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const usageDescription =
|
||||
access.mode === "usage" && access.scope.type === "user" ? "Ask about your usage" : "Ask about gateway usage";
|
||||
const description = access.mode === "admin" ? "Manage your gateway · Changes require approval" : usageDescription;
|
||||
access.mode === "usage" && access.scope.type === "user"
|
||||
? t("aria.start.descriptionUser")
|
||||
: t("aria.start.descriptionGateway");
|
||||
const description = access.mode === "admin" ? t("aria.start.descriptionAdmin") : usageDescription;
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 py-10 sm:px-6 sm:pb-[16vh]">
|
||||
<div className="my-auto w-full shrink-0 space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl leading-tight font-medium tracking-[-0.035em] text-foreground sm:text-4xl">
|
||||
What would you like to know?
|
||||
{t("aria.start.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="[&>div]:p-0">{composer}</div>
|
||||
<div aria-label="Suggested questions" className="flex flex-wrap gap-2">
|
||||
<div aria-label={t("aria.start.suggestedQuestions")} className="flex flex-wrap gap-2">
|
||||
{QUESTIONS.map(({ label, question, Icon }) => (
|
||||
<Button
|
||||
key={label}
|
||||
variant="outline"
|
||||
aria-label={question}
|
||||
aria-label={t(question)}
|
||||
disabled={disabled}
|
||||
onClick={() => onQuestion(question)}
|
||||
onClick={() => onQuestion(t(question))}
|
||||
className="h-9 gap-2 rounded-full border-border bg-transparent dark:bg-transparent px-3.5 text-[13px] font-normal text-muted-foreground shadow-none hover:border-foreground/20 hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-3.5" />
|
||||
{label}
|
||||
{t(label)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { uiHref } from "@/utils/uiHref";
|
||||
|
|
@ -149,19 +150,20 @@ export function AriaSidebarLink({ active }: { active: boolean }) {
|
|||
}
|
||||
|
||||
export function LiteAdminPage() {
|
||||
const { t } = useTranslation();
|
||||
const setPageContainer = useContext(LiteAdminContext)?.setPageContainer;
|
||||
if (!setPageContainer)
|
||||
return <p className="p-6 text-sm text-muted-foreground">Aria is not available for this session.</p>;
|
||||
if (!setPageContainer) return <p className="p-6 text-sm text-muted-foreground">{t("aria.page.unavailable")}</p>;
|
||||
return <div ref={setPageContainer} className="flex min-h-0 flex-1 flex-col" />;
|
||||
}
|
||||
|
||||
export function AriaUsageTrigger() {
|
||||
const { t } = useTranslation();
|
||||
const state = useContext(LiteAdminContext);
|
||||
if (!state) return null;
|
||||
return (
|
||||
<Link href={uiHref("aria")} className={cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-1.5")}>
|
||||
<AriaAvatar size={18} />
|
||||
Ask Aria
|
||||
{t("aria.usageTrigger.ask")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -200,6 +202,7 @@ function Destination({
|
|||
retry: () => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [approved, setApproved] = useState(false);
|
||||
if (target?.baseUrl && (!target.requiresConsent || approved)) {
|
||||
return <LiteAdminChat session={{ ...session, inferenceBaseUrl: target.baseUrl }} open={open} />;
|
||||
|
|
@ -208,29 +211,26 @@ function Destination({
|
|||
<Panel
|
||||
composer={
|
||||
<div className="p-4">
|
||||
{loading && <Skeleton className="h-24" aria-label="Loading gateway settings" />}
|
||||
{loading && <Skeleton className="h-24" aria-label={t("aria.destination.loadingSettings")} />}
|
||||
{!loading && !target?.baseUrl && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{target?.error || "Could not load gateway settings."}</AlertDescription>
|
||||
<AlertDescription>{target?.error || t("aria.destination.settingsError")}</AlertDescription>
|
||||
<Button variant="link" onClick={retry}>
|
||||
Retry
|
||||
{t("aria.destination.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
{!loading && target?.baseUrl && (
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Connect to the configured gateway</CardTitle>
|
||||
<CardTitle>{t("aria.destination.consentTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="break-all font-mono text-xs">{target.baseUrl}</p>
|
||||
<p className="text-muted-foreground">
|
||||
This gateway uses a different address. Aria will send your existing session credential to the address
|
||||
shown above.
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("aria.destination.consentBody")}</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button onClick={() => setApproved(true)}>Use configured gateway</Button>
|
||||
<Button onClick={() => setApproved(true)}>{t("aria.destination.useConfigured")}</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)}
|
||||
|
|
@ -292,6 +292,7 @@ function useForegroundReviewFocus(
|
|||
}
|
||||
|
||||
function LiteAdminChat({ session, open }: { session: LiteAdminSession; open: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const state = useContext(LiteAdminContext);
|
||||
const chat = useLiteAdmin(session);
|
||||
const [input, setInput] = useState("");
|
||||
|
|
@ -391,7 +392,7 @@ function LiteAdminChat({ session, open }: { session: LiteAdminSession; open: boo
|
|||
className="text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Start over
|
||||
{t("aria.chat.startOver")}
|
||||
</Button>
|
||||
)}
|
||||
</PanelHeader>
|
||||
|
|
@ -418,13 +419,14 @@ function LiteAdminComposer({
|
|||
onModelChange: (value: string | null) => void;
|
||||
tooLong: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-3 px-4 pt-2 pb-5 sm:px-6">
|
||||
<ModelStatus models={models} />
|
||||
{tooLong && <FieldError>Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters.</FieldError>}
|
||||
{tooLong && <FieldError>{t("aria.composer.tooLong", { limit: MAX_INPUT_LENGTH.toLocaleString() })}</FieldError>}
|
||||
<ChatComposer
|
||||
{...props}
|
||||
placeholder="Ask Aria…"
|
||||
placeholder={t("aria.composer.placeholder")}
|
||||
className="[&>div>[data-slot=input-group]]:min-h-0 [&>div>[data-slot=input-group]]:rounded-2xl [&>div>[data-slot=input-group]]:border-foreground/15 [&>div>[data-slot=input-group]]:shadow-none [&>div>[data-slot=input-group]]:ring-0 dark:[&>div>[data-slot=input-group]]:bg-foreground/[0.03] [&_textarea]:min-h-20 [&_textarea]:px-5 [&_textarea]:pt-5 [&_textarea]:text-base [&_textarea]:placeholder:text-muted-foreground"
|
||||
tools={
|
||||
<ModelPicker
|
||||
|
|
@ -453,15 +455,16 @@ function ModelPicker({
|
|||
disabled: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (models.isPending) return <Skeleton className="h-8 w-full" aria-label="Loading models" />;
|
||||
const { t } = useTranslation();
|
||||
if (models.isPending) return <Skeleton className="h-8 w-full" aria-label={t("aria.modelPicker.loading")} />;
|
||||
return (
|
||||
<SearchSelect
|
||||
aria-label={`${ARIA_AGENT_LABEL} model`}
|
||||
aria-label={t("aria.modelPicker.ariaLabel", { agent: ARIA_AGENT_LABEL })}
|
||||
options={(models.data ?? []).map((item) => ({ value: item.model_group, label: item.model_group }))}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder="Choose a chat model"
|
||||
emptyText={models.data?.length ? "No matching models" : "No available models"}
|
||||
placeholder={t("aria.modelPicker.placeholder")}
|
||||
emptyText={models.data?.length ? t("aria.modelPicker.noMatch") : t("aria.modelPicker.noModels")}
|
||||
disabled={disabled}
|
||||
allowClear={false}
|
||||
className={compact ? "border-transparent shadow-none dark:bg-transparent" : undefined}
|
||||
|
|
@ -470,13 +473,14 @@ function ModelPicker({
|
|||
}
|
||||
|
||||
function ModelStatus({ models }: { models: UseQueryResult<ModelGroup[]> }) {
|
||||
const { t } = useTranslation();
|
||||
if (!models.isError) return null;
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Could not load models.{" "}
|
||||
{t("aria.modelStatus.loadError")}{" "}
|
||||
<Button variant="link" onClick={() => void models.refetch()}>
|
||||
Retry
|
||||
{t("aria.modelStatus.retry")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useEffect, type RefObject } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { ArrowDown, Check, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MessageScroller, useMessageScroller } from "@shadcn/react/message-scroller";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
|
@ -13,12 +15,33 @@ import type { ActionEntry, ConversationEntry } from "./useLiteAdmin";
|
|||
import { AriaAnswer } from "./AriaAnswer";
|
||||
import { ARIA_AGENT_LABEL } from "./aria-brand";
|
||||
|
||||
const ACTION_STATUS: Record<ActionEntry["status"], string> = {
|
||||
review: "Review change",
|
||||
applying: "Applying…",
|
||||
completed: "Completed",
|
||||
cancelled: "Cancelled",
|
||||
unknown: "Check result",
|
||||
const ACTION_STATUS: Record<ActionEntry["status"], ParseKeys> = {
|
||||
review: "aria.conversation.status.review",
|
||||
applying: "aria.conversation.status.applying",
|
||||
completed: "aria.conversation.status.completed",
|
||||
cancelled: "aria.conversation.status.cancelled",
|
||||
unknown: "aria.conversation.status.unknown",
|
||||
};
|
||||
|
||||
const ACTION_TITLES: Record<string, ParseKeys> = {
|
||||
key_create: "aria.actions.keyCreate",
|
||||
key_update: "aria.actions.keyUpdate",
|
||||
key_detach_from_team: "aria.actions.keyDetachFromTeam",
|
||||
key_delete: "aria.actions.keyDelete",
|
||||
key_block: "aria.actions.keyBlock",
|
||||
key_unblock: "aria.actions.keyUnblock",
|
||||
team_create: "aria.actions.teamCreate",
|
||||
team_update: "aria.actions.teamUpdate",
|
||||
team_delete: "aria.actions.teamDelete",
|
||||
team_member_add: "aria.actions.teamMemberAdd",
|
||||
team_member_update: "aria.actions.teamMemberUpdate",
|
||||
team_member_delete: "aria.actions.teamMemberDelete",
|
||||
user_create: "aria.actions.userCreate",
|
||||
user_update: "aria.actions.userUpdate",
|
||||
user_delete: "aria.actions.userDelete",
|
||||
budget_create: "aria.actions.budgetCreate",
|
||||
budget_update: "aria.actions.budgetUpdate",
|
||||
budget_delete: "aria.actions.budgetDelete",
|
||||
};
|
||||
|
||||
interface ConversationProps {
|
||||
|
|
@ -30,12 +53,13 @@ interface ConversationProps {
|
|||
}
|
||||
|
||||
export function LiteAdminConversation({ entries, thinking, open, reviewRef, onAnswer }: ConversationProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MessageScroller.Provider autoScroll>
|
||||
<MessageScroller.Root className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<MessageScroller.Viewport
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain data-pending-scroll:invisible"
|
||||
aria-label={`${ARIA_AGENT_LABEL} conversation`}
|
||||
aria-label={t("aria.conversation.ariaLabel", { agent: ARIA_AGENT_LABEL })}
|
||||
>
|
||||
<MessageScroller.Content className="flex flex-col gap-6 p-4 sm:p-6" aria-busy={thinking}>
|
||||
{entries.map((entry) => (
|
||||
|
|
@ -45,7 +69,7 @@ export function LiteAdminConversation({ entries, thinking, open, reviewRef, onAn
|
|||
<AriaAnswer message={entry.message} chart={entry.chart} question={entry.question} />
|
||||
) : (
|
||||
<article
|
||||
aria-label="Your question"
|
||||
aria-label={t("aria.conversation.yourQuestion")}
|
||||
className="ml-auto w-fit max-w-[90%] whitespace-pre-wrap break-words rounded-xl bg-muted px-4 py-3 text-sm leading-relaxed"
|
||||
>
|
||||
{entry.message.content}
|
||||
|
|
@ -63,17 +87,21 @@ export function LiteAdminConversation({ entries, thinking, open, reviewRef, onAn
|
|||
))}
|
||||
{thinking && (
|
||||
<MessageScroller.Item messageId="thinking">
|
||||
<div role="status" aria-label={`${ARIA_AGENT_LABEL} is working`} className="space-y-2">
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t("aria.conversation.workingLabel", { agent: ARIA_AGENT_LABEL })}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
<span className="sr-only">Working…</span>
|
||||
<span className="sr-only">{t("aria.conversation.working")}</span>
|
||||
</div>
|
||||
</MessageScroller.Item>
|
||||
)}
|
||||
</MessageScroller.Content>
|
||||
</MessageScroller.Viewport>
|
||||
<MessageScroller.Button
|
||||
aria-label="Jump to latest"
|
||||
aria-label={t("aria.conversation.jumpToLatest")}
|
||||
className="absolute bottom-3 left-1/2 -translate-x-1/2 rounded-full shadow-sm data-[active=false]:pointer-events-none data-[active=false]:opacity-0"
|
||||
render={<Button variant="outline" size="icon-sm" />}
|
||||
>
|
||||
|
|
@ -90,8 +118,12 @@ function ActionCard({
|
|||
reviewRef,
|
||||
onAnswer,
|
||||
}: Pick<ConversationProps, "open" | "reviewRef" | "onAnswer"> & { entry: ActionEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const { scrollToEnd } = useMessageScroller();
|
||||
const review = entry.status === "review";
|
||||
const title = Object.hasOwn(ACTION_TITLES, entry.action.name)
|
||||
? t(ACTION_TITLES[entry.action.name])
|
||||
: entry.action.title;
|
||||
useEffect(() => {
|
||||
if (!open || !review) return;
|
||||
scrollToEnd();
|
||||
|
|
@ -123,14 +155,14 @@ function ActionCard({
|
|||
ref={review ? reviewRef : undefined}
|
||||
tabIndex={review ? -1 : undefined}
|
||||
role="region"
|
||||
aria-label={entry.action.title}
|
||||
aria-label={title}
|
||||
size="sm"
|
||||
>
|
||||
<CardHeader className="gap-2">
|
||||
<CardTitle>{entry.action.title}</CardTitle>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<Badge variant={entry.status === "unknown" ? "destructive" : "secondary"} role="status">
|
||||
{entry.status === "completed" && <Check className="size-3" />}
|
||||
{ACTION_STATUS[entry.status]}
|
||||
{t(ACTION_STATUS[entry.status])}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
|
|
@ -140,7 +172,7 @@ function ActionCard({
|
|||
<Collapsible>
|
||||
<CollapsibleTrigger render={<Button variant="ghost" size="xs" />}>
|
||||
<ChevronDown className="size-3" />
|
||||
Details
|
||||
{t("aria.conversation.details")}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>{fields}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
|
@ -154,19 +186,19 @@ function ActionCard({
|
|||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<code className="min-w-0 flex-1 break-all text-xs">{entry.key}</code>
|
||||
<CopyButton value={entry.key} label="Copy generated key" />
|
||||
<CopyButton value={entry.key} label={t("aria.conversation.copyGeneratedKey")} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Copy this key now. It stays only in this chat session.</p>
|
||||
<p className="text-xs text-muted-foreground">{t("aria.conversation.keyNotice")}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
{review && (
|
||||
<CardFooter className="justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => answer(false)}>
|
||||
Cancel
|
||||
{t("aria.conversation.cancel")}
|
||||
</Button>
|
||||
<Button variant={entry.action.destructive ? "destructive" : "default"} onClick={() => answer(true)}>
|
||||
Confirm change
|
||||
{t("aria.conversation.confirmChange")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,54 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart } from "@/components/shared/charts/bar_chart";
|
||||
import { LineChart } from "@/components/shared/charts/line_chart";
|
||||
import { STACKED_USAGE_PALETTE } from "@/components/shared/charts/StackedUsageChart";
|
||||
import { formatUsageChartValue, type UsageChart } from "./usageChart";
|
||||
|
||||
const CHART_TITLES: Record<string, ParseKeys> = {
|
||||
"Daily spend": "aria.chart.titles.dailySpend",
|
||||
"Daily requests": "aria.chart.titles.dailyRequests",
|
||||
"Daily tokens": "aria.chart.titles.dailyTokens",
|
||||
"Spend by model": "aria.chart.titles.spendByModel",
|
||||
"Spend by provider": "aria.chart.titles.spendByProvider",
|
||||
"Token breakdown": "aria.chart.titles.tokenBreakdown",
|
||||
};
|
||||
|
||||
const CHART_SCOPES: Record<string, ParseKeys> = {
|
||||
"Gateway usage": "aria.chart.scopes.gateway",
|
||||
"All teams": "aria.chart.scopes.allTeams",
|
||||
"All tags": "aria.chart.scopes.allTags",
|
||||
"Your usage": "aria.chart.scopes.yours",
|
||||
};
|
||||
|
||||
const CHART_LABELS: Record<string, ParseKeys> = {
|
||||
Unknown: "aria.chart.labels.unknown",
|
||||
Other: "aria.chart.labels.other",
|
||||
Prompt: "aria.chart.labels.prompt",
|
||||
Completion: "aria.chart.labels.completion",
|
||||
"Cache read": "aria.chart.labels.cacheRead",
|
||||
"Cache creation": "aria.chart.labels.cacheCreation",
|
||||
};
|
||||
|
||||
const CHART_NOTES: Record<string, ParseKeys> = {
|
||||
"Up to six leading entries. Other includes remaining recorded spend.": "aria.chart.notes.otherRemainder",
|
||||
"Cache counts are reported separately. Do not add them to total tokens.": "aria.chart.notes.cacheSeparate",
|
||||
};
|
||||
|
||||
export function UsageAnswerChart({ chart }: { chart: UsageChart }) {
|
||||
const { t } = useTranslation();
|
||||
const display = (keys: Record<string, ParseKeys>, text: string) => (Object.hasOwn(keys, text) ? t(keys[text]) : text);
|
||||
const title = display(CHART_TITLES, chart.title);
|
||||
const formatValue = (value: number) => formatUsageChartValue(value, chart.unit);
|
||||
const unavailable = chart.data.filter((point) => point.value == null).map((point) => point.label);
|
||||
const data = chart.data.map((point) => ({ label: point.label, [chart.unit]: point.value }));
|
||||
const unavailable = chart.data
|
||||
.filter((point) => point.value == null)
|
||||
.map((point) => display(CHART_LABELS, point.label));
|
||||
const data = chart.data.map((point) => ({ label: display(CHART_LABELS, point.label), [chart.unit]: point.value }));
|
||||
return (
|
||||
<figure aria-label={chart.title} className="space-y-3 rounded-xl border bg-card p-4">
|
||||
<figure aria-label={title} className="space-y-3 rounded-xl border bg-card p-4">
|
||||
<figcaption className="space-y-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{chart.title}</p>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xl font-semibold tracking-tight tabular-nums">
|
||||
{formatUsageChartValue(chart.total, chart.unit)}
|
||||
{chart.unit !== "usd" && (
|
||||
|
|
@ -20,7 +57,7 @@ export function UsageAnswerChart({ chart }: { chart: UsageChart }) {
|
|||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{chart.scope} · {chart.period}
|
||||
{display(CHART_SCOPES, chart.scope)} · {chart.period}
|
||||
</p>
|
||||
</figcaption>
|
||||
{chart.kind === "bar" ? (
|
||||
|
|
@ -51,17 +88,17 @@ export function UsageAnswerChart({ chart }: { chart: UsageChart }) {
|
|||
className="h-40"
|
||||
/>
|
||||
)}
|
||||
<table className="sr-only" aria-label={`${chart.title} values`}>
|
||||
<table className="sr-only" aria-label={t("aria.chart.valuesLabel", { title })}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">{t("aria.chart.category")}</th>
|
||||
<th scope="col">{chart.unit}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{chart.data.map((point, index) => (
|
||||
<tr key={`${point.label}-${index}`}>
|
||||
<th scope="row">{point.label}</th>
|
||||
<th scope="row">{display(CHART_LABELS, point.label)}</th>
|
||||
<td>{formatUsageChartValue(point.value, chart.unit)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -69,8 +106,8 @@ export function UsageAnswerChart({ chart }: { chart: UsageChart }) {
|
|||
</table>
|
||||
{(chart.note || unavailable.length > 0) && (
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
{chart.note}
|
||||
{unavailable.length > 0 && ` Values unavailable for: ${unavailable.join(", ")}.`}
|
||||
{chart.note && display(CHART_NOTES, chart.note)}
|
||||
{unavailable.length > 0 && ` ${t("aria.chart.valuesUnavailable", { labels: unavailable.join(", ") })}`}
|
||||
</p>
|
||||
)}
|
||||
</figure>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { ChatMessage } from "@/components/chat/types";
|
|||
import { EndpointType, isModeCompatibleWithEndpoint } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { createGatewayClient } from "@/components/llm_calls/gateway_client";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { createLiteAdminOperations, type AriaAccess, type OperationContext } from "./operations";
|
||||
|
||||
export const MAX_INPUT_LENGTH = 8_000;
|
||||
|
|
@ -53,7 +54,7 @@ export function resolveInferenceTarget(candidate: string, managementBaseUrl: str
|
|||
const invalid: InferenceTarget = {
|
||||
baseUrl: null,
|
||||
requiresConsent: false,
|
||||
error: "The gateway must be a valid HTTP(S) URL without credentials, a query, or a fragment.",
|
||||
error: i18n.t("aria.inferenceTarget.invalid"),
|
||||
};
|
||||
try {
|
||||
const page = new URL(pageUrl);
|
||||
|
|
@ -65,7 +66,7 @@ export function resolveInferenceTarget(candidate: string, managementBaseUrl: str
|
|||
return {
|
||||
baseUrl: null,
|
||||
requiresConsent: false,
|
||||
error: "Inference must use HTTPS when the dashboard or management gateway uses HTTPS.",
|
||||
error: i18n.t("aria.inferenceTarget.httpsRequired"),
|
||||
};
|
||||
}
|
||||
return { baseUrl: target.href, requiresConsent: target.origin !== management.origin, error: null };
|
||||
|
|
@ -140,10 +141,8 @@ export async function runLiteAdmin(options: LiteAdminOptions, client?: OpenAI):
|
|||
active();
|
||||
const message = completion.choices[0]?.message;
|
||||
if (message?.tool_calls?.length) {
|
||||
options.onMessage(
|
||||
"I reached the step limit. Any completed actions remain applied; check the relevant page before continuing.",
|
||||
);
|
||||
options.onMessage(i18n.t("aria.agent.stepLimit"));
|
||||
} else if (!message?.content && !message?.refusal) {
|
||||
options.onMessage("The model returned no answer. Try another request or model.");
|
||||
options.onMessage(i18n.t("aria.agent.noAnswer"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import i18n from "@/i18n/i18n";
|
||||
import { ARIA_AGENT_LABEL } from "./aria-brand";
|
||||
|
||||
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
||||
|
|
@ -209,7 +210,7 @@ export function exportAriaAnswer(question: string, answerElement: HTMLElement):
|
|||
const owner = answerElement.ownerDocument;
|
||||
const report = createAriaAnswerDocument(question, answerElement);
|
||||
const frame = owner.createElement("iframe");
|
||||
frame.title = `${ARIA_AGENT_LABEL} print preview`;
|
||||
frame.title = i18n.t("aria.answer.printPreview", { agent: ARIA_AGENT_LABEL });
|
||||
frame.setAttribute("sandbox", "allow-modals allow-same-origin");
|
||||
frame.style.cssText = "position:fixed;inset:0;width:0;height:0;border:0;";
|
||||
owner.body.appendChild(frame);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { zodFunction } from "openai/helpers/zod";
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { ApiError } from "@/lib/http/client";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { generatedKey, projectToolResult, type ResultKind } from "./toolResults";
|
||||
|
|
@ -192,7 +193,7 @@ export function createLiteAdminOperations(context: OperationContext) {
|
|||
if (action) {
|
||||
const result: ActionResult = {
|
||||
status: "unknown",
|
||||
message: "The change could not be verified. Check the resource before trying again.",
|
||||
message: i18n.t("aria.actions.unverified"),
|
||||
};
|
||||
context.onResult(action, result);
|
||||
throw new Error(result.message);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { AriaAccess } from "./operations";
|
||||
|
||||
export const usageChartRequestSchema = z.strictObject({
|
||||
|
|
@ -141,7 +142,7 @@ export function buildUsageChart(source: UsageChartSource, view: UsageChartView):
|
|||
}
|
||||
|
||||
export function formatUsageChartValue(value: number | null, unit: UsageChart["unit"]): string {
|
||||
if (value == null) return "Unavailable";
|
||||
if (value == null) return i18n.t("aria.chart.unavailable");
|
||||
return unit === "usd"
|
||||
? value.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 6 })
|
||||
: value.toLocaleString("en-US");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import type { ChatMessage } from "@/components/chat/types";
|
||||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getCookie } from "@/utils/cookieUtils";
|
||||
|
|
@ -32,7 +34,7 @@ type ActiveTurn = {
|
|||
approval?: { id: string; resolve: (approved: boolean) => void };
|
||||
chart?: UsageChart;
|
||||
};
|
||||
const INTERRUPTED_WRITE = "A submitted change may have completed. Check the relevant page before trying again.";
|
||||
const INTERRUPTED_WRITE: ParseKeys = "aria.session.interruptedWrite";
|
||||
const RESOURCE_QUERIES = new Set([
|
||||
"keys",
|
||||
"infiniteKeys",
|
||||
|
|
@ -59,7 +61,7 @@ export function useLiteAdmin(session: LiteAdminSession) {
|
|||
() => () => {
|
||||
const turn = active.current;
|
||||
active.current = null;
|
||||
if (turn?.outcome === "submitted") toast.warning(INTERRUPTED_WRITE);
|
||||
if (turn?.outcome === "submitted") toast.warning(i18n.t(INTERRUPTED_WRITE));
|
||||
turn?.controller.abort();
|
||||
turn?.approval?.resolve(false);
|
||||
},
|
||||
|
|
@ -168,10 +170,10 @@ export function useLiteAdmin(session: LiteAdminSession) {
|
|||
} catch (error) {
|
||||
if (active.current !== turn) return;
|
||||
if (!sameSession()) {
|
||||
if (turn.outcome === "submitted") toast.warning(INTERRUPTED_WRITE);
|
||||
if (turn.outcome === "submitted") toast.warning(i18n.t(INTERRUPTED_WRITE));
|
||||
setEntries([]);
|
||||
} else if (!turn.controller.signal.aborted && turn.outcome !== "unknown") {
|
||||
const context = turn.outcome === "completed" ? "Completed changes are shown above. " : "";
|
||||
const context = turn.outcome === "completed" ? `${i18n.t("aria.session.completedChangesShown")} ` : "";
|
||||
setEntries((current) => [
|
||||
...current,
|
||||
{ kind: "error", id: crypto.randomUUID(), text: context + extractProxyErrorMessage(error) },
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
|
|||
) : (
|
||||
<ChevronDownIcon className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold text-foreground text-sm">{meta.label}</span>
|
||||
<span className="font-semibold text-foreground text-sm">{t(meta.label)}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${RISK_BADGE[meta.risk]}`}>
|
||||
{meta.risk === "high"
|
||||
? t("mcpTools.crudPermissionPanel.risk.high")
|
||||
|
|
@ -210,7 +210,7 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
|
|||
</p>
|
||||
{/* Checkbox supports `indeterminate`; Switch does not. */}
|
||||
<Checkbox
|
||||
aria-label={t("mcpTools.crudPermissionPanel.allowAllAria", { label: meta.label })}
|
||||
aria-label={t("mcpTools.crudPermissionPanel.allowAllAria", { label: t(meta.label) })}
|
||||
checked={fullyAllowed}
|
||||
indeterminate={partial}
|
||||
onCheckedChange={(checked) => toggleGroup(op, checked)}
|
||||
|
|
@ -223,7 +223,7 @@ const McpCrudPermissionPanel: React.FC<McpCrudPermissionPanelProps> = ({
|
|||
{/* Description row */}
|
||||
{!isCollapsed && (
|
||||
<div className="px-4 pt-2 pb-1 text-xs text-muted-foreground bg-card border-b border-border">
|
||||
{meta.description}
|
||||
{t(meta.description)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -5553,6 +5553,163 @@
|
|||
"autoApply": "Changes apply automatically when the proxy refreshes its configuration."
|
||||
}
|
||||
},
|
||||
"aria": {
|
||||
"page": {
|
||||
"unavailable": "Aria is not available for this session."
|
||||
},
|
||||
"usageTrigger": {
|
||||
"ask": "Ask Aria"
|
||||
},
|
||||
"destination": {
|
||||
"loadingSettings": "Loading gateway settings",
|
||||
"settingsError": "Could not load gateway settings.",
|
||||
"retry": "Retry",
|
||||
"consentTitle": "Connect to the configured gateway",
|
||||
"consentBody": "This gateway uses a different address. Aria will send your existing session credential to the address shown above.",
|
||||
"useConfigured": "Use configured gateway"
|
||||
},
|
||||
"inferenceTarget": {
|
||||
"invalid": "The gateway must be a valid HTTP(S) URL without credentials, a query, or a fragment.",
|
||||
"httpsRequired": "Inference must use HTTPS when the dashboard or management gateway uses HTTPS."
|
||||
},
|
||||
"chat": {
|
||||
"startOver": "Start over"
|
||||
},
|
||||
"composer": {
|
||||
"tooLong": "Keep your message within {{limit}} characters.",
|
||||
"placeholder": "Ask Aria…"
|
||||
},
|
||||
"modelPicker": {
|
||||
"loading": "Loading models",
|
||||
"ariaLabel": "{{agent}} model",
|
||||
"placeholder": "Choose a chat model",
|
||||
"noMatch": "No matching models",
|
||||
"noModels": "No available models"
|
||||
},
|
||||
"modelStatus": {
|
||||
"loadError": "Could not load models.",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"start": {
|
||||
"title": "What would you like to know?",
|
||||
"descriptionAdmin": "Manage your gateway · Changes require approval",
|
||||
"descriptionUser": "Ask about your usage",
|
||||
"descriptionGateway": "Ask about gateway usage",
|
||||
"suggestedQuestions": "Suggested questions",
|
||||
"questions": {
|
||||
"spendOverview": {
|
||||
"label": "Spend overview",
|
||||
"question": "How much have I spent this week?"
|
||||
},
|
||||
"costByModel": {
|
||||
"label": "Cost by model",
|
||||
"question": "Which models are costing the most?"
|
||||
},
|
||||
"findSavings": {
|
||||
"label": "Find savings",
|
||||
"question": "How can I reduce my spend?"
|
||||
}
|
||||
}
|
||||
},
|
||||
"conversation": {
|
||||
"ariaLabel": "{{agent}} conversation",
|
||||
"yourQuestion": "Your question",
|
||||
"workingLabel": "{{agent}} is working",
|
||||
"working": "Working…",
|
||||
"jumpToLatest": "Jump to latest",
|
||||
"status": {
|
||||
"review": "Review change",
|
||||
"applying": "Applying…",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled",
|
||||
"unknown": "Check result"
|
||||
},
|
||||
"details": "Details",
|
||||
"copyGeneratedKey": "Copy generated key",
|
||||
"keyNotice": "Copy this key now. It stays only in this chat session.",
|
||||
"cancel": "Cancel",
|
||||
"confirmChange": "Confirm change"
|
||||
},
|
||||
"actions": {
|
||||
"keyCreate": "Create a virtual key",
|
||||
"keyUpdate": "Update a virtual key",
|
||||
"keyDetachFromTeam": "Remove a virtual key from its team without deleting the key",
|
||||
"keyDelete": "Delete virtual keys",
|
||||
"keyBlock": "Block a virtual key",
|
||||
"keyUnblock": "Unblock a virtual key",
|
||||
"teamCreate": "Create a team",
|
||||
"teamUpdate": "Update a team",
|
||||
"teamDelete": "Delete teams",
|
||||
"teamMemberAdd": "Add a team member",
|
||||
"teamMemberUpdate": "Update a team member",
|
||||
"teamMemberDelete": "Remove a team member",
|
||||
"userCreate": "Create a user without a key",
|
||||
"userUpdate": "Update a user",
|
||||
"userDelete": "Delete users",
|
||||
"budgetCreate": "Create a budget",
|
||||
"budgetUpdate": "Update a budget",
|
||||
"budgetDelete": "Delete a budget",
|
||||
"unverified": "The change could not be verified. Check the resource before trying again."
|
||||
},
|
||||
"session": {
|
||||
"interruptedWrite": "A submitted change may have completed. Check the relevant page before trying again.",
|
||||
"completedChangesShown": "Completed changes are shown above."
|
||||
},
|
||||
"agent": {
|
||||
"stepLimit": "I reached the step limit. Any completed actions remain applied; check the relevant page before continuing.",
|
||||
"noAnswer": "The model returned no answer. Try another request or model."
|
||||
},
|
||||
"answer": {
|
||||
"ariaLabel": "{{agent}} answer",
|
||||
"export": "Export answer",
|
||||
"exportFailed": "Could not open the PDF print dialog. Please try again.",
|
||||
"printPreview": "{{agent}} print preview"
|
||||
},
|
||||
"chart": {
|
||||
"valuesLabel": "{{title}} values",
|
||||
"category": "Category",
|
||||
"valuesUnavailable": "Values unavailable for: {{labels}}.",
|
||||
"unavailable": "Unavailable",
|
||||
"titles": {
|
||||
"dailySpend": "Daily spend",
|
||||
"dailyRequests": "Daily requests",
|
||||
"dailyTokens": "Daily tokens",
|
||||
"spendByModel": "Spend by model",
|
||||
"spendByProvider": "Spend by provider",
|
||||
"tokenBreakdown": "Token breakdown"
|
||||
},
|
||||
"scopes": {
|
||||
"gateway": "Gateway usage",
|
||||
"allTeams": "All teams",
|
||||
"allTags": "All tags",
|
||||
"yours": "Your usage"
|
||||
},
|
||||
"labels": {
|
||||
"unknown": "Unknown",
|
||||
"other": "Other",
|
||||
"prompt": "Prompt",
|
||||
"completion": "Completion",
|
||||
"cacheRead": "Cache read",
|
||||
"cacheCreation": "Cache creation"
|
||||
},
|
||||
"notes": {
|
||||
"otherRemainder": "Up to six leading entries. Other includes remaining recorded spend.",
|
||||
"cacheSeparate": "Cache counts are reported separately. Do not add them to total tokens."
|
||||
}
|
||||
}
|
||||
},
|
||||
"betaBadge": {
|
||||
"label": "Beta"
|
||||
},
|
||||
"chatComposer": {
|
||||
"stopRequest": "Stop request",
|
||||
"sendMessage": "Send message",
|
||||
"codeInterpreterEnabled": "Code Interpreter enabled (click to disable)",
|
||||
"enableCodeInterpreter": "Enable Code Interpreter"
|
||||
},
|
||||
"userBanner": {
|
||||
"dismiss": "Dismiss banner"
|
||||
},
|
||||
"caching": {
|
||||
"dashboard": {
|
||||
"tabs": {
|
||||
|
|
@ -6640,6 +6797,10 @@
|
|||
}
|
||||
},
|
||||
"metadataKeyValueFields": {
|
||||
"errors": {
|
||||
"duplicateKey": "Duplicate key",
|
||||
"missingKey": "Missing key"
|
||||
},
|
||||
"keyPlaceholder": "Key",
|
||||
"valuePlaceholder": "Value",
|
||||
"removePair": "Remove key-value pair",
|
||||
|
|
@ -13523,6 +13684,28 @@
|
|||
}
|
||||
},
|
||||
"crudPermissionPanel": {
|
||||
"groups": {
|
||||
"read": {
|
||||
"label": "Read",
|
||||
"description": "Safe operations — fetch, list, search. No side effects."
|
||||
},
|
||||
"create": {
|
||||
"label": "Create",
|
||||
"description": "Add new resources — insert, upload, register."
|
||||
},
|
||||
"update": {
|
||||
"label": "Update",
|
||||
"description": "Modify existing resources — edit, patch, rename."
|
||||
},
|
||||
"delete": {
|
||||
"label": "Delete",
|
||||
"description": "Destructive operations — remove, purge, destroy."
|
||||
},
|
||||
"unknown": {
|
||||
"label": "Other",
|
||||
"description": "Operations that could not be automatically classified."
|
||||
}
|
||||
},
|
||||
"risk": {
|
||||
"high": "High Risk",
|
||||
"medium": "Medium Risk",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
|
||||
export type CrudOp = "read" | "create" | "update" | "delete" | "unknown";
|
||||
|
||||
const DELETE_RE = /\b(delete|remove|destroy|purge|drop|erase|unlink)\b/i;
|
||||
|
|
@ -56,31 +58,31 @@ export function groupToolsByCrud(tools: MCPToolEntry[]): Record<CrudOp, MCPToolE
|
|||
|
||||
export const CRUD_GROUP_META: Record<
|
||||
CrudOp,
|
||||
{ label: string; description: string; risk: "low" | "medium" | "high" | "unknown" }
|
||||
{ label: ParseKeys; description: ParseKeys; risk: "low" | "medium" | "high" | "unknown" }
|
||||
> = {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "Safe operations — fetch, list, search. No side effects.",
|
||||
label: "mcpTools.crudPermissionPanel.groups.read.label",
|
||||
description: "mcpTools.crudPermissionPanel.groups.read.description",
|
||||
risk: "low",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description: "Add new resources — insert, upload, register.",
|
||||
label: "mcpTools.crudPermissionPanel.groups.create.label",
|
||||
description: "mcpTools.crudPermissionPanel.groups.create.description",
|
||||
risk: "medium",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
description: "Modify existing resources — edit, patch, rename.",
|
||||
label: "mcpTools.crudPermissionPanel.groups.update.label",
|
||||
description: "mcpTools.crudPermissionPanel.groups.update.description",
|
||||
risk: "medium",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Destructive operations — remove, purge, destroy.",
|
||||
label: "mcpTools.crudPermissionPanel.groups.delete.label",
|
||||
description: "mcpTools.crudPermissionPanel.groups.delete.description",
|
||||
risk: "high",
|
||||
},
|
||||
unknown: {
|
||||
label: "Other",
|
||||
description: "Operations that could not be automatically classified.",
|
||||
label: "mcpTools.crudPermissionPanel.groups.unknown.label",
|
||||
description: "mcpTools.crudPermissionPanel.groups.unknown.description",
|
||||
risk: "unknown",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue