mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-05 07:37:50 +00:00
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:
parent
d9fbfbd03b
commit
3492453262
86 changed files with 3902 additions and 1449 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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't load this server'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
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 "{selectedAgent?.model_name}"? 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 "GitHub Read-only" 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">"{resultPage.search_query}"</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">
|
||||
|
|
|
|||
|
|
@ -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" \\
|
||||
|
|
|
|||
|
|
@ -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]) => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" ? (
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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’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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue