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:
yuneng-jiang 2026-11-05 12:43:09 -08:00 • committed by GitHub
parent 09b721ab2e
commit 7cdaa73fc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 400 additions and 103 deletions

View file

@ -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>
);
}

View file

@ -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">

View file

@ -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>

View file

@ -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"],
});
}
});
});

View file

@ -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" />

View file

@ -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>

View file

@ -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>

View file

@ -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>
)}

View file

@ -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>

View file

@ -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"));
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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");

View file

@ -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) },

View file

@ -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>
)}

View file

@ -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",

View file

@ -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",
},
};