refactor(ui): move playground and chat text onto i18n (#49991)

Moves the page-authored English on /playground, the /chat app and /moyai
into t() and Trans with keys in en.json, as a pure text move. The rendered
English is unchanged
This commit is contained in:
yuneng-jiang 2026-11-04 11:34:15 -08:00 • committed by GitHub
parent d9fbfbd03b
commit 3492453262
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 3902 additions and 1449 deletions

View file

@ -2,6 +2,8 @@
import { useId, useState } from "react";
import { Check, ExternalLink, KeyRound } from "lucide-react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { Button } from "@/components/ui/button";
import { InputGroupButton } from "@/components/ui/input-group";
@ -13,13 +15,14 @@ import type { MCPServer } from "@/components/mcp_tools/types";
import { fetchClient } from "@/lib/http/api";
import { ApiError } from "@/lib/http/client";
import { toast } from "@/lib/toast";
import i18n from "@/i18n/i18n";
const saveErrorMessage = (error: unknown): string => {
const saveErrorMessage = (error: unknown, t: TFunction = i18n.t.bind(i18n)): string => {
if (error instanceof ApiError) {
const detail = (error.body as { detail?: { error?: string } } | null)?.detail?.error;
if (detail) return detail;
}
return error instanceof Error && error.message ? error.message : "Failed to connect";
return error instanceof Error && error.message ? error.message : t("playground.chat.byok.failed");
};
export function ByokConnect({
@ -33,6 +36,7 @@ export function ByokConnect({
onConnected: () => void;
onCancel?: () => void;
}) {
const { t } = useTranslation();
const [credential, setCredential] = useState("");
const [save, setSave] = useState(true);
const [isSaving, setIsSaving] = useState(false);
@ -41,7 +45,7 @@ export function ByokConnect({
const connect = async () => {
if (!credential.trim()) {
toast.error("Enter your API key");
toast.error(t("playground.chat.byok.enterKey"));
return;
}
setIsSaving(true);
@ -50,11 +54,11 @@ export function ByokConnect({
params: { path: { server_id: server.server_id } },
body: { credential: credential.trim(), save },
});
toast.success(`Connected to ${name}`);
toast.success(t("playground.chat.byok.connected", { name }));
setCredential("");
onConnected();
} catch (error) {
toast.error(saveErrorMessage(error));
toast.error(saveErrorMessage(error, t));
} finally {
setIsSaving(false);
}
@ -65,12 +69,14 @@ export function ByokConnect({
<div className="flex flex-col gap-1">
<p className="flex items-center gap-2 text-sm font-medium">
<KeyRound className="size-4 text-muted-foreground" />
{server.has_user_credential ? `Replace your ${name} API key` : `${name} needs your API key`}
{server.has_user_credential
? t("playground.chat.byok.replaceTitle", { name })
: t("playground.chat.byok.connectTitle", { name })}
</p>
<p className="text-sm text-muted-foreground">
{server.has_user_credential
? `The new key replaces the one the gateway uses for ${name}.`
: `The gateway calls ${name} with your own key. Connect it to list and use its tools.`}
? t("playground.chat.byok.replaceDescription", { name })
: t("playground.chat.byok.connectDescription", { name })}
</p>
</div>
{server.byok_description && server.byok_description.length > 0 && (
@ -84,17 +90,17 @@ export function ByokConnect({
</ul>
)}
<Field>
<FieldLabel htmlFor={keyId}>{name} API key</FieldLabel>
<FieldLabel htmlFor={keyId}>{t("playground.chat.byok.keyLabel", { name })}</FieldLabel>
<PasswordInput
id={keyId}
value={credential}
onChange={(event) => setCredential(event.target.value)}
placeholder="Paste your API key"
placeholder={t("playground.chat.byok.keyPlaceholder")}
/>
{server.byok_api_key_help_url && (
<FieldDescription>
<a href={server.byok_api_key_help_url} target="_blank" rel="noopener noreferrer">
Where to find this key
{t("playground.chat.byok.helpLink")}
<ExternalLink className="ml-1 inline size-3" />
</a>
</FieldDescription>
@ -102,18 +108,18 @@ export function ByokConnect({
</Field>
<Field orientation="horizontal">
<FieldLabel htmlFor={saveId} className="font-normal">
Save the key for next time
{t("playground.chat.byok.save")}
</FieldLabel>
<Switch id={saveId} checked={save} onCheckedChange={setSave} />
</Field>
<div className="flex gap-2">
<Button size="sm" onClick={() => void connect()} disabled={isSaving}>
{isSaving && <Spinner />}
Connect
{t("playground.chat.byok.connect")}
</Button>
{onCancel && (
<Button variant="ghost" size="sm" onClick={onCancel} disabled={isSaving}>
Cancel
{t("playground.chat.byok.cancel")}
</Button>
)}
</div>
@ -130,12 +136,13 @@ export function ByokKeyButton({
name: string;
onConnected: () => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={<InputGroupButton size="xs" className="shrink-0" />}>
<KeyRound />
{server.has_user_credential ? "Replace key" : "Connect key"}
{server.has_user_credential ? t("playground.chat.byok.replaceKey") : t("playground.chat.byok.connectKey")}
</PopoverTrigger>
<PopoverContent align="start" className="w-80 p-0">
<ByokConnect

View file

@ -2,6 +2,7 @@
import { useRef, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode, type RefObject } from "react";
import { ArrowUp, Code2, Database, Paperclip, Shield, Square } from "lucide-react";
import { useTranslation } from "react-i18next";
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import MCPToolArgumentsForm, { type MCPToolArgumentsFormRef } from "@/components/mcp_tools/MCPToolArgumentsForm";
import type { MCPTool } from "@/components/mcp_tools/types";
@ -38,9 +39,10 @@ function AttachButton({
capabilities: Capabilities;
attach: (files: readonly File[]) => void;
}) {
const { t } = useTranslation();
const input = useRef<HTMLInputElement>(null);
if (!capabilities.attach) return null;
const label = ATTACH_LABEL[capabilities.attach];
const label = t(ATTACH_LABEL[capabilities.attach]);
return (
<>
<Tooltip>
@ -69,9 +71,15 @@ function AttachButton({
}
function SendButton({ draft, disabled }: { draft: DraftState; disabled: boolean }) {
const { t } = useTranslation();
if (draft.isLoading)
return (
<InputGroupButton size="icon-xs" variant="default" aria-label="Stop" onClick={draft.cancel}>
<InputGroupButton
size="icon-xs"
variant="default"
aria-label={t("playground.chat.composer.stop")}
onClick={draft.cancel}
>
<Square className="fill-current" />
</InputGroupButton>
);
@ -79,7 +87,7 @@ function SendButton({ draft, disabled }: { draft: DraftState; disabled: boolean
<InputGroupButton
size="icon-xs"
variant="default"
aria-label="Send message"
aria-label={t("playground.chat.composer.send")}
disabled={disabled}
onClick={() => void draft.send()}
>
@ -115,6 +123,7 @@ export function Composer({
settingsControl: ReactNode;
sendDisabled: boolean;
}) {
const { t } = useTranslation();
const isDirectCall = endpoint === EndpointType.MCP;
const isRealtime = endpoint === EndpointType.REALTIME;
const message = useRef<HTMLTextAreaElement>(null);
@ -138,7 +147,7 @@ export function Composer({
{directTool ? (
<MCPToolArgumentsForm ref={toolArgsForm} tool={directTool} className="flex flex-col gap-3" />
) : (
<p className="text-sm text-muted-foreground">Pick a server and a tool above to fill in its arguments</p>
<p className="text-sm text-muted-foreground">{t("playground.chat.composer.pickTool")}</p>
)}
</div>
);
@ -148,9 +157,9 @@ export function Composer({
value={draft.text}
onChange={(event) => draft.setText(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={capabilities.placeholder}
placeholder={t(capabilities.placeholder)}
rows={2}
aria-label="Message"
aria-label={t("playground.chat.composer.message")}
className="max-h-60"
/>
);
@ -168,21 +177,31 @@ export function Composer({
<InputGroupAddon align="block-end" className="gap-1" onClick={focusMessage}>
{capabilities.mcp && mcpControl}
{capabilities.vectorStores && (
<ChecklistMenu label="Vector stores" icon={<Database />} emptyText="No vector stores yet" {...vectorStores} />
<ChecklistMenu
label={t("playground.chat.composer.vectorStores")}
icon={<Database />}
emptyText={t("playground.chat.composer.noVectorStores")}
{...vectorStores}
/>
)}
{capabilities.guardrails && (
<ChecklistMenu label="Guardrails" icon={<Shield />} emptyText="No guardrails yet" {...guardrails} />
<ChecklistMenu
label={t("playground.chat.composer.guardrails")}
icon={<Shield />}
emptyText={t("playground.chat.composer.noGuardrails")}
{...guardrails}
/>
)}
{capabilities.codeInterpreter && (
<Toggle
size="sm"
pressed={codeInterpreter.enabled}
onPressedChange={codeInterpreter.onToggle}
aria-label="Code interpreter"
aria-label={t("playground.chat.composer.codeInterpreter")}
className="h-6 gap-1 px-1.5 text-xs"
>
<Code2 />
Code interpreter
{t("playground.chat.composer.codeInterpreter")}
</Toggle>
)}
<span className="ml-auto flex items-center gap-1">

View file

@ -1,6 +1,7 @@
"use client";
import { ChevronDown, FileText, Music, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import {
Attachment,
@ -33,16 +34,19 @@ export function EndpointMenu({
value: EndpointType;
onValueChange: (value: EndpointType) => void;
}) {
const { t } = useTranslation();
const current = ENDPOINT_OPTIONS.find((option) => option.value === value);
return (
<DropdownMenu>
<DropdownMenuTrigger render={<InputGroupButton size="xs" className="shrink-0" aria-label="Endpoint" />}>
<DropdownMenuTrigger
render={<InputGroupButton size="xs" className="shrink-0" aria-label={t("playground.chat.controls.endpoint")} />}
>
<span className="font-mono text-xs">{shortEndpoint(current?.label ?? value)}</span>
<ChevronDown className="text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuRadioGroup value={value} onValueChange={(next) => onValueChange(next as EndpointType)}>
<DropdownMenuLabel>Endpoint</DropdownMenuLabel>
<DropdownMenuLabel>{t("playground.chat.controls.endpoint")}</DropdownMenuLabel>
{ENDPOINT_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value} closeOnClick className="font-mono text-xs">
{option.label}
@ -61,19 +65,24 @@ export function VoiceMenu({
value: OpenAIVoice;
onValueChange: (value: OpenAIVoice) => void;
}) {
const { t } = useTranslation();
const current = OPEN_AI_VOICE_SELECT_OPTIONS.find((option) => option.value === value);
return (
<DropdownMenu>
<DropdownMenuTrigger render={<InputGroupButton size="xs" className="shrink-0" aria-label="Voice" />}>
<span className="text-xs">Voice: {current?.label.split(" - ")[0] ?? value}</span>
<DropdownMenuTrigger
render={<InputGroupButton size="xs" className="shrink-0" aria-label={t("playground.chat.controls.voice")} />}
>
<span className="text-xs">
{t("playground.chat.controls.voiceValue", { voice: current ? t(current.label).split(" - ")[0] : value })}
</span>
<ChevronDown className="text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
<DropdownMenuRadioGroup value={value} onValueChange={(next) => onValueChange(next as OpenAIVoice)}>
<DropdownMenuLabel>Voice</DropdownMenuLabel>
<DropdownMenuLabel>{t("playground.chat.controls.voice")}</DropdownMenuLabel>
{OPEN_AI_VOICE_SELECT_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value} closeOnClick>
{option.label}
{t(option.label)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
@ -99,6 +108,7 @@ export function DraftAttachments({
files: readonly DraftFile[];
onRemove: (index: number) => void;
}) {
const { t } = useTranslation();
return (
<AttachmentGroup className="w-full">
{files.map((draft, index) => (
@ -109,7 +119,10 @@ export function DraftAttachments({
<AttachmentDescription>{formatBytes(draft.file.size)}</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label={`Remove ${draft.file.name}`} onClick={() => onRemove(index)}>
<AttachmentAction
aria-label={t("playground.chat.controls.removeFile", { name: draft.file.name })}
onClick={() => onRemove(index)}
>
<X />
</AttachmentAction>
</AttachmentActions>

View file

@ -16,6 +16,9 @@ import {
Timer,
Wrench,
} from "lucide-react";
import type { TFunction } from "i18next";
import { Trans, useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
@ -68,11 +71,20 @@ const fellBack = (message: MessageType): boolean => (message.trace?.attemptedFal
export const servedModel = (message: MessageType): string | undefined =>
message.trace?.modelGroup ?? (message.isError ? undefined : message.model ?? message.trace?.requestedModel);
const replyModel = (message: MessageType): Readonly<{ value: string; detail: string }> | null => {
const replyModel = (
message: MessageType,
t: TFunction = i18n.t.bind(i18n),
): Readonly<{ value: string; detail: string }> | null => {
const served = servedModel(message);
if (served) return { value: served, detail: fellBack(message) ? "Served by, after a fallback" : "Served by" };
if (served)
return {
value: served,
detail: fellBack(message)
? t("playground.chat.conversation.servedByAfterFallback")
: t("playground.chat.conversation.servedBy"),
};
const requested = message.trace?.requestedModel ?? message.model;
return requested ? { value: requested, detail: "Requested model" } : null;
return requested ? { value: requested, detail: t("playground.chat.conversation.requestedModel") } : null;
};
function Metric({ icon, value, label }: { icon: ReactNode; value: string; label?: string }) {
@ -86,35 +98,60 @@ function Metric({ icon, value, label }: { icon: ReactNode; value: string; label?
}
function ReplyMetrics({ message }: { message: MessageType }) {
const { t } = useTranslation();
const usage = message.usage;
const cost = usage?.cost ?? message.trace?.cost;
const cached = usage?.servedFromResponseCache || message.trace?.cacheHit;
return (
<div
aria-label="Request metrics"
aria-label={t("playground.chat.conversation.metrics.label")}
className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground tabular-nums"
>
{message.timeToFirstToken !== undefined && (
<Metric icon={<Timer />} value={formatSeconds(message.timeToFirstToken)} label="to first token" />
<Metric
icon={<Timer />}
value={formatSeconds(message.timeToFirstToken)}
label={t("playground.chat.conversation.metrics.firstToken")}
/>
)}
{message.totalLatency !== undefined && (
<Metric icon={<Clock />} value={formatSeconds(message.totalLatency)} label="total" />
<Metric
icon={<Clock />}
value={formatSeconds(message.totalLatency)}
label={t("playground.chat.conversation.metrics.total")}
/>
)}
{usage?.promptTokens !== undefined && (
<Metric icon={<ArrowDownToLine />} value={String(usage.promptTokens)} label="in" />
<Metric
icon={<ArrowDownToLine />}
value={String(usage.promptTokens)}
label={t("playground.chat.conversation.metrics.in")}
/>
)}
{usage?.completionTokens !== undefined && (
<Metric icon={<ArrowUpFromLine />} value={String(usage.completionTokens)} label="out" />
<Metric
icon={<ArrowUpFromLine />}
value={String(usage.completionTokens)}
label={t("playground.chat.conversation.metrics.out")}
/>
)}
{usage?.reasoningTokens ? (
<Metric icon={<Brain />} value={String(usage.reasoningTokens)} label="reasoning" />
<Metric
icon={<Brain />}
value={String(usage.reasoningTokens)}
label={t("playground.chat.conversation.metrics.reasoning")}
/>
) : null}
{usage?.cacheReadTokens ? (
<Metric icon={<DatabaseZap />} value={String(usage.cacheReadTokens)} label="cached" />
<Metric
icon={<DatabaseZap />}
value={String(usage.cacheReadTokens)}
label={t("playground.chat.conversation.metrics.cached")}
/>
) : null}
{cost !== undefined && <Metric icon={<Coins />} value={formatCost(cost)} />}
{message.toolName && <Metric icon={<Wrench />} value={message.toolName} />}
{cached && <Badge variant="secondary">Cached response</Badge>}
{cached && <Badge variant="secondary">{t("playground.chat.conversation.metrics.cachedResponse")}</Badge>}
</div>
);
}
@ -152,7 +189,10 @@ function UserTurn({ message }: { message: MessageType }) {
<BubbleContent className="flex flex-col gap-1">
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Wrench className="size-3.5" />
Called <span className="font-mono text-foreground">{message.mcpToolCall.tool}</span>
<Trans
i18nKey="playground.chat.conversation.calledTool"
components={{ tool: <span className="font-mono text-foreground">{message.mcpToolCall.tool}</span> }}
/>
</span>
<pre className="font-mono text-xs whitespace-pre-wrap">
{JSON.stringify(message.mcpToolCall.arguments, null, 2)}
@ -190,7 +230,8 @@ function AssistantReply({
inspected: boolean;
onInspect: () => void;
}) {
const model = replyModel(message);
const { t } = useTranslation();
const model = replyModel(message, t);
const showMcpEvents = isLast && extras.mcpEvents.length > 0 && MCP_EVENT_ENDPOINTS.includes(extras.endpoint);
const showCode = isLast && extras.codeInterpreter && extras.endpoint === EndpointType.RESPONSES;
return (
@ -201,7 +242,7 @@ function AssistantReply({
<LongValue
value={model.value}
detail={model.detail}
copyLabel="model name"
copyLabel={t("playground.chat.conversation.modelNameCopy")}
className="max-w-96 text-xs text-muted-foreground"
/>
{fellBack(message) && <FallbackBadge />}
@ -220,7 +261,7 @@ function AssistantReply({
)}
{message.isError ? (
<Alert variant="destructive">
<AlertTitle>The request failed</AlertTitle>
<AlertTitle>{t("playground.chat.conversation.requestFailed")}</AlertTitle>
<AlertDescription className="font-mono text-xs wrap-anywhere">{textOf(message)}</AlertDescription>
</Alert>
) : (
@ -239,17 +280,19 @@ function AssistantReply({
)}
</div>
<span className="flex shrink-0 items-center gap-0.5">
{!message.isImage && !message.isAudio && <CopyIconButton value={textOf(message)} label="reply" />}
{!message.isImage && !message.isAudio && (
<CopyIconButton value={textOf(message)} label={t("playground.chat.conversation.replyCopy")} />
)}
{message.trace && (
<Toggle
size="sm"
pressed={inspected}
onPressedChange={onInspect}
aria-label="Inspect this request"
aria-label={t("playground.chat.conversation.inspectLabel")}
className="h-6 gap-1 px-1.5 text-xs"
>
<ScanSearch />
Inspect
{t("playground.chat.conversation.inspect")}
</Toggle>
)}
</span>
@ -260,12 +303,17 @@ function AssistantReply({
}
function WaitingTurn({ target }: { target: string | undefined }) {
const { t } = useTranslation();
return (
<Message>
<MessageContent className="text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Spinner className="size-3.5" />
<span className="shimmer">{target ? `Waiting for ${target}` : "Waiting for a reply"}</span>
<span className="shimmer">
{target
? t("playground.chat.conversation.waitingFor", { target })
: t("playground.chat.conversation.waitingForReply")}
</span>
</div>
</MessageContent>
</Message>
@ -273,12 +321,13 @@ function WaitingTurn({ target }: { target: string | undefined }) {
}
function SwitchMarker({ model }: { model: string }) {
const { t } = useTranslation();
return (
<Marker variant="separator">
<MarkerIcon>
<Repeat />
</MarkerIcon>
<MarkerContent>Switched to {model}</MarkerContent>
<MarkerContent>{t("playground.chat.conversation.switchedTo", { model })}</MarkerContent>
</Marker>
);
}
@ -290,16 +339,15 @@ function EmptyConversation({
suggestions: readonly string[];
onPick: (text: string) => void;
}) {
const { t } = useTranslation();
return (
<Empty className="flex-1">
<EmptyHeader>
<EmptyMedia variant="icon">
<PlayCircle />
</EmptyMedia>
<EmptyTitle>Send a test request</EmptyTitle>
<EmptyDescription>
Pick a model and anything the request should go through, then inspect what the gateway did with it
</EmptyDescription>
<EmptyTitle>{t("playground.chat.conversation.empty.title")}</EmptyTitle>
<EmptyDescription>{t("playground.chat.conversation.empty.description")}</EmptyDescription>
</EmptyHeader>
{suggestions.length > 0 && (
<EmptyContent className="max-w-xl flex-row flex-wrap justify-center gap-2">
@ -340,13 +388,14 @@ export function Conversation({
suggestions: readonly string[];
onPickSuggestion: (text: string) => void;
}) {
const { t } = useTranslation();
if (messages.length === 0) return <EmptyConversation suggestions={suggestions} onPick={onPickSuggestion} />;
const lastIndex = messages.length - 1;
const waiting = isLoading && messages[lastIndex]?.role === "user";
return (
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
<MessageScroller className="min-h-0 flex-1">
<MessageScrollerViewport aria-label="Conversation" className="scroll-fade-b">
<MessageScrollerViewport aria-label={t("playground.chat.conversation.label")} className="scroll-fade-b">
<MessageScrollerContent className="mx-auto w-full max-w-3xl gap-6 px-6 py-6">
{messages.flatMap((message, index) => {
const switched = switchedModel(messages, index);

View file

@ -14,6 +14,9 @@ import {
Shuffle,
Timer,
} from "lucide-react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import type { MessageType } from "@/components/chat_ui/types";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
@ -80,130 +83,175 @@ const millis = (value: number | undefined): string | undefined =>
value === undefined ? undefined : `${Math.round(value)}ms`;
function Routing({ message }: { message: MessageType }) {
const { t } = useTranslation();
const trace = message.trace;
const served = servedModel(message);
const fellBack = (trace?.attemptedFallbacks ?? 0) > 0;
const reported = Boolean(trace?.callId || trace?.modelGroup || trace?.providerModel);
return (
<TraceSection value="routing" title="Routing">
<TraceSection value="routing" title={t("playground.chat.inspector.routing.title")}>
{trace?.requestedModel && (
<TraceRow title="Requested">
<LongValue value={trace.requestedModel} detail="Requested model" copyLabel="model name" className="text-xs" />
<TraceRow title={t("playground.chat.inspector.routing.requested")}>
<LongValue
value={trace.requestedModel}
detail={t("playground.chat.inspector.routing.requestedDetail")}
copyLabel={t("playground.chat.inspector.routing.modelNameCopy")}
className="text-xs"
/>
</TraceRow>
)}
{served && (
<TraceRow title="Served by">
<TraceRow title={t("playground.chat.inspector.routing.servedBy")}>
{fellBack && <FallbackBadge />}
<LongValue value={served} detail="Model group that answered" copyLabel="model name" className="text-xs" />
<LongValue
value={served}
detail={t("playground.chat.inspector.routing.servedByDetail")}
copyLabel={t("playground.chat.inspector.routing.modelNameCopy")}
className="text-xs"
/>
</TraceRow>
)}
{trace?.providerModel && (
<TraceRow title="Provider model">
<TraceRow title={t("playground.chat.inspector.routing.providerModel")}>
<LongValue
value={trace.providerModel}
detail={trace.deploymentId ? `Deployment ID ${trace.deploymentId}` : "Model sent to the provider"}
copyLabel="provider model"
detail={
trace.deploymentId
? t("playground.chat.inspector.routing.deploymentDetail", { deploymentId: trace.deploymentId })
: t("playground.chat.inspector.routing.providerModelDetail")
}
copyLabel={t("playground.chat.inspector.routing.providerModelCopy")}
className="text-xs"
align="end"
/>
</TraceRow>
)}
{trace?.apiBase && (
<TraceRow title="API base">
<TraceRow title={t("playground.chat.inspector.routing.apiBase")}>
<LongValue
value={trace.apiBase}
detail="Provider endpoint"
copyLabel="API base"
detail={t("playground.chat.inspector.routing.apiBaseDetail")}
copyLabel={t("playground.chat.inspector.routing.apiBaseCopy")}
className="text-xs"
align="end"
/>
</TraceRow>
)}
{trace?.callId && (
<TraceRow title="Call ID">
<TraceRow title={t("playground.chat.inspector.routing.callId")}>
<span className="min-w-0 truncate font-mono text-xs">{trace.callId}</span>
<CopyIconButton value={trace.callId} label="call ID" />
<CopyIconButton value={trace.callId} label={t("playground.chat.inspector.routing.callIdCopy")} />
</TraceRow>
)}
{trace?.responseId && (
<TraceRow title="Response ID">
<TraceRow title={t("playground.chat.inspector.routing.responseId")}>
<span className="min-w-0 truncate font-mono text-xs">{trace.responseId}</span>
<CopyIconButton value={trace.responseId} label="response ID" />
<CopyIconButton value={trace.responseId} label={t("playground.chat.inspector.routing.responseIdCopy")} />
</TraceRow>
)}
<StatGrid
stats={[
{ label: "Fallbacks", icon: <Shuffle />, value: count(trace?.attemptedFallbacks) },
{ label: "Retries", icon: <RotateCw />, value: count(trace?.attemptedRetries) },
{ label: "Overhead", icon: <Gauge />, value: millis(trace?.overheadMs) },
{
label: t("playground.chat.inspector.routing.fallbacks"),
icon: <Shuffle />,
value: count(trace?.attemptedFallbacks),
},
{
label: t("playground.chat.inspector.routing.retries"),
icon: <RotateCw />,
value: count(trace?.attemptedRetries),
},
{ label: t("playground.chat.inspector.routing.overhead"), icon: <Gauge />, value: millis(trace?.overheadMs) },
]}
/>
{!reported && (
<Muted>
The gateway did not share routing details with this page. Open the dashboard from the proxy to see them
</Muted>
)}
{!reported && <Muted>{t("playground.chat.inspector.routing.notReported")}</Muted>}
</TraceSection>
);
}
const noneRan = (policies: readonly string[] | undefined): string | null => {
if (policies === undefined) return "No guardrails ran on this request";
return policies.length === 0 ? "No guardrails or policies ran on this request" : null;
const noneRan = (policies: readonly string[] | undefined, t: TFunction = i18n.t.bind(i18n)): string | null => {
if (policies === undefined) return t("playground.chat.inspector.guardrails.noGuardrailsRan");
return policies.length === 0 ? t("playground.chat.inspector.guardrails.noneRan") : null;
};
function Guardrails({ message }: { message: MessageType }) {
const { t } = useTranslation();
const guardrails = message.trace?.appliedGuardrails;
const policies = message.trace?.appliedPolicies;
if (guardrails === undefined) {
return (
<TraceSection value="guardrails" title="Guardrails and policies">
<Muted>This endpoint does not report which guardrails or policies ran</Muted>
<TraceSection value="guardrails" title={t("playground.chat.inspector.guardrails.title")}>
<Muted>{t("playground.chat.inspector.guardrails.unsupported")}</Muted>
</TraceSection>
);
}
const emptyNote = guardrails.length === 0 ? noneRan(policies) : null;
const emptyNote = guardrails.length === 0 ? noneRan(policies, t) : null;
return (
<TraceSection value="guardrails" title="Guardrails and policies">
<TraceSection value="guardrails" title={t("playground.chat.inspector.guardrails.title")}>
{guardrails.map((name) => (
<TraceRow key={`g-${name}`} title={name} description="Guardrail">
<Badge variant="secondary">Ran</Badge>
<TraceRow key={`g-${name}`} title={name} description={t("playground.chat.inspector.guardrails.guardrail")}>
<Badge variant="secondary">{t("playground.chat.inspector.guardrails.ran")}</Badge>
</TraceRow>
))}
{policies?.map((name) => (
<TraceRow key={`p-${name}`} title={name} description="Policy">
<Badge variant="secondary">Ran</Badge>
<TraceRow key={`p-${name}`} title={name} description={t("playground.chat.inspector.guardrails.policy")}>
<Badge variant="secondary">{t("playground.chat.inspector.guardrails.ran")}</Badge>
</TraceRow>
))}
{emptyNote && <Muted>{emptyNote}</Muted>}
{policies === undefined && <Muted>Policies are only reported when the dashboard is opened from the proxy</Muted>}
{policies === undefined && <Muted>{t("playground.chat.inspector.guardrails.policiesUnreported")}</Muted>}
</TraceSection>
);
}
function SpeedAndCost({ message }: { message: MessageType }) {
const { t } = useTranslation();
const usage = message.usage;
const cost = usage?.cost ?? message.trace?.cost;
return (
<TraceSection value="performance" title="Speed and cost">
<TraceSection value="performance" title={t("playground.chat.inspector.performance.title")}>
<StatGrid
stats={[
{
label: "First token",
label: t("playground.chat.inspector.performance.firstToken"),
icon: <Timer />,
value: message.timeToFirstToken === undefined ? undefined : formatSeconds(message.timeToFirstToken),
},
{
label: "Total",
label: t("playground.chat.inspector.performance.total"),
icon: <Clock />,
value: message.totalLatency === undefined ? undefined : formatSeconds(message.totalLatency),
},
{ label: "Cost", icon: <Coins />, value: cost === undefined ? undefined : formatCost(cost) },
{ label: "Tokens in", icon: <ArrowDownToLine />, value: count(usage?.promptTokens) },
{ label: "Tokens out", icon: <ArrowUpFromLine />, value: count(usage?.completionTokens) },
{ label: "Cached", icon: <DatabaseZap />, value: count(usage?.cacheReadTokens) },
{ label: "Reasoning", icon: <Brain />, value: count(usage?.reasoningTokens) },
{ label: "Gateway time", icon: <Server />, value: millis(message.trace?.responseMs) },
{
label: t("playground.chat.inspector.performance.cost"),
icon: <Coins />,
value: cost === undefined ? undefined : formatCost(cost),
},
{
label: t("playground.chat.inspector.performance.tokensIn"),
icon: <ArrowDownToLine />,
value: count(usage?.promptTokens),
},
{
label: t("playground.chat.inspector.performance.tokensOut"),
icon: <ArrowUpFromLine />,
value: count(usage?.completionTokens),
},
{
label: t("playground.chat.inspector.performance.cached"),
icon: <DatabaseZap />,
value: count(usage?.cacheReadTokens),
},
{
label: t("playground.chat.inspector.performance.reasoning"),
icon: <Brain />,
value: count(usage?.reasoningTokens),
},
{
label: t("playground.chat.inspector.performance.gatewayTime"),
icon: <Server />,
value: millis(message.trace?.responseMs),
},
]}
/>
</TraceSection>
@ -211,13 +259,12 @@ function SpeedAndCost({ message }: { message: MessageType }) {
}
function NothingYet() {
const { t } = useTranslation();
return (
<Empty className="flex-1">
<EmptyHeader>
<EmptyTitle className="text-sm">Nothing to inspect yet</EmptyTitle>
<EmptyDescription>
Each reply shows here with the deployment that served it, fallbacks, guardrails, timing and cost
</EmptyDescription>
<EmptyTitle className="text-sm">{t("playground.chat.inspector.empty.title")}</EmptyTitle>
<EmptyDescription>{t("playground.chat.inspector.empty.description")}</EmptyDescription>
</EmptyHeader>
</Empty>
);
@ -235,6 +282,7 @@ function Trace({ message }: { message: MessageType | null }) {
}
function CodeTab({ code }: { code: (sdk: CodeSdk) => string }) {
const { t } = useTranslation();
const [sdk, setSdk] = useState<CodeSdk>("openai");
const snippet = code(sdk);
return (
@ -246,21 +294,21 @@ function CodeTab({ code }: { code: (sdk: CodeSdk) => string }) {
variant="outline"
size="sm"
spacing={0}
aria-label="SDK"
aria-label={t("playground.chat.inspector.code.sdk")}
>
<ToggleGroupItem value="openai" className="h-7 px-2 text-xs">
OpenAI SDK
{t("playground.chat.inspector.code.openai")}
</ToggleGroupItem>
<ToggleGroupItem value="azure" className="h-7 px-2 text-xs">
Azure SDK
{t("playground.chat.inspector.code.azure")}
</ToggleGroupItem>
</ToggleGroup>
<span className="ml-auto">
<CopyIconButton value={snippet} label="code" />
<CopyIconButton value={snippet} label={t("playground.chat.inspector.code.copyLabel")} />
</span>
</div>
<FieldDescription className="px-4 pt-2 text-xs">
Send this request from your own app. Replace YOUR_LITELLM_API_KEY with a virtual key
{t("playground.chat.inspector.code.description")}
</FieldDescription>
<ScrollArea className="w-full">
<pre className="px-4 py-3 font-mono text-xs leading-5 whitespace-pre text-foreground">{snippet}</pre>
@ -271,12 +319,13 @@ function CodeTab({ code }: { code: (sdk: CodeSdk) => string }) {
}
export function InspectorPanel({ message, code }: { message: MessageType | null; code: (sdk: CodeSdk) => string }) {
const { t } = useTranslation();
return (
<Tabs defaultValue="trace" className="min-h-0 flex-1 gap-0">
<div className="flex h-12 shrink-0 items-center border-b border-border px-4">
<TabsList className="w-full">
<TabsTrigger value="trace">Trace</TabsTrigger>
<TabsTrigger value="code">Code</TabsTrigger>
<TabsTrigger value="trace">{t("playground.chat.inspector.tabs.trace")}</TabsTrigger>
<TabsTrigger value="code">{t("playground.chat.inspector.tabs.code")}</TabsTrigger>
</TabsList>
</div>
<ScrollArea className="min-h-0 flex-1">

View file

@ -2,6 +2,7 @@
import { useId, useState, type ReactNode } from "react";
import { Blocks, KeyRound, Search } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@ -84,9 +85,14 @@ function MasterRow({
onSelect: () => void;
summary?: string;
}) {
const { t } = useTranslation();
return (
<div data-active={active || undefined} className="flex items-center gap-1 rounded-sm pl-2 data-active:bg-accent">
<Checkbox checked={checked} onCheckedChange={onCheckedChange} aria-label={`Use ${row.label}`} />
<Checkbox
checked={checked}
onCheckedChange={onCheckedChange}
aria-label={t("playground.chat.mcp.useRow", { label: row.label })}
/>
<Button
variant="ghost"
size="sm"
@ -168,6 +174,7 @@ export function McpDialog({
onCredentialSaved: () => void;
defaultOpen?: boolean;
}) {
const { t } = useTranslation();
const [focused, setFocused] = useState<string>(selection.find((item) => item !== ALL_MCP_SERVERS) ?? ALL_MCP_SERVERS);
const [query, setQuery] = useState("");
const [toolQuery, setToolQuery] = useState("");
@ -200,11 +207,13 @@ export function McpDialog({
if (!selection.includes(row.value)) return undefined;
const server = serverById.get(row.value);
if (!server) return undefined;
if (needsKey(server)) return "Needs your key";
if (needsKey(server)) return t("playground.chat.mcp.needsKey");
const tools = toolNames(row.value);
if (tools.length === 0) return undefined;
const allowed = allowedTools(toolLimits, row.value, tools).length;
return allowed === tools.length ? `All ${tools.length} tools` : `${allowed} of ${tools.length} tools`;
return allowed === tools.length
? t("playground.chat.mcp.allTools", { total: tools.length })
: t("playground.chat.mcp.someTools", { allowed, total: tools.length });
};
const focus = (item: string) => {
@ -227,14 +236,16 @@ export function McpDialog({
const detail = (): ReactNode => {
if (focused === ALL_MCP_SERVERS)
return <Note title="All MCP servers">Every server this key can reach, with all of its tools</Note>;
return (
<Note title={t("playground.chat.mcp.allServers.title")}>{t("playground.chat.mcp.allServers.description")}</Note>
);
const toolset = toolsets.find((item) => `${TOOLSET_PREFIX}${item.toolset_id}` === focused);
if (toolset)
return (
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-3 p-4">
<p className="text-sm text-muted-foreground">
{toolset.description || "A toolset's tools are fixed by whoever created it"}
{toolset.description || t("playground.chat.mcp.toolsetFixed")}
</p>
<div className="flex flex-wrap gap-1.5">
{toolset.tools.map((tool) => (
@ -247,7 +258,12 @@ export function McpDialog({
</ScrollArea>
);
const server = serverById.get(focused);
if (!server) return <Note title="Nothing selected">Pick a server or toolset on the left</Note>;
if (!server)
return (
<Note title={t("playground.chat.mcp.nothingSelected.title")}>
{t("playground.chat.mcp.nothingSelected.description")}
</Note>
);
const name = serverName(server);
if (needsKey(server) || replacingKey === focused)
return (
@ -264,13 +280,13 @@ export function McpDialog({
const replaceKey = server.is_byok ? (
<Button variant="outline" size="sm" onClick={() => setReplacingKey(focused)}>
<KeyRound />
Replace key
{t("playground.chat.mcp.replaceKey")}
</Button>
) : undefined;
if (!selection.includes(focused))
return (
<Note title={name} action={replaceKey}>
Check this server to choose which of its tools the model can call
{t("playground.chat.mcp.checkServer")}
</Note>
);
const state = toolsByServer[focused];
@ -283,11 +299,11 @@ export function McpDialog({
if (state.failed)
return (
<Note title={name} action={replaceKey}>
Couldn&apos;t load this server&apos;s tools
{t("playground.chat.mcp.toolsFailed")}
</Note>
);
const tools = toolNames(focused);
if (tools.length === 0) return <Note title={name}>This server has no tools</Note>;
if (tools.length === 0) return <Note title={name}>{t("playground.chat.mcp.noTools")}</Note>;
const allowed = allowedTools(toolLimits, focused, tools);
return (
<div className="flex min-h-0 flex-1 flex-col">
@ -295,7 +311,7 @@ export function McpDialog({
<SearchField
value={toolQuery}
onChange={setToolQuery}
placeholder={`Search ${name} tools`}
placeholder={t("playground.chat.mcp.searchTools", { name })}
autoFocus={false}
/>
<Button
@ -304,7 +320,7 @@ export function McpDialog({
disabled={allowed.length === tools.length}
onClick={() => onChange(selection, { ...toolLimits, [focused]: [] })}
>
Allow all
{t("playground.chat.mcp.allowAll")}
</Button>
{replaceKey}
</div>
@ -333,9 +349,9 @@ export function McpDialog({
return (
<Dialog defaultOpen={defaultOpen} onOpenChange={(open) => !open && setReplacingKey(null)}>
<DialogTrigger render={<InputGroupButton size="xs" aria-label="MCP" />}>
<DialogTrigger render={<InputGroupButton size="xs" aria-label={t("playground.chat.mcp.trigger")} />}>
<Blocks />
MCP
{t("playground.chat.mcp.trigger")}
{selection.length > 0 && (
<Badge variant="secondary" className="h-4 min-w-4 px-1 tabular-nums">
{selection.length}
@ -344,23 +360,28 @@ export function McpDialog({
</DialogTrigger>
<DialogContent className="gap-0 p-0 sm:max-w-3xl">
<DialogHeader className="border-b border-border p-4">
<DialogTitle>MCP servers</DialogTitle>
<DialogDescription>
Pick the servers the model can use, then limit their tools. A server allows every tool until you uncheck
some.
</DialogDescription>
<DialogTitle>{t("playground.chat.mcp.title")}</DialogTitle>
<DialogDescription>{t("playground.chat.mcp.description")}</DialogDescription>
</DialogHeader>
{nothingAvailable ? (
<div className="h-96">
<Note title="No MCP servers for this key">
Add one on the <a href={uiHref("mcp-servers")}>MCP servers page</a>, or send as a key that can reach one
<Note title={t("playground.chat.mcp.noServers.title")}>
<Trans
i18nKey="playground.chat.mcp.noServers.description"
components={{ page: <a href={uiHref("mcp-servers")} /> }}
/>
</Note>
</div>
) : (
<div className="grid h-96 grid-cols-[18rem_1fr]">
<div className="flex min-h-0 flex-col border-r border-border">
<div className="p-2">
<SearchField value={query} onChange={setQuery} placeholder="Search servers" autoFocus />
<SearchField
value={query}
onChange={setQuery}
placeholder={t("playground.chat.mcp.searchServers")}
autoFocus
/>
</div>
<ScrollArea className="min-h-0 flex-1">
{isLoading ? (
@ -370,11 +391,18 @@ export function McpDialog({
) : (
<div className="p-1">
<RowGroup
title="MCP servers"
rows={visible([{ value: ALL_MCP_SERVERS, label: "All MCP servers" }, ...serverRows])}
title={t("playground.chat.mcp.serversGroup")}
rows={visible([
{ value: ALL_MCP_SERVERS, label: t("playground.chat.mcp.allServers.title") },
...serverRows,
])}
render={masterRow}
/>
<RowGroup
title={t("playground.chat.mcp.toolsetsGroup")}
rows={visible(toolsetRows)}
render={masterRow}
/>
<RowGroup title="Toolsets" rows={visible(toolsetRows)} render={masterRow} />
</div>
)}
</ScrollArea>
@ -383,7 +411,7 @@ export function McpDialog({
</div>
)}
<DialogFooter className="m-0 border-t border-border p-3">
<DialogClose render={<Button size="sm" />}>Done</DialogClose>
<DialogClose render={<Button size="sm" />}>{t("playground.chat.mcp.done")}</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -1,6 +1,9 @@
"use client";
import { Copy, Split } from "lucide-react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
@ -8,24 +11,30 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { cn } from "@/lib/cva.config";
import { toast } from "@/lib/toast";
export function copyText(value: string, label: string): void {
export function copyText(value: string, label: string, t: TFunction = i18n.t.bind(i18n)): void {
void navigator.clipboard.writeText(value).then(
() => toast.success(`${label} copied`),
() => toast.error("Unable to copy to clipboard"),
() => toast.success(t("playground.chat.modelName.copied", { label })),
() => toast.error(t("playground.chat.modelName.copyFailed")),
);
}
export function CopyIconButton({ value, label }: { value: string; label: string }) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger
render={
<Button variant="ghost" size="icon-xs" aria-label={`Copy ${label}`} onClick={() => copyText(value, label)} />
<Button
variant="ghost"
size="icon-xs"
aria-label={t("playground.chat.modelName.copy", { label })}
onClick={() => copyText(value, label, t)}
/>
}
>
<Copy />
</TooltipTrigger>
<TooltipContent>Copy {label}</TooltipContent>
<TooltipContent>{t("playground.chat.modelName.copy", { label })}</TooltipContent>
</Tooltip>
);
}
@ -65,10 +74,11 @@ export function LongValue({
}
export function FallbackBadge() {
const { t } = useTranslation();
return (
<Badge variant="outline" className="border-warning/20 bg-warning/10 text-warning">
<Split data-icon="inline-start" />
Fallback
{t("playground.chat.modelName.fallback")}
</Badge>
);
}

View file

@ -2,6 +2,7 @@
import { useState, type ReactNode } from "react";
import { Plus } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import {
Combobox,
@ -68,7 +69,10 @@ function CustomItem({ value }: { value: string }) {
<ComboboxItem value={value} className="font-mono text-xs">
<Plus />
<span className="min-w-0 truncate">
<span className="font-sans">Use</span> {value}
<Trans
i18nKey="playground.chat.pickers.useCustom"
components={{ sans: <span className="font-sans" />, value: <>{value}</> }}
/>
</span>
</ComboboxItem>
);
@ -102,6 +106,7 @@ export function ChecklistMenu({
emptyText: string;
defaultOpen?: boolean;
}) {
const { t } = useTranslation();
const index = optionIndex(groups);
const items = toItems(groups);
return (
@ -120,10 +125,13 @@ export function ChecklistMenu({
<CountBadge count={value.length} />
</ComboboxTrigger>
<ComboboxContent side="top" className="w-72">
<ComboboxInput showTrigger={false} placeholder={`Search ${label.toLowerCase()}`} />
<ComboboxInput
showTrigger={false}
placeholder={t("playground.chat.pickers.searchChecklist", { label: label.toLowerCase() })}
/>
<ComboboxEmpty>
{isLoading ? <Spinner className="size-3.5" /> : items.length === 0 && emptyText}
{!isLoading && items.length > 0 && "Nothing matches"}
{!isLoading && items.length > 0 && t("playground.chat.pickers.nothingMatches")}
</ComboboxEmpty>
<GroupedOptions index={index} />
</ComboboxContent>
@ -152,12 +160,16 @@ export function TargetPicker({
allowCustom?: boolean;
defaultOpen?: boolean;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const index = optionIndex(groups);
const custom = query.trim();
const offerCustom = allowCustom && custom !== "" && !isListed(index, custom);
const items = [...toItems(groups), ...(offerCustom ? [{ value: "Custom name", items: [custom] }] : [])];
const unsetText = isLoading ? `Loading ${label.toLowerCase()}s` : placeholder;
const items = [
...toItems(groups),
...(offerCustom ? [{ value: t("playground.chat.pickers.customName"), items: [custom] }] : []),
];
const unsetText = isLoading ? t("playground.chat.pickers.loading", { label: label.toLowerCase() }) : placeholder;
return (
<Combobox
items={items}
@ -182,9 +194,14 @@ export function TargetPicker({
</span>
</ComboboxTrigger>
<ComboboxContent side="top" className="w-80">
<ComboboxInput showTrigger={false} placeholder={`Search ${label.toLowerCase()}s`} />
<ComboboxInput
showTrigger={false}
placeholder={t("playground.chat.pickers.searchTargets", { label: label.toLowerCase() })}
/>
<ComboboxEmpty>
{items.length === 0 && !custom ? emptyText : `No matching ${label.toLowerCase()}`}
{items.length === 0 && !custom
? emptyText
: t("playground.chat.pickers.noMatching", { label: label.toLowerCase() })}
</ComboboxEmpty>
<GroupedOptions index={index} mono />
</ComboboxContent>

View file

@ -1,7 +1,9 @@
"use client";
import { useRef, useState } from "react";
import type { TFunction } from "i18next";
import { ChevronDown, PanelRight, RotateCcw, SquarePen } from "lucide-react";
import { useTranslation } from "react-i18next";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets";
import { EndpointType, getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
@ -20,6 +22,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { toast } from "@/lib/toast";
import i18n from "@/i18n/i18n";
import { useChatHistory } from "../../hooks/useChatHistory";
import { useCodeInterpreter } from "../../hooks/useCodeInterpreter";
import { filterModelsForEndpoint, isModelCompatibleWithEndpoint } from "../chat_ui/EndpointUtils";
@ -64,10 +67,10 @@ const modelGroups = (models: readonly ModelGroup[]): readonly OptionGroup[] =>
.map((model) => ({ value: model.model_group, label: model.model_group })),
}));
const modelEmptyText = (failed: boolean, hasKey: boolean): string => {
if (failed) return "Unable to load models for this key";
if (!hasKey) return "Enter a virtual key to load models";
return "No models available for this endpoint";
const modelEmptyText = (failed: boolean, hasKey: boolean, t: TFunction = i18n.t.bind(i18n)): string => {
if (failed) return t("playground.chat.page.models.failed");
if (!hasKey) return t("playground.chat.page.models.needsKey");
return t("playground.chat.page.models.empty");
};
const reachableMcp = (servers: readonly MCPServer[], toolsets: readonly MCPToolset[]): ReadonlySet<string> =>
@ -112,10 +115,10 @@ const lastTracedReply = (messages: readonly MessageType[]): number =>
const totalCost = (messages: readonly MessageType[]): number =>
messages.reduce((sum, message) => sum + (message.usage?.cost ?? message.trace?.cost ?? 0), 0);
const conversationSummary = (messages: readonly MessageType[]): string => {
const conversationSummary = (messages: readonly MessageType[], t: TFunction = i18n.t.bind(i18n)): string => {
const requests = messages.filter((message) => message.role === "user").length;
if (requests === 0) return "New chat";
const label = requests === 1 ? "1 request" : `${requests} requests`;
if (requests === 0) return t("playground.chat.page.newChat");
const label = t("playground.chat.page.requests", { count: requests });
const cost = totalCost(messages);
return cost > 0 ? `${label}, ${formatCost(cost)}` : label;
};
@ -129,6 +132,7 @@ export function PlaygroundChat({
disabledPersonalKeyCreation: boolean;
proxySettings: ProxySettings | undefined;
}) {
const { t } = useTranslation();
const { settings: stored, update, reset } = usePlaygroundSettings(disabledPersonalKeyCreation);
const canViewPolicies = useCan("viewPolicies");
const apiKey = (stored.keySource === "session" ? accessToken : stored.virtualKey.trim()) || null;
@ -211,13 +215,13 @@ export function PlaygroundChat({
const newChat = () => {
clearChat();
toast.success("Chat history cleared.");
toast.success(t("playground.chat.page.historyCleared"));
};
const resetAll = () => {
clearChat();
reset();
toast.success("Chat and settings reset");
toast.success(t("playground.chat.page.resetDone"));
};
const code = (sdk: CodeSdk): string => {
@ -250,23 +254,23 @@ export function PlaygroundChat({
<>
{capabilities.target === "model" && (
<TargetPicker
label="Model"
placeholder="Select a model"
label={t("playground.chat.page.models.label")}
placeholder={t("playground.chat.page.models.placeholder")}
groups={modelGroups(filterModelsForEndpoint([...modelList], settings.endpoint))}
value={settings.model}
onValueChange={selectModel}
isLoading={models.isLoading}
emptyText={modelEmptyText(models.isError, Boolean(apiKey))}
emptyText={modelEmptyText(models.isError, Boolean(apiKey), t)}
allowCustom
/>
)}
{capabilities.target === "agent" && (
<TargetPicker
label="Agent"
placeholder="Select an agent"
label={t("playground.chat.page.agents.label")}
placeholder={t("playground.chat.page.agents.placeholder")}
groups={[
{
label: "Agents",
label: t("playground.chat.page.agents.group"),
options: (agents.data ?? []).map((agent) => ({
value: agent.agent_name,
label: agent.agent_name || agent.agent_id,
@ -277,21 +281,21 @@ export function PlaygroundChat({
value={settings.agent}
onValueChange={(agent) => update({ agent })}
isLoading={agents.isLoading}
emptyText="No agents available for this key"
emptyText={t("playground.chat.page.agents.empty")}
/>
)}
{capabilities.target === "mcpTool" && (
<>
<TargetPicker
label="Server"
placeholder="Select an MCP server"
label={t("playground.chat.page.servers.label")}
placeholder={t("playground.chat.page.servers.placeholder")}
groups={[
{
label: "MCP servers",
label: t("playground.chat.page.servers.group"),
options: mcp.servers.map((server) => ({ value: server.server_id, label: serverName(server) })),
},
{
label: "Toolsets",
label: t("playground.chat.page.servers.toolsetsGroup"),
options: mcp.toolsets.map((toolset) => ({
value: `${TOOLSET_PREFIX}${toolset.toolset_id}`,
label: toolset.toolset_name,
@ -302,18 +306,18 @@ export function PlaygroundChat({
value={directSelection}
onValueChange={(server) => update({ mcpSelection: [server], directTool: null })}
isLoading={mcp.isLoading}
emptyText="No MCP servers available for this key"
emptyText={t("playground.chat.page.servers.empty")}
/>
{directByok?.is_byok && (
<ByokKeyButton server={directByok} name={serverName(directByok)} onConnected={mcp.refreshCredentials} />
)}
{directSelection && (
<TargetPicker
label="Tool"
placeholder="Select a tool"
label={t("playground.chat.page.tools.label")}
placeholder={t("playground.chat.page.tools.placeholder")}
groups={[
{
label: "Tools",
label: t("playground.chat.page.tools.group"),
options: targets.map(({ tool }) => ({
value: tool.name,
label: tool.name,
@ -324,7 +328,7 @@ export function PlaygroundChat({
value={settings.directTool}
onValueChange={(tool) => update({ directTool: tool })}
isLoading={directServers.some((server) => directServerTools[server]?.isLoading)}
emptyText={toolsFailed ? "Unable to load tools for this server" : "This server has no tools"}
emptyText={toolsFailed ? t("playground.chat.page.tools.failed") : t("playground.chat.page.tools.empty")}
/>
)}
</>
@ -338,8 +342,10 @@ export function PlaygroundChat({
);
const guardrailGroups: readonly OptionGroup[] = [
{ label: "Guardrails", options: guardrails.data ?? [] },
...(canViewPolicies ? [{ label: "Policies", options: prefixed(policies.data ?? []) }] : []),
{ label: t("playground.chat.page.guardrailsGroup"), options: guardrails.data ?? [] },
...(canViewPolicies
? [{ label: t("playground.chat.page.policiesGroup"), options: prefixed(policies.data ?? []) }]
: []),
];
const composer = (
@ -363,7 +369,7 @@ export function PlaygroundChat({
/>
}
vectorStores={{
groups: [{ label: "Vector stores", options: vectorStores.data ?? [] }],
groups: [{ label: t("playground.chat.page.vectorStoresGroup"), options: vectorStores.data ?? [] }],
value: settings.vectorStores,
onValueChange: (value) => update({ vectorStores: value }),
isLoading: vectorStores.isLoading,
@ -411,33 +417,35 @@ export function PlaygroundChat({
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4">
<span className="text-xs text-muted-foreground tabular-nums">{conversationSummary(messages)}</span>
<span className="text-xs text-muted-foreground tabular-nums">{conversationSummary(messages, t)}</span>
<span className="ml-auto flex items-center gap-2">
{!isWide && (
<Button variant="outline" size="sm" onClick={() => setSheetOpen(true)}>
<PanelRight />
Inspector
{t("playground.chat.page.inspector")}
</Button>
)}
<ButtonGroup>
<Button variant="outline" size="sm" onClick={newChat}>
<SquarePen />
New chat
{t("playground.chat.page.newChat")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" aria-label="More chat actions" />}
render={
<Button variant="outline" size="icon-sm" aria-label={t("playground.chat.page.moreActions")} />
}
>
<ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem onClick={newChat}>
<SquarePen />
New chat, keep settings
{t("playground.chat.page.newChatKeepSettings")}
</DropdownMenuItem>
<DropdownMenuItem onClick={resetAll}>
<RotateCcw />
Reset chat and settings
{t("playground.chat.page.resetAll")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -466,20 +474,23 @@ export function PlaygroundChat({
}}
inspectedIndex={inspectedIndex}
onInspect={inspect}
suggestions={suggestions}
suggestions={suggestions.map((suggestion) => t(suggestion))}
onPickSuggestion={draft.setText}
/>
)}
<div className="mx-auto w-full max-w-3xl px-6 pb-6">{composer}</div>
</div>
{isWide ? (
<aside aria-label="Inspector" className="flex w-96 shrink-0 flex-col border-l border-border">
<aside
aria-label={t("playground.chat.page.inspector")}
className="flex w-96 shrink-0 flex-col border-l border-border"
>
{inspector}
</aside>
) : (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent side="right" className="w-96 gap-0 p-0" showCloseButton={false}>
<SheetTitle className="sr-only">Inspector</SheetTitle>
<SheetTitle className="sr-only">{t("playground.chat.page.inspector")}</SheetTitle>
{inspector}
</SheetContent>
</Sheet>

View file

@ -1,6 +1,7 @@
"use client";
import ReactMarkdown from "react-markdown";
import { useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import remarkGfm from "remark-gfm";
@ -67,7 +68,14 @@ export function Markdown({ children }: { children: string }) {
}
function GeneratedImage({ src }: { src: string }) {
return <img src={src} alt="Generated image" className="max-h-[500px] max-w-full rounded-lg border border-border" />;
const { t } = useTranslation();
return (
<img
src={src}
alt={t("playground.chat.reply.generatedImage")}
className="max-h-[500px] max-w-full rounded-lg border border-border"
/>
);
}
function Embeddings({ value }: { value: string }) {

View file

@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import type { ParseKeys } from "i18next";
import { ChevronRight, Minus, Plus, SlidersHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { KeyValuePair } from "@/components/key_value_input";
import { PasswordInput } from "@/components/shared/PasswordInput";
import { Button } from "@/components/ui/button";
@ -31,9 +33,9 @@ import { CopyIconButton } from "./ModelName";
import type { KeySource, PlaygroundSettings } from "./playgroundSettings";
import type { SettingsUpdate } from "./usePlaygroundSettings";
const KEY_SOURCES = [
{ value: "session", label: "Current UI session" },
{ value: "custom", label: "Virtual key" },
const KEY_SOURCES: readonly { value: string; label: ParseKeys }[] = [
{ value: "session", label: "playground.chat.settings.keySources.session" },
{ value: "custom", label: "playground.chat.settings.keySources.custom" },
];
const DEFAULT_TEMPERATURE = 1;
@ -47,6 +49,7 @@ function TagField({
options: readonly string[];
onChange: (value: readonly string[]) => void;
}) {
const { t } = useTranslation();
const anchor = useComboboxAnchor();
const [query, setQuery] = useState("");
const custom = query.trim();
@ -66,10 +69,13 @@ function TagField({
<ComboboxValue>
{(values: string[]) => values.map((tag) => <ComboboxChip key={tag}>{tag}</ComboboxChip>)}
</ComboboxValue>
<ComboboxChipsInput id="playground-tags" placeholder={value.length === 0 ? "Add a tag" : ""} />
<ComboboxChipsInput
id="playground-tags"
placeholder={value.length === 0 ? t("playground.chat.settings.tags.placeholder") : ""}
/>
</ComboboxChips>
<ComboboxContent anchor={anchor}>
<ComboboxEmpty>No tags yet, type one to add it</ComboboxEmpty>
<ComboboxEmpty>{t("playground.chat.settings.tags.empty")}</ComboboxEmpty>
<ComboboxList>
{(tag: string) => (
<ComboboxItem key={tag} value={tag}>
@ -78,7 +84,7 @@ function TagField({
) : (
<>
<Plus />
Add {tag}
{t("playground.chat.settings.tags.add", { tag })}
</>
)}
</ComboboxItem>
@ -96,29 +102,32 @@ function HeaderEditor({
pairs: readonly KeyValuePair[];
onChange: (pairs: readonly KeyValuePair[]) => void;
}) {
const { t } = useTranslation();
const replace = (index: number, pair: KeyValuePair) => onChange(pairs.map((item, i) => (i === index ? pair : item)));
return (
<div className="flex flex-col gap-2">
{pairs.map(([name, value], index) => (
<ButtonGroup key={index} className="w-full">
<Input
aria-label="Header name"
aria-label={t("playground.chat.settings.headers.nameLabel")}
value={name}
onChange={(event) => replace(index, [event.target.value, value])}
placeholder="Name"
placeholder={t("playground.chat.settings.headers.namePlaceholder")}
className="h-8 font-mono text-xs"
/>
<Input
aria-label="Header value"
aria-label={t("playground.chat.settings.headers.valueLabel")}
value={value}
onChange={(event) => replace(index, [name, event.target.value])}
placeholder="Value"
placeholder={t("playground.chat.settings.headers.valuePlaceholder")}
className="h-8 font-mono text-xs"
/>
<Button
variant="outline"
size="icon-sm"
aria-label={`Remove ${name || "header"}`}
aria-label={t("playground.chat.settings.headers.remove", {
name: name || t("playground.chat.settings.headers.fallbackName"),
})}
onClick={() => onChange(pairs.filter((_, i) => i !== index))}
>
<Minus />
@ -127,7 +136,7 @@ function HeaderEditor({
))}
<Button variant="ghost" size="sm" className="-ml-2 w-fit" onClick={() => onChange([...pairs, ["", ""]])}>
<Plus />
Add header
{t("playground.chat.settings.headers.add")}
</Button>
</div>
);
@ -142,18 +151,19 @@ function ProxyBaseUrlField({
suggested: string | null;
onChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<Field>
<div className="flex items-center justify-between">
<FieldLabel htmlFor="playground-base-url">Proxy base URL</FieldLabel>
<FieldLabel htmlFor="playground-base-url">{t("playground.chat.settings.baseUrl.label")}</FieldLabel>
{value ? (
<Button variant="link" size="xs" className="h-auto p-0" onClick={() => onChange("")}>
Clear
{t("playground.chat.settings.baseUrl.clear")}
</Button>
) : (
suggested && (
<Button variant="link" size="xs" className="h-auto p-0" onClick={() => onChange(suggested)}>
Use {suggested}
{t("playground.chat.settings.baseUrl.use", { url: suggested })}
</Button>
)
)}
@ -162,10 +172,10 @@ function ProxyBaseUrlField({
id="playground-base-url"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="Same as this dashboard"
placeholder={t("playground.chat.settings.baseUrl.placeholder")}
className="h-8"
/>
{value && <FieldDescription>Requests go to {value}</FieldDescription>}
{value && <FieldDescription>{t("playground.chat.settings.baseUrl.target", { url: value })}</FieldDescription>}
</Field>
);
}
@ -177,6 +187,7 @@ const parseTemperature = (text: string): number | null | undefined => {
};
function TemperatureInput({ value, onChange }: { value: number | null; onChange: (value: number | null) => void }) {
const { t } = useTranslation();
const [draft, setDraft] = useState<string | null>(null);
const edit = (text: string) => {
setDraft(text);
@ -185,9 +196,9 @@ function TemperatureInput({ value, onChange }: { value: number | null; onChange:
};
return (
<Input
aria-label="Temperature value"
aria-label={t("playground.chat.settings.sampling.temperatureValue")}
inputMode="decimal"
placeholder="Default"
placeholder={t("playground.chat.settings.sampling.default")}
value={draft ?? value?.toString() ?? ""}
onChange={(event) => edit(event.target.value)}
onBlur={() => setDraft(null)}
@ -203,12 +214,13 @@ function SamplingFields({
settings: PlaygroundSettings;
update: (change: SettingsUpdate) => void;
}) {
const { t } = useTranslation();
const temperature = settings.temperature ?? DEFAULT_TEMPERATURE;
return (
<>
<Field orientation="horizontal">
<FieldLabel htmlFor="playground-temperature" className="w-28 shrink-0 font-normal">
Temperature
{t("playground.chat.settings.sampling.temperature")}
</FieldLabel>
<Slider
id="playground-temperature"
@ -222,7 +234,7 @@ function SamplingFields({
</Field>
<Field orientation="horizontal">
<FieldLabel htmlFor="playground-max-tokens" className="w-28 shrink-0 font-normal">
Max tokens
{t("playground.chat.settings.sampling.maxTokens")}
</FieldLabel>
<Input
id="playground-max-tokens"
@ -233,7 +245,7 @@ function SamplingFields({
const parsed = Number.parseInt(event.target.value, 10);
update({ maxTokens: Number.isFinite(parsed) && parsed > 0 ? parsed : null });
}}
placeholder="Default"
placeholder={t("playground.chat.settings.sampling.default")}
className="h-8 w-24"
/>
</Field>
@ -260,6 +272,7 @@ export function SettingsPopover({
sessions: Readonly<{ enabled: boolean; responseId: string | null; onToggle: (enabled: boolean) => void }>;
defaultOpen?: boolean;
}) {
const { t } = useTranslation();
const advancedSet = [
settings.headerPairs.length > 0,
Boolean(settings.proxyBaseUrl),
@ -273,42 +286,47 @@ export function SettingsPopover({
<Popover defaultOpen={defaultOpen}>
<PopoverTrigger render={<InputGroupButton size="xs" />}>
<SlidersHorizontal />
Settings
{t("playground.chat.settings.trigger")}
</PopoverTrigger>
<PopoverContent align="end" side="top" className="max-h-(--available-height) w-96 overflow-y-auto p-0">
<FieldGroup className="gap-5 p-4">
<Field>
<FieldLabel htmlFor="playground-key-source">Send as</FieldLabel>
<FieldLabel htmlFor="playground-key-source">{t("playground.chat.settings.keySources.label")}</FieldLabel>
<Select
items={KEY_SOURCES}
items={KEY_SOURCES.map((item) => ({ ...item, label: t(item.label) }))}
value={settings.keySource}
onValueChange={(value) => update({ keySource: value as KeySource })}
disabled={keysDisabled}
>
<SelectTrigger id="playground-key-source" size="sm" className="w-full" aria-label="Send as">
<SelectTrigger
id="playground-key-source"
size="sm"
className="w-full"
aria-label={t("playground.chat.settings.keySources.label")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{KEY_SOURCES.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
{t(item.label)}
</SelectItem>
))}
</SelectContent>
</Select>
{settings.keySource === "custom" ? (
<PasswordInput
aria-label="Virtual key"
aria-label={t("playground.chat.settings.virtualKey.label")}
value={settings.virtualKey}
onChange={(event) => update({ virtualKey: event.target.value })}
placeholder="Paste a virtual key"
placeholder={t("playground.chat.settings.virtualKey.placeholder")}
/>
) : (
<FieldDescription>Pick a virtual key to test what it can access</FieldDescription>
<FieldDescription>{t("playground.chat.settings.virtualKey.hint")}</FieldDescription>
)}
</Field>
<Field>
<FieldLabel htmlFor="playground-tags">Tags</FieldLabel>
<FieldLabel htmlFor="playground-tags">{t("playground.chat.settings.tags.label")}</FieldLabel>
<TagField value={settings.tags} options={tagOptions} onChange={(tags) => update({ tags })} />
</Field>
{hasToggles && (
@ -316,7 +334,7 @@ export function SettingsPopover({
{capabilities.stream && (
<Field orientation="horizontal">
<FieldLabel htmlFor="playground-stream" className="font-normal">
Stream response
{t("playground.chat.settings.toggles.stream")}
</FieldLabel>
<Switch
id="playground-stream"
@ -328,7 +346,7 @@ export function SettingsPopover({
{capabilities.simulateFallback && (
<Field orientation="horizontal">
<FieldLabel htmlFor="playground-fallback" className="font-normal">
Simulate a fallback
{t("playground.chat.settings.toggles.simulateFallback")}
</FieldLabel>
<Switch
id="playground-fallback"
@ -340,9 +358,9 @@ export function SettingsPopover({
{capabilities.sessions && (
<Field orientation="horizontal">
<FieldLabel htmlFor="playground-sessions" className="flex-col items-start gap-0.5 font-normal">
Keep context on the gateway
{t("playground.chat.settings.toggles.sessions")}
<span className="text-xs text-muted-foreground">
Sends previous_response_id instead of the whole history
{t("playground.chat.settings.toggles.sessionsHint")}
</span>
</FieldLabel>
<Switch id="playground-sessions" checked={sessions.enabled} onCheckedChange={sessions.onToggle} />
@ -350,9 +368,9 @@ export function SettingsPopover({
)}
{lastResponseId && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="shrink-0">Last response</span>
<span className="shrink-0">{t("playground.chat.settings.toggles.lastResponse")}</span>
<span className="min-w-0 truncate font-mono">{lastResponseId}</span>
<CopyIconButton value={lastResponseId} label="response ID" />
<CopyIconButton value={lastResponseId} label={t("playground.chat.settings.toggles.responseIdCopy")} />
</div>
)}
</FieldSet>
@ -368,15 +386,15 @@ export function SettingsPopover({
}
>
<ChevronRight className="transition-transform in-data-panel-open:rotate-90" />
Advanced
{t("playground.chat.settings.advanced")}
</CollapsibleTrigger>
<CollapsibleContent>
<FieldGroup className="gap-5 pt-3">
{capabilities.customHeaders && (
<Field>
<FieldLabel>Custom headers</FieldLabel>
<FieldLabel>{t("playground.chat.settings.headers.label")}</FieldLabel>
<HeaderEditor pairs={settings.headerPairs} onChange={(headerPairs) => update({ headerPairs })} />
<FieldDescription>Sent with every request, like anthropic-beta</FieldDescription>
<FieldDescription>{t("playground.chat.settings.headers.hint")}</FieldDescription>
</Field>
)}
<ProxyBaseUrlField

View file

@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import type { MessageType } from "@/components/chat_ui/types";
import { makeOpenAIChatCompletionRequest } from "@/components/llm_calls/chat_completion";
@ -16,6 +17,7 @@ import { makeOpenAIEmbeddingsRequest } from "../../llm_calls/embeddings_api";
import { makeOpenAIImageEditsRequest } from "../../llm_calls/image_edits";
import { makeOpenAIImageGenerationRequest } from "../../llm_calls/image_generation";
import { makeInteractionsRequest } from "../../llm_calls/interactions_api";
import i18n from "@/i18n/i18n";
import type { PlaygroundSettings } from "./playgroundSettings";
type ApiMessage = { role: string; content: string | any[] };
@ -63,7 +65,10 @@ const inExchange = (history: readonly MessageType[], index: number): boolean =>
export const textHistory = (history: readonly MessageType[]): ApiMessage[] =>
history.filter((_, index) => inExchange(history, index)).map(({ role, content }) => ({ role, content }));
const toolResultText = (result: { content?: { type: string; text?: string }[] } | null): string => {
const toolResultText = (
result: { content?: { type: string; text?: string }[] } | null,
t: TFunction = i18n.t.bind(i18n),
): string => {
if (result?.content && result.content.length > 0) {
return JSON.stringify(
result.content.map((item) => (item.type === "text" ? item.text : item)).filter(Boolean),
@ -71,7 +76,7 @@ const toolResultText = (result: { content?: { type: string; text?: string }[] }
2,
);
}
return JSON.stringify(result, null, 2) || "Tool executed successfully.";
return JSON.stringify(result, null, 2) || t("playground.chat.dispatch.toolExecuted");
};
export async function dispatchRequest(context: DispatchContext): Promise<void> {

View file

@ -1,3 +1,4 @@
import type { ParseKeys } from "i18next";
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import { AUDIO_ACCEPT, CHAT_ATTACHMENT_ACCEPT, IMAGE_EDIT_ACCEPT } from "../chat_ui/uploadValidation";
@ -18,7 +19,7 @@ export type Capabilities = Readonly<{
voice: boolean;
customHeaders: boolean;
textRequired: boolean;
placeholder: string;
placeholder: ParseKeys;
}>;
const BASE: Capabilities = {
@ -36,7 +37,7 @@ const BASE: Capabilities = {
voice: false,
customHeaders: true,
textRequired: true,
placeholder: "Send a test request",
placeholder: "playground.chat.endpointCapabilities.placeholders.default",
};
const TEXT_CHAT: Partial<Capabilities> = {
@ -51,18 +52,31 @@ const CAPABILITIES: Readonly<Record<EndpointType, Capabilities>> = {
[EndpointType.CHAT]: { ...BASE, ...TEXT_CHAT, attach: "chat", simulateFallback: true, sampling: true },
[EndpointType.RESPONSES]: { ...BASE, ...TEXT_CHAT, attach: "chat", codeInterpreter: true, sessions: true },
[EndpointType.ANTHROPIC_MESSAGES]: { ...BASE, ...TEXT_CHAT },
[EndpointType.IMAGE]: { ...BASE, placeholder: "Describe the image you want to generate" },
[EndpointType.VIDEO]: { ...BASE, placeholder: "Describe the video you want to generate" },
[EndpointType.IMAGE_EDITS]: { ...BASE, attach: "images", placeholder: "Describe how to edit the images" },
[EndpointType.EMBEDDINGS]: { ...BASE, placeholder: "Text to embed" },
[EndpointType.SPEECH]: { ...BASE, voice: true, placeholder: "Text to turn into speech" },
[EndpointType.IMAGE]: { ...BASE, placeholder: "playground.chat.endpointCapabilities.placeholders.image" },
[EndpointType.VIDEO]: { ...BASE, placeholder: "playground.chat.endpointCapabilities.placeholders.video" },
[EndpointType.IMAGE_EDITS]: {
...BASE,
attach: "images",
placeholder: "playground.chat.endpointCapabilities.placeholders.imageEdits",
},
[EndpointType.EMBEDDINGS]: { ...BASE, placeholder: "playground.chat.endpointCapabilities.placeholders.embeddings" },
[EndpointType.SPEECH]: {
...BASE,
voice: true,
placeholder: "playground.chat.endpointCapabilities.placeholders.speech",
},
[EndpointType.TRANSCRIPTION]: {
...BASE,
attach: "audio",
textRequired: false,
placeholder: "Optional context for the transcription",
placeholder: "playground.chat.endpointCapabilities.placeholders.transcription",
},
[EndpointType.A2A_AGENTS]: {
...BASE,
target: "agent",
guardrails: true,
placeholder: "playground.chat.endpointCapabilities.placeholders.agent",
},
[EndpointType.A2A_AGENTS]: { ...BASE, target: "agent", guardrails: true, placeholder: "Send a message to the agent" },
[EndpointType.MCP]: { ...BASE, target: "mcpTool", guardrails: true, textRequired: false },
[EndpointType.REALTIME]: { ...BASE, guardrails: true, customHeaders: false },
[EndpointType.INTERACTIONS]: { ...BASE },
@ -76,29 +90,37 @@ export const ACCEPT_FOR: Readonly<Record<AttachKind, string>> = {
audio: AUDIO_ACCEPT,
};
export const ATTACH_LABEL: Readonly<Record<AttachKind, string>> = {
chat: "Attach an image or PDF",
images: "Add images to edit",
audio: "Attach an audio file",
export const ATTACH_LABEL: Readonly<Record<AttachKind, ParseKeys>> = {
chat: "playground.chat.endpointCapabilities.attach.chat",
images: "playground.chat.endpointCapabilities.attach.images",
audio: "playground.chat.endpointCapabilities.attach.audio",
};
export const SUGGESTIONS: Readonly<Partial<Record<EndpointType, readonly string[]>>> = {
[EndpointType.CHAT]: ["Write me a poem", "Explain quantum computing", "Draft a polite email requesting a meeting"],
export const SUGGESTIONS: Readonly<Partial<Record<EndpointType, readonly ParseKeys[]>>> = {
[EndpointType.CHAT]: [
"playground.chat.endpointCapabilities.suggestions.poem",
"playground.chat.endpointCapabilities.suggestions.quantum",
"playground.chat.endpointCapabilities.suggestions.email",
],
[EndpointType.RESPONSES]: [
"Write me a poem",
"Explain quantum computing",
"Draft a polite email requesting a meeting",
"playground.chat.endpointCapabilities.suggestions.poem",
"playground.chat.endpointCapabilities.suggestions.quantum",
"playground.chat.endpointCapabilities.suggestions.email",
],
[EndpointType.ANTHROPIC_MESSAGES]: [
"Write me a poem",
"Explain quantum computing",
"Draft a polite email requesting a meeting",
"playground.chat.endpointCapabilities.suggestions.poem",
"playground.chat.endpointCapabilities.suggestions.quantum",
"playground.chat.endpointCapabilities.suggestions.email",
],
[EndpointType.A2A_AGENTS]: [
"playground.chat.endpointCapabilities.suggestions.agentHelp",
"playground.chat.endpointCapabilities.suggestions.agentAbout",
"playground.chat.endpointCapabilities.suggestions.agentTasks",
],
[EndpointType.A2A_AGENTS]: ["What can you help me with?", "Tell me about yourself", "What tasks can you perform?"],
};
export const CODE_INTERPRETER_SUGGESTIONS: readonly string[] = [
"Generate sample sales data CSV and create a chart",
"Create a PNG bar chart comparing AI gateway providers including LiteLLM",
"Generate a CSV of LLM pricing data and visualize it as a line chart",
export const CODE_INTERPRETER_SUGGESTIONS: readonly ParseKeys[] = [
"playground.chat.endpointCapabilities.codeInterpreterSuggestions.salesChart",
"playground.chat.endpointCapabilities.codeInterpreterSuggestions.providersChart",
"playground.chat.endpointCapabilities.codeInterpreterSuggestions.pricingChart",
];

View file

@ -1,4 +1,6 @@
import { useRef, useState, type RefObject } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { v4 as uuidv4 } from "uuid";
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import type { MessageType, ReplyTrace } from "@/components/chat_ui/types";
@ -7,6 +9,7 @@ import type { CustomHeaders } from "@/components/llm_calls/request_headers";
import type { MCPToolArgumentsFormRef } from "@/components/mcp_tools/MCPToolArgumentsForm";
import type { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
import { toast } from "@/lib/toast";
import i18n from "@/i18n/i18n";
import type { UseChatHistoryReturn } from "../../hooks/useChatHistory";
import type { UseCodeInterpreterReturn } from "../../hooks/useCodeInterpreter";
import { createChatMultimodalMessage } from "../chat_ui/ChatImageUtils";
@ -49,17 +52,20 @@ const previewFor = (file: File): string | undefined => {
return url.startsWith("blob:") ? url : undefined;
};
const missingInput = (deps: SendDeps, text: string, files: readonly DraftFile[]): string | null => {
const missingInput = (
deps: SendDeps,
text: string,
files: readonly DraftFile[],
t: TFunction = i18n.t.bind(i18n),
): string | null => {
const { settings, capabilities } = deps;
if (settings.endpoint === EndpointType.IMAGE_EDITS && files.length === 0)
return "Please upload at least one image for editing";
return t("playground.chat.send.missingImage");
if (settings.endpoint === EndpointType.TRANSCRIPTION && files.length === 0)
return "Please upload an audio file for transcription";
return t("playground.chat.send.missingAudio");
if (capabilities.textRequired && text.trim() === "") return "";
if (settings.endpoint === EndpointType.A2A_AGENTS && !settings.agent)
return "Please select an agent to send a message";
if (MODEL_ENDPOINTS.includes(settings.endpoint) && !settings.model)
return "Please select a model before sending a request";
if (settings.endpoint === EndpointType.A2A_AGENTS && !settings.agent) return t("playground.chat.send.missingAgent");
if (MODEL_ENDPOINTS.includes(settings.endpoint) && !settings.model) return t("playground.chat.send.missingModel");
return null;
};
@ -103,6 +109,7 @@ type PreparedRequest = Readonly<{
}>;
export function usePlaygroundSend(deps: SendDeps) {
const { t } = useTranslation();
const [text, setText] = useState("");
const [draft, setDraft] = useState<Draft>({ kind: null, files: [] });
const [isLoading, setIsLoading] = useState(false);
@ -140,37 +147,37 @@ export function usePlaygroundSend(deps: SendDeps) {
const cancel = () => {
if (!abortRef.current) return;
stop();
toast.info("Request cancelled");
toast.info(t("playground.chat.send.cancelled"));
};
const prepare = async (): Promise<PreparedRequest | null> => {
const { settings } = deps;
const missing = missingInput(deps, text, files);
const missing = missingInput(deps, text, files, t);
if (missing !== null) {
if (missing) toast.fromError(missing);
return null;
}
const direct = settings.endpoint === EndpointType.MCP ? deps.directCall() : null;
if (settings.endpoint === EndpointType.MCP && !direct) {
toast.fromError("Pick an MCP server and a tool to call");
toast.fromError(t("playground.chat.send.missingTool"));
return null;
}
const toolArguments = await (direct
? deps.toolArgsForm.current?.getSubmitValues().catch((error: unknown) => {
toast.fromError(error instanceof Error ? error.message : "Please fill in all required parameters");
toast.fromError(error instanceof Error ? error.message : t("playground.chat.send.missingParameters"));
return null;
})
: Promise.resolve({}));
if (toolArguments === null) return null;
if (!deps.signedIn) return null;
if (!deps.apiKey) {
toast.fromError("Please provide a Virtual Key or select Current UI Session");
toast.fromError(t("playground.chat.send.missingKey"));
return null;
}
const userMessage = await apiMessage(settings.endpoint, text, files[0]?.file).catch(() => null);
if (!userMessage) {
toast.fromError("Failed to process the attachment. Please try again.");
toast.fromError(t("playground.chat.send.attachmentFailed"));
return null;
}
return { direct, toolArguments: toolArguments ?? {}, userMessage };

View file

@ -14,6 +14,7 @@ import {
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useTranslation } from "react-i18next";
export interface A2ATaskMetadata {
taskId?: string;
@ -82,6 +83,7 @@ const copyToClipboard = (text: string) => {
};
const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken, totalLatency }) => {
const { t } = useTranslation();
const [showDetails, setShowDetails] = useState(false);
if (!a2aMetadata && !timeToFirstToken && !totalLatency) return null;
@ -94,7 +96,7 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
{/* A2A Metadata Header */}
<div className="flex items-center mb-2 text-muted-foreground">
<Bot className="mr-1.5 size-4 text-info" />
<span className="font-medium text-foreground">A2A Metadata</span>
<span className="font-medium text-foreground">{t("playground.chatUi.a2aMetrics.header")}</span>
</div>
{/* Main metrics row */}
@ -127,7 +129,7 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
<Clock className="mr-1 size-3" />
{(totalLatency / 1000).toFixed(2)}s
</TooltipTrigger>
<TooltipContent>Total latency</TooltipContent>
<TooltipContent>{t("playground.chatUi.a2aMetrics.totalLatency")}</TooltipContent>
</Tooltip>
)}
@ -135,9 +137,9 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
{timeToFirstToken !== undefined && (
<Tooltip>
<TooltipTrigger render={<span className="flex items-center text-success" />}>
TTFT: {(timeToFirstToken / 1000).toFixed(2)}s
{t("playground.chatUi.a2aMetrics.ttft", { seconds: (timeToFirstToken / 1000).toFixed(2) })}
</TooltipTrigger>
<TooltipContent>Time to first token</TooltipContent>
<TooltipContent>{t("playground.chatUi.a2aMetrics.timeToFirstToken")}</TooltipContent>
</Tooltip>
)}
</div>
@ -155,15 +157,15 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
size="xs"
className="h-auto p-0 font-normal text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={() => copyToClipboard(taskId)}
aria-label={`Copy task ID ${taskId}`}
aria-label={t("playground.chatUi.a2aMetrics.copyTaskId", { taskId })}
/>
}
>
<FileText className="size-3" />
Task: {truncateId(taskId)}
{t("playground.chatUi.a2aMetrics.task", { id: truncateId(taskId) })}
<Copy className="size-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>Click to copy: {taskId}</TooltipContent>
<TooltipContent>{t("playground.chatUi.a2aMetrics.clickToCopy", { id: taskId })}</TooltipContent>
</Tooltip>
)}
@ -178,15 +180,15 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
size="xs"
className="h-auto p-0 font-normal text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={() => copyToClipboard(contextId)}
aria-label={`Copy session ID ${contextId}`}
aria-label={t("playground.chatUi.a2aMetrics.copySessionId", { sessionId: contextId })}
/>
}
>
<Link className="size-3" />
Session: {truncateId(contextId)}
{t("playground.chatUi.a2aMetrics.session", { id: truncateId(contextId) })}
<Copy className="size-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>Click to copy: {contextId}</TooltipContent>
<TooltipContent>{t("playground.chatUi.a2aMetrics.clickToCopy", { id: contextId })}</TooltipContent>
</Tooltip>
)}
@ -204,7 +206,7 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
}
>
{showDetails ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
Details
{t("playground.chatUi.a2aMetrics.details")}
</CollapsibleTrigger>
</Collapsible>
)}
@ -217,7 +219,7 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
{/* Status message */}
{status?.message && (
<div className="mb-2">
<span className="font-medium text-foreground">Status Message:</span>
<span className="font-medium text-foreground">{t("playground.chatUi.a2aMetrics.statusMessage")}</span>
<span className="ml-2">{status.message}</span>
</div>
)}
@ -225,7 +227,7 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
{/* Full IDs */}
{taskId && (
<div className="mb-1.5 flex items-center">
<span className="font-medium text-foreground w-24">Task ID:</span>
<span className="font-medium text-foreground w-24">{t("playground.chatUi.a2aMetrics.taskId")}</span>
<code className="ml-2 px-2 py-1 bg-card border border-border rounded-sm text-xs font-mono">
{taskId}
</code>
@ -235,7 +237,7 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
size="icon-xs"
className="ml-2 text-muted-foreground hover:text-info"
onClick={() => copyToClipboard(taskId)}
aria-label={`Copy task ID ${taskId}`}
aria-label={t("playground.chatUi.a2aMetrics.copyTaskId", { taskId })}
>
<Copy className="size-3" />
</Button>
@ -244,7 +246,7 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
{contextId && (
<div className="mb-1.5 flex items-center">
<span className="font-medium text-foreground w-24">Session ID:</span>
<span className="font-medium text-foreground w-24">{t("playground.chatUi.a2aMetrics.sessionId")}</span>
<code className="ml-2 px-2 py-1 bg-card border border-border rounded-sm text-xs font-mono">
{contextId}
</code>
@ -254,7 +256,7 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
size="icon-xs"
className="ml-2 text-muted-foreground hover:text-info"
onClick={() => copyToClipboard(contextId)}
aria-label={`Copy session ID ${contextId}`}
aria-label={t("playground.chatUi.a2aMetrics.copySessionId", { sessionId: contextId })}
>
<Copy className="size-3" />
</Button>
@ -264,7 +266,7 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
{/* Metadata fields */}
{metadata && Object.keys(metadata).length > 0 && (
<div className="mt-3">
<span className="font-medium text-foreground">Custom Metadata:</span>
<span className="font-medium text-foreground">{t("playground.chatUi.a2aMetrics.customMetadata")}</span>
<pre className="mt-1.5 p-2 bg-card border border-border rounded-sm text-xs font-mono overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(metadata, null, 2)}
</pre>

View file

@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/cva.config";
import { Trans, useTranslation } from "react-i18next";
interface AdditionalModelSettingsProps {
temperature?: number;
@ -101,6 +102,7 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
}
};
const { t } = useTranslation();
const disabledTextColor = useAdvancedParams ? "text-foreground" : "text-muted-foreground";
return (
@ -111,18 +113,17 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
id={streamingId}
checked={streamingEnabled}
onCheckedChange={(checked) => onStreamingChange(checked === true)}
aria-label="Stream responses"
aria-label={t("playground.chatUi.additionalModelSettings.streamResponses")}
/>
<label htmlFor={streamingId} className="cursor-pointer text-sm font-medium">
Stream responses
{t("playground.chatUi.additionalModelSettings.streamResponses")}
</label>
<Tooltip>
<TooltipTrigger aria-label="Help: Stream responses">
<TooltipTrigger aria-label={t("playground.chatUi.additionalModelSettings.streamResponsesHelp")}>
<Info className="size-3 shrink-0 cursor-pointer text-muted-foreground hover:text-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Streams the answer token by token. Uncheck to send a non-streaming request and render the full response at
once.
{t("playground.chatUi.additionalModelSettings.streamResponsesTooltip")}
</TooltipContent>
</Tooltip>
</div>
@ -134,10 +135,10 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
id={advancedId}
checked={useAdvancedParams}
onCheckedChange={(checked) => handleUseAdvancedParamsChange(checked === true)}
aria-label="Use Advanced Parameters"
aria-label={t("playground.chatUi.additionalModelSettings.useAdvancedParams")}
/>
<label htmlFor={advancedId} className="cursor-pointer text-sm font-medium">
Use Advanced Parameters
{t("playground.chatUi.additionalModelSettings.useAdvancedParams")}
</label>
</div>
)}
@ -148,30 +149,31 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
id={fallbacksId}
checked={mockTestFallbacks ?? false}
onCheckedChange={(checked) => onMockTestFallbacksChange(checked === true)}
aria-label="Simulate failure to test fallbacks"
aria-label={t("playground.chatUi.additionalModelSettings.simulateFailure")}
/>
<label htmlFor={fallbacksId} className="cursor-pointer text-sm font-medium">
Simulate failure to test fallbacks
{t("playground.chatUi.additionalModelSettings.simulateFailure")}
</label>
<Popover>
<PopoverTrigger aria-label="Help: Simulate failure to test fallbacks">
<PopoverTrigger aria-label={t("playground.chatUi.additionalModelSettings.simulateFailureHelp")}>
<Info className="size-3 shrink-0 cursor-pointer text-muted-foreground hover:text-foreground" />
</PopoverTrigger>
<PopoverContent side="right" className="max-w-[340px] gap-2 p-3 text-sm">
<p>{t("playground.chatUi.additionalModelSettings.simulateFailureDescription")}</p>
<p>
Causes the first request to fail so the router tries fallbacks (if configured). Use this to verify your
fallback setup.
</p>
<p>
Behavior can differ when keys, teams, or router settings are configured.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/keys_teams_router_settings"
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-info/80"
>
Learn more
</a>
<Trans
i18nKey="playground.chatUi.additionalModelSettings.behaviorDiffers"
components={{
docs: (
<a
href="https://docs.litellm.ai/docs/proxy/keys_teams_router_settings"
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-info/80"
/>
),
}}
/>
</p>
</PopoverContent>
</Popover>
@ -186,14 +188,14 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1">
<label htmlFor={temperatureId} className={cn("text-sm", disabledTextColor)}>
Temperature
{t("playground.chatUi.additionalModelSettings.temperature")}
</label>
<Tooltip>
<TooltipTrigger aria-label="Help: Temperature">
<TooltipTrigger aria-label={t("playground.chatUi.additionalModelSettings.temperatureHelp")}>
<Info className={cn("size-3 cursor-help", disabledTextColor)} />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Controls randomness. Lower values make output more deterministic, higher values more creative.
{t("playground.chatUi.additionalModelSettings.temperatureTooltip")}
</TooltipContent>
</Tooltip>
</div>
@ -201,7 +203,7 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
id={`${temperatureId}-number`}
type="text"
inputMode="decimal"
aria-label="Temperature value"
aria-label={t("playground.chatUi.additionalModelSettings.temperatureValue")}
value={temperatureText}
disabled={!useAdvancedParams}
className="h-8 w-20"
@ -217,7 +219,7 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
step={0.1}
value={localTemperature}
disabled={!useAdvancedParams}
aria-label="Temperature"
aria-label={t("playground.chatUi.additionalModelSettings.temperature")}
className="w-full accent-primary disabled:cursor-not-allowed"
onChange={(event) => handleTemperatureChange(Number(event.target.value))}
/>
@ -232,14 +234,14 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1">
<label htmlFor={maxTokensId} className={cn("text-sm", disabledTextColor)}>
Max Tokens
{t("playground.chatUi.additionalModelSettings.maxTokens")}
</label>
<Tooltip>
<TooltipTrigger aria-label="Help: Max Tokens">
<TooltipTrigger aria-label={t("playground.chatUi.additionalModelSettings.maxTokensHelp")}>
<Info className={cn("size-3 cursor-help", disabledTextColor)} />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Maximum number of tokens to generate in the response.
{t("playground.chatUi.additionalModelSettings.maxTokensTooltip")}
</TooltipContent>
</Tooltip>
</div>
@ -247,7 +249,7 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
id={`${maxTokensId}-number`}
type="text"
inputMode="numeric"
aria-label="Max tokens value"
aria-label={t("playground.chatUi.additionalModelSettings.maxTokensValue")}
value={maxTokensText}
disabled={!useAdvancedParams}
className="h-8 w-24"
@ -263,7 +265,7 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
step={1}
value={localMaxTokens}
disabled={!useAdvancedParams}
aria-label="Max Tokens"
aria-label={t("playground.chatUi.additionalModelSettings.maxTokens")}
className="w-full accent-primary disabled:cursor-not-allowed"
onChange={(event) => handleMaxTokensChange(Number(event.target.value))}
/>

View file

@ -2,6 +2,7 @@
import { Bot, FlaskConical, Link as LinkIcon, MessageSquare, Plus, Save, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogAction,
@ -85,6 +86,7 @@ function ConnectTabContent({
createdKeyValue,
onCreateKey,
}: ConnectTabContentProps) {
const { t } = useTranslation();
const baseUrl = proxyBaseUrl ?? getConnectTabBaseUrl(proxySettings, customProxyBaseUrl);
const apiKeyForCurl = createdKeyValue
? createdKeyValue.startsWith("Bearer ")
@ -109,31 +111,37 @@ function ConnectTabContent({
return (
<div className="mx-auto max-w-3xl space-y-6">
<div>
<h3 className="text-sm font-semibold text-foreground mb-1">Proxy base URL</h3>
<h3 className="text-sm font-semibold text-foreground mb-1">
{t("playground.chatUi.agentBuilderView.proxyBaseUrl")}
</h3>
<p className="text-sm text-muted-foreground font-mono bg-muted px-2 py-1.5 rounded-sm border border-border break-all">
{baseUrl}
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-foreground mb-2">Call your agent (cURL)</h3>
<h3 className="text-sm font-semibold text-foreground mb-2">
{t("playground.chatUi.agentBuilderView.callYourAgent")}
</h3>
<CodeBlock code={curlExample} language="bash" />
</div>
<div className="rounded-lg border border-border bg-muted p-4">
<h3 className="text-sm font-semibold text-foreground mb-2">Create a key for this agent</h3>
<h3 className="text-sm font-semibold text-foreground mb-2">
{t("playground.chatUi.agentBuilderView.createKeyTitle")}
</h3>
<p className="text-sm text-muted-foreground mb-3">
Create a virtual key that can only call this agent. The key will be scoped to you (user_id) and restricted to
the model <span className="font-mono text-foreground">{agentName}</span>.
<Trans
i18nKey="playground.chatUi.agentBuilderView.createKeyDescription"
components={{ model: <span className="font-mono text-foreground">{agentName}</span> }}
/>
</p>
<Button onClick={onCreateKey} disabled={creatingKey || disabledPersonalKeyCreation}>
Create key for this agent
{t("playground.chatUi.agentBuilderView.createKeyButton")}
</Button>
{disabledPersonalKeyCreation && (
<p className="text-xs text-warning mt-2">Key creation is disabled for your account.</p>
<p className="text-xs text-warning mt-2">{t("playground.chatUi.agentBuilderView.keyCreationDisabled")}</p>
)}
{createdKeyValue && (
<p className="text-xs text-success mt-2">
Key created. It is shown in the cURL example above — copy the snippet to use it.
</p>
<p className="text-xs text-success mt-2">{t("playground.chatUi.agentBuilderView.keyCreated")}</p>
)}
</div>
</div>
@ -194,6 +202,7 @@ export default function AgentBuilderView({
apiKey,
customProxyBaseUrl,
}: AgentBuilderViewProps) {
const { t } = useTranslation();
const [agentModels, setAgentModels] = useState<AgentModel[]>([]);
const [modelGroups, setModelGroups] = useState<ModelGroup[]>([]);
const [loadingAgents, setLoadingAgents] = useState(true);
@ -240,7 +249,7 @@ export default function AgentBuilderView({
return list;
} catch (e) {
console.error(e);
toast.fromError("Failed to load agents");
toast.fromError(t("playground.chatUi.agentBuilderView.loadAgentsFailed"));
return [];
} finally {
setLoadingAgents(false);
@ -333,7 +342,7 @@ export default function AgentBuilderView({
const handleSaveAgent = async () => {
if (!accessToken || !draftName?.trim() || !draftUnderlyingModel) {
toast.fromError("Name and underlying model are required");
toast.fromError(t("playground.chatUi.agentBuilderView.nameAndModelRequired"));
return;
}
setSaving(true);
@ -360,7 +369,7 @@ export default function AgentBuilderView({
setSelectedId(created ? getAgentSelectionKey(created) : list[0] ? getAgentSelectionKey(list[0]) : null);
goToTab("chat");
} catch (e) {
toast.fromError("Failed to save agent");
toast.fromError(t("playground.chatUi.agentBuilderView.saveFailed"));
} finally {
setSaving(false);
}
@ -368,7 +377,7 @@ export default function AgentBuilderView({
const handleUpdateAgent = async () => {
if (!accessToken || !selectedAgent || !selectedAgentModelId || !draftName?.trim() || !draftUnderlyingModel) {
toast.fromError("Name and underlying model are required");
toast.fromError(t("playground.chatUi.agentBuilderView.nameAndModelRequired"));
return;
}
setSaving(true);
@ -388,13 +397,13 @@ export default function AgentBuilderView({
},
selectedAgentModelId,
);
toast.success("Agent updated successfully");
toast.success(t("playground.chatUi.agentBuilderView.updated"));
const list = await loadAgents();
const stillSelected = list.find((a) => getAgentModelId(a) === selectedAgentModelId);
const target = stillSelected ?? list[0];
setSelectedId(target ? getAgentSelectionKey(target) : null);
} catch (e) {
toast.fromError("Failed to update agent");
toast.fromError(t("playground.chatUi.agentBuilderView.updateFailed"));
} finally {
setSaving(false);
}
@ -412,12 +421,12 @@ export default function AgentBuilderView({
const keyValue = response?.key ?? null;
if (keyValue) {
setCreatedKeyValue(keyValue);
toast.success("Virtual key created. Use it in the curl example below.");
toast.success(t("playground.chatUi.agentBuilderView.virtualKeyCreated"));
} else {
toast.fromError("Key created but value not returned");
toast.fromError(t("playground.chatUi.agentBuilderView.keyValueMissing"));
}
} catch (e) {
toast.fromError("Failed to create key for agent");
toast.fromError(t("playground.chatUi.agentBuilderView.createKeyFailed"));
} finally {
setCreatingKey(false);
}
@ -433,12 +442,12 @@ export default function AgentBuilderView({
setDeleting(true);
try {
await modelDeleteCall(accessToken, selectedAgentModelId);
toast.success("Agent deleted");
toast.success(t("playground.chatUi.agentBuilderView.deleted"));
const list = await loadAgents();
const remaining = list.filter((a) => getAgentModelId(a) !== selectedAgentModelId);
setSelectedId(remaining.length > 0 ? getAgentSelectionKey(remaining[0]) : null);
} catch (e) {
toast.fromError("Failed to delete agent");
toast.fromError(t("playground.chatUi.agentBuilderView.deleteFailed"));
} finally {
setDeleting(false);
setConfirmingDelete(false);
@ -448,7 +457,7 @@ export default function AgentBuilderView({
if (!accessToken || !userID || !userRole) {
return (
<div className="flex h-full items-center justify-center p-8 text-muted-foreground">
Sign in to use Agent Builder.
{t("playground.chatUi.agentBuilderView.signIn")}
</div>
);
}
@ -457,25 +466,30 @@ export default function AgentBuilderView({
<div className="flex h-full flex-col bg-card text-foreground">
<div className="flex shrink-0 flex-col border-b border-border">
<div className="flex h-12 items-center justify-between px-4">
<span className="text-sm font-medium text-foreground">Agent Builder</span>
<span className="text-sm font-medium text-foreground">{t("playground.chatUi.agentBuilderView.title")}</span>
{isNewAgent ? (
<Button onClick={handleSaveAgent} disabled={saving || !draftName?.trim() || !draftUnderlyingModel}>
<Save />
Save Agent
{t("playground.chatUi.agentBuilderView.saveAgent")}
</Button>
) : (
<span className="text-xs text-muted-foreground">Build Agents that pass your compliance requirements.</span>
<span className="text-xs text-muted-foreground">{t("playground.chatUi.agentBuilderView.subtitle")}</span>
)}
</div>
<div className="flex items-center gap-2 border-t border-warning/20 bg-warning/10 px-4 py-2 text-xs text-warning">
<FlaskConical className="size-4 shrink-0 text-warning" />
<span>
Agent Builder is experimental and may change or be removed without notice. We’d love your feedback—email us
at{" "}
<a href="mailto:product@berri.ai" className="font-medium text-warning underline hover:text-warning/80">
product@berri.ai
</a>
.
<Trans
i18nKey="playground.chatUi.agentBuilderView.experimentalNotice"
components={{
email: (
<a
href="mailto:product@berri.ai"
className="font-medium text-warning underline hover:text-warning/80"
/>
),
}}
/>
</span>
</div>
</div>
@ -484,8 +498,15 @@ export default function AgentBuilderView({
{/* Roster */}
<div className="w-60 shrink-0 border-r border-border bg-card flex flex-col">
<div className="flex items-center justify-between border-b border-border p-3">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Agents</span>
<Button variant="ghost" size="icon-sm" onClick={handleAddAgent} aria-label="Add agent">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t("playground.chatUi.agentBuilderView.agents")}
</span>
<Button
variant="ghost"
size="icon-sm"
onClick={handleAddAgent}
aria-label={t("playground.chatUi.agentBuilderView.addAgent")}
>
<Plus />
</Button>
</div>
@ -517,7 +538,7 @@ export default function AgentBuilderView({
onClick={handleAddAgent}
className="mb-1 w-full rounded-md border border-dashed border-border px-3 py-2 text-left text-sm text-muted-foreground hover:border-info hover:bg-info/10 hover:text-foreground"
>
<Plus className="mr-1 inline size-4" /> New agent
<Plus className="mr-1 inline size-4" /> {t("playground.chatUi.agentBuilderView.newAgent")}
</button>
</>
)}
@ -528,7 +549,7 @@ export default function AgentBuilderView({
<div className="flex flex-1 flex-col overflow-hidden">
{selectedId === null && !isNewAgent && agentModels.length === 0 && !loadingAgents && (
<div className="flex flex-1 items-center justify-center p-8 text-muted-foreground">
No agents yet. Add an agent to get started.
{t("playground.chatUi.agentBuilderView.noAgents")}
</div>
)}
{(selectedId !== null || isNewAgent) && (
@ -541,19 +562,19 @@ export default function AgentBuilderView({
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0 pl-4">
<TabsTrigger value="configure" className="flex-none rounded-none px-4 py-2">
<Bot />
Configure
{t("playground.chatUi.agentBuilderView.tabs.configure")}
</TabsTrigger>
<TabsTrigger value="chat" disabled={isNewAgent} className="flex-none rounded-none px-4 py-2">
<MessageSquare />
Chat
{t("playground.chatUi.agentBuilderView.tabs.chat")}
</TabsTrigger>
<TabsTrigger value="test" disabled={isNewAgent} className="flex-none rounded-none px-4 py-2">
<FlaskConical />
Batch Test
{t("playground.chatUi.agentBuilderView.tabs.batchTest")}
</TabsTrigger>
<TabsTrigger value="connect" disabled={isNewAgent} className="flex-none rounded-none px-4 py-2">
<LinkIcon />
Connect
{t("playground.chatUi.agentBuilderView.tabs.connect")}
</TabsTrigger>
</TabsList>
@ -567,36 +588,44 @@ export default function AgentBuilderView({
<div className="mx-auto max-w-xl space-y-4">
{!selectedAgentModelId && selectedAgent && (
<div className="rounded-sm border border-warning/20 bg-warning/10 px-3 py-2 text-xs text-warning">
This agent cannot be updated or deleted here (missing model id). Manage it from Models &amp;
Endpoints.
{t("playground.chatUi.agentBuilderView.missingModelId")}
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Agent name</label>
<label className="mb-1 block text-sm font-medium text-foreground">
{t("playground.chatUi.agentBuilderView.agentName")}
</label>
<Input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
placeholder="My Agent"
placeholder={t("playground.chatUi.agentBuilderView.agentNamePlaceholder")}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">System prompt</label>
<label className="mb-1 block text-sm font-medium text-foreground">
{t("playground.chatUi.agentBuilderView.systemPrompt")}
</label>
<Textarea
value={draftSystemPrompt}
onChange={(e) => setDraftSystemPrompt(e.target.value)}
placeholder="You are a helpful assistant..."
placeholder={t("playground.chatUi.agentBuilderView.systemPromptPlaceholder")}
rows={6}
className="field-sizing-fixed"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Underlying LLM</label>
<label className="mb-1 block text-sm font-medium text-foreground">
{t("playground.chatUi.agentBuilderView.underlyingLlm")}
</label>
<Select
value={draftUnderlyingModel ?? null}
onValueChange={(model: string | null) => setDraftUnderlyingModel(model ?? undefined)}
>
<SelectTrigger className="w-full" aria-label="Underlying LLM">
<SelectValue placeholder="Select model" />
<SelectTrigger
className="w-full"
aria-label={t("playground.chatUi.agentBuilderView.underlyingLlm")}
>
<SelectValue placeholder={t("playground.chatUi.agentBuilderView.selectModel")} />
</SelectTrigger>
<SelectContent>
{modelGroups.map((m) => (
@ -609,7 +638,9 @@ export default function AgentBuilderView({
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Temperature</label>
<label className="mb-1 block text-sm font-medium text-foreground">
{t("playground.chatUi.agentBuilderView.temperature")}
</label>
<Input
type="number"
min={0}
@ -620,7 +651,9 @@ export default function AgentBuilderView({
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">Max tokens</label>
<label className="mb-1 block text-sm font-medium text-foreground">
{t("playground.chatUi.agentBuilderView.maxTokens")}
</label>
<Input
type="number"
min={1}
@ -630,9 +663,11 @@ export default function AgentBuilderView({
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-foreground">MCP servers</label>
<label className="mb-1 block text-sm font-medium text-foreground">
{t("playground.chatUi.agentBuilderView.mcpServers")}
</label>
<MultiSelect
placeholder="Select MCP servers to attach (same format as chat completions API)"
placeholder={t("playground.chatUi.agentBuilderView.mcpServersPlaceholder")}
value={selectedMCPServerIds}
onValueChange={handleMCPServerChange}
loading={loadingMCPServers}
@ -644,9 +679,11 @@ export default function AgentBuilderView({
/>
{selectedAgent && draftTools.length > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{draftTools.length} MCP server{draftTools.length !== 1 ? "s" : ""} saved. Use the same{" "}
<code className="rounded-sm bg-muted px-1">tools</code> array in chat completions when
calling this agent.
<Trans
i18nKey="playground.chatUi.agentBuilderView.mcpServersSaved"
count={draftTools.length}
components={{ field: <code className="rounded-sm bg-muted px-1" /> }}
/>
</p>
)}
</div>
@ -659,17 +696,17 @@ export default function AgentBuilderView({
disabled={saving || !draftName?.trim() || !draftUnderlyingModel}
>
<Save />
Update Agent
{t("playground.chatUi.agentBuilderView.updateAgent")}
</Button>
<Button variant="destructive" onClick={handleDeleteAgent} disabled={deleting}>
<Trash2 />
Delete
{t("playground.chatUi.agentBuilderView.delete")}
</Button>
</>
)}
<Button onClick={() => goToTab("chat")}>
<MessageSquare />
Test in Chat
{t("playground.chatUi.agentBuilderView.testInChat")}
</Button>
</div>
)}
@ -693,7 +730,7 @@ export default function AgentBuilderView({
/>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
Save an agent first to test in Chat.
{t("playground.chatUi.agentBuilderView.saveFirst")}
</div>
)}
</div>
@ -710,7 +747,7 @@ export default function AgentBuilderView({
/>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
Select an agent to run batch tests.
{t("playground.chatUi.agentBuilderView.selectForBatch")}
</div>
)}
</div>
@ -731,7 +768,7 @@ export default function AgentBuilderView({
/>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground">
Select an agent to see how to connect.
{t("playground.chatUi.agentBuilderView.selectForConnect")}
</div>
)}
</div>
@ -745,15 +782,17 @@ export default function AgentBuilderView({
<AlertDialog open={confirmingDelete} onOpenChange={setConfirmingDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete agent</AlertDialogTitle>
<AlertDialogTitle>{t("playground.chatUi.agentBuilderView.deleteDialog.title")}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{selectedAgent?.model_name}&quot;? This cannot be undone.
{t("playground.chatUi.agentBuilderView.deleteDialog.description", { name: selectedAgent?.model_name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction variant="outline">Cancel</AlertDialogAction>
<AlertDialogAction variant="outline">
{t("playground.chatUi.agentBuilderView.deleteDialog.cancel")}
</AlertDialogAction>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={deleting}>
Delete
{t("playground.chatUi.agentBuilderView.deleteDialog.confirm")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { MessageType } from "@/components/chat_ui/types";
interface AudioRendererProps {
@ -6,6 +7,7 @@ interface AudioRendererProps {
}
const AudioRenderer: React.FC<AudioRendererProps> = ({ message }) => {
const { t } = useTranslation();
// Check if this message contains audio
if (!message.isAudio || typeof message.content !== "string") {
return null;
@ -14,7 +16,7 @@ const AudioRenderer: React.FC<AudioRendererProps> = ({ message }) => {
return (
<div className="mb-2">
<audio controls src={message.content} className="max-w-full" style={{ maxWidth: "500px" }}>
Your browser does not support the audio element.
{t("playground.chatUi.audioRenderer.unsupported")}
</audio>
</div>
);

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Image from "next/image";
import { FileText } from "lucide-react";
import { MessageType } from "@/components/chat_ui/types";
@ -9,6 +10,7 @@ interface ChatImageRendererProps {
}
const ChatImageRenderer: React.FC<ChatImageRendererProps> = ({ message }) => {
const { t } = useTranslation();
if (!shouldShowChatAttachedImage(message)) {
return null;
}
@ -19,12 +21,15 @@ const ChatImageRenderer: React.FC<ChatImageRendererProps> = ({ message }) => {
<div className="mb-2">
{isPdf ? (
<div className="flex h-32 w-64 items-center justify-center rounded-md border border-border bg-destructive/10">
<FileText className="size-12 text-destructive" aria-label="PDF attachment" />
<FileText
className="size-12 text-destructive"
aria-label={t("playground.chatUi.chatImageRenderer.pdfAttachment")}
/>
</div>
) : (
<Image
src={message.imagePreviewUrl || ""}
alt="User uploaded image"
alt={t("playground.chatUi.chatImageRenderer.uploadedImage")}
width={256}
height={200}
className="max-w-64 rounded-md border border-border shadow-xs"

View file

@ -1,5 +1,6 @@
import React, { useId, useRef } from "react";
import { Paperclip } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "@/lib/toast";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -14,6 +15,7 @@ interface ChatImageUploadProps {
}
const ChatImageUpload: React.FC<ChatImageUploadProps> = ({ chatUploadedImage, onImageUpload, disabled = false }) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const inputId = useId();
@ -27,7 +29,7 @@ const ChatImageUpload: React.FC<ChatImageUploadProps> = ({ chatUploadedImage, on
if (!file) {
return;
}
const result = validateChatAttachment(file);
const result = validateChatAttachment(file, t);
if (!result.ok) {
toast.error(result.error);
return;
@ -55,7 +57,7 @@ const ChatImageUpload: React.FC<ChatImageUploadProps> = ({ chatUploadedImage, on
variant="ghost"
size="icon-sm"
disabled={disabled}
aria-label="Attach image or PDF"
aria-label={t("playground.chatUi.chatImageUpload.attach")}
className="text-muted-foreground hover:text-foreground"
onClick={() => inputRef.current?.click()}
/>
@ -63,7 +65,7 @@ const ChatImageUpload: React.FC<ChatImageUploadProps> = ({ chatUploadedImage, on
>
<Paperclip className="size-4" />
</TooltipTrigger>
<TooltipContent>Attach image or PDF</TooltipContent>
<TooltipContent>{t("playground.chatUi.chatImageUpload.attach")}</TooltipContent>
</Tooltip>
</>
);

View file

@ -1,5 +1,6 @@
import { Bot, User } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
@ -40,6 +41,7 @@ function ChatMessageBubble({
codeInterpreterResult,
accessToken,
}: ChatMessageBubbleProps) {
const { t } = useTranslation();
const syntaxTheme = useSyntaxTheme(coy);
const isUser = message.role === "user";
@ -117,7 +119,7 @@ function ChatMessageBubble({
{message.isImage ? (
<img
src={typeof message.content === "string" ? message.content : ""}
alt="Generated image"
alt={t("playground.chatUi.chatMessageBubble.generatedImage")}
className="max-w-full rounded-md border border-border shadow-xs"
style={{ maxHeight: "500px" }}
/>
@ -175,7 +177,7 @@ function ChatMessageBubble({
<div className="mt-3">
<img
src={message.image.url}
alt="Generated image"
alt={t("playground.chatUi.chatMessageBubble.generatedImage")}
className="max-w-full rounded-md border border-border shadow-xs"
style={{ maxHeight: "500px" }}
/>

View file

@ -21,6 +21,7 @@ import {
X,
} from "lucide-react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
@ -130,6 +131,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
simplified = false,
fixedModel,
}) => {
const { t } = useTranslation();
const syntaxTheme = useSyntaxTheme(coy);
const canViewPolicies = useCan("viewPolicies");
const [mcpServers, setMCPServers] = useState<MCPServer[]>([]);
@ -536,7 +538,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsLoading(false);
toast.info("Request cancelled");
toast.info(t("playground.chatUi.chatUi.requestCancelled"));
}
};
@ -550,7 +552,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
const accepted: File[] = [];
const previews: string[] = [];
for (const file of files) {
const result = validateImageEditFile(file, nextCount);
const result = validateImageEditFile(file, nextCount, t);
if (!result.ok) {
toast.error(result.error);
continue;
@ -587,7 +589,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
};
const handleResponsesImageUpload = (file: File): void => {
const result = validateChatAttachment(file);
const result = validateChatAttachment(file, t);
if (!result.ok) {
toast.error(result.error);
return;
@ -605,7 +607,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
};
const handleChatImageUpload = (file: File): void => {
const result = validateChatAttachment(file);
const result = validateChatAttachment(file, t);
if (!result.ok) {
toast.error(result.error);
return;
@ -623,7 +625,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
};
const handleAudioUpload = (file: File): void => {
const result = validateAudioFile(file);
const result = validateAudioFile(file, t);
if (!result.ok) {
toast.error(result.error);
return;
@ -668,15 +670,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
if (endpointType !== EndpointType.MCP) {
options.push({
value: "__all__",
label: "All MCP Servers",
description: "Use all available MCP servers",
label: t("playground.chatUi.chatUi.allMcpServers"),
description: t("playground.chatUi.chatUi.allMcpServersDescription"),
});
}
for (const toolset of mcpToolsets) {
options.push({
value: `toolset:${toolset.toolset_id}`,
label: toolset.toolset_name,
description: toolset.description || `Toolset (${toolset.tools.length} tools)`,
description:
toolset.description || t("playground.chatUi.chatUi.toolsetDescription", { tools: toolset.tools.length }),
});
}
for (const server of mcpServers) {
@ -687,7 +690,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
});
}
return options;
}, [endpointType, mcpToolsets, mcpServers]);
}, [endpointType, mcpToolsets, mcpServers, t]);
const handleMcpServersChange = (value: string[]) => {
if (endpointType === EndpointType.MCP) {
@ -727,7 +730,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
const handleSendMessage = async () => {
if (endpointType === null) {
toast.fromError("Please select an endpoint before sending a request");
toast.fromError(t("playground.chatUi.chatUi.selectEndpoint"));
return;
}
@ -736,19 +739,19 @@ const ChatUI: React.FC<ChatUIProps> = ({
// For image edits, require both image and prompt
if (endpointType === EndpointType.IMAGE_EDITS && uploadedImages.length === 0) {
toast.fromError("Please upload at least one image for editing");
toast.fromError(t("playground.chatUi.chatUi.uploadImageForEdit"));
return;
}
// For audio transcriptions, require audio file
if (endpointType === EndpointType.TRANSCRIPTION && !uploadedAudio) {
toast.fromError("Please upload an audio file for transcription");
toast.fromError(t("playground.chatUi.chatUi.uploadAudio"));
return;
}
// For A2A agents, require agent selection
if (endpointType === EndpointType.A2A_AGENTS && !selectedAgent) {
toast.fromError("Please select an agent to send a message");
toast.fromError(t("playground.chatUi.chatUi.selectAgentToSend"));
return;
}
@ -758,11 +761,11 @@ const ChatUI: React.FC<ChatUIProps> = ({
const rawSelected =
selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__" ? selectedMCPServers[0] : null;
if (!rawSelected) {
toast.fromError("Please select an MCP server to test");
toast.fromError(t("playground.chatUi.chatUi.selectMcpServer"));
return;
}
if (!selectedMCPDirectTool) {
toast.fromError("Please select an MCP tool to call");
toast.fromError(t("playground.chatUi.chatUi.selectMcpTool"));
return;
}
// For toolsets, find the tool in the servers that back this toolset
@ -780,13 +783,13 @@ const ChatUI: React.FC<ChatUIProps> = ({
}
const mcpTool = searchPool.find((t: any) => t.name === selectedMCPDirectTool);
if (!mcpTool) {
toast.fromError("Please wait for tool schema to load");
toast.fromError(t("playground.chatUi.chatUi.waitForSchema"));
return;
}
try {
mcpToolArguments = (await mcpToolArgsFormRef.current?.getSubmitValues()) ?? {};
} catch (err) {
toast.fromError(err instanceof Error ? err.message : "Please fill in all required parameters");
toast.fromError(err instanceof Error ? err.message : t("playground.chatUi.chatUi.fillRequiredParams"));
return;
}
}
@ -805,7 +808,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
];
if (modelRequiredEndpoints.includes(endpointType as EndpointType) && !selectedModel) {
toast.fromError("Please select a model before sending a request");
toast.fromError(t("playground.chatUi.chatUi.selectModelToSend"));
return;
}
@ -816,7 +819,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
const effectiveApiKey = simplified ? accessToken : apiKeySource === "session" ? accessToken : apiKey;
if (!effectiveApiKey) {
toast.fromError("Please provide a Virtual Key or select Current UI Session");
toast.fromError(t("playground.chatUi.chatUi.provideKey"));
return;
}
@ -832,7 +835,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
try {
newUserMessage = await createMultimodalMessage(inputMessage, responsesUploadedImage);
} catch (error) {
toast.fromError("Failed to process image. Please try again.");
toast.fromError(t("playground.chatUi.chatUi.processImageFailed"));
return;
}
}
@ -841,7 +844,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
try {
newUserMessage = await createChatMultimodalMessage(inputMessage, chatUploadedImage);
} catch (error) {
toast.fromError("Failed to process image. Please try again.");
toast.fromError(t("playground.chatUi.chatUi.processImageFailed"));
return;
}
} else {
@ -1122,7 +1125,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
2,
)
: JSON.stringify(result, null, 2);
updateTextUI("assistant", resultText || "Tool executed successfully.");
updateTextUI("assistant", resultText || t("playground.chatUi.chatUi.toolExecuted"));
}
}
@ -1178,7 +1181,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
handleRemoveResponsesImage();
handleRemoveChatImage();
handleRemoveAudio();
toast.success("Chat history cleared.");
toast.success(t("playground.chatUi.chatUi.historyCleared"));
};
const onModelChange = (value: string | null) => {
@ -1212,13 +1215,13 @@ const ChatUI: React.FC<ChatUIProps> = ({
() => filterModelsForEndpoint(modelInfo, endpointType as EndpointType),
[modelInfo, endpointType],
);
let modelEmptyText = "No models available for this key";
let modelEmptyText = t("playground.chatUi.chatUi.noModelsForKey");
if (modelLoadError) {
modelEmptyText = "Unable to load models for this key";
modelEmptyText = t("playground.chatUi.chatUi.modelsLoadFailed");
} else if (apiKeySource === "custom" && !apiKey.trim()) {
modelEmptyText = "Enter a Virtual Key to load models";
modelEmptyText = t("playground.chatUi.chatUi.enterKeyForModels");
} else if (modelInfo.length > 0 && modelsForEndpoint.length === 0) {
modelEmptyText = "No models available for this endpoint";
modelEmptyText = t("playground.chatUi.chatUi.noModelsForEndpoint");
}
const inputPlaceholder =
@ -1227,16 +1230,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
endpointType === EndpointType.RESPONSES ||
endpointType === EndpointType.ANTHROPIC_MESSAGES ||
endpointType === EndpointType.INTERACTIONS
? "Type your message... (Shift+Enter for new line)"
? t("playground.chatUi.chatUi.placeholders.chat")
: endpointType === EndpointType.A2A_AGENTS
? "Send a message to the A2A agent..."
? t("playground.chatUi.chatUi.placeholders.a2a")
: endpointType === EndpointType.IMAGE_EDITS
? "Describe how you want to edit the image..."
? t("playground.chatUi.chatUi.placeholders.imageEdits")
: endpointType === EndpointType.SPEECH
? "Enter text to convert to speech..."
? t("playground.chatUi.chatUi.placeholders.speech")
: endpointType === EndpointType.TRANSCRIPTION
? "Optional: Add context or prompt for transcription..."
: "Describe the image you want to generate...";
? t("playground.chatUi.chatUi.placeholders.transcription")
: t("playground.chatUi.chatUi.placeholders.image");
const sendDisabled =
endpointType === null ||
@ -1253,11 +1256,11 @@ const ChatUI: React.FC<ChatUIProps> = ({
<div className="flex h-full min-h-0 min-w-0 w-full flex-col lg:flex-row">
{!simplified && (
<div className="max-h-[42%] w-full shrink-0 overflow-y-auto border-b border-border bg-muted p-4 lg:max-h-none lg:w-72 lg:border-r lg:border-b-0 xl:w-80">
<h2 className="mb-6 mt-2 text-xl font-semibold">Configurations</h2>
<h2 className="mb-6 mt-2 text-xl font-semibold">{t("playground.chatUi.chatUi.configurations")}</h2>
<div className="space-y-4">
<div>
<label className="mb-2 flex items-center text-sm font-medium text-foreground">
<Key className="mr-2 size-4" aria-hidden="true" /> Virtual Key Source
<Key className="mr-2 size-4" aria-hidden="true" /> {t("playground.chatUi.chatUi.keySource")}
</label>
<ShadcnSelect
disabled={disabledPersonalKeyCreation}
@ -1266,12 +1269,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
setApiKeySource(value as "session" | "custom");
}}
>
<SelectTrigger className="w-full" size="sm" aria-label="Virtual Key Source">
<SelectValue>{apiKeySource === "custom" ? "Virtual Key" : "Current UI Session"}</SelectValue>
<SelectTrigger className="w-full" size="sm" aria-label={t("playground.chatUi.chatUi.keySource")}>
<SelectValue>
{apiKeySource === "custom"
? t("playground.chatUi.chatUi.virtualKey")
: t("playground.chatUi.chatUi.currentSession")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="session">Current UI Session</SelectItem>
<SelectItem value="custom">Virtual Key</SelectItem>
<SelectItem value="session">{t("playground.chatUi.chatUi.currentSession")}</SelectItem>
<SelectItem value="custom">{t("playground.chatUi.chatUi.virtualKey")}</SelectItem>
</SelectContent>
</ShadcnSelect>
{apiKeySource === "custom" && (
@ -1279,7 +1286,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
<Key className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 pl-8"
placeholder="Enter custom Virtual Key"
placeholder={t("playground.chatUi.chatUi.customKeyPlaceholder")}
type="password"
onChange={(event) => setApiKey(event.target.value)}
value={apiKey}
@ -1291,7 +1298,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
<div>
<div className="mb-2 flex items-center justify-between">
<label className="flex items-center text-sm font-medium text-foreground">
<Settings className="mr-2 size-4" aria-hidden="true" /> Custom Proxy Base URL
<Settings className="mr-2 size-4" aria-hidden="true" />{" "}
{t("playground.chatUi.chatUi.customProxyBaseUrl")}
</label>
{proxySettings?.LITELLM_UI_API_DOC_BASE_URL && !customProxyBaseUrl && (
<Button
@ -1305,7 +1313,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
}}
>
<Link2 className="size-3" />
Fill
{t("playground.chatUi.chatUi.fill")}
</Button>
)}
{customProxyBaseUrl && (
@ -1320,7 +1328,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
}}
>
<Eraser className="size-3" />
Clear
{t("playground.chatUi.chatUi.clear")}
</Button>
)}
</div>
@ -1328,7 +1336,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
<Wrench className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 pl-8"
placeholder="Optional: Enter custom proxy URL (e.g., http://localhost:5000)"
placeholder={t("playground.chatUi.chatUi.customProxyPlaceholder")}
value={customProxyBaseUrl}
onChange={(event) => {
setCustomProxyBaseUrl(event.target.value);
@ -1338,14 +1346,14 @@ const ChatUI: React.FC<ChatUIProps> = ({
</div>
{customProxyBaseUrl && (
<p className="mt-1 text-xs text-muted-foreground">
API calls will be sent to: {customProxyBaseUrl}
{t("playground.chatUi.chatUi.apiCallsSentTo", { url: customProxyBaseUrl })}
</p>
)}
</div>
<div>
<label className="mb-2 flex items-center text-sm font-medium text-foreground">
<Wrench className="mr-2 size-4" aria-hidden="true" /> Endpoint Type
<Wrench className="mr-2 size-4" aria-hidden="true" /> {t("playground.chatUi.chatUi.endpointType")}
</label>
<EndpointSelector
endpointType={endpointType}
@ -1357,20 +1365,20 @@ const ChatUI: React.FC<ChatUIProps> = ({
<div className="mb-4">
<label className="mb-2 flex items-center text-sm font-medium text-foreground">
<Volume2 className="mr-2 size-4" aria-hidden="true" />
Voice
{t("playground.chatUi.chatUi.voice")}
</label>
<ShadcnSelect
items={OPEN_AI_VOICE_SELECT_OPTIONS}
items={OPEN_AI_VOICE_SELECT_OPTIONS.map((voice) => ({ ...voice, label: t(voice.label) }))}
value={selectedVoice}
onValueChange={handleVoiceChange}
>
<SelectTrigger className="w-full" size="sm" aria-label="Voice">
<SelectTrigger className="w-full" size="sm" aria-label={t("playground.chatUi.chatUi.voice")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPEN_AI_VOICE_SELECT_OPTIONS.map((voice) => (
<SelectItem key={voice.value} value={voice.value}>
{voice.label}
{t(voice.label)}
</SelectItem>
))}
</SelectContent>
@ -1390,7 +1398,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
<div>
<div className="mb-2 flex items-center justify-between text-sm font-medium text-foreground">
<span className="flex items-center">
<Bot className="mr-2 size-4" aria-hidden="true" /> Select Model
<Bot className="mr-2 size-4" aria-hidden="true" /> {t("playground.chatUi.chatUi.selectModel")}
</span>
{isChatModel() || supportsStreamingToggle ? (
<Popover>
@ -1401,7 +1409,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
aria-label="Model Settings"
aria-label={t("playground.chatUi.chatUi.modelSettings")}
data-testid="model-settings-button"
/>
}
@ -1409,7 +1417,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
<Settings className="size-3.5" />
</PopoverTrigger>
<PopoverContent side="right" className="w-auto p-0">
<div className="border-b border-border px-4 py-2 text-sm font-medium">Model Settings</div>
<div className="border-b border-border px-4 py-2 text-sm font-medium">
{t("playground.chatUi.chatUi.modelSettings")}
</div>
<AdditionalModelSettings
showAdvancedParams={isChatModel()}
temperature={temperature}
@ -1435,37 +1445,41 @@ const ChatUI: React.FC<ChatUIProps> = ({
size="icon-xs"
className="cursor-not-allowed text-muted-foreground"
disabled
aria-label="Model Settings unavailable"
aria-label={t("playground.chatUi.chatUi.modelSettingsUnavailable")}
/>
}
>
<Settings className="size-3.5" />
</TooltipTrigger>
<TooltipContent>
Advanced parameters are only supported for chat models currently
</TooltipContent>
<TooltipContent>{t("playground.chatUi.chatUi.advancedParamsChatOnly")}</TooltipContent>
</Tooltip>
)}
</div>
<SearchSelect
value={selectedModel}
placeholder={isLoadingModels ? "Loading models..." : "Select a Model"}
placeholder={
isLoadingModels
? t("playground.chatUi.chatUi.loadingModels")
: t("playground.chatUi.chatUi.selectAModel")
}
emptyText={modelEmptyText}
disabled={isLoadingModels}
onValueChange={onModelChange}
options={[
{ value: "custom", label: "Enter custom model" },
{ value: "custom", label: t("playground.chatUi.chatUi.customModelOption") },
...modelsForEndpoint.map((model) => ({
value: model.model_group,
label: model.model_group,
sublabel: model.mode ? `Mode: ${model.mode}` : undefined,
sublabel: model.mode
? t("playground.chatUi.chatUi.modeSublabel", { mode: model.mode })
: undefined,
})),
]}
/>
{showCustomModelInput && (
<Input
className="mt-2 h-8"
placeholder="Enter custom model name"
placeholder={t("playground.chatUi.chatUi.customModelPlaceholder")}
onChange={(event) => debouncedSetSelectedModel(event.target.value)}
/>
)}
@ -1475,11 +1489,11 @@ const ChatUI: React.FC<ChatUIProps> = ({
{endpointType === EndpointType.A2A_AGENTS && (
<div>
<label className="mb-2 flex items-center text-sm font-medium text-foreground">
<Bot className="mr-2 size-4" aria-hidden="true" /> Select Agent
<Bot className="mr-2 size-4" aria-hidden="true" /> {t("playground.chatUi.chatUi.selectAgent")}
</label>
<SearchSelect
value={selectedAgent}
placeholder="Select an Agent"
placeholder={t("playground.chatUi.chatUi.selectAnAgent")}
onValueChange={(value) => setSelectedAgent(value)}
options={agentInfo.map((agent) => ({
value: agent.agent_name,
@ -1488,16 +1502,14 @@ const ChatUI: React.FC<ChatUIProps> = ({
}))}
/>
{agentInfo.length === 0 && (
<p className="mt-2 text-xs text-muted-foreground">
No agents found. Create agents via /v1/agents endpoint.
</p>
<p className="mt-2 text-xs text-muted-foreground">{t("playground.chatUi.chatUi.noAgents")}</p>
)}
</div>
)}
<div>
<label className="mb-2 flex items-center text-sm font-medium text-foreground">
<Tags className="mr-2 size-4" aria-hidden="true" /> Tags
<Tags className="mr-2 size-4" aria-hidden="true" /> {t("playground.chatUi.chatUi.tags")}
</label>
<TagSelector
value={selectedTags}
@ -1510,11 +1522,12 @@ const ChatUI: React.FC<ChatUIProps> = ({
{endpointType !== EndpointType.REALTIME && (
<div>
<label className="mb-2 flex items-center text-sm font-medium text-foreground">
<ListPlus className="mr-2 size-4" aria-hidden="true" /> Custom Headers
<ListPlus className="mr-2 size-4" aria-hidden="true" />{" "}
{t("playground.chatUi.chatUi.customHeaders")}
</label>
<KeyValueInput value={customHeaderPairs} onChange={setCustomHeaderPairs} />
<p className="mt-2 text-xs text-muted-foreground">
Sent with every playground request, e.g. provider-specific headers like anthropic-beta.
{t("playground.chatUi.chatUi.customHeadersHint")}
</p>
</div>
)}
@ -1522,14 +1535,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
<div>
<div className="mb-2 flex items-center gap-1 text-sm font-medium text-foreground">
<Wrench className="mr-1 size-4" aria-hidden="true" />
{endpointType === EndpointType.MCP ? "MCP Server" : "MCP Servers"}
{endpointType === EndpointType.MCP
? t("playground.chatUi.chatUi.mcpServer")
: t("playground.chatUi.chatUi.mcpServers")}
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
className="inline-flex"
aria-label="About MCP servers and toolsets"
aria-label={t("playground.chatUi.chatUi.aboutMcp")}
onClick={() => setIsToolsetsInfoModalVisible(true)}
/>
}
@ -1538,8 +1553,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{endpointType === EndpointType.MCP
? "Select an MCP server or toolset to test tools directly."
: "Select MCP servers or toolsets to use in your conversation."}
? t("playground.chatUi.chatUi.mcpDirectHint")
: t("playground.chatUi.chatUi.mcpChatHint")}
</TooltipContent>
</Tooltip>
</div>
@ -1550,8 +1565,12 @@ const ChatUI: React.FC<ChatUIProps> = ({
? selectedMCPServers[0]
: undefined
}
placeholder="Select MCP server"
emptyText={isLoadingMCPServers ? "Loading..." : "No MCP servers"}
placeholder={t("playground.chatUi.chatUi.selectMcpServerPlaceholder")}
emptyText={
isLoadingMCPServers
? t("playground.chatUi.chatUi.loading")
: t("playground.chatUi.chatUi.noMcpServers")
}
disabled={!MCP_SUPPORTED_ENDPOINTS.has(endpointType as EndpointType) || isLoadingMCPServers}
onValueChange={(value) => handleMcpServersChange(value ? [value] : [])}
options={mcpServerOptions}
@ -1561,8 +1580,12 @@ const ChatUI: React.FC<ChatUIProps> = ({
<MultiSelect
value={selectedMCPServers}
onValueChange={handleMcpServersChange}
placeholder="Select MCP servers"
emptyText={isLoadingMCPServers ? "Loading..." : "No MCP servers"}
placeholder={t("playground.chatUi.chatUi.selectMcpServersPlaceholder")}
emptyText={
isLoadingMCPServers
? t("playground.chatUi.chatUi.loading")
: t("playground.chatUi.chatUi.noMcpServers")
}
disabled={!MCP_SUPPORTED_ENDPOINTS.has(endpointType as EndpointType)}
loading={isLoadingMCPServers}
options={mcpServerOptions}
@ -1594,10 +1617,12 @@ const ChatUI: React.FC<ChatUIProps> = ({
}
return (
<div className="mt-3">
<p className="mb-1 block text-xs text-muted-foreground">Select Tool</p>
<p className="mb-1 block text-xs text-muted-foreground">
{t("playground.chatUi.chatUi.selectTool")}
</p>
<SearchSelect
value={selectedMCPDirectTool}
placeholder="Select a tool to call"
placeholder={t("playground.chatUi.chatUi.selectToolPlaceholder")}
onValueChange={(value) => setSelectedMCPDirectTool(value || undefined)}
options={toolOptions}
className="rounded-md"
@ -1619,7 +1644,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
return (
<div key={serverId} className="rounded-sm border p-2">
<p className="mb-1 text-xs text-muted-foreground">
Limit tools for {server?.alias || server?.server_name || serverId}:
{t("playground.chatUi.chatUi.limitToolsFor", {
server: server?.alias || server?.server_name || serverId,
})}
</p>
<MultiSelect
value={mcpServerToolRestrictions[serverId] || []}
@ -1629,7 +1656,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
[serverId]: selectedTools,
}));
}}
placeholder="All tools (default)"
placeholder={t("playground.chatUi.chatUi.allToolsDefault")}
options={tools.map((tool: { name: string }) => ({
value: tool.name,
label: tool.name,
@ -1657,18 +1684,20 @@ const ChatUI: React.FC<ChatUIProps> = ({
key={serverId}
className="flex items-center justify-between rounded-sm border border-info/15 bg-info/10 p-2"
>
<p className="text-xs text-info">{serverName} requires your API key</p>
<p className="text-xs text-info">
{t("playground.chatUi.chatUi.byokRequired", { server: serverName })}
</p>
{server.has_user_credential ? (
<div className="flex items-center gap-2">
<span className="flex items-center gap-1 text-xs font-medium text-success">
<Key className="size-3" /> Connected
<Key className="size-3" /> {t("playground.chatUi.chatUi.connected")}
</span>
<button
type="button"
className="text-xs text-muted-foreground underline hover:text-info"
onClick={() => setByokModalServer(server)}
>
Reconnect
{t("playground.chatUi.chatUi.reconnect")}
</button>
</div>
) : (
@ -1678,7 +1707,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
className="rounded-lg bg-info px-3 py-1 text-xs font-medium text-info-foreground hover:bg-info/80"
onClick={() => setByokModalServer(server)}
>
Connect
{t("playground.chatUi.chatUi.connect")}
</Button>
)}
</div>
@ -1690,17 +1719,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
<div>
<div className="mb-2 flex items-center gap-1 text-sm font-medium text-foreground">
<Database className="mr-1 size-4" aria-hidden="true" /> Vector Store
<Database className="mr-1 size-4" aria-hidden="true" /> {t("playground.chatUi.chatUi.vectorStore")}
<Tooltip>
<TooltipTrigger aria-label="About vector stores">
<TooltipTrigger aria-label={t("playground.chatUi.chatUi.aboutVectorStores")}>
<Info className="size-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Select vector store(s) to use for this LLM API call. You can set up your vector store{" "}
<a href={uiHref("vector-stores")} className="text-info underline">
here
</a>
.
<Trans
i18nKey="playground.chatUi.chatUi.vectorStoreHint"
components={{ page: <a href={uiHref("vector-stores")} className="text-info underline" /> }}
/>
</TooltipContent>
</Tooltip>
</div>
@ -1714,17 +1742,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
<div>
<div className="mb-2 flex items-center gap-1 text-sm font-medium text-foreground">
<Shield className="mr-1 size-4" aria-hidden="true" /> Guardrails
<Shield className="mr-1 size-4" aria-hidden="true" /> {t("playground.chatUi.chatUi.guardrails")}
<Tooltip>
<TooltipTrigger aria-label="About guardrails">
<TooltipTrigger aria-label={t("playground.chatUi.chatUi.aboutGuardrails")}>
<Info className="size-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Select guardrail(s) to use for this LLM API call. You can set up your guardrails{" "}
<a href={uiHref("guardrails")} className="text-info underline">
here
</a>
.
<Trans
i18nKey="playground.chatUi.chatUi.guardrailsHint"
components={{ page: <a href={uiHref("guardrails")} className="text-info underline" /> }}
/>
</TooltipContent>
</Tooltip>
</div>
@ -1739,18 +1766,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
{canViewPolicies && (
<div>
<div className="mb-2 flex items-center gap-1 text-sm font-medium text-foreground">
<Shield className="mr-1 size-4" aria-hidden="true" /> Policies
<Shield className="mr-1 size-4" aria-hidden="true" /> {t("playground.chatUi.chatUi.policies")}
<Tooltip>
<TooltipTrigger aria-label="About policies">
<TooltipTrigger aria-label={t("playground.chatUi.chatUi.aboutPolicies")}>
<Info className="size-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Select policy/policies to apply to this LLM API call. Policies define which guardrails are
applied based on conditions. You can set up your policies{" "}
<a href={uiHref("policies")} className="text-info underline">
here
</a>
.
<Trans
i18nKey="playground.chatUi.chatUi.policiesHint"
components={{ page: <a href={uiHref("policies")} className="text-info underline" /> }}
/>
</TooltipContent>
</Tooltip>
</div>
@ -1790,16 +1815,18 @@ const ChatUI: React.FC<ChatUIProps> = ({
) : (
<>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border p-3 sm:p-4">
<h2 className="mb-0 text-xl font-semibold">{simplified ? "Chat" : "Test Key"}</h2>
<h2 className="mb-0 text-xl font-semibold">
{simplified ? t("playground.chatUi.chatUi.chatTitle") : t("playground.chatUi.chatUi.testKeyTitle")}
</h2>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={clearChatHistory}>
<Eraser className="size-3.5" />
Clear Chat
{t("playground.chatUi.chatUi.clearChat")}
</Button>
{!simplified && (
<Button type="button" variant="outline" size="sm" onClick={() => setIsGetCodeModalVisible(true)}>
<Code2 className="size-3.5" />
Get Code
{t("playground.chatUi.chatUi.getCode")}
</Button>
)}
</div>
@ -1812,7 +1839,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
{chatHistory.length === 0 && (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Bot className="mb-4 size-12" aria-hidden="true" />
<p className="text-sm">Start a conversation, generate an image, or handle audio</p>
<p className="text-sm">{t("playground.chatUi.chatUi.emptyState")}</p>
</div>
)}
@ -1840,7 +1867,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
<div className="mr-1 flex h-6 w-6 items-center justify-center rounded-full bg-muted">
<Bot className="size-3 text-muted-foreground" aria-hidden="true" />
</div>
<strong className="text-sm capitalize">Assistant</strong>
<strong className="text-sm capitalize">{t("playground.chatUi.chatUi.assistant")}</strong>
</div>
<MCPEventsDisplay events={mcpEvents} />
</div>
@ -1849,7 +1876,10 @@ const ChatUI: React.FC<ChatUIProps> = ({
{isLoading && (
<div className="my-4 flex items-center justify-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-label="Loading" />
<Loader2
className="size-6 animate-spin text-muted-foreground"
aria-label={t("playground.chatUi.chatUi.loadingLabel")}
/>
</div>
)}
{chatHistory.length > 0 && <div aria-hidden className="h-[calc(100%-3rem)]" />}
@ -1859,7 +1889,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
type="button"
variant="outline"
size="icon"
aria-label="Jump to bottom"
aria-label={t("playground.chatUi.chatUi.jumpToBottom")}
className="absolute bottom-3 right-3 rounded-full shadow-sm"
onClick={scrollToLastMessage}
>
@ -1881,9 +1911,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
}}
>
<ImageIcon className="mb-2 size-6 text-muted-foreground" aria-hidden="true" />
<p className="text-sm">Click or drag images to upload</p>
<p className="text-sm">{t("playground.chatUi.chatUi.imageDropzone")}</p>
<p className="text-xs text-muted-foreground">
Support for PNG, JPG, JPEG, GIF, WebP. Multiple images supported.
{t("playground.chatUi.chatUi.imageDropzoneHint")}
</p>
<input
type="file"
@ -1911,7 +1941,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
return "";
}
})()}
alt={`Upload preview ${index + 1}`}
alt={t("playground.chatUi.chatUi.uploadPreview", { number: index + 1 })}
className="max-h-32 max-w-32 rounded-md border border-border object-cover"
/>
<Button
@ -1919,7 +1949,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
variant="outline"
size="icon-xs"
className="absolute top-1 right-1 bg-card text-destructive hover:bg-destructive/10"
aria-label={`Remove ${file.name}`}
aria-label={t("playground.chatUi.chatUi.removeFile", { name: file.name })}
onClick={() => handleRemoveImage(index)}
>
<X className="size-3" />
@ -1928,7 +1958,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
))}
<label className="flex h-32 w-32 cursor-pointer flex-col items-center justify-center rounded-md border-2 border-dashed border-border hover:border-ring">
<ImageIcon className="size-6 text-muted-foreground" aria-hidden="true" />
<p className="mt-1 text-xs text-muted-foreground">Add more</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("playground.chatUi.chatUi.addMore")}
</p>
<input
type="file"
accept={IMAGE_EDIT_ACCEPT}
@ -1960,9 +1992,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
}}
>
<Volume2 className="mb-2 size-6 text-muted-foreground" aria-hidden="true" />
<p className="text-sm">Click or drag audio file to upload</p>
<p className="text-sm">{t("playground.chatUi.chatUi.audioDropzone")}</p>
<p className="text-xs text-muted-foreground">
Support for MP3, MP4, MPEG, MPGA, M4A, WAV, WEBM formats. Max file size: 25 MB.
{t("playground.chatUi.chatUi.audioDropzoneHint")}
</p>
<input
type="file"
@ -1977,7 +2009,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
<Volume2 className="size-5 text-muted-foreground" aria-hidden="true" />
<span className="text-sm font-medium">{uploadedAudio.name}</span>
<span className="text-xs text-muted-foreground">
({(uploadedAudio.size / 1024 / 1024).toFixed(2)} MB)
{t("playground.chatUi.chatUi.audioSize", {
size: (uploadedAudio.size / 1024 / 1024).toFixed(2),
})}
</span>
</div>
<Button
@ -1988,7 +2022,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
onClick={handleRemoveAudio}
>
<Trash2 className="size-3" />
Remove
{t("playground.chatUi.chatUi.remove")}
</Button>
</div>
)}
@ -2018,12 +2052,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin text-info" aria-hidden="true" />
<span className="text-sm font-medium text-info">Running Python code...</span>
<span className="text-sm font-medium text-info">
{t("playground.chatUi.chatUi.runningPython")}
</span>
</>
) : (
<>
<Code2 className="size-4 text-info" aria-hidden="true" />
<span className="text-sm font-medium text-info">Code Interpreter Active</span>
<span className="text-sm font-medium text-info">
{t("playground.chatUi.chatUi.codeInterpreterActive")}
</span>
</>
)}
</div>
@ -2032,15 +2070,15 @@ const ChatUI: React.FC<ChatUIProps> = ({
className="text-xs text-info hover:text-info/80"
onClick={() => codeInterpreter.setEnabled(false)}
>
Disable
{t("playground.chatUi.chatUi.disable")}
</button>
</div>
{!isLoading && (
<div className="flex flex-wrap gap-2">
{[
"Generate sample sales data CSV and create a chart",
"Create a PNG bar chart comparing AI gateway providers including LiteLLM",
"Generate a CSV of LLM pricing data and visualize it as a line chart",
t("playground.chatUi.chatUi.codeInterpreterPrompts.salesChart"),
t("playground.chatUi.chatUi.codeInterpreterPrompts.gatewayChart"),
t("playground.chatUi.chatUi.codeInterpreterPrompts.pricingChart"),
].map((prompt, idx) => (
<button
key={idx}
@ -2068,8 +2106,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
showSuggestions={chatHistory.length === 0 && !isLoading && endpointType !== EndpointType.MCP}
suggestions={
endpointType === EndpointType.A2A_AGENTS
? ["What can you help me with?", "Tell me about yourself", "What tasks can you perform?"]
: ["Write me a poem", "Explain quantum computing", "Draft a polite email requesting a meeting"]
? [
t("playground.chatUi.chatUi.suggestions.a2aHelp"),
t("playground.chatUi.chatUi.suggestions.a2aAbout"),
t("playground.chatUi.chatUi.suggestions.a2aTasks"),
]
: [
t("playground.chatUi.chatUi.suggestions.poem"),
t("playground.chatUi.chatUi.suggestions.quantum"),
t("playground.chatUi.chatUi.suggestions.email"),
]
}
onSuggestionSelect={setInputMessage}
tools={
@ -2096,7 +2142,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
onToggle={() => {
codeInterpreter.toggle();
if (!codeInterpreter.enabled) {
toast.success("Code Interpreter enabled!");
toast.success(t("playground.chatUi.chatUi.codeInterpreterEnabled"));
}
}}
/>
@ -2128,7 +2174,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
<MCPToolArgumentsForm ref={mcpToolArgsFormRef} tool={mcpTool} className="space-y-2" />
) : (
<div className="flex h-10 items-center justify-center text-sm text-muted-foreground">
Loading tool schema...
{t("playground.chatUi.chatUi.loadingToolSchema")}
</div>
);
})()
@ -2145,17 +2191,17 @@ const ChatUI: React.FC<ChatUIProps> = ({
<Dialog open={isGetCodeModalVisible} onOpenChange={setIsGetCodeModalVisible}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Generated Code</DialogTitle>
<DialogTitle>{t("playground.chatUi.chatUi.generatedCode")}</DialogTitle>
</DialogHeader>
<div className="my-2 flex items-end justify-between gap-3">
<div>
<p className="mb-1 text-sm font-medium text-foreground">SDK Type</p>
<p className="mb-1 text-sm font-medium text-foreground">{t("playground.chatUi.chatUi.sdkType")}</p>
<ShadcnSelect
items={SDK_ITEMS}
value={selectedSdk}
onValueChange={(value) => setSelectedSdk(value as "openai" | "azure")}
>
<SelectTrigger className="w-[150px]" size="sm" aria-label="SDK Type">
<SelectTrigger className="w-[150px]" size="sm" aria-label={t("playground.chatUi.chatUi.sdkType")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -2173,12 +2219,12 @@ const ChatUI: React.FC<ChatUIProps> = ({
size="sm"
onClick={() => {
void navigator.clipboard.writeText(generatedCode).then(
() => toast.success("Copied to clipboard!"),
() => toast.error("Unable to copy to clipboard"),
() => toast.success(t("playground.chatUi.chatUi.copied")),
() => toast.error(t("playground.chatUi.chatUi.copyFailed")),
);
}}
>
Copy to Clipboard
{t("playground.chatUi.chatUi.copyToClipboard")}
</Button>
</div>
<SyntaxHighlighter
@ -2212,43 +2258,51 @@ const ChatUI: React.FC<ChatUIProps> = ({
<Dialog open={isToolsetsInfoModalVisible} onOpenChange={setIsToolsetsInfoModalVisible}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>How Toolsets Work</DialogTitle>
<DialogTitle>{t("playground.chatUi.chatUi.toolsetsInfo.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-foreground">
<strong>Toolsets</strong> are named collections of specific tools from one or more MCP servers. Instead of
exposing all tools from a server, a toolset gives an agent exactly the tools it needs.
<Trans i18nKey="playground.chatUi.chatUi.toolsetsInfo.intro" components={{ term: <strong /> }} />
</p>
<div>
<h4 className="mb-2 font-semibold text-foreground">How to use a toolset:</h4>
<h4 className="mb-2 font-semibold text-foreground">
{t("playground.chatUi.chatUi.toolsetsInfo.howToUse")}
</h4>
<ol className="list-inside list-decimal space-y-2 text-foreground">
<li>
Select a <span className="font-semibold text-violet-600">Toolset</span> (purple badge) from the MCP
Servers dropdown.
<Trans
i18nKey="playground.chatUi.chatUi.toolsetsInfo.stepSelect"
components={{ badge: <span className="font-semibold text-violet-600" /> }}
/>
</li>
<li>The tool picker will show only the tools included in that toolset.</li>
<li>Select a tool and fill in its parameters, then send.</li>
<li>The tool call is routed to the correct underlying MCP server automatically.</li>
<li>{t("playground.chatUi.chatUi.toolsetsInfo.stepPicker")}</li>
<li>{t("playground.chatUi.chatUi.toolsetsInfo.stepFill")}</li>
<li>{t("playground.chatUi.chatUi.toolsetsInfo.stepRouted")}</li>
</ol>
</div>
<div className="rounded-sm border border-purple-200 bg-purple-50 p-3 dark:border-purple-800 dark:bg-purple-950">
<p className="text-sm text-purple-800 dark:text-purple-300">
<strong>Example:</strong> A &quot;GitHub Read-only&quot; toolset might include only{" "}
<code>list_repos</code> and <code>get_file</code> from a GitHub MCP server, preventing agents from
making writes.
<Trans
i18nKey="playground.chatUi.chatUi.toolsetsInfo.example"
components={{ label: <strong />, tool: <code /> }}
/>
</p>
</div>
<div>
<h4 className="mb-1 font-semibold text-foreground">Creating toolsets:</h4>
<h4 className="mb-1 font-semibold text-foreground">
{t("playground.chatUi.chatUi.toolsetsInfo.creating")}
</h4>
<p className="text-sm text-muted-foreground">
Admins can create and manage toolsets from the <strong>MCP</strong> page → <strong>Toolsets</strong>{" "}
tab. Toolsets can then be assigned to keys and teams to scope their tool access.
<Trans
i18nKey="playground.chatUi.chatUi.toolsetsInfo.creatingDescription"
components={{ place: <strong /> }}
/>
</p>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsToolsetsInfoModalVisible(false)}>
Close
{t("playground.chatUi.chatUi.close")}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Code, Download, FileImage, FileText, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
@ -35,6 +36,7 @@ function isImageFilename(filename: string | undefined): boolean {
}
const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({ code, annotations = [], accessToken }) => {
const { t } = useTranslation();
const syntaxTheme = useSyntaxTheme(coy);
const [imageUrls, setImageUrls] = useState<Record<string, string>>({});
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>({});
@ -144,7 +146,7 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({ code, ann
}
>
<Code className="size-4" />
Python Code Executed
{t("playground.chatUi.codeInterpreterOutput.pythonCodeExecuted")}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border p-2">
@ -171,13 +173,15 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({ code, ann
{loadingImages[annotation.file_id] ? (
<div className="flex items-center justify-center bg-muted p-8">
<Loader2 className="size-4 animate-spin text-muted-foreground" aria-hidden="true" />
<span className="ml-2 text-sm text-muted-foreground">Loading image...</span>
<span className="ml-2 text-sm text-muted-foreground">
{t("playground.chatUi.codeInterpreterOutput.loadingImage")}
</span>
</div>
) : imageUrls[annotation.file_id] ? (
<div>
<img
src={imageUrls[annotation.file_id]}
alt={annotation.filename || "Generated chart"}
alt={annotation.filename || t("playground.chatUi.codeInterpreterOutput.generatedChart")}
className="max-h-[400px] max-w-full"
/>
<div className="flex items-center justify-between border-t border-border bg-muted px-3 py-2">
@ -193,13 +197,15 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({ code, ann
onClick={() => void handleDownload(annotation)}
>
<Download className="size-3" />
Download
{t("playground.chatUi.codeInterpreterOutput.download")}
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-center bg-muted p-4">
<span className="text-sm text-muted-foreground">Image not available</span>
<span className="text-sm text-muted-foreground">
{t("playground.chatUi.codeInterpreterOutput.imageNotAvailable")}
</span>
</div>
)}
</div>

View file

@ -3,6 +3,7 @@ import { toast } from "@/lib/toast";
import { Code, Info, TriangleAlert } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useTranslation } from "react-i18next";
interface CodeInterpreterToolProps {
accessToken: string;
@ -34,12 +35,13 @@ const CodeInterpreterTool: React.FC<CodeInterpreterToolProps> = ({
selectedModel,
disabled = false,
}) => {
const { t } = useTranslation();
const isOpenAI = isOpenAIModel(selectedModel);
const isDisabled = disabled || !isOpenAI;
const handleToggle = (checked: boolean) => {
if (checked && !isOpenAI) {
toast.warning("Code Interpreter is only available for OpenAI models");
toast.warning(t("playground.chatUi.codeInterpreterTool.openAIOnlyToast"));
return;
}
onEnabledChange(checked);
@ -50,14 +52,12 @@ const CodeInterpreterTool: React.FC<CodeInterpreterToolProps> = ({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Code className="size-4 text-info" />
<span className="font-medium text-foreground">Code Interpreter</span>
<span className="font-medium text-foreground">{t("playground.chatUi.codeInterpreterTool.title")}</span>
<Tooltip>
<TooltipTrigger aria-label="About Code Interpreter">
<TooltipTrigger aria-label={t("playground.chatUi.codeInterpreterTool.about")}>
<Info className="size-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
Run Python code to generate files, charts, and analyze data. Container is created automatically.
</TooltipContent>
<TooltipContent>{t("playground.chatUi.codeInterpreterTool.description")}</TooltipContent>
</Tooltip>
</div>
<Switch
@ -65,7 +65,7 @@ const CodeInterpreterTool: React.FC<CodeInterpreterToolProps> = ({
onCheckedChange={handleToggle}
disabled={isDisabled}
size="sm"
aria-label="Enable Code Interpreter"
aria-label={t("playground.chatUi.codeInterpreterTool.enable")}
/>
</div>
@ -74,14 +74,14 @@ const CodeInterpreterTool: React.FC<CodeInterpreterToolProps> = ({
<div className="flex items-start gap-2">
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-warning" />
<div className="text-xs text-muted-foreground">
<span>Code Interpreter is currently only supported for OpenAI models. </span>
<span>{t("playground.chatUi.codeInterpreterTool.unsupported")}</span>
<a
href={GITHUB_FEATURE_REQUEST_URL}
target="_blank"
rel="noopener noreferrer"
className="text-info hover:text-info/80 underline"
>
Request support for other providers
{t("playground.chatUi.codeInterpreterTool.requestSupport")}
</a>
</div>
</div>

View file

@ -1,5 +1,6 @@
import { SearchSelect } from "@/components/shared/SearchSelect";
import React from "react";
import { useTranslation } from "react-i18next";
import { ENDPOINT_OPTIONS } from "./chatConstants";
interface EndpointSelectorProps {
@ -9,13 +10,14 @@ interface EndpointSelectorProps {
}
const EndpointSelector: React.FC<EndpointSelectorProps> = ({ endpointType, onEndpointChange, className }) => {
const { t } = useTranslation();
return (
<div className={className}>
<SearchSelect
value={endpointType}
onValueChange={onEndpointChange}
options={ENDPOINT_OPTIONS}
placeholder="Select an endpoint"
placeholder={t("playground.chatUi.endpointSelector.placeholder")}
/>
</div>
);

View file

@ -1,4 +1,5 @@
import { FileText, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
interface FilePreviewCardProps {
@ -8,6 +9,7 @@ interface FilePreviewCardProps {
}
function FilePreviewCard({ file, previewUrl, onRemove }: FilePreviewCardProps) {
const { t } = useTranslation();
const isPdf = file.name.toLowerCase().endsWith(".pdf");
return (
@ -21,20 +23,22 @@ function FilePreviewCard({ file, previewUrl, onRemove }: FilePreviewCardProps) {
) : (
<img
src={previewUrl || ""}
alt="Upload preview"
alt={t("playground.chatUi.filePreviewCard.uploadPreview")}
className="w-10 h-10 rounded-md border border-border object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">{file.name}</div>
<div className="text-xs text-muted-foreground">{isPdf ? "PDF" : "Image"}</div>
<div className="text-xs text-muted-foreground">
{isPdf ? "PDF" : t("playground.chatUi.filePreviewCard.image")}
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Remove ${file.name}`}
aria-label={t("playground.chatUi.filePreviewCard.remove", { name: file.name })}
className="text-muted-foreground hover:text-foreground hover:bg-accent"
onClick={onRemove}
>

View file

@ -3,6 +3,7 @@
import { useTerminology } from "@/contexts/TerminologyContext";
import { CircleX, Mic, MicOff, Send, Volume2 } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -43,6 +44,7 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
selectedGuardrails,
}) => {
const term = useTerminology();
const { t } = useTranslation();
const [messages, setMessages] = useState<RealtimeMessage[]>([]);
const [inputText, setInputText] = useState("");
const [isConnected, setIsConnected] = useState(false);
@ -104,7 +106,7 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
const connect = useCallback(async () => {
if (wsRef.current) return;
if (!selectedModel) {
addMessage("status", "Please select a model first");
addMessage("status", t("playground.chatUi.realtimePlayground.selectModelFirst"));
return;
}
setIsConnecting(true);
@ -124,7 +126,7 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
ws.onopen = () => {
setIsConnected(true);
setIsConnecting(false);
addMessage("status", "Connected to realtime API");
addMessage("status", t("playground.chatUi.realtimePlayground.connectedStatus"));
};
ws.onmessage = async (event) => {
@ -181,7 +183,12 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
return prev;
});
} else if (type === "error") {
addMessage("status", `Error: ${data.error?.message || JSON.stringify(data.error)}`);
addMessage(
"status",
t("playground.chatUi.realtimePlayground.errorStatus", {
message: String(data.error?.message || JSON.stringify(data.error)),
}),
);
}
} catch {
// ignore parse errors
@ -189,13 +196,13 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
};
ws.onerror = () => {
addMessage("status", "WebSocket error");
addMessage("status", t("playground.chatUi.realtimePlayground.websocketError"));
setIsConnected(false);
setIsConnecting(false);
};
ws.onclose = () => {
addMessage("status", "Disconnected");
addMessage("status", t("playground.chatUi.realtimePlayground.disconnectedStatus"));
setIsConnected(false);
setIsConnecting(false);
wsRef.current = null;
@ -203,7 +210,10 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
wsRef.current = ws;
} catch (err: any) {
addMessage("status", `Connection failed: ${err.message}`);
addMessage(
"status",
t("playground.chatUi.realtimePlayground.connectionFailed", { message: String(err.message) }),
);
setIsConnecting(false);
}
}, [
@ -215,6 +225,7 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
addMessage,
appendAssistantText,
playAudioChunk,
t,
]);
const disconnect = useCallback(() => {
@ -282,11 +293,11 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
source.connect(processor);
processor.connect(ctx.destination);
setIsRecording(true);
addMessage("status", "🎙️ Listening...");
addMessage("status", t("playground.chatUi.realtimePlayground.listeningStatus"));
} catch (err: any) {
addMessage("status", `Microphone error: ${err.message}`);
addMessage("status", t("playground.chatUi.realtimePlayground.microphoneError", { message: String(err.message) }));
}
}, [addMessage, selectedVoice]);
}, [addMessage, selectedVoice, t]);
const stopRecording = useCallback(() => {
processorRef.current?.disconnect();
@ -338,10 +349,14 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted">
<div className="flex items-center gap-3">
<Volume2 className="size-5 text-info" />
<span className="font-semibold text-foreground">Realtime Voice Chat</span>
<span className="font-semibold text-foreground">{t("playground.chatUi.realtimePlayground.title")}</span>
<span className={`inline-block w-2 h-2 rounded-full ${isConnected ? "bg-success" : "bg-border"}`} />
<span className="text-xs text-muted-foreground">
{isConnected ? "Connected" : isConnecting ? "Connecting..." : "Disconnected"}
{isConnected
? t("playground.chatUi.realtimePlayground.connected")
: isConnecting
? t("playground.chatUi.realtimePlayground.connecting")
: t("playground.chatUi.realtimePlayground.disconnected")}
</span>
</div>
<div className="flex items-center gap-2">
@ -350,25 +365,27 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
onValueChange={(voice) => setSelectedVoice(voice ?? selectedVoice)}
disabled={isConnected}
>
<SelectTrigger size="sm" className="w-[220px]" aria-label="Voice">
<SelectValue>{OPEN_AI_VOICE_SELECT_OPTIONS.find((v) => v.value === selectedVoice)?.label}</SelectValue>
<SelectTrigger size="sm" className="w-[220px]" aria-label={t("playground.chatUi.realtimePlayground.voice")}>
<SelectValue>
{OPEN_AI_VOICE_SELECT_OPTIONS.filter((v) => v.value === selectedVoice).map((v) => t(v.label))}
</SelectValue>
</SelectTrigger>
<SelectContent>
{OPEN_AI_VOICE_SELECT_OPTIONS.map((voice) => (
<SelectItem key={voice.value} value={voice.value}>
{voice.label}
{t(voice.label)}
</SelectItem>
))}
</SelectContent>
</Select>
{!isConnected ? (
<Button onClick={connect} disabled={isConnecting} size="sm">
Connect
{t("playground.chatUi.realtimePlayground.connect")}
</Button>
) : (
<Button variant="destructive" onClick={disconnect} size="sm">
<CircleX />
Disconnect
{t("playground.chatUi.realtimePlayground.disconnect")}
</Button>
)}
</div>
@ -379,10 +396,12 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
{messages.length === 0 && !isConnected && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
<Volume2 className="size-12" />
<span className="text-lg text-muted-foreground">Realtime Voice Playground</span>
<span className="text-lg text-muted-foreground">
{t("playground.chatUi.realtimePlayground.emptyTitle")}
</span>
<p className="text-sm text-muted-foreground text-center max-w-md">
Click <b>Connect</b> to start a realtime session. You can speak using your microphone or type messages.{" "}
{term("The AI will respond with voice and text.")}
<Trans i18nKey="playground.chatUi.realtimePlayground.emptyIntro" components={{ action: <b /> }} />{" "}
{term(t("playground.chatUi.realtimePlayground.emptyResponds"))}
</p>
</div>
)}
@ -401,7 +420,11 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
: "bg-muted text-foreground rounded-bl-md"
}`}
>
<div className="text-xs font-medium mb-0.5 opacity-70">{msg.role === "user" ? "You" : term("AI")}</div>
<div className="text-xs font-medium mb-0.5 opacity-70">
{msg.role === "user"
? t("playground.chatUi.realtimePlayground.you")
: term(t("playground.chatUi.realtimePlayground.ai"))}
</div>
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
</div>
)}
@ -418,13 +441,17 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
size="icon-lg"
variant={isRecording ? "destructive" : "outline"}
onClick={isRecording ? stopRecording : startRecording}
title={isRecording ? "Stop recording" : "Start recording"}
title={
isRecording
? t("playground.chatUi.realtimePlayground.stopRecording")
: t("playground.chatUi.realtimePlayground.startRecording")
}
className={`rounded-full ${isRecording ? "animate-pulse" : ""}`}
>
{isRecording ? <MicOff /> : <Mic />}
</Button>
<Input
placeholder="Type a message or use the mic..."
placeholder={t("playground.chatUi.realtimePlayground.inputPlaceholder")}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => {
@ -432,14 +459,19 @@ const RealtimePlayground: React.FC<RealtimePlaygroundProps> = ({
}}
className="h-10 flex-1"
/>
<Button size="icon-lg" onClick={sendTextMessage} disabled={!inputText.trim()} aria-label="Send">
<Button
size="icon-lg"
onClick={sendTextMessage}
disabled={!inputText.trim()}
aria-label={t("playground.chatUi.realtimePlayground.send")}
>
<Send />
</Button>
</div>
{isRecording && (
<div className="mt-2 flex items-center gap-2 text-destructive text-xs">
<span className="inline-block w-2 h-2 rounded-full bg-destructive animate-pulse" />
Listening — speak into your microphone. Server VAD will detect when you stop.
{t("playground.chatUi.realtimePlayground.listeningHint")}
</div>
)}
</div>

View file

@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { FileText } from "lucide-react";
import { MessageType } from "@/components/chat_ui/types";
import { shouldShowAttachedImage } from "./ResponsesImageUtils";
@ -8,6 +9,7 @@ interface ResponsesImageRendererProps {
}
const ResponsesImageRenderer: React.FC<ResponsesImageRendererProps> = ({ message }) => {
const { t } = useTranslation();
if (!shouldShowAttachedImage(message)) {
return null;
}
@ -18,12 +20,15 @@ const ResponsesImageRenderer: React.FC<ResponsesImageRendererProps> = ({ message
<div className="mb-2">
{isPdf ? (
<div className="flex h-32 w-64 items-center justify-center rounded-md border border-border bg-destructive/10">
<FileText className="size-12 text-destructive" aria-label="PDF attachment" />
<FileText
className="size-12 text-destructive"
aria-label={t("playground.chatUi.responsesImageRenderer.pdfAttachment")}
/>
</div>
) : (
<img
src={message.imagePreviewUrl}
alt="User uploaded image"
alt={t("playground.chatUi.responsesImageRenderer.uploadedImage")}
className="max-h-[200px] max-w-64 rounded-md border border-border shadow-xs"
/>
)}

View file

@ -1,5 +1,6 @@
import React, { useId, useRef } from "react";
import { Paperclip } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "@/lib/toast";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -18,6 +19,7 @@ const ResponsesImageUpload: React.FC<ResponsesImageUploadProps> = ({
onImageUpload,
disabled = false,
}) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const inputId = useId();
@ -31,7 +33,7 @@ const ResponsesImageUpload: React.FC<ResponsesImageUploadProps> = ({
if (!file) {
return;
}
const result = validateChatAttachment(file);
const result = validateChatAttachment(file, t);
if (!result.ok) {
toast.error(result.error);
return;
@ -59,7 +61,7 @@ const ResponsesImageUpload: React.FC<ResponsesImageUploadProps> = ({
variant="ghost"
size="icon-sm"
disabled={disabled}
aria-label="Attach image or PDF"
aria-label={t("playground.chatUi.responsesImageUpload.attach")}
className="text-muted-foreground hover:text-foreground"
onClick={() => inputRef.current?.click()}
/>
@ -67,7 +69,7 @@ const ResponsesImageUpload: React.FC<ResponsesImageUploadProps> = ({
>
<Paperclip className="size-4" />
</TooltipTrigger>
<TooltipContent>Attach image or PDF</TooltipContent>
<TooltipContent>{t("playground.chatUi.responsesImageUpload.attach")}</TooltipContent>
</Tooltip>
</>
);

View file

@ -1,6 +1,7 @@
import React, { useState } from "react";
import { VectorStoreSearchResponse } from "@/components/chat_ui/types";
import { ChevronDown, ChevronRight, Database, FileText } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
@ -9,6 +10,7 @@ interface SearchResultsDisplayProps {
}
export function SearchResultsDisplay({ searchResults }: SearchResultsDisplayProps) {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(true);
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
@ -40,7 +42,9 @@ export function SearchResultsDisplay({ searchResults }: SearchResultsDisplayProp
}
>
<Database className="size-4" />
{isExpanded ? "Hide sources" : `Show sources (${totalResults})`}
{isExpanded
? t("playground.chatUi.searchResultsDisplay.hideSources")
: t("playground.chatUi.searchResultsDisplay.showSources", { total: totalResults })}
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</CollapsibleTrigger>
@ -50,11 +54,13 @@ export function SearchResultsDisplay({ searchResults }: SearchResultsDisplayProp
{searchResults.map((resultPage, pageIndex) => (
<div key={pageIndex}>
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-2">
<span className="font-medium">Query:</span>
<span className="italic">&quot;{resultPage.search_query}&quot;</span>
<span className="font-medium">{t("playground.chatUi.searchResultsDisplay.query")}</span>
<span className="italic">
{t("playground.chatUi.searchResultsDisplay.quotedQuery", { query: resultPage.search_query })}
</span>
<span className="text-muted-foreground">•</span>
<span className="text-muted-foreground">
{resultPage.data.length} result{resultPage.data.length !== 1 ? "s" : ""}
{t("playground.chatUi.searchResultsDisplay.resultCount", { count: resultPage.data.length })}
</span>
</div>
@ -76,7 +82,11 @@ export function SearchResultsDisplay({ searchResults }: SearchResultsDisplayProp
/>
<FileText className="size-3 shrink-0 text-muted-foreground" />
<span className="text-xs font-medium text-foreground truncate">
{result.filename || result.file_id || `Result ${resultIndex + 1}`}
{result.filename ||
result.file_id ||
t("playground.chatUi.searchResultsDisplay.resultFallback", {
number: resultIndex + 1,
})}
</span>
<span className="text-xs px-2 py-0.5 rounded-sm bg-info/15 text-info font-mono shrink-0">
{result.score.toFixed(3)}
@ -97,7 +107,9 @@ export function SearchResultsDisplay({ searchResults }: SearchResultsDisplayProp
{result.attributes && Object.keys(result.attributes).length > 0 && (
<div className="mt-2 pt-2 border-t border-border">
<div className="text-xs text-muted-foreground mb-1 font-medium">Metadata:</div>
<div className="text-xs text-muted-foreground mb-1 font-medium">
{t("playground.chatUi.searchResultsDisplay.metadata")}
</div>
<div className="space-y-1">
{Object.entries(result.attributes).map(([key, value]) => (
<div key={key} className="text-xs flex gap-2">

View file

@ -1,5 +1,6 @@
import React from "react";
import { Copy, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import { toast } from "@/lib/toast";
import { Button } from "@/components/ui/button";
@ -19,6 +20,7 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
useApiSessionManagement,
onToggleSessionManagement,
}) => {
const { t } = useTranslation();
if (endpointType !== EndpointType.RESPONSES) {
return null;
}
@ -27,19 +29,23 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
if (responsesSessionId) {
try {
await navigator.clipboard.writeText(responsesSessionId);
toast.success("Response ID copied to clipboard!");
toast.success(t("playground.chatUi.sessionManagement.copied"));
} catch {
toast.error("Unable to copy response ID");
toast.error(t("playground.chatUi.sessionManagement.copyFailed"));
}
}
};
const getSessionDisplay = () => {
if (!responsesSessionId) {
return useApiSessionManagement ? "API Session: Ready" : "UI Session: Ready";
return useApiSessionManagement
? t("playground.chatUi.sessionManagement.apiReady")
: t("playground.chatUi.sessionManagement.uiReady");
}
const sessionPrefix = useApiSessionManagement ? "Response ID" : "UI Session";
const sessionPrefix = useApiSessionManagement
? t("playground.chatUi.sessionManagement.responseIdPrefix")
: t("playground.chatUi.sessionManagement.uiSessionPrefix");
const truncatedId = responsesSessionId.slice(0, 10);
return `${sessionPrefix}: ${truncatedId}...`;
};
@ -47,13 +53,13 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
const getSessionDescription = () => {
if (!responsesSessionId) {
return useApiSessionManagement
? "LiteLLM will manage session using previous_response_id"
: "UI will manage session using chat history";
? t("playground.chatUi.sessionManagement.apiPending")
: t("playground.chatUi.sessionManagement.uiPending");
}
return useApiSessionManagement
? "LiteLLM API session active - context maintained server-side"
: "UI session active - context maintained client-side";
? t("playground.chatUi.sessionManagement.apiActive")
: t("playground.chatUi.sessionManagement.uiActive");
};
return (
@ -61,26 +67,23 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
{/* Session Management Toggle */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">Session Management</span>
<span className="text-sm font-medium text-foreground">{t("playground.chatUi.sessionManagement.title")}</span>
<Tooltip>
<TooltipTrigger aria-label="About session management">
<TooltipTrigger aria-label={t("playground.chatUi.sessionManagement.about")}>
<Info className="size-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
Choose between LiteLLM API session management (using previous_response_id) or UI-based session management
(using chat history)
</TooltipContent>
<TooltipContent>{t("playground.chatUi.sessionManagement.description")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span aria-hidden="true">UI</span>
<span aria-hidden="true">{t("playground.chatUi.sessionManagement.uiLabel")}</span>
<Switch
checked={useApiSessionManagement}
onCheckedChange={onToggleSessionManagement}
aria-label="Use API session management"
aria-label={t("playground.chatUi.sessionManagement.toggle")}
size="sm"
/>
<span aria-hidden="true">API</span>
<span aria-hidden="true">{t("playground.chatUi.sessionManagement.apiLabel")}</span>
</div>
</div>
@ -106,7 +109,7 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
variant="ghost"
size="icon-xs"
onClick={handleCopySessionId}
aria-label="Copy response ID"
aria-label={t("playground.chatUi.sessionManagement.copyResponseId")}
className="ml-2 hover:bg-success/15"
/>
}
@ -115,7 +118,7 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
</TooltipTrigger>
<TooltipContent className="max-w-lg">
<div className="text-xs">
<div className="mb-1">Copy response ID to continue session:</div>
<div className="mb-1">{t("playground.chatUi.sessionManagement.copyToContinue")}</div>
<div className="bg-gray-800 text-gray-100 p-2 rounded-sm font-mono text-xs whitespace-pre-wrap">
{`curl -X POST "your-proxy-url/v1/responses" \\
-H "Authorization: Bearer your-api-key" \\

View file

@ -1,3 +1,4 @@
import type { ParseKeys } from "i18next";
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
export const OPEN_AI_VOICES = {
@ -15,17 +16,17 @@ export const OPEN_AI_VOICES = {
export type OpenAIVoice = (typeof OPEN_AI_VOICES)[keyof typeof OPEN_AI_VOICES];
export const OPEN_AI_VOICE_LABELS = {
ALLOY: "Alloy - Professional and confident",
ASH: "Ash - Casual and relaxed",
BALAD: "Ballad - Smooth and melodic",
CORAL: "Coral - Warm and engaging",
ECHO: "Echo - Friendly and conversational",
FABLE: "Fable - Wise and measured",
NOVA: "Nova - Friendly and conversational",
ONYX: "Onyx - Deep and authoritative",
SAGE: "Sage - Wise and measured",
SHIMMER: "Shimmer - Bright and cheerful",
export const OPEN_AI_VOICE_LABELS: Record<keyof typeof OPEN_AI_VOICES, ParseKeys> = {
ALLOY: "playground.chatUi.voices.alloy",
ASH: "playground.chatUi.voices.ash",
BALAD: "playground.chatUi.voices.ballad",
CORAL: "playground.chatUi.voices.coral",
ECHO: "playground.chatUi.voices.echo",
FABLE: "playground.chatUi.voices.fable",
NOVA: "playground.chatUi.voices.nova",
ONYX: "playground.chatUi.voices.onyx",
SAGE: "playground.chatUi.voices.sage",
SHIMMER: "playground.chatUi.voices.shimmer",
};
export const OPEN_AI_VOICE_SELECT_OPTIONS = Object.entries(OPEN_AI_VOICES).map(([key, voice]) => ({

View file

@ -1,3 +1,6 @@
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
export type UploadValidationResult = { ok: true } | { ok: false; error: string };
export const CHAT_ATTACHMENT_ACCEPT = "image/png,image/jpeg,image/jpg,image/gif,image/webp,application/pdf,.pdf";
@ -50,48 +53,52 @@ function isAudioFile(file: File): boolean {
return AUDIO_EXTENSIONS.has(getExtension(file.name));
}
function validateSize(file: File, maxBytes: number): UploadValidationResult {
function validateSize(file: File, maxBytes: number, t: TFunction): UploadValidationResult {
if (file.size <= maxBytes) {
return { ok: true };
}
return {
ok: false,
error: `"${file.name}" is too large. Maximum size is ${formatMb(maxBytes)}.`,
error: t("playground.chatUi.uploadValidation.tooLarge", { name: file.name, size: formatMb(maxBytes) }),
};
}
export function validateChatAttachment(file: File): UploadValidationResult {
export function validateChatAttachment(file: File, t: TFunction = i18n.t.bind(i18n)): UploadValidationResult {
if (!isImageFile(file) && !isPdfFile(file)) {
return {
ok: false,
error: `"${file.name}" is not a supported attachment. Use PNG, JPEG, GIF, WebP, or PDF.`,
error: t("playground.chatUi.uploadValidation.unsupportedAttachment", { name: file.name }),
};
}
return validateSize(file, MAX_CHAT_ATTACHMENT_BYTES);
return validateSize(file, MAX_CHAT_ATTACHMENT_BYTES, t);
}
export function validateImageEditFile(file: File, currentCount: number): UploadValidationResult {
export function validateImageEditFile(
file: File,
currentCount: number,
t: TFunction = i18n.t.bind(i18n),
): UploadValidationResult {
if (currentCount >= MAX_IMAGE_EDIT_COUNT) {
return {
ok: false,
error: `You can upload at most ${MAX_IMAGE_EDIT_COUNT} images.`,
error: t("playground.chatUi.uploadValidation.tooManyImages", { max: MAX_IMAGE_EDIT_COUNT }),
};
}
if (!isImageFile(file)) {
return {
ok: false,
error: `"${file.name}" is not a supported image. Use PNG, JPEG, GIF, or WebP.`,
error: t("playground.chatUi.uploadValidation.unsupportedImage", { name: file.name }),
};
}
return validateSize(file, MAX_IMAGE_EDIT_BYTES);
return validateSize(file, MAX_IMAGE_EDIT_BYTES, t);
}
export function validateAudioFile(file: File): UploadValidationResult {
export function validateAudioFile(file: File, t: TFunction = i18n.t.bind(i18n)): UploadValidationResult {
if (!isAudioFile(file)) {
return {
ok: false,
error: `"${file.name}" is not a supported audio file. Use MP3, MP4, MPEG, MPGA, M4A, WAV, or WEBM.`,
error: t("playground.chatUi.uploadValidation.unsupportedAudio", { name: file.name }),
};
}
return validateSize(file, MAX_AUDIO_BYTES);
return validateSize(file, MAX_AUDIO_BYTES, t);
}

View file

@ -1,5 +1,6 @@
"use client";
import type { ParseKeys } from "i18next";
import { toast } from "@/lib/toast";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { Eraser, FileText, Plus, Trash2 } from "lucide-react";
@ -9,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { v4 as uuidv4 } from "uuid";
import ChatImageUpload from "../chat_ui/ChatImageUpload";
import { createChatDisplayMessage, createChatMultimodalMessage } from "../chat_ui/ChatImageUtils";
@ -50,14 +52,19 @@ interface CompareUIProps {
accessToken: string | null;
disabledPersonalKeyCreation: boolean;
}
const GENERIC_FOLLOW_UPS = [
"Can you summarize the key points?",
"What assumptions did you make?",
"What are the next steps?",
const GENERIC_FOLLOW_UPS: ParseKeys[] = [
"playground.compare.followUps.summarize",
"playground.compare.followUps.assumptions",
"playground.compare.followUps.nextSteps",
];
const SUGGESTED_PROMPTS: ParseKeys[] = [
"playground.compare.suggestions.poem",
"playground.compare.suggestions.quantum",
"playground.compare.suggestions.email",
];
const SUGGESTED_PROMPTS = ["Write me a poem", "Explain quantum computing", "Draft a polite email requesting a meeting"];
const DEFAULT_ENDPOINT = EndpointId.CHAT_COMPLETIONS;
export default function CompareUI({ accessToken, disabledPersonalKeyCreation }: CompareUIProps) {
const { t } = useTranslation();
const [comparisons, setComparisons] = useState<ComparisonInstance[]>([
{
id: "1",
@ -482,7 +489,7 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
return;
}
if (!effectiveApiKey) {
toast.fromError("Please provide a Virtual Key or select Current UI Session");
toast.fromError(t("playground.compare.toasts.missingKey"));
return;
}
const targetComparisons = comparisons;
@ -692,20 +699,26 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<div className="border-b px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Virtual Key Source</span>
<span className="text-sm font-medium text-muted-foreground">
{t("playground.compare.header.keySource")}
</span>
<Select
value={apiKeySource}
onValueChange={(value) => setApiKeySource(value as "session" | "custom")}
disabled={disabledPersonalKeyCreation}
>
<SelectTrigger className="w-48" aria-label="Virtual Key Source">
<SelectValue>{apiKeySource === "custom" ? "Virtual Key" : "Current UI Session"}</SelectValue>
<SelectTrigger className="w-48" aria-label={t("playground.compare.header.keySource")}>
<SelectValue>
{apiKeySource === "custom"
? t("playground.compare.header.virtualKey")
: t("playground.compare.header.currentSession")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="session" disabled={!canUseSessionKey}>
Current UI Session
{t("playground.compare.header.currentSession")}
</SelectItem>
<SelectItem value="custom">Virtual Key</SelectItem>
<SelectItem value="custom">{t("playground.compare.header.virtualKey")}</SelectItem>
</SelectContent>
</Select>
{apiKeySource === "custom" && (
@ -713,15 +726,17 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
type="password"
value={customApiKey}
onChange={(event) => setCustomApiKey(event.target.value)}
placeholder="Enter Virtual Key"
placeholder={t("playground.compare.header.keyPlaceholder")}
className="w-56"
/>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Endpoint</span>
<span className="text-sm font-medium text-muted-foreground">
{t("playground.compare.header.endpoint")}
</span>
<Select value={selectedEndpoint} onValueChange={(value) => setSelectedEndpoint(value as EndpointIdType)}>
<SelectTrigger className="w-56" aria-label="Endpoint">
<SelectTrigger className="w-56" aria-label={t("playground.compare.header.endpoint")}>
<SelectValue>{endpointConfig.label}</SelectValue>
</SelectTrigger>
<SelectContent>
@ -736,17 +751,19 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<div className="flex items-center gap-3">
<Button variant="outline" onClick={clearAllChats} disabled={!hasMessages}>
<Eraser />
Clear All Chats
{t("playground.compare.header.clearAll")}
</Button>
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>
<Button variant="outline" onClick={addComparison} disabled={comparisons.length >= maxComparisons}>
<Plus />
Add Comparison
{t("playground.compare.header.addComparison")}
</Button>
</TooltipTrigger>
<TooltipContent>
{comparisons.length >= maxComparisons ? "Compare up to 3 models at a time" : "Add another comparison"}
{comparisons.length >= maxComparisons
? t("playground.compare.header.maxReached")
: t("playground.compare.header.addAnother")}
</TooltipContent>
</Tooltip>
</div>
@ -778,17 +795,17 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<div className="border border-border shadow-lg rounded-xl bg-card p-4">
<div className="flex items-center justify-between gap-4 mb-3 min-h-8">
{hasAttachment ? (
<span className="text-sm text-muted-foreground">Attachment ready to send</span>
<span className="text-sm text-muted-foreground">{t("playground.compare.attachment.ready")}</span>
) : showSuggestedPrompts ? (
<div className="flex items-center gap-2 overflow-x-auto">
{SUGGESTED_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => handleFollowUpSelect(prompt)}
onClick={() => handleFollowUpSelect(t(prompt))}
className="shrink-0 rounded-full border border-border px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent cursor-pointer"
>
{prompt}
{t(prompt)}
</button>
))}
</div>
@ -798,10 +815,10 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
<button
key={question}
type="button"
onClick={() => handleFollowUpSelect(question)}
onClick={() => handleFollowUpSelect(t(question))}
className="shrink-0 rounded-full border border-border px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent cursor-pointer"
>
{question}
{t(question)}
</button>
))}
</div>
@ -825,19 +842,21 @@ export default function CompareUI({ accessToken, disabledPersonalKeyCreation }:
) : (
<img
src={uploadedFilePreviewUrl || ""}
alt="Upload preview"
alt={t("playground.compare.attachment.preview")}
className="w-10 h-10 rounded-md border border-border object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">{uploadedFile.name}</div>
<div className="text-xs text-muted-foreground">{isUploadedFilePdf ? "PDF" : "Image"}</div>
<div className="text-xs text-muted-foreground">
{isUploadedFilePdf ? "PDF" : t("playground.compare.attachment.image")}
</div>
</div>
<button
className="flex items-center justify-center w-6 h-6 text-muted-foreground hover:text-foreground hover:bg-accent rounded-full transition-colors"
onClick={handleRemoveFile}
aria-label="Remove attachment"
aria-label={t("playground.compare.attachment.remove")}
>
<Trash2 className="size-3" />
</button>

View file

@ -1,5 +1,6 @@
import { Settings, X } from "lucide-react";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { ComparisonInstance } from "../CompareUI";
import { MessageDisplay } from "./MessageDisplay";
import { UnifiedSelector } from "./UnifiedSelector";
@ -35,6 +36,7 @@ export function ComparisonPanel({
endpointConfig,
apiKey,
}: ComparisonPanelProps) {
const { t } = useTranslation();
const isA2AMode = isAgentEndpoint(endpointConfig.id);
const currentSelection = getComparisonSelection(comparison, endpointConfig.id);
const [popoverVisible, setPopoverVisible] = useState(false);
@ -112,10 +114,10 @@ export function ComparisonPanel({
id={syncId}
checked={comparison.applyAcrossModels}
onCheckedChange={handleSyncChange}
aria-label="Sync Settings Across Models"
aria-label={t("playground.compare.panel.syncSettings")}
/>
<label htmlFor={syncId} className="cursor-pointer text-xs font-medium">
Sync Settings Across Models
{t("playground.compare.panel.syncSettings")}
</label>
</div>
@ -123,10 +125,14 @@ export function ComparisonPanel({
{/* General Settings */}
<div>
<h4 className="text-xs font-semibold text-foreground mb-1.5 uppercase tracking-wide">General Settings</h4>
<h4 className="text-xs font-semibold text-foreground mb-1.5 uppercase tracking-wide">
{t("playground.compare.panel.generalSettings")}
</h4>
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-muted-foreground block mb-0.5">Tags</label>
<label className="text-xs font-medium text-muted-foreground block mb-0.5">
{t("playground.compare.panel.tags")}
</label>
<TagSelector
value={comparison.tags}
onChange={(value) => handleSettingChange("tags", value)}
@ -134,7 +140,9 @@ export function ComparisonPanel({
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground block mb-0.5">Vector Stores</label>
<label className="text-xs font-medium text-muted-foreground block mb-0.5">
{t("playground.compare.panel.vectorStores")}
</label>
<VectorStoreSelector
value={comparison.vectorStores}
onChange={(value) => handleSettingChange("vectorStores", value)}
@ -142,7 +150,9 @@ export function ComparisonPanel({
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground block mb-0.5">Guardrails</label>
<label className="text-xs font-medium text-muted-foreground block mb-0.5">
{t("playground.compare.panel.guardrails")}
</label>
<GuardrailSelector
value={comparison.guardrails}
onChange={(value) => handleSettingChange("guardrails", value)}
@ -153,23 +163,27 @@ export function ComparisonPanel({
</div>
{/* Advanced Settings */}
<div>
<h4 className="text-xs font-semibold text-foreground mb-1.5 uppercase tracking-wide">Advanced Settings</h4>
<h4 className="text-xs font-semibold text-foreground mb-1.5 uppercase tracking-wide">
{t("playground.compare.panel.advancedSettings")}
</h4>
<div className="space-y-2">
<div className="flex items-center gap-2 pb-1">
<Checkbox
id={advancedParamsId}
checked={comparison.useAdvancedParams}
onCheckedChange={handleAdvancedParamsChange}
aria-label="Use Advanced Parameters"
aria-label={t("playground.compare.panel.useAdvancedParams")}
/>
<label htmlFor={advancedParamsId} className="cursor-pointer text-sm font-medium">
Use Advanced Parameters
{t("playground.compare.panel.useAdvancedParams")}
</label>
</div>
<div className="space-y-2 transition-opacity duration-200" style={{ opacity: disabledOpacity }}>
<div>
<div className="flex items-center justify-between mb-1">
<label className={`text-xs font-medium ${disabledTextColor}`}>Temperature</label>
<label className={`text-xs font-medium ${disabledTextColor}`}>
{t("playground.compare.panel.temperature")}
</label>
<span className={`text-xs ${disabledTextColor}`}>{comparison.temperature.toFixed(2)}</span>
</div>
<Slider
@ -187,7 +201,9 @@ export function ComparisonPanel({
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className={`text-xs font-medium ${disabledTextColor}`}>Max Tokens</label>
<label className={`text-xs font-medium ${disabledTextColor}`}>
{t("playground.compare.panel.maxTokens")}
</label>
<span className={`text-xs ${disabledTextColor}`}>{comparison.maxTokens}</span>
</div>
<Slider

View file

@ -1,6 +1,7 @@
import { Bot, Loader2, UserRound } from "lucide-react";
import React from "react";
import ReactMarkdown from "react-markdown";
import { useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
@ -18,6 +19,7 @@ interface MessageDisplayProps {
export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
const syntaxTheme = useSyntaxTheme(coy);
const { t } = useTranslation();
if (messages.length === 0) {
return <div className="h-full" />;
}
@ -104,7 +106,7 @@ export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
<div className="flex flex-col gap-6 min-w-0 w-full p-4">
{conversationBlocks.map((block, blockIndex) => {
const assistantMessage = block.assistant;
const displayModel = assistantMessage?.model || "Assistant";
const displayModel = assistantMessage?.model || t("playground.compare.messages.assistant");
return (
<div key={blockIndex} className="space-y-4">
{block.user && (
@ -113,7 +115,7 @@ export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-info/15 text-info">
<UserRound size={16} />
</div>
<div className="text-sm font-semibold text-foreground">You</div>
<div className="text-sm font-semibold text-foreground">{t("playground.compare.messages.you")}</div>
</div>
{renderMessageBody(block.user)}
</div>
@ -155,10 +157,10 @@ export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
) : isLoading && blockIndex === conversationBlocks.length - 1 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
<span>Generating response...</span>
<span>{t("playground.compare.messages.generating")}</span>
</div>
) : (
<div className="text-sm text-muted-foreground">Waiting for a response...</div>
<div className="text-sm text-muted-foreground">{t("playground.compare.messages.waiting")}</div>
)}
</div>
);
@ -166,7 +168,7 @@ export function MessageDisplay({ messages, isLoading }: MessageDisplayProps) {
{isLoading && conversationBlocks.length === 0 && (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 size={18} className="animate-spin" />
<span>Generating response...</span>
<span>{t("playground.compare.messages.generating")}</span>
</div>
)}
</div>

View file

@ -1,5 +1,6 @@
import React from "react";
import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
@ -13,6 +14,7 @@ interface MessageInputProps {
}
export function MessageInput({ value, onChange, onSend, disabled, hasAttachment, uploadComponent }: MessageInputProps) {
const { t } = useTranslation();
const canSend = !disabled && (value.trim().length > 0 || Boolean(hasAttachment));
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@ -32,7 +34,7 @@ export function MessageInput({ value, onChange, onSend, disabled, hasAttachment,
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Shift+Enter for new line)"
placeholder={t("playground.compare.input.placeholder")}
disabled={disabled}
rows={1}
className="max-h-20 min-h-0 flex-1 resize-none overflow-y-auto border-0 bg-transparent px-0 py-1 text-sm leading-5 shadow-none focus-visible:ring-0"
@ -43,7 +45,7 @@ export function MessageInput({ value, onChange, onSend, disabled, hasAttachment,
size="icon-sm"
variant="outline"
className="rounded-full"
aria-label="Send message"
aria-label={t("playground.compare.input.send")}
>
<ArrowUp />
</Button>

View file

@ -12,6 +12,7 @@ import {
ComboboxList,
} from "@/components/ui/combobox";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { useTranslation } from "react-i18next";
import { SelectorOption, EndpointConfig } from "../endpoint_config";
interface UnifiedSelectorProps {
@ -26,6 +27,7 @@ const matchesQuery = (option: SelectorOption, query: string): boolean =>
option.label.toLowerCase().includes(query.trim().toLowerCase());
export function UnifiedSelector({ value, options, loading, config, onChange }: UnifiedSelectorProps) {
const { t } = useTranslation();
const selected = options.find((option) => option.value === value) ?? null;
const noun = config.selectorLabel.toLowerCase();
@ -39,7 +41,7 @@ export function UnifiedSelector({ value, options, loading, config, onChange }: U
filter={matchesQuery}
>
<ComboboxInput
placeholder={loading ? `Loading ${noun}s...` : config.selectorPlaceholder}
placeholder={loading ? t("playground.compare.selector.loading", { noun }) : config.selectorPlaceholder}
className="w-48 md:w-64 lg:w-72"
/>
<ComboboxContent>
@ -49,7 +51,7 @@ export function UnifiedSelector({ value, options, loading, config, onChange }: U
<UiLoadingSpinner className="size-4" />
</span>
) : (
`No ${noun}s available`
t("playground.compare.selector.empty", { noun })
)}
</ComboboxEmpty>
<ComboboxList>

View file

@ -47,6 +47,7 @@ import {
X,
} from "lucide-react";
import Papa from "papaparse";
import { Trans, useTranslation } from "react-i18next";
import React, { useCallback, useEffect, useRef, useState } from "react";
const CATEGORY_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
@ -132,6 +133,7 @@ export default function ComplianceUI({
fixedModel,
proxySettings,
}: ComplianceUIProps) {
const { t } = useTranslation();
const canViewPolicies = useCan("viewPolicies");
const term = useTerminology();
const frameworks = getFrameworks(term);
@ -286,7 +288,7 @@ export default function ComplianceUI({
framework: "Custom",
category: "Custom Prompts",
categoryIcon: "pencil",
categoryDescription: "Custom prompts added this session.",
categoryDescription: t("playground.compliance.library.customCategoryDescription"),
prompt: newPromptText.trim(),
expectedResult: newPromptExpected,
};
@ -341,11 +343,11 @@ export default function ComplianceUI({
setCsvError(null);
if (!file.name.endsWith(".csv") && file.type !== "text/csv") {
setCsvError("Please upload a .csv file.");
setCsvError(t("playground.compliance.csv.errors.notCsv"));
return;
}
if (file.size > 5 * 1024 * 1024) {
setCsvError("File too large (max 5 MB).");
setCsvError(t("playground.compliance.csv.errors.tooLarge"));
return;
}
@ -354,16 +356,14 @@ export default function ComplianceUI({
skipEmptyLines: true,
complete: (results) => {
if (!results.data || results.data.length === 0) {
setCsvError("CSV file is empty.");
setCsvError(t("playground.compliance.csv.errors.empty"));
return;
}
const headers = results.meta.fields ?? [];
const missing = EXPECTED_CSV_COLUMNS.filter((col) => !headers.includes(col));
if (missing.length > 0) {
setCsvError(
`Missing required columns: ${missing.join(", ")}. Expected: prompt, expected_result. Optional: framework, category.`,
);
setCsvError(t("playground.compliance.csv.errors.missingColumns", { columns: missing.join(", ") }));
return;
}
@ -376,11 +376,13 @@ export default function ComplianceUI({
const expected = row.expected_result?.trim().toLowerCase();
if (!prompt) {
errors.push(`Row ${rowNum}: missing prompt text`);
errors.push(t("playground.compliance.csv.errors.missingPrompt", { row: rowNum }));
return;
}
if (expected !== "fail" && expected !== "pass") {
errors.push(`Row ${rowNum}: expected_result must be "fail" or "pass", got "${row.expected_result ?? ""}"`);
errors.push(
t("playground.compliance.csv.errors.invalidExpected", { row: rowNum, value: row.expected_result ?? "" }),
);
return;
}
@ -392,7 +394,7 @@ export default function ComplianceUI({
framework,
category,
categoryIcon: "file-text",
categoryDescription: `Prompts uploaded from CSV — ${category}.`,
categoryDescription: t("playground.compliance.csv.categoryDescription", { category }),
prompt,
expectedResult: expected as "fail" | "pass",
});
@ -400,13 +402,16 @@ export default function ComplianceUI({
if (errors.length > 0) {
setCsvError(
errors.slice(0, 5).join("\n") + (errors.length > 5 ? `\n...and ${errors.length - 5} more errors` : ""),
errors.slice(0, 5).join("\n") +
(errors.length > 5
? `\n${t("playground.compliance.csv.errors.moreErrors", { remaining: errors.length - 5 })}`
: ""),
);
return;
}
if (newPrompts.length === 0) {
setCsvError("No valid prompts found in CSV.");
setCsvError(t("playground.compliance.csv.errors.noValidPrompts"));
return;
}
@ -429,7 +434,7 @@ export default function ComplianceUI({
setCsvError(null);
},
error: () => {
setCsvError("Failed to parse CSV file.");
setCsvError(t("playground.compliance.csv.errors.parseFailed"));
},
});
@ -481,7 +486,7 @@ export default function ComplianceUI({
const sysMsg: QuickTestMessage = {
id: `msg-${Date.now()}-sys`,
type: "system",
text: "Allowed — model response received.",
text: t("playground.compliance.quickTest.allowedModelResponse"),
result: "allowed",
returnedText: fullResponse,
timestamp: new Date(),
@ -503,8 +508,10 @@ export default function ComplianceUI({
const returnedText = Array.isArray(inputs?.texts) && inputs.texts.length > 0 ? inputs.texts[0] : undefined;
const displayText =
result === "blocked"
? `Blocked — ${triggeredBy ?? "content filter"}`
: "Allowed — no policy or guardrail violations detected.";
? t("playground.compliance.quickTest.blockedBy", {
reason: triggeredBy ?? t("playground.compliance.quickTest.contentFilter"),
})
: t("playground.compliance.quickTest.allowedClean");
const sysMsg: QuickTestMessage = {
id: `msg-${Date.now()}-sys`,
type: "system",
@ -521,7 +528,7 @@ export default function ComplianceUI({
const sysMsg: QuickTestMessage = {
id: `msg-${Date.now()}-sys`,
type: "system",
text: `Error: ${errorMessage}`,
text: t("playground.compliance.quickTest.error", { message: errorMessage }),
result: "blocked",
triggeredBy: errorMessage,
timestamp: new Date(),
@ -530,7 +537,16 @@ export default function ComplianceUI({
} finally {
setIsQuickTesting(false);
}
}, [accessToken, quickTestInput, selectedPolicies, selectedGuardrails, backendMode, fixedModel, requestProxyBaseUrl]);
}, [
accessToken,
quickTestInput,
selectedPolicies,
selectedGuardrails,
backendMode,
fixedModel,
requestProxyBaseUrl,
t,
]);
const handleQuickTestKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
@ -698,11 +714,11 @@ export default function ComplianceUI({
const testButtonLabel = (() => {
const parts: string[] = [];
if (selectedPolicies.length > 0)
parts.push(`${selectedPolicies.length} ${selectedPolicies.length === 1 ? "policy" : "policies"}`);
parts.push(t("playground.compliance.config.policyCount", { count: selectedPolicies.length }));
if (selectedGuardrails.length > 0)
parts.push(`${selectedGuardrails.length} ${selectedGuardrails.length === 1 ? "guardrail" : "guardrails"}`);
if (parts.length === 0) return "Test";
return `Test ${parts.join(" & ")}`;
parts.push(t("playground.compliance.config.guardrailCount", { count: selectedGuardrails.length }));
if (parts.length === 0) return t("playground.compliance.quickTest.test");
return t("playground.compliance.quickTest.testTargets", { targets: parts.join(" & ") });
})();
return (
@ -711,11 +727,11 @@ export default function ComplianceUI({
{/* Top config */}
<div className="shrink-0 border-b border-border px-6 py-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-foreground">Test Configuration</h3>
<h3 className="text-sm font-semibold text-foreground">{t("playground.compliance.config.title")}</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{canViewPolicies
? "Select policies, guardrails, or both to test against."
: "Select guardrails to test against."}
? t("playground.compliance.config.descriptionWithPolicies")
: t("playground.compliance.config.descriptionGuardrailsOnly")}
</p>
</div>
@ -724,7 +740,7 @@ export default function ComplianceUI({
<>
<div className="flex-1 min-w-[200px]">
<label className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide mb-1.5 block">
Policies
{t("playground.compliance.config.policies")}
</label>
{accessToken && (
<PolicySelector
@ -738,7 +754,9 @@ export default function ComplianceUI({
<div className="flex flex-col items-center pt-6 shrink-0">
<div className="w-px h-4 bg-border" />
<span className="text-[10px] font-medium text-muted-foreground my-1">or</span>
<span className="text-[10px] font-medium text-muted-foreground my-1">
{t("playground.compliance.config.or")}
</span>
<div className="w-px h-4 bg-border" />
</div>
</>
@ -746,7 +764,7 @@ export default function ComplianceUI({
<div className="flex-1 min-w-[200px]">
<label className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide mb-1.5 block">
Guardrails
{t("playground.compliance.config.guardrails")}
</label>
<div className="relative">
<button
@ -755,7 +773,9 @@ export default function ComplianceUI({
className="w-full flex items-center justify-between border border-border rounded-lg px-3 py-2 text-sm text-left hover:border-ring transition-colors"
>
<span className={selectedGuardrails.length > 0 ? "text-foreground" : "text-muted-foreground"}>
{selectedGuardrails.length > 0 ? `${selectedGuardrails.length} selected` : "None selected"}
{selectedGuardrails.length > 0
? t("playground.compliance.config.selectedCount", { selected: selectedGuardrails.length })
: t("playground.compliance.config.noneSelected")}
</span>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</button>
@ -763,7 +783,7 @@ export default function ComplianceUI({
<div className="absolute z-floating top-full left-0 right-0 mt-1 bg-card border border-border rounded-lg shadow-lg py-1 max-h-52 overflow-y-auto">
{guardrailOptions.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
No guardrails available. Create guardrails in the Guardrails page.
{t("playground.compliance.config.noGuardrails")}
</div>
) : (
guardrailOptions.map((g) => (
@ -802,7 +822,7 @@ export default function ComplianceUI({
type="button"
onClick={() => toggleGuardrail(id)}
className="hover:text-indigo-900 dark:hover:text-indigo-100"
aria-label="Remove"
aria-label={t("playground.compliance.config.removeGuardrail")}
>
<X className="w-2.5 h-2.5" />
</button>
@ -820,7 +840,7 @@ export default function ComplianceUI({
onClick={() => batchAbortControllerRef.current?.abort()}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap bg-destructive text-destructive-foreground hover:bg-destructive/80"
>
<Square className="w-3.5 h-3.5" /> Stop
<Square className="w-3.5 h-3.5" /> {t("playground.compliance.config.stop")}
</button>
) : (
<button
@ -829,12 +849,13 @@ export default function ComplianceUI({
disabled={selectedPromptIds.size === 0 || disabledPersonalKeyCreation}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${selectedPromptIds.size === 0 || disabledPersonalKeyCreation ? "bg-muted text-muted-foreground cursor-not-allowed" : "bg-info text-info-foreground hover:bg-info/80"}`}
>
<Play className="w-3.5 h-3.5" /> Simulate ({selectedPromptIds.size})
<Play className="w-3.5 h-3.5" />{" "}
{t("playground.compliance.config.simulate", { selected: selectedPromptIds.size })}
</button>
)}
{isRunning && (
<span className="text-[11px] text-muted-foreground flex items-center gap-1">
<Loader2 className="w-3 h-3 animate-spin" /> Running...
<Loader2 className="w-3 h-3 animate-spin" /> {t("playground.compliance.config.running")}
</span>
)}
<button
@ -847,7 +868,7 @@ export default function ComplianceUI({
}}
className="flex items-center justify-center gap-1.5 px-4 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:bg-accent transition-colors"
>
<RotateCcw className="w-3 h-3" /> Reset
<RotateCcw className="w-3 h-3" /> {t("playground.compliance.config.reset")}
</button>
</div>
</div>
@ -860,7 +881,7 @@ export default function ComplianceUI({
<div className="flex-1 overflow-y-auto min-h-0">
<div className="px-4 pt-4 pb-2">
<div className="flex items-center justify-between mb-2.5">
<h3 className="text-sm font-semibold text-foreground">Test Prompts</h3>
<h3 className="text-sm font-semibold text-foreground">{t("playground.compliance.library.title")}</h3>
<span className="text-[11px] text-muted-foreground tabular-nums">
{selectedPromptIds.size}/{totalPromptCount}
</span>
@ -872,7 +893,7 @@ export default function ComplianceUI({
type="text"
value={searchPrompt}
onChange={(e) => setSearchPrompt(e.target.value)}
placeholder="Search prompts..."
placeholder={t("playground.compliance.library.searchPlaceholder")}
className="w-full border border-border rounded-lg pl-8 pr-3 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-blue-500/20 focus:border-info"
/>
</div>
@ -884,7 +905,7 @@ export default function ComplianceUI({
onClick={selectAll}
className="text-[11px] font-medium text-info hover:text-info/80"
>
Select All
{t("playground.compliance.library.selectAll")}
</button>
<span className="text-muted-foreground text-[10px]">·</span>
<button
@ -892,7 +913,7 @@ export default function ComplianceUI({
onClick={deselectAll}
className="text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
Clear
{t("playground.compliance.library.clear")}
</button>
</div>
<div className="flex items-center gap-1">
@ -904,7 +925,7 @@ export default function ComplianceUI({
}}
className={`flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-sm transition-colors ${showAddPrompt ? "bg-info/10 text-info" : "text-muted-foreground hover:bg-accent"}`}
>
<Plus className="w-3 h-3" /> Add
<Plus className="w-3 h-3" /> {t("playground.compliance.library.add")}
</button>
<button
type="button"
@ -914,7 +935,7 @@ export default function ComplianceUI({
}}
className={`flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-sm transition-colors ${showCsvUpload ? "bg-info/10 text-info" : "text-muted-foreground hover:bg-accent"}`}
>
<Upload className="w-3 h-3" /> CSV
<Upload className="w-3 h-3" /> {t("playground.compliance.library.csv")}
</button>
</div>
</div>
@ -925,7 +946,7 @@ export default function ComplianceUI({
<textarea
value={newPromptText}
onChange={(e) => setNewPromptText(e.target.value)}
placeholder="Enter your test prompt..."
placeholder={t("playground.compliance.addPrompt.placeholder")}
rows={2}
className="w-full border border-border rounded-sm px-2.5 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus:outline-hidden focus:ring-2 focus:ring-blue-500/20 focus:border-info resize-none bg-card"
/>
@ -936,14 +957,14 @@ export default function ComplianceUI({
onClick={() => setNewPromptExpected("fail")}
className={`text-[10px] font-semibold px-2 py-0.5 rounded-sm ${newPromptExpected === "fail" ? "bg-destructive/15 text-destructive" : "bg-muted text-muted-foreground"}`}
>
Should Fail
{t("playground.compliance.expected.shouldFail")}
</button>
<button
type="button"
onClick={() => setNewPromptExpected("pass")}
className={`text-[10px] font-semibold px-2 py-0.5 rounded-sm ${newPromptExpected === "pass" ? "bg-success/15 text-success" : "bg-muted text-muted-foreground"}`}
>
Should Pass
{t("playground.compliance.expected.shouldPass")}
</button>
</div>
<div className="flex items-center gap-1.5">
@ -955,7 +976,7 @@ export default function ComplianceUI({
}}
className="text-[11px] text-muted-foreground px-2 py-1"
>
Cancel
{t("playground.compliance.addPrompt.cancel")}
</button>
<button
type="button"
@ -963,7 +984,7 @@ export default function ComplianceUI({
disabled={!newPromptText.trim()}
className={`text-[11px] font-medium px-2.5 py-1 rounded-sm ${newPromptText.trim() ? "bg-info text-info-foreground" : "bg-muted text-muted-foreground"}`}
>
Add
{t("playground.compliance.addPrompt.add")}
</button>
</div>
</div>
@ -973,25 +994,31 @@ export default function ComplianceUI({
{showCsvUpload && (
<div className="mx-4 mb-2 border border-info/20 bg-info/5 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-[11px] font-semibold text-foreground">Upload CSV Dataset</span>
<span className="text-[11px] font-semibold text-foreground">
{t("playground.compliance.csv.title")}
</span>
<button
type="button"
onClick={downloadCsvTemplate}
className="flex items-center gap-1 text-[10px] font-medium text-info hover:text-info/80"
>
<Download className="w-3 h-3" /> Download Template
<Download className="w-3 h-3" /> {t("playground.compliance.csv.downloadTemplate")}
</button>
</div>
<div className="mb-2 p-2 bg-card rounded-sm border border-border">
<p className="text-[10px] text-muted-foreground leading-relaxed">
<span className="font-semibold text-muted-foreground">Required columns:</span>{" "}
<span className="font-semibold text-muted-foreground">
{t("playground.compliance.csv.requiredColumns")}
</span>{" "}
<code className="bg-muted px-1 rounded-sm text-[10px]">prompt</code>,{" "}
<code className="bg-muted px-1 rounded-sm text-[10px]">expected_result</code>{" "}
<span className="text-muted-foreground">(fail or pass)</span>
<span className="text-muted-foreground">{t("playground.compliance.csv.expectedHint")}</span>
</p>
<p className="text-[10px] text-muted-foreground leading-relaxed mt-0.5">
<span className="font-semibold text-muted-foreground">Optional columns:</span>{" "}
<span className="font-semibold text-muted-foreground">
{t("playground.compliance.csv.optionalColumns")}
</span>{" "}
<code className="bg-muted px-1 rounded-sm text-[10px]">framework</code>,{" "}
<code className="bg-muted px-1 rounded-sm text-[10px]">category</code>
</p>
@ -1012,7 +1039,7 @@ export default function ComplianceUI({
onClick={() => csvInputRef.current?.click()}
className="w-full flex items-center justify-center gap-1.5 py-2 border-2 border-dashed border-border rounded-lg text-xs text-muted-foreground hover:border-info hover:text-info transition-colors"
>
<Upload className="w-3.5 h-3.5" /> Choose CSV file
<Upload className="w-3.5 h-3.5" /> {t("playground.compliance.csv.choose")}
</button>
{csvError && (
@ -1030,7 +1057,7 @@ export default function ComplianceUI({
}}
className="text-[11px] text-muted-foreground px-2 py-1"
>
Cancel
{t("playground.compliance.csv.cancel")}
</button>
</div>
</div>
@ -1059,7 +1086,9 @@ export default function ComplianceUI({
<CategoryIcon iconKey={fw.icon} className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-foreground">{fw.name}</span>
<span className="text-[10px] text-muted-foreground ml-1.5">{fwPromptCount} prompts</span>
<span className="text-[10px] text-muted-foreground ml-1.5">
{t("playground.compliance.library.promptCount", { total: fwPromptCount })}
</span>
</div>
{fwSelectedCount > 0 && (
<span className="text-[10px] font-medium bg-info/15 text-info px-1.5 py-0.5 rounded-full">
@ -1074,7 +1103,9 @@ export default function ComplianceUI({
}}
className="text-[10px] font-medium text-info px-1.5 py-0.5 rounded-sm hover:bg-info/10 shrink-0"
>
{fwSelectedCount === fwPromptCount ? "Clear" : "All"}
{fwSelectedCount === fwPromptCount
? t("playground.compliance.library.clearFramework")
: t("playground.compliance.library.selectFramework")}
</button>
</button>
@ -1129,7 +1160,9 @@ export default function ComplianceUI({
onClick={() => toggleCategoryPrompts(category)}
className="text-[10px] font-medium text-info hover:text-info/80 shrink-0 whitespace-nowrap"
>
{allCatSelected ? "Clear" : "Select all"}
{allCatSelected
? t("playground.compliance.library.clearCategory")
: t("playground.compliance.library.selectCategory")}
</button>
</div>
{category.prompts.map((prompt) => (
@ -1148,7 +1181,9 @@ export default function ComplianceUI({
<span
className={`inline-block mt-0.5 text-[9px] font-semibold px-1 py-0.5 rounded-sm ${prompt.expectedResult === "fail" ? "bg-destructive/10 text-destructive" : "bg-success/10 text-success"}`}
>
{prompt.expectedResult === "fail" ? "Should Fail" : "Should Pass"}
{prompt.expectedResult === "fail"
? t("playground.compliance.expected.shouldFail")
: t("playground.compliance.expected.shouldPass")}
</span>
</div>
{isCustom && (
@ -1160,7 +1195,7 @@ export default function ComplianceUI({
deleteCustomPrompt(prompt.id);
}}
className="opacity-0 group-hover:opacity-100 p-0.5 text-muted-foreground hover:text-destructive transition-all shrink-0"
aria-label="Delete"
aria-label={t("playground.compliance.library.deletePrompt")}
>
<Trash2 className="w-3 h-3" />
</button>
@ -1190,7 +1225,7 @@ export default function ComplianceUI({
onClick={() => setRightTab("quick-test")}
className={`relative flex items-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors ${rightTab === "quick-test" ? "text-info" : "text-muted-foreground hover:text-foreground"}`}
>
<MessageSquare className="w-3.5 h-3.5" /> Quick Test
<MessageSquare className="w-3.5 h-3.5" /> {t("playground.compliance.tabs.quickTest")}
{rightTab === "quick-test" && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-info rounded-t" />
)}
@ -1200,7 +1235,7 @@ export default function ComplianceUI({
onClick={() => setRightTab("batch-results")}
className={`relative flex items-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors ${rightTab === "batch-results" ? "text-info" : "text-muted-foreground hover:text-foreground"}`}
>
<ListChecks className="w-3.5 h-3.5" /> Batch Results
<ListChecks className="w-3.5 h-3.5" /> {t("playground.compliance.tabs.batchResults")}
{testResults.length > 0 && (
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full">
{testResults.length}
@ -1218,7 +1253,9 @@ export default function ComplianceUI({
<div className="px-5 pt-4 pb-2 shrink-0">
{hasAnyConfig ? (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[11px] font-medium text-muted-foreground">Testing against:</span>
<span className="text-[11px] font-medium text-muted-foreground">
{t("playground.compliance.quickTest.testingAgainst")}
</span>
{selectedPolicies.map((id) => (
<span key={id} className="text-[11px] bg-info/10 text-info px-2 py-0.5 rounded-sm font-medium">
{policyValueToLabel.get(id) ?? id}
@ -1238,7 +1275,7 @@ export default function ComplianceUI({
</div>
) : (
<p className="text-[11px] text-muted-foreground">
No policies or guardrails selected — select above to test against specific rules.
{t("playground.compliance.quickTest.noTargets")}
</p>
)}
</div>
@ -1250,7 +1287,7 @@ export default function ComplianceUI({
<div className="w-10 h-10 bg-muted rounded-xl flex items-center justify-center mx-auto mb-3">
<MessageSquare className="w-5 h-5 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground">Type a prompt below to quickly test it.</p>
<p className="text-xs text-muted-foreground">{t("playground.compliance.quickTest.empty")}</p>
</div>
</div>
)}
@ -1269,14 +1306,18 @@ export default function ComplianceUI({
) : (
<CheckCircle2 className="w-3 h-3 inline" />
)}
{msg.result === "blocked" ? "Blocked" : "Allowed"}
{msg.result === "blocked"
? t("playground.compliance.quickTest.blocked")
: t("playground.compliance.quickTest.allowed")}
<span className="font-normal mx-0.5">—</span>
</span>
)}
{msg.text}
{msg.type === "system" && msg.returnedText != null && (
<span className="block mt-1.5 pt-1.5 border-t border-gray-200/60">
<span className="text-muted-foreground">Returned: </span>
<span className="text-muted-foreground">
{t("playground.compliance.quickTest.returned")}
</span>
<span className="font-medium text-foreground break-all">{msg.returnedText}</span>
</span>
)}
@ -1301,13 +1342,16 @@ export default function ComplianceUI({
value={quickTestInput}
onChange={(e) => setQuickTestInput(e.target.value)}
onKeyDown={handleQuickTestKeyDown}
placeholder="Enter text to test..."
placeholder={t("playground.compliance.quickTest.placeholder")}
rows={3}
className="w-full px-3 pt-3 pb-1 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden resize-none"
/>
<div className="flex items-center justify-between px-3 pb-2">
<span className="text-[10px] text-muted-foreground">
Press <Kbd>Enter</Kbd> to submit · <Kbd>Shift+Enter</Kbd> for new line
<Trans
i18nKey="playground.compliance.quickTest.shortcuts"
components={{ submit: <Kbd />, newline: <Kbd /> }}
/>
</span>
<span className="text-[10px] text-muted-foreground tabular-nums">{quickTestInput.length}</span>
</div>
@ -1329,7 +1373,9 @@ export default function ComplianceUI({
<div className="flex-1 flex flex-col overflow-hidden bg-card min-h-0">
<div className="px-5 py-3 border-b border-border shrink-0">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-foreground">Results</h2>
<h2 className="text-sm font-semibold text-foreground">
{t("playground.compliance.results.title")}
</h2>
{testResults.length > 0 && (
<div className="flex items-center gap-2">
<button
@ -1338,7 +1384,7 @@ export default function ComplianceUI({
disabled={filteredResults.length === 0}
className="flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-accent px-2 py-1 rounded-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent"
>
<Download className="w-3 h-3" /> Export CSV
<Download className="w-3 h-3" /> {t("playground.compliance.results.export")}
</button>
<div className="flex items-center gap-2.5 text-[11px]">
<span className="flex items-center gap-1 text-success">
@ -1347,17 +1393,17 @@ export default function ComplianceUI({
</span>
<span
className="flex items-center gap-1 text-warning"
title="Allowed content that should have been blocked"
title={t("playground.compliance.results.falseNegativeHint")}
>
<AlertTriangle className="w-3 h-3" />
{falseNegativeCount} FN
{t("playground.compliance.results.falseNegativeShort", { value: falseNegativeCount })}
</span>
<span
className="flex items-center gap-1 text-destructive"
title="Blocked content that should have been allowed"
title={t("playground.compliance.results.falsePositiveHint")}
>
<X className="w-3 h-3" />
{falsePositiveCount} FP
{t("playground.compliance.results.falsePositiveShort", { value: falsePositiveCount })}
</span>
{pendingCount > 0 && (
<span className="flex items-center gap-1 text-muted-foreground">
@ -1403,7 +1449,7 @@ export default function ComplianceUI({
<FlaskConical className="w-6 h-6 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground max-w-[240px]">
Select prompts and click Simulate to run batch compliance tests.
{t("playground.compliance.results.empty")}
</p>
</div>
</div>
@ -1414,22 +1460,28 @@ export default function ComplianceUI({
<div className="flex items-center gap-3 text-sm flex-1">
<span>
<span className="font-semibold text-foreground">{testResults.length}</span>{" "}
<span className="text-muted-foreground">total</span>
<span className="text-muted-foreground">{t("playground.compliance.results.total")}</span>
</span>
<div className="w-px h-4 bg-border" />
<span>
<span className="font-semibold text-success">{matchCount}</span>{" "}
<span className="text-muted-foreground">correct</span>
<span className="text-muted-foreground">
{t("playground.compliance.results.correct")}
</span>
</span>
<div className="w-px h-4 bg-border" />
<span title="Allowed content that should have been blocked">
<span title={t("playground.compliance.results.falseNegativeHint")}>
<span className="font-semibold text-warning">{falseNegativeCount}</span>{" "}
<span className="text-muted-foreground">false negative</span>
<span className="text-muted-foreground">
{t("playground.compliance.results.falseNegative")}
</span>
</span>
<div className="w-px h-4 bg-border" />
<span title="Blocked content that should have been allowed">
<span title={t("playground.compliance.results.falsePositiveHint")}>
<span className="font-semibold text-destructive">{falsePositiveCount}</span>{" "}
<span className="text-muted-foreground">false positive</span>
<span className="text-muted-foreground">
{t("playground.compliance.results.falsePositive")}
</span>
</span>
</div>
<div
@ -1441,7 +1493,9 @@ export default function ComplianceUI({
: "bg-destructive/10 border-destructive/20 text-destructive"
}`}
>
<span className="text-[10px] font-semibold uppercase tracking-wider opacity-90">Score</span>
<span className="text-[10px] font-semibold uppercase tracking-wider opacity-90">
{t("playground.compliance.results.score")}
</span>
<span>{Math.round((matchCount / completedResults.length) * 100)}%</span>
</div>
</div>
@ -1475,13 +1529,17 @@ export default function ComplianceUI({
<span
className={`text-[9px] font-semibold px-1 py-0.5 rounded-sm ${result.expectedResult === "fail" ? "bg-destructive/10 text-destructive" : "bg-success/10 text-success"}`}
>
{result.expectedResult === "fail" ? "Expect Block" : "Expect Allow"}
{result.expectedResult === "fail"
? t("playground.compliance.results.expectBlock")
: t("playground.compliance.results.expectAllow")}
</span>
{result.status === "complete" && (
<span
className={`text-[9px] font-bold px-1 py-0.5 rounded-sm ${result.isMatch ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive"}`}
>
{result.isMatch ? "✓ Match" : "✗ Gap"}
{result.isMatch
? t("playground.compliance.results.match")
: t("playground.compliance.results.gap")}
</span>
)}
</div>
@ -1498,7 +1556,11 @@ export default function ComplianceUI({
});
}}
className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground"
aria-label={isExpanded ? "Collapse" : "Expand"}
aria-label={
isExpanded
? t("playground.compliance.results.collapse")
: t("playground.compliance.results.expand")
}
>
{isExpanded ? (
<ChevronDown className="w-3.5 h-3.5" />
@ -1512,25 +1574,31 @@ export default function ComplianceUI({
<div className="mt-2 pt-2 border-t border-border text-[11px] space-y-1">
{result.triggeredBy && (
<div>
<span className="text-muted-foreground">Triggered by:</span>{" "}
<span className="text-muted-foreground">
{t("playground.compliance.results.triggeredBy")}
</span>{" "}
<span className="font-medium text-foreground bg-muted px-1.5 py-0.5 rounded-sm">
{result.triggeredBy}
</span>
</div>
)}
<div>
<span className="text-muted-foreground">Verdict:</span>{" "}
<span className="text-muted-foreground">
{t("playground.compliance.results.verdict")}
</span>{" "}
<span className={result.isMatch ? "text-success" : "text-destructive"}>
{result.isMatch
? "Correctly handled"
? t("playground.compliance.results.correctlyHandled")
: result.expectedResult === "fail"
? "Gap — should have been blocked"
: "False positive — incorrectly blocked"}
? t("playground.compliance.results.shouldHaveBlocked")
: t("playground.compliance.results.incorrectlyBlocked")}
</span>
</div>
{result.returnedText != null && result.returnedText !== "" && (
<div className="mt-1.5">
<span className="text-muted-foreground block mb-0.5">LLM response:</span>
<span className="text-muted-foreground block mb-0.5">
{t("playground.compliance.results.llmResponse")}
</span>
<div className="text-foreground bg-muted rounded-sm px-2 py-1.5 border border-border max-h-32 overflow-y-auto whitespace-pre-wrap wrap-break-word">
{result.returnedText}
</div>

View file

@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import CopyButton from "@/components/shared/CopyButton";
import { buildCurlCommand, type CurlCommandInput } from "./lib/curlCommand";
@ -6,26 +7,27 @@ interface CurlPreviewProps extends CurlCommandInput {
}
export default function CurlPreview({ errorCount = 0, ...input }: CurlPreviewProps) {
const { t } = useTranslation();
const command = buildCurlCommand(input);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center justify-between gap-2 border-b px-3 py-2 text-sm">
<span className="font-medium">cURL request</span>
<span className="font-medium">{t("playground.systemOne.curlPreview.title")}</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{errorCount > 0 ? (
<span role="alert" className="text-destructive">
Fix {errorCount} {errorCount === 1 ? "issue" : "issues"} on the Form tab before copying
{t("playground.systemOne.curlPreview.fixIssues", { count: errorCount })}
</span>
) : (
<>
<span>Replace $LITELLM_API_KEY with a virtual key</span>
<CopyButton value={command} label="Copy cURL" variant="action" />
<span>{t("playground.systemOne.curlPreview.replaceKey")}</span>
<CopyButton value={command} label={t("playground.systemOne.curlPreview.copy")} variant="action" />
</>
)}
</div>
</div>
<pre
aria-label="cURL request"
aria-label={t("playground.systemOne.curlPreview.title")}
className="m-0 min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-anywhere px-3 py-3 font-mono text-xs leading-5"
>
{command}

View file

@ -1,6 +1,7 @@
import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import { useId, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { IssueList } from "./ValidationFeedback";
import type { PayloadValidation } from "./lib/validatePayload";
@ -34,9 +35,12 @@ export function QuestionCardHeader({
removeLabel: string;
onRemove: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">Question {index + 1}</span>
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
{t("playground.systemOne.decisionFormControls.question", { number: index + 1 })}
</span>
<RemoveButton label={removeLabel} onClick={onRemove} />
</div>
);

View file

@ -5,7 +5,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { ParseKeys } from "i18next";
import { useId, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AddButton, FormFrame, QuestionCardHeader, RemoveButton } from "./DecisionFormControls";
import { freeName, removeAt, replaceAt } from "./lib/formPayload";
import {
@ -19,7 +21,11 @@ import type { OpenAIDecisionsRequest } from "./lib/openAIDecisions";
import type { PayloadValidation } from "./lib/validatePayload";
const QUESTION_TYPES: readonly DecisionsQuestionType[] = ["predicate", "choice", "score"];
const TYPE_LABELS = { predicate: "Predicate", choice: "Choice", score: "Score" };
const TYPE_LABELS: Record<DecisionsQuestionType, ParseKeys> = {
predicate: "playground.systemOne.openAIDecisionsForm.types.predicate",
choice: "playground.systemOne.openAIDecisionsForm.types.choice",
score: "playground.systemOne.openAIDecisionsForm.types.score",
};
interface QuestionEditorProps<Q extends DecisionsFormQuestion = DecisionsFormQuestion> {
question: Q;
@ -27,12 +33,13 @@ interface QuestionEditorProps<Q extends DecisionsFormQuestion = DecisionsFormQue
}
function Choices({ question, onChange }: QuestionEditorProps<Extract<DecisionsFormQuestion, { type: "choice" }>>) {
const { t } = useTranslation();
const choices = question.choices ?? [];
const write = (next: typeof choices) => onChange({ ...question, choices: next });
return (
<div className="grid gap-2">
<span className="text-sm font-medium">Choices</span>
<span className="text-sm font-medium">{t("playground.systemOne.openAIDecisionsForm.choices")}</span>
{choices.map((choice, index) => (
<div key={index} className="grid gap-2 rounded-md border p-2">
<div className="flex items-center gap-2">
@ -45,28 +52,34 @@ function Choices({ question, onChange }: QuestionEditorProps<Extract<DecisionsFo
}
}}
>
<SelectTrigger className="w-40" aria-label={`Choice ${index + 1} value type`}>
<SelectTrigger
className="w-40"
aria-label={t("playground.systemOne.openAIDecisionsForm.choiceValueType", { number: index + 1 })}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="true">true (boolean)</SelectItem>
<SelectItem value="false">false (boolean)</SelectItem>
<SelectItem value="text">{t("playground.systemOne.openAIDecisionsForm.valueTypes.text")}</SelectItem>
<SelectItem value="true">{t("playground.systemOne.openAIDecisionsForm.valueTypes.true")}</SelectItem>
<SelectItem value="false">{t("playground.systemOne.openAIDecisionsForm.valueTypes.false")}</SelectItem>
</SelectContent>
</Select>
{typeof choice.value === "string" && (
<Input
aria-label={`Choice ${index + 1} value`}
aria-label={t("playground.systemOne.openAIDecisionsForm.choiceValue", { number: index + 1 })}
value={choice.value}
onChange={(event) => write(replaceAt(choices, index, { ...choice, value: event.target.value }))}
/>
)}
<RemoveButton label={`Remove choice ${index + 1}`} onClick={() => write(removeAt(choices, index))} />
<RemoveButton
label={t("playground.systemOne.openAIDecisionsForm.removeChoice", { number: index + 1 })}
onClick={() => write(removeAt(choices, index))}
/>
</div>
<Input
aria-label={`Choice ${index + 1} description`}
aria-label={t("playground.systemOne.openAIDecisionsForm.choiceDescription", { number: index + 1 })}
value={choice.description ?? ""}
placeholder="Optional description"
placeholder={t("playground.systemOne.openAIDecisionsForm.optionalDescription")}
onChange={(event) => write(replaceAt(choices, index, { ...choice, description: event.target.value }))}
/>
</div>
@ -84,37 +97,43 @@ function Choices({ question, onChange }: QuestionEditorProps<Extract<DecisionsFo
])
}
>
Add choice
{t("playground.systemOne.openAIDecisionsForm.addChoice")}
</AddButton>
</div>
);
}
function Levels({ question, onChange }: QuestionEditorProps<Extract<DecisionsFormQuestion, { type: "score" }>>) {
const { t } = useTranslation();
const levels = question.levels ?? [];
const write = (next: typeof levels) => onChange({ ...question, levels: next });
return (
<div className="grid gap-2">
<span className="text-sm font-medium">Levels, lowest first</span>
<span className="text-sm font-medium">{t("playground.systemOne.openAIDecisionsForm.levels")}</span>
{levels.map((level, index) => (
<div key={index} className="flex items-center gap-2">
<Input
aria-label={`Level ${index + 1} label`}
aria-label={t("playground.systemOne.openAIDecisionsForm.levelLabel", { number: index + 1 })}
value={level.label}
placeholder="Label"
placeholder={t("playground.systemOne.openAIDecisionsForm.labelPlaceholder")}
onChange={(event) => write(replaceAt(levels, index, { ...level, label: event.target.value }))}
/>
<Input
aria-label={`Level ${index + 1} description`}
aria-label={t("playground.systemOne.openAIDecisionsForm.levelDescription", { number: index + 1 })}
value={level.description ?? ""}
placeholder="Optional description"
placeholder={t("playground.systemOne.openAIDecisionsForm.optionalDescription")}
onChange={(event) => write(replaceAt(levels, index, { ...level, description: event.target.value }))}
/>
<RemoveButton label={`Remove level ${index + 1}`} onClick={() => write(removeAt(levels, index))} />
<RemoveButton
label={t("playground.systemOne.openAIDecisionsForm.removeLevel", { number: index + 1 })}
onClick={() => write(removeAt(levels, index))}
/>
</div>
))}
<AddButton onClick={() => write([...levels, { label: String(levels.length) }])}>Add level</AddButton>
<AddButton onClick={() => write([...levels, { label: String(levels.length) }])}>
{t("playground.systemOne.openAIDecisionsForm.addLevel")}
</AddButton>
</div>
);
}
@ -134,23 +153,31 @@ const blankDecisionsQuestion = (existing: DecisionsFormQuestion[]): DecisionsFor
});
function QuestionCard({ question, index, onChange, onRemove }: QuestionCardProps) {
const { t } = useTranslation();
const instructionsId = useId();
return (
<fieldset aria-label={`Question ${index + 1}`} className="grid gap-3 rounded-lg border bg-muted/30 p-3">
<QuestionCardHeader index={index} onRemove={onRemove} removeLabel={`Remove question ${index + 1}`} />
<fieldset
aria-label={t("playground.systemOne.openAIDecisionsForm.question", { number: index + 1 })}
className="grid gap-3 rounded-lg border bg-muted/30 p-3"
>
<QuestionCardHeader
index={index}
onRemove={onRemove}
removeLabel={t("playground.systemOne.openAIDecisionsForm.removeQuestion", { number: index + 1 })}
/>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_10rem]">
<div className="grid gap-1.5">
<span className="text-sm font-medium">Name</span>
<span className="text-sm font-medium">{t("playground.systemOne.openAIDecisionsForm.name")}</span>
<Input
aria-label="Question name"
aria-label={t("playground.systemOne.openAIDecisionsForm.questionName")}
value={question.name ?? ""}
placeholder="Optional question name"
placeholder={t("playground.systemOne.openAIDecisionsForm.questionNamePlaceholder")}
onChange={(event) => onChange({ ...question, name: event.target.value })}
/>
</div>
<div className="grid gap-1.5">
<span className="text-sm font-medium">Answer type</span>
<span className="text-sm font-medium">{t("playground.systemOne.openAIDecisionsForm.answerType")}</span>
<Select
value={question.type}
onValueChange={(type) => {
@ -158,13 +185,13 @@ function QuestionCard({ question, index, onChange, onRemove }: QuestionCardProps
if (next) onChange(retypeDecisionQuestion(question, next));
}}
>
<SelectTrigger className="w-full" aria-label="Answer type">
<SelectValue>{TYPE_LABELS[question.type]}</SelectValue>
<SelectTrigger className="w-full" aria-label={t("playground.systemOne.openAIDecisionsForm.answerType")}>
<SelectValue>{t(TYPE_LABELS[question.type])}</SelectValue>
</SelectTrigger>
<SelectContent>
{QUESTION_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{TYPE_LABELS[type]}
{t(TYPE_LABELS[type])}
</SelectItem>
))}
</SelectContent>
@ -172,12 +199,12 @@ function QuestionCard({ question, index, onChange, onRemove }: QuestionCardProps
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor={instructionsId}>Instructions</Label>
<Label htmlFor={instructionsId}>{t("playground.systemOne.openAIDecisionsForm.instructions")}</Label>
<Textarea
id={instructionsId}
rows={2}
value={question.instructions ?? ""}
placeholder="What should the model decide?"
placeholder={t("playground.systemOne.openAIDecisionsForm.instructionsPlaceholder")}
onChange={(event) => onChange({ ...question, instructions: event.target.value })}
/>
</div>
@ -195,6 +222,7 @@ interface OpenAIDecisionsFormProps {
}
export default function OpenAIDecisionsForm({ value, onChange, validation, onReset }: OpenAIDecisionsFormProps) {
const { t } = useTranslation();
const inputId = useId();
const safetyId = useId();
const form = useMemo(() => readDecisionsForm(value), [value]);
@ -202,9 +230,9 @@ export default function OpenAIDecisionsForm({ value, onChange, validation, onRes
if (form === undefined) {
return (
<FormFrame validation={validation}>
<p className="text-sm text-muted-foreground">This request has a shape the form can&apos;t show.</p>
<p className="text-sm text-muted-foreground">{t("playground.systemOne.openAIDecisionsForm.unreadable")}</p>
<Button variant="outline" className="w-fit" onClick={onReset}>
Reset to example
{t("playground.systemOne.openAIDecisionsForm.resetToExample")}
</Button>
</FormFrame>
);
@ -219,16 +247,18 @@ export default function OpenAIDecisionsForm({ value, onChange, validation, onRes
<div className="grid gap-1.5">
{Array.isArray(form.input) ? (
<>
<p className="text-sm text-muted-foreground">Input contains messages. Form edits keep them unchanged.</p>
<p className="text-sm text-muted-foreground">
{t("playground.systemOne.openAIDecisionsForm.inputMessages")}
</p>
</>
) : (
<>
<Label htmlFor={inputId}>Input</Label>
<Label htmlFor={inputId}>{t("playground.systemOne.openAIDecisionsForm.input")}</Label>
<Textarea
id={inputId}
rows={4}
value={form.input ?? ""}
placeholder="Paste the text the model should decide about"
placeholder={t("playground.systemOne.openAIDecisionsForm.inputPlaceholder")}
onChange={(event) => write({ ...form, input: event.target.value })}
/>
</>
@ -244,14 +274,14 @@ export default function OpenAIDecisionsForm({ value, onChange, validation, onRes
/>
))}
<AddButton onClick={() => writeQuestions([...questions, blankDecisionsQuestion(questions)])}>
Add question
{t("playground.systemOne.openAIDecisionsForm.addQuestion")}
</AddButton>
<div className="grid gap-1.5">
<Label htmlFor={safetyId}>Safety identifier</Label>
<Label htmlFor={safetyId}>{t("playground.systemOne.openAIDecisionsForm.safetyIdentifier")}</Label>
<Input
id={safetyId}
value={form.safety_identifier ?? ""}
placeholder="Optional"
placeholder={t("playground.systemOne.openAIDecisionsForm.safetyIdentifierPlaceholder")}
onChange={(event) => write({ ...form, safety_identifier: event.target.value || undefined })}
/>
</div>

View file

@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronRight, Inbox, LoaderCircle } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { SystemOneAnswer, SystemOneResponse } from "./lib/schemas";
import type { OpenAIDecisionsResponse } from "./lib/openAIDecisions";
@ -28,6 +29,7 @@ function ProbabilityMeter({
probability: number;
selected?: boolean;
}) {
const { t } = useTranslation();
const percentage = Math.round(probability * 100);
return (
<div className="grid gap-1.5">
@ -37,9 +39,11 @@ function ProbabilityMeter({
</div>
<div
role="meter"
aria-label={`${label} probability`}
aria-label={t("playground.systemOne.responseView.probability", { label })}
aria-valuenow={percentage}
aria-valuetext={selected ? `${percentage}%, selected` : `${percentage}%`}
aria-valuetext={
selected ? t("playground.systemOne.responseView.percentSelected", { percentage }) : `${percentage}%`
}
aria-valuemin={0}
aria-valuemax={100}
className="h-2 overflow-hidden rounded-full bg-muted"
@ -54,12 +58,13 @@ function ProbabilityMeter({
}
function AnswerDetails({ answer }: { answer: SystemOneAnswer }) {
const { t } = useTranslation();
if (answer.type === "noul") {
const percentage = Math.round(answer.noul * 100);
return (
<div className="grid gap-3">
<p className="text-sm font-medium">{percentage}% yes</p>
<ProbabilityMeter label="Yes" probability={answer.noul} />
<p className="text-sm font-medium">{t("playground.systemOne.responseView.percentYes", { percentage })}</p>
<ProbabilityMeter label={t("playground.systemOne.responseView.yes")} probability={answer.noul} />
</div>
);
}
@ -69,10 +74,12 @@ function AnswerDetails({ answer }: { answer: SystemOneAnswer }) {
return (
<div className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">Selected choice</span>
<span className="text-xs text-muted-foreground">{t("playground.systemOne.responseView.selectedChoice")}</span>
<Badge>{answer.choice}</Badge>
{typeof answer.confidence === "number" && (
<Badge variant="outline">{Math.round(answer.confidence * 100)}% confidence</Badge>
<Badge variant="outline">
{t("playground.systemOne.responseView.confidence", { percentage: Math.round(answer.confidence * 100) })}
</Badge>
)}
</div>
<div className="grid gap-3">
@ -88,10 +95,12 @@ function AnswerDetails({ answer }: { answer: SystemOneAnswer }) {
return (
<div className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">Score</span>
<span className="text-xs text-muted-foreground">{t("playground.systemOne.responseView.score")}</span>
<Badge>{SCORE_FORMAT.format(answer.score)}</Badge>
{typeof answer.confidence === "number" && (
<Badge variant="outline">{Math.round(answer.confidence * 100)}% confidence</Badge>
<Badge variant="outline">
{t("playground.systemOne.responseView.confidence", { percentage: Math.round(answer.confidence * 100) })}
</Badge>
)}
</div>
<div className="grid gap-3">
@ -116,9 +125,10 @@ function AnswerDetails({ answer }: { answer: SystemOneAnswer }) {
}
function OpenAIAnswerDetails({ answer }: { answer: OpenAIDecisionsResponse["answers"][number] }) {
const { t } = useTranslation();
switch (answer.type) {
case "refusal":
return <p className="text-sm text-muted-foreground">Model refused to answer</p>;
return <p className="text-sm text-muted-foreground">{t("playground.systemOne.responseView.refused")}</p>;
case "predicate":
return <AnswerDetails answer={{ type: "noul", noul: answer.probability }} />;
case "choice":
@ -152,8 +162,9 @@ function OpenAIAnswerDetails({ answer }: { answer: OpenAIDecisionsResponse["answ
}
function RawResponse({ response }: { response: NonNullable<ResponseViewProps["response"]> }) {
const { t } = useTranslation();
return (
<section aria-label="Decisions response JSON">
<section aria-label={t("playground.systemOne.responseView.rawJson")}>
<pre className="whitespace-pre-wrap wrap-anywhere rounded-md bg-muted p-3 font-mono text-xs">
{JSON.stringify(response, null, 2)}
</pre>
@ -162,10 +173,11 @@ function RawResponse({ response }: { response: NonNullable<ResponseViewProps["re
}
function AnswerCards({ answers }: { answers: SystemOneResponse["answers"] | OpenAIDecisionsResponse["answers"] }) {
const { t } = useTranslation();
const cards = Array.isArray(answers)
? answers.map((answer, index) => ({
id: index,
name: answer.name ?? `Question ${index + 1}`,
name: answer.name ?? t("playground.systemOne.responseView.question", { number: index + 1 }),
type: answer.type,
details: <OpenAIAnswerDetails answer={answer} />,
}))
@ -198,6 +210,7 @@ export default function ResponseView({
error,
isLoading,
}: ResponseViewProps) {
const { t } = useTranslation();
const [showRaw, setShowRaw] = useState(false);
if (isLoading) {
@ -205,7 +218,7 @@ export default function ResponseView({
<Card>
<CardContent className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" />
Calculating calibrated probabilities
{t("playground.systemOne.responseView.loading")}
</CardContent>
</Card>
);
@ -214,7 +227,7 @@ export default function ResponseView({
if (error) {
return (
<Alert variant="destructive">
<AlertTitle>Decisions request failed</AlertTitle>
<AlertTitle>{t("playground.systemOne.responseView.failed")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
);
@ -224,13 +237,13 @@ export default function ResponseView({
return (
<Card className="gap-0 py-0">
<CardHeader className="flex min-h-12 flex-row items-center border-b px-4 py-2 [.border-b]:pb-2">
<CardTitle className="text-sm font-medium">Response</CardTitle>
<CardTitle className="text-sm font-medium">{t("playground.systemOne.responseView.response")}</CardTitle>
</CardHeader>
<CardContent className="flex min-h-[360px] flex-col items-center justify-center gap-3 p-4 text-center">
<div className="flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">Send a request to see answers here</p>
<p className="text-sm text-muted-foreground">{t("playground.systemOne.responseView.empty")}</p>
</CardContent>
</Card>
);
@ -241,11 +254,15 @@ export default function ResponseView({
return (
<Card className="gap-0 overflow-hidden py-0 wrap-anywhere">
<CardHeader className="flex min-h-12 flex-row flex-wrap items-center justify-between gap-2 border-b px-4 py-2 [.border-b]:pb-2">
<CardTitle className="text-sm font-medium">{view === "curl" ? "Response JSON" : "Response"}</CardTitle>
<CardTitle className="text-sm font-medium">
{view === "curl"
? t("playground.systemOne.responseView.responseJson")
: t("playground.systemOne.responseView.response")}
</CardTitle>
<div className="flex flex-wrap items-center gap-1.5">
{latencyMs !== undefined && (
<Badge variant="outline" className="font-mono text-[11px] font-normal tabular-nums">
{Math.round(latencyMs)} ms
{t("playground.systemOne.responseView.latency", { ms: Math.round(latencyMs) })}
</Badge>
)}
{model && (
@ -258,7 +275,10 @@ export default function ResponseView({
)}
{response.usage && (
<Badge variant="outline" className="font-mono text-[11px] font-normal tabular-nums">
{response.usage.input_tokens} in / {response.usage.output_tokens} out
{t("playground.systemOne.responseView.usage", {
input: response.usage.input_tokens,
output: response.usage.output_tokens,
})}
</Badge>
)}
</div>
@ -271,7 +291,7 @@ export default function ResponseView({
render={
<Button variant="ghost" className="w-full justify-start rounded-none px-4 text-muted-foreground">
<ChevronRight className={`size-4 transition-transform ${showRaw ? "rotate-90" : ""}`} />
Raw response
{t("playground.systemOne.responseView.rawResponse")}
</Button>
}
/>

View file

@ -5,7 +5,9 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { ParseKeys, TFunction } from "i18next";
import { useId, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AddButton, FormFrame, QuestionCardHeader, RemoveButton } from "./DecisionFormControls";
import {
blankQuestion,
@ -27,10 +29,14 @@ import {
import type { SystemOnePayloadValidation } from "./lib/validatePayload";
const QUESTION_TYPES: readonly QuestionType[] = ["choice", "noul", "score"];
const TYPE_LABELS: Record<QuestionType, string> = { choice: "Choice", noul: "Yes / no", score: "Score" };
const TYPE_HELP: Record<QuestionType, string | undefined> = {
const TYPE_LABELS: Record<QuestionType, ParseKeys> = {
choice: "playground.systemOne.systemOneForm.types.choice",
noul: "playground.systemOne.systemOneForm.types.noul",
score: "playground.systemOne.systemOneForm.types.score",
};
const TYPE_HELP: Record<QuestionType, ParseKeys | undefined> = {
choice: undefined,
noul: "The model returns the probability that the answer is yes",
noul: "playground.systemOne.systemOneForm.typeHelp.noul",
score: undefined,
};
@ -56,21 +62,22 @@ interface KeyInputProps {
placeholder?: string;
}
function keyError(next: string, current: string, taken: readonly string[]): string | undefined {
function keyError(next: string, current: string, taken: readonly string[], t: TFunction): string | undefined {
if (next === "") {
return "Enter a name";
return t("playground.systemOne.systemOneForm.enterName");
}
if (next !== current && taken.includes(next)) {
return `"${next}" is already used`;
return t("playground.systemOne.systemOneForm.nameTaken", { name: next });
}
return undefined;
}
function KeyInput({ value, taken, onCommit, label, placeholder }: KeyInputProps) {
const { t } = useTranslation();
const errorId = useId();
const [draft, setDraft] = useState(value);
const next = draft.trim();
const error = next === value ? undefined : keyError(next, value, taken);
const error = next === value ? undefined : keyError(next, value, taken, t);
function commit() {
if (error === undefined && next !== value) {
@ -101,62 +108,64 @@ function KeyInput({ value, taken, onCommit, label, placeholder }: KeyInputProps)
}
function ChoiceOptions({ question, onChange }: QuestionEditorProps<ChoiceQuestion>) {
const { t } = useTranslation();
const labels = Object.keys(question.criteria);
const writeCriteria = (criteria: Record<string, string>) => onChange({ ...question, criteria });
return (
<div className="grid gap-2">
<span className="text-sm font-medium">Options</span>
<span className="text-sm font-medium">{t("playground.systemOne.systemOneForm.options")}</span>
{Object.entries(question.criteria).map(([label, description], index) => (
<div key={index} className="flex items-start gap-2">
<KeyInput
key={label}
label="Option label"
label={t("playground.systemOne.systemOneForm.optionLabel")}
value={label}
taken={labels}
onCommit={(next) => writeCriteria(renameKey(question.criteria, label, next))}
/>
<Input
aria-label={`Description of ${label}`}
aria-label={t("playground.systemOne.systemOneForm.optionDescription", { label })}
className="flex-2"
value={description}
placeholder="Optional description"
placeholder={t("playground.systemOne.systemOneForm.optionalDescription")}
onChange={(event) => writeCriteria({ ...question.criteria, [label]: event.target.value })}
/>
<RemoveButton
label={`Remove option ${label}`}
label={t("playground.systemOne.systemOneForm.removeOption", { label })}
onClick={() => writeCriteria(withoutKey(question.criteria, label))}
/>
</div>
))}
<AddButton onClick={() => writeCriteria({ ...question.criteria, [freeName(labels, "option")]: "" })}>
Add option
{t("playground.systemOne.systemOneForm.addOption")}
</AddButton>
</div>
);
}
function NoulCriteria({ question, onChange }: QuestionEditorProps<NoulQuestion>) {
const { t } = useTranslation();
const yesId = useId();
const noId = useId();
return (
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor={yesId}>Yes means</Label>
<Label htmlFor={yesId}>{t("playground.systemOne.systemOneForm.yesMeans")}</Label>
<Input
id={yesId}
value={question.criteria?.true ?? ""}
placeholder="Optional"
placeholder={t("playground.systemOne.systemOneForm.optional")}
onChange={(event) => onChange(withNoulCriterion(question, "true", event.target.value))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor={noId}>No means</Label>
<Label htmlFor={noId}>{t("playground.systemOne.systemOneForm.noMeans")}</Label>
<Input
id={noId}
value={question.criteria?.false ?? ""}
placeholder="Optional"
placeholder={t("playground.systemOne.systemOneForm.optional")}
onChange={(event) => onChange(withNoulCriterion(question, "false", event.target.value))}
/>
</div>
@ -165,27 +174,30 @@ function NoulCriteria({ question, onChange }: QuestionEditorProps<NoulQuestion>)
}
function ScoreLevels({ question, onChange }: QuestionEditorProps<ScoreQuestion>) {
const { t } = useTranslation();
const writeCriteria = (criteria: string[]) => onChange({ ...question, criteria });
return (
<div className="grid gap-2">
<span className="text-sm font-medium">Levels, lowest first</span>
<span className="text-sm font-medium">{t("playground.systemOne.systemOneForm.levels")}</span>
{question.criteria.map((level, index) => (
<div key={index} className="flex items-center gap-2">
<span className="w-6 shrink-0 text-right text-xs text-muted-foreground tabular-nums">{index}</span>
<Input
aria-label={`Level ${index}`}
aria-label={t("playground.systemOne.systemOneForm.level", { number: index })}
value={level}
placeholder="What this score means"
placeholder={t("playground.systemOne.systemOneForm.levelPlaceholder")}
onChange={(event) => writeCriteria(replaceAt(question.criteria, index, event.target.value))}
/>
<RemoveButton
label={`Remove level ${index}`}
label={t("playground.systemOne.systemOneForm.removeLevel", { number: index })}
onClick={() => writeCriteria(removeAt(question.criteria, index))}
/>
</div>
))}
<AddButton onClick={() => writeCriteria([...question.criteria, ""])}>Add level</AddButton>
<AddButton onClick={() => writeCriteria([...question.criteria, ""])}>
{t("playground.systemOne.systemOneForm.addLevel")}
</AddButton>
</div>
);
}
@ -210,37 +222,45 @@ interface QuestionCardProps extends QuestionEditorProps<FormQuestion> {
}
function QuestionCard({ name, index, question, taken, onChange, onRename, onRemove }: QuestionCardProps) {
const { t } = useTranslation();
const instructionsId = useId();
const title = name === "" ? String(index + 1) : name;
return (
<fieldset aria-label={`Question ${title}`} className="grid gap-3 rounded-lg border bg-muted/30 p-3">
<QuestionCardHeader index={index} onRemove={onRemove} removeLabel={`Remove question ${title}`} />
<fieldset
aria-label={t("playground.systemOne.systemOneForm.question", { title })}
className="grid gap-3 rounded-lg border bg-muted/30 p-3"
>
<QuestionCardHeader
index={index}
onRemove={onRemove}
removeLabel={t("playground.systemOne.systemOneForm.removeQuestion", { title })}
/>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_10rem]">
<div className="grid gap-1.5">
<span className="text-sm font-medium">Name</span>
<span className="text-sm font-medium">{t("playground.systemOne.systemOneForm.name")}</span>
<KeyInput
key={name}
label="Question name"
label={t("playground.systemOne.systemOneForm.questionName")}
value={name}
taken={taken}
onCommit={onRename}
placeholder="e.g. area"
placeholder={t("playground.systemOne.systemOneForm.questionNamePlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<span className="text-sm font-medium">Answer type</span>
<span className="text-sm font-medium">{t("playground.systemOne.systemOneForm.answerType")}</span>
<Select
value={question.type}
onValueChange={(type) => isQuestionType(type) && onChange(retype(question, type))}
>
<SelectTrigger className="w-full" aria-label="Answer type">
<SelectValue>{TYPE_LABELS[question.type]}</SelectValue>
<SelectTrigger className="w-full" aria-label={t("playground.systemOne.systemOneForm.answerType")}>
<SelectValue>{t(TYPE_LABELS[question.type])}</SelectValue>
</SelectTrigger>
<SelectContent>
{QUESTION_TYPES.map((type) => (
<SelectItem key={type} value={type} title={TYPE_HELP[type]}>
{TYPE_LABELS[type]}
<SelectItem key={type} value={type} title={TYPE_HELP[type] && t(TYPE_HELP[type])}>
{t(TYPE_LABELS[type])}
</SelectItem>
))}
</SelectContent>
@ -248,12 +268,12 @@ function QuestionCard({ name, index, question, taken, onChange, onRename, onRemo
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor={instructionsId}>Instructions</Label>
<Label htmlFor={instructionsId}>{t("playground.systemOne.systemOneForm.instructions")}</Label>
<Textarea
id={instructionsId}
rows={2}
value={question.instructions ?? ""}
placeholder="What should the model decide?"
placeholder={t("playground.systemOne.systemOneForm.instructionsPlaceholder")}
onChange={(event) => onChange({ ...question, instructions: event.target.value })}
/>
</div>
@ -263,15 +283,16 @@ function QuestionCard({ name, index, question, taken, onChange, onRename, onRemo
}
export default function SystemOneForm({ value, onChange, validation, onReset }: SystemOneFormProps) {
const { t } = useTranslation();
const stateId = useId();
const form = useMemo(() => readForm(value), [value]);
if (form === undefined) {
return (
<FormFrame validation={validation}>
<p className="text-sm text-muted-foreground">This request has a shape the form can&apos;t show.</p>
<p className="text-sm text-muted-foreground">{t("playground.systemOne.systemOneForm.unreadable")}</p>
<Button variant="outline" className="w-fit" onClick={onReset}>
Reset to example
{t("playground.systemOne.systemOneForm.resetToExample")}
</Button>
</FormFrame>
);
@ -285,15 +306,13 @@ export default function SystemOneForm({ value, onChange, validation, onReset }:
return (
<FormFrame validation={validation}>
<div className="grid gap-1.5">
<Label htmlFor={stateId}>Input</Label>
<p className="text-xs text-muted-foreground">
The text the model reads before it answers, such as a support ticket or a chat message.
</p>
<Label htmlFor={stateId}>{t("playground.systemOne.systemOneForm.input")}</Label>
<p className="text-xs text-muted-foreground">{t("playground.systemOne.systemOneForm.inputHint")}</p>
<Textarea
id={stateId}
rows={4}
value={form.state ?? ""}
placeholder="Paste the text the model should decide about"
placeholder={t("playground.systemOne.systemOneForm.inputPlaceholder")}
onChange={(event) => write({ ...form, state: event.target.value })}
/>
</div>
@ -310,7 +329,7 @@ export default function SystemOneForm({ value, onChange, validation, onReset }:
/>
))}
<AddButton onClick={() => writeQuestions({ ...questions, [freeName(names, "question")]: blankQuestion() })}>
Add question
{t("playground.systemOne.systemOneForm.addQuestion")}
</AddButton>
</FormFrame>
);

View file

@ -19,6 +19,7 @@ import { uiHref } from "@/utils/uiHref";
import { useMutation } from "@tanstack/react-query";
import { BookOpen, ChevronDown, Eraser, FileText, LoaderCircle, Send } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { makePlaygroundDecisionRequest, type PlaygroundDecisionRequest } from "../../llm_calls/system_one";
import { DECISION_PRESETS, emptyPayload, presetPayload } from "./lib/example";
import { payloadModel, withPayloadModel } from "./lib/payloadModel";
@ -55,11 +56,12 @@ function SetupField({ label, htmlFor, children }: { label: string; htmlFor: stri
}
function ExamplesMenu({ onPick }: { onPick: (id: string) => void }) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<FileText className="text-muted-foreground" />
Load example
{t("playground.systemOne.systemOneUi.loadExample")}
<ChevronDown className="text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
@ -78,6 +80,7 @@ function getCustomProxyBaseUrl(): string | undefined {
}
export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation = false }: SystemOneUIProps) {
const { t } = useTranslation();
const [apiKeySource, setApiKeySource] = useState<ApiKeySource>(disabledPersonalKeyCreation ? "custom" : "session");
const [customApiKey, setCustomApiKey] = useState("");
const effectiveApiKey = apiKeySource === "session" ? accessToken || "" : customApiKey.trim();
@ -96,9 +99,9 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
const validated = useMemo(
() =>
endpoint === "/v1/decisions"
? ({ endpoint, validation: validateOpenAIDecisionsPayload(rawPayload) } as const)
: ({ endpoint, validation: validateSystemOnePayload(rawPayload, endpoint) } as const),
[rawPayload, endpoint],
? ({ endpoint, validation: validateOpenAIDecisionsPayload(rawPayload, t) } as const)
: ({ endpoint, validation: validateSystemOnePayload(rawPayload, endpoint, t) } as const),
[rawPayload, endpoint, t],
);
const validation = validated.validation;
@ -167,9 +170,12 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
return (
<div className="flex h-full min-h-0 flex-col overflow-auto p-4 xl:overflow-hidden">
<div className="mx-auto grid w-full max-w-[1400px] gap-4 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,11fr)_minmax(0,9fr)]">
<section className="flex min-h-96 flex-col gap-4 xl:min-h-0" aria-label="Decisions request editor">
<section
className="flex min-h-96 flex-col gap-4 xl:min-h-0"
aria-label={t("playground.systemOne.systemOneUi.editorRegion")}
>
<div className="grid gap-3 rounded-lg border bg-card p-3 sm:grid-cols-3">
<SetupField label="Endpoint" htmlFor="decision-endpoint">
<SetupField label={t("playground.systemOne.systemOneUi.endpoint")} htmlFor="decision-endpoint">
<Select
value={endpoint}
onValueChange={(value) => {
@ -179,7 +185,11 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
}
}}
>
<SelectTrigger id="decision-endpoint" className="w-full" aria-label="Decision endpoint">
<SelectTrigger
id="decision-endpoint"
className="w-full"
aria-label={t("playground.systemOne.systemOneUi.endpointLabel")}
>
<SelectValue>{endpoint}</SelectValue>
</SelectTrigger>
<SelectContent>
@ -188,24 +198,28 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
</SelectContent>
</Select>
</SetupField>
<SetupField label="Model" htmlFor="decision-model">
<SetupField label={t("playground.systemOne.systemOneUi.model")} htmlFor="decision-model">
<SearchSelect
id="decision-model"
aria-label="Decision model"
aria-label={t("playground.systemOne.systemOneUi.modelLabel")}
options={decisionModels.map((model) => ({ label: model, value: model }))}
value={payloadModel(rawPayload) ?? null}
onValueChange={handleModelPick}
placeholder={decisionModels.length > 0 ? "Pick a decision model" : "No decision models yet"}
emptyText="No decision models found"
placeholder={
decisionModels.length > 0
? t("playground.systemOne.systemOneUi.pickModel")
: t("playground.systemOne.systemOneUi.noModelsYet")
}
emptyText={t("playground.systemOne.systemOneUi.noModelsFound")}
allowClear={false}
/>
{decisionModelsLoaded && decisionModels.length === 0 && (
<a href={uiHref("models-and-endpoints")} className="text-xs text-primary underline">
Add a decision model
{t("playground.systemOne.systemOneUi.addModel")}
</a>
)}
</SetupField>
<SetupField label="API key" htmlFor="decision-key-source">
<SetupField label={t("playground.systemOne.systemOneUi.apiKey")} htmlFor="decision-key-source">
<Select
value={apiKeySource}
onValueChange={(value) => {
@ -215,23 +229,31 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
}}
disabled={disabledPersonalKeyCreation}
>
<SelectTrigger id="decision-key-source" className="w-full" aria-label="Virtual Key Source">
<SelectValue>{apiKeySource === "custom" ? "Virtual Key" : "Current UI Session"}</SelectValue>
<SelectTrigger
id="decision-key-source"
className="w-full"
aria-label={t("playground.systemOne.systemOneUi.keySource")}
>
<SelectValue>
{apiKeySource === "custom"
? t("playground.systemOne.systemOneUi.virtualKey")
: t("playground.systemOne.systemOneUi.currentSession")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="session" disabled={!accessToken}>
Current UI Session
{t("playground.systemOne.systemOneUi.currentSession")}
</SelectItem>
<SelectItem value="custom">Virtual Key</SelectItem>
<SelectItem value="custom">{t("playground.systemOne.systemOneUi.virtualKey")}</SelectItem>
</SelectContent>
</Select>
{apiKeySource === "custom" && (
<Input
type="password"
aria-label="Virtual Key"
aria-label={t("playground.systemOne.systemOneUi.virtualKey")}
value={customApiKey}
onChange={(event) => setCustomApiKey(event.target.value)}
placeholder="Enter Virtual Key"
placeholder={t("playground.systemOne.systemOneUi.virtualKeyPlaceholder")}
/>
)}
</SetupField>
@ -239,7 +261,7 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
<Tabs
role="group"
aria-label="Request editor"
aria-label={t("playground.systemOne.systemOneUi.requestEditor")}
value={view}
onValueChange={(value) => (value === "form" || value === "curl") && setView(value)}
className={cn(
@ -248,17 +270,17 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
)}
>
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
<TabsList aria-label="Request editor view">
<TabsTrigger value="form">Form</TabsTrigger>
<TabsList aria-label={t("playground.systemOne.systemOneUi.requestEditorView")}>
<TabsTrigger value="form">{t("playground.systemOne.systemOneUi.formTab")}</TabsTrigger>
<TabsTrigger value="curl">cURL</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
<ExamplesMenu onPick={handlePresetPick} />
<SimpleTooltip content="Clear form">
<SimpleTooltip content={t("playground.systemOne.systemOneUi.clearForm")}>
<Button
variant="ghost"
size="icon"
aria-label="Clear form"
aria-label={t("playground.systemOne.systemOneUi.clearForm")}
className="hover:text-destructive"
onClick={() => handlePayloadChange(clearedPayload)}
disabled={rawPayload === clearedPayload || isLoading}
@ -266,12 +288,12 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
<Eraser />
</Button>
</SimpleTooltip>
<SimpleTooltip content="How to call /v1/decisions and /v1/systemone">
<SimpleTooltip content={t("playground.systemOne.systemOneUi.docs")}>
<a
href={DECISIONS_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="How to call /v1/decisions and /v1/systemone"
aria-label={t("playground.systemOne.systemOneUi.docs")}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "shrink-0")}
>
<BookOpen />
@ -307,26 +329,29 @@ export default function SystemOneUI({ accessToken, disabledPersonalKeyCreation =
</TabsContent>
<div
role="toolbar"
aria-label="Request actions"
aria-label={t("playground.systemOne.systemOneUi.requestActions")}
className="flex flex-wrap items-center justify-between gap-3 border-t bg-muted/50 px-4 py-3"
>
<ValidationSummary validation={validation} />
<div className="flex items-center gap-2">
{isLoading && (
<Button variant="outline" onClick={clearRequestState}>
Cancel request
{t("playground.systemOne.systemOneUi.cancelRequest")}
</Button>
)}
<Button size="sm" onClick={handleSend} disabled={!validation.isValid || isLoading || !effectiveApiKey}>
{isLoading ? <LoaderCircle className="animate-spin" /> : <Send />}
Send
{t("playground.systemOne.systemOneUi.send")}
</Button>
</div>
</div>
</Tabs>
</section>
<section className="grid content-start gap-4 xl:min-h-0 xl:overflow-auto" aria-label="Decisions results">
<section
className="grid content-start gap-4 xl:min-h-0 xl:overflow-auto"
aria-label={t("playground.systemOne.systemOneUi.results")}
>
<ResponseView
response={systemOne.data?.response}
view={view}

View file

@ -1,20 +1,23 @@
import { Badge } from "@/components/ui/badge";
import { CircleAlert, CircleCheck, TriangleAlert } from "lucide-react";
import { useTranslation } from "react-i18next";
import { errorCount as countErrors, type PayloadValidation } from "./lib/validatePayload";
export function ValidationStatus({ validation }: { validation: PayloadValidation<unknown> }) {
const { t } = useTranslation();
const errorCount = countErrors(validation);
if (errorCount > 0) {
return (
<Badge variant="destructive">
{errorCount} {errorCount === 1 ? "issue" : "issues"}
{t("playground.systemOne.validationFeedback.issueCount", { count: errorCount })}
</Badge>
);
}
return <Badge variant="secondary">Valid payload</Badge>;
return <Badge variant="secondary">{t("playground.systemOne.validationFeedback.valid")}</Badge>;
}
export function ValidationSummary({ validation }: { validation: PayloadValidation<unknown> }) {
const { t } = useTranslation();
const errorCount = countErrors(validation);
return (
<p role="status" aria-live="polite" className="flex min-w-0 items-center gap-2 text-sm">
@ -24,18 +27,25 @@ export function ValidationSummary({ validation }: { validation: PayloadValidatio
<CircleAlert className="size-4 shrink-0 text-destructive" />
)}
<span className={errorCount === 0 ? "text-muted-foreground" : "text-destructive"}>
{errorCount === 0 ? "Ready to send" : `${errorCount} ${errorCount === 1 ? "issue" : "issues"} to fix`}
{errorCount === 0
? t("playground.systemOne.validationFeedback.ready")
: t("playground.systemOne.validationFeedback.issuesToFix", { count: errorCount })}
</span>
</p>
);
}
export function IssueList({ id, validation }: { id: string; validation: PayloadValidation<unknown> }) {
const { t } = useTranslation();
if (validation.issues.length === 0) {
return null;
}
return (
<ul id={id} aria-label="Payload validation issues" className="grid max-h-36 gap-1 overflow-auto border-t px-3 py-2">
<ul
id={id}
aria-label={t("playground.systemOne.validationFeedback.issuesList")}
className="grid max-h-36 gap-1 overflow-auto border-t px-3 py-2"
>
{validation.issues.map((issue, index) => (
<li key={`${issue.path}-${index}`} className="flex items-start gap-1.5 text-xs">
{issue.severity === "error" ? (

View file

@ -1,30 +1,36 @@
const TOP_LEVEL: Record<string, string> = {
state: "Input",
input: "Input",
model: "Model",
questions: "Questions",
safety_identifier: "Safety identifier",
root: "Request",
syntax: "Request",
import type { ParseKeys, TFunction } from "i18next";
import i18n from "@/i18n/i18n";
const TOP_LEVEL: Record<string, ParseKeys> = {
state: "playground.systemOne.issueLabel.input",
input: "playground.systemOne.issueLabel.input",
model: "playground.systemOne.issueLabel.model",
questions: "playground.systemOne.issueLabel.questions",
safety_identifier: "playground.systemOne.issueLabel.safetyIdentifier",
root: "playground.systemOne.issueLabel.request",
syntax: "playground.systemOne.issueLabel.request",
};
const QUESTION_FIELD: Record<string, string> = {
name: "name",
type: "answer type",
instructions: "instructions",
criteria: "options",
const QUESTION_FIELD: Record<string, ParseKeys> = {
name: "playground.systemOne.issueLabel.fields.name",
type: "playground.systemOne.issueLabel.fields.type",
instructions: "playground.systemOne.issueLabel.fields.instructions",
criteria: "playground.systemOne.issueLabel.fields.criteria",
};
const humanize = (segment: PropertyKey): string => String(segment).replace(/_/g, " ");
export function issueLabel(path: readonly PropertyKey[]): string {
export function issueLabel(path: readonly PropertyKey[], t: TFunction = i18n.t.bind(i18n)): string {
const [head = "root", question, field] = path;
if (head !== "questions" || question === undefined) {
return TOP_LEVEL[String(head)] ?? humanize(head);
return Object.hasOwn(TOP_LEVEL, String(head)) ? t(TOP_LEVEL[String(head)]) : humanize(head);
}
const title = typeof question === "number" ? `Question ${question + 1}` : `Question "${String(question)}"`;
const title =
typeof question === "number"
? t("playground.systemOne.issueLabel.question", { number: question + 1 })
: t("playground.systemOne.issueLabel.namedQuestion", { name: String(question) });
if (field === undefined) {
return title;
}
return `${title} ${QUESTION_FIELD[String(field)] ?? humanize(field)}`;
return `${title} ${Object.hasOwn(QUESTION_FIELD, String(field)) ? t(QUESTION_FIELD[String(field)]) : humanize(field)}`;
}

View file

@ -1,4 +1,6 @@
import type { TFunction } from "i18next";
import { z } from "zod";
import i18n from "@/i18n/i18n";
import { issueLabel } from "./issueLabel";
import { openAIDecisionsRequestSchema, type OpenAIDecisionsRequest } from "./openAIDecisions";
import {
@ -28,21 +30,21 @@ export type SystemOnePayloadValidation = PayloadValidation<PlaygroundRequest>;
export const errorCount = (validation: PayloadValidation<unknown>): number =>
validation.issues.filter((issue) => issue.severity === "error").length;
const error = (path: readonly PropertyKey[], message: string): SystemOnePayloadIssue => ({
const error = (path: readonly PropertyKey[], message: string, t: TFunction): SystemOnePayloadIssue => ({
path: path.length > 0 ? path.join(".") : "root",
label: issueLabel(path),
label: issueLabel(path, t),
message,
severity: "error",
});
const toIssue = (path: PropertyKey[], message: string): SystemOnePayloadIssue =>
const toIssue = (path: PropertyKey[], message: string, t: TFunction): SystemOnePayloadIssue =>
path[0] === "questions" && path[1] === ""
? error(["questions"], "Give every question a name.")
: error(path, message);
? error(["questions"], t("playground.systemOne.validatePayload.nameEveryQuestion"), t)
: error(path, message, t);
const invalid = (path: string, message: string) => ({
const invalid = (path: string, message: string, t: TFunction) => ({
isValid: false,
issues: [error([path], message)],
issues: [error([path], message, t)],
});
export function parseJson(raw: string): { ok: true; value: unknown } | { ok: false; message: string } {
@ -53,31 +55,31 @@ export function parseJson(raw: string): { ok: true; value: unknown } | { ok: fal
}
}
const scoreLevelWarnings = (payload: PlaygroundRequest): SystemOnePayloadIssue[] =>
const scoreLevelWarnings = (payload: PlaygroundRequest, t: TFunction): SystemOnePayloadIssue[] =>
Object.entries(payload.questions)
.filter(([, question]) => question.type === "score" && question.criteria.length > RECOMMENDED_MAX_SCORE_LEVELS)
.map(([id]) => ({
path: `questions.${id}.criteria`,
label: issueLabel(["questions", id, "criteria"]),
message: `More than ${RECOMMENDED_MAX_SCORE_LEVELS} score levels may reduce result quality.`,
label: issueLabel(["questions", id, "criteria"], t),
message: t("playground.systemOne.validatePayload.tooManyLevels", { max: RECOMMENDED_MAX_SCORE_LEVELS }),
severity: "warning",
}));
function validatePayload<T>(raw: string, schema: z.ZodType<T>): PayloadValidation<T> {
function validatePayload<T>(raw: string, schema: z.ZodType<T>, t: TFunction): PayloadValidation<T> {
if (!raw.trim()) {
return invalid("root", "Payload cannot be empty.");
return invalid("root", t("playground.systemOne.validatePayload.empty"), t);
}
const json = parseJson(raw);
if (!json.ok) {
return invalid("syntax", `Invalid JSON syntax: ${json.message}`);
return invalid("syntax", t("playground.systemOne.validatePayload.invalidJson", { message: json.message }), t);
}
const result = schema.safeParse(json.value);
if (!result.success) {
return {
isValid: false,
issues: result.error.issues.map((issue) => toIssue(issue.path, issue.message)),
issues: result.error.issues.map((issue) => toIssue(issue.path, issue.message, t)),
};
}
@ -95,29 +97,39 @@ const isMissingContext = (raw: string, path: "state" | "input"): boolean => {
return isBlankContext((json.value as Record<string, unknown>)[path]);
};
const requireContext = <T>(raw: string, path: "state" | "input", result: PayloadValidation<T>): PayloadValidation<T> =>
const requireContext = <T>(
raw: string,
path: "state" | "input",
result: PayloadValidation<T>,
t: TFunction,
): PayloadValidation<T> =>
isMissingContext(raw, path)
? {
...result,
isValid: false,
issues: [error([path], "Add the text the model should decide about.")],
issues: [error([path], t("playground.systemOne.validatePayload.missingContext"), t)],
}
: result;
export function validateSystemOnePayload(
raw: string,
endpoint: DecisionEndpoint = "/typesafe/v1/systemone",
t: TFunction = i18n.t.bind(i18n),
): SystemOnePayloadValidation {
const schema = endpoint === "/v1/systemone" ? decisionsRequestSchema : systemOneRequestSchema;
const result = validatePayload<PlaygroundRequest>(raw, schema);
const result = validatePayload<PlaygroundRequest>(raw, schema, t);
return requireContext(
raw,
"state",
result.payload ? { ...result, issues: scoreLevelWarnings(result.payload) } : result,
result.payload ? { ...result, issues: scoreLevelWarnings(result.payload, t) } : result,
t,
);
}
export function validateOpenAIDecisionsPayload(raw: string): PayloadValidation<OpenAIDecisionsRequest> {
const result = validatePayload<OpenAIDecisionsRequest>(raw, openAIDecisionsRequestSchema);
return requireContext(raw, "input", result);
export function validateOpenAIDecisionsPayload(
raw: string,
t: TFunction = i18n.t.bind(i18n),
): PayloadValidation<OpenAIDecisionsRequest> {
const result = validatePayload<OpenAIDecisionsRequest>(raw, openAIDecisionsRequestSchema, t);
return requireContext(raw, "input", result, t);
}

View file

@ -6,6 +6,7 @@ import { buildMcpToolBlocks } from "@/components/llm_calls/mcp_tool_blocks";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import { MCPServer, MCPToolset } from "@/components/mcp_tools/types";
import { getGatewayBaseUrl } from "@/components/networking";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
@ -134,7 +135,7 @@ export async function makeAnthropicMessagesRequest(
} catch (error) {
if (signal?.aborted) {
} else {
toast.fromError(`Error occurred while generating model response. Please try again. Error: ${error}`);
toast.fromError(i18n.t("playground.llmCalls.anthropicMessages.requestFailed", { error: String(error) }));
}
throw error;
}

View file

@ -2,6 +2,7 @@ import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http
import openai from "openai";
import { getGatewayBaseUrl } from "@/components/networking";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import type { OpenAIVoice } from "../components/chat_ui/chatConstants";
@ -54,7 +55,7 @@ export async function makeOpenAIAudioSpeechRequest(
} catch (error) {
if (signal?.aborted) {
} else {
toast.fromError(`Error occurred while generating speech. Please try again. Error: ${error}`);
toast.fromError(i18n.t("playground.llmCalls.audioSpeech.requestFailed", { error: String(error) }));
}
throw error; // Re-throw to allow the caller to handle the error
}

View file

@ -2,6 +2,7 @@ import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http
import openai from "openai";
import { getGatewayBaseUrl } from "@/components/networking";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
export async function makeOpenAIAudioTranscriptionRequest(
@ -50,7 +51,7 @@ export async function makeOpenAIAudioTranscriptionRequest(
// The response is a transcription object with a text field
if (response && response.text) {
updateUI(response.text, selectedModel);
toast.success(`Audio transcribed successfully`);
toast.success(i18n.t("playground.llmCalls.audioTranscriptions.success"));
} else {
throw new Error("No transcription text in response");
}
@ -59,7 +60,7 @@ export async function makeOpenAIAudioTranscriptionRequest(
if (signal?.aborted) {
} else {
let errorMessage = "Failed to transcribe audio";
let errorMessage = i18n.t("playground.llmCalls.audioTranscriptions.defaultError");
if (error?.error?.message) {
errorMessage = error.error.message;
@ -67,7 +68,7 @@ export async function makeOpenAIAudioTranscriptionRequest(
errorMessage = error.message;
}
toast.fromError(`Audio transcription failed: ${errorMessage}`);
toast.fromError(i18n.t("playground.llmCalls.audioTranscriptions.failed", { message: String(errorMessage) }));
}
throw error; // Re-throw to allow the caller to handle the error
}

View file

@ -1,4 +1,5 @@
import type { ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import { getGatewayBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import {
@ -61,7 +62,7 @@ export async function makeOpenAIEmbeddingsRequest(
updateEmbeddingsUI(JSON.stringify(embedding), responseData?.model ?? selectedModel);
} catch (error: unknown) {
toast.fromError(`Error occurred while making embeddings request. Please try again. Error: ${error}`);
toast.fromError(i18n.t("playground.llmCalls.embeddingsApi.requestFailed", { error: String(error) }));
throw error; // Re-throw to allow the caller to handle the error
}

View file

@ -2,6 +2,7 @@ import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http
import openai from "openai";
import { getGatewayBaseUrl } from "@/components/networking";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
export async function makeOpenAIImageEditsRequest(
@ -68,14 +69,14 @@ export async function makeOpenAIImageEditsRequest(
}
if (results.length > 1) {
toast.success(`Successfully processed ${results.length} images`);
toast.success(i18n.t("playground.llmCalls.imageEdits.success", { total: results.length }));
}
} catch (error: any) {
console.error("Error making image edit request:", error);
if (signal?.aborted) {
} else {
let errorMessage = "Failed to edit image(s)";
let errorMessage = i18n.t("playground.llmCalls.imageEdits.defaultError");
if (error?.error?.message) {
errorMessage = error.error.message;
@ -83,7 +84,7 @@ export async function makeOpenAIImageEditsRequest(
errorMessage = error.message;
}
toast.fromError(`Image edit failed: ${errorMessage}`);
toast.fromError(i18n.t("playground.llmCalls.imageEdits.failed", { message: String(errorMessage) }));
}
throw error; // Re-throw to allow the caller to handle the error
}

View file

@ -2,6 +2,7 @@ import { observeResponseHeaders, type ResponseHeadersListener } from "@/lib/http
import openai from "openai";
import { getGatewayBaseUrl } from "@/components/networking";
import { buildPlaygroundHeaders, type CustomHeaders } from "@/components/llm_calls/request_headers";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
export async function makeOpenAIImageGenerationRequest(
@ -56,7 +57,7 @@ export async function makeOpenAIImageGenerationRequest(
} catch (error) {
if (signal?.aborted) {
} else {
toast.fromError(`Error occurred while generating image. Please try again. Error: ${error}`);
toast.fromError(i18n.t("playground.llmCalls.imageGeneration.requestFailed", { error: String(error) }));
}
throw error; // Re-throw to allow the caller to handle the error
}

View file

@ -1,4 +1,5 @@
import type { ResponseHeadersListener } from "@/lib/http/observeResponseHeaders";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import { getGatewayBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
import { streamErrorMessage } from "@/utils/streamErrorMessage";
@ -122,7 +123,7 @@ export async function makeInteractionsRequest(
if (signal?.aborted) {
throw error;
}
toast.fromError(`Error occurred while making Interactions API request. Error: ${error}`);
toast.fromError(i18n.t("playground.llmCalls.interactionsApi.requestFailed", { error: String(error) }));
throw error;
}
}

View file

@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { PlayCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import AgentBuilderView from "@/app/(dashboard)/playground/components/chat_ui/AgentBuilderView";
import { PlaygroundChat, type ProxySettings } from "@/app/(dashboard)/playground/components/chat/PlaygroundChat";
import CompareUI from "@/app/(dashboard)/playground/components/compareUI/CompareUI";
@ -19,6 +20,7 @@ const PLAYGROUND_TABS = ["chat", "compare", "compliance", "system-one", "agent-b
const TAB_TRIGGER = "h-full group-data-horizontal/tabs:after:-bottom-0.5";
export default function PlaygroundPage() {
const { t } = useTranslation();
const { accessToken, userRole, userId, disabledPersonalKeyCreation, token, isViewOnly } = useAuthorized();
const [proxySettings, setProxySettings] = useState<ProxySettings | undefined>(undefined);
const [activeTab, setActiveTab] = useUrlTab(PLAYGROUND_TABS, "chat");
@ -42,10 +44,8 @@ export default function PlaygroundPage() {
if (isViewOnly) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-8 text-center">
<h1 className="text-2xl font-semibold">Access Denied</h1>
<p className="text-muted-foreground">
Your role does not have access to the Playground. Ask your proxy admin for access to test models.
</p>
<h1 className="text-2xl font-semibold">{t("playground.page.accessDenied.title")}</h1>
<p className="text-muted-foreground">{t("playground.page.accessDenied.description")}</p>
</div>
);
}
@ -55,28 +55,26 @@ export default function PlaygroundPage() {
<PageHeader>
<PageHeaderTitle>
<PlayCircle />
Playground
{t("playground.page.title")}
</PageHeaderTitle>
<PageHeaderDescription>
Send test requests through the gateway with any model, key, guardrail or tool
</PageHeaderDescription>
<PageHeaderDescription>{t("playground.page.description")}</PageHeaderDescription>
</PageHeader>
<PageTabs value={activeTab} onValueChange={setActiveTab} className="gap-0">
<PageTabsList>
<PageTabsTrigger value="chat" className={TAB_TRIGGER}>
Chat
{t("playground.page.tabs.chat")}
</PageTabsTrigger>
<PageTabsTrigger value="compare" className={TAB_TRIGGER}>
Compare
{t("playground.page.tabs.compare")}
</PageTabsTrigger>
<PageTabsTrigger value="compliance" className={TAB_TRIGGER}>
Compliance
{t("playground.page.tabs.compliance")}
</PageTabsTrigger>
<PageTabsTrigger value="system-one" className={TAB_TRIGGER}>
<BetaBadge>Decisions</BetaBadge>
<BetaBadge>{t("playground.page.tabs.decisions")}</BetaBadge>
</PageTabsTrigger>
<PageTabsTrigger value="agent-builder" className={TAB_TRIGGER}>
Agent builder
{t("playground.page.tabs.agentBuilder")}
</PageTabsTrigger>
</PageTabsList>
<PageTabsContent value="chat" className="overflow-hidden data-hidden:hidden" keepMounted>

View file

@ -3,20 +3,20 @@
import ModelHubTable from "@/components/AIHub/ModelHubTable";
import PublicModelHub from "@/components/public_model_hub";
import { useChatShell } from "@/contexts/ChatShellContext";
import { useTranslation } from "react-i18next";
import { isAdminRole } from "@/utils/roles";
export default function ChatModelsPage() {
const { t } = useTranslation();
const { accessToken, userRole, premiumUser } = useChatShell();
return (
<div className="moyai-models-page">
<div className="moyai-models-content">
<header className="moyai-models-header">
<p className="text-xs font-medium text-muted-foreground">Model Hub</p>
<h1>Models</h1>
<p className="text-sm text-muted-foreground">
Find the right model. Explore providers, compare pricing, and see what each model can do.
</p>
<p className="text-xs font-medium text-muted-foreground">{t("chat.modelsPage.eyebrow")}</p>
<h1>{t("chat.modelsPage.title")}</h1>
<p className="text-sm text-muted-foreground">{t("chat.modelsPage.description")}</p>
</header>
{isAdminRole(userRole) ? (
<ModelHubTable

View file

@ -20,6 +20,7 @@ import { runChatRequest } from "@/components/chat/runChatRequest";
import { readChatAttachment, MAX_CHAT_ATTACHMENTS, CHAT_ATTACHMENT_ACCEPT } from "@/components/chat/attachmentHelpers";
import ChatFileChip from "@/components/chat/ChatFileChip";
import { useQueryState } from "nuqs";
import { useTranslation } from "react-i18next";
const LOCALSTORAGE_MODEL_KEY = "litellm_chat_selected_model";
@ -46,9 +47,10 @@ function AttachmentPreview({
disabled: boolean;
onRemove: (id: string) => void;
}) {
const { t } = useTranslation();
if (!attachments.length) return null;
return (
<div className="flex w-full flex-wrap gap-3 px-4 pt-4" aria-label="Attached files">
<div className="flex w-full flex-wrap gap-3 px-4 pt-4" aria-label={t("chat.page.attachedFiles")}>
{attachments.map((attachment) => (
<div key={attachment.id} className={attachment.kind === "file" ? "relative max-w-64" : "relative w-20"}>
{attachment.kind === "file" ? (
@ -65,7 +67,7 @@ function AttachmentPreview({
type="button"
variant="secondary"
size="icon-xs"
aria-label={`Remove ${attachment.name}`}
aria-label={t("chat.page.removeAttachment", { name: attachment.name })}
className="absolute -right-2 -top-2 rounded-full"
disabled={disabled}
onClick={() => onRemove(attachment.id)}
@ -79,6 +81,7 @@ function AttachmentPreview({
}
export default function ChatConversationPage() {
const { t } = useTranslation();
const [, setConversationIdInUrl] = useQueryState("id", { history: "push" });
const router = useRouter();
const {
@ -141,9 +144,9 @@ export default function ChatConversationPage() {
models.some(
(model) => model.model_group === selectedModel && model.supports_web_search && model.supports_reasoning,
);
let researchUnavailableReason = "Choose a reasoning model with web search.";
if (!useBackground) researchUnavailableReason = "Enable background chats in Admin Settings to use deep research.";
if (storageUnavailable) researchUnavailableReason = "Allow chat history storage to use deep research.";
let researchUnavailableReason = t("chat.page.researchUnavailable.noWebSearch");
if (!useBackground) researchUnavailableReason = t("chat.page.researchUnavailable.noBackground");
if (storageUnavailable) researchUnavailableReason = t("chat.page.researchUnavailable.noStorage");
const [inputText, setInputText] = useState("");
const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false);
const [storageBannerDismissed, setStorageBannerDismissed] = useState(false);
@ -181,9 +184,9 @@ export default function ChatConversationPage() {
setMode(modelChatMode(available[0]));
}
})
.catch(() => toast.error("Could not load models"))
.catch(() => toast.error(t("chat.page.errors.loadModels")))
.finally(() => setIsLoadingModels(false));
}, [accessToken]);
}, [accessToken, t]);
useLayoutEffect(() => {
if (
@ -215,7 +218,7 @@ export default function ChatConversationPage() {
const selectModel = useCallback(
(model: ModelGroup) => {
if (modelChatMode(model) === "image" && attachments.length > 0) {
setAttachmentError("Remove the attached files before switching to image generation.");
setAttachmentError(t("chat.page.errors.removeAttachmentsForImage"));
return;
}
responsesSessionGenerationRef.current += 1;
@ -230,14 +233,14 @@ export default function ChatConversationPage() {
return;
}
},
[attachments.length],
[attachments.length, t],
);
const addAttachments = async (files: File[]) => {
if (mode !== "chat" || files.length === 0) return;
if (isStreaming || readAttachmentsRef.current) return;
if (attachments.length + files.length > MAX_CHAT_ATTACHMENTS) {
setAttachmentError(`Attach up to ${MAX_CHAT_ATTACHMENTS} files per message.`);
setAttachmentError(t("chat.page.errors.tooManyAttachments", { max: MAX_CHAT_ATTACHMENTS }));
return;
}
const draftVersion = draftVersionRef.current;
@ -249,7 +252,7 @@ export default function ChatConversationPage() {
if (draftVersion === draftVersionRef.current) setAttachments((current) => [...current, ...images]);
} catch (error) {
if (draftVersion === draftVersionRef.current)
setAttachmentError(error instanceof Error ? error.message : "Could not read these files.");
setAttachmentError(error instanceof Error ? error.message : t("chat.page.errors.unreadableFiles"));
} finally {
readAttachmentsRef.current = false;
setIsReadingAttachments(false);
@ -290,7 +293,7 @@ export default function ChatConversationPage() {
const model = generationModel ?? selectedModel;
const doResearch = requestMode === "chat" && researchRequested;
if (doResearch && !canResearch) {
setAttachmentError("Deep research needs a reasoning model with web search and background chats enabled.");
setAttachmentError(t("chat.page.errors.researchBlocked"));
return;
}
if (!hasMessageContent(trimmed, images, requestMode) || !model) return;
@ -364,7 +367,7 @@ export default function ChatConversationPage() {
};
backgroundRequestRef.current = request.background;
try {
await runChatRequest(request);
await runChatRequest(request, t);
} finally {
if (abortControllerRef.current === requestController) {
setIsStreaming(false);
@ -389,6 +392,7 @@ export default function ChatConversationPage() {
responsesSessionId,
useBackground,
setConversationIdInUrl,
t,
],
);
@ -415,12 +419,12 @@ export default function ChatConversationPage() {
if (idx < 0) return;
const original = msgs[idx];
if (!original.imageGeneration && (mode !== "chat" || !selectedModel)) {
setAttachmentError("Choose a chat model before resending this message.");
setAttachmentError(t("chat.page.errors.resendNeedsChatModel"));
return;
}
if (!newContent.trim() && !original.attachments?.length) return;
if (original.research && !canResearch) {
setAttachmentError("Choose a reasoning model with web search and background chats before resending research.");
setAttachmentError(t("chat.page.errors.resendNeedsResearchModel"));
return;
}
const priorMessages = msgs.slice(0, idx);
@ -443,6 +447,7 @@ export default function ChatConversationPage() {
mode,
selectedModel,
canResearch,
t,
],
);
@ -495,20 +500,19 @@ export default function ChatConversationPage() {
const backgroundHint = () => {
if (background.cancelling || (isRequestInFlight && cancelOnAcceptance === activeConversationId))
return "Requesting cancellation…";
if (background.pending && storageUnavailable)
return "Keep this tab open. This run could not be saved in this browser.";
if (background.pending) return "Running in the background. You can close this tab and return to this chat.";
return t("chat.page.backgroundHint.cancelling");
if (background.pending && storageUnavailable) return t("chat.page.backgroundHint.unsaved");
if (background.pending) return t("chat.page.backgroundHint.running");
if (useBackground) return null;
return "Keep this tab open. Background runs are not available for this chat.";
return t("chat.page.backgroundHint.unavailable");
};
const inputBar = (inConversation: boolean) => {
const hint = mode === "chat" || background.pending ? backgroundHint() : null;
const defaultPlaceholder = inConversation
? "Send a message..."
: "Ask a question, attach a file, or paste something to work on…";
const chatPlaceholder = research ? "What should I research on the web?" : defaultPlaceholder;
? t("chat.page.placeholder.conversation")
: t("chat.page.placeholder.home");
const chatPlaceholder = research ? t("chat.page.placeholder.research") : defaultPlaceholder;
const preparing = isLoadingModels || isReadingAttachments;
const researchBlocked = research && !canResearch;
const submitDisabled =
@ -531,9 +535,9 @@ export default function ChatConversationPage() {
onChange={setInputText}
onSubmit={() => void handleSend({ text: inputText, research })}
onCancel={handleStop}
placeholder={mode === "image" ? "Describe the image you want to create…" : chatPlaceholder}
placeholder={mode === "image" ? t("chat.page.placeholder.image") : chatPlaceholder}
isLoading={isStreaming}
submitLabel={mode === "image" ? "Generate image" : "Send"}
submitLabel={mode === "image" ? t("chat.page.submit.generateImage") : t("chat.page.submit.send")}
submitDisabled={submitDisabled}
onPaste={(event) => {
const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/"));
@ -566,7 +570,7 @@ export default function ChatConversationPage() {
type="file"
accept={CHAT_ATTACHMENT_ACCEPT}
multiple
aria-label="Upload files"
aria-label={t("chat.page.uploadFiles")}
className="sr-only"
disabled={isStreaming || isReadingAttachments}
onChange={handleAttachmentInput}
@ -575,8 +579,8 @@ export default function ChatConversationPage() {
type="button"
variant="ghost"
size="icon-sm"
aria-label="Attach files"
title="Attach images, PDF, Word (.docx), Excel (.xlsx), text, or code files (up to 4 files, 10 MB each)"
aria-label={t("chat.page.attachFiles")}
title={t("chat.page.attachFilesHint")}
disabled={isStreaming || isReadingAttachments || attachments.length >= MAX_CHAT_ATTACHMENTS}
onClick={() => attachmentInputRef.current?.click()}
>
@ -589,11 +593,11 @@ export default function ChatConversationPage() {
variant="ghost"
size="sm"
disabled={isStreaming}
aria-label="Connect tools"
aria-label={t("chat.page.connectTools")}
className="gap-1.5 text-muted-foreground"
>
<Plus className="size-3.5" />
<span>Tools</span>
<span>{t("chat.page.tools")}</span>
{selectedMCPServers.length > 0 && (
<span className="text-primary">{selectedMCPServers.length}</span>
)}
@ -614,7 +618,7 @@ export default function ChatConversationPage() {
}}
>
<BookOpen className="size-4" />
Deep research
{t("chat.page.deepResearch")}
</Button>
{!canResearch && (
<p className="max-w-64 px-2 pb-1 text-xs text-muted-foreground">{researchUnavailableReason}</p>
@ -632,11 +636,11 @@ export default function ChatConversationPage() {
variant="secondary"
size="sm"
disabled={isStreaming}
aria-label="Turn off deep research"
aria-label={t("chat.page.turnOffDeepResearch")}
onClick={() => setResearch(false)}
>
<BookOpen className="size-3.5" />
Deep research
{t("chat.page.deepResearch")}
<X className="size-3.5" />
</Button>
)}
@ -644,18 +648,18 @@ export default function ChatConversationPage() {
) : (
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<ImageIcon className="size-4" />
Create an image from your description
{t("chat.page.imageModeTools")}
</span>
)
}
/>
{isReadingAttachments && (
<p role="status" className="mt-2 text-xs text-muted-foreground">
Preparing files…
{t("chat.page.preparingFiles")}
</p>
)}
{attachments.some((attachment) => attachment.kind === "file") && (
<p className="mt-2 text-xs text-muted-foreground">File support depends on the selected model and provider.</p>
<p className="mt-2 text-xs text-muted-foreground">{t("chat.page.fileSupportNote")}</p>
)}
{attachmentError && (
<p role="alert" className="mt-2 text-sm text-destructive">
@ -664,16 +668,14 @@ export default function ChatConversationPage() {
)}
{!isLoadingModels && !selectedModel && (
<p className="mt-2 text-sm text-muted-foreground">
{mode === "image"
? "No image-generation models are available. Ask your admin to add one to Model Hub."
: "No chat models are available for your account."}
{mode === "image" ? t("chat.page.noImageModels") : t("chat.page.noChatModels")}
</p>
)}
{background.connectionError && (
<div role="alert" className="mt-2 flex items-center gap-2 text-sm text-destructive">
<span>{background.connectionError}</span>
<Button variant="ghost" size="sm" onClick={background.reconnect}>
Reconnect
{t("chat.page.reconnect")}
</Button>
</div>
)}
@ -684,7 +686,7 @@ export default function ChatConversationPage() {
)}
{lastMessage?.backgroundRun?.status === "cancelled" && (
<p role="status" className="mt-2 text-xs text-muted-foreground">
Cancellation requested
{t("chat.page.cancellationRequested")}
</p>
)}
{hint && (
@ -693,7 +695,7 @@ export default function ChatConversationPage() {
</p>
)}
<p className="moyai-chat-home-composer-hint">
{mode === "image" ? "Describe an image · Enter to generate" : "Drop files or paste images · Enter to send"}
{mode === "image" ? t("chat.page.composerHint.image") : t("chat.page.composerHint.chat")}
</p>
</div>
);
@ -703,7 +705,7 @@ export default function ChatConversationPage() {
<>
{storageUnavailable && !storageBannerDismissed && (
<div className="bg-warning/10 border-b border-warning/20 px-5 py-1.5 text-[13px] text-warning flex justify-between items-center">
<span>Some chat history or attachments could not be saved or restored.</span>
<span>{t("chat.page.storageBanner")}</span>
<Button
variant="ghost"
size="icon-xs"
@ -757,7 +759,7 @@ export default function ChatConversationPage() {
}
}}
className="absolute bottom-[100px] left-1/2 -translate-x-1/2 z-chrome rounded-full border bg-background/75 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background/95"
aria-label="Scroll to bottom"
aria-label={t("chat.page.scrollToBottom")}
>
<ChevronDown className="h-3 w-3" />
</Button>

View file

@ -1,6 +1,7 @@
"use client";
import { Suspense, useEffect, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import Navbar from "@/components/navbar";
@ -13,6 +14,7 @@ import { isProxyAdminRole } from "@/utils/roles";
import { uiHref } from "@/utils/uiHref";
function MoyaiPageContent() {
const { t } = useTranslation();
const { accessToken, userRole } = useAuthorized();
const { data: uiSettings, isLoading, refetch } = useUISettings();
@ -67,9 +69,9 @@ function MoyaiPageContent() {
} else if (moyaiUrl) {
content = (
<div className="flex min-h-[calc(100vh-3.5rem)] flex-col items-center justify-center gap-3 bg-[#04060c] px-6 text-center text-[#e3e3e3]">
<p className="m-0 text-lg font-medium text-white">Opening Moyai</p>
<p className="m-0 text-lg font-medium text-white">{t("moyai.page.opening")}</p>
<a href={moyaiUrl} className="text-sm text-[#8b9bff] underline underline-offset-4">
Continue to {moyaiUrl}
{t("moyai.page.continueTo", { url: moyaiUrl })}
</a>
</div>
);

View file

@ -3,13 +3,15 @@
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import React from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
const ThemeToggle: React.FC = () => {
const { t } = useTranslation();
const { setTheme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
const label = isDark ? "Switch to light mode" : "Switch to dark mode";
const label = isDark ? t("themeToggle.switchToLight") : t("themeToggle.switchToDark");
return (
<Button

View file

@ -1,7 +1,9 @@
import { FileText } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ChatFileAttachment } from "./types";
export default function ChatFileChip({ file }: { file: ChatFileAttachment }) {
const { t } = useTranslation();
return (
<div className="flex min-w-0 items-center gap-2 rounded-lg border bg-muted px-3 py-2 text-sm">
<FileText className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
@ -10,7 +12,7 @@ export default function ChatFileChip({ file }: { file: ChatFileAttachment }) {
{file.name}
</p>
<p className="text-xs text-muted-foreground">
{file.url ? "Attached file" : "File unavailable — start a new chat and attach it again"}
{file.url ? t("chat.fileChip.attached") : t("chat.fileChip.unavailable")}
</p>
</div>
</div>

View file

@ -9,12 +9,14 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import React, { useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { useSyntaxTheme } from "@/hooks/useSyntaxTheme";
import i18n from "@/i18n/i18n";
import ReasoningContent from "@/components/chat_ui/ReasoningContent";
import MCPEventsDisplay from "@/components/chat_ui/MCPEventsDisplay";
import ResponseMetrics from "@/components/chat_ui/ResponseMetrics";
@ -93,16 +95,17 @@ const markdownComponents: Components = {
const markdownWithoutImages: Components = {
...markdownComponents,
img: ({ alt }) => <span>{alt || "Image omitted"}</span>,
img: ({ alt }) => <span>{alt || i18n.t("chat.messages.imageOmitted")}</span>,
};
function ChatImage({ image, allowImages }: { image: ChatImageAttachment; allowImages: boolean }) {
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const { t } = useTranslation();
if (!allowImages) return <span className="text-sm text-muted-foreground">{image.name}</span>;
if (!isChatImageUrl(image.url) || failedUrl === image.url) {
return (
<div className="rounded-xl border bg-muted p-4 text-sm text-muted-foreground">
{image.name} — image unavailable. Start a new chat and attach a saved copy to use it again.
<Trans i18nKey="chat.messages.imageUnavailable" components={{ name: <>{image.name}</> }} />
</div>
);
}
@ -125,7 +128,7 @@ function ChatImage({ image, allowImages }: { image: ChatImageAttachment; allowIm
role="link"
render={<a href={image.url} target="_blank" rel="noopener noreferrer" download={image.name} />}
>
Open image
{t("chat.messages.openImage")}
</Button>
</figure>
);
@ -154,6 +157,7 @@ interface UserBubbleProps {
}
function UserBubble({ message, allowImages, onEdit, isStreaming }: UserBubbleProps) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(message.content);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -204,7 +208,7 @@ function UserBubble({ message, allowImages, onEdit, isStreaming }: UserBubblePro
) : null}
<Textarea
ref={textareaRef}
aria-label="Edit message"
aria-label={t("chat.messages.editMessage")}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
@ -219,10 +223,10 @@ function UserBubble({ message, allowImages, onEdit, isStreaming }: UserBubblePro
setEditing(false);
}}
>
Cancel
{t("chat.messages.cancel")}
</Button>
<Button size="sm" onClick={handleSave} disabled={!editValue.trim() && !message.attachments?.length}>
Save & Send
{t("chat.messages.saveAndSend")}
</Button>
</div>
</div>
@ -241,7 +245,7 @@ function UserBubble({ message, allowImages, onEdit, isStreaming }: UserBubblePro
<Button
variant="ghost"
size="icon-xs"
aria-label="Edit message"
aria-label={t("chat.messages.editMessage")}
onClick={() => {
setEditValue(message.content);
setEditing(true);
@ -253,7 +257,7 @@ function UserBubble({ message, allowImages, onEdit, isStreaming }: UserBubblePro
}
/>
<TooltipContent>
<p>Edit message</p>
<p>{t("chat.messages.editMessage")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -289,6 +293,7 @@ function AssistantBubble({
mcpEvents,
actions,
}: AssistantBubbleProps) {
const { t } = useTranslation();
const [reasoningKey, setReasoningKey] = useState(0);
const prevStreamingRef = useRef<boolean>(isStreaming);
@ -308,7 +313,7 @@ function AssistantBubble({
return (
<div role="status" className="w-full max-w-sm space-y-3">
<Skeleton className="aspect-square w-full rounded-2xl" />
<p className="text-sm text-muted-foreground">Creating your image…</p>
<p className="text-sm text-muted-foreground">{t("chat.messages.creatingImage")}</p>
</div>
);
}
@ -316,7 +321,8 @@ function AssistantBubble({
<div className="flex flex-col items-start">
{message.research && (
<p role="status" className="text-sm text-muted-foreground">
Deep research{message.webSearchCount ? ` · ${message.webSearchCount} web searches` : "…"}
{t("chat.messages.deepResearch")}
{message.webSearchCount ? t("chat.messages.webSearches", { searches: message.webSearchCount }) : "…"}
</p>
)}
<div className="flex items-center gap-1 px-1 py-2.5">
@ -351,8 +357,8 @@ function AssistantBubble({
{mainContent}
</ReactMarkdown>
{message.citations && message.citations.length > 0 && (
<div aria-label="Sources" className="mt-4 border-t pt-3 text-sm">
<p className="font-medium">Sources</p>
<div aria-label={t("chat.messages.sources")} className="mt-4 border-t pt-3 text-sm">
<p className="font-medium">{t("chat.messages.sources")}</p>
<ol className="mt-2 list-decimal space-y-1 pl-5">
{message.citations
.filter((citation) => /^https?:\/\//i.test(citation.url))
@ -373,7 +379,7 @@ function AssistantBubble({
</ol>
</div>
)}
{stoppedSuffix && <span className="text-muted-foreground italic"> [stopped]</span>}
{stoppedSuffix && <span className="text-muted-foreground italic">{t("chat.messages.stopped")}</span>}
</BubbleContent>
{actions ?? (mainContent.trim() && <CopyButton text={mainContent} />)}
@ -393,6 +399,7 @@ function AssistantBubble({
}
function CopyButton({ text }: { text: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const handleCopy = () => {
@ -414,7 +421,7 @@ function CopyButton({ text }: { text: string }) {
<Button
variant="ghost"
size="icon-xs"
aria-label={copied ? "Copied message" : "Copy message"}
aria-label={copied ? t("chat.messages.copiedMessage") : t("chat.messages.copyMessage")}
onClick={handleCopy}
className={copied ? "text-success" : "text-muted-foreground hover:text-foreground"}
>
@ -423,7 +430,7 @@ function CopyButton({ text }: { text: string }) {
}
/>
<TooltipContent>
<p>{copied ? "Copied!" : "Copy"}</p>
<p>{copied ? t("chat.messages.copied") : t("chat.messages.copy")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -432,6 +439,7 @@ function CopyButton({ text }: { text: string }) {
}
function ThinkingPlaceholder() {
const { t } = useTranslation();
return (
<>
<style>{`
@ -444,7 +452,7 @@ function ThinkingPlaceholder() {
}
`}</style>
<div className="inline-flex items-center gap-1.5 px-2.5 mb-2 bg-muted/50 border rounded-lg text-xs text-muted-foreground">
<span className="chat-thinking-text py-1">Thinking...</span>
<span className="chat-thinking-text py-1">{t("chat.messages.thinking")}</span>
</div>
</>
);
@ -480,6 +488,7 @@ interface ToolCardProps {
}
function ToolCard({ message }: ToolCardProps) {
const { t } = useTranslation();
const redactedArgs = message.toolArgs ? redactSensitiveValues(message.toolArgs) : undefined;
const [open, setOpen] = useState(false);
@ -488,13 +497,13 @@ function ToolCard({ message }: ToolCardProps) {
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-1.5 text-[13px] px-3 py-2 border rounded-lg bg-muted/50 hover:bg-muted transition-colors w-full text-left">
<Wrench className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">{message.toolName ?? "Tool call"}</span>
<span className="font-medium text-foreground">{message.toolName ?? t("chat.messages.toolCall")}</span>
</CollapsibleTrigger>
<CollapsibleContent className="border border-t-0 rounded-b-lg px-3 py-2 bg-muted/30">
{redactedArgs !== undefined && (
<div className={message.toolResult ? "mb-3" : ""}>
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">
Arguments
{t("chat.messages.arguments")}
</div>
<pre className="m-0 p-2 bg-muted rounded-md text-xs font-mono whitespace-pre-wrap break-words text-foreground">
{JSON.stringify(redactedArgs, null, 2)}
@ -504,7 +513,7 @@ function ToolCard({ message }: ToolCardProps) {
{message.toolResult && (
<div>
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">
Result
{t("chat.messages.result")}
</div>
<div className="text-[13px] text-foreground whitespace-pre-wrap break-words font-mono">
{message.toolResult}

View file

@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, ImageIcon, MessageSquare, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -30,6 +31,7 @@ export default function ChatModelPicker({
disabled: boolean;
onSelect: (model: ModelGroup) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"all" | ChatMode>("all");
@ -51,7 +53,7 @@ export default function ChatModelPicker({
<PopoverTrigger
render={
<Button variant="ghost" disabled={disabled} className="moyai-chat-model-picker">
<span className="truncate">{selectedModel ?? "Choose a model"}</span>
<span className="truncate">{selectedModel ?? t("chat.modelPicker.chooseModel")}</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</Button>
}
@ -59,25 +61,25 @@ export default function ChatModelPicker({
<PopoverContent align="start" className="chat-theme w-[min(360px,calc(100vw-2rem))] p-0">
<div className="space-y-3 p-3">
<div>
<p className="text-sm font-medium">Model Hub</p>
<p className="text-xs text-muted-foreground">Choose a model to try it here.</p>
<p className="text-sm font-medium">{t("chat.modelPicker.title")}</p>
<p className="text-xs text-muted-foreground">{t("chat.modelPicker.subtitle")}</p>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<Input
aria-label="Search models"
placeholder="Search models or providers"
aria-label={t("chat.modelPicker.searchLabel")}
placeholder={t("chat.modelPicker.searchPlaceholder")}
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-8"
/>
</div>
<div className="flex gap-1" aria-label="Model type">
<div className="flex gap-1" aria-label={t("chat.modelPicker.modelType")}>
{(
[
["all", "All"],
["chat", "Chat"],
["image", "Images"],
["all", t("chat.modelPicker.filters.all")],
["chat", t("chat.modelPicker.filters.chat")],
["image", t("chat.modelPicker.filters.images")],
] as const
).map(([value, label]) => (
<Button
@ -93,7 +95,9 @@ export default function ChatModelPicker({
</div>
</div>
<ScrollArea className="h-[min(320px,50dvh)] border-t p-1">
{visibleModels.length === 0 && <p className="p-4 text-sm text-muted-foreground">No models found.</p>}
{visibleModels.length === 0 && (
<p className="p-4 text-sm text-muted-foreground">{t("chat.modelPicker.noModels")}</p>
)}
{visibleModels.map((model) => {
const image = modelChatMode(model) === "image";
const Icon = image ? ImageIcon : MessageSquare;
@ -108,7 +112,10 @@ export default function ChatModelPicker({
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm">{model.model_group}</span>
<span className="block truncate text-xs font-normal text-muted-foreground">
{[image ? "Image generation" : "Chat", ...(model.providers ?? [])].join(" · ")}
{[
image ? t("chat.modelPicker.kind.image") : t("chat.modelPicker.kind.chat"),
...(model.providers ?? []),
].join(" · ")}
</span>
</span>
{selectedModel === model.model_group && <Check className="size-4 shrink-0 text-primary" />}

View file

@ -22,6 +22,7 @@ import {
LogOut,
} from "lucide-react";
import { useMediaQuery } from "usehooks-ts";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import {
@ -78,6 +79,7 @@ function NavItem({ icon, label, onClick, active = false }: NavItemProps) {
}
const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { t } = useTranslation();
const router = useRouter();
const pathname = stripTrailingSlash(usePathname() ?? "");
const {
@ -101,12 +103,12 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const darkLogo = logoUrlDark || logoUrl;
const routes = getChatRoutes();
const isChatsRoute = pathname === routes.chats;
const accountName = userEmail?.split("@")[0] || userId || "Account";
const accountName = userEmail?.split("@")[0] || userId || t("chat.shell.accountFallback");
const workspaceItems = [
{ label: "Credentials", route: routes.credentials, Icon: KeyRound },
{ label: "API Keys", route: routes.apiKeys, Icon: Lock },
{ label: "Logs", route: routes.logs, Icon: ScrollText },
{ label: "Usage", route: routes.usage, Icon: BarChart3 },
{ label: t("chat.shell.workspaceItems.credentials"), route: routes.credentials, Icon: KeyRound },
{ label: t("chat.shell.workspaceItems.apiKeys"), route: routes.apiKeys, Icon: Lock },
{ label: t("chat.shell.workspaceItems.logs"), route: routes.logs, Icon: ScrollText },
{ label: t("chat.shell.workspaceItems.usage"), route: routes.usage, Icon: BarChart3 },
];
const workspaceActive = workspaceItems.some((item) => item.route === pathname);
const navigate = (href: string) => {
@ -123,8 +125,8 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
variant="ghost"
size="icon-sm"
className="moyai-chat-search"
aria-label="Search chats"
title="Search chats (⌘K)"
aria-label={t("chat.shell.searchChats")}
title={t("chat.shell.searchChatsShortcut")}
onClick={() => setSearchOpen(true)}
>
<Search className="size-4" />
@ -132,7 +134,7 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
<Button
variant="ghost"
size="icon-sm"
aria-label={collapsed ? "Open sidebar" : "Close sidebar"}
aria-label={collapsed ? t("chat.shell.openSidebar") : t("chat.shell.closeSidebar")}
aria-expanded={!collapsed}
onClick={() => (isDesktop ? setSidebarCollapsed(!sidebarCollapsed) : setSidebarOpen(false))}
>
@ -145,24 +147,24 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
className="chat-new-conversation moyai-chat-rail-button w-full justify-start"
>
<Plus className="size-4" />
New Chat
{t("chat.shell.newChat")}
</Button>
<nav aria-label="Chat workspace" className="moyai-chat-destinations">
<nav aria-label={t("chat.shell.workspaceNav")} className="moyai-chat-destinations">
<NavItem
icon={<MessageSquare className="size-4" />}
label="Chats"
label={t("chat.shell.chats")}
onClick={() => navigate(routes.chats)}
active={isChatsRoute}
/>
<NavItem
icon={<Network className="size-4" />}
label="Models"
label={t("chat.shell.models")}
onClick={() => navigate(routes.models)}
active={pathname === routes.models}
/>
<NavItem
icon={<LayoutGrid className="size-4" />}
label="Integrations"
label={t("chat.shell.integrations")}
onClick={() => navigate(routes.integrations)}
active={pathname === routes.integrations}
/>
@ -177,7 +179,7 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
}
>
<Settings2 className="size-4" />
<span className="flex-1 text-left">Workspace</span>
<span className="flex-1 text-left">{t("chat.shell.workspace")}</span>
<ChevronDown className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="chat-theme min-w-44">
@ -198,7 +200,7 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
}
>
<Info className="size-4" />
Share feedback
{t("chat.shell.shareFeedback")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -219,7 +221,11 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
/>
</div>
{lightLogo && darkLogo && (
<div className="flex h-12 shrink-0 items-center px-1 pb-2" role="img" aria-label="Organization logo">
<div
className="flex h-12 shrink-0 items-center px-1 pb-2"
role="img"
aria-label={t("chat.shell.organizationLogo")}
>
<Image
src={lightLogo}
alt=""
@ -243,10 +249,15 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
{accountName}
</span>
<ThemeToggle />
<Button variant="ghost" size="icon-sm" aria-label="Settings" onClick={() => navigate(routes.credentials)}>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("chat.shell.settings")}
onClick={() => navigate(routes.credentials)}
>
<Settings className="size-4" />
</Button>
<Button variant="ghost" size="icon-sm" aria-label="Sign out" onClick={handleLogout}>
<Button variant="ghost" size="icon-sm" aria-label={t("chat.shell.signOut")} onClick={handleLogout}>
<LogOut className="size-4" />
</Button>
</footer>
@ -258,7 +269,7 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
className={`chat-theme chat-workspace moyai-chat-shell flex h-full w-full overflow-hidden text-foreground ${collapsed ? "moyai-chat-collapsed" : ""}`}
>
{isDesktop ? (
<aside aria-label="Chat navigation" className="chat-glass-sidebar moyai-chat-sidebar">
<aside aria-label={t("chat.shell.chatNavigation")} className="chat-glass-sidebar moyai-chat-sidebar">
{sidebar}
</aside>
) : (
@ -270,7 +281,7 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
variant="ghost"
size="icon-lg"
className="moyai-chat-mobile-trigger"
aria-label="Open chat navigation"
aria-label={t("chat.shell.openChatNavigation")}
/>
}
>
@ -284,8 +295,8 @@ const ChatShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
className="chat-theme chat-mobile-sidebar moyai-chat-sidebar"
>
<SheetHeader className="sr-only">
<SheetTitle>LiteLLM Chat</SheetTitle>
<SheetDescription>Conversations and chat workspace navigation</SheetDescription>
<SheetTitle>{t("chat.shell.sheetTitle")}</SheetTitle>
<SheetDescription>{t("chat.shell.sheetDescription")}</SheetDescription>
</SheetHeader>
{sidebar}
</SheetContent>

View file

@ -2,8 +2,11 @@
import React from "react";
import { CheckCircle } from "lucide-react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { getProxyBaseUrl, ConnectFlowStatus } from "@/components/networking";
import { OAuth2ConnectButton } from "@/components/chat/MCPAppsPanel";
import i18n from "@/i18n/i18n";
interface Props {
flowHandle: string;
@ -24,41 +27,45 @@ export function isLoopbackOrigin(origin: string | null): boolean {
}
}
const copyFor = (flow: ConnectFlowStatus | undefined, failed: boolean): readonly [string, string] => {
const clientLabel = flow?.client_origin ?? "the application";
const serverLabel = flow?.server_name ?? "the requested MCP server";
const copyFor = (
flow: ConnectFlowStatus | undefined,
failed: boolean,
t: TFunction = i18n.t.bind(i18n),
): readonly [string, string] => {
const clientLabel = flow?.client_origin ?? t("chat.connectFlowBanner.defaultClient");
const serverLabel = flow?.server_name ?? t("chat.connectFlowBanner.defaultServer");
const serverNames = flow?.servers?.map((server) => server.server_name).join(", ") ?? "";
if (failed || flow === undefined || flow.state === "stale") {
return [
"The connection cannot continue",
`The gateway could not validate this connection. Cancel to return to ${clientLabel}.`,
t("chat.connectFlowBanner.stale.title"),
t("chat.connectFlowBanner.stale.description", { client: clientLabel }),
];
}
if (flow.state === "unscoped") {
return [
`Connect your MCP servers to ${clientLabel}`,
`Authorize the servers you want to use below, then click Finish connecting to return to ${clientLabel}.`,
t("chat.connectFlowBanner.unscoped.title", { client: clientLabel }),
t("chat.connectFlowBanner.unscoped.description", { client: clientLabel }),
];
}
if (flow.state === "multi") {
const allServersConnected =
flow.servers !== null && flow.servers.length > 0 && flow.servers.every((server) => server.connected);
return [
`Allow ${clientLabel} to use ${serverNames}`,
t("chat.connectFlowBanner.multi.title", { client: clientLabel, servers: serverNames }),
allServersConnected
? `Click Finish connecting to give ${clientLabel} access to ${serverNames} as you.`
: `Authorize each requested server below to continue, or cancel to send ${clientLabel} away.`,
? t("chat.connectFlowBanner.multi.ready", { client: clientLabel, servers: serverNames })
: t("chat.connectFlowBanner.multi.pending", { client: clientLabel }),
];
}
if (flow.state === "interactive" && !flow.connected) {
return [
`Allow ${clientLabel} to use ${serverLabel}`,
`Authorize ${serverLabel} below to continue, or cancel to send ${clientLabel} away.`,
t("chat.connectFlowBanner.single.title", { client: clientLabel, server: serverLabel }),
t("chat.connectFlowBanner.single.pending", { client: clientLabel, server: serverLabel }),
];
}
return [
`Allow ${clientLabel} to use ${serverLabel}`,
`Click Finish connecting to give ${clientLabel} access to ${serverLabel} as you.`,
t("chat.connectFlowBanner.single.title", { client: clientLabel, server: serverLabel }),
t("chat.connectFlowBanner.single.ready", { client: clientLabel, server: serverLabel }),
];
};
@ -83,13 +90,14 @@ const vendorServersFor = (
};
const ConnectFlowBanner: React.FC<Props> = ({ flowHandle, flow, accessToken, onConnected, failed }) => {
const { t } = useTranslation();
const action = `${getProxyBaseUrl()}/authorize/complete`;
const state = failed || flow === undefined ? "stale" : flow.state;
const canFinish = canFinishConnectFlow(state, flow);
const canCancel = state !== "unscoped";
const loopbackClient = isLoopbackOrigin(flow?.client_origin ?? null);
const vendorServers = vendorServersFor(state, flow);
const copy = copyFor(flow, failed);
const copy = copyFor(flow, failed, t);
return (
<div className="mb-6 rounded-lg border border-primary/30 bg-primary/5 px-5 py-4">
@ -110,7 +118,11 @@ const ConnectFlowBanner: React.FC<Props> = ({ flowHandle, flow, accessToken, onC
onConnect={onConnected}
scopes={flow?.requested_scopes}
variant="button"
buttonLabel={state === "multi" ? `Connect ${server.server_name ?? server.server_id}` : undefined}
buttonLabel={
state === "multi"
? t("chat.connectFlowBanner.connectServer", { server: server.server_name ?? server.server_id })
: undefined
}
{...(state === "multi"
? {}
: { autoStartKey: `litellm-mcp-autostart:${flowHandle}:${server.server_id}` })}
@ -123,7 +135,7 @@ const ConnectFlowBanner: React.FC<Props> = ({ flowHandle, flow, accessToken, onC
type="submit"
className="h-[38px] rounded-md bg-primary px-4 text-sm font-semibold text-primary-foreground hover:bg-primary/90"
>
Finish connecting
{t("chat.connectFlowBanner.finish")}
</button>
)}
{canCancel && (
@ -133,13 +145,13 @@ const ConnectFlowBanner: React.FC<Props> = ({ flowHandle, flow, accessToken, onC
value="deny"
className="ml-2 h-[38px] rounded-md border px-4 text-sm font-semibold text-foreground hover:bg-accent/40"
>
Cancel
{t("chat.connectFlowBanner.cancel")}
</button>
)}
{loopbackClient && (
<label className="mt-2 flex items-center gap-2 text-[13px] text-muted-foreground">
<input type="checkbox" name="delivery" value="manual" />
My client is on a remote or SSH machine
{t("chat.connectFlowBanner.manualDelivery")}
</label>
)}
</form>

View file

@ -19,6 +19,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ScrollArea } from "@/components/ui/scroll-area";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import { Conversation } from "./types";
interface Props {
@ -72,6 +73,7 @@ interface ConversationRowProps {
}
const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSelect, onDelete, onRename }) => {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(conv.title);
const inputRef = useRef<HTMLInputElement | null>(null);
@ -121,7 +123,7 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
{editing ? (
<Input
ref={inputRef}
aria-label="Conversation title"
aria-label={t("chat.conversationList.conversationTitle")}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
@ -161,7 +163,7 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
onClick={startEditing}
variant="ghost"
size="icon-xs"
aria-label={`Rename ${conv.title}`}
aria-label={t("chat.conversationList.renameConversation", { title: conv.title })}
className="text-muted-foreground"
>
<Pencil className="h-3 w-3" />
@ -169,7 +171,7 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
}
/>
<TooltipContent side="bottom">
<p>Rename</p>
<p>{t("chat.conversationList.rename")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -184,7 +186,7 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
<Button
variant="ghost"
size="icon-xs"
aria-label={`Delete ${conv.title}`}
aria-label={t("chat.conversationList.deleteConversation", { title: conv.title })}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
@ -194,22 +196,24 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
}
/>
<TooltipContent side="bottom">
<p>Delete</p>
<p>{t("chat.conversationList.delete")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialogContent className="chat-theme">
<AlertDialogHeader>
<AlertDialogTitle>Delete this conversation?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone</AlertDialogDescription>
<AlertDialogTitle>{t("chat.conversationList.deleteConfirm.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("chat.conversationList.deleteConfirm.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("chat.conversationList.deleteConfirm.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(conv.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
{t("chat.conversationList.deleteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -229,6 +233,7 @@ interface SearchModalProps {
}
const SearchModal: React.FC<SearchModalProps> = ({ open, conversations, onSelect, onClose }) => {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [wasOpen, setWasOpen] = useState(open);
@ -249,12 +254,12 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, conversations, onSelect
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="chat-theme sm:max-w-[480px] p-4 gap-0">
<DialogTitle className="sr-only">Search conversations</DialogTitle>
<DialogTitle className="sr-only">{t("chat.conversationList.searchTitle")}</DialogTitle>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
autoFocus
aria-label="Search conversations"
aria-label={t("chat.conversationList.searchLabel")}
placeholder="Search conversations\u2026"
value={query}
onChange={(e) => setQuery(e.target.value)}
@ -264,7 +269,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, conversations, onSelect
<ScrollArea className="h-[320px]">
{filtered.length === 0 ? (
<div className="text-center py-6 text-muted-foreground text-sm">No conversations found</div>
<div className="text-center py-6 text-muted-foreground text-sm">{t("chat.conversationList.noResults")}</div>
) : (
filtered.map((conv) => {
return (
@ -299,6 +304,7 @@ const ConversationList: React.FC<Props> = ({
onSearchOpenChange,
hideSearchButton = false,
}) => {
const { t } = useTranslation();
const [localSearchOpen, setLocalSearchOpen] = useState(false);
const searchModalOpen = searchOpen ?? localSearchOpen;
const setSearchModalOpen = onSearchOpenChange ?? setLocalSearchOpen;
@ -331,7 +337,7 @@ const ConversationList: React.FC<Props> = ({
className="chat-nav-item w-full justify-start gap-2.5 text-muted-foreground hover:bg-sidebar-accent"
>
<Search className="size-4" />
Search chats
{t("chat.conversationList.searchChats")}
</Button>
</div>
)}
@ -340,17 +346,19 @@ const ConversationList: React.FC<Props> = ({
<div>
<div className="moyai-chat-history-heading flex items-center gap-2 text-xs text-sidebar-foreground">
<Clock3 className="size-3.5" aria-hidden="true" />
<span className="flex-1">Recent</span>
<span className="flex-1">{t("chat.conversationList.recent")}</span>
<span>0</span>
</div>
<p className="moyai-chat-empty-history text-xs text-muted-foreground">No chats here yet.</p>
<p className="moyai-chat-empty-history text-xs text-muted-foreground">
{t("chat.conversationList.empty")}
</p>
</div>
) : (
grouped.map(({ group, items }) => (
<div key={group} className="mb-2">
<div className="moyai-chat-history-heading flex items-center gap-2 px-2 pt-2 pb-1 text-xs font-normal text-sidebar-foreground">
<Clock3 className="size-3.5" aria-hidden="true" />
{group === "Recents" ? "Recent" : group}
{group === "Recents" ? t("chat.conversationList.recent") : group}
</div>
{items.map((conv) => (
<ConversationRow

View file

@ -4,6 +4,8 @@ import React, { useState } from "react";
import { Loader2, RefreshCw, KeyRound, Copy, Check } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CopyToClipboard } from "react-copy-to-clipboard";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -20,6 +22,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/cva.config";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import { keyListCall, regenerateKeyCall } from "../networking";
import { KeyResponse } from "../key_team_helpers/key_list";
@ -41,18 +44,18 @@ function maskKey(keyName: string | undefined): string {
return keyName.slice(0, 7) + "..." + keyName.slice(-4);
}
function relativeTime(isoString: string | null | undefined): string {
function relativeTime(isoString: string | null | undefined, t: TFunction = i18n.t.bind(i18n)): string {
if (!isoString) return "";
try {
const date = new Date(isoString);
const diffMs = Date.now() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return "just now";
if (diffSec < 60) return t("chat.keysPanel.relativeTime.justNow");
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 60) return t("chat.keysPanel.relativeTime.minutes", { minutes: diffMin });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return `${Math.floor(diffHr / 24)}d ago`;
if (diffHr < 24) return t("chat.keysPanel.relativeTime.hours", { hours: diffHr });
return t("chat.keysPanel.relativeTime.days", { days: Math.floor(diffHr / 24) });
} catch {
return "";
}
@ -68,6 +71,7 @@ interface FormState {
}
const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [rotateTarget, setRotateTarget] = useState<KeyResponse | null>(null);
const [regeneratedKey, setRegeneratedKey] = useState<string | null>(null);
@ -114,13 +118,13 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
const keyExpired = rotateTarget ? isKeyExpired(rotateTarget.expires) : false;
if (formState.duration && !DURATION_RE.test(formState.duration)) {
errors.duration = "Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo";
errors.duration = t("chat.keysPanel.validation.duration");
}
if (keyExpired && !formState.duration) {
errors.duration = "Expiration is required for expired keys";
errors.duration = t("chat.keysPanel.validation.expiredNeedsDuration");
}
if (formState.grace_period && !DURATION_RE.test(formState.grace_period)) {
errors.grace_period = "Must be a duration like 24h, 2d";
errors.grace_period = t("chat.keysPanel.validation.gracePeriod");
}
setFormErrors(errors);
@ -141,10 +145,10 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
const response = await regenerateKeyCall(accessToken, rotateTarget.token || rotateTarget.token_id, payload);
setRegeneratedKey(response.key);
toast.success("Key rotated successfully");
toast.success(t("chat.keysPanel.toast.rotated"));
queryClient.invalidateQueries({ queryKey: [KEYS_QUERY_KEY] });
} catch {
toast.error("Failed to rotate key");
toast.error(t("chat.keysPanel.toast.rotateFailed"));
} finally {
setIsRegenerating(false);
}
@ -171,11 +175,10 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
return (
<div className="w-full">
<div className="mb-4">
<h2 className="text-base font-semibold text-foreground mb-0.5">Your API Keys</h2>
<h2 className="text-base font-semibold text-foreground mb-0.5">{t("chat.keysPanel.title")}</h2>
<p className="text-sm text-muted-foreground m-0">
View your virtual keys and spend
{premiumUser &&
". Rotate keys to generate new credentials while optionally keeping the old key valid during a grace period"}
{t("chat.keysPanel.description")}
{premiumUser && t("chat.keysPanel.descriptionRotate")}
</p>
</div>
@ -184,12 +187,18 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-semibold uppercase tracking-wide">Key</TableHead>
<TableHead className={cn("text-xs font-semibold uppercase tracking-wide", NUMERIC_CELL_CLASS)}>
Spend
<TableHead className="text-xs font-semibold uppercase tracking-wide">
{t("chat.keysPanel.columns.key")}
</TableHead>
<TableHead className={cn("text-xs font-semibold uppercase tracking-wide", NUMERIC_CELL_CLASS)}>
{t("chat.keysPanel.columns.spend")}
</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wide">
{t("chat.keysPanel.columns.expires")}
</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wide">
{t("chat.keysPanel.columns.created")}
</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wide">Expires</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wide">Created</TableHead>
{premiumUser && (
<TableHead className="text-xs font-semibold uppercase tracking-wide text-right w-[80px]" />
)}
@ -223,19 +232,25 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
) : keys.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-12 border border-dashed rounded-lg">
<KeyRound className="h-6 w-6 mb-3 mx-auto text-muted-foreground/50" />
No keys found
{t("chat.keysPanel.noKeys")}
</div>
) : (
<div className="rounded-lg border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-semibold uppercase tracking-wide">Key</TableHead>
<TableHead className={cn("text-xs font-semibold uppercase tracking-wide", NUMERIC_CELL_CLASS)}>
Spend
<TableHead className="text-xs font-semibold uppercase tracking-wide">
{t("chat.keysPanel.columns.key")}
</TableHead>
<TableHead className={cn("text-xs font-semibold uppercase tracking-wide", NUMERIC_CELL_CLASS)}>
{t("chat.keysPanel.columns.spend")}
</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wide">
{t("chat.keysPanel.columns.expires")}
</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wide">
{t("chat.keysPanel.columns.created")}
</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wide">Expires</TableHead>
<TableHead className="text-xs font-semibold uppercase tracking-wide">Created</TableHead>
{premiumUser && (
<TableHead className="text-xs font-semibold uppercase tracking-wide text-right w-[80px]" />
)}
@ -258,21 +273,26 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
</TableCell>
<TableCell>
{!record.expires ? (
<span className="text-muted-foreground text-[13px]">Never</span>
<span className="text-muted-foreground text-[13px]">{t("chat.keysPanel.never")}</span>
) : (
<Badge variant={expired ? "destructive" : "outline"}>
{expired ? "Expired" : formatExpiresUtc(record.expires)}
{expired ? t("chat.keysPanel.expired") : formatExpiresUtc(record.expires)}
</Badge>
)}
</TableCell>
<TableCell className="text-muted-foreground text-[13px]">
{relativeTime(record.created_at)}
{relativeTime(record.created_at, t)}
</TableCell>
{premiumUser && (
<TableCell className="text-right">
<Button variant="outline" size="xs" onClick={() => openRotateModal(record)} title="Rotate key">
<Button
variant="outline"
size="xs"
onClick={() => openRotateModal(record)}
title={t("chat.keysPanel.rotateKey")}
>
<RefreshCw className="h-3 w-3" />
Rotate
{t("chat.keysPanel.rotate")}
</Button>
</TableCell>
)}
@ -287,15 +307,15 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
<Dialog open={!!rotateTarget} onOpenChange={(v) => !v && closeModal()}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Rotate Key</DialogTitle>
<DialogTitle>{t("chat.keysPanel.dialog.title")}</DialogTitle>
</DialogHeader>
{regeneratedKey ? (
<div>
<div className="rounded-lg border border-warning/20 bg-warning/10 px-3 py-2 text-sm text-warning mb-4">
Save this key now; you will not see it again
{t("chat.keysPanel.dialog.saveWarning")}
</div>
<div className="text-xs text-muted-foreground mb-1">New Key</div>
<div className="text-xs text-muted-foreground mb-1">{t("chat.keysPanel.dialog.newKey")}</div>
<div className="bg-muted border rounded-md px-4 py-3 font-mono text-sm break-all text-foreground">
{regeneratedKey}
</div>
@ -303,12 +323,12 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
) : (
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<Label>Key Alias</Label>
<Label>{t("chat.keysPanel.dialog.keyAlias")}</Label>
<Input value={formState.key_alias} disabled />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Max Budget (USD)</Label>
<Label>{t("chat.keysPanel.dialog.maxBudget")}</Label>
<Input
type="number"
step="0.01"
@ -317,7 +337,7 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>TPM Limit</Label>
<Label>{t("chat.keysPanel.dialog.tpmLimit")}</Label>
<Input
type="number"
value={formState.tpm_limit}
@ -325,7 +345,7 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>RPM Limit</Label>
<Label>{t("chat.keysPanel.dialog.rpmLimit")}</Label>
<Input
type="number"
value={formState.rpm_limit}
@ -335,23 +355,31 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label>Expire Key</Label>
<Label>{t("chat.keysPanel.dialog.expireKey")}</Label>
<Input
placeholder="e.g. 30s, 30h, 30d"
placeholder={t("chat.keysPanel.dialog.durationPlaceholder")}
value={formState.duration}
onChange={(e) => updateField("duration", e.target.value)}
/>
{formErrors.duration && <p className="text-xs text-destructive">{formErrors.duration}</p>}
<p className={`text-xs ${keyIsExpired ? "text-destructive" : "text-muted-foreground"}`}>
Current: {rotateTarget?.expires ? formatExpiresUtc(rotateTarget.expires) : "Never"}
{keyIsExpired && " (expired)"}
{t("chat.keysPanel.dialog.current", {
expiry: rotateTarget?.expires
? formatExpiresUtc(rotateTarget.expires)
: t("chat.keysPanel.never"),
})}
{keyIsExpired && t("chat.keysPanel.dialog.expiredSuffix")}
</p>
{newExpiryTime && <p className="text-xs text-success">New: {newExpiryTime}</p>}
{newExpiryTime && (
<p className="text-xs text-success">
{t("chat.keysPanel.dialog.newExpiry", { expiry: newExpiryTime })}
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label>Grace Period</Label>
<Label>{t("chat.keysPanel.dialog.gracePeriod")}</Label>
<Input
placeholder="e.g. 24h, 2d"
placeholder={t("chat.keysPanel.dialog.gracePeriodPlaceholder")}
value={formState.grace_period}
onChange={(e) => updateField("grace_period", e.target.value)}
/>
@ -365,19 +393,19 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
{regeneratedKey ? (
<>
<Button variant="outline" onClick={closeModal}>
Close
{t("chat.keysPanel.dialog.close")}
</Button>
<CopyToClipboard text={regeneratedKey} onCopy={() => setCopied(true)}>
<Button>
{copied ? <Check className="h-4 w-4 mr-1.5" /> : <Copy className="h-4 w-4 mr-1.5" />}
{copied ? "Copied" : "Copy Key"}
{copied ? t("chat.keysPanel.dialog.copied") : t("chat.keysPanel.dialog.copyKey")}
</Button>
</CopyToClipboard>
</>
) : (
<>
<Button variant="outline" onClick={closeModal}>
Cancel
{t("chat.keysPanel.dialog.cancel")}
</Button>
<Button onClick={handleRegenerate} disabled={isRegenerating}>
{isRegenerating ? (
@ -385,7 +413,7 @@ const KeysPanel: React.FC<Props> = ({ accessToken, userId, premiumUser }) => {
) : (
<RefreshCw className="h-4 w-4 mr-1.5" />
)}
Rotate
{t("chat.keysPanel.dialog.rotate")}
</Button>
</>
)}

View file

@ -4,6 +4,7 @@ import React, { useState } from "react";
import moment from "moment";
import { AlertCircle, ScrollText } from "lucide-react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { uiSpendLogDetailsCall, uiSpendLogsCall } from "../networking";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@ -84,18 +85,20 @@ function formatDuration(row: LogRow): string {
}
function StatusBadge({ status }: { status?: string }) {
const { t } = useTranslation();
const isFailure = status === "failure";
return (
<span className={`inline-flex items-center gap-1.5 text-xs ${isFailure ? "text-destructive" : "text-success"}`}>
<span className={`h-1.5 w-1.5 rounded-full ${isFailure ? "bg-destructive" : "bg-success"}`} />
{isFailure ? "Failure" : "Success"}
{isFailure ? t("chat.logsPanel.status.failure") : t("chat.logsPanel.status.success")}
</span>
);
}
function JsonBlock({ value }: { value: unknown }) {
const { t } = useTranslation();
if (value == null || value === "") {
return <p className="m-0 text-xs text-muted-foreground">Not available</p>;
return <p className="m-0 text-xs text-muted-foreground">{t("chat.logsPanel.notAvailable")}</p>;
}
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
return (
@ -123,38 +126,53 @@ function LogsSkeleton() {
}
function LogsEmpty() {
const { t } = useTranslation();
return (
<div className="rounded-lg border border-dashed py-12 text-center text-sm text-muted-foreground">
<ScrollText className="mx-auto mb-3 h-6 w-6 text-muted-foreground/50" />
No logs for this period
{t("chat.logsPanel.empty")}
</div>
);
}
function LogsError({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed py-12 text-center text-sm text-muted-foreground">
<AlertCircle className="h-6 w-6 text-destructive/70" />
Failed to load your logs
{t("chat.logsPanel.loadFailed")}
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
{t("chat.logsPanel.retry")}
</Button>
</div>
);
}
function LogsTable({ rows, onRowClick }: { rows: LogRow[]; onRowClick: (row: LogRow) => void }) {
const { t } = useTranslation();
return (
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-[11px] font-medium uppercase tracking-wide">Time</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide">Model</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide">Status</TableHead>
<TableHead className="text-right text-[11px] font-medium uppercase tracking-wide">Tokens</TableHead>
<TableHead className="text-right text-[11px] font-medium uppercase tracking-wide">Duration</TableHead>
<TableHead className="text-right text-[11px] font-medium uppercase tracking-wide">Cost</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide">
{t("chat.logsPanel.columns.time")}
</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide">
{t("chat.logsPanel.columns.model")}
</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide">
{t("chat.logsPanel.columns.status")}
</TableHead>
<TableHead className="text-right text-[11px] font-medium uppercase tracking-wide">
{t("chat.logsPanel.columns.tokens")}
</TableHead>
<TableHead className="text-right text-[11px] font-medium uppercase tracking-wide">
{t("chat.logsPanel.columns.duration")}
</TableHead>
<TableHead className="text-right text-[11px] font-medium uppercase tracking-wide">
{t("chat.logsPanel.columns.cost")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -191,39 +209,45 @@ function LogDetailDialog({
isLoading: boolean;
onClose: () => void;
}) {
const { t } = useTranslation();
return (
<Dialog open={!!log} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Request details</DialogTitle>
<DialogTitle>{t("chat.logsPanel.detail.title")}</DialogTitle>
<DialogDescription className="break-all font-mono text-xs">{log?.request_id}</DialogDescription>
</DialogHeader>
{log && (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md border bg-card p-3">
<div className="mb-0.5 text-xs text-muted-foreground">Model</div>
<div className="mb-0.5 text-xs text-muted-foreground">{t("chat.logsPanel.detail.model")}</div>
<div className="text-sm text-foreground">{log.model || "-"}</div>
</div>
<div className="rounded-md border bg-card p-3">
<div className="mb-0.5 text-xs text-muted-foreground">Cost</div>
<div className="mb-0.5 text-xs text-muted-foreground">{t("chat.logsPanel.detail.cost")}</div>
<div className="text-sm text-foreground">{formatCost(log.spend)}</div>
</div>
<div className="rounded-md border bg-card p-3">
<div className="mb-0.5 text-xs text-muted-foreground">Tokens</div>
<div className="mb-0.5 text-xs text-muted-foreground">{t("chat.logsPanel.detail.tokens")}</div>
<div className="text-sm text-foreground">
{formatTokens(log.total_tokens)} ({formatTokens(log.prompt_tokens)} in /{" "}
{formatTokens(log.completion_tokens)} out)
{t("chat.logsPanel.detail.tokenBreakdown", {
total: formatTokens(log.total_tokens),
prompt: formatTokens(log.prompt_tokens),
completion: formatTokens(log.completion_tokens),
})}
</div>
</div>
<div className="rounded-md border bg-card p-3">
<div className="mb-0.5 text-xs text-muted-foreground">Duration</div>
<div className="mb-0.5 text-xs text-muted-foreground">{t("chat.logsPanel.detail.duration")}</div>
<div className="text-sm text-foreground">{formatDuration(log)}</div>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Request</div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chat.logsPanel.detail.request")}
</div>
{isLoading ? (
<Skeleton className="h-16 w-full" />
) : (
@ -231,7 +255,9 @@ function LogDetailDialog({
)}
</div>
<div className="flex flex-col gap-1.5">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Response</div>
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chat.logsPanel.detail.response")}
</div>
{isLoading ? <Skeleton className="h-16 w-full" /> : <JsonBlock value={details?.response} />}
</div>
</div>
@ -242,6 +268,7 @@ function LogDetailDialog({
}
const LogsPanel: React.FC<Props> = ({ accessToken, userId }) => {
const { t } = useTranslation();
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
const [page, setPage] = useState(1);
const [selectedLog, setSelectedLog] = useState<LogRow | null>(null);
@ -287,16 +314,16 @@ const LogsPanel: React.FC<Props> = ({ accessToken, userId }) => {
<LogsTable rows={rows} onRowClick={setSelectedLog} />
<div className="mt-3 flex items-center justify-between">
<p className="m-0 text-xs text-muted-foreground">
{total.toLocaleString()} request{total === 1 ? "" : "s"}
{totalPages > 1 ? ` · Page ${page} of ${totalPages}` : ""}
{t("chat.logsPanel.requestCount", { count: total, total: total.toLocaleString() })}
{totalPages > 1 ? t("chat.logsPanel.pageOf", { page, totalPages }) : ""}
</p>
{totalPages > 1 && (
<div className="flex gap-1">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Previous
{t("chat.logsPanel.previous")}
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Next
{t("chat.logsPanel.next")}
</Button>
</div>
)}
@ -309,8 +336,8 @@ const LogsPanel: React.FC<Props> = ({ accessToken, userId }) => {
<div className="w-full">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="mb-0.5 text-base font-semibold tracking-tight text-foreground">Your Logs</h2>
<p className="m-0 text-sm text-muted-foreground">Request logs for your account only</p>
<h2 className="mb-0.5 text-base font-semibold tracking-tight text-foreground">{t("chat.logsPanel.title")}</h2>
<p className="m-0 text-sm text-muted-foreground">{t("chat.logsPanel.subtitle")}</p>
</div>
<div className="flex gap-1">
{TIME_RANGE_OPTIONS.map((opt) => (

View file

@ -2,6 +2,7 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Search, ArrowLeft, ChevronRight, Wrench, CheckCircle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -21,6 +22,7 @@ import {
isUnsupportedOnGatewayConnect,
} from "../mcp_tools/types";
import { Logo } from "@/components/molecules/logo/Logo";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import { useUserMcpOAuthFlow } from "@/hooks/useUserMcpOAuthFlow";
import { getSecureItem, setSecureItem } from "@/utils/secureStorage";
@ -42,8 +44,9 @@ export const OAuth2ConnectButton: React.FC<OAuth2ConnectButtonProps> = ({
variant = "badge",
autoStartKey = null,
scopes,
buttonLabel = "Connect",
buttonLabel = i18n.t("chat.mcpAppsPanel.connect"),
}) => {
const { t } = useTranslation();
const name = server.server_name ?? server.alias ?? server.server_id;
const flowOptions = {
accessToken,
@ -66,7 +69,7 @@ export const OAuth2ConnectButton: React.FC<OAuth2ConnectButtonProps> = ({
return (
<Button onClick={startOAuthFlow} disabled={loading} className="font-semibold h-[38px] min-w-[110px]">
{loading && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
{loading ? "Connecting\u2026" : buttonLabel}
{loading ? t("chat.mcpAppsPanel.connecting") : buttonLabel}
</Button>
);
}
@ -83,7 +86,7 @@ export const OAuth2ConnectButton: React.FC<OAuth2ConnectButtonProps> = ({
: "text-primary-foreground bg-primary cursor-pointer hover:bg-primary/90"
}`}
>
{loading ? "Connecting\u2026" : "Connect"}
{loading ? t("chat.mcpAppsPanel.connecting") : t("chat.mcpAppsPanel.connect")}
</span>
);
};
@ -119,6 +122,7 @@ type TabKey = "all" | "connected";
const TOOLS_FETCH_CONCURRENCY = 5;
const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange, connectMode }) => {
const { t } = useTranslation();
const [servers, setServers] = useState<MCPServer[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState("");
@ -151,10 +155,10 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
const connectUnavailabilityLabel = useCallback(
(s: MCPServer): string | null => {
if (!connectMode) return null;
if (isUnsupportedOnGatewayConnect(s.auth_type)) return "Not supported on this connection";
if (isUnsupportedOnGatewayConnect(s.auth_type)) return t("chat.mcpAppsPanel.notSupported");
return null;
},
[connectMode],
[connectMode, t],
);
const connectableNow = useCallback(
@ -270,7 +274,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
try {
const result = await listMCPTools(accessToken, server.server_id);
if (result?.error) {
toast.warning(`Could not load tools for ${serverName}`);
toast.warning(t("chat.mcpAppsPanel.toolsLoadFailed", { serverName }));
return;
}
if (connectableNow(server.server_id) === undefined) return;
@ -278,7 +282,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
onChange([...selectedServersRef.current, serverName]);
}
} catch {
toast.warning(`Could not load tools for ${serverName}`);
toast.warning(t("chat.mcpAppsPanel.toolsLoadFailed", { serverName }));
} finally {
setTogglingOn((prev) => {
const next = new Set(prev);
@ -344,11 +348,11 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
const emptyStateText = () => {
if (servers.length === 0) {
return connectMode
? "No MCP servers are available to this connection yet. Ask an admin to grant your user or team access."
: "No MCP servers configured. Add servers in Tools -> MCP Servers.";
return connectMode ? t("chat.mcpAppsPanel.empty.connectMode") : t("chat.mcpAppsPanel.empty.noServers");
}
return activeTab === "connected" ? "No servers connected yet." : "No servers match your search.";
return activeTab === "connected"
? t("chat.mcpAppsPanel.empty.noneConnected")
: t("chat.mcpAppsPanel.empty.noMatch");
};
const totalTools = Object.values(toolCounts).reduce((sum, n) => sum + n, 0);
@ -364,7 +368,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
return <span className="text-[13px] text-muted-foreground py-2.5 shrink-0">{unavailabilityLabel}</span>;
}
if (getMcpOAuthMode(detailServer) === "m2m") {
return <span className="text-[13px] text-muted-foreground">Authorized</span>;
return <span className="text-[13px] text-muted-foreground">{t("chat.mcpAppsPanel.authorized")}</span>;
}
if (getMcpOAuthMode(detailServer) !== "authorization_code") {
return (
@ -375,7 +379,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
className="font-semibold h-[38px] min-w-[110px]"
>
{isTogglingOn && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
{isConnected ? "Disconnect" : "Connect"}
{isConnected ? t("chat.mcpAppsPanel.disconnect") : t("chat.mcpAppsPanel.connect")}
</Button>
);
}
@ -398,7 +402,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
}}
className="font-semibold h-[38px] min-w-[110px]"
>
Disconnect
{t("chat.mcpAppsPanel.disconnect")}
</Button>
);
}
@ -423,7 +427,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
className="-ml-3 mb-5 gap-1.5 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3 w-3" />
Back
{t("chat.mcpAppsPanel.back")}
</Button>
<div className="flex items-start gap-5 mb-7">
@ -443,17 +447,22 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
)}
<div className="flex-1">
<h2 className="m-0 mb-1 text-[22px] font-bold text-foreground">{name}</h2>
<p className="m-0 text-sm text-muted-foreground">{detailServer.description ?? "MCP server"}</p>
<p className="m-0 text-sm text-muted-foreground">
{detailServer.description ?? t("chat.mcpAppsPanel.defaultDescription")}
</p>
</div>
{renderDetailAction()}
</div>
<h3 className="m-0 mb-3 text-[15px] font-semibold text-foreground">Information</h3>
<h3 className="m-0 mb-3 text-[15px] font-semibold text-foreground">{t("chat.mcpAppsPanel.information")}</h3>
<div className="border rounded-lg overflow-hidden mb-7">
{[
["Server ID", detailServer.server_id],
["Transport", handleTransport(detailServer.transport, detailServer.spec_path)],
["Status", isConnected ? "Connected" : "Not connected"],
[t("chat.mcpAppsPanel.info.serverId"), detailServer.server_id],
[t("chat.mcpAppsPanel.info.transport"), handleTransport(detailServer.transport, detailServer.spec_path)],
[
t("chat.mcpAppsPanel.info.status"),
isConnected ? t("chat.mcpAppsPanel.info.connected") : t("chat.mcpAppsPanel.info.notConnected"),
],
]
.filter(([, v]) => v)
.map(([label, value], i, arr) => (
@ -465,7 +474,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
</div>
<div className="flex items-center gap-2 mb-3">
<h3 className="m-0 text-[15px] font-semibold text-foreground">Available Tools</h3>
<h3 className="m-0 text-[15px] font-semibold text-foreground">{t("chat.mcpAppsPanel.availableTools")}</h3>
{!loadingTools && (
<span className="text-[11px] font-semibold text-muted-foreground bg-muted rounded px-1.5 py-0.5">
{detailTools.length}
@ -482,7 +491,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
))}
</div>
) : detailTools.length === 0 ? (
<div className="text-muted-foreground text-[13px] py-2">No tools available</div>
<div className="text-muted-foreground text-[13px] py-2">{t("chat.mcpAppsPanel.noTools")}</div>
) : (
<div className="flex flex-col gap-2">
{detailTools.map((tool) => (
@ -505,27 +514,27 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
<div className="flex items-center justify-between mb-5 gap-4 flex-wrap">
<div>
<div className="flex items-center gap-2 mb-1">
<h2 className="m-0 text-lg font-semibold text-foreground">MCP Servers</h2>
<h2 className="m-0 text-lg font-semibold text-foreground">{t("chat.mcpAppsPanel.title")}</h2>
{!connectMode && (
<span className="text-[10px] font-semibold text-primary bg-primary/10 rounded px-1.5 py-0.5 uppercase tracking-wider">
Beta
{t("chat.mcpAppsPanel.beta")}
</span>
)}
</div>
{connectMode ? (
<p className="m-0 text-[13px] text-muted-foreground">Click a server to see its tools and connect</p>
<p className="m-0 text-[13px] text-muted-foreground">{t("chat.mcpAppsPanel.connectModeSubtitle")}</p>
) : (
<div className="flex items-center gap-3">
<p className="m-0 text-[13px] text-muted-foreground">Browse tools, authenticate once, use in chat</p>
<p className="m-0 text-[13px] text-muted-foreground">{t("chat.mcpAppsPanel.subtitle")}</p>
{loadingCounts ? (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Loading tools...
{t("chat.mcpAppsPanel.loadingTools")}
</span>
) : totalTools > 0 ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Wrench className="h-3 w-3" />
{totalTools} tool{totalTools !== 1 ? "s" : ""} available
{t("chat.mcpAppsPanel.toolsAvailable", { count: totalTools })}
</span>
) : null}
</div>
@ -534,7 +543,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
<div className="relative w-[220px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search servers..."
placeholder={t("chat.mcpAppsPanel.searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 text-[13px] h-9"
@ -545,10 +554,11 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabKey)} className="mb-4">
<TabsList variant="line" className="border-b rounded-none w-full justify-start h-auto p-0">
<TabsTrigger value="all" className="rounded-none px-4 py-2 text-[13px]">
All
{t("chat.mcpAppsPanel.tabs.all")}
</TabsTrigger>
<TabsTrigger value="connected" className="rounded-none px-4 py-2 text-[13px]">
Connected{connectedCount > 0 ? ` (${connectedCount})` : ""}
{t("chat.mcpAppsPanel.tabs.connected")}
{connectedCount > 0 ? ` (${connectedCount})` : ""}
</TabsTrigger>
</TabsList>
</Tabs>
@ -606,7 +616,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange,
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">{name}</div>
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1.5">
<span className="truncate">{server.description ?? "MCP server"}</span>
<span className="truncate">{server.description ?? t("chat.mcpAppsPanel.defaultDescription")}</span>
{count !== undefined ? (
count > 0 ? (
<span className="shrink-0 flex items-center gap-1 text-muted-foreground">

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "@/lib/toast";
@ -14,6 +15,7 @@ interface Props {
}
const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
const { t } = useTranslation();
const [servers, setServers] = useState<MCPServer[]>([]);
const [loadingServers, setLoadingServers] = useState(true);
const [togglingOn, setTogglingOn] = useState<Set<string>>(new Set());
@ -56,12 +58,12 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
try {
const result = await listMCPTools(accessToken, serverName);
if (result?.error) {
toast.warning(`Could not load tools for ${serverName} \u2014 it will be excluded from this message.`);
toast.warning(t("chat.mcpConnectPicker.toolsUnavailable", { serverName }));
return;
}
onChange([...selectedServers, serverName]);
} catch {
toast.warning(`Could not load tools for ${serverName} \u2014 it will be excluded from this message.`);
toast.warning(t("chat.mcpConnectPicker.toolsUnavailable", { serverName }));
} finally {
setTogglingOn((prev) => {
const next = new Set(prev);
@ -89,7 +91,9 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
))}
</div>
) : servers.length === 0 ? (
<div className="px-3 py-4 text-muted-foreground text-[13px] text-center">No MCP servers configured</div>
<div className="px-3 py-4 text-muted-foreground text-[13px] text-center">
{t("chat.mcpConnectPicker.noServers")}
</div>
) : (
servers.map((server) => {
const name = server.server_name ?? server.alias ?? server.server_id;

View file

@ -2,6 +2,8 @@
import React, { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { TFunction } from "i18next";
import { Trans, useTranslation } from "react-i18next";
import { Loader2, Trash2, Link } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -18,6 +20,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import { deleteMCPOAuthUserCredential, listMCPUserCredentials, MCPUserCredentialListItem } from "../networking";
@ -27,45 +30,49 @@ interface Props {
accessToken: string;
}
function relativeTime(isoString: string | null | undefined): string {
function relativeTime(isoString: string | null | undefined, t: TFunction = i18n.t.bind(i18n)): string {
if (!isoString) return "";
try {
const date = new Date(isoString);
const diffMs = Date.now() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return "just now";
if (diffSec < 60) return t("chat.mcpCredentialsTab.relativeTime.justNow");
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 60) return t("chat.mcpCredentialsTab.relativeTime.minutes", { minutes: diffMin });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return `${Math.floor(diffHr / 24)}d ago`;
if (diffHr < 24) return t("chat.mcpCredentialsTab.relativeTime.hours", { hours: diffHr });
return t("chat.mcpCredentialsTab.relativeTime.days", { days: Math.floor(diffHr / 24) });
} catch {
return "";
}
}
function expiryLabel(isoString: string | null | undefined): {
function expiryLabel(
isoString: string | null | undefined,
t: TFunction = i18n.t.bind(i18n),
): {
text: string;
variant: "secondary" | "destructive" | "outline";
} {
if (!isoString) return { text: "Does not expire", variant: "secondary" };
if (!isoString) return { text: t("chat.mcpCredentialsTab.expiry.never"), variant: "secondary" };
try {
const exp = new Date(isoString);
const diffMs = exp.getTime() - Date.now();
if (diffMs <= 0) return { text: "Expired", variant: "destructive" };
if (diffMs <= 0) return { text: t("chat.mcpCredentialsTab.expiry.expired"), variant: "destructive" };
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffDay > 0) return { text: `Expires in ${diffDay}d`, variant: "outline" };
if (diffHr > 0) return { text: `Expires in ${diffHr}h`, variant: "outline" };
return { text: `Expires in ${diffMin}m`, variant: "outline" };
if (diffDay > 0) return { text: t("chat.mcpCredentialsTab.expiry.days", { days: diffDay }), variant: "outline" };
if (diffHr > 0) return { text: t("chat.mcpCredentialsTab.expiry.hours", { hours: diffHr }), variant: "outline" };
return { text: t("chat.mcpCredentialsTab.expiry.minutes", { minutes: diffMin }), variant: "outline" };
} catch {
return { text: "", variant: "outline" };
}
}
const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [revoking, setRevoking] = useState<Set<string>>(new Set());
@ -83,7 +90,7 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
(prev ?? []).filter((c) => c.server_id !== serverId),
);
} catch {
toast.error("Failed to revoke connection. Please try again.");
toast.error(t("chat.mcpCredentialsTab.revokeFailed"));
} finally {
setRevoking((prev) => {
const n = new Set(prev);
@ -98,8 +105,8 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
return (
<div className="w-full">
<div className="mb-4">
<h2 className="text-base font-semibold text-foreground mb-0.5">App Credentials</h2>
<p className="text-sm text-muted-foreground m-0">Your stored OAuth connections; used automatically in chat</p>
<h2 className="text-base font-semibold text-foreground mb-0.5">{t("chat.mcpCredentialsTab.title")}</h2>
<p className="text-sm text-muted-foreground m-0">{t("chat.mcpCredentialsTab.description")}</p>
</div>
{loading ? (
@ -108,16 +115,16 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
App
{t("chat.mcpCredentialsTab.columns.app")}
</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Connected
{t("chat.mcpCredentialsTab.columns.connected")}
</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Status
{t("chat.mcpCredentialsTab.columns.status")}
</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground text-right">
Actions
{t("chat.mcpCredentialsTab.columns.actions")}
</TableHead>
</TableRow>
</TableHeader>
@ -144,10 +151,12 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
) : credentials.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-12 border border-dashed rounded-lg">
<Link className="h-6 w-6 mb-3 mx-auto text-muted-foreground/50" />
<p className="m-0">No connections yet</p>
<p className="m-0">{t("chat.mcpCredentialsTab.emptyTitle")}</p>
<p className="m-0 mt-1 text-xs">
Go to <span className="font-medium">Integrations</span> and click{" "}
<span className="font-medium">Connect</span> to authorize an MCP server
<Trans
i18nKey="chat.mcpCredentialsTab.emptyHint"
components={{ integrations: <span className="font-medium" />, connect: <span className="font-medium" /> }}
/>
</p>
</div>
) : (
@ -156,28 +165,28 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
App
{t("chat.mcpCredentialsTab.columns.app")}
</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Connected
{t("chat.mcpCredentialsTab.columns.connected")}
</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Status
{t("chat.mcpCredentialsTab.columns.status")}
</TableHead>
<TableHead className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground text-right">
Actions
{t("chat.mcpCredentialsTab.columns.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{credentials.map((cred) => {
const isRevoking = revoking.has(cred.server_id);
const exp = expiryLabel(cred.expires_at);
const exp = expiryLabel(cred.expires_at, t);
return (
<TableRow key={cred.server_id}>
<TableCell className="text-sm font-medium">{displayName(cred)}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{relativeTime(cred.connected_at) || "\u2014"}
{relativeTime(cred.connected_at, t) || "\u2014"}
</TableCell>
<TableCell>
<Badge variant={exp.variant}>{exp.text}</Badge>
@ -190,7 +199,7 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
variant="outline"
size="icon-sm"
disabled={isRevoking}
title="Revoke connection"
title={t("chat.mcpCredentialsTab.revokeConnection")}
className="text-muted-foreground hover:text-destructive hover:border-destructive/50"
>
{isRevoking ? (
@ -203,16 +212,18 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke connection?</AlertDialogTitle>
<AlertDialogTitle>{t("chat.mcpCredentialsTab.revokeConfirm.title")}</AlertDialogTitle>
<AlertDialogDescription>
This removes the stored OAuth credential for {displayName(cred)}. You&apos;ll need to
reconnect to use it in chat again.
<Trans
i18nKey="chat.mcpCredentialsTab.revokeConfirm.description"
components={{ server: <>{displayName(cred)}</> }}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("chat.mcpCredentialsTab.revokeConfirm.cancel")}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={() => handleRevoke(cred.server_id)}>
Revoke
{t("chat.mcpCredentialsTab.revokeConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -3,6 +3,7 @@
import React, { useState } from "react";
import { BarChart3 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { dailyActivityAggregatedCall } from "../networking";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@ -90,6 +91,7 @@ const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [
];
const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
const { t } = useTranslation();
const [timeRange, setTimeRange] = useState<TimeRange>("30d");
const { start, end } = getDateRange(timeRange);
@ -117,20 +119,26 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
const statCards: Array<{ label: string; value: string; sub?: string; subVariant?: "error" }> = meta
? [
{ label: "Total Spend", value: `$${meta.total_spend.toFixed(2)}` },
{ label: "API Requests", value: formatNumber(meta.total_api_requests) },
{ label: t("chat.usagePanel.totalSpend"), value: `$${meta.total_spend.toFixed(2)}` },
{ label: t("chat.usagePanel.apiRequests"), value: formatNumber(meta.total_api_requests) },
{
label: "Tokens Used",
label: t("chat.usagePanel.tokensUsed"),
value: formatNumber(meta.total_tokens),
sub: `${formatNumber(meta.total_prompt_tokens)} in / ${formatNumber(meta.total_completion_tokens)} out`,
sub: t("chat.usagePanel.tokenSplit", {
prompt: formatNumber(meta.total_prompt_tokens),
completion: formatNumber(meta.total_completion_tokens),
}),
},
{
label: "Success Rate",
label: t("chat.usagePanel.successRate"),
value:
meta.total_api_requests > 0
? `${((meta.total_successful_requests / meta.total_api_requests) * 100).toFixed(1)}%`
: "N/A",
sub: meta.total_failed_requests > 0 ? `${meta.total_failed_requests} failed` : undefined,
: t("chat.usagePanel.notApplicable"),
sub:
meta.total_failed_requests > 0
? t("chat.usagePanel.failedCount", { failed: meta.total_failed_requests })
: undefined,
subVariant: meta.total_failed_requests > 0 ? "error" : undefined,
},
]
@ -140,8 +148,8 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
<div className="w-full">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-base font-semibold text-foreground mb-0.5">Your Usage</h2>
<p className="text-sm text-muted-foreground m-0">Spend and request activity</p>
<h2 className="text-base font-semibold text-foreground mb-0.5">{t("chat.usagePanel.title")}</h2>
<p className="text-sm text-muted-foreground m-0">{t("chat.usagePanel.subtitle")}</p>
</div>
<div className="flex gap-1">
{TIME_RANGE_OPTIONS.map((opt) => (
@ -169,7 +177,7 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
) : !meta || meta.total_api_requests === 0 ? (
<div className="text-center text-muted-foreground text-sm py-12 border border-dashed rounded-lg">
<BarChart3 className="h-6 w-6 mb-3 mx-auto text-muted-foreground/50" />
No usage data for this period
{t("chat.usagePanel.empty")}
</div>
) : (
<div>
@ -194,11 +202,11 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
{dailyData.length > 1 && (
<div className="grid grid-cols-2 gap-3">
<div className="border rounded-lg p-4 bg-card">
<div className="text-xs text-muted-foreground mb-2">Daily Spend</div>
<div className="text-xs text-muted-foreground mb-2">{t("chat.usagePanel.dailySpend")}</div>
<SparklineBar data={dailySpend} maxVal={maxSpend} />
</div>
<div className="border rounded-lg p-4 bg-card">
<div className="text-xs text-muted-foreground mb-2">Daily Requests</div>
<div className="text-xs text-muted-foreground mb-2">{t("chat.usagePanel.dailyRequests")}</div>
<SparklineBar data={dailyRequests} maxVal={maxRequests} />
</div>
</div>

View file

@ -1,5 +1,7 @@
import OpenAI from "openai";
import type { Response } from "openai/resources/responses/responses";
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import { getGatewayBaseUrl } from "@/components/networking";
import { parseUsageCost } from "@/components/llm_calls/usage_cost";
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
@ -15,7 +17,10 @@ type TimedResponse = Response & {
};
};
export function backgroundResponseUpdate(response: TimedResponse): AssistantMessageUpdate {
export function backgroundResponseUpdate(
response: TimedResponse,
t: TFunction = i18n.t.bind(i18n),
): AssistantMessageUpdate {
const output = response.output ?? [];
const citations = Array.from(
new Map(
@ -67,8 +72,8 @@ export function backgroundResponseUpdate(response: TimedResponse): AssistantMess
});
const status = response.status ?? "in_progress";
const errors: Partial<Record<ChatBackgroundRun["status"], string>> = {
failed: response.error?.message || "The background run failed.",
incomplete: "The run ended before completing its answer.",
failed: response.error?.message || t("chat.backgroundChat.failed"),
incomplete: t("chat.backgroundChat.incomplete"),
};
const backgroundError = errors[status];
const cost = response.usage && "cost" in response.usage ? parseUsageCost(response.usage.cost) : undefined;
@ -118,23 +123,26 @@ function waitForPoll(signal: AbortSignal): Promise<void> {
});
}
export async function watchBackgroundChat({
accessToken,
responseId,
signal,
onUpdate,
}: {
accessToken: string;
responseId: string;
signal: AbortSignal;
onUpdate: (update: AssistantMessageUpdate) => void;
}): Promise<void> {
export async function watchBackgroundChat(
{
accessToken,
responseId,
signal,
onUpdate,
}: {
accessToken: string;
responseId: string;
signal: AbortSignal;
onUpdate: (update: AssistantMessageUpdate) => void;
},
t: TFunction = i18n.t.bind(i18n),
): Promise<void> {
const client = backgroundClient(accessToken);
while (!signal.aborted) {
try {
const response = await client.responses.retrieve(responseId, { stream: false }, { signal });
if (signal.aborted) return;
onUpdate(backgroundResponseUpdate(response));
onUpdate(backgroundResponseUpdate(response, t));
if (!isBackgroundRunPending({ id: response.id, status: response.status ?? "in_progress" })) return;
await waitForPoll(signal);
} catch (error) {
@ -142,7 +150,7 @@ export async function watchBackgroundChat({
if (error instanceof OpenAI.APIError && error.status === 404) {
onUpdate({
backgroundRun: { id: responseId, status: "expired" },
backgroundError: "This saved run is no longer available. Its result may have expired on the server.",
backgroundError: t("chat.backgroundChat.expired"),
});
return;
}
@ -151,7 +159,11 @@ export async function watchBackgroundChat({
}
}
export async function cancelBackgroundChat(accessToken: string, responseId: string): Promise<AssistantMessageUpdate> {
export async function cancelBackgroundChat(
accessToken: string,
responseId: string,
t: TFunction = i18n.t.bind(i18n),
): Promise<AssistantMessageUpdate> {
const response = await backgroundClient(accessToken).responses.cancel(responseId);
return backgroundResponseUpdate(response);
return backgroundResponseUpdate(response, t);
}

View file

@ -1,6 +1,8 @@
import OpenAI from "openai";
import type { TFunction } from "i18next";
import { v4 as uuid } from "uuid";
import { getGatewayBaseUrl } from "@/components/networking";
import i18n from "@/i18n/i18n";
import type { ChatImageAttachment } from "./types";
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024;
@ -37,24 +39,27 @@ export async function readChatImage(file: File): Promise<ChatImageAttachment> {
return { id: uuid(), name: file.name, url, mimeType: file.type };
}
export async function generateChatImages({
prompt,
model,
accessToken,
signal,
}: {
prompt: string;
model: string;
accessToken: string;
signal?: AbortSignal;
}): Promise<ChatImageAttachment[]> {
export async function generateChatImages(
{
prompt,
model,
accessToken,
signal,
}: {
prompt: string;
model: string;
accessToken: string;
signal?: AbortSignal;
},
t: TFunction = i18n.t.bind(i18n),
): Promise<ChatImageAttachment[]> {
const client = new OpenAI({ apiKey: accessToken, baseURL: getGatewayBaseUrl(), dangerouslyAllowBrowser: true });
const response = await client.images.generate({ model, prompt }, { signal });
const mimeType = `image/${response.output_format ?? "png"}`;
const images = (response.data ?? []).flatMap((image, index): ChatImageAttachment[] => {
const url = image.b64_json ? `data:${mimeType};base64,${image.b64_json}` : image.url;
if (!url || !isChatImageUrl(url)) return [];
return [{ id: uuid(), name: `Generated image ${index + 1}`, url }];
return [{ id: uuid(), name: t("chat.imageHelpers.generatedImageName", { number: index + 1 }), url }];
});
if (!images.length) throw new Error("The model did not return an image. Try another prompt or image model.");
return images;

View file

@ -1,5 +1,6 @@
import type { ClipboardEventHandler, ReactNode } from "react";
import { Send, Square } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import "./moyai-chat.css";
@ -35,11 +36,12 @@ export default function MoyaiChatComposer({
modelPicker,
inConversation = false,
}: MoyaiChatComposerProps) {
const { t } = useTranslation();
const send = () => {
if (!submitDisabled && !isLoading) onSubmit();
};
const sendButton = isLoading ? (
<Button type="button" size="icon" aria-label="Stop request" onClick={onCancel}>
<Button type="button" size="icon" aria-label={t("chat.composer.stopRequest")} onClick={onCancel}>
<Square className="size-4" />
</Button>
) : (
@ -56,7 +58,7 @@ export default function MoyaiChatComposer({
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
aria-label="Message"
aria-label={t("chat.composer.message")}
onPaste={onPaste}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {

View file

@ -1,21 +1,28 @@
import type { ReactNode } from "react";
import { ArrowUpRight, Bug, Clock3, Code2, ListChecks } from "lucide-react";
import type { ParseKeys } from "i18next";
import { ArrowUpRight, Bug, Clock3, Code2, ListChecks, type LucideIcon } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import type { Conversation } from "../types";
import "./moyai-chat.css";
const starters = [
const starters: { label: ParseKeys; detail: ParseKeys; prompt: ParseKeys; Icon: LucideIcon }[] = [
{
label: "Review code",
detail: "Find the edge cases",
prompt: "Review my changes for bugs and improvements.",
label: "chat.moyaiHome.starters.review.label",
detail: "chat.moyaiHome.starters.review.detail",
prompt: "chat.moyaiHome.starters.review.prompt",
Icon: Code2,
},
{ label: "Fix a bug", detail: "Trace it to the source", prompt: "Help me investigate and fix this bug: ", Icon: Bug },
{
label: "Plan a feature",
detail: "Make the next change clear",
prompt: "Help me plan and implement this feature: ",
label: "chat.moyaiHome.starters.fixBug.label",
detail: "chat.moyaiHome.starters.fixBug.detail",
prompt: "chat.moyaiHome.starters.fixBug.prompt",
Icon: Bug,
},
{
label: "chat.moyaiHome.starters.planFeature.label",
detail: "chat.moyaiHome.starters.planFeature.detail",
prompt: "chat.moyaiHome.starters.planFeature.prompt",
Icon: ListChecks,
},
];
@ -39,6 +46,7 @@ export default function MoyaiChatHome({
onSelectConversation: (id: string) => void;
onPrompt: (prompt: string) => void;
}) {
const { t } = useTranslation();
const recent = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 3);
return (
<section className="moyai-chat-home">
@ -46,15 +54,15 @@ export default function MoyaiChatHome({
<div className="moyai-chat-home-content">
<header className="moyai-chat-home-header">
<h1>
{imageMode && "What are we creating?"}
{imageMode && t("chat.moyaiHome.headingImage")}
{!imageMode && researchMode && (
<>
What should we <span>research?</span>
<Trans i18nKey="chat.moyaiHome.headingResearch" components={{ highlight: <span /> }} />
</>
)}
{!imageMode && !researchMode && (
<>
What are we <span>working on?</span>
<Trans i18nKey="chat.moyaiHome.headingChat" components={{ highlight: <span /> }} />
</>
)}
</h1>
@ -62,11 +70,11 @@ export default function MoyaiChatHome({
{children}
<section className="moyai-chat-home-recent" aria-labelledby="recent-chats-heading">
<div className="moyai-chat-home-recent-header">
<h2 id="recent-chats-heading">Recent chats</h2>
<h2 id="recent-chats-heading">{t("chat.moyaiHome.recentChats")}</h2>
</div>
{recent.length === 0 && (
<p className="text-muted-foreground" role="status">
No recent chats in this browser yet.
{t("chat.moyaiHome.noRecentChats")}
</p>
)}
<ul>
@ -89,20 +97,20 @@ export default function MoyaiChatHome({
<div
className="moyai-chat-home-starters"
role="group"
aria-label="Start with a prompt"
aria-label={t("chat.moyaiHome.startersLabel")}
aria-hidden={Boolean(draft.trim())}
>
<p className="moyai-chat-home-starters-heading">Start with a prompt</p>
<p className="moyai-chat-home-starters-heading">{t("chat.moyaiHome.startersHeading")}</p>
{starters.map(({ label, detail, prompt, Icon }) => (
<Button
key={label}
variant="ghost"
disabled={busy || Boolean(draft.trim())}
onClick={() => onPrompt(prompt)}
onClick={() => onPrompt(t(prompt))}
>
<Icon />
<span className="moyai-chat-home-starter-label">{label}</span>
<span className="moyai-chat-home-starter-detail">{detail}</span>
<span className="moyai-chat-home-starter-label">{t(label)}</span>
<span className="moyai-chat-home-starter-detail">{t(detail)}</span>
<ArrowUpRight className="moyai-chat-home-starter-arrow" />
</Button>
))}

View file

@ -1,4 +1,6 @@
import OpenAI from "openai";
import type { TFunction } from "i18next";
import i18n from "@/i18n/i18n";
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
import { backgroundResponseUpdate } from "./backgroundChat";
import { generateChatImages } from "./imageHelpers";
@ -29,31 +31,35 @@ function requestErrorMessage(
error: unknown,
messages: ChatRequestOptions["messages"],
mode: ChatRequestOptions["mode"],
t: TFunction = i18n.t.bind(i18n),
): string {
const message = error instanceof Error ? error.message : "Something went wrong. Please try again.";
const message = error instanceof Error ? error.message : t("chat.runChatRequest.genericError");
const hasFile =
mode === "chat" && messages.some((item) => item.attachments?.some((attachment) => attachment.kind === "file"));
const fileRejectionStatus = error instanceof OpenAI.APIError && (error.status === 400 || error.status === 422);
if (hasFile && fileRejectionStatus) {
return message + " Try a model that supports file inputs, or paste the relevant text.";
return message + t("chat.runChatRequest.fileInputHint");
}
return message;
}
export async function runChatRequest({
model,
mode,
prompt,
accessToken,
signal,
messages,
previousResponseId,
background = false,
research = false,
mcpServers,
onResponseId,
onUpdate,
}: ChatRequestOptions): Promise<void> {
export async function runChatRequest(
{
model,
mode,
prompt,
accessToken,
signal,
messages,
previousResponseId,
background = false,
research = false,
mcpServers,
onResponseId,
onUpdate,
}: ChatRequestOptions,
t: TFunction = i18n.t.bind(i18n),
): Promise<void> {
let content = "";
let reasoningContent = "";
const mcpEvents: MCPEvent[] = [];
@ -61,7 +67,7 @@ export async function runChatRequest({
try {
signal.throwIfAborted();
if (research && !background) {
onUpdate({ content: "Enable background chats before starting deep research." });
onUpdate({ content: t("chat.runChatRequest.researchNeedsBackground") });
return;
}
if (mode === "image") {
@ -110,7 +116,7 @@ export async function runChatRequest({
background
? (response) => {
onResponseId(null);
onUpdate(backgroundResponseUpdate(response));
onUpdate(backgroundResponseUpdate(response, t));
}
: undefined,
research ? { instructions: RESEARCH_INSTRUCTIONS } : undefined,
@ -119,7 +125,7 @@ export async function runChatRequest({
} catch (error: unknown) {
onResponseId(null);
const stopped = signal.aborted || (error instanceof Error && error.name === "AbortError");
const errorMessage = requestErrorMessage(error, messages, mode);
const errorMessage = requestErrorMessage(error, messages, mode, t);
onUpdate({ content: stopped ? content + " [stopped]" : content || errorMessage });
} finally {
if (mcpEvents.length) onUpdate({ mcpEvents });

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { cancelBackgroundChat, isBackgroundRunPending, watchBackgroundChat } from "./backgroundChat";
import type { AssistantMessageUpdate, ChatBackgroundRun } from "./types";
@ -8,6 +9,7 @@ export function useBackgroundChat(
accessToken: string,
updateMessage: (conversationId: string, update: AssistantMessageUpdate) => void,
) {
const { t } = useTranslation();
const [failure, setFailure] = useState<{ responseId: string; retry: number; message: string } | null>(null);
const [retry, setRetry] = useState(0);
const [cancellingId, setCancellingId] = useState<string | null>(null);
@ -28,15 +30,15 @@ export function useBackgroundChat(
signal: controller.signal,
onUpdate: (update: AssistantMessageUpdate) => updateMessage(conversationId, update),
};
void watchBackgroundChat(polling).catch(() => {
void watchBackgroundChat(polling, t).catch(() => {
if (!controller.signal.aborted)
setFailure({ responseId, retry, message: "Connection lost. Your run may still be running on the server." });
setFailure({ responseId, retry, message: t("chat.useBackgroundChat.connectionLost") });
});
return () => {
controller.abort();
if (controllerRef.current === controller) controllerRef.current = null;
};
}, [conversationId, responseId, pending, accessToken, updateMessage, retry]);
}, [conversationId, responseId, pending, accessToken, updateMessage, retry, t]);
const reconnect = useCallback(() => setRetry((value) => value + 1), []);
@ -46,17 +48,17 @@ export function useBackgroundChat(
controller?.abort();
setCancellingId(responseId);
try {
const update = await cancelBackgroundChat(accessToken, responseId);
const update = await cancelBackgroundChat(accessToken, responseId, t);
updateMessage(conversationId, update);
if (controllerRef.current === controller) reconnect();
} catch {
if (controllerRef.current === controller) {
setFailure({ responseId, retry, message: "Could not stop the run. Reconnect and try Stop again." });
setFailure({ responseId, retry, message: t("chat.useBackgroundChat.stopFailed") });
}
} finally {
setCancellingId(null);
}
}, [conversationId, responseId, accessToken, cancelling, reconnect, retry, updateMessage]);
}, [conversationId, responseId, accessToken, cancelling, reconnect, retry, updateMessage, t]);
return { pending, connectionError, cancelling, reconnect, cancel };
}

View file

@ -1,5 +1,6 @@
import { v4 as uuid } from "uuid";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AssistantMessageUpdate, ChatMessage, Conversation } from "./types";
import {
deleteChatImages,
@ -75,6 +76,7 @@ export function useChatHistory(
renameConversation: (id: string, newTitle: string) => void;
setActiveConversationId: (id: string | null) => void;
} {
const { t } = useTranslation();
const [conversations, setConversations] = useState<Conversation[]>(
() => loadFromStorage(storageKeyFor(userId)).conversations,
);
@ -191,30 +193,35 @@ export function useChatHistory(
return id;
}, []);
const appendMessage = useCallback((conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => {
const newMessage: ChatMessage = {
...message,
id: uuid(),
timestamp: Date.now(),
};
const appendMessage = useCallback(
(conversationId: string, message: Omit<ChatMessage, "id" | "timestamp">) => {
const newMessage: ChatMessage = {
...message,
id: uuid(),
timestamp: Date.now(),
};
setConversations((prev) => {
const updated = prev.map((conv) => {
if (conv.id !== conversationId) return conv;
const updatedMessages = [...conv.messages, newMessage];
let title = conv.title;
if (
title === "New conversation" &&
newMessage.role === "user" &&
conv.messages.filter((m) => m.role === "user").length === 0
) {
title = generateTitle(newMessage.content || newMessage.attachments?.[0]?.name || "Image conversation");
}
return { ...conv, title, messages: updatedMessages, updatedAt: Date.now() };
setConversations((prev) => {
const updated = prev.map((conv) => {
if (conv.id !== conversationId) return conv;
const updatedMessages = [...conv.messages, newMessage];
let title = conv.title;
if (
title === "New conversation" &&
newMessage.role === "user" &&
conv.messages.filter((m) => m.role === "user").length === 0
) {
title = generateTitle(
newMessage.content || newMessage.attachments?.[0]?.name || t("chat.chatHistory.imageConversation"),
);
}
return { ...conv, title, messages: updatedMessages, updatedAt: Date.now() };
});
return trimConversations(updated);
});
return trimConversations(updated);
});
}, []);
},
[t],
);
const updateLastAssistantMessage = useCallback((conversationId: string, updates: AssistantMessageUpdate) => {
setConversations((prev) => {

View file

@ -8,6 +8,7 @@ import type {
import { MessageType } from "../chat_ui/types";
import { TokenUsage } from "../chat_ui/ResponseMetrics";
import { getGatewayBaseUrl } from "@/components/networking";
import i18n from "@/i18n/i18n";
import { toast } from "@/lib/toast";
import { extractPromptCacheTokens } from "@/utils/promptCacheUsage";
import { parseUsageCost } from "./usage_cost";
@ -353,7 +354,7 @@ export async function makeOpenAIResponsesRequest(
} catch (error) {
if (signal?.aborted) {
} else {
toast.fromError(`Error occurred while generating model response. Please try again. Error: ${error}`);
toast.fromError(i18n.t("llmCalls.responsesApi.requestFailed", { error: String(error) }));
}
throw error; // Re-throw to allow the caller to handle the error
}

View file

@ -1,4 +1,7 @@
import type { ParseKeys, TFunction } from "i18next";
import React, { forwardRef, useImperativeHandle, useMemo } from "react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import { CircleHelp } from "lucide-react";
import { useForm, type Resolver, type ResolverResult } from "react-hook-form";
import { FieldGroup } from "@/components/ui/field";
@ -14,7 +17,7 @@ type ToolFormValues = { args: unknown[] };
const argumentValues = (schema: InputSchema, values: ToolFormValues): Record<string, unknown> =>
Object.fromEntries(Object.keys(schema.properties ?? {}).map((key, index) => [key, values.args[index]]));
const STRING_SCHEMA_MESSAGES: Readonly<Record<string, string>> = { input: "Please enter input for this tool" };
const STRING_SCHEMA_MESSAGES: Readonly<Record<string, ParseKeys>> = { input: "mcpToolArguments.stringSchemaRequired" };
const BOOLEAN_ITEMS = [
{ value: true, label: "True" },
@ -26,16 +29,16 @@ const isBlank = (value: unknown): boolean => value === undefined || value === nu
const isUnsetArgument = (prop: InputSchemaProperty | undefined, value: unknown): boolean =>
prop?.type === "string" && prop.enum ? value == null : isBlank(value);
const jsonErrorFor = (prop: InputSchemaProperty, value: unknown): string | null => {
const jsonErrorFor = (prop: InputSchemaProperty, value: unknown, t: TFunction): string | null => {
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
const isValidObject =
prop.type === "object" && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
const isValidArray = prop.type === "array" && Array.isArray(parsed);
if (isValidObject || isValidArray) return null;
return prop.type === "object" ? "Please enter a JSON object" : "Please enter a JSON array";
return prop.type === "object" ? t("mcpToolArguments.enterJsonObject") : t("mcpToolArguments.enterJsonArray");
} catch {
return "Invalid JSON";
return t("mcpToolArguments.invalidJson");
}
};
@ -43,32 +46,47 @@ type FieldError = { type: string; message: string };
const collectErrors = (
actualSchema: InputSchema,
requiredMessages: Readonly<Record<string, string>>,
requiredMessages: Readonly<Record<string, ParseKeys>>,
values: Record<string, unknown>,
t: TFunction,
): Record<string, FieldError> => {
const entries = Object.entries(actualSchema.properties ?? {}).flatMap<[string, FieldError]>(([key, prop]) => {
const value = values[key];
const blank = isUnsetArgument(prop, value);
if (actualSchema.required?.includes(key) && blank) {
return [[key, { type: "required", message: requiredMessages[key] ?? `Please enter ${key}` }]];
return [
[
key,
{
type: "required",
message: Object.hasOwn(requiredMessages, key)
? t(requiredMessages[key])
: t("mcpToolArguments.pleaseEnter", { key }),
},
],
];
}
if (prop.type === "string" && prop.enum) {
if (!blank && !prop.enum.includes(String(value))) {
return [[key, { type: "validate", message: `Please select a valid ${key}` }]];
return [[key, { type: "validate", message: t("mcpToolArguments.selectValid", { key }) }]];
}
}
if (prop.type !== "object" && prop.type !== "array") return [];
if (blank) return [];
const message = jsonErrorFor(prop, value);
const message = jsonErrorFor(prop, value, t);
return message === null ? [] : [[key, { type: "validate", message }]];
});
return Object.fromEntries(entries);
};
const buildResolver =
(actualSchema: InputSchema, requiredMessages: Readonly<Record<string, string>> = {}): Resolver<ToolFormValues> =>
(
actualSchema: InputSchema,
requiredMessages: Readonly<Record<string, ParseKeys>> = {},
t: TFunction = i18n.t.bind(i18n),
): Resolver<ToolFormValues> =>
(values): ResolverResult<ToolFormValues> => {
const errors = collectErrors(actualSchema, requiredMessages, argumentValues(actualSchema, values));
const errors = collectErrors(actualSchema, requiredMessages, argumentValues(actualSchema, values), t);
if (Object.keys(errors).length === 0) return { values, errors: {} };
return {
values: {},
@ -232,6 +250,7 @@ interface MCPToolArgumentsFormProps {
const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgumentsFormProps>(
({ tool, className }, ref) => {
const { t } = useTranslation();
const schema: InputSchema = useMemo(() => {
if (typeof tool.inputSchema === "string") {
return {
@ -239,14 +258,14 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
properties: {
input: {
type: "string",
description: "Input for this tool",
description: t("mcpToolArguments.stringSchemaDescription"),
},
},
required: ["input"],
};
}
return tool.inputSchema as InputSchema;
}, [tool.inputSchema]);
}, [tool.inputSchema, t]);
const actualSchema: InputSchema = useMemo(() => {
if (schema.properties?.params?.type === "object" && schema.properties.params.properties) {
@ -268,14 +287,14 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
const requiredMessages = isStringSchema ? STRING_SCHEMA_MESSAGES : {};
const form = useForm<ToolFormValues>({
defaultValues,
resolver: buildResolver(actualSchema, requiredMessages),
resolver: buildResolver(actualSchema, requiredMessages, t),
});
const { reset } = form;
useImperativeHandle(ref, () => ({
getSubmitValues: async () => {
const values = argumentValues(actualSchema, form.getValues());
const errors = collectErrors(actualSchema, requiredMessages, values);
const errors = collectErrors(actualSchema, requiredMessages, values, t);
if (Object.keys(errors).length > 0) {
await form.trigger();
return Promise.reject({
@ -308,12 +327,16 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
name="args.0"
label={
<span>
Input <span className="text-destructive">*</span>
{t("mcpToolArguments.input")} <span className="text-destructive">*</span>
</span>
}
>
{(field) => (
<Input {...field} value={(field.value as string) ?? ""} placeholder="Enter input for this tool" />
<Input
{...field}
value={(field.value as string) ?? ""}
placeholder={t("mcpToolArguments.inputPlaceholder")}
/>
)}
</FormField>
</FieldGroup>
@ -324,7 +347,7 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
if (!actualSchema.properties) {
return (
<form onSubmit={(event) => event.preventDefault()} className={className}>
<div className="py-4 text-center text-sm text-muted-foreground">No parameters required for this tool.</div>
<div className="py-4 text-center text-sm text-muted-foreground">{t("mcpToolArguments.noParameters")}</div>
</form>
);
}
@ -358,15 +381,17 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
aria-invalid={field["aria-invalid"]}
className="w-full"
>
<SelectValue placeholder={`Select ${key}`}>
{field.value === "" ? "Empty string" : undefined}
<SelectValue placeholder={t("mcpToolArguments.selectKey", { key })}>
{field.value === "" ? t("mcpToolArguments.emptyString") : undefined}
</SelectValue>
</SelectTrigger>
<SelectContent>
{!required && <SelectItem value={null}>Select {key}</SelectItem>}
{!required && (
<SelectItem value={null}>{t("mcpToolArguments.selectKey", { key })}</SelectItem>
)}
{prop.enum.map((v) => (
<SelectItem key={v} value={v}>
{v === "" ? "Empty string" : v}
{v === "" ? t("mcpToolArguments.emptyString") : v}
</SelectItem>
))}
</SelectContent>
@ -376,7 +401,11 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
if (prop.type === "boolean") {
return (
<Select
items={required ? BOOLEAN_ITEMS : [{ value: null, label: `Select ${key}` }, ...BOOLEAN_ITEMS]}
items={
required
? BOOLEAN_ITEMS
: [{ value: null, label: t("mcpToolArguments.selectKey", { key }) }, ...BOOLEAN_ITEMS]
}
value={field.value ?? null}
onValueChange={field.onChange}
>
@ -386,10 +415,12 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
aria-invalid={field["aria-invalid"]}
className="w-full"
>
<SelectValue placeholder={`Select ${key}`} />
<SelectValue placeholder={t("mcpToolArguments.selectKey", { key })} />
</SelectTrigger>
<SelectContent>
{!required && <SelectItem value={null}>Select {key}</SelectItem>}
{!required && (
<SelectItem value={null}>{t("mcpToolArguments.selectKey", { key })}</SelectItem>
)}
<SelectItem value={true}>True</SelectItem>
<SelectItem value={false}>False</SelectItem>
</SelectContent>
@ -403,7 +434,7 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
type="number"
step={prop.type === "integer" ? 1 : undefined}
value={(field.value as number | string) ?? ""}
placeholder={prop.description || `Enter ${key}`}
placeholder={prop.description || t("mcpToolArguments.enterKey", { key })}
/>
);
}
@ -417,7 +448,9 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
className="font-mono"
placeholder={
prop.description ||
(prop.type === "object" ? `Enter JSON object for ${key}` : `Enter JSON array for ${key}`)
(prop.type === "object"
? t("mcpToolArguments.enterJsonObjectFor", { key })
: t("mcpToolArguments.enterJsonArrayFor", { key }))
}
/>
);
@ -426,7 +459,7 @@ const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgument
<Input
{...field}
value={(field.value as string) ?? ""}
placeholder={prop.description || `Enter ${key}`}
placeholder={prop.description || t("mcpToolArguments.enterKey", { key })}
/>
);
}}

View file

@ -2,6 +2,7 @@
import { useTerminology } from "@/contexts/TerminologyContext";
import React from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpRight, Check, ExternalLink } from "lucide-react";
import moyaiHead from "../../../public/assets/moyai/moyai-head.svg";
import styles from "./MoyaiLanding.module.css";
@ -17,10 +18,11 @@ export default function MoyaiConnected({
models?: number | null;
}) {
const term = useTerminology();
const { t } = useTranslation();
const rows = [
{ label: "Workspace linked", value: moyaiUrl },
{ label: "Virtual key issued", value: keyAlias },
{ label: "models available through LiteLLM", value: models != null ? `${models}` : null, prefix: true },
{ label: t("moyai.connected.workspaceLinked"), value: moyaiUrl },
{ label: t("moyai.connected.keyIssued"), value: keyAlias },
{ label: t("moyai.connected.modelsAvailable"), value: models != null ? `${models}` : null, prefix: true },
];
return (
@ -30,13 +32,13 @@ export default function MoyaiConnected({
className={`${styles.rise} mb-5 inline-flex items-center gap-2 rounded-full bg-white/[0.06] px-3 py-1 text-[11px] uppercase tracking-[0.22em] text-[rgba(220,228,255,0.75)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.12)]`}
>
<span className="size-1.5 rounded-full bg-[#28c840] shadow-[0_0_8px_#28c840]" />
Connected
{t("moyai.connected.badge")}
</div>
<img src={moyaiHead.src} alt="" className={`${styles.mark} mb-4 h-14 w-auto`} />
<h1 className="m-0 text-[clamp(28px,3.5vw,44px)] font-medium tracking-[-0.03em] text-white">Moyai connected</h1>
<p className="mb-0 mt-3 max-w-md text-[16px] text-[#c2c6d0]">
This deployment is now linked to your LiteLLM gateway.
</p>
<h1 className="m-0 text-[clamp(28px,3.5vw,44px)] font-medium tracking-[-0.03em] text-white">
{t("moyai.connected.title")}
</h1>
<p className="mb-0 mt-3 max-w-md text-[16px] text-[#c2c6d0]">{t("moyai.connected.description")}</p>
<div className="mt-8 w-full max-w-md space-y-2 text-left">
{rows.map((row) =>
@ -68,7 +70,7 @@ export default function MoyaiConnected({
href={moyaiUrl}
className={`${styles.cta} group inline-flex items-center gap-3 rounded-full px-7 py-3.5 text-base font-semibold text-[#05070d] transition-transform hover:scale-[1.03]`}
>
Open Moyai
{t("moyai.connected.openMoyai")}
<ArrowUpRight className="size-4" />
</a>
)}
@ -77,7 +79,7 @@ export default function MoyaiConnected({
className="inline-flex items-center gap-2 rounded-full bg-white/[0.06] px-5 py-3 text-sm font-medium text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)] backdrop-blur transition-colors hover:bg-white/[0.12]"
>
<ExternalLink className="size-4" />
{term("Back to AI Gateway")}
{term(t("moyai.connected.backToGateway"))}
</a>
</div>
</div>

View file

@ -2,6 +2,8 @@
import React, { useEffect, useRef, useState } from "react";
import { ArrowRight, ArrowUpRight, Github, Play, Plug, Star } from "lucide-react";
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
import moyaiHead from "../../../public/assets/moyai/moyai-head.svg";
import anthropicLogo from "../../../public/assets/moyai/logos/anthropic.svg";
import bedrockLogo from "../../../public/assets/moyai/logos/bedrock.svg";
@ -17,6 +19,7 @@ import xaiLogo from "../../../public/assets/moyai/logos/xai.svg";
import { PLANET_HORIZON, prefersReducedMotion, startPlanetrise, startStarfield } from "./moyaiSky";
import { normalizeMoyaiUrl } from "./moyaiConnect";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import i18n from "@/i18n/i18n";
import styles from "./MoyaiLanding.module.css";
export const MOYAI_GITHUB_URL = "https://github.com/BerriAI/moyai";
@ -168,7 +171,7 @@ function LogoWall({ title, items, footnote }: { title: string; items: LogoItem[]
);
}
function GithubCta({ label = "Star Moyai on GitHub" }: { label?: string }) {
function GithubCta({ label = i18n.t("moyai.landing.starOnGithub") }: { label?: string }) {
return (
<a
href={MOYAI_GITHUB_URL}
@ -198,6 +201,7 @@ function SecondaryCta({ href, icon, children }: { href: string; icon: React.Reac
}
function DemoPreview() {
const { t } = useTranslation();
const [failed, setFailed] = useState(false);
return (
<a
@ -215,25 +219,26 @@ function DemoPreview() {
{failed ? (
<div className="flex aspect-[1196/720] flex-col items-center justify-center gap-3 text-[#c2c6d0]">
<Play className="size-10" />
<span>Watch the demo on GitHub</span>
<span>{t("moyai.landing.demoFallback")}</span>
</div>
) : (
<img
src={MOYAI_DEMO_GIF_URL}
alt="Moyai demo: picking a harness and model, then running a task"
alt={t("moyai.landing.demoAlt")}
className="block aspect-[1196/720] w-full bg-white object-cover"
loading="lazy"
onError={() => setFailed(true)}
/>
)}
<span className="absolute bottom-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-[#05070d]/80 px-3 py-1.5 text-xs font-medium text-white opacity-0 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.15)] transition-opacity group-hover:opacity-100">
Full walkthrough on GitHub <ArrowUpRight className="size-3.5" />
{t("moyai.landing.fullWalkthrough")} <ArrowUpRight className="size-3.5" />
</span>
</a>
);
}
function QuickConnectDialog({ onQuickConnect }: { onQuickConnect: (url: string) => Promise<void> | void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState("");
const [error, setError] = useState<string | null>(null);
@ -242,7 +247,7 @@ function QuickConnectDialog({ onQuickConnect }: { onQuickConnect: (url: string)
const connect = async () => {
const normalized = normalizeMoyaiUrl(url);
if (!normalized) {
setError("Enter a full http or https URL, without credentials");
setError(t("moyai.landing.quickConnect.invalidUrl"));
return;
}
setError(null);
@ -250,7 +255,7 @@ function QuickConnectDialog({ onQuickConnect }: { onQuickConnect: (url: string)
try {
await onQuickConnect(normalized);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not start quick connect");
setError(e instanceof Error ? e.message : t("moyai.landing.quickConnect.failed"));
setPending(false);
}
};
@ -266,16 +271,16 @@ function QuickConnectDialog({ onQuickConnect }: { onQuickConnect: (url: string)
}
>
<Plug aria-hidden className="size-4 text-[#cfe3ff]" />
Quick connect
{t("moyai.landing.quickConnect.trigger")}
<ArrowRight aria-hidden className="size-4" />
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Quick connect Moyai</DialogTitle>
<DialogTitle>{t("moyai.landing.quickConnect.title")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<label htmlFor="moyai-quick-connect-url" className="text-sm font-medium">
Moyai URL
{t("moyai.landing.quickConnect.urlLabel")}
</label>
<input
id="moyai-quick-connect-url"
@ -285,9 +290,7 @@ function QuickConnectDialog({ onQuickConnect }: { onQuickConnect: (url: string)
placeholder="https://moyai.your-company.com"
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
/>
<p className="m-0 text-xs text-muted-foreground">
You&rsquo;ll confirm on your Moyai workspace as an admin, then come straight back
</p>
<p className="m-0 text-xs text-muted-foreground">{t("moyai.landing.quickConnect.hint")}</p>
{error && (
<p role="alert" className="m-0 text-xs text-red-500">
{error}
@ -301,7 +304,7 @@ function QuickConnectDialog({ onQuickConnect }: { onQuickConnect: (url: string)
onClick={connect}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
{pending ? "Connecting" : "Connect"}
{pending ? t("moyai.landing.quickConnect.connecting") : t("moyai.landing.quickConnect.connect")}
</button>
</div>
</DialogContent>
@ -309,10 +312,10 @@ function QuickConnectDialog({ onQuickConnect }: { onQuickConnect: (url: string)
);
}
const STATS = [
{ value: "79%", label: "cheaper than our Devin bill" },
{ value: "100+", label: "providers through LiteLLM" },
{ value: "6", label: "agent harnesses" },
const STATS: { value: string; label: ParseKeys }[] = [
{ value: "79%", label: "moyai.landing.stats.cheaper" },
{ value: "100+", label: "moyai.landing.stats.providers" },
{ value: "6", label: "moyai.landing.stats.harnesses" },
];
export default function MoyaiLanding({
@ -322,6 +325,7 @@ export default function MoyaiLanding({
canQuickConnect?: boolean;
onQuickConnect?: (url: string) => Promise<void> | void;
}) {
const { t } = useTranslation();
const heroRef = useRef<HTMLElement>(null);
const anchorRef = useRef<HTMLDivElement>(null);
const starsRef = useCanvasScene(startStarfield);
@ -339,7 +343,7 @@ export default function MoyaiLanding({
className={`${styles.rise} mb-5 inline-flex items-center gap-2 rounded-full bg-white/[0.06] px-3 py-1 text-[11px] uppercase tracking-[0.22em] text-[rgba(220,228,255,0.75)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.12)]`}
>
<span className="size-1.5 rounded-full bg-[#28c840] shadow-[0_0_8px_#28c840]" />
Now open source
{t("moyai.landing.badge")}
</div>
<h1
className={`${styles.rise} m-0 flex flex-col items-center text-[clamp(56px,min(8vw,11vh),112px)] font-medium leading-none tracking-[-0.04em] text-white [text-shadow:0_0_40px_rgba(3,4,8,0.9)]`}
@ -352,7 +356,7 @@ export default function MoyaiLanding({
className={`${styles.rise} mb-0 mt-3 text-[clamp(18px,1.8vw,26px)] tracking-[-0.01em] text-[rgba(226,234,255,0.86)]`}
style={{ animationDelay: "0.16s" }}
>
The open source cloud coding agent
{t("moyai.landing.tagline")}
</p>
<div
className={`${styles.rise} mt-7 flex flex-wrap items-center justify-center gap-3`}
@ -360,7 +364,7 @@ export default function MoyaiLanding({
>
<GithubCta />
<SecondaryCta href={MOYAI_WALKTHROUGH_URL} icon={<Play className="size-4" />}>
Watch the demo
{t("moyai.landing.watchDemo")}
</SecondaryCta>
</div>
<div
@ -369,11 +373,11 @@ export default function MoyaiLanding({
>
{canQuickConnect && onQuickConnect ? (
<span className="inline-flex flex-wrap items-center justify-center gap-3 rounded-full bg-white/[0.05] py-1.5 pl-5 pr-1.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.12)] backdrop-blur">
Already have Moyai deployed?
{t("moyai.landing.alreadyDeployed")}
<QuickConnectDialog onQuickConnect={onQuickConnect} />
</span>
) : (
<span>Already have Moyai deployed? Ask a proxy admin to connect it</span>
<span>{t("moyai.landing.askAdmin")}</span>
)}
</div>
</div>
@ -391,9 +395,9 @@ export default function MoyaiLanding({
<div className="absolute inset-x-0 bottom-[6vh] z-chrome flex flex-col items-center gap-1 px-6 text-center [text-shadow:0_0_30px_rgba(3,4,8,0.95)]">
<span className="text-[clamp(18px,2vw,28px)] font-medium tracking-[-0.02em] text-white">
Works with Claude Code and Codex
{t("moyai.landing.worksWith")}
</span>
<span className="text-[15px] text-[rgba(240,236,228,0.8)]">Self-hosted · 100+ providers through LiteLLM</span>
<span className="text-[15px] text-[rgba(240,236,228,0.8)]">{t("moyai.landing.selfHosted")}</span>
</div>
</section>
@ -407,30 +411,29 @@ export default function MoyaiLanding({
<div className="bg-gradient-to-b from-white to-[#9cc3ff] bg-clip-text text-4xl font-semibold tracking-[-0.03em] text-transparent">
{s.value}
</div>
<div className="mt-1 text-sm text-[#a6aab3]">{s.label}</div>
<div className="mt-1 text-sm text-[#a6aab3]">{t(s.label)}</div>
</div>
))}
</div>
<h2 className="mb-3 mt-20 text-center text-[clamp(28px,3.2vw,44px)] font-medium leading-tight tracking-[-0.03em] text-white">
See it in action
{t("moyai.landing.seeItInAction")}
</h2>
<p className="mx-auto mb-8 mt-0 max-w-2xl text-center text-[17px] text-[#c2c6d0]">
Give it a task from Slack or the browser. It edits code, runs your tests in its own cloud workspace, and opens
a pull request while your laptop is closed.
{t("moyai.landing.seeItInActionBody")}
</p>
<DemoPreview />
<div className="mt-16 grid gap-4 md:grid-cols-2">
<LogoWall
title="Any harness"
title={t("moyai.landing.anyHarness")}
items={HARNESSES}
footnote="Pick the harness per session. Same workspace, tools and permissions."
footnote={t("moyai.landing.anyHarnessFootnote")}
/>
<LogoWall
title="Any model, any provider"
title={t("moyai.landing.anyModel")}
items={PROVIDERS}
footnote="Routed through your LiteLLM gateway, with spend tracked per teammate."
footnote={t("moyai.landing.anyModelFootnote")}
/>
</div>
@ -441,19 +444,18 @@ export default function MoyaiLanding({
/>
<img src={moyaiHead.src} alt="" className={`${styles.mark} relative z-raised mx-auto mb-4 h-14 w-auto`} />
<h2 className="relative z-raised m-0 text-[clamp(26px,3vw,40px)] font-medium tracking-[-0.03em] text-white">
Run your own cloud agent
{t("moyai.landing.runYourOwn")}
</h2>
<p className="relative z-raised mx-auto mb-7 mt-3 max-w-xl text-[16px] text-[#c2c6d0]">
Deploy it on your own infrastructure and point it at this gateway. Your code and credentials stay in your
accounts.
{t("moyai.landing.runYourOwnBody")}
</p>
<code className="relative z-raised mx-auto mb-8 block w-fit max-w-full overflow-x-auto rounded-xl bg-black/50 px-5 py-3 text-left font-mono text-sm text-[#cfe3ff] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.1)]">
git clone {MOYAI_GITHUB_URL}.git
</code>
<div className="relative z-raised flex flex-wrap items-center justify-center gap-3">
<GithubCta label="Get Moyai on GitHub" />
<GithubCta label={t("moyai.landing.getOnGithub")} />
<SecondaryCta href={MOYAI_LAUNCH_POST_URL} icon={<ArrowUpRight className="size-4" />}>
Read the launch post
{t("moyai.landing.readLaunchPost")}
</SecondaryCta>
</div>
</div>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Tag } from "./types";
import { tagListCall } from "../networking";
import { MultiSelect } from "@/components/shared/MultiSelect";
@ -11,6 +12,7 @@ interface TagSelectorProps {
}
const TagSelector: React.FC<TagSelectorProps> = ({ onChange, value, className, accessToken }) => {
const { t } = useTranslation();
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(false);
@ -33,7 +35,7 @@ const TagSelector: React.FC<TagSelectorProps> = ({ onChange, value, className, a
return (
<MultiSelect
placeholder="Select or create tags"
placeholder={t("tagSelector.placeholder")}
onValueChange={onChange}
value={value}
loading={loading}

View file

@ -13,6 +13,7 @@
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
buildMcpOAuthAuthorizeUrl,
exchangeMcpOAuthToken,
@ -79,6 +80,7 @@ export const useUserMcpOAuthFlow = ({
clientId: preClientId,
onSuccess,
}: UseUserMcpOAuthFlowOptions): UseUserMcpOAuthFlowResult => {
const { t } = useTranslation();
const [status, setStatus] = useState<UserMcpOAuthStatus>("idle");
const [error, setError] = useState<string | null>(null);
const processingRef = useRef(false);
@ -181,7 +183,7 @@ export const useUserMcpOAuthFlow = ({
const raw = getStorage(FLOW_STATE_KEY);
flowState = raw ? JSON.parse(raw) : null;
} catch (_) {
setError("Failed to resume OAuth flow. Please retry.");
setError(t("mcpOAuthFlow.resumeFailed"));
setStatus("error");
processingRef.current = false;
clearStorage(FLOW_STATE_KEY);
@ -224,7 +226,7 @@ export const useUserMcpOAuthFlow = ({
setStatus("success");
setError(null);
toast.success("Connected successfully");
toast.success(t("mcpOAuthFlow.connected"));
onSuccess();
} catch (err) {
const msg = extractErrorMessage(err);
@ -237,7 +239,7 @@ export const useUserMcpOAuthFlow = ({
processingRef.current = false;
}, 1000);
}
}, [accessToken, serverId, onSuccess]);
}, [accessToken, serverId, onSuccess, t]);
useEffect(() => {
resumeOAuthFlow();

File diff suppressed because it is too large Load diff