mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
refactor(ui): move agents and knowledge text onto i18n
Move the page-authored English on the agents, vector stores, prompts, search tools, memory and skills pages, plus the shared skill detail, make-skill-public and model selector components, into en.json behind t(). The English on screen is unchanged. Keys are added under agents, vectorStores, prompts, searchTools, memory, skills, claudeCodePlugins and modelSelector, inserted at the top of en.json Strings that could only move with a structural change stay English, along with API values, code samples, paths and names
This commit is contained in:
parent
c43a1addf2
commit
4eec9d0de4
79 changed files with 3942 additions and 1526 deletions
|
|
@ -27,6 +27,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { KeyValueFormValue, KillSwitchConfig, KillSwitchFormValue } from "./kill_switch_config";
|
||||
|
||||
export interface AgentSkillFormValue {
|
||||
|
|
@ -282,7 +283,7 @@ export const AgentTagsInput = ({
|
|||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
emptyText = "No matching options",
|
||||
emptyText = i18n.t("agents.formKit.noMatchingOptions"),
|
||||
...props
|
||||
}: AgentTagsInputProps) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
|
|
@ -396,7 +397,7 @@ export const AgentMultiSelect = ({
|
|||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
emptyText = "No matching options",
|
||||
emptyText = i18n.t("agents.formKit.noMatchingOptions"),
|
||||
...props
|
||||
}: AgentMultiSelectProps) => {
|
||||
const anchor = useComboboxAnchor();
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import React from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { readAgentIdentity } from "./agent_identity";
|
||||
|
||||
const authenticationMessage = (error: boolean, lastAuthenticated?: string | null): string => {
|
||||
if (error) return "Could not load authentication evidence";
|
||||
if (lastAuthenticated) return `Last authenticated identity match: ${new Date(lastAuthenticated).toLocaleString()}`;
|
||||
return "Configured, awaiting an authenticated request";
|
||||
const authenticationMessage = (t: TFunction, error: boolean, lastAuthenticated?: string | null): string => {
|
||||
if (error) return t("agents.identityDetails.evidence.loadFailed");
|
||||
if (lastAuthenticated)
|
||||
return t("agents.identityDetails.evidence.lastMatch", { date: new Date(lastAuthenticated).toLocaleString() });
|
||||
return t("agents.identityDetails.evidence.awaiting");
|
||||
};
|
||||
|
||||
export const AgentIdentityDetails = ({
|
||||
|
|
@ -22,6 +25,7 @@ export const AgentIdentityDetails = ({
|
|||
accessToken: string | null;
|
||||
isAdmin: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const identity = readAgentIdentity(value);
|
||||
const { data, isError, isFetching, refetch } = useQuery({
|
||||
queryKey: ["agent-identity", agentId, identity],
|
||||
|
|
@ -36,31 +40,39 @@ export const AgentIdentityDetails = ({
|
|||
});
|
||||
|
||||
if (!identity || !isAdmin) return null;
|
||||
const executionLabel = data?.enabled ? "Enabled" : "Disabled";
|
||||
const executionLabel = data?.enabled
|
||||
? t("agents.identityDetails.execution.enabled")
|
||||
: t("agents.identityDetails.execution.disabled");
|
||||
return (
|
||||
<section aria-label="Agent Identity" className="mb-6 space-y-2 rounded-lg border border-border p-4">
|
||||
<h3 className="font-medium">Agent Identity: Microsoft Entra ID</h3>
|
||||
<section
|
||||
aria-label={t("agents.identityDetails.regionLabel")}
|
||||
className="mb-6 space-y-2 rounded-lg border border-border p-4"
|
||||
>
|
||||
<h3 className="font-medium">{t("agents.identityDetails.title")}</h3>
|
||||
<p className="text-sm">
|
||||
Tenant: <span className="font-mono">{identity.tenant_id}</span>
|
||||
{t("agents.identityDetails.tenant")} <span className="font-mono">{identity.tenant_id}</span>
|
||||
</p>
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Application (Client) ID: <span className="font-mono">{identity.client_id}</span>
|
||||
{t("agents.identityDetails.clientId")} <span className="font-mono">{identity.client_id}</span>
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("agents.identityDetails.servicePrincipal")}{" "}
|
||||
{identity.service_principal_id || t("agents.identityDetails.notConfigured")}
|
||||
</p>
|
||||
<p className="text-sm">Enterprise application Object ID: {identity.service_principal_id || "Not configured"}</p>
|
||||
</>
|
||||
<p className="text-sm">
|
||||
Execution: {data ? executionLabel : "Loading"} · Mode: {data?.execution_mode ?? "Loading"}
|
||||
{t("agents.identityDetails.executionSummary", {
|
||||
execution: data ? executionLabel : t("agents.identityDetails.loading"),
|
||||
mode: data?.execution_mode ?? t("agents.identityDetails.loading"),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{data?.identity?.active === false
|
||||
? "Identity unbound; execution is disabled"
|
||||
: authenticationMessage(isError, data?.last_authenticated_at)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recent evidence comes from a validated Entra token matching this binding. It is persisted across restarts and
|
||||
cleared when the binding changes. Tool and model permissions are checked separately.
|
||||
? t("agents.identityDetails.evidence.unbound")
|
||||
: authenticationMessage(t, isError, data?.last_authenticated_at)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("agents.identityDetails.evidenceNote")}</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -70,10 +82,10 @@ export const AgentIdentityDetails = ({
|
|||
void refetch();
|
||||
}}
|
||||
>
|
||||
Refresh authentication evidence
|
||||
{t("agents.identityDetails.refresh")}
|
||||
</Button>
|
||||
<a className="text-sm underline" href="/ui/logs/">
|
||||
View request logs
|
||||
{t("agents.identityDetails.viewLogs")}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,30 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { apiClient } from "@/components/networking";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { AgentFormField, type AgentFormValues } from "./AgentFormKit";
|
||||
import { entraTenantFromIssuer, IDENTITY_UUID_PATTERN } from "./agent_identity";
|
||||
|
||||
const PROVIDER_OPTIONS = [
|
||||
{ value: "none", label: "No explicit identity binding" },
|
||||
{ value: "microsoft_entra", label: "Microsoft Entra ID" },
|
||||
const PROVIDER_OPTIONS: { value: string; labelKey: ParseKeys }[] = [
|
||||
{ value: "none", labelKey: "agents.identityFields.providers.none" },
|
||||
{ value: "microsoft_entra", labelKey: "agents.identityFields.providers.microsoftEntra" },
|
||||
];
|
||||
const EXECUTION_MODE_OPTIONS = [
|
||||
{ value: "autonomous", label: "Autonomous" },
|
||||
{ value: "delegated", label: "On behalf of a user" },
|
||||
{ value: "both", label: "Both" },
|
||||
const EXECUTION_MODE_OPTIONS: { value: string; labelKey: ParseKeys }[] = [
|
||||
{ value: "autonomous", labelKey: "agents.identityFields.executionModes.autonomous" },
|
||||
{ value: "delegated", labelKey: "agents.identityFields.executionModes.delegated" },
|
||||
{ value: "both", labelKey: "agents.identityFields.executionModes.both" },
|
||||
];
|
||||
const EXECUTION_OPTIONS = [
|
||||
{ value: "enabled", label: "Enabled" },
|
||||
{ value: "disabled", label: "Disabled" },
|
||||
const EXECUTION_OPTIONS: { value: string; labelKey: ParseKeys }[] = [
|
||||
{ value: "enabled", labelKey: "agents.identityFields.execution.enabled" },
|
||||
{ value: "disabled", labelKey: "agents.identityFields.execution.disabled" },
|
||||
];
|
||||
|
||||
export const AgentIdentityFields = ({ accessToken }: { accessToken: string | null }) => {
|
||||
const { t } = useTranslation();
|
||||
const provider = useWatch<AgentFormValues>({ name: "identity_provider" });
|
||||
const mode = useWatch<AgentFormValues>({ name: "execution_mode" });
|
||||
const showScopes = mode !== "autonomous" && mode !== undefined;
|
||||
|
|
@ -44,7 +48,7 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setError("Could not load the gateway's trusted identity providers");
|
||||
if (active) setError(i18n.t("agents.identityFields.errors.providersLoadFailed"));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
|
|
@ -53,18 +57,18 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
|
||||
return (
|
||||
<>
|
||||
<section aria-label="Agent Identity" className="my-6 space-y-4 rounded-lg border border-border p-4">
|
||||
<section
|
||||
aria-label={t("agents.identityFields.regionLabel")}
|
||||
className="my-6 space-y-4 rounded-lg border border-border p-4"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium">Agent Identity</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Connect an existing identity provider application to this agent. Its name and runtime address can change
|
||||
independently.
|
||||
</p>
|
||||
<h3 className="font-medium">{t("agents.identityFields.title")}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("agents.identityFields.description")}</p>
|
||||
</div>
|
||||
<AgentFormField name="identity_provider" label="Identity Provider" defaultValue="none">
|
||||
<AgentFormField name="identity_provider" label={t("agents.identityFields.provider")} defaultValue="none">
|
||||
{({ value, onChange, id }) => (
|
||||
<Select
|
||||
items={PROVIDER_OPTIONS}
|
||||
items={PROVIDER_OPTIONS.map((option) => ({ value: option.value, label: t(option.labelKey) }))}
|
||||
value={typeof value === "string" ? value : "none"}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
|
|
@ -74,7 +78,7 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
<SelectContent>
|
||||
{PROVIDER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -85,13 +89,13 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
<>
|
||||
<AgentFormField
|
||||
name="identity_tenant_id"
|
||||
label="Trusted Entra Tenant"
|
||||
rules={{ required: "Select a trusted tenant" }}
|
||||
label={t("agents.identityFields.tenant.label")}
|
||||
rules={{ required: t("agents.identityFields.tenant.required") }}
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
<Select value={typeof value === "string" ? value : ""} onValueChange={onChange}>
|
||||
<SelectTrigger id={id}>
|
||||
<SelectValue placeholder="Select the gateway's trusted tenant" />
|
||||
<SelectValue placeholder={t("agents.identityFields.tenant.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenants.map((tenant) => (
|
||||
|
|
@ -109,26 +113,24 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
</p>
|
||||
)}
|
||||
{!error && tenants.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No trusted Entra tenant is available. Configure JWT issuer and audience validation on the gateway first.
|
||||
Dashboard Microsoft SSO is configured separately.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("agents.identityFields.tenant.noneAvailable")}</p>
|
||||
)}
|
||||
<AgentFormField
|
||||
name="identity_client_id"
|
||||
label="Application (Client) ID"
|
||||
label={t("agents.identityFields.clientId.label")}
|
||||
rules={{
|
||||
required: "Enter the Entra application client ID",
|
||||
pattern: { value: IDENTITY_UUID_PATTERN, message: "Enter a valid application client UUID" },
|
||||
required: t("agents.identityFields.clientId.required"),
|
||||
pattern: { value: IDENTITY_UUID_PATTERN, message: t("agents.identityFields.clientId.invalid") },
|
||||
}}
|
||||
description={
|
||||
<>
|
||||
Find this under{" "}
|
||||
<a className="underline" href="https://entra.microsoft.com/" target="_blank" rel="noreferrer">
|
||||
Entra App registrations
|
||||
</a>
|
||||
, select your agent application, then Overview. No client secret is required here.
|
||||
</>
|
||||
<Trans
|
||||
i18nKey="agents.identityFields.clientId.description"
|
||||
components={{
|
||||
registrations: (
|
||||
<a className="underline" href="https://entra.microsoft.com/" target="_blank" rel="noreferrer" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
|
|
@ -141,10 +143,14 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<AgentFormField name="execution_mode" label="Execution Mode" defaultValue="autonomous">
|
||||
<AgentFormField
|
||||
name="execution_mode"
|
||||
label={t("agents.identityFields.executionMode")}
|
||||
defaultValue="autonomous"
|
||||
>
|
||||
{({ value, onChange, id }) => (
|
||||
<Select
|
||||
items={EXECUTION_MODE_OPTIONS}
|
||||
items={EXECUTION_MODE_OPTIONS.map((option) => ({ value: option.value, label: t(option.labelKey) }))}
|
||||
value={typeof value === "string" ? value : "autonomous"}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
|
|
@ -154,7 +160,7 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
<SelectContent>
|
||||
{EXECUTION_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -163,25 +169,25 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
</AgentFormField>
|
||||
<AgentFormField
|
||||
name="identity_service_principal_id"
|
||||
label="Enterprise Application Object ID"
|
||||
label={t("agents.identityFields.servicePrincipal.label")}
|
||||
rules={{
|
||||
required: mode !== "delegated" ? "Enter the service principal Object ID" : false,
|
||||
pattern: { value: IDENTITY_UUID_PATTERN, message: "Enter a valid service principal UUID" },
|
||||
required: mode !== "delegated" ? t("agents.identityFields.servicePrincipal.required") : false,
|
||||
pattern: { value: IDENTITY_UUID_PATTERN, message: t("agents.identityFields.servicePrincipal.invalid") },
|
||||
}}
|
||||
description={
|
||||
<>
|
||||
Open{" "}
|
||||
<a
|
||||
className="underline"
|
||||
href="https://entra.microsoft.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Entra Enterprise applications
|
||||
</a>
|
||||
, select this application, and copy its Object ID. The App registrations Object ID is a different
|
||||
value.
|
||||
</>
|
||||
<Trans
|
||||
i18nKey="agents.identityFields.servicePrincipal.description"
|
||||
components={{
|
||||
enterprise: (
|
||||
<a
|
||||
className="underline"
|
||||
href="https://entra.microsoft.com/#view/Microsoft_AAD_IAM/StartboardApplicationsMenuBlade/~/AppAppsPreview"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
|
|
@ -191,8 +197,8 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
|
||||
<AgentFormField
|
||||
name="identity_required_roles"
|
||||
label="Required Application Roles"
|
||||
description="Comma-separated role values required on autonomous application tokens"
|
||||
label={t("agents.identityFields.requiredRoles.label")}
|
||||
description={t("agents.identityFields.requiredRoles.description")}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
|
|
@ -209,9 +215,9 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
<>
|
||||
<AgentFormField
|
||||
name="identity_required_scopes"
|
||||
label="Required Delegated Scopes"
|
||||
label={t("agents.identityFields.requiredScopes.label")}
|
||||
defaultValue="user_impersonation"
|
||||
rules={{ required: "Enter a delegated scope" }}
|
||||
rules={{ required: t("agents.identityFields.requiredScopes.required") }}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
|
|
@ -222,16 +228,13 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Users must first sign in through this gateway's Microsoft SSO. Subsequent delegated calls must
|
||||
satisfy both user and agent permissions.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("agents.identityFields.requiredScopes.note")}</p>
|
||||
</>
|
||||
)}
|
||||
<AgentFormField name="enabled" label="Execution" defaultValue={true}>
|
||||
<AgentFormField name="enabled" label={t("agents.identityFields.executionLabel")} defaultValue={true}>
|
||||
{({ value, onChange, id }) => (
|
||||
<Select
|
||||
items={EXECUTION_OPTIONS}
|
||||
items={EXECUTION_OPTIONS.map((option) => ({ value: option.value, label: t(option.labelKey) }))}
|
||||
value={value === false ? "disabled" : "enabled"}
|
||||
onValueChange={(next) => onChange(next === "enabled")}
|
||||
>
|
||||
|
|
@ -241,18 +244,14 @@ export const AgentIdentityFields = ({ accessToken }: { accessToken: string | nul
|
|||
<SelectContent>
|
||||
{EXECUTION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
LiteLLM verifies the agent's Entra token before matching this identity. Saving these fields
|
||||
configures the binding; an authenticated request provides verification. Runtime authentication headers are
|
||||
configured separately.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("agents.identityFields.verificationNote")}</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CircleAlert } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -24,6 +25,7 @@ const AgentKillSwitchDangerZone: React.FC<AgentKillSwitchDangerZoneProps> = ({
|
|||
accessToken,
|
||||
isAdmin,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [confirmationInput, setConfirmationInput] = useState("");
|
||||
const [isFiring, setIsFiring] = useState(false);
|
||||
|
|
@ -44,9 +46,9 @@ const AgentKillSwitchDangerZone: React.FC<AgentKillSwitchDangerZoneProps> = ({
|
|||
const result = await triggerAgentKillSwitchCall(accessToken, agentId);
|
||||
setLastResult(result);
|
||||
setIsConfirmOpen(false);
|
||||
toast.success(`Kill switch fired (HTTP ${result.status_code})`);
|
||||
toast.success(t("agents.dangerZone.toasts.fired", { status: result.status_code }));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to fire kill switch");
|
||||
toast.error(error instanceof Error ? error.message : t("agents.dangerZone.toasts.fireFailed"));
|
||||
} finally {
|
||||
setIsFiring(false);
|
||||
}
|
||||
|
|
@ -55,38 +57,33 @@ const AgentKillSwitchDangerZone: React.FC<AgentKillSwitchDangerZoneProps> = ({
|
|||
return (
|
||||
<section aria-labelledby="agent-danger-zone-heading" className="mt-6">
|
||||
<h3 id="agent-danger-zone-heading" className="text-lg font-medium text-destructive">
|
||||
Danger Zone
|
||||
{t("agents.dangerZone.title")}
|
||||
</h3>
|
||||
<div className="mt-4 rounded-lg border border-destructive/40 bg-destructive/5 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 space-y-1 text-sm">
|
||||
<p className="font-medium text-foreground">Kill switch</p>
|
||||
<p className="font-medium text-foreground">{t("agents.dangerZone.killSwitch")}</p>
|
||||
{killSwitch ? (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
Calls the configured webhook to stop this agent's upstream runtime. This can cause an outage for
|
||||
everyone using the agent and cannot be undone from LiteLLM
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("agents.dangerZone.configuredDescription")}</p>
|
||||
<p className="font-mono break-all text-foreground">
|
||||
{killSwitch.method ?? "POST"} {killSwitch.url}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
Not configured. Add a kill switch webhook under Settings to enable this action
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("agents.dangerZone.notConfigured")}</p>
|
||||
)}
|
||||
</div>
|
||||
{killSwitch && (
|
||||
<Button type="button" variant="destructive" onClick={openConfirm} disabled={isFiring} aria-busy={isFiring}>
|
||||
{isFiring && <UiLoadingSpinner className="size-4" />}
|
||||
Fire Kill Switch
|
||||
{t("agents.dangerZone.fire")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{lastResult && (
|
||||
<p className="mt-3 text-sm text-muted-foreground" role="status">
|
||||
Last result: HTTP {lastResult.status_code}
|
||||
{t("agents.dangerZone.lastResult", { status: lastResult.status_code })}
|
||||
{lastResult.response_body ? ` ${lastResult.response_body}` : ""}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -95,27 +92,32 @@ const AgentKillSwitchDangerZone: React.FC<AgentKillSwitchDangerZoneProps> = ({
|
|||
<Dialog open={isConfirmOpen} onOpenChange={(open) => !open && !isFiring && setIsConfirmOpen(false)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fire kill switch for {agentName}?</DialogTitle>
|
||||
<DialogTitle>{t("agents.dangerZone.confirm.title", { name: agentName })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Alert variant="error">
|
||||
<CircleAlert />
|
||||
<AlertTitle>This can cause an outage</AlertTitle>
|
||||
<AlertTitle>{t("agents.dangerZone.confirm.outageTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
LiteLLM will call {killSwitch?.method ?? "POST"} {killSwitch?.url} immediately. Whatever that webhook
|
||||
does to the agent is outside LiteLLM's control and cannot be reverted here
|
||||
{t("agents.dangerZone.confirm.outageDescription", {
|
||||
method: killSwitch?.method ?? "POST",
|
||||
url: killSwitch?.url,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div>
|
||||
<p className="mb-2 text-base font-medium text-foreground">
|
||||
Type <span className="font-semibold text-destructive">{agentName}</span> to confirm:
|
||||
<Trans
|
||||
i18nKey="agents.dangerZone.confirm.typeToConfirm"
|
||||
components={{ name: <span className="font-semibold text-destructive">{agentName}</span> }}
|
||||
/>
|
||||
</p>
|
||||
<InputGroup className="rounded-md">
|
||||
<InputGroupAddon>
|
||||
<CircleAlert className="size-3.5 text-destructive" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
aria-label="Confirm agent name"
|
||||
aria-label={t("agents.dangerZone.confirm.inputLabel")}
|
||||
value={confirmationInput}
|
||||
onChange={(e) => setConfirmationInput(e.target.value)}
|
||||
placeholder={agentName}
|
||||
|
|
@ -126,7 +128,7 @@ const AgentKillSwitchDangerZone: React.FC<AgentKillSwitchDangerZoneProps> = ({
|
|||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsConfirmOpen(false)} disabled={isFiring}>
|
||||
Cancel
|
||||
{t("agents.dangerZone.confirm.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
|
|
@ -135,7 +137,7 @@ const AgentKillSwitchDangerZone: React.FC<AgentKillSwitchDangerZoneProps> = ({
|
|||
aria-busy={isFiring}
|
||||
>
|
||||
{isFiring && <UiLoadingSpinner className="size-4" />}
|
||||
{isFiring ? "Firing..." : "Fire Kill Switch"}
|
||||
{isFiring ? t("agents.dangerZone.confirm.firing") : t("agents.dangerZone.fire")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Info, Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAgentsList, deleteAgentCall } from "@/components/networking";
|
||||
import AddAgentForm from "./add_agent_form";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
|
|
@ -33,6 +34,7 @@ interface AgentsResponse {
|
|||
|
||||
const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams }) => {
|
||||
const term = useTerminology();
|
||||
const { t } = useTranslation();
|
||||
const [agentsList, setAgentsList] = useState<Agent[]>([]);
|
||||
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -122,11 +124,11 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
|
|||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAgentCall(accessToken, agentToDelete.id);
|
||||
toast.success(`Agent "${agentToDelete.name}" deleted successfully`);
|
||||
toast.success(t("agents.panel.toasts.deleted", { name: agentToDelete.name }));
|
||||
await refetchAgents(healthCheckEnabled);
|
||||
} catch (error) {
|
||||
console.error("Error deleting agent:", error);
|
||||
toast.fromError("Failed to delete agent");
|
||||
toast.fromError(t("agents.panel.toasts.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setAgentToDelete(null);
|
||||
|
|
@ -140,25 +142,18 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
|
|||
return (
|
||||
<div className="w-full mx-auto flex-auto overflow-y-auto m-8 p-2">
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<h1 className="text-2xl font-bold">Agents</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{term(
|
||||
"List of A2A-spec agents that are available to be used in your organization. Go to AI Hub, to make agents public.",
|
||||
)}
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold">{t("agents.panel.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{term(t("agents.panel.description"))}</p>
|
||||
<Alert className="mb-3">
|
||||
<Info />
|
||||
<AlertTitle>How do agents authenticate?</AlertTitle>
|
||||
<AlertDescription>
|
||||
Agents can authenticate with a virtual key or a trusted identity provider using JWT. Configure an identity
|
||||
binding when adding or editing an agent. JWT authentication does not require a virtual key.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("agents.panel.authAlert.title")}</AlertTitle>
|
||||
<AlertDescription>{t("agents.panel.authAlert.description")}</AlertDescription>
|
||||
</Alert>
|
||||
{isAdmin && (
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
<Button onClick={handleAddAgent} disabled={!accessToken}>
|
||||
<Plus />
|
||||
Add New Agent
|
||||
{t("agents.panel.addAgent")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -201,15 +196,15 @@ const AgentsPanel: React.FC<AgentsPanelProps> = ({ accessToken, userRole, teams
|
|||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Agent</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("agents.panel.deleteDialog.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete agent: {agentToDelete.name}? This action cannot be undone.
|
||||
{t("agents.panel.deleteDialog.description", { name: agentToDelete.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("agents.panel.deleteDialog.cancel")}</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
|
||||
Delete
|
||||
{t("agents.panel.deleteDialog.confirm")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Bot, CircleCheck, Search as SearchIcon, X } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Agent } from "@/components/agents/types";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
|
@ -27,16 +28,17 @@ interface AgentsTableProps {
|
|||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
function EmptyState({ isFiltered }: { isFiltered: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Bot className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">{isFiltered ? "No matching agents" : "No agents yet"}</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{isFiltered ? t("agents.table.empty.filteredTitle") : t("agents.table.empty.title")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isFiltered
|
||||
? "Adjust the search to see more agents."
|
||||
: "Add an agent to make it available in your organization."}
|
||||
{isFiltered ? t("agents.table.empty.filteredDescription") : t("agents.table.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -52,6 +54,7 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
|
|||
onAgentClick,
|
||||
onDeleteClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const filteredAgents = useMemo(
|
||||
|
|
@ -79,7 +82,7 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
|
|||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading agents…"
|
||||
loadingMessage={t("agents.table.loading")}
|
||||
noDataMessage={<EmptyState isFiltered={agents.length > 0} />}
|
||||
size="compact"
|
||||
toolbar={() => (
|
||||
|
|
@ -89,13 +92,17 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
|
|||
<SearchIcon className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search agents by name, ID, or description..."
|
||||
placeholder={t("agents.table.searchPlaceholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchTerm("")}>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={t("agents.table.clearSearch")}
|
||||
onClick={() => setSearchTerm("")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
|
|
@ -109,7 +116,7 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
|
|||
<CircleCheck
|
||||
className={healthCheckEnabled ? "size-4 text-success" : "size-4 text-muted-foreground"}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Health Check</span>
|
||||
<span className="text-sm text-muted-foreground">{t("agents.table.healthCheck.label")}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={healthCheckEnabled}
|
||||
|
|
@ -119,7 +126,7 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
|
|||
</div>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>When enabled, only agents with reachable URLs are shown</TooltipContent>
|
||||
<TooltipContent>{t("agents.table.healthCheck.hint")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MoreHorizontal, Trash2 } from "lucide-react";
|
||||
|
||||
import { Agent } from "@/components/agents/types";
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
interface AgentRowActionsProps {
|
||||
|
|
@ -22,10 +24,11 @@ interface AgentRowActionsProps {
|
|||
}
|
||||
|
||||
function AgentRowActions({ agent, onDeleteClick }: AgentRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open agent actions"
|
||||
aria-label={t("agents.table.actions.open")}
|
||||
data-testid={`agent-actions-${agent.agent_id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -38,7 +41,7 @@ function AgentRowActions({ agent, onDeleteClick }: AgentRowActionsProps) {
|
|||
onClick={() => onDeleteClick(agent.agent_id, agent.agent_name)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("agents.table.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -59,8 +62,8 @@ export const getAgentsTableColumns = ({
|
|||
{
|
||||
id: "agent_name",
|
||||
accessorKey: "agent_name",
|
||||
meta: { title: "Agent Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Agent Name" />,
|
||||
meta: { title: i18n.t("agents.table.columns.name") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("agents.table.columns.name")} />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -75,8 +78,8 @@ export const getAgentsTableColumns = ({
|
|||
{
|
||||
id: "agent_id",
|
||||
accessorKey: "agent_id",
|
||||
meta: { title: "Agent ID" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Agent ID" />,
|
||||
meta: { title: i18n.t("agents.table.columns.id") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("agents.table.columns.id")} />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -90,22 +93,22 @@ export const getAgentsTableColumns = ({
|
|||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend (USD)", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
|
||||
meta: { title: i18n.t("agents.table.columns.spend"), numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("agents.table.columns.spend")} />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "model",
|
||||
meta: { title: "Model" },
|
||||
header: "Model",
|
||||
meta: { title: i18n.t("agents.table.columns.model") },
|
||||
header: i18n.t("agents.table.columns.model"),
|
||||
size: 170,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original.litellm_params?.model;
|
||||
if (!model) {
|
||||
return <span className="text-muted-foreground">N/A</span>;
|
||||
return <span className="text-muted-foreground">{i18n.t("agents.table.noModel")}</span>;
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="max-w-40 font-normal">
|
||||
|
|
@ -122,25 +125,26 @@ export const getAgentsTableColumns = ({
|
|||
const timestamp = agent.created_at ? new Date(agent.created_at).getTime() : 0;
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
},
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
|
||||
meta: { title: i18n.t("agents.table.columns.created") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("agents.table.columns.created")} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
meta: { title: "Status" },
|
||||
header: "Status",
|
||||
meta: { title: i18n.t("agents.table.columns.status") },
|
||||
header: i18n.t("agents.table.columns.status"),
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const hasKeys = (row.original.keys?.length ?? 0) > 0;
|
||||
if (row.original.jwt_auth_configured) return <StatusBadge tone="success" label="JWT configured" />;
|
||||
if (row.original.jwt_auth_configured)
|
||||
return <StatusBadge tone="success" label={i18n.t("agents.table.status.jwtConfigured")} />;
|
||||
return hasKeys ? (
|
||||
<StatusBadge tone="success" label="Active" />
|
||||
<StatusBadge tone="success" label={i18n.t("agents.table.status.active")} />
|
||||
) : (
|
||||
<StatusBadge tone="warning" label="Needs Setup" />
|
||||
<StatusBadge tone="warning" label={i18n.t("agents.table.status.needsSetup")} />
|
||||
);
|
||||
},
|
||||
},
|
||||
|
|
@ -149,7 +153,7 @@ export const getAgentsTableColumns = ({
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{i18n.t("agents.table.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { useFieldArray, useFormContext, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -7,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Field, FieldTitle } from "@/components/ui/field";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { AgentFormField, AgentFormValues, labelWithHint } from "./AgentFormKit";
|
||||
import { KILL_SWITCH_AUTH_TYPES, KILL_SWITCH_METHODS, validateKillSwitchBody } from "./kill_switch_config";
|
||||
|
||||
|
|
@ -21,6 +23,7 @@ const KeyValueFieldArray = ({
|
|||
keyPlaceholder: string;
|
||||
valuePlaceholder: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<AgentFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name });
|
||||
|
||||
|
|
@ -28,7 +31,10 @@ const KeyValueFieldArray = ({
|
|||
<div className="flex flex-col gap-2">
|
||||
{fields.map((item, index) => (
|
||||
<div key={item.id} className="flex items-start gap-2">
|
||||
<AgentFormField name={`${name}.${index}.key`} rules={{ required: "Name required" }}>
|
||||
<AgentFormField
|
||||
name={`${name}.${index}.key`}
|
||||
rules={{ required: t("agents.killSwitchFields.errors.nameRequired") }}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
|
|
@ -56,7 +62,7 @@ const KeyValueFieldArray = ({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove ${addLabel.replace(/^Add /, "").toLowerCase()}`}
|
||||
aria-label={t("agents.killSwitchFields.removeItem", { item: addLabel.replace(/^Add /, "").toLowerCase() })}
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
|
|
@ -109,24 +115,50 @@ const TextField = ({
|
|||
);
|
||||
|
||||
const KillSwitchAuthFields = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<AgentFormValues>();
|
||||
const authType = useWatch({ control, name: "kill_switch.auth_type" });
|
||||
|
||||
switch (authType) {
|
||||
case "bearer":
|
||||
return <TextField name="kill_switch.auth_token" label="Bearer token" required="Token required" secret />;
|
||||
return (
|
||||
<TextField
|
||||
name="kill_switch.auth_token"
|
||||
label={t("agents.killSwitchFields.auth.bearerToken")}
|
||||
required={t("agents.killSwitchFields.errors.tokenRequired")}
|
||||
secret
|
||||
/>
|
||||
);
|
||||
case "api_key":
|
||||
return (
|
||||
<>
|
||||
<TextField name="kill_switch.auth_header_name" label="Header name" placeholder="X-API-Key" />
|
||||
<TextField name="kill_switch.auth_api_key" label="API key" required="API key required" secret />
|
||||
<TextField
|
||||
name="kill_switch.auth_header_name"
|
||||
label={t("agents.killSwitchFields.auth.headerName")}
|
||||
placeholder="X-API-Key"
|
||||
/>
|
||||
<TextField
|
||||
name="kill_switch.auth_api_key"
|
||||
label={t("agents.killSwitchFields.auth.apiKey")}
|
||||
required={t("agents.killSwitchFields.errors.apiKeyRequired")}
|
||||
secret
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "basic":
|
||||
return (
|
||||
<>
|
||||
<TextField name="kill_switch.auth_username" label="Username" required="Username required" />
|
||||
<TextField name="kill_switch.auth_password" label="Password" required="Password required" secret />
|
||||
<TextField
|
||||
name="kill_switch.auth_username"
|
||||
label={t("agents.killSwitchFields.auth.username")}
|
||||
required={t("agents.killSwitchFields.errors.usernameRequired")}
|
||||
/>
|
||||
<TextField
|
||||
name="kill_switch.auth_password"
|
||||
label={t("agents.killSwitchFields.auth.password")}
|
||||
required={t("agents.killSwitchFields.errors.passwordRequired")}
|
||||
secret
|
||||
/>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
|
|
@ -139,13 +171,13 @@ const KillSwitchFormFields = () => (
|
|||
<TextField
|
||||
name="kill_switch.url"
|
||||
label={labelWithHint(
|
||||
"Webhook URL",
|
||||
"Absolute http(s) URL LiteLLM calls when the kill switch is triggered. Leave empty to remove the kill switch.",
|
||||
i18n.t("agents.killSwitchFields.webhookUrl.label"),
|
||||
i18n.t("agents.killSwitchFields.webhookUrl.hint"),
|
||||
)}
|
||||
placeholder="https://example.com/hooks/kill-agent"
|
||||
/>
|
||||
|
||||
<AgentFormField name="kill_switch.method" label="Method">
|
||||
<AgentFormField name="kill_switch.method" label={i18n.t("agents.killSwitchFields.method")}>
|
||||
{({ value, onChange, ref: _ref, ...control }) => (
|
||||
<Select value={typeof value === "string" ? value : "POST"} onValueChange={onChange}>
|
||||
<SelectTrigger {...control} className="w-full">
|
||||
|
|
@ -163,28 +195,28 @@ const KillSwitchFormFields = () => (
|
|||
</AgentFormField>
|
||||
|
||||
<Field>
|
||||
<FieldTitle>Headers</FieldTitle>
|
||||
<FieldTitle>{i18n.t("agents.killSwitchFields.headers.title")}</FieldTitle>
|
||||
<KeyValueFieldArray
|
||||
name="kill_switch.headers"
|
||||
addLabel="Add Header"
|
||||
keyPlaceholder="Header name"
|
||||
valuePlaceholder="Header value"
|
||||
addLabel={i18n.t("agents.killSwitchFields.headers.add")}
|
||||
keyPlaceholder={i18n.t("agents.killSwitchFields.headers.keyPlaceholder")}
|
||||
valuePlaceholder={i18n.t("agents.killSwitchFields.headers.valuePlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldTitle>Query Parameters</FieldTitle>
|
||||
<FieldTitle>{i18n.t("agents.killSwitchFields.queryParams.title")}</FieldTitle>
|
||||
<KeyValueFieldArray
|
||||
name="kill_switch.query_params"
|
||||
addLabel="Add Query Parameter"
|
||||
keyPlaceholder="Parameter name"
|
||||
valuePlaceholder="Parameter value"
|
||||
addLabel={i18n.t("agents.killSwitchFields.queryParams.add")}
|
||||
keyPlaceholder={i18n.t("agents.killSwitchFields.queryParams.keyPlaceholder")}
|
||||
valuePlaceholder={i18n.t("agents.killSwitchFields.queryParams.valuePlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<AgentFormField
|
||||
name="kill_switch.body"
|
||||
label={labelWithHint("JSON Body", "Optional JSON object sent as the request body")}
|
||||
label={labelWithHint(i18n.t("agents.killSwitchFields.body.label"), i18n.t("agents.killSwitchFields.body.hint"))}
|
||||
rules={{ validate: (value) => validateKillSwitchBody(typeof value === "string" ? value : "") }}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
|
|
@ -199,7 +231,7 @@ const KillSwitchFormFields = () => (
|
|||
)}
|
||||
</AgentFormField>
|
||||
|
||||
<AgentFormField name="kill_switch.auth_type" label="Authentication">
|
||||
<AgentFormField name="kill_switch.auth_type" label={i18n.t("agents.killSwitchFields.authentication")}>
|
||||
{({ value, onChange, ref: _ref, ...control }) => (
|
||||
<Select value={typeof value === "string" ? value : "none"} onValueChange={onChange}>
|
||||
<SelectTrigger {...control} className="w-full">
|
||||
|
|
@ -208,7 +240,7 @@ const KillSwitchFormFields = () => (
|
|||
<SelectContent>
|
||||
{KILL_SWITCH_AUTH_TYPES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{i18n.t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { FormProvider, useForm, useWatch } from "react-hook-form";
|
|||
import { toast } from "@/lib/toast";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { Bot, Check, CircleCheck, Key, LayoutGrid } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import CreatedKeyDisplay from "@/components/shared/CreatedKeyDisplay";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusBadge } from "@/components/shared/table_cells/status_badge";
|
||||
|
|
@ -55,10 +56,17 @@ import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermis
|
|||
import AccessGroupSelector from "@/components/common_components/AccessGroupSelector";
|
||||
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
const CUSTOM_AGENT_TYPE = "custom";
|
||||
|
||||
const STEP_TITLES = ["Configure", "Entitlements", "Governance", "Agent Management", "Ready"] as const;
|
||||
const STEP_TITLES = [
|
||||
"agents.addForm.steps.configure",
|
||||
"agents.addForm.steps.entitlements",
|
||||
"agents.addForm.steps.governance",
|
||||
"agents.addForm.steps.agentManagement",
|
||||
"agents.addForm.steps.ready",
|
||||
] as const;
|
||||
|
||||
const stepMarkerClass = (index: number, current: number): string => {
|
||||
if (index < current) return "border-primary text-primary";
|
||||
|
|
@ -73,11 +81,12 @@ const stepTitleClass = (index: number, current: number): string => {
|
|||
};
|
||||
|
||||
const AgentTypeLabel: React.FC<{ agentType: string; info: AgentCreateInfo | undefined }> = ({ agentType, info }) => {
|
||||
const { t } = useTranslation();
|
||||
if (agentType === CUSTOM_AGENT_TYPE) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutGrid className="size-4 text-warning" />
|
||||
<span>Custom / Other</span>
|
||||
<span>{t("agents.addForm.agentType.custom")}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -91,7 +100,7 @@ const AgentTypeLabel: React.FC<{ agentType: string; info: AgentCreateInfo | unde
|
|||
};
|
||||
|
||||
const StepProgress: React.FC<{ current: number }> = ({ current }) => (
|
||||
<ol aria-label="Agent creation steps" className="mb-8 flex items-center">
|
||||
<ol aria-label={i18n.t("agents.addForm.steps.ariaLabel")} className="mb-8 flex items-center">
|
||||
{STEP_TITLES.map((title, index) => (
|
||||
<li
|
||||
key={title}
|
||||
|
|
@ -104,7 +113,7 @@ const StepProgress: React.FC<{ current: number }> = ({ current }) => (
|
|||
>
|
||||
{index < current ? <Check className="size-3.5" /> : index + 1}
|
||||
</span>
|
||||
<span className={`text-xs whitespace-nowrap ${stepTitleClass(index, current)}`}>{title}</span>
|
||||
<span className={`text-xs whitespace-nowrap ${stepTitleClass(index, current)}`}>{i18n.t(title)}</span>
|
||||
{index < STEP_TITLES.length - 1 && <span aria-hidden="true" className="mx-2 h-px flex-1 bg-border" />}
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -132,6 +141,7 @@ interface AddAgentFormProps {
|
|||
}
|
||||
|
||||
const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessToken, onSuccess, teams }) => {
|
||||
const { t } = useTranslation();
|
||||
const { userId, userRole } = useAuthorized();
|
||||
const form = useForm<AgentFormValues>({ defaultValues: buildInitialValues("a2a") });
|
||||
const panels = useCollapsiblePanels([AGENT_FORM_CONFIG.basic.key]);
|
||||
|
|
@ -339,12 +349,12 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
|
||||
const handleCreateAgent = async () => {
|
||||
if (!accessToken) {
|
||||
toast.error("No access token available");
|
||||
toast.error(t("agents.addForm.toasts.noAccessToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyAssignOption === "existing_key" && !selectedExistingKey) {
|
||||
toast.error("Please select an existing key to assign");
|
||||
toast.error(t("agents.addForm.toasts.selectExistingKey"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -358,7 +368,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
const values = form.getValues();
|
||||
const built = buildAgentData(values);
|
||||
if (!built) {
|
||||
toast.error("Failed to build agent data");
|
||||
toast.error(t("agents.addForm.toasts.buildFailed"));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
|
@ -434,7 +444,11 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
} catch (error) {
|
||||
console.error("Error creating agent:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
toast.error(errorMessage ? `Failed to create agent: ${errorMessage}` : "Failed to create agent");
|
||||
toast.error(
|
||||
errorMessage
|
||||
? t("agents.addForm.toasts.createFailedWithError", { error: errorMessage })
|
||||
: t("agents.addForm.toasts.createFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
|
@ -461,17 +475,14 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
|
||||
const renderEntitlementsStep = () => (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure which models, agents, and MCP tools this agent is allowed to use. Leave fields empty to allow all
|
||||
(subject to key/team permissions).
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("agents.addForm.entitlements.description")}</p>
|
||||
|
||||
<FieldGroup>
|
||||
<AgentFormField
|
||||
name="entitlement_models"
|
||||
label={labelWithHint(
|
||||
"Allowed Models",
|
||||
"Restrict which models this agent can call. Leave empty to allow all.",
|
||||
t("agents.addForm.entitlements.models.label"),
|
||||
t("agents.addForm.entitlements.models.hint"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
|
|
@ -479,7 +490,11 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
id={id}
|
||||
value={Array.isArray(value) ? (value as string[]) : []}
|
||||
onValueChange={onChange}
|
||||
placeholder={loadingModels ? "Loading models..." : "Select models (leave empty for all)"}
|
||||
placeholder={
|
||||
loadingModels
|
||||
? t("agents.addForm.entitlements.models.loading")
|
||||
: t("agents.addForm.entitlements.models.placeholder")
|
||||
}
|
||||
options={availableModels.map((m) => ({ label: getModelDisplayName(m), value: m }))}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -488,8 +503,8 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<AgentFormField
|
||||
name="entitlement_agents"
|
||||
label={labelWithHint(
|
||||
"Allowed Agents (Sub-Agents)",
|
||||
"Restrict which other agents this agent can invoke as sub-agents. Leave empty to allow all.",
|
||||
t("agents.addForm.entitlements.agents.label"),
|
||||
t("agents.addForm.entitlements.agents.hint"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
|
|
@ -497,7 +512,11 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
id={id}
|
||||
value={Array.isArray(value) ? (value as string[]) : []}
|
||||
onValueChange={onChange}
|
||||
placeholder={loadingAgents ? "Loading agents..." : "Select agents (leave empty for all)"}
|
||||
placeholder={
|
||||
loadingAgents
|
||||
? t("agents.addForm.entitlements.agents.loading")
|
||||
: t("agents.addForm.entitlements.agents.placeholder")
|
||||
}
|
||||
options={availableAgents.map((a) => ({ label: a.agent_name, value: a.agent_id }))}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -506,15 +525,15 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<AgentFormField
|
||||
name="access_group_ids"
|
||||
label={labelWithHint(
|
||||
"Access Groups",
|
||||
"Attach access groups to this agent. Attached groups cap which models, MCP servers, and agents the agent can reach, on top of its key and team permissions. Leave empty to apply no extra cap.",
|
||||
t("agents.addForm.entitlements.accessGroups.label"),
|
||||
t("agents.addForm.entitlements.accessGroups.hint"),
|
||||
)}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<AccessGroupSelector
|
||||
value={Array.isArray(value) ? (value as string[]) : []}
|
||||
onChange={onChange}
|
||||
placeholder="Select access groups (optional)"
|
||||
placeholder={t("agents.addForm.entitlements.accessGroups.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
|
@ -524,8 +543,8 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<AgentFormField
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
label={labelWithHint(
|
||||
"Allowed MCP Servers",
|
||||
"Select which MCP servers or access groups this agent can access",
|
||||
t("agents.addForm.entitlements.mcpServers.label"),
|
||||
t("agents.addForm.entitlements.mcpServers.hint"),
|
||||
)}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
|
|
@ -536,7 +555,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
accessGroups: (value as McpServerSelection | undefined)?.accessGroups ?? [],
|
||||
}}
|
||||
accessToken={accessToken ?? ""}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
placeholder={t("agents.addForm.entitlements.mcpServers.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
|
@ -574,15 +593,15 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
const renderObservabilityStep = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium text-foreground">Tracing</h4>
|
||||
<h4 className="mb-3 text-sm font-medium text-foreground">{t("agents.addForm.governance.tracing.title")}</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Require x-litellm-trace-id on calls TO this agent
|
||||
{t("agents.addForm.governance.tracing.inbound.label", { header: "x-litellm-trace-id" })}
|
||||
</span>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Only accept this agent being invoked with a trace-id (e.g. when used as a sub-agent).
|
||||
{t("agents.addForm.governance.tracing.inbound.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={requireTraceIdInbound} onCheckedChange={setRequireTraceIdInbound} />
|
||||
|
|
@ -591,10 +610,10 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Require x-litellm-trace-id on calls BY this agent
|
||||
{t("agents.addForm.governance.tracing.outbound.label", { header: "x-litellm-trace-id" })}
|
||||
</span>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Requires LLM/MCP calls made by this agent to include x-litellm-trace-id for session tracking.
|
||||
{t("agents.addForm.governance.tracing.outbound.description", { header: "x-litellm-trace-id" })}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
|
@ -614,24 +633,27 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium text-foreground">Budgets & Rate Limits</h4>
|
||||
<h4 className="mb-3 text-sm font-medium text-foreground">{t("agents.addForm.governance.budgets.title")}</h4>
|
||||
<div className="space-y-4">
|
||||
{!requireTraceIdOutbound && (
|
||||
<div className="rounded-lg border border-warning/20 bg-warning/10 p-3 text-sm text-warning">
|
||||
Enable "Require x-litellm-trace-id on calls BY this agent" in Tracing to configure budgets and
|
||||
rate limits.
|
||||
{t("agents.addForm.governance.budgets.requiresOutbound", { header: "x-litellm-trace-id" })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm font-medium text-foreground">Session Budgets</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("agents.addForm.governance.budgets.session.title")}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="agent-max-iterations">Max Iterations</FieldLabel>
|
||||
<FieldLabel htmlFor="agent-max-iterations">
|
||||
{t("agents.addForm.governance.budgets.session.maxIterations.label")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="agent-max-iterations"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="e.g. 25"
|
||||
placeholder={t("agents.addForm.governance.budgets.session.maxIterations.placeholder")}
|
||||
disabled={!requireTraceIdOutbound}
|
||||
value={maxIterations ?? ""}
|
||||
onChange={(event) =>
|
||||
|
|
@ -639,15 +661,19 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
}
|
||||
onBlur={() => setMaxIterations((current) => (current !== null && current < 1 ? 1 : current))}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Hard cap on LLM calls per session</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("agents.addForm.governance.budgets.session.maxIterations.hint")}
|
||||
</p>
|
||||
</Field>
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="agent-max-budget-per-session">Max Budget Per Session ($)</FieldLabel>
|
||||
<FieldLabel htmlFor="agent-max-budget-per-session">
|
||||
{t("agents.addForm.governance.budgets.session.maxBudget.label")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="agent-max-budget-per-session"
|
||||
type="number"
|
||||
step="any"
|
||||
placeholder="e.g. 5.00"
|
||||
placeholder={t("agents.addForm.governance.budgets.session.maxBudget.placeholder")}
|
||||
disabled={!requireTraceIdOutbound}
|
||||
value={maxBudgetPerSession ?? ""}
|
||||
onChange={(event) =>
|
||||
|
|
@ -657,26 +683,48 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
setMaxBudgetPerSession((current) => (current !== null && current < 0.01 ? 0.01 : current))
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Max spend per trace before returning 429</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("agents.addForm.governance.budgets.session.maxBudget.hint")}
|
||||
</p>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="text-sm font-medium text-foreground">Agent Rate Limits</div>
|
||||
<p className="text-xs text-muted-foreground">Global rate limits applied across all callers of this agent.</p>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("agents.addForm.governance.rateLimits.agent.title")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("agents.addForm.governance.rateLimits.agent.description")}</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{rateLimitField("tpm_limit", "TPM Limit", "e.g. 100000")}
|
||||
{rateLimitField("rpm_limit", "RPM Limit", "e.g. 100")}
|
||||
{rateLimitField(
|
||||
"tpm_limit",
|
||||
t("agents.addForm.governance.rateLimits.agent.tpm.label"),
|
||||
t("agents.addForm.governance.rateLimits.agent.tpm.placeholder"),
|
||||
)}
|
||||
{rateLimitField(
|
||||
"rpm_limit",
|
||||
t("agents.addForm.governance.rateLimits.agent.rpm.label"),
|
||||
t("agents.addForm.governance.rateLimits.agent.rpm.placeholder"),
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm font-medium text-foreground">Per-Session Rate Limits</div>
|
||||
<div className="mt-4 text-sm font-medium text-foreground">
|
||||
{t("agents.addForm.governance.rateLimits.session.title")}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Rate limits per session (x-litellm-trace-id). Each session gets its own counters.
|
||||
{t("agents.addForm.governance.rateLimits.session.description", { header: "x-litellm-trace-id" })}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{rateLimitField("session_tpm_limit", "Session TPM Limit", "e.g. 10000")}
|
||||
{rateLimitField("session_rpm_limit", "Session RPM Limit", "e.g. 20")}
|
||||
{rateLimitField(
|
||||
"session_tpm_limit",
|
||||
t("agents.addForm.governance.rateLimits.session.tpm.label"),
|
||||
t("agents.addForm.governance.rateLimits.session.tpm.placeholder"),
|
||||
)}
|
||||
{rateLimitField(
|
||||
"session_rpm_limit",
|
||||
t("agents.addForm.governance.rateLimits.session.rpm.label"),
|
||||
t("agents.addForm.governance.rateLimits.session.rpm.placeholder"),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -684,10 +732,8 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium text-foreground">Guardrails</h4>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Apply guardrails to this agent. Selected guardrails will run on all calls made by this agent.
|
||||
</p>
|
||||
<h4 className="mb-3 text-sm font-medium text-foreground">{t("agents.addForm.governance.guardrails.title")}</h4>
|
||||
<p className="mb-3 text-xs text-muted-foreground">{t("agents.addForm.governance.guardrails.description")}</p>
|
||||
<AgentFormField name="guardrails">
|
||||
{({ value, onChange }) => (
|
||||
<GuardrailSelector
|
||||
|
|
@ -763,7 +809,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<>
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="agent-type">
|
||||
{labelWithHint("Agent Type", "Select the type of agent you want to create")}
|
||||
{labelWithHint(t("agents.addForm.configure.agentType.label"), t("agents.addForm.configure.agentType.hint"))}
|
||||
</FieldLabel>
|
||||
<Select value={agentType} onValueChange={(value) => value !== null && handleAgentTypeChange(value)}>
|
||||
<SelectTrigger id="agent-type" className="h-10 w-full">
|
||||
|
|
@ -785,18 +831,22 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
))}
|
||||
<SelectSeparator />
|
||||
<div className="mb-1 px-2 text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Not listed?
|
||||
{t("agents.addForm.configure.agentType.notListed")}
|
||||
</div>
|
||||
<SelectItem value={CUSTOM_AGENT_TYPE} className="focus:bg-warning/10">
|
||||
<span className="flex items-center gap-3">
|
||||
<LayoutGrid className="size-4.5 shrink-0 text-warning" />
|
||||
<span className="block">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-medium text-warning">Custom / Other</span>
|
||||
<StatusBadge tone="warning" label="GENERIC" className="h-4 px-1 text-[10px]" />
|
||||
<span className="font-medium text-warning">{t("agents.addForm.agentType.custom")}</span>
|
||||
<StatusBadge
|
||||
tone="warning"
|
||||
label={t("agents.addForm.configure.agentType.genericBadge")}
|
||||
className="h-4 px-1 text-[10px]"
|
||||
/>
|
||||
</span>
|
||||
<span className="block text-xs whitespace-normal text-warning">
|
||||
For outbound agents using an identity provider or virtual key
|
||||
{t("agents.addForm.configure.agentType.customDescription")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -810,24 +860,28 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<div className="mt-4">
|
||||
{agentType === CUSTOM_AGENT_TYPE ? (
|
||||
<FieldGroup>
|
||||
<AgentFormField name="agent_name" label="Agent Name" rules={{ required: "Please enter an agent name" }}>
|
||||
<AgentFormField
|
||||
name="agent_name"
|
||||
label={t("agents.addForm.configure.custom.agentName.label")}
|
||||
rules={{ required: t("agents.addForm.configure.custom.agentName.required") }}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
placeholder="e.g. my-custom-agent"
|
||||
placeholder={t("agents.addForm.configure.custom.agentName.placeholder")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<AgentFormField name="description" label="Description">
|
||||
<AgentFormField name="description" label={t("agents.addForm.configure.custom.description.label")}>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Textarea
|
||||
{...control}
|
||||
ref={ref}
|
||||
rows={3}
|
||||
placeholder="Describe what this agent does…"
|
||||
placeholder={t("agents.addForm.configure.custom.description.placeholder")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
|
@ -842,7 +896,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
{selectedAgentTypeInfo.credential_fields.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-border p-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-foreground">
|
||||
{selectedAgentTypeInfo.agent_type_display_name} Settings
|
||||
{t("agents.addForm.configure.typeSettings", { type: selectedAgentTypeInfo.agent_type_display_name })}
|
||||
</h4>
|
||||
<FieldGroup>
|
||||
{selectedAgentTypeInfo.credential_fields.map((field) => (
|
||||
|
|
@ -851,7 +905,11 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
name={field.key}
|
||||
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
|
||||
defaultValue={field.default_value ?? undefined}
|
||||
rules={field.required ? { required: `Please enter ${field.label}` } : undefined}
|
||||
rules={
|
||||
field.required
|
||||
? { required: t("agents.addForm.configure.enterField", { field: field.label }) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) =>
|
||||
field.field_type === "password" ? (
|
||||
|
|
@ -901,7 +959,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
);
|
||||
|
||||
const renderAssignKeyStep = () => {
|
||||
const agentName = form.getValues("agent_name") || "your-agent";
|
||||
const agentName = form.getValues("agent_name") || t("agents.addForm.assignKey.agentNameFallback");
|
||||
return (
|
||||
<div>
|
||||
{/* Agent name chip */}
|
||||
|
|
@ -914,10 +972,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
|
||||
<AgentFormField
|
||||
name="team_id"
|
||||
label={labelWithHint(
|
||||
"Assign to Team",
|
||||
"Optionally select a team for the virtual key. The agent identity and its permissions are managed separately.",
|
||||
)}
|
||||
label={labelWithHint(t("agents.addForm.assignKey.team.label"), t("agents.addForm.assignKey.team.hint"))}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<TeamDropdown value={typeof value === "string" ? value : undefined} onChange={onChange} />
|
||||
|
|
@ -927,9 +982,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<Separator className="my-4" />
|
||||
|
||||
{form.getValues("identity_provider") === "microsoft_entra" && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
This agent will authenticate with Microsoft Entra ID. You can skip virtual key creation.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t("agents.addForm.assignKey.entraNotice")}</p>
|
||||
)}
|
||||
<RadioGroup
|
||||
value={keyAssignOption}
|
||||
|
|
@ -947,29 +1000,33 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-1 items-start gap-3">
|
||||
<RadioGroupItem value="create_new" aria-label="Create a new key for this agent" />
|
||||
<RadioGroupItem value="create_new" aria-label={t("agents.addForm.assignKey.createNew.title")} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="size-4 text-info" />
|
||||
<span className="font-medium text-foreground">Create a new key for this agent</span>
|
||||
<span className="font-medium text-foreground">{t("agents.addForm.assignKey.createNew.title")}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">A dedicated key scoped to this agent.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("agents.addForm.assignKey.createNew.description")}
|
||||
</p>
|
||||
{keyAssignOption === "create_new" && (
|
||||
<div className="mt-3 space-y-3" onClick={(e) => e.stopPropagation()}>
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor="agent-new-key-name">Key Name</FieldLabel>
|
||||
<FieldLabel htmlFor="agent-new-key-name">
|
||||
{t("agents.addForm.assignKey.createNew.keyName.label")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="agent-new-key-name"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="e.g. my-agent-key"
|
||||
placeholder={t("agents.addForm.assignKey.createNew.keyName.placeholder")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge tone="success" label="Recommended" />
|
||||
<StatusBadge tone="success" label={t("agents.addForm.assignKey.createNew.recommended")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -983,18 +1040,24 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
onClick={() => setKeyAssignOption("existing_key")}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<RadioGroupItem value="existing_key" aria-label="Assign an existing key" />
|
||||
<RadioGroupItem value="existing_key" aria-label={t("agents.addForm.assignKey.existing.title")} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">Assign an existing key</span>
|
||||
<span className="font-medium text-foreground">{t("agents.addForm.assignKey.existing.title")}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Re-assign a key you already have to this agent.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("agents.addForm.assignKey.existing.description")}
|
||||
</p>
|
||||
{keyAssignOption === "existing_key" && (
|
||||
<div className="mt-3" onClick={(e) => e.stopPropagation()}>
|
||||
<SearchSelect
|
||||
inputId="agent-existing-key"
|
||||
placeholder={loadingKeys ? "Loading keys…" : "Search by key name…"}
|
||||
placeholder={
|
||||
loadingKeys
|
||||
? t("agents.addForm.assignKey.existing.loading")
|
||||
: t("agents.addForm.assignKey.existing.placeholder")
|
||||
}
|
||||
value={selectedExistingKey}
|
||||
onValueChange={setSelectedExistingKey}
|
||||
options={existingKeys.map((k) => ({
|
||||
|
|
@ -1016,8 +1079,8 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
onClick={() => setKeyAssignOption("skip")}
|
||||
>
|
||||
{form.getValues("identity_provider") === "microsoft_entra"
|
||||
? "Use Entra JWT authentication"
|
||||
: "Skip for now, I’ll assign a key later"}
|
||||
? t("agents.addForm.assignKey.skip.entra")
|
||||
: t("agents.addForm.assignKey.skip.later")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1027,7 +1090,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
const renderReadyStep = () => (
|
||||
<div className="py-6 text-center">
|
||||
<CircleCheck className="mb-4 size-12 text-success" />
|
||||
<h3 className="mb-2 text-xl font-semibold text-foreground">Agent Created!</h3>
|
||||
<h3 className="mb-2 text-xl font-semibold text-foreground">{t("agents.addForm.ready.title")}</h3>
|
||||
<div className="mb-4 flex justify-center">
|
||||
<Badge className="h-auto gap-1.5 bg-purple-100 px-3 py-1 text-sm text-purple-700 dark:bg-purple-950 dark:text-purple-300">
|
||||
<Bot className="size-3.5" />
|
||||
|
|
@ -1041,14 +1104,17 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
)}
|
||||
{assignedKeyAlias && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Key <span className="font-medium">{assignedKeyAlias}</span> has been assigned to this agent.
|
||||
<Trans
|
||||
i18nKey="agents.addForm.ready.keyAssigned"
|
||||
components={{ alias: <span className="font-medium">{assignedKeyAlias}</span> }}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{!createdKeyValue && !assignedKeyAlias && keyAssignOption === "skip" && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{form.getValues("identity_provider") === "microsoft_entra"
|
||||
? "Microsoft Entra ID is configured. Send an authenticated agent request to verify the connection."
|
||||
: "No key assigned. You can create one from the Virtual Keys page."}
|
||||
? t("agents.addForm.ready.entraConfigured")
|
||||
: t("agents.addForm.ready.noKey")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1060,9 +1126,15 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<DialogHeader>
|
||||
<div className="flex items-center space-x-3 border-b border-border pb-4">
|
||||
{selectedLogo && currentStep < 1 && (
|
||||
<Logo src={selectedLogo} label="Agent" className="h-6 w-6 object-contain" />
|
||||
<Logo
|
||||
src={selectedLogo}
|
||||
label={t("agents.addForm.dialog.logoLabel")}
|
||||
className="h-6 w-6 object-contain"
|
||||
/>
|
||||
)}
|
||||
<DialogTitle className="text-xl font-semibold text-foreground">Add New Agent</DialogTitle>
|
||||
<DialogTitle className="text-xl font-semibold text-foreground">
|
||||
{t("agents.addForm.dialog.title")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<TooltipProvider>
|
||||
|
|
@ -1083,24 +1155,24 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
<div>
|
||||
{currentStep > 0 && currentStep < 4 && (
|
||||
<Button type="button" variant="outline" onClick={handleBack}>
|
||||
← Back
|
||||
{t("agents.addForm.dialog.back")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{currentStep < 4 && (
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("agents.addForm.dialog.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep < 3 && <Button onClick={handleNext}>Next →</Button>}
|
||||
{currentStep < 3 && <Button onClick={handleNext}>{t("agents.addForm.dialog.next")}</Button>}
|
||||
{currentStep === 3 && (
|
||||
<Button disabled={isSubmitting} aria-busy={isSubmitting} onClick={handleCreateAgent}>
|
||||
{isSubmitting && <UiLoadingSpinner className="size-4" />}
|
||||
{isSubmitting ? "Creating..." : "Create Agent →"}
|
||||
{isSubmitting ? t("agents.addForm.dialog.creating") : t("agents.addForm.dialog.create")}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 4 && <Button onClick={handleClose}>Done</Button>}
|
||||
{currentStep === 4 && <Button onClick={handleClose}>{t("agents.addForm.dialog.done")}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, CircleAlert, CircleCheck, Info, Link as LinkIcon, RotateCw, Search, X } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { DiscoveredAgentCard, discoverAgentCardCall } from "@/components/networking";
|
||||
|
|
@ -63,6 +65,7 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
discoveryRequest,
|
||||
savedAgentCard,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// When the parent drives discovery, ``manualUrl`` is unused — the URL
|
||||
// comes from ``discoveryRequest.url`` directly. When the parent hasn't
|
||||
// supplied a plan, the admin types into this field manually.
|
||||
|
|
@ -117,14 +120,16 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
|
||||
const handleDiscover = useCallback(async () => {
|
||||
if (!accessToken) {
|
||||
setError("No access token available");
|
||||
setError(i18n.t("agents.cardDiscovery.errors.noAccessToken"));
|
||||
onApplyRef.current(null);
|
||||
return;
|
||||
}
|
||||
const trimmed = effectiveUrl.trim();
|
||||
if (!trimmed) {
|
||||
setError(
|
||||
isParentDriven ? "Fill in the agent's connection details above first" : "Enter the agent's base URL first",
|
||||
isParentDriven
|
||||
? i18n.t("agents.cardDiscovery.errors.fillConnectionDetails")
|
||||
: i18n.t("agents.cardDiscovery.errors.enterBaseUrl"),
|
||||
);
|
||||
setCard(null);
|
||||
onApplyRef.current(null);
|
||||
|
|
@ -152,7 +157,7 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
resetSelections(response.agent_card);
|
||||
} catch (e: any) {
|
||||
if (requestId !== discoverRequestIdRef.current) return;
|
||||
setError(e?.message ? String(e.message) : "Failed to discover agent card");
|
||||
setError(e?.message ? String(e.message) : i18n.t("agents.cardDiscovery.errors.discoverFailed"));
|
||||
setCard(null);
|
||||
lastSyncedSelectionRef.current = null;
|
||||
onApplyRef.current(null);
|
||||
|
|
@ -247,13 +252,13 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
if (card) return <RotateCw />;
|
||||
return <Search />;
|
||||
};
|
||||
const discoverLabel = card ? "Re-discover" : "Discover";
|
||||
const discoverLabel = card ? t("agents.cardDiscovery.rediscover") : t("agents.cardDiscovery.discover");
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border border-border bg-muted/50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<LinkIcon className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium text-foreground">Discover from agent URL</span>
|
||||
<span className="text-sm font-medium text-foreground">{t("agents.cardDiscovery.title")}</span>
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
@ -264,20 +269,17 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
LiteLLM will fetch /.well-known/agent-card.json from this URL and let you pick which skills and
|
||||
capabilities to expose through the proxy.
|
||||
{t("agents.cardDiscovery.titleHint", { path: "/.well-known/agent-card.json" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{isParentDriven ? (
|
||||
<>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Using the connection details you entered above. We'll fetch:
|
||||
</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t("agents.cardDiscovery.parentDrivenIntro")}</p>
|
||||
<div className="mb-3 rounded-sm border border-border bg-background px-3 py-2 font-mono text-xs break-all text-foreground">
|
||||
{discoveryRequest!.display_url || effectiveUrl || (
|
||||
<span className="text-muted-foreground italic">Fill in the fields above first</span>
|
||||
<span className="text-muted-foreground italic">{t("agents.cardDiscovery.fillFieldsFirst")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
|
|
@ -290,8 +292,14 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Paste the upstream agent's base URL. We'll try <code>/.well-known/agent-card.json</code>,{" "}
|
||||
<code>/.well-known/agent.json</code>, and <code>/agent.json</code> in order.
|
||||
<Trans
|
||||
i18nKey="agents.cardDiscovery.manualIntro"
|
||||
components={{
|
||||
cardfile: <code>/.well-known/agent-card.json</code>,
|
||||
agentfile: <code>/.well-known/agent.json</code>,
|
||||
rootfile: <code>/agent.json</code>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div className="flex w-full items-center gap-2">
|
||||
|
|
@ -315,10 +323,15 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
{error && (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<CircleAlert />
|
||||
<AlertTitle>Discovery failed</AlertTitle>
|
||||
<AlertTitle>{t("agents.cardDiscovery.failedTitle")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={() => setError(null)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t("agents.cardDiscovery.dismissError")}
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
|
|
@ -335,7 +348,7 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
<div className="mt-4 rounded-lg border border-border bg-background p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<CircleCheck className="size-4 text-success" />
|
||||
<span className="text-sm font-medium text-foreground">Upstream card loaded</span>
|
||||
<span className="text-sm font-medium text-foreground">{t("agents.cardDiscovery.cardLoaded")}</span>
|
||||
{card.version && <Badge variant="secondary">v{card.version}</Badge>}
|
||||
{card.provider?.organization && <Badge variant="secondary">{card.provider.organization}</Badge>}
|
||||
</div>
|
||||
|
|
@ -343,18 +356,24 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
Name (shown to API clients)
|
||||
{t("agents.cardDiscovery.nameLabel")}
|
||||
</label>
|
||||
<Input value={editedName} onChange={(e) => setEditedName(e.target.value)} placeholder="Agent name" />
|
||||
<Input
|
||||
value={editedName}
|
||||
onChange={(e) => setEditedName(e.target.value)}
|
||||
placeholder={t("agents.cardDiscovery.namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Description</label>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
{t("agents.cardDiscovery.descriptionLabel")}
|
||||
</label>
|
||||
<Textarea
|
||||
className="field-sizing-fixed min-h-0"
|
||||
value={editedDescription}
|
||||
onChange={(e) => setEditedDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="What this agent does"
|
||||
placeholder={t("agents.cardDiscovery.descriptionPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -366,17 +385,19 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
render={
|
||||
<button type="button" className="group flex items-center gap-2">
|
||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[panel-open]:rotate-180" />
|
||||
<span className="text-sm font-medium text-foreground">Skills</span>
|
||||
<span className="text-sm font-medium text-foreground">{t("agents.cardDiscovery.skills")}</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Badge variant="secondary">
|
||||
{selectedSkillCount} / {skillCount} selected
|
||||
{t("agents.cardDiscovery.skillsSelected", { selected: selectedSkillCount, total: skillCount })}
|
||||
</Badge>
|
||||
</div>
|
||||
<CollapsibleContent className="pt-2">
|
||||
{skillCount === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">Upstream card has no skills</div>
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t("agents.cardDiscovery.noSkills")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(card.skills ?? []).map((skill, idx) => {
|
||||
|
|
@ -394,9 +415,9 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">{skill.name || id}</span>
|
||||
{skill.id && <Badge variant="secondary">{skill.id}</Badge>}
|
||||
{(skill.tags ?? []).map((t: string) => (
|
||||
<Badge key={t} variant="outline">
|
||||
{t}
|
||||
{(skill.tags ?? []).map((tag: string) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -418,7 +439,9 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
render={
|
||||
<button type="button" className="group flex items-center gap-2">
|
||||
<ChevronDown className="size-4 text-muted-foreground transition-transform group-data-[panel-open]:rotate-180" />
|
||||
<span className="text-sm font-medium text-foreground">Capabilities</span>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("agents.cardDiscovery.capabilities")}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
|
@ -431,10 +454,7 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
</span>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
Only capabilities LiteLLM can faithfully proxy today are listed. Others (push notifications,
|
||||
extensions) are coming soon.
|
||||
</TooltipContent>
|
||||
<TooltipContent>{t("agents.cardDiscovery.capabilitiesHint")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
|
@ -449,7 +469,7 @@ const AgentCardDiscovery: React.FC<AgentCardDiscoveryProps> = ({
|
|||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground capitalize">{key}</span>
|
||||
{!upstreamHas && <Badge variant="outline">not advertised upstream</Badge>}
|
||||
{!upstreamHas && <Badge variant="outline">{t("agents.cardDiscovery.notAdvertised")}</Badge>}
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(selectedCapabilities[key])}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import { parseIdentityForForm } from "./agent_identity";
|
||||
/**
|
||||
* Shared configuration for agent form fields
|
||||
|
|
@ -23,12 +24,12 @@ export interface FieldConfig {
|
|||
rows?: number;
|
||||
validation?: any[];
|
||||
options?: string[];
|
||||
helpText?: string;
|
||||
helpTextKey?: ParseKeys;
|
||||
}
|
||||
|
||||
export interface SectionConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
titleKey: ParseKeys;
|
||||
fields: FieldConfig[];
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
|
@ -44,7 +45,7 @@ export const AGENT_FORM_CONFIG: {
|
|||
} = {
|
||||
basic: {
|
||||
key: "basic",
|
||||
title: "Basic Information",
|
||||
titleKey: "agents.formConfig.basic.title",
|
||||
defaultExpanded: true,
|
||||
fields: [
|
||||
{
|
||||
|
|
@ -85,14 +86,13 @@ export const AGENT_FORM_CONFIG: {
|
|||
defaultValue: "1.0",
|
||||
tooltip:
|
||||
"The A2A protocol version LiteLLM serves to clients for this agent. LiteLLM converts the upstream agent's responses to this version, so clients always see the version you pick here regardless of the original agent's version.",
|
||||
helpText:
|
||||
"LiteLLM serves this version to clients and converts the upstream agent's responses to match it, regardless of the original agent's version.",
|
||||
helpTextKey: "agents.formConfig.basic.protocolVersionHelp",
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
key: "skills",
|
||||
title: "Skills",
|
||||
titleKey: "agents.formConfig.skills.title",
|
||||
fields: [
|
||||
{
|
||||
name: "skills",
|
||||
|
|
@ -104,7 +104,7 @@ export const AGENT_FORM_CONFIG: {
|
|||
},
|
||||
capabilities: {
|
||||
key: "capabilities",
|
||||
title: "Capabilities",
|
||||
titleKey: "agents.formConfig.capabilities.title",
|
||||
fields: [
|
||||
{
|
||||
name: "streaming",
|
||||
|
|
@ -126,7 +126,7 @@ export const AGENT_FORM_CONFIG: {
|
|||
},
|
||||
optional: {
|
||||
key: "optional",
|
||||
title: "Optional Settings",
|
||||
titleKey: "agents.formConfig.optional.title",
|
||||
fields: [
|
||||
{
|
||||
name: "iconUrl",
|
||||
|
|
@ -149,7 +149,7 @@ export const AGENT_FORM_CONFIG: {
|
|||
},
|
||||
litellm: {
|
||||
key: "litellm",
|
||||
title: "LiteLLM Parameters",
|
||||
titleKey: "agents.formConfig.litellm.title",
|
||||
fields: [
|
||||
{
|
||||
name: "model",
|
||||
|
|
@ -165,7 +165,7 @@ export const AGENT_FORM_CONFIG: {
|
|||
},
|
||||
cost: {
|
||||
key: "cost",
|
||||
title: "Cost Configuration",
|
||||
titleKey: "agents.formConfig.cost.title",
|
||||
fields: [
|
||||
{
|
||||
name: "cost_per_query",
|
||||
|
|
@ -192,7 +192,7 @@ export const AGENT_FORM_CONFIG: {
|
|||
},
|
||||
tracing: {
|
||||
key: "tracing",
|
||||
title: "Tracing",
|
||||
titleKey: "agents.formConfig.tracing.title",
|
||||
fields: [
|
||||
{
|
||||
name: "enable_tracing",
|
||||
|
|
@ -208,33 +208,33 @@ export const AGENT_FORM_CONFIG: {
|
|||
export const SKILL_FIELD_CONFIG = {
|
||||
id: {
|
||||
name: "id",
|
||||
label: "Skill ID",
|
||||
labelKey: "agents.formConfig.skillFields.id" as ParseKeys,
|
||||
required: true,
|
||||
placeholder: "e.g., hello_world",
|
||||
placeholderKey: "agents.formConfig.skillFields.idPlaceholder" as ParseKeys,
|
||||
},
|
||||
name: {
|
||||
name: "name",
|
||||
label: "Skill Name",
|
||||
labelKey: "agents.formConfig.skillFields.name" as ParseKeys,
|
||||
required: true,
|
||||
placeholder: "e.g., Returns hello world",
|
||||
placeholderKey: "agents.formConfig.skillFields.namePlaceholder" as ParseKeys,
|
||||
},
|
||||
description: {
|
||||
name: "description",
|
||||
label: "Description",
|
||||
labelKey: "agents.formConfig.skillFields.description" as ParseKeys,
|
||||
required: true,
|
||||
placeholder: "What this skill does",
|
||||
placeholderKey: "agents.formConfig.skillFields.descriptionPlaceholder" as ParseKeys,
|
||||
rows: 2,
|
||||
},
|
||||
tags: {
|
||||
name: "tags",
|
||||
label: "Tags",
|
||||
labelKey: "agents.formConfig.skillFields.tags" as ParseKeys,
|
||||
required: true,
|
||||
placeholder: "Type a tag and press Enter",
|
||||
placeholderKey: "agents.formConfig.skillFields.tagsPlaceholder" as ParseKeys,
|
||||
},
|
||||
examples: {
|
||||
name: "examples",
|
||||
label: "Examples",
|
||||
placeholder: "Type an example and press Enter",
|
||||
labelKey: "agents.formConfig.skillFields.examples" as ParseKeys,
|
||||
placeholderKey: "agents.formConfig.skillFields.examplesPlaceholder" as ParseKeys,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Agent } from "@/components/agents/types";
|
||||
|
||||
interface AgentCostViewProps {
|
||||
|
|
@ -6,6 +7,7 @@ interface AgentCostViewProps {
|
|||
}
|
||||
|
||||
const AgentCostView: React.FC<AgentCostViewProps> = ({ agent }) => {
|
||||
const { t } = useTranslation();
|
||||
const params = agent.litellm_params;
|
||||
|
||||
if (params?.cost_per_query == null && params?.input_cost_per_token == null && params?.output_cost_per_token == null) {
|
||||
|
|
@ -14,15 +16,15 @@ const AgentCostView: React.FC<AgentCostViewProps> = ({ agent }) => {
|
|||
|
||||
const rows = (
|
||||
[
|
||||
["Cost Per Query", params.cost_per_query],
|
||||
["Input Cost Per Token", params.input_cost_per_token],
|
||||
["Output Cost Per Token", params.output_cost_per_token],
|
||||
[t("agents.costView.costPerQuery"), params.cost_per_query],
|
||||
[t("agents.costView.inputCostPerToken"), params.input_cost_per_token],
|
||||
[t("agents.costView.outputCostPerToken"), params.output_cost_per_token],
|
||||
] as const
|
||||
).filter(([, value]) => value != null);
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold text-foreground">Cost Configuration</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground">{t("agents.costView.title")}</h3>
|
||||
<dl className="mt-4 divide-y divide-border overflow-hidden rounded-lg border border-border">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="grid grid-cols-1 sm:grid-cols-3">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -47,6 +48,7 @@ interface AgentFormFieldsProps {
|
|||
}
|
||||
|
||||
const SkillsFieldArray = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<AgentFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "skills" });
|
||||
|
||||
|
|
@ -57,14 +59,14 @@ const SkillsFieldArray = () => {
|
|||
<FieldGroup>
|
||||
<AgentFormField
|
||||
name={`skills.${index}.id`}
|
||||
label={SKILL_FIELD_CONFIG.id.label}
|
||||
rules={SKILL_FIELD_CONFIG.id.required ? { required: "Required" } : undefined}
|
||||
label={t(SKILL_FIELD_CONFIG.id.labelKey)}
|
||||
rules={SKILL_FIELD_CONFIG.id.required ? { required: t("agents.formFields.errors.required") } : undefined}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
placeholder={SKILL_FIELD_CONFIG.id.placeholder}
|
||||
placeholder={t(SKILL_FIELD_CONFIG.id.placeholderKey)}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
|
@ -73,14 +75,16 @@ const SkillsFieldArray = () => {
|
|||
|
||||
<AgentFormField
|
||||
name={`skills.${index}.name`}
|
||||
label={SKILL_FIELD_CONFIG.name.label}
|
||||
rules={SKILL_FIELD_CONFIG.name.required ? { required: "Required" } : undefined}
|
||||
label={t(SKILL_FIELD_CONFIG.name.labelKey)}
|
||||
rules={
|
||||
SKILL_FIELD_CONFIG.name.required ? { required: t("agents.formFields.errors.required") } : undefined
|
||||
}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
placeholder={SKILL_FIELD_CONFIG.name.placeholder}
|
||||
placeholder={t(SKILL_FIELD_CONFIG.name.placeholderKey)}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
|
@ -89,15 +93,19 @@ const SkillsFieldArray = () => {
|
|||
|
||||
<AgentFormField
|
||||
name={`skills.${index}.description`}
|
||||
label={SKILL_FIELD_CONFIG.description.label}
|
||||
rules={SKILL_FIELD_CONFIG.description.required ? { required: "Required" } : undefined}
|
||||
label={t(SKILL_FIELD_CONFIG.description.labelKey)}
|
||||
rules={
|
||||
SKILL_FIELD_CONFIG.description.required
|
||||
? { required: t("agents.formFields.errors.required") }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Textarea
|
||||
{...control}
|
||||
ref={ref}
|
||||
rows={SKILL_FIELD_CONFIG.description.rows}
|
||||
placeholder={SKILL_FIELD_CONFIG.description.placeholder}
|
||||
placeholder={t(SKILL_FIELD_CONFIG.description.placeholderKey)}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
|
@ -106,26 +114,28 @@ const SkillsFieldArray = () => {
|
|||
|
||||
<AgentFormField
|
||||
name={`skills.${index}.tags`}
|
||||
label={SKILL_FIELD_CONFIG.tags.label}
|
||||
rules={SKILL_FIELD_CONFIG.tags.required ? { required: "Required" } : undefined}
|
||||
label={t(SKILL_FIELD_CONFIG.tags.labelKey)}
|
||||
rules={
|
||||
SKILL_FIELD_CONFIG.tags.required ? { required: t("agents.formFields.errors.required") } : undefined
|
||||
}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<AgentTagsInput
|
||||
id={id}
|
||||
value={Array.isArray(value) ? (value as string[]) : []}
|
||||
onValueChange={onChange}
|
||||
placeholder={SKILL_FIELD_CONFIG.tags.placeholder}
|
||||
placeholder={t(SKILL_FIELD_CONFIG.tags.placeholderKey)}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
||||
<AgentFormField name={`skills.${index}.examples`} label={SKILL_FIELD_CONFIG.examples.label}>
|
||||
<AgentFormField name={`skills.${index}.examples`} label={t(SKILL_FIELD_CONFIG.examples.labelKey)}>
|
||||
{({ id, value, onChange }) => (
|
||||
<AgentTagsInput
|
||||
id={id}
|
||||
value={Array.isArray(value) ? (value as string[]) : []}
|
||||
onValueChange={onChange}
|
||||
placeholder={SKILL_FIELD_CONFIG.examples.placeholder}
|
||||
placeholder={t(SKILL_FIELD_CONFIG.examples.placeholderKey)}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
|
@ -138,19 +148,20 @@ const SkillsFieldArray = () => {
|
|||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 />
|
||||
Remove Skill
|
||||
{t("agents.formFields.skills.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" className="w-full border-dashed" onClick={() => append({})}>
|
||||
<Plus />
|
||||
Add Skill
|
||||
{t("agents.formFields.skills.add")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const StaticHeadersFieldArray = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<AgentFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: "static_headers" });
|
||||
|
||||
|
|
@ -158,25 +169,31 @@ const StaticHeadersFieldArray = () => {
|
|||
<>
|
||||
{fields.map((item, index) => (
|
||||
<div key={item.id} className="flex items-start gap-2">
|
||||
<AgentFormField name={`static_headers.${index}.header`} rules={{ required: "Header name required" }}>
|
||||
<AgentFormField
|
||||
name={`static_headers.${index}.header`}
|
||||
rules={{ required: t("agents.formFields.staticHeaders.errors.nameRequired") }}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
className="w-55"
|
||||
placeholder="Header name (e.g. Authorization)"
|
||||
placeholder={t("agents.formFields.staticHeaders.namePlaceholder")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
<AgentFormField name={`static_headers.${index}.value`} rules={{ required: "Value required" }}>
|
||||
<AgentFormField
|
||||
name={`static_headers.${index}.value`}
|
||||
rules={{ required: t("agents.formFields.staticHeaders.errors.valueRequired") }}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
className="w-65"
|
||||
placeholder="Value (e.g. Bearer token123)"
|
||||
placeholder={t("agents.formFields.staticHeaders.valuePlaceholder")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
|
@ -186,7 +203,7 @@ const StaticHeadersFieldArray = () => {
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Remove static header"
|
||||
aria-label={t("agents.formFields.staticHeaders.remove")}
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
|
|
@ -196,13 +213,14 @@ const StaticHeadersFieldArray = () => {
|
|||
))}
|
||||
<Button type="button" variant="outline" className="w-full border-dashed" onClick={() => append({})}>
|
||||
<Plus />
|
||||
Add Static Header
|
||||
{t("agents.formFields.staticHeaders.add")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName = true, visiblePanels }) => {
|
||||
const { t } = useTranslation();
|
||||
const shouldShow = (key: string) => !visiblePanels || visiblePanels.includes(key);
|
||||
|
||||
return (
|
||||
|
|
@ -211,14 +229,14 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
<FieldGroup className="mb-4">
|
||||
<AgentFormField
|
||||
name="agent_name"
|
||||
label={labelWithHint("Agent Name", "Unique identifier for the agent")}
|
||||
rules={{ required: "Please enter a unique agent name" }}
|
||||
label={labelWithHint(t("agents.formFields.agentName.label"), t("agents.formFields.agentName.hint"))}
|
||||
rules={{ required: t("agents.formFields.agentName.required") }}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
placeholder="e.g., customer-support-agent"
|
||||
placeholder={t("agents.formFields.agentName.placeholder")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
|
@ -231,7 +249,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
{shouldShow(AGENT_FORM_CONFIG.basic.key) && (
|
||||
<AgentFormPanel
|
||||
panelKey={AGENT_FORM_CONFIG.basic.key}
|
||||
title={`${AGENT_FORM_CONFIG.basic.title} (Required)`}
|
||||
title={t("agents.formFields.requiredSection", { section: t(AGENT_FORM_CONFIG.basic.titleKey) })}
|
||||
panels={panels}
|
||||
>
|
||||
{AGENT_FORM_CONFIG.basic.fields.map((field) => (
|
||||
|
|
@ -239,8 +257,12 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
key={field.name}
|
||||
name={field.name}
|
||||
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
|
||||
description={field.helpText}
|
||||
rules={field.required ? { required: `Please enter ${field.label.toLowerCase()}` } : undefined}
|
||||
description={field.helpTextKey ? t(field.helpTextKey) : undefined}
|
||||
rules={
|
||||
field.required
|
||||
? { required: t("agents.formFields.errors.enterField", { field: field.label.toLowerCase() }) }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => {
|
||||
const text = typeof value === "string" ? value : "";
|
||||
|
|
@ -284,7 +306,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
{shouldShow(AGENT_FORM_CONFIG.skills.key) && (
|
||||
<AgentFormPanel
|
||||
panelKey={AGENT_FORM_CONFIG.skills.key}
|
||||
title={AGENT_FORM_CONFIG.skills.title}
|
||||
title={t(AGENT_FORM_CONFIG.skills.titleKey)}
|
||||
panels={panels}
|
||||
>
|
||||
<SkillsFieldArray />
|
||||
|
|
@ -294,7 +316,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
{shouldShow(AGENT_FORM_CONFIG.capabilities.key) && (
|
||||
<AgentFormPanel
|
||||
panelKey={AGENT_FORM_CONFIG.capabilities.key}
|
||||
title={AGENT_FORM_CONFIG.capabilities.title}
|
||||
title={t(AGENT_FORM_CONFIG.capabilities.titleKey)}
|
||||
panels={panels}
|
||||
>
|
||||
{AGENT_FORM_CONFIG.capabilities.fields.map((field) => (
|
||||
|
|
@ -310,7 +332,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
{shouldShow(AGENT_FORM_CONFIG.optional.key) && (
|
||||
<AgentFormPanel
|
||||
panelKey={AGENT_FORM_CONFIG.optional.key}
|
||||
title={AGENT_FORM_CONFIG.optional.title}
|
||||
title={t(AGENT_FORM_CONFIG.optional.titleKey)}
|
||||
panels={panels}
|
||||
>
|
||||
{AGENT_FORM_CONFIG.optional.fields.map((field) => (
|
||||
|
|
@ -334,7 +356,11 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
)}
|
||||
|
||||
{shouldShow(AGENT_FORM_CONFIG.cost.key) && (
|
||||
<AgentFormPanel panelKey={AGENT_FORM_CONFIG.cost.key} title={AGENT_FORM_CONFIG.cost.title} panels={panels}>
|
||||
<AgentFormPanel
|
||||
panelKey={AGENT_FORM_CONFIG.cost.key}
|
||||
title={t(AGENT_FORM_CONFIG.cost.titleKey)}
|
||||
panels={panels}
|
||||
>
|
||||
<CostConfigFields />
|
||||
</AgentFormPanel>
|
||||
)}
|
||||
|
|
@ -342,7 +368,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
{shouldShow(AGENT_FORM_CONFIG.litellm.key) && (
|
||||
<AgentFormPanel
|
||||
panelKey={AGENT_FORM_CONFIG.litellm.key}
|
||||
title={AGENT_FORM_CONFIG.litellm.title}
|
||||
title={t(AGENT_FORM_CONFIG.litellm.titleKey)}
|
||||
panels={panels}
|
||||
>
|
||||
{AGENT_FORM_CONFIG.litellm.fields.map((field) => (
|
||||
|
|
@ -366,12 +392,16 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
)}
|
||||
|
||||
{shouldShow(AUTH_HEADERS_PANEL_KEY) && (
|
||||
<AgentFormPanel panelKey={AUTH_HEADERS_PANEL_KEY} title="Authentication Headers" panels={panels}>
|
||||
<AgentFormPanel
|
||||
panelKey={AUTH_HEADERS_PANEL_KEY}
|
||||
title={t("agents.formFields.authHeaders.title")}
|
||||
panels={panels}
|
||||
>
|
||||
<Field>
|
||||
<FieldTitle>
|
||||
{labelWithHint(
|
||||
"Static Headers",
|
||||
"Headers always sent to the backend agent, regardless of the client request. Admin-configured, static wins on conflict.",
|
||||
t("agents.formFields.authHeaders.staticHeaders"),
|
||||
t("agents.formFields.authHeaders.staticHeadersHint"),
|
||||
)}
|
||||
</FieldTitle>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
@ -382,8 +412,8 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
<AgentFormField
|
||||
name="extra_headers"
|
||||
label={labelWithHint(
|
||||
"Forward Client Headers",
|
||||
"Header names to extract from the client's request and forward to the agent. Type a name and press Enter.",
|
||||
t("agents.formFields.authHeaders.forwardHeaders"),
|
||||
t("agents.formFields.authHeaders.forwardHeadersHint"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
|
|
@ -391,7 +421,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
id={id}
|
||||
value={Array.isArray(value) ? (value as string[]) : []}
|
||||
onValueChange={onChange}
|
||||
placeholder="e.g. x-api-key, Authorization"
|
||||
placeholder={t("agents.formFields.authHeaders.forwardHeadersPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
|
@ -399,7 +429,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
|
|||
)}
|
||||
|
||||
{shouldShow(KILL_SWITCH_PANEL_KEY) && (
|
||||
<AgentFormPanel panelKey={KILL_SWITCH_PANEL_KEY} title="Kill Switch" panels={panels}>
|
||||
<AgentFormPanel panelKey={KILL_SWITCH_PANEL_KEY} title={t("agents.formFields.killSwitch")} panels={panels}>
|
||||
<KillSwitchFormFields />
|
||||
</AgentFormPanel>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
|||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAgentInfo, patchAgentCall, getAgentCreateMetadata, AgentCreateInfo } from "@/components/networking";
|
||||
import { Agent } from "@/components/agents/types";
|
||||
import { KeyResponse } from "@/components/key_team_helpers/key_list";
|
||||
|
|
@ -79,6 +80,7 @@ const DetailItem: React.FC<{ label: React.ReactNode; children: React.ReactNode }
|
|||
);
|
||||
|
||||
const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessToken, isAdmin }) => {
|
||||
const { t } = useTranslation();
|
||||
const [agent, setAgent] = useState<Agent | null>(null);
|
||||
const [selectedKey, setSelectedKey] = useState<KeyResponse | null>(null);
|
||||
const { data: keysData, isLoading: keysLoading, refetch: refetchAgentKeys } = useKeys(1, 100, { agentID: agentId });
|
||||
|
|
@ -127,7 +129,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
if (agentType === "a2a") {
|
||||
form.reset(parseAgentForForm(data));
|
||||
} else {
|
||||
const typeInfo = agentTypeMetadata.find((t) => t.agent_type === agentType);
|
||||
const typeInfo = agentTypeMetadata.find((info) => info.agent_type === agentType);
|
||||
if (typeInfo) {
|
||||
form.reset({
|
||||
...parseDynamicAgentForForm(data, typeInfo),
|
||||
|
|
@ -140,7 +142,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching agent info:", error);
|
||||
toast.error("Failed to load agent information");
|
||||
toast.error(t("agents.info.toasts.loadFailed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -151,7 +153,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
if (agent && agentTypeMetadata.length > 0) {
|
||||
const agentType = detectAgentType(agent);
|
||||
if (agentType !== "a2a") {
|
||||
const typeInfo = agentTypeMetadata.find((t) => t.agent_type === agentType);
|
||||
const typeInfo = agentTypeMetadata.find((info) => info.agent_type === agentType);
|
||||
if (typeInfo) {
|
||||
form.reset({
|
||||
...parseDynamicAgentForForm(agent, typeInfo),
|
||||
|
|
@ -163,7 +165,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
}
|
||||
}, [agentTypeMetadata, agent]);
|
||||
|
||||
const selectedAgentTypeInfo = agentTypeMetadata.find((t) => t.agent_type === detectedAgentType);
|
||||
const selectedAgentTypeInfo = agentTypeMetadata.find((info) => info.agent_type === detectedAgentType);
|
||||
const watchedFormValues = useWatch({ control: form.control });
|
||||
const mcpSelection = useWatch({ control: form.control, name: "allowed_mcp_servers_and_groups" });
|
||||
const mcpToolPermissions = useWatch({ control: form.control, name: "mcp_tool_permissions" });
|
||||
|
|
@ -249,12 +251,12 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
object_permission: buildMcpObjectPermission(values),
|
||||
access_group_ids: values.access_group_ids ?? [],
|
||||
});
|
||||
toast.success("Agent updated successfully");
|
||||
toast.success(t("agents.info.toasts.updated"));
|
||||
setIsEditing(false);
|
||||
fetchAgentInfo();
|
||||
} catch (error) {
|
||||
console.error("Error updating agent:", error);
|
||||
toast.error("Failed to update agent");
|
||||
toast.error(t("agents.info.toasts.updateFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
|
@ -273,9 +275,9 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
if (!agent) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="text-center">Agent not found</div>
|
||||
<div className="text-center">{t("agents.info.notFound")}</div>
|
||||
<Button onClick={onClose} className="mt-4">
|
||||
Back to Agents List
|
||||
{t("agents.info.backToList")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -297,7 +299,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
onChange={onChange}
|
||||
inputRef={ref}
|
||||
min={0}
|
||||
placeholder="Unlimited"
|
||||
placeholder={t("agents.info.unlimited")}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
|
@ -314,7 +316,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
refetchAgentKeys();
|
||||
}}
|
||||
teams={null}
|
||||
backButtonText="Back to Agent"
|
||||
backButtonText={t("agents.info.backToAgent")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -324,20 +326,20 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
<div>
|
||||
<Button variant="ghost" onClick={onClose} className="mb-4">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to Agents
|
||||
{t("agents.info.backToAgents")}
|
||||
</Button>
|
||||
<h1 className="text-2xl font-semibold">{agent.agent_name || "Unnamed Agent"}</h1>
|
||||
<h1 className="text-2xl font-semibold">{agent.agent_name || t("agents.info.unnamed")}</h1>
|
||||
<p className="text-sm text-muted-foreground font-mono">{agent.agent_id}</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
|
||||
Overview
|
||||
{t("agents.info.tabs.overview")}
|
||||
</TabsTrigger>
|
||||
{isAdmin && (
|
||||
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
|
||||
Settings
|
||||
{t("agents.info.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
|
@ -352,38 +354,62 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
isAdmin={isAdmin}
|
||||
/>
|
||||
<DetailList>
|
||||
<DetailItem label="Agent ID">{agent.agent_id}</DetailItem>
|
||||
<DetailItem label="Agent Name">{agent.agent_name}</DetailItem>
|
||||
<DetailItem label="Display Name">{agent.agent_card_params?.name || "-"}</DetailItem>
|
||||
<DetailItem label="Description">{agent.agent_card_params?.description || "-"}</DetailItem>
|
||||
<DetailItem label="URL">{agent.agent_card_params?.url || "-"}</DetailItem>
|
||||
<DetailItem label="Version">{agent.agent_card_params?.version || "-"}</DetailItem>
|
||||
<DetailItem label="Protocol Version">{agent.agent_card_params?.protocolVersion || "-"}</DetailItem>
|
||||
<DetailItem label="Streaming">
|
||||
{agent.agent_card_params?.capabilities?.streaming ? "Yes" : "No"}
|
||||
<DetailItem label={t("agents.info.details.agentId")}>{agent.agent_id}</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.agentName")}>{agent.agent_name}</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.displayName")}>
|
||||
{agent.agent_card_params?.name || "-"}
|
||||
</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.description")}>
|
||||
{agent.agent_card_params?.description || "-"}
|
||||
</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.url")}>{agent.agent_card_params?.url || "-"}</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.version")}>
|
||||
{agent.agent_card_params?.version || "-"}
|
||||
</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.protocolVersion")}>
|
||||
{agent.agent_card_params?.protocolVersion || "-"}
|
||||
</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.streaming")}>
|
||||
{agent.agent_card_params?.capabilities?.streaming ? t("agents.info.yes") : t("agents.info.no")}
|
||||
</DetailItem>
|
||||
{agent.agent_card_params?.capabilities?.pushNotifications && (
|
||||
<DetailItem label="Push Notifications">Yes</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.pushNotifications")}>{t("agents.info.yes")}</DetailItem>
|
||||
)}
|
||||
{agent.agent_card_params?.capabilities?.stateTransitionHistory && (
|
||||
<DetailItem label="State Transition History">Yes</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.stateTransitionHistory")}>{t("agents.info.yes")}</DetailItem>
|
||||
)}
|
||||
<DetailItem label={t("agents.info.details.skills")}>
|
||||
{t("agents.info.skillsConfigured", { count: agent.agent_card_params?.skills?.length || 0 })}
|
||||
</DetailItem>
|
||||
{agent.litellm_params?.model && (
|
||||
<DetailItem label={t("agents.info.details.model")}>{agent.litellm_params.model}</DetailItem>
|
||||
)}
|
||||
<DetailItem label="Skills">{agent.agent_card_params?.skills?.length || 0} configured</DetailItem>
|
||||
{agent.litellm_params?.model && <DetailItem label="Model">{agent.litellm_params.model}</DetailItem>}
|
||||
{agent.litellm_params?.make_public !== undefined && (
|
||||
<DetailItem label="Make Public">{agent.litellm_params.make_public ? "Yes" : "No"}</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.makePublic")}>
|
||||
{agent.litellm_params.make_public ? t("agents.info.yes") : t("agents.info.no")}
|
||||
</DetailItem>
|
||||
)}
|
||||
{agent.agent_card_params?.iconUrl && (
|
||||
<DetailItem label="Icon URL">{agent.agent_card_params.iconUrl}</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.iconUrl")}>{agent.agent_card_params.iconUrl}</DetailItem>
|
||||
)}
|
||||
{agent.agent_card_params?.documentationUrl && (
|
||||
<DetailItem label="Documentation URL">{agent.agent_card_params.documentationUrl}</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.documentationUrl")}>
|
||||
{agent.agent_card_params.documentationUrl}
|
||||
</DetailItem>
|
||||
)}
|
||||
<DetailItem label="TPM Limit">{agent.tpm_limit ?? "Unlimited"}</DetailItem>
|
||||
<DetailItem label="RPM Limit">{agent.rpm_limit ?? "Unlimited"}</DetailItem>
|
||||
<DetailItem label="Session TPM Limit">{agent.session_tpm_limit ?? "Unlimited"}</DetailItem>
|
||||
<DetailItem label="Session RPM Limit">{agent.session_rpm_limit ?? "Unlimited"}</DetailItem>
|
||||
<DetailItem label="Access Groups">
|
||||
<DetailItem label={t("agents.info.details.tpmLimit")}>
|
||||
{agent.tpm_limit ?? t("agents.info.unlimited")}
|
||||
</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.rpmLimit")}>
|
||||
{agent.rpm_limit ?? t("agents.info.unlimited")}
|
||||
</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.sessionTpmLimit")}>
|
||||
{agent.session_tpm_limit ?? t("agents.info.unlimited")}
|
||||
</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.sessionRpmLimit")}>
|
||||
{agent.session_rpm_limit ?? t("agents.info.unlimited")}
|
||||
</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.accessGroups")}>
|
||||
{agent.access_group_ids?.length ? (
|
||||
<div className="space-y-1">
|
||||
{agent.access_group_ids.map((accessGroupId) => (
|
||||
|
|
@ -391,11 +417,11 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
"None"
|
||||
t("agents.info.none")
|
||||
)}
|
||||
</DetailItem>
|
||||
<DetailItem label="Created At">{formatDate(agent.created_at)}</DetailItem>
|
||||
<DetailItem label="Updated At">{formatDate(agent.updated_at)}</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.createdAt")}>{formatDate(agent.created_at)}</DetailItem>
|
||||
<DetailItem label={t("agents.info.details.updatedAt")}>{formatDate(agent.updated_at)}</DetailItem>
|
||||
</DetailList>
|
||||
|
||||
<AgentVirtualKeys keys={agentKeys} isLoading={keysLoading} onKeyClick={setSelectedKey} />
|
||||
|
|
@ -407,10 +433,10 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
(agent.object_permission.mcp_tool_permissions &&
|
||||
Object.keys(agent.object_permission.mcp_tool_permissions).length > 0)) && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3 className="text-lg font-medium">MCP Tool Permissions</h3>
|
||||
<h3 className="text-lg font-medium">{t("agents.info.mcpPermissions.title")}</h3>
|
||||
<DetailList className="mt-4">
|
||||
{agent.object_permission.mcp_servers && agent.object_permission.mcp_servers.length > 0 && (
|
||||
<DetailItem label="MCP Servers">
|
||||
<DetailItem label={t("agents.info.mcpPermissions.servers")}>
|
||||
<div className="space-y-1">
|
||||
{agent.object_permission.mcp_servers.map((serverId) => (
|
||||
<div key={serverId}>{mcpServerLabel(serverId)}</div>
|
||||
|
|
@ -420,16 +446,18 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
)}
|
||||
{agent.object_permission.mcp_access_groups &&
|
||||
agent.object_permission.mcp_access_groups.length > 0 && (
|
||||
<DetailItem label="MCP Access Groups">
|
||||
<DetailItem label={t("agents.info.mcpPermissions.accessGroups")}>
|
||||
{agent.object_permission.mcp_access_groups.join(", ")}
|
||||
</DetailItem>
|
||||
)}
|
||||
{agent.object_permission.mcp_toolsets && agent.object_permission.mcp_toolsets.length > 0 && (
|
||||
<DetailItem label="MCP Toolsets">{agent.object_permission.mcp_toolsets.join(", ")}</DetailItem>
|
||||
<DetailItem label={t("agents.info.mcpPermissions.toolsets")}>
|
||||
{agent.object_permission.mcp_toolsets.join(", ")}
|
||||
</DetailItem>
|
||||
)}
|
||||
{agent.object_permission.mcp_tool_permissions &&
|
||||
Object.keys(agent.object_permission.mcp_tool_permissions).length > 0 && (
|
||||
<DetailItem label="Tool permissions per server">
|
||||
<DetailItem label={t("agents.info.mcpPermissions.toolsPerServer")}>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(agent.object_permission.mcp_tool_permissions).map(([serverId, tools]) => (
|
||||
<div key={serverId}>
|
||||
|
|
@ -448,23 +476,27 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
|
||||
{agent.agent_card_params?.skills && agent.agent_card_params.skills.length > 0 && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3 className="text-lg font-medium">Skills</h3>
|
||||
<h3 className="text-lg font-medium">{t("agents.info.skillsSection.title")}</h3>
|
||||
<DetailList className="mt-4">
|
||||
{agent.agent_card_params.skills.map((skill, index) => (
|
||||
<DetailItem label={skill.name || `Skill ${index + 1}`} key={index}>
|
||||
<DetailItem
|
||||
label={skill.name || t("agents.info.skillsSection.fallbackName", { number: index + 1 })}
|
||||
key={index}
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<strong>ID:</strong> {skill.id}
|
||||
<strong>{t("agents.info.skillsSection.id")}</strong> {skill.id}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Description:</strong> {skill.description}
|
||||
<strong>{t("agents.info.skillsSection.description")}</strong> {skill.description}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Tags:</strong> {Array.isArray(skill.tags) ? skill.tags.join(", ") : skill.tags}
|
||||
<strong>{t("agents.info.skillsSection.tags")}</strong>{" "}
|
||||
{Array.isArray(skill.tags) ? skill.tags.join(", ") : skill.tags}
|
||||
</div>
|
||||
{skill.examples && skill.examples.length > 0 && (
|
||||
<div>
|
||||
<strong>Examples:</strong>{" "}
|
||||
<strong>{t("agents.info.skillsSection.examples")}</strong>{" "}
|
||||
{Array.isArray(skill.examples) ? skill.examples.join(", ") : skill.examples}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -489,7 +521,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
<TabsContent value="settings" keepMounted>
|
||||
<Card className="block p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Agent Settings</h3>
|
||||
<h3 className="text-lg font-medium">{t("agents.info.settings.title")}</h3>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
|
@ -497,7 +529,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
Edit Settings
|
||||
{t("agents.info.settings.edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -508,7 +540,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
<form onSubmit={form.handleSubmit(handleUpdate)}>
|
||||
<FieldGroup className="mb-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="agent-id">Agent ID</FieldLabel>
|
||||
<FieldLabel htmlFor="agent-id">{t("agents.info.settings.agentId")}</FieldLabel>
|
||||
<Input id="agent-id" value={agent.agent_id} disabled readOnly />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
|
@ -533,44 +565,44 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
)}
|
||||
|
||||
<Separator className="my-6" />
|
||||
<h3 className="text-lg font-medium mb-4">Rate Limits</h3>
|
||||
<h3 className="text-lg font-medium mb-4">{t("agents.info.settings.rateLimits")}</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{rateLimitField("tpm_limit", "TPM Limit")}
|
||||
{rateLimitField("rpm_limit", "RPM Limit")}
|
||||
{rateLimitField("tpm_limit", t("agents.info.settings.tpmLimit"))}
|
||||
{rateLimitField("rpm_limit", t("agents.info.settings.rpmLimit"))}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
{rateLimitField("session_tpm_limit", "Session TPM Limit")}
|
||||
{rateLimitField("session_rpm_limit", "Session RPM Limit")}
|
||||
{rateLimitField("session_tpm_limit", t("agents.info.settings.sessionTpmLimit"))}
|
||||
{rateLimitField("session_rpm_limit", t("agents.info.settings.sessionRpmLimit"))}
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
<h3 className="text-lg font-medium mb-4">Access Groups</h3>
|
||||
<h3 className="text-lg font-medium mb-4">{t("agents.info.settings.accessGroups.title")}</h3>
|
||||
<FieldGroup>
|
||||
<AgentFormField
|
||||
name="access_group_ids"
|
||||
label={labelWithHint(
|
||||
"Access Groups",
|
||||
"Attached groups cap which models, MCP servers, and agents this agent can reach, on top of its key and team permissions. Leave empty to apply no extra cap.",
|
||||
t("agents.info.settings.accessGroups.label"),
|
||||
t("agents.info.settings.accessGroups.hint"),
|
||||
)}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<AccessGroupSelector
|
||||
value={Array.isArray(value) ? (value as string[]) : []}
|
||||
onChange={onChange}
|
||||
placeholder="Select access groups (optional)"
|
||||
placeholder={t("agents.info.settings.accessGroups.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
</FieldGroup>
|
||||
|
||||
<Separator className="my-6" />
|
||||
<h3 className="text-lg font-medium mb-4">MCP Servers</h3>
|
||||
<h3 className="text-lg font-medium mb-4">{t("agents.info.settings.mcpServers.title")}</h3>
|
||||
<FieldGroup>
|
||||
<AgentFormField
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
label={labelWithHint(
|
||||
"Allowed MCP Servers",
|
||||
"Select which MCP servers or access groups this agent can access. Keys bound to this agent can only reach servers granted here.",
|
||||
t("agents.info.settings.mcpServers.label"),
|
||||
t("agents.info.settings.mcpServers.hint"),
|
||||
)}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
|
|
@ -582,7 +614,7 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
toolsets: (value as McpServerSelection | undefined)?.toolsets ?? [],
|
||||
}}
|
||||
accessToken={accessToken ?? ""}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
placeholder={t("agents.info.settings.mcpServers.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</AgentFormField>
|
||||
|
|
@ -608,18 +640,18 @@ const AgentInfoView: React.FC<AgentInfoViewProps> = ({ agentId, onClose, accessT
|
|||
fetchAgentInfo();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t("agents.info.settings.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving} aria-busy={isSaving}>
|
||||
{isSaving && <UiLoadingSpinner className="size-4" />}
|
||||
Save Changes
|
||||
{t("agents.info.settings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<p>Click "Edit Settings" to modify agent configuration.</p>
|
||||
<p>{t("agents.info.settings.editHint")}</p>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { KeyResponse } from "@/components/key_team_helpers/key_list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -11,19 +12,22 @@ interface AgentVirtualKeysProps {
|
|||
}
|
||||
|
||||
const AgentVirtualKeys: React.FC<AgentVirtualKeysProps> = ({ keys, isLoading, onKeyClick }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-base font-semibold text-foreground">Virtual Keys</h4>
|
||||
<h4 className="text-base font-semibold text-foreground">{t("agents.virtualKeys.title")}</h4>
|
||||
{isLoading ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">Loading keys...</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("agents.virtualKeys.loading")}</p>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">No virtual key assigned to this agent.</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("agents.virtualKeys.empty")}</p>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{keys.map((key) => (
|
||||
<div key={key.token} className="flex items-center gap-3 rounded-sm border border-border px-3 py-2">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">{key.key_alias || "Unnamed key"}</span>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{key.key_alias || t("agents.virtualKeys.unnamed")}
|
||||
</span>
|
||||
{key.key_name && <span className="font-mono text-xs text-muted-foreground">{key.key_name}</span>}
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import React from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { AgentCreateInfo, AgentCredentialFieldMetadata } from "@/components/networking";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { AGENT_FORM_CONFIG, applyKillSwitchToPayload } from "./agent_config";
|
||||
import CostConfigFields, { COST_FIELD_NAMES } from "./cost_config_fields";
|
||||
import KillSwitchFormFields from "./KillSwitchFormFields";
|
||||
|
|
@ -32,12 +35,13 @@ export const unmountedDynamicFieldNames = (mountedPanels: readonly string[]): re
|
|||
// validation rather than throwing during render and taking the whole form down with it.
|
||||
const buildValidationPatternRule = (
|
||||
field: AgentCredentialFieldMetadata,
|
||||
t: TFunction,
|
||||
): { value: RegExp; message: string } | undefined => {
|
||||
if (!field.validation_pattern) return undefined;
|
||||
try {
|
||||
return {
|
||||
value: new RegExp(field.validation_pattern),
|
||||
message: field.validation_message || `${field.label} looks incomplete or malformed`,
|
||||
message: field.validation_message || t("agents.dynamicFields.errors.malformed", { field: field.label }),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
|
|
@ -45,14 +49,15 @@ const buildValidationPatternRule = (
|
|||
};
|
||||
|
||||
const CredentialField = ({ field }: { field: AgentCredentialFieldMetadata }) => {
|
||||
const patternRule = buildValidationPatternRule(field);
|
||||
const { t } = useTranslation();
|
||||
const patternRule = buildValidationPatternRule(field, t);
|
||||
return (
|
||||
<AgentFormField
|
||||
name={field.key}
|
||||
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
|
||||
defaultValue={field.default_value ?? undefined}
|
||||
rules={{
|
||||
...(field.required ? { required: `Please enter ${field.label}` } : {}),
|
||||
...(field.required ? { required: t("agents.dynamicFields.errors.enterField", { field: field.label }) } : {}),
|
||||
...(patternRule ? { pattern: patternRule } : {}),
|
||||
}}
|
||||
>
|
||||
|
|
@ -108,14 +113,17 @@ const DynamicAgentFormFields: React.FC<DynamicAgentFormFieldsProps> = ({ agentTy
|
|||
<FieldGroup className="mb-4">
|
||||
<AgentFormField
|
||||
name="agent_name"
|
||||
label={labelWithHint("Agent Name", "Unique identifier for the agent")}
|
||||
rules={{ required: "Please enter a unique agent name" }}
|
||||
label={labelWithHint(
|
||||
i18n.t("agents.dynamicFields.agentName.label"),
|
||||
i18n.t("agents.dynamicFields.agentName.hint"),
|
||||
)}
|
||||
rules={{ required: i18n.t("agents.dynamicFields.agentName.required") }}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Input
|
||||
{...control}
|
||||
ref={ref}
|
||||
placeholder="e.g., my-langgraph-agent"
|
||||
placeholder={i18n.t("agents.dynamicFields.agentName.placeholder")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
|
@ -124,14 +132,17 @@ const DynamicAgentFormFields: React.FC<DynamicAgentFormFieldsProps> = ({ agentTy
|
|||
|
||||
<AgentFormField
|
||||
name="description"
|
||||
label={labelWithHint("Description", "Brief description of what this agent does")}
|
||||
label={labelWithHint(
|
||||
i18n.t("agents.dynamicFields.description.label"),
|
||||
i18n.t("agents.dynamicFields.description.hint"),
|
||||
)}
|
||||
>
|
||||
{({ value, onChange, ref, ...control }) => (
|
||||
<Textarea
|
||||
{...control}
|
||||
ref={ref}
|
||||
rows={2}
|
||||
placeholder="Describe what this agent does..."
|
||||
placeholder={i18n.t("agents.dynamicFields.description.placeholder")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
|
@ -144,10 +155,18 @@ const DynamicAgentFormFields: React.FC<DynamicAgentFormFieldsProps> = ({ agentTy
|
|||
</FieldGroup>
|
||||
|
||||
<div className="mb-4 rounded-md border border-border px-4">
|
||||
<AgentFormPanel panelKey={AGENT_FORM_CONFIG.cost.key} title={AGENT_FORM_CONFIG.cost.title} panels={panels}>
|
||||
<AgentFormPanel
|
||||
panelKey={AGENT_FORM_CONFIG.cost.key}
|
||||
title={i18n.t(AGENT_FORM_CONFIG.cost.titleKey)}
|
||||
panels={panels}
|
||||
>
|
||||
<CostConfigFields />
|
||||
</AgentFormPanel>
|
||||
<AgentFormPanel panelKey={KILL_SWITCH_PANEL_KEY} title="Kill Switch" panels={panels}>
|
||||
<AgentFormPanel
|
||||
panelKey={KILL_SWITCH_PANEL_KEY}
|
||||
title={i18n.t("agents.dynamicFields.killSwitch")}
|
||||
panels={panels}
|
||||
>
|
||||
<KillSwitchFormFields />
|
||||
</AgentFormPanel>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type KillSwitchConfig = components["schemas"]["AgentKillSwitchConfig"];
|
||||
|
|
@ -6,11 +8,11 @@ export type KillSwitchMethod = NonNullable<KillSwitchConfig["method"]>;
|
|||
export type KillSwitchAuthType = KillSwitchAuth["type"] | "none";
|
||||
|
||||
export const KILL_SWITCH_METHODS: readonly KillSwitchMethod[] = ["POST", "PUT", "PATCH", "DELETE", "GET"];
|
||||
export const KILL_SWITCH_AUTH_TYPES: readonly { value: KillSwitchAuthType; label: string }[] = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "bearer", label: "Bearer token" },
|
||||
{ value: "api_key", label: "API key header" },
|
||||
{ value: "basic", label: "Basic auth" },
|
||||
export const KILL_SWITCH_AUTH_TYPES: readonly { value: KillSwitchAuthType; labelKey: ParseKeys }[] = [
|
||||
{ value: "none", labelKey: "agents.killSwitchConfig.authTypes.none" },
|
||||
{ value: "bearer", labelKey: "agents.killSwitchConfig.authTypes.bearer" },
|
||||
{ value: "api_key", labelKey: "agents.killSwitchConfig.authTypes.apiKey" },
|
||||
{ value: "basic", labelKey: "agents.killSwitchConfig.authTypes.basic" },
|
||||
];
|
||||
|
||||
export interface KeyValueFormValue {
|
||||
|
|
@ -61,12 +63,12 @@ export const parseKillSwitchBody = (text: string | undefined): Record<string, un
|
|||
return parsed as Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const validateKillSwitchBody = (text: string | undefined): true | string => {
|
||||
export const validateKillSwitchBody = (text: string | undefined, t: TFunction = i18n.t.bind(i18n)): true | string => {
|
||||
try {
|
||||
parseKillSwitchBody(text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : "Body must be valid JSON";
|
||||
return error instanceof Error ? error.message : t("agents.killSwitchConfig.errors.invalidJson");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MemoryRow } from "@/components/networking";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
|
|
@ -25,6 +26,7 @@ function formatTimestamp(ts?: string): string {
|
|||
}
|
||||
|
||||
export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Sheet
|
||||
open={!!row}
|
||||
|
|
@ -34,47 +36,49 @@ export function MemoryDetailDrawer({ row, onClose }: MemoryDetailDrawerProps) {
|
|||
>
|
||||
<SheetContent className="overflow-y-auto data-[side=right]:w-full data-[side=right]:max-w-full data-[side=right]:sm:w-[720px] data-[side=right]:sm:max-w-full">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetTitle>{row ? <code className={CODE_CLASS}>{row.key}</code> : "Memory"}</SheetTitle>
|
||||
<SheetTitle>
|
||||
{row ? <code className={CODE_CLASS}>{row.key}</code> : t("memory.detailDrawer.title")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
{row && (
|
||||
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3">
|
||||
<div>
|
||||
<span className={`block ${LABEL_CLASS}`}>Memory ID</span>
|
||||
<span className={`block ${LABEL_CLASS}`}>{t("memory.detailDrawer.memoryId")}</span>
|
||||
<code className={CODE_CLASS}>{row.memory_id}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`block ${LABEL_CLASS}`}>User ID</span>
|
||||
<span className={`block ${LABEL_CLASS}`}>{t("memory.detailDrawer.userId")}</span>
|
||||
<span className={row.user_id ? "text-sm text-foreground" : "text-sm text-muted-foreground"}>
|
||||
{row.user_id ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={`block ${LABEL_CLASS}`}>Team ID</span>
|
||||
<span className={`block ${LABEL_CLASS}`}>{t("memory.detailDrawer.teamId")}</span>
|
||||
<span className={row.team_id ? "text-sm text-foreground" : "text-sm text-muted-foreground"}>
|
||||
{row.team_id ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>Value</span>
|
||||
<span className={LABEL_CLASS}>{t("memory.detailDrawer.value")}</span>
|
||||
<p className={`${BLOCK_CLASS} text-[13px]`}>{row.value}</p>
|
||||
</div>
|
||||
{row.metadata !== undefined && row.metadata !== null && (
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>Metadata</span>
|
||||
<span className={LABEL_CLASS}>{t("memory.detailDrawer.metadata")}</span>
|
||||
<p className={`${BLOCK_CLASS} text-xs`}>{JSON.stringify(row.metadata, null, 2)}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Created {formatTimestamp(row.created_at)}
|
||||
{row.created_by ? ` by ${row.created_by}` : ""}
|
||||
{t("memory.detailDrawer.created", { time: formatTimestamp(row.created_at) })}
|
||||
{row.created_by ? t("memory.detailDrawer.by", { name: row.created_by }) : ""}
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
Updated {formatTimestamp(row.updated_at)}
|
||||
{row.updated_by ? ` by ${row.updated_by}` : ""}
|
||||
{t("memory.detailDrawer.updated", { time: formatTimestamp(row.updated_at) })}
|
||||
{row.updated_by ? t("memory.detailDrawer.by", { name: row.updated_by }) : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { MemoryRow } from "@/components/networking";
|
||||
|
|
@ -15,8 +17,8 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
|||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const memorySchema = z.object({
|
||||
key: z.string().min(1, "Key is required"),
|
||||
value: z.string().min(1, "Value is required"),
|
||||
key: z.string().min(1, { error: () => i18n.t("memory.editModal.errors.keyRequired") }),
|
||||
value: z.string().min(1, { error: () => i18n.t("memory.editModal.errors.valueRequired") }),
|
||||
metadata: z.string(),
|
||||
});
|
||||
|
||||
|
|
@ -43,6 +45,7 @@ interface MemoryEditModalProps {
|
|||
}
|
||||
|
||||
export const MemoryEditModal: React.FC<MemoryEditModalProps> = ({ open, mode, initialRow, onClose, onSave }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(memorySchema, { defaultValues: EMPTY_MEMORY, mode: "onChange" });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
|
|
@ -80,7 +83,11 @@ export const MemoryEditModal: React.FC<MemoryEditModalProps> = ({ open, mode, in
|
|||
>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "create" ? "Create memory" : `Edit ${initialRow?.key ?? ""}`}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{mode === "create"
|
||||
? t("memory.editModal.createTitle")
|
||||
: t("memory.editModal.editTitle", { name: initialRow?.key ?? "" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<TooltipProvider>
|
||||
|
|
@ -88,23 +95,25 @@ export const MemoryEditModal: React.FC<MemoryEditModalProps> = ({ open, mode, in
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="key"
|
||||
label={labelWithHint(
|
||||
"Key",
|
||||
"Globally unique — two memories cannot share a key. Namespace your own keys if you need per-user isolation (e.g. user:123:notes).",
|
||||
)}
|
||||
label={labelWithHint(t("memory.editModal.labels.key"), t("memory.editModal.hints.key"))}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="e.g. user_role" disabled={mode === "edit"} />
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("memory.editModal.placeholders.key")}
|
||||
disabled={mode === "edit"}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
label={labelWithHint("Value", "Markdown/text injected into LLM context. Plain strings are fine.")}
|
||||
label={labelWithHint(t("memory.editModal.labels.value"), t("memory.editModal.hints.value"))}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea {...field} ref={ref} rows={8} placeholder="What the agent should remember…" />
|
||||
<Textarea {...field} ref={ref} rows={8} placeholder={t("memory.editModal.placeholders.value")} />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
|
|
@ -113,9 +122,12 @@ export const MemoryEditModal: React.FC<MemoryEditModalProps> = ({ open, mode, in
|
|||
name="metadata"
|
||||
label={labelWithHint(
|
||||
<span>
|
||||
Metadata <span className="text-muted-foreground">(optional JSON)</span>
|
||||
<Trans
|
||||
i18nKey="memory.editModal.labels.metadata"
|
||||
components={{ muted: <span className="text-muted-foreground" /> }}
|
||||
/>
|
||||
</span>,
|
||||
"Optional structured metadata — must be valid JSON if provided.",
|
||||
t("memory.editModal.hints.metadata"),
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
|
|
@ -133,10 +145,10 @@ export const MemoryEditModal: React.FC<MemoryEditModalProps> = ({ open, mode, in
|
|||
onClose();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t("memory.editModal.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleOk} disabled={submitting} aria-busy={submitting}>
|
||||
{mode === "create" ? "Create" : "Save"}
|
||||
{mode === "create" ? t("memory.editModal.create") : t("memory.editModal.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { OnChangeFn, PaginationState } from "@tanstack/react-table";
|
||||
import { Database } from "lucide-react";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MemoryRow } from "@/components/networking";
|
||||
import { DataTable, DataTableToolbar } from "@/components/shared/DataTable";
|
||||
|
|
@ -26,18 +27,17 @@ interface MemoryTableProps {
|
|||
}
|
||||
|
||||
function MemoryEmptyState({ hasActiveSearch }: { hasActiveSearch: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Database className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{hasActiveSearch ? "No matching memories" : "No memories stored yet"}
|
||||
{hasActiveSearch ? t("memory.table.empty.titleSearch") : t("memory.table.empty.title")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{hasActiveSearch
|
||||
? "No memories match your search."
|
||||
: "Memories your agents store under /v1/memory will appear here."}
|
||||
{hasActiveSearch ? t("memory.table.empty.descriptionSearch") : t("memory.table.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -58,10 +58,11 @@ export function MemoryTable({
|
|||
onEditClick,
|
||||
onDeleteClick,
|
||||
}: MemoryTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo(() => {
|
||||
const columnDeps = { onViewClick, onEditClick, onDeleteClick };
|
||||
const columnDeps = { onViewClick, onEditClick, onDeleteClick, t };
|
||||
return getMemoryTableColumns(columnDeps);
|
||||
}, [onViewClick, onEditClick, onDeleteClick]);
|
||||
}, [onViewClick, onEditClick, onDeleteClick, t]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
@ -73,7 +74,7 @@ export function MemoryTable({
|
|||
onPaginationChange={onPaginationChange}
|
||||
rowCount={rowCount}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading memories…"
|
||||
loadingMessage={t("memory.table.loading")}
|
||||
noDataMessage={<MemoryEmptyState hasActiveSearch={hasActiveSearch} />}
|
||||
size="compact"
|
||||
toolbar={(table) => (
|
||||
|
|
@ -81,7 +82,7 @@ export function MemoryTable({
|
|||
table={table}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
searchPlaceholder="Search by key prefix or memory ID…"
|
||||
searchPlaceholder={t("memory.table.searchPlaceholder")}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
showViewOptions={false}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Eye, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MemoryRow } from "@/components/networking";
|
||||
import { DateCell, IdCell, IdentityCell } from "@/components/shared/table_cells";
|
||||
|
|
@ -24,10 +26,11 @@ interface MemoryRowActionsProps {
|
|||
}
|
||||
|
||||
function MemoryRowActions({ row, onViewClick, onEditClick, onDeleteClick }: MemoryRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open memory actions"
|
||||
aria-label={t("memory.table.actions.open")}
|
||||
data-testid={`memory-actions-${row.memory_id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -36,16 +39,16 @@ function MemoryRowActions({ row, onViewClick, onEditClick, onDeleteClick }: Memo
|
|||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem data-testid="memory-action-view" onClick={() => onViewClick(row)}>
|
||||
<Eye />
|
||||
View
|
||||
{t("memory.table.actions.view")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem data-testid="memory-action-edit" onClick={() => onEditClick(row)}>
|
||||
<Pencil />
|
||||
Edit
|
||||
{t("memory.table.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" data-testid="memory-action-delete" onClick={() => onDeleteClick(row)}>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("memory.table.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -56,18 +59,20 @@ export interface MemoryTableColumnsDeps {
|
|||
onViewClick: (row: MemoryRow) => void;
|
||||
onEditClick: (row: MemoryRow) => void;
|
||||
onDeleteClick: (row: MemoryRow) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export const getMemoryTableColumns = ({
|
||||
onViewClick,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
t,
|
||||
}: MemoryTableColumnsDeps): ColumnDef<MemoryRow>[] => [
|
||||
{
|
||||
id: "memory_id",
|
||||
accessorKey: "memory_id",
|
||||
meta: { title: "ID" },
|
||||
header: "ID",
|
||||
meta: { title: t("memory.table.columns.id") },
|
||||
header: t("memory.table.columns.id"),
|
||||
size: 180,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -81,8 +86,8 @@ export const getMemoryTableColumns = ({
|
|||
{
|
||||
id: "key",
|
||||
accessorKey: "key",
|
||||
meta: { title: "Name" },
|
||||
header: "Name",
|
||||
meta: { title: t("memory.table.columns.name") },
|
||||
header: t("memory.table.columns.name"),
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -94,8 +99,8 @@ export const getMemoryTableColumns = ({
|
|||
{
|
||||
id: "value",
|
||||
accessorKey: "value",
|
||||
meta: { title: "Preview" },
|
||||
header: "Preview",
|
||||
meta: { title: t("memory.table.columns.preview") },
|
||||
header: t("memory.table.columns.preview"),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-72 truncate text-sm text-muted-foreground" title={row.original.value}>
|
||||
|
|
@ -106,8 +111,8 @@ export const getMemoryTableColumns = ({
|
|||
{
|
||||
id: "user_id",
|
||||
accessorKey: "user_id",
|
||||
meta: { title: "User ID" },
|
||||
header: "User ID",
|
||||
meta: { title: t("memory.table.columns.userId") },
|
||||
header: t("memory.table.columns.userId"),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -118,8 +123,8 @@ export const getMemoryTableColumns = ({
|
|||
{
|
||||
id: "team_id",
|
||||
accessorKey: "team_id",
|
||||
meta: { title: "Team ID" },
|
||||
header: "Team ID",
|
||||
meta: { title: t("memory.table.columns.teamId") },
|
||||
header: t("memory.table.columns.teamId"),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -130,8 +135,8 @@ export const getMemoryTableColumns = ({
|
|||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
meta: { title: "Updated" },
|
||||
header: "Updated",
|
||||
meta: { title: t("memory.table.columns.updated") },
|
||||
header: t("memory.table.columns.updated"),
|
||||
size: 170,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
|
||||
|
|
@ -139,7 +144,7 @@ export const getMemoryTableColumns = ({
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("memory.table.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { Plus } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { MemoryRow, createMemory, deleteMemory, fetchMemoryList, updateMemory } from "@/components/networking";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
|
|
@ -25,6 +26,7 @@ interface MemoryViewProps {
|
|||
const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS });
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: DEFAULT_PAGE_SIZE });
|
||||
|
|
@ -72,11 +74,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
return createMemory(accessToken, args);
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
toast.success(`Created ${row.key}`);
|
||||
toast.success(t("memory.view.toasts.created", { name: row.key }));
|
||||
invalidateList();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`Save failed: ${err.message}`);
|
||||
toast.error(t("memory.view.toasts.saveFailed", { error: err.message }));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -87,11 +89,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
return updateMemory(accessToken, key, payload);
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
toast.success(`Updated ${row.key}`);
|
||||
toast.success(t("memory.view.toasts.updated", { name: row.key }));
|
||||
invalidateList();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`Save failed: ${err.message}`);
|
||||
toast.error(t("memory.view.toasts.saveFailed", { error: err.message }));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -101,11 +103,11 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
return deleteMemory(accessToken, key).then(() => key);
|
||||
},
|
||||
onSuccess: (key) => {
|
||||
toast.success(`Deleted ${key}`);
|
||||
toast.success(t("memory.view.toasts.deleted", { name: key }));
|
||||
invalidateList();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`Delete failed: ${err.message}`);
|
||||
toast.error(t("memory.view.toasts.deleteFailed", { error: err.message }));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -147,7 +149,7 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
try {
|
||||
metadataPayload = JSON.parse(metadataText);
|
||||
} catch {
|
||||
toast.error("Metadata must be valid JSON (or leave empty).");
|
||||
toast.error(t("memory.view.toasts.invalidMetadata"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -178,18 +180,23 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Memory</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t("memory.view.title")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Inspect what your agents have stored under{" "}
|
||||
<code className="rounded-sm border border-border bg-muted px-1 py-0.5 font-mono text-xs text-foreground">
|
||||
/v1/memory
|
||||
</code>
|
||||
. Scoped to memories visible to your user / team (admins see all).
|
||||
<Trans
|
||||
i18nKey="memory.view.intro"
|
||||
components={{
|
||||
endpoint: (
|
||||
<code className="rounded-sm border border-border bg-muted px-1 py-0.5 font-mono text-xs text-foreground">
|
||||
/v1/memory
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateOpen(true)}>
|
||||
<Plus />
|
||||
New memory
|
||||
{t("memory.view.newMemory")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -228,16 +235,16 @@ export const MemoryView: React.FC<MemoryViewProps> = ({ accessToken }) => {
|
|||
{/* Delete confirmation modal */}
|
||||
<DeleteResourceModal
|
||||
isOpen={!!deleteRow}
|
||||
title="Delete memory"
|
||||
message="This action cannot be undone."
|
||||
resourceInformationTitle="Memory"
|
||||
title={t("memory.view.deleteModal.title")}
|
||||
message={t("memory.view.deleteModal.message")}
|
||||
resourceInformationTitle={t("memory.view.deleteModal.infoTitle")}
|
||||
resourceInformation={
|
||||
deleteRow
|
||||
? [
|
||||
{ label: "Key", value: deleteRow.key, code: true },
|
||||
{ label: "Memory ID", value: deleteRow.memory_id, code: true },
|
||||
{ label: "User ID", value: deleteRow.user_id ?? "-", code: true },
|
||||
{ label: "Team ID", value: deleteRow.team_id ?? "-", code: true },
|
||||
{ label: t("memory.view.deleteModal.fields.key"), value: deleteRow.key, code: true },
|
||||
{ label: t("memory.view.deleteModal.fields.memoryId"), value: deleteRow.memory_id, code: true },
|
||||
{ label: t("memory.view.deleteModal.fields.userId"), value: deleteRow.user_id ?? "-", code: true },
|
||||
{ label: t("memory.view.deleteModal.fields.teamId"), value: deleteRow.team_id ?? "-", code: true },
|
||||
]
|
||||
: []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ import { MemoryView } from "./_components/MemoryView";
|
|||
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function Memory() {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken, userRole, userId } = useAuthorized();
|
||||
const canViewMemory = useCan("viewMemory");
|
||||
|
||||
if (!canViewMemory) {
|
||||
return <AdminOnlyNotice pageTitle="Memory" />;
|
||||
return <AdminOnlyNotice pageTitle={t("memory.page.title")} />;
|
||||
}
|
||||
|
||||
return <MemoryView accessToken={accessToken} userID={userId} userRole={userRole} />;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { modelHubCall, PromptSpec } from "@/components/networking";
|
||||
|
|
@ -22,13 +23,14 @@ interface PromptTableProps {
|
|||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No prompts yet</div>
|
||||
<div className="text-sm text-muted-foreground">Add a prompt to start managing reusable templates.</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("prompts.table.empty.title")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("prompts.table.empty.description")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -41,6 +43,7 @@ const PromptTable: React.FC<PromptTableProps> = ({
|
|||
accessToken,
|
||||
isAdmin,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
const [modelHubData, setModelHubData] = useState<Map<string, ModelGroupInfo>>(new Map());
|
||||
|
||||
|
|
@ -66,8 +69,8 @@ const PromptTable: React.FC<PromptTableProps> = ({
|
|||
}, [accessToken]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getPromptTableColumns({ modelHubData, isAdmin, onPromptClick, onDeleteClick }),
|
||||
[modelHubData, isAdmin, onPromptClick, onDeleteClick],
|
||||
() => getPromptTableColumns({ modelHubData, isAdmin, onPromptClick, onDeleteClick, t }),
|
||||
[modelHubData, isAdmin, onPromptClick, onDeleteClick, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -82,7 +85,7 @@ const PromptTable: React.FC<PromptTableProps> = ({
|
|||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading prompts…"
|
||||
loadingMessage={t("prompts.table.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Copy, MoreHorizontal, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { CellTooltip, DateCell, IdentityCell, StatusBadge, StatusTone } from "@/components/shared/table_cells";
|
||||
|
|
@ -69,10 +71,11 @@ interface PromptRowActionsProps {
|
|||
}
|
||||
|
||||
function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open prompt actions"
|
||||
aria-label={t("prompts.table.rowActions.open")}
|
||||
data-testid={`prompt-actions-${prompt.prompt_id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -81,10 +84,10 @@ function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsPr
|
|||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem
|
||||
data-testid="prompt-action-copy"
|
||||
onClick={() => void copyToClipboard(prompt.prompt_id, "Prompt ID copied")}
|
||||
onClick={() => void copyToClipboard(prompt.prompt_id, t("prompts.table.rowActions.idCopied"))}
|
||||
>
|
||||
<Copy />
|
||||
Copy prompt ID
|
||||
{t("prompts.table.rowActions.copyId")}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<>
|
||||
|
|
@ -95,13 +98,13 @@ function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsPr
|
|||
onClick={() =>
|
||||
onDeleteClick?.(
|
||||
prompt.prompt_id,
|
||||
prompt.prompt_id || "Unknown Prompt",
|
||||
prompt.prompt_id || t("prompts.table.rowActions.unknownPrompt"),
|
||||
prompt.environment || "development",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("prompts.table.rowActions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -115,6 +118,7 @@ interface PromptTableColumnsDeps {
|
|||
isAdmin: boolean;
|
||||
onPromptClick?: (id: string, environment: string) => void;
|
||||
onDeleteClick?: (id: string, name: string, environment: string) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export const getPromptTableColumns = ({
|
||||
|
|
@ -122,12 +126,13 @@ export const getPromptTableColumns = ({
|
|||
isAdmin,
|
||||
onPromptClick,
|
||||
onDeleteClick,
|
||||
t,
|
||||
}: PromptTableColumnsDeps): ColumnDef<PromptSpec>[] => [
|
||||
{
|
||||
id: "prompt_id",
|
||||
accessorKey: "prompt_id",
|
||||
meta: { title: "Prompt ID" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Prompt ID" />,
|
||||
meta: { title: t("prompts.table.columns.promptId") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("prompts.table.columns.promptId")} />,
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -145,8 +150,8 @@ export const getPromptTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "model",
|
||||
meta: { title: "Model" },
|
||||
header: "Model",
|
||||
meta: { title: t("prompts.table.columns.model") },
|
||||
header: t("prompts.table.columns.model"),
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <PromptModelCell prompt={row.original} modelHubData={modelHubData} />,
|
||||
|
|
@ -155,8 +160,8 @@ export const getPromptTableColumns = ({
|
|||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
sortingFn: "datetime",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
meta: { title: t("prompts.table.columns.createdAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("prompts.table.columns.createdAt")} />,
|
||||
size: 160,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
|
|
@ -165,8 +170,8 @@ export const getPromptTableColumns = ({
|
|||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
sortingFn: "datetime",
|
||||
meta: { title: "Updated At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
|
||||
meta: { title: t("prompts.table.columns.updatedAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("prompts.table.columns.updatedAt")} />,
|
||||
size: 160,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
|
||||
|
|
@ -174,8 +179,8 @@ export const getPromptTableColumns = ({
|
|||
{
|
||||
id: "environment",
|
||||
accessorKey: "environment",
|
||||
meta: { title: "Environment", skeleton: "badge" },
|
||||
header: "Environment",
|
||||
meta: { title: t("prompts.table.columns.environment"), skeleton: "badge" },
|
||||
header: t("prompts.table.columns.environment"),
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -186,8 +191,8 @@ export const getPromptTableColumns = ({
|
|||
{
|
||||
id: "created_by",
|
||||
accessorKey: "created_by",
|
||||
meta: { title: "Created By" },
|
||||
header: "Created By",
|
||||
meta: { title: t("prompts.table.columns.createdBy") },
|
||||
header: t("prompts.table.columns.createdBy"),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -209,8 +214,8 @@ export const getPromptTableColumns = ({
|
|||
{
|
||||
id: "prompt_type",
|
||||
accessorKey: "prompt_info.prompt_type",
|
||||
meta: { title: "Type" },
|
||||
header: "Type",
|
||||
meta: { title: t("prompts.table.columns.type") },
|
||||
header: t("prompts.table.columns.type"),
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -225,7 +230,7 @@ export const getPromptTableColumns = ({
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("prompts.table.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React, { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Upload as UploadIcon, X } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import { convertPromptFileToJson, createPromptCall } from "@/components/networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { Field, FieldDescription, FieldGroup, FieldSeparator, FieldTitle } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -36,8 +38,8 @@ const PROMPT_INTEGRATION_OPTIONS = [{ label: "dotprompt", value: "dotprompt" }];
|
|||
const addPromptSchema = z.object({
|
||||
prompt_id: z
|
||||
.string()
|
||||
.min(1, "Please enter a prompt ID")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Prompt ID can only contain letters, numbers, underscores, and hyphens"),
|
||||
.min(1, { error: () => i18n.t("prompts.addForm.errors.idRequired") })
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, { error: () => i18n.t("prompts.addForm.errors.idInvalid") }),
|
||||
prompt_integration: z.string(),
|
||||
});
|
||||
|
||||
|
|
@ -46,6 +48,7 @@ type AddPromptFormValues = z.infer<typeof addPromptSchema>;
|
|||
const EMPTY_VALUES: AddPromptFormValues = { prompt_id: "", prompt_integration: "dotprompt" };
|
||||
|
||||
const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessToken, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(addPromptSchema, { defaultValues: EMPTY_VALUES });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
|
@ -70,7 +73,7 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
const picked = event.target.files?.[0];
|
||||
if (!picked) return;
|
||||
if (!picked.name.endsWith(".prompt")) {
|
||||
toast.fromError("Please upload a .prompt file");
|
||||
toast.fromError(t("prompts.addForm.toasts.fileRequired"));
|
||||
clearSelectedFile();
|
||||
return;
|
||||
}
|
||||
|
|
@ -104,21 +107,21 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
};
|
||||
} catch (conversionError) {
|
||||
console.error("Error converting prompt file:", conversionError);
|
||||
toast.fromError("Failed to convert prompt file to JSON");
|
||||
toast.fromError(t("prompts.addForm.toasts.convertFailed"));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: AddPromptFormValues) => {
|
||||
if (!accessToken) {
|
||||
toast.fromError("Access token is required");
|
||||
toast.fromError(t("prompts.addForm.toasts.tokenRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const isDotprompt = promptIntegration === "dotprompt";
|
||||
|
||||
if (isDotprompt && !selectedFile) {
|
||||
toast.fromError("Please upload a .prompt file");
|
||||
toast.fromError(t("prompts.addForm.toasts.fileRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -134,12 +137,12 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
|
||||
try {
|
||||
await createPromptCall(accessToken, promptData);
|
||||
toast.success("Prompt created successfully!");
|
||||
toast.success(t("prompts.addForm.toasts.created"));
|
||||
handleCancel();
|
||||
onSuccess();
|
||||
} catch (createError) {
|
||||
console.error("Error creating prompt:", createError);
|
||||
toast.fromError("Failed to create prompt");
|
||||
toast.fromError(t("prompts.addForm.toasts.createFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -149,17 +152,17 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
<Dialog open={visible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Prompt</DialogTitle>
|
||||
<DialogTitle>{t("prompts.addForm.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="prompt_id" label="Prompt ID">
|
||||
<FormField control={form.control} name="prompt_id" label={t("prompts.addForm.fields.promptId")}>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="Enter unique prompt ID (e.g., my_prompt_id)" />
|
||||
<Input {...field} ref={ref} placeholder={t("prompts.addForm.fields.promptIdPlaceholder")} />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="prompt_integration" label="Prompt Integration">
|
||||
<FormField control={form.control} name="prompt_integration" label={t("prompts.addForm.fields.integration")}>
|
||||
{({ id, value, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Select items={PROMPT_INTEGRATION_OPTIONS} value={value} onValueChange={handleIntegrationChange}>
|
||||
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy}>
|
||||
|
|
@ -180,25 +183,25 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
<>
|
||||
<FieldSeparator />
|
||||
<Field>
|
||||
<FieldTitle>Prompt File</FieldTitle>
|
||||
<FieldTitle>{t("prompts.addForm.fields.file")}</FieldTitle>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".prompt"
|
||||
aria-label="Prompt file"
|
||||
aria-label={t("prompts.addForm.fields.fileInput")}
|
||||
className="sr-only"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||
<UploadIcon />
|
||||
Select .prompt File
|
||||
{t("prompts.addForm.fields.selectFile")}
|
||||
</Button>
|
||||
{selectedFile && (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Selected: {selectedFile.name}</span>
|
||||
<span>{t("prompts.addForm.fields.selected", { name: selectedFile.name })}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${selectedFile.name}`}
|
||||
aria-label={t("prompts.addForm.fields.removeFile", { name: selectedFile.name })}
|
||||
onClick={clearSelectedFile}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
|
|
@ -206,7 +209,7 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
<FieldDescription>Upload a .prompt file that follows the Dotprompt specification</FieldDescription>
|
||||
<FieldDescription>{t("prompts.addForm.fields.fileDescription")}</FieldDescription>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -214,11 +217,11 @@ const AddPromptForm: React.FC<AddPromptFormProps> = ({ visible, onClose, accessT
|
|||
</form>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("prompts.addForm.cancel")}
|
||||
</Button>
|
||||
<Button type="button" disabled={loading} onClick={() => void form.handleSubmit(handleSubmit)()}>
|
||||
{loading && <UiLoadingSpinner className="size-4" />}
|
||||
Create Prompt
|
||||
{t("prompts.addForm.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Plus, Upload } from "lucide-react";
|
||||
import { getPromptsList, PromptSpec, ListPromptsResponse, deletePromptCall } from "@/components/networking";
|
||||
|
|
@ -20,16 +22,16 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const ALL_ENVIRONMENTS_LABEL = "All Environments";
|
||||
const ALL_ENVIRONMENTS_LABEL_KEY: ParseKeys = "prompts.panel.environments.all";
|
||||
|
||||
const ENVIRONMENT_OPTIONS = [
|
||||
{ label: "Development", value: "development" },
|
||||
{ label: "Staging", value: "staging" },
|
||||
{ label: "Production", value: "production" },
|
||||
const ENVIRONMENT_OPTIONS: { labelKey: ParseKeys; value: string }[] = [
|
||||
{ labelKey: "prompts.panel.environments.development", value: "development" },
|
||||
{ labelKey: "prompts.panel.environments.staging", value: "staging" },
|
||||
{ labelKey: "prompts.panel.environments.production", value: "production" },
|
||||
];
|
||||
|
||||
// SelectValue falls back to the raw value unless the root can map it to a label.
|
||||
const ENVIRONMENT_ITEMS = [{ label: ALL_ENVIRONMENTS_LABEL, value: null }, ...ENVIRONMENT_OPTIONS];
|
||||
const ENVIRONMENT_ITEMS = [{ labelKey: ALL_ENVIRONMENTS_LABEL_KEY, value: null }, ...ENVIRONMENT_OPTIONS];
|
||||
|
||||
interface PromptsProps {
|
||||
accessToken: string | null;
|
||||
|
|
@ -37,6 +39,7 @@ interface PromptsProps {
|
|||
}
|
||||
|
||||
const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
|
||||
const { t } = useTranslation();
|
||||
const [promptsList, setPromptsList] = useState<PromptSpec[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState<string | undefined>(undefined);
|
||||
|
|
@ -123,11 +126,13 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
|
|||
setIsDeleting(true);
|
||||
try {
|
||||
await deletePromptCall(accessToken, promptToDelete.id, promptToDelete.environment);
|
||||
toast.success(`Prompt "${promptToDelete.name}" deleted successfully from ${promptToDelete.environment}`);
|
||||
toast.success(
|
||||
t("prompts.panel.toasts.deleted", { name: promptToDelete.name, environment: promptToDelete.environment }),
|
||||
);
|
||||
fetchPrompts(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Error deleting prompt:", error);
|
||||
toast.fromError("Failed to delete prompt");
|
||||
toast.fromError(t("prompts.panel.toasts.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setPromptToDelete(null);
|
||||
|
|
@ -165,28 +170,28 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
|
|||
<>
|
||||
<Button onClick={handleAddPrompt} disabled={!accessToken}>
|
||||
<Plus />
|
||||
Add New Prompt
|
||||
{t("prompts.panel.addNew")}
|
||||
</Button>
|
||||
<Button onClick={handleAddPromptFromFile} disabled={!accessToken} variant="secondary">
|
||||
<Upload />
|
||||
Upload .prompt File
|
||||
{t("prompts.panel.uploadFile")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
items={ENVIRONMENT_ITEMS}
|
||||
items={ENVIRONMENT_ITEMS.map((item) => ({ label: t(item.labelKey), value: item.value }))}
|
||||
value={selectedEnvironment ?? null}
|
||||
onValueChange={(value) => setSelectedEnvironment((value as string | null) ?? undefined)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={ALL_ENVIRONMENTS_LABEL} />
|
||||
<SelectValue placeholder={t(ALL_ENVIRONMENTS_LABEL_KEY)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>{ALL_ENVIRONMENTS_LABEL}</SelectItem>
|
||||
<SelectItem value={null}>{t(ALL_ENVIRONMENTS_LABEL_KEY)}</SelectItem>
|
||||
{ENVIRONMENT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -220,16 +225,18 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
|
|||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Prompt</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("prompts.panel.deleteDialog.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete the {promptToDelete.environment} copy of prompt: {promptToDelete.name}?
|
||||
This action cannot be undone.
|
||||
{t("prompts.panel.deleteDialog.description", {
|
||||
environment: promptToDelete.environment,
|
||||
name: promptToDelete.name,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isDeleting}>{t("prompts.panel.deleteDialog.cancel")}</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
|
||||
Delete
|
||||
{t("prompts.panel.deleteDialog.confirm")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import VariableTextArea from "../variable_textarea";
|
||||
|
||||
|
|
@ -8,16 +9,17 @@ interface DeveloperMessageCardProps {
|
|||
}
|
||||
|
||||
const DeveloperMessageCard: React.FC<DeveloperMessageCardProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<p className="mb-2 text-sm font-medium text-foreground">Developer message</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">Optional system instructions for the model</p>
|
||||
<p className="mb-2 text-sm font-medium text-foreground">{t("prompts.developerMessage.title")}</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t("prompts.developerMessage.description")}</p>
|
||||
<VariableTextArea
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
rows={3}
|
||||
placeholder="e.g., You are a helpful assistant..."
|
||||
placeholder={t("prompts.developerMessage.placeholder")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PromptType } from "./types";
|
||||
import { convertToDotPrompt } from "./utils";
|
||||
|
||||
|
|
@ -7,13 +8,14 @@ interface DotpromptViewTabProps {
|
|||
}
|
||||
|
||||
const DotpromptViewTab: React.FC<DotpromptViewTabProps> = ({ prompt }) => {
|
||||
const { t } = useTranslation();
|
||||
const dotpromptContent = convertToDotPrompt(prompt);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">Generated .prompt file</h3>
|
||||
<p className="text-xs text-muted-foreground">This is the dotprompt format that will be saved to the database</p>
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">{t("prompts.dotpromptView.title")}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t("prompts.dotpromptView.description")}</p>
|
||||
</div>
|
||||
<div className="bg-muted border border-border rounded-lg p-4 overflow-auto">
|
||||
<pre className="text-sm text-foreground font-mono whitespace-pre-wrap">{dotpromptContent}</pre>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ModelSelector from "@/components/common_components/ModelSelector";
|
||||
|
||||
interface ModelConfigCardProps {
|
||||
|
|
@ -24,6 +25,7 @@ const ModelConfigCard: React.FC<ModelConfigCardProps> = ({
|
|||
onTemperatureChange,
|
||||
onMaxTokensChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -34,19 +36,19 @@ const ModelConfigCard: React.FC<ModelConfigCardProps> = ({
|
|||
|
||||
<Button type="button" variant="outline" onClick={() => setShowConfig(!showConfig)} className="gap-2">
|
||||
<SettingsIcon size={16} />
|
||||
<span>Parameters</span>
|
||||
<span>{t("prompts.modelConfig.parameters")}</span>
|
||||
</Button>
|
||||
|
||||
<Dialog open={showConfig} onOpenChange={setShowConfig}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Model Parameters</DialogTitle>
|
||||
<DialogTitle>{t("prompts.modelConfig.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label htmlFor="prompt-temperature" className="text-sm text-foreground">
|
||||
Temperature
|
||||
{t("prompts.modelConfig.temperature")}
|
||||
</label>
|
||||
<Input
|
||||
id="prompt-temperature"
|
||||
|
|
@ -63,7 +65,7 @@ const ModelConfigCard: React.FC<ModelConfigCardProps> = ({
|
|||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label htmlFor="prompt-max-tokens" className="text-sm text-foreground">
|
||||
Max Tokens
|
||||
{t("prompts.modelConfig.maxTokens")}
|
||||
</label>
|
||||
<Input
|
||||
id="prompt-max-tokens"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { CodeIcon, CopyIcon } 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";
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ const PromptCodeSnippets: React.FC<PromptCodeSnippetsProps> = ({
|
|||
environment,
|
||||
proxySettings,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const syntaxTheme = useSyntaxTheme(coy);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<"curl" | "python" | "javascript">("curl");
|
||||
|
|
@ -252,18 +254,18 @@ main();`;
|
|||
<>
|
||||
<Button variant="outline" onClick={showModal}>
|
||||
<CodeIcon />
|
||||
Get Code
|
||||
{t("prompts.codeSnippets.trigger")}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isModalVisible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generated Code</DialogTitle>
|
||||
<DialogTitle>{t("prompts.codeSnippets.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<label htmlFor="prompt-code-language" className="font-medium block mb-1 text-foreground">
|
||||
Language
|
||||
{t("prompts.codeSnippets.language")}
|
||||
</label>
|
||||
<Select
|
||||
items={LANGUAGE_ITEMS}
|
||||
|
|
@ -286,19 +288,19 @@ main();`;
|
|||
variant="outline"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(generatedCode);
|
||||
toast.success("Copied to clipboard!");
|
||||
toast.success(t("prompts.codeSnippets.copied"));
|
||||
}}
|
||||
>
|
||||
<CopyIcon />
|
||||
Copy to Clipboard
|
||||
{t("prompts.codeSnippets.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs value={selectedTab} onValueChange={(value) => setSelectedTab(String(value))}>
|
||||
<TabsList aria-label="Generated code type">
|
||||
<TabsTrigger value="basic">Basic</TabsTrigger>
|
||||
<TabsTrigger value="messages">With Messages</TabsTrigger>
|
||||
<TabsTrigger value="version">With Version</TabsTrigger>
|
||||
<TabsList aria-label={t("prompts.codeSnippets.typeLabel")}>
|
||||
<TabsTrigger value="basic">{t("prompts.codeSnippets.tabs.basic")}</TabsTrigger>
|
||||
<TabsTrigger value="messages">{t("prompts.codeSnippets.tabs.messages")}</TabsTrigger>
|
||||
<TabsTrigger value="version">{t("prompts.codeSnippets.tabs.version")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { ArrowLeftIcon, SaveIcon, ClockIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PromptCodeSnippets from "./PromptCodeSnippets";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -7,9 +8,9 @@ import { Input } from "@/components/ui/input";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const ENVIRONMENT_ITEMS = [
|
||||
{ value: "development", label: "Development" },
|
||||
{ value: "staging", label: "Staging" },
|
||||
{ value: "production", label: "Production" },
|
||||
{ value: "development", labelKey: "prompts.editorHeader.environments.development" },
|
||||
{ value: "staging", labelKey: "prompts.editorHeader.environments.staging" },
|
||||
{ value: "production", labelKey: "prompts.editorHeader.environments.production" },
|
||||
] as const;
|
||||
|
||||
interface PromptEditorHeaderProps {
|
||||
|
|
@ -48,15 +49,16 @@ const PromptEditorHeader: React.FC<PromptEditorHeaderProps> = ({
|
|||
environment,
|
||||
onEnvironmentChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="bg-background border-b border-border px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" onClick={onBack} size="sm">
|
||||
<ArrowLeftIcon />
|
||||
Back
|
||||
{t("prompts.editorHeader.back")}
|
||||
</Button>
|
||||
<Input
|
||||
aria-label="Prompt name"
|
||||
aria-label={t("prompts.editorHeader.nameLabel")}
|
||||
value={promptName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
className="text-base font-medium border-none shadow-none"
|
||||
|
|
@ -64,23 +66,23 @@ const PromptEditorHeader: React.FC<PromptEditorHeaderProps> = ({
|
|||
/>
|
||||
{version && <Badge>{version}</Badge>}
|
||||
<Select
|
||||
items={ENVIRONMENT_ITEMS}
|
||||
items={ENVIRONMENT_ITEMS.map((item) => ({ value: item.value, label: t(item.labelKey) }))}
|
||||
value={environment}
|
||||
onValueChange={(value) => onEnvironmentChange(String(value))}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[140px]" aria-label="Environment">
|
||||
<SelectTrigger size="sm" className="w-[140px]" aria-label={t("prompts.editorHeader.environmentLabel")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENVIRONMENT_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Badge variant="secondary">Draft</Badge>
|
||||
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
||||
<Badge variant="secondary">{t("prompts.editorHeader.draft")}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{t("prompts.editorHeader.unsaved")}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<PromptCodeSnippets
|
||||
|
|
@ -95,12 +97,12 @@ const PromptEditorHeader: React.FC<PromptEditorHeaderProps> = ({
|
|||
{editMode && onShowHistory && (
|
||||
<Button variant="outline" onClick={onShowHistory}>
|
||||
<ClockIcon />
|
||||
History
|
||||
{t("prompts.editorHeader.history")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSave} disabled={isSaving}>
|
||||
{isSaving ? <LoaderCircleIcon className="animate-spin" /> : <SaveIcon />}
|
||||
{editMode ? "Update" : "Save"}
|
||||
{editMode ? t("prompts.editorHeader.update") : t("prompts.editorHeader.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { PlusIcon, TrashIcon, GripVerticalIcon } from "lucide-react";
|
||||
import VariableTextArea from "../variable_textarea";
|
||||
import { Message } from "./types";
|
||||
|
|
@ -7,9 +8,9 @@ import { Card } from "@/components/ui/card";
|
|||
import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
const ROLE_ITEMS = [
|
||||
{ value: "user", label: "User" },
|
||||
{ value: "assistant", label: "Assistant" },
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "user", labelKey: "prompts.messagesCard.roles.user" },
|
||||
{ value: "assistant", labelKey: "prompts.messagesCard.roles.assistant" },
|
||||
{ value: "system", labelKey: "prompts.messagesCard.roles.system" },
|
||||
] as const;
|
||||
|
||||
interface PromptMessagesCardProps {
|
||||
|
|
@ -27,6 +28,7 @@ const PromptMessagesCard: React.FC<PromptMessagesCardProps> = ({
|
|||
onRemoveMessage,
|
||||
onMoveMessage,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
|
|
@ -56,9 +58,15 @@ const PromptMessagesCard: React.FC<PromptMessagesCardProps> = ({
|
|||
return (
|
||||
<Card className="p-3">
|
||||
<div className="mb-2">
|
||||
<p className="text-sm font-medium">Prompt messages</p>
|
||||
<p className="text-sm font-medium">{t("prompts.messagesCard.title")}</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
Use <code className="bg-muted px-1 rounded-sm text-xs">{"{{variable}}"}</code> syntax for template variables
|
||||
<Trans
|
||||
i18nKey="prompts.messagesCard.syntaxHint"
|
||||
components={{
|
||||
code: <code className="bg-muted px-1 rounded-sm text-xs" />,
|
||||
variable: <>{"{{variable}}"}</>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -76,21 +84,21 @@ const PromptMessagesCard: React.FC<PromptMessagesCardProps> = ({
|
|||
>
|
||||
<div className="bg-muted px-2 py-1.5 border-b border-border flex items-center justify-between">
|
||||
<ShadcnSelect
|
||||
items={ROLE_ITEMS}
|
||||
items={ROLE_ITEMS.map((item) => ({ value: item.value, label: t(item.labelKey) }))}
|
||||
value={message.role}
|
||||
onValueChange={(value) => onUpdateMessage(index, "role", String(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-[110px] border-0 shadow-none"
|
||||
aria-label={`Message ${index + 1} role`}
|
||||
aria-label={t("prompts.messagesCard.roleLabel", { number: index + 1 })}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -100,7 +108,7 @@ const PromptMessagesCard: React.FC<PromptMessagesCardProps> = ({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove message ${index + 1}`}
|
||||
aria-label={t("prompts.messagesCard.remove", { number: index + 1 })}
|
||||
onClick={() => onRemoveMessage(index)}
|
||||
>
|
||||
<TrashIcon size={14} />
|
||||
|
|
@ -116,7 +124,7 @@ const PromptMessagesCard: React.FC<PromptMessagesCardProps> = ({
|
|||
value={message.content}
|
||||
onChange={(value) => onUpdateMessage(index, "content", value)}
|
||||
rows={3}
|
||||
placeholder="Enter prompt content..."
|
||||
placeholder={t("prompts.messagesCard.contentPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -124,7 +132,7 @@ const PromptMessagesCard: React.FC<PromptMessagesCardProps> = ({
|
|||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onAddMessage} className="mt-2">
|
||||
<PlusIcon size={14} className="mr-1" />
|
||||
Add message
|
||||
{t("prompts.messagesCard.add")}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -28,36 +29,35 @@ const PublishModal: React.FC<PublishModalProps> = ({
|
|||
onPublish,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Publish Prompt</DialogTitle>
|
||||
<DialogDescription>Published prompts are versioned and can be used in API calls.</DialogDescription>
|
||||
<DialogTitle>{t("prompts.publishModal.title")}</DialogTitle>
|
||||
<DialogDescription>{t("prompts.publishModal.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<label htmlFor="publish-prompt-name" className="mb-2 block">
|
||||
Name
|
||||
{t("prompts.publishModal.nameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="publish-prompt-name"
|
||||
value={promptName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="Enter prompt name"
|
||||
placeholder={t("prompts.publishModal.namePlaceholder")}
|
||||
onKeyDown={(event) => event.key === "Enter" && onPublish()}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs mt-2">
|
||||
Published prompts can be used in API calls and are versioned for easy tracking.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs mt-2">{t("prompts.publishModal.hint")}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
{t("prompts.publishModal.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onPublish} disabled={isSaving}>
|
||||
{isSaving && <LoaderCircleIcon className="animate-spin" />}
|
||||
Publish
|
||||
{t("prompts.publishModal.publish")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tool } from "./types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
|
@ -12,17 +13,18 @@ interface ToolsCardProps {
|
|||
}
|
||||
|
||||
const ToolsCard: React.FC<ToolsCardProps> = ({ tools, onAddTool, onEditTool, onRemoveTool }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">Tools</p>
|
||||
<p className="text-sm font-medium">{t("prompts.toolsCard.title")}</p>
|
||||
<Button variant="ghost" size="sm" onClick={onAddTool}>
|
||||
<PlusIcon size={14} className="mr-1" />
|
||||
Add
|
||||
{t("prompts.toolsCard.add")}
|
||||
</Button>
|
||||
</div>
|
||||
{tools.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">No tools added</p>
|
||||
<p className="text-muted-foreground text-xs">{t("prompts.toolsCard.empty")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tools.map((tool, index) => (
|
||||
|
|
@ -33,12 +35,12 @@ const ToolsCard: React.FC<ToolsCardProps> = ({ tools, onAddTool, onEditTool, onR
|
|||
</div>
|
||||
<div className="flex items-center space-x-1 ml-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => onEditTool(index)}>
|
||||
Edit
|
||||
{t("prompts.toolsCard.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove ${tool.name}`}
|
||||
aria-label={t("prompts.toolsCard.remove", { name: tool.name })}
|
||||
onClick={() => onRemoveTool(index)}
|
||||
>
|
||||
<TrashIcon size={14} aria-hidden="true" />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface VersionHistorySidePanelProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -22,6 +23,7 @@ const VersionHistorySidePanel: React.FC<VersionHistorySidePanelProps> = ({
|
|||
activeVersionId,
|
||||
onSelectVersion,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [versions, setVersions] = useState<PromptSpec[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
|
@ -90,23 +92,23 @@ const VersionHistorySidePanel: React.FC<VersionHistorySidePanelProps> = ({
|
|||
>
|
||||
<Button type="button" variant="ghost" size="icon-sm" className="absolute top-4 right-4" onClick={onClose}>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t("prompts.versionHistory.close")}</span>
|
||||
</Button>
|
||||
<header className="flex flex-col gap-1.5 p-4">
|
||||
<h2 id="version-history-title" className="font-medium text-foreground">
|
||||
Version History
|
||||
{t("prompts.versionHistory.title")}
|
||||
</h2>
|
||||
</header>
|
||||
<div className="overflow-y-auto px-4 pb-4">
|
||||
{loading ? (
|
||||
<div className="space-y-3" role="status" aria-label="Loading version history">
|
||||
<div className="space-y-3" role="status" aria-label={t("prompts.versionHistory.loading")}>
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No version history available.</div>
|
||||
<div className="text-center py-8 text-muted-foreground">{t("prompts.versionHistory.empty")}</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{versions.map((item, index) => {
|
||||
|
|
@ -138,15 +140,17 @@ const VersionHistorySidePanel: React.FC<VersionHistorySidePanelProps> = ({
|
|||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{getVersionNumber(item)}</Badge>
|
||||
{index === 0 && <Badge>Latest</Badge>}
|
||||
{index === 0 && <Badge>{t("prompts.versionHistory.latest")}</Badge>}
|
||||
</div>
|
||||
{isSelected && <Badge variant="secondary">Active</Badge>}
|
||||
{isSelected && <Badge variant="secondary">{t("prompts.versionHistory.active")}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-muted-foreground font-medium">{formatDate(item.created_at)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.prompt_info?.prompt_type === "db" ? "Saved to Database" : "Config Prompt"}
|
||||
{item.prompt_info?.prompt_type === "db"
|
||||
? t("prompts.versionHistory.savedToDatabase")
|
||||
: t("prompts.versionHistory.configPrompt")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import React from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface EmptyStateProps {
|
||||
hasVariables: boolean;
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({ hasVariables }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Bot className="mb-4 size-12" aria-hidden="true" />
|
||||
<span className="text-base">
|
||||
{hasVariables
|
||||
? "Fill in the variables above, then type a message to start testing"
|
||||
: "Type a message below to start testing your prompt"}
|
||||
? t("prompts.conversation.emptyState.withVariables")
|
||||
: t("prompts.conversation.emptyState.withoutVariables")}
|
||||
</span>
|
||||
</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";
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||
onKeyDown,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center flex-1 bg-background border border-border rounded-xl px-3 py-1 min-h-[44px]">
|
||||
|
|
@ -29,7 +31,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||
value={inputMessage}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Type your message... (Shift+Enter for new line)"
|
||||
placeholder={t("prompts.conversation.input.placeholder")}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
className="field-sizing-content max-h-24 min-h-8 flex-1 resize-none overflow-y-auto border-0 bg-transparent px-0 py-1 text-sm shadow-none focus-visible:ring-0"
|
||||
|
|
@ -41,7 +43,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||
onClick={onSend}
|
||||
disabled={isDisabled}
|
||||
className="ml-2 shrink-0 rounded-full"
|
||||
aria-label="Send message"
|
||||
aria-label={t("prompts.conversation.input.send")}
|
||||
>
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
|
|
@ -49,7 +51,7 @@ const MessageInput: React.FC<MessageInputProps> = ({
|
|||
|
||||
{isLoading && (
|
||||
<Button type="button" variant="destructive" onClick={onCancel}>
|
||||
Cancel
|
||||
{t("prompts.conversation.input.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmptyState from "./EmptyState";
|
||||
import MessageBubble from "./MessageBubble";
|
||||
import { Message } from "./types";
|
||||
|
|
@ -12,6 +13,7 @@ interface MessageListProps {
|
|||
}
|
||||
|
||||
const MessageList: React.FC<MessageListProps> = ({ messages, isLoading, hasVariables, messagesEndRef }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-0">
|
||||
{messages.length === 0 && <EmptyState hasVariables={hasVariables} />}
|
||||
|
|
@ -22,7 +24,10 @@ const MessageList: React.FC<MessageListProps> = ({ messages, isLoading, hasVaria
|
|||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center my-4">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" aria-label="Loading response" />
|
||||
<Loader2
|
||||
className="size-6 animate-spin text-muted-foreground"
|
||||
aria-label={t("prompts.conversation.list.loadingResponse")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} style={{ height: "1px" }} />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface VariableInputProps {
|
||||
|
|
@ -8,13 +9,14 @@ interface VariableInputProps {
|
|||
}
|
||||
|
||||
const VariableInput: React.FC<VariableInputProps> = ({ extractedVariables, variables, onVariableChange }) => {
|
||||
const { t } = useTranslation();
|
||||
if (extractedVariables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-border bg-accent">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">Fill in template variables to start testing</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3">{t("prompts.conversation.variables.title")}</h3>
|
||||
<div className="space-y-2">
|
||||
{extractedVariables.map((varName) => (
|
||||
<div key={varName}>
|
||||
|
|
@ -26,7 +28,7 @@ const VariableInput: React.FC<VariableInputProps> = ({ extractedVariables, varia
|
|||
<Input
|
||||
value={variables[varName] || ""}
|
||||
onChange={(e) => onVariableChange(varName, e.target.value)}
|
||||
placeholder={`Enter value for ${varName}`}
|
||||
placeholder={t("prompts.conversation.variables.placeholder", { name: varName })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface VariableWarningProps {
|
||||
extractedVariables: string[];
|
||||
|
|
@ -6,6 +7,7 @@ interface VariableWarningProps {
|
|||
}
|
||||
|
||||
const VariableWarning: React.FC<VariableWarningProps> = ({ extractedVariables, variables }) => {
|
||||
const { t } = useTranslation();
|
||||
const missingVariables = extractedVariables.filter(
|
||||
(varName) => !variables[varName] || variables[varName].trim() === "",
|
||||
);
|
||||
|
|
@ -19,9 +21,11 @@ const VariableWarning: React.FC<VariableWarningProps> = ({ extractedVariables, v
|
|||
<div className="flex items-start gap-2">
|
||||
<span className="text-warning text-sm">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-warning font-medium mb-1">Please fill in all template variables above</p>
|
||||
<p className="text-sm text-warning font-medium mb-1">{t("prompts.conversation.warning.title")}</p>
|
||||
<p className="text-xs text-warning">
|
||||
Missing: {missingVariables.map((varName) => `{{${varName}}}`).join(", ")}
|
||||
{t("prompts.conversation.warning.missing", {
|
||||
variables: missingVariables.map((varName) => `{{${varName}}}`).join(", "),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConversationPanelProps } from "./types";
|
||||
import { useConversation } from "./useConversation";
|
||||
|
|
@ -9,6 +10,7 @@ import VariableWarning from "./VariableWarning";
|
|||
import MessageInput from "./MessageInput";
|
||||
|
||||
const ConversationPanel: React.FC<ConversationPanelProps> = ({ prompt, accessToken }) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
isLoading,
|
||||
messages,
|
||||
|
|
@ -40,7 +42,7 @@ const ConversationPanel: React.FC<ConversationPanelProps> = ({ prompt, accessTok
|
|||
<div className="p-3 border-b border-border bg-background flex justify-end">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleClearConversation}>
|
||||
<Trash2 aria-hidden="true" />
|
||||
Clear Chat
|
||||
{t("prompts.conversation.panel.clearChat")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { TokenUsage } from "@/components/chat_ui/ResponseMetrics";
|
||||
import { Message } from "./types";
|
||||
|
|
@ -7,6 +8,7 @@ import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networ
|
|||
import { streamErrorMessage } from "@/utils/streamErrorMessage";
|
||||
|
||||
export const useConversation = (prompt: any, accessToken: string | null) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
|
|
@ -38,12 +40,12 @@ export const useConversation = (prompt: any, accessToken: string | null) => {
|
|||
|
||||
const handleSendMessage = async () => {
|
||||
if (!accessToken) {
|
||||
toast.fromError("Access token is required");
|
||||
toast.fromError(t("prompts.conversation.toasts.tokenRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (extractedVariables.length > 0 && !allVariablesFilled) {
|
||||
toast.fromError("Please fill in all template variables");
|
||||
toast.fromError(t("prompts.conversation.toasts.variablesRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -208,14 +210,14 @@ export const useConversation = (prompt: any, accessToken: string | null) => {
|
|||
abortController.abort();
|
||||
setAbortController(null);
|
||||
setIsLoading(false);
|
||||
toast.info("Request cancelled");
|
||||
toast.info(t("prompts.conversation.toasts.cancelled"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearConversation = () => {
|
||||
setMessages([]);
|
||||
setVariablesFilled(false);
|
||||
toast.success("Chat history cleared.");
|
||||
toast.success(t("prompts.conversation.toasts.cleared"));
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ToolModal from "../tool_modal";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { createPromptCall, updatePromptCall, getPromptInfo } from "@/components/networking";
|
||||
|
|
@ -15,13 +16,14 @@ import DotpromptViewTab from "./DotpromptViewTab";
|
|||
import VersionHistorySidePanel from "./VersionHistorySidePanel";
|
||||
|
||||
const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess, accessToken, initialPromptData }) => {
|
||||
const { t } = useTranslation();
|
||||
const getInitialPrompt = (): PromptType => {
|
||||
if (initialPromptData) {
|
||||
try {
|
||||
return parseExistingPrompt(initialPromptData);
|
||||
} catch (error) {
|
||||
console.error("Error parsing existing prompt:", error);
|
||||
toast.fromError("Failed to parse prompt data");
|
||||
toast.fromError(t("prompts.editor.toasts.parseFailed"));
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
|
@ -120,7 +122,7 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
|
|||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
const tool: Tool = {
|
||||
name: parsed.function?.name || "Unnamed Tool",
|
||||
name: parsed.function?.name || t("prompts.editor.unnamedTool"),
|
||||
description: parsed.function?.description || "",
|
||||
json: json,
|
||||
};
|
||||
|
|
@ -142,7 +144,7 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
|
|||
setShowToolModal(false);
|
||||
setEditingToolIndex(null);
|
||||
} catch (error) {
|
||||
toast.fromError("Invalid JSON format");
|
||||
toast.fromError(t("prompts.editor.toasts.invalidJson"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -171,7 +173,7 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
|
|||
setActiveVersionId(`${versionData.prompt_id}.v${versionNum}`);
|
||||
} catch (error) {
|
||||
console.error("Error loading version:", error);
|
||||
toast.fromError("Failed to load prompt version");
|
||||
toast.fromError(t("prompts.editor.toasts.loadVersionFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -185,12 +187,12 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
|
|||
|
||||
const handleSave = async () => {
|
||||
if (!accessToken) {
|
||||
toast.fromError("Access token is required");
|
||||
toast.fromError(t("prompts.editor.toasts.tokenRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prompt.name || prompt.name.trim() === "") {
|
||||
toast.fromError("Please enter a valid prompt name");
|
||||
toast.fromError(t("prompts.editor.toasts.nameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -214,16 +216,16 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
|
|||
|
||||
if (editMode && initialPromptData?.prompt_spec?.prompt_id) {
|
||||
await updatePromptCall(accessToken, initialPromptData.prompt_spec.prompt_id, promptData);
|
||||
toast.success("Prompt updated successfully!");
|
||||
toast.success(t("prompts.editor.toasts.updated"));
|
||||
} else {
|
||||
await createPromptCall(accessToken, promptData);
|
||||
toast.success("Prompt created successfully!");
|
||||
toast.success(t("prompts.editor.toasts.created"));
|
||||
}
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error saving prompt:", error);
|
||||
toast.fromError(editMode ? "Failed to update prompt" : "Failed to save prompt");
|
||||
toast.fromError(editMode ? t("prompts.editor.toasts.updateFailed") : t("prompts.editor.toasts.saveFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setShowNameModal(false);
|
||||
|
|
@ -320,7 +322,7 @@ const PromptEditorView: React.FC<PromptEditorViewProps> = ({ onClose, onSuccess,
|
|||
}`}
|
||||
onClick={() => setViewMode("pretty")}
|
||||
>
|
||||
PRETTY
|
||||
{t("prompts.editor.viewModes.pretty")}
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1 text-xs font-medium rounded-full transition-colors ${
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import i18n from "@/i18n/i18n";
|
||||
import { PromptType, Message, Tool } from "./types";
|
||||
|
||||
export const extractVariables = (prompt: PromptType): string[] => {
|
||||
|
|
@ -117,7 +118,7 @@ const parseToolsFromFrontmatter = (lines: string[]): Tool[] => {
|
|||
try {
|
||||
const toolObj = JSON.parse(rawJson);
|
||||
tools.push({
|
||||
name: toolObj?.function?.name || "Unnamed Tool",
|
||||
name: toolObj?.function?.name || i18n.t("prompts.editor.unnamedTool"),
|
||||
description: toolObj?.function?.description || "",
|
||||
json: JSON.stringify(toolObj, null, 2),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
|
@ -37,6 +38,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
onDelete,
|
||||
onEdit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [promptData, setPromptData] = useState<PromptSpec | null>(null);
|
||||
const [promptTemplate, setPromptTemplate] = useState<PromptTemplateBase | null>(null);
|
||||
const [rawApiResponse, setRawApiResponse] = useState<any>(null);
|
||||
|
|
@ -71,7 +73,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
}
|
||||
setSelectedVersion(response.prompt_spec.version || null);
|
||||
} catch (error) {
|
||||
toast.fromError("Failed to load prompt information");
|
||||
toast.fromError(t("prompts.info.toasts.loadFailed"));
|
||||
console.error("Error fetching prompt info:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -118,11 +120,11 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
}, [selectedEnv]);
|
||||
|
||||
if (loading && !promptData) {
|
||||
return <div className="p-4">Loading...</div>;
|
||||
return <div className="p-4">{t("prompts.info.loading")}</div>;
|
||||
}
|
||||
|
||||
if (!promptData) {
|
||||
return <div className="p-4">Prompt not found</div>;
|
||||
return <div className="p-4">{t("prompts.info.notFound")}</div>;
|
||||
}
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
|
|
@ -150,12 +152,12 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
setIsDeleting(true);
|
||||
try {
|
||||
await deletePromptCall(accessToken, basePromptId);
|
||||
toast.success(`Prompt "${basePromptId}" deleted successfully`);
|
||||
toast.success(t("prompts.info.toasts.deleted", { name: basePromptId }));
|
||||
onDelete?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error deleting prompt:", error);
|
||||
toast.fromError("Failed to delete prompt");
|
||||
toast.fromError(t("prompts.info.toasts.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
|
|
@ -178,7 +180,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
setPromptTemplate(response.raw_prompt_template);
|
||||
setRawApiResponse(response);
|
||||
} catch {
|
||||
toast.fromError(`Failed to load version v${versionNum}`);
|
||||
toast.fromError(t("prompts.info.toasts.loadVersionFailed", { version: versionNum }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -193,11 +195,11 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
<div>
|
||||
<Button variant="ghost" onClick={onClose} className="mb-4">
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to Prompts
|
||||
{t("prompts.info.back")}
|
||||
</Button>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Prompt Details</h1>
|
||||
<h1 className="text-2xl font-semibold">{t("prompts.info.title")}</h1>
|
||||
<div className="flex items-center cursor-pointer">
|
||||
<p className="text-sm text-muted-foreground font-mono">{basePromptId}</p>
|
||||
<Button
|
||||
|
|
@ -225,12 +227,12 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
/>
|
||||
<Button onClick={() => onEdit?.(rawApiResponse)} className="flex items-center">
|
||||
<Pencil />
|
||||
Prompt Studio
|
||||
{t("prompts.info.openStudio")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button variant="secondary" onClick={handleDeleteClick} className="flex items-center">
|
||||
<Trash2 />
|
||||
Delete Prompt
|
||||
{t("prompts.info.deletePrompt")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -264,7 +266,9 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
>
|
||||
{env}
|
||||
{versionHistory.length > 0 && selectedEnv === env && (
|
||||
<span className="ml-1 text-xs opacity-75">(v{latestVersion})</span>
|
||||
<span className="ml-1 text-xs opacity-75">
|
||||
{t("prompts.info.environmentVersion", { version: latestVersion })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -275,7 +279,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
{isViewingOldVersion && (
|
||||
<div className="mb-4 p-3 bg-warning/10 border border-warning/20 rounded-lg flex items-center justify-between">
|
||||
<p className="text-sm text-warning">
|
||||
Viewing v{selectedVersion} — not the latest version (v{latestVersion})
|
||||
{t("prompts.info.oldVersionBanner", { selected: selectedVersion, latest: latestVersion })}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -285,7 +289,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
if (latest) handleVersionClick(latest);
|
||||
}}
|
||||
>
|
||||
Go to latest
|
||||
{t("prompts.info.goToLatest")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -293,15 +297,15 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
<Tabs defaultValue="overview">
|
||||
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
|
||||
Overview
|
||||
{t("prompts.info.tabs.overview")}
|
||||
</TabsTrigger>
|
||||
{promptTemplate && (
|
||||
<TabsTrigger value="prompt-template" className="flex-none rounded-none px-4 py-2">
|
||||
Prompt Template
|
||||
{t("prompts.info.tabs.template")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="raw-json" className="flex-none rounded-none px-4 py-2">
|
||||
Raw JSON
|
||||
{t("prompts.info.tabs.rawJson")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -310,51 +314,55 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
<TabsContent value="overview" keepMounted>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="block p-6">
|
||||
<p>Version</p>
|
||||
<p>{t("prompts.info.overview.version")}</p>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-lg font-medium">{currentVersion}</h3>
|
||||
<Badge variant="secondary" className="mt-1">
|
||||
v{currentVersion}
|
||||
{t("prompts.info.versionLabel", { version: currentVersion })}
|
||||
</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="block p-6">
|
||||
<p>Prompt Type</p>
|
||||
<p>{t("prompts.info.overview.promptType")}</p>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-lg font-medium">{promptData.prompt_info?.prompt_type || "-"}</h3>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="block p-6">
|
||||
<p>Created By</p>
|
||||
<p>{t("prompts.info.overview.createdBy")}</p>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-sm font-medium">{promptData.created_by || "-"}</h3>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="block p-6">
|
||||
<p>Created At</p>
|
||||
<p>{t("prompts.info.overview.createdAt")}</p>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-sm font-medium">{formatDate(promptData.created_at)}</h3>
|
||||
<p className="text-xs">Updated: {formatDate(promptData.updated_at)}</p>
|
||||
<p className="text-xs">
|
||||
{t("prompts.info.overview.updated", { date: formatDate(promptData.updated_at) })}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Version History Table */}
|
||||
<Card className="block mt-6 p-6">
|
||||
<h3 className="text-lg font-medium mb-3">Version History — {selectedEnv}</h3>
|
||||
<h3 className="text-lg font-medium mb-3">
|
||||
{t("prompts.info.history.title", { environment: selectedEnv })}
|
||||
</h3>
|
||||
{loadingVersions ? (
|
||||
<p>Loading versions...</p>
|
||||
<p>{t("prompts.info.history.loading")}</p>
|
||||
) : versionHistory.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Created By</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
<TableHead>{t("prompts.info.history.columns.version")}</TableHead>
|
||||
<TableHead>{t("prompts.info.history.columns.createdBy")}</TableHead>
|
||||
<TableHead>{t("prompts.info.history.columns.date")}</TableHead>
|
||||
<TableHead>{t("prompts.info.history.columns.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -371,10 +379,12 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
onClick={() => handleVersionClick(v)}
|
||||
>
|
||||
<TableCell>
|
||||
<span className={isSelected ? "font-bold" : ""}>v{vNum}</span>
|
||||
<span className={isSelected ? "font-bold" : ""}>
|
||||
{t("prompts.info.versionLabel", { version: vNum })}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
latest
|
||||
{t("prompts.info.history.latest")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
|
|
@ -403,7 +413,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
}}
|
||||
>
|
||||
<Pencil />
|
||||
Edit
|
||||
{t("prompts.info.history.edit")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -412,7 +422,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No versions found in {selectedEnv}</p>
|
||||
<p className="text-muted-foreground">{t("prompts.info.history.empty", { environment: selectedEnv })}</p>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
|
@ -422,7 +432,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
<TabsContent value="prompt-template" keepMounted>
|
||||
<Card className="block p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Prompt Template</h3>
|
||||
<h3 className="text-lg font-medium">{t("prompts.info.template.title")}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -434,18 +444,18 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
}`}
|
||||
>
|
||||
{copiedStates["prompt-content"] ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
|
||||
{copiedStates["prompt-content"] ? "Copied!" : "Copy Content"}
|
||||
{copiedStates["prompt-content"] ? t("prompts.info.copied") : t("prompts.info.template.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="font-medium">Template ID</p>
|
||||
<p className="font-medium">{t("prompts.info.template.id")}</p>
|
||||
<div className="font-mono text-sm bg-muted p-2 rounded-sm">{promptTemplate.litellm_prompt_id}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-medium">Content</p>
|
||||
<p className="font-medium">{t("prompts.info.template.content")}</p>
|
||||
<div className="mt-2 p-4 bg-muted rounded-md border overflow-auto max-h-96">
|
||||
<pre className="text-sm text-foreground whitespace-pre-wrap">{promptTemplate.content}</pre>
|
||||
</div>
|
||||
|
|
@ -453,7 +463,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
|
||||
{promptTemplate.metadata && Object.keys(promptTemplate.metadata).length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium">Template Metadata</p>
|
||||
<p className="font-medium">{t("prompts.info.template.metadata")}</p>
|
||||
<div className="mt-2 p-3 bg-muted rounded-md border">
|
||||
<pre className="text-xs text-foreground whitespace-pre-wrap overflow-auto max-h-64">
|
||||
{JSON.stringify(promptTemplate.metadata, null, 2)}
|
||||
|
|
@ -470,7 +480,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
<TabsContent value="raw-json" keepMounted>
|
||||
<Card className="block p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Raw API Response</h3>
|
||||
<h3 className="text-lg font-medium">{t("prompts.info.rawJson.title")}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -482,7 +492,7 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
}`}
|
||||
>
|
||||
{copiedStates["raw-json"] ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
|
||||
{copiedStates["raw-json"] ? "Copied!" : "Copy JSON"}
|
||||
{copiedStates["raw-json"] ? t("prompts.info.copied") : t("prompts.info.rawJson.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -500,18 +510,21 @@ const PromptInfoView: React.FC<PromptInfoProps> = ({
|
|||
<Dialog open={showDeleteConfirm} onOpenChange={(open) => !open && handleDeleteCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Prompt</DialogTitle>
|
||||
<DialogTitle>{t("prompts.info.deleteDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p>
|
||||
Are you sure you want to delete prompt: <strong>{basePromptId}</strong> from every environment?
|
||||
<Trans
|
||||
i18nKey="prompts.info.deleteDialog.description"
|
||||
components={{ strong: <strong />, name: <>{basePromptId}</> }}
|
||||
/>
|
||||
</p>
|
||||
<p>This action cannot be undone.</p>
|
||||
<p>{t("prompts.info.deleteDialog.warning")}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleDeleteCancel}>
|
||||
Cancel
|
||||
{t("prompts.info.deleteDialog.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleDeleteConfirm} variant="destructive" disabled={isDeleting} aria-busy={isDeleting}>
|
||||
Delete
|
||||
{t("prompts.info.deleteDialog.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ const defaultToolJson = `{
|
|||
}`;
|
||||
|
||||
const ToolModal: React.FC<ToolModalProps> = ({ visible, initialJson, onSave, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
const [json, setJson] = useState(initialJson || defaultToolJson);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -41,7 +43,7 @@ const ToolModal: React.FC<ToolModalProps> = ({ visible, initialJson, onSave, onC
|
|||
setError(null);
|
||||
onSave(json);
|
||||
} catch (e) {
|
||||
setError("Invalid JSON format. Please check your syntax.");
|
||||
setError(t("prompts.toolModal.invalidJson"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -54,7 +56,7 @@ const ToolModal: React.FC<ToolModalProps> = ({ visible, initialJson, onSave, onC
|
|||
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Tool</DialogTitle>
|
||||
<DialogTitle>{t("prompts.toolModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
{error && (
|
||||
|
|
@ -66,18 +68,18 @@ const ToolModal: React.FC<ToolModalProps> = ({ visible, initialJson, onSave, onC
|
|||
</div>
|
||||
)}
|
||||
<textarea
|
||||
aria-label="Tool JSON"
|
||||
aria-label={t("prompts.toolModal.jsonLabel")}
|
||||
value={json}
|
||||
onChange={(e) => setJson(e.target.value)}
|
||||
className="w-full min-h-[400px] px-4 py-3 border border-input rounded-lg text-sm font-mono focus:outline-hidden focus:ring-2 focus:ring-ring resize-none"
|
||||
placeholder="Paste your tool JSON here..."
|
||||
placeholder={t("prompts.toolModal.jsonPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("prompts.toolModal.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Add</Button>
|
||||
<Button onClick={handleSave}>{t("prompts.toolModal.add")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { PencilIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -15,6 +16,7 @@ interface VariableTextAreaProps {
|
|||
}
|
||||
|
||||
const VariableTextArea: React.FC<VariableTextAreaProps> = ({ value, onChange, placeholder, rows = 4, className }) => {
|
||||
const { t } = useTranslation();
|
||||
const [editingVariable, setEditingVariable] = useState<{
|
||||
oldName: string;
|
||||
start: number;
|
||||
|
|
@ -75,7 +77,7 @@ const VariableTextArea: React.FC<VariableTextAreaProps> = ({ value, onChange, pl
|
|||
{/* Variable Management - Clear and Functional */}
|
||||
{variables.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-xs text-muted-foreground mr-1">Detected variables:</span>
|
||||
<span className="text-xs text-muted-foreground mr-1">{t("prompts.variableTextarea.detected")}</span>
|
||||
{variables.map((variable, index) => (
|
||||
<Popover
|
||||
key={`${variable.start}-${index}`}
|
||||
|
|
@ -111,17 +113,17 @@ const VariableTextArea: React.FC<VariableTextAreaProps> = ({ value, onChange, pl
|
|||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[216px]">
|
||||
<div className="p-2">
|
||||
<div className="text-xs text-muted-foreground mb-2">Edit variable name</div>
|
||||
<div className="text-xs text-muted-foreground mb-2">{t("prompts.variableTextarea.editTitle")}</div>
|
||||
<Input
|
||||
value={newVariableName}
|
||||
onChange={(e) => setNewVariableName(e.target.value)}
|
||||
onKeyDown={(event) => event.key === "Enter" && handleVariableEdit()}
|
||||
placeholder="Variable name"
|
||||
placeholder={t("prompts.variableTextarea.namePlaceholder")}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button size="sm" onClick={handleVariableEdit}>
|
||||
Save
|
||||
{t("prompts.variableTextarea.save")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -131,7 +133,7 @@ const VariableTextArea: React.FC<VariableTextAreaProps> = ({ value, onChange, pl
|
|||
setNewVariableName("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t("prompts.variableTextarea.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { CircleHelp } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { z } from "zod";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -63,15 +65,22 @@ export const SearchProviderLabel: React.FC<SearchProviderLabelProps> = ({ provid
|
|||
const createSearchToolShape = {
|
||||
search_tool_name: z
|
||||
.string()
|
||||
.min(1, "Please enter a search tool name")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "Name can only contain letters, numbers, hyphens, and underscores"),
|
||||
.min(1, { error: () => i18n.t("searchTools.createForm.errors.nameRequired") })
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, { error: () => i18n.t("searchTools.createForm.errors.nameInvalid") }),
|
||||
search_provider: z
|
||||
.string()
|
||||
.nullable()
|
||||
.pipe(z.string({ error: "Please select a search provider" }).min(1, "Please select a search provider")),
|
||||
.pipe(
|
||||
z
|
||||
.string({ error: () => i18n.t("searchTools.createForm.errors.providerRequired") })
|
||||
.min(1, { error: () => i18n.t("searchTools.createForm.errors.providerRequired") }),
|
||||
),
|
||||
api_key: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
input_cost_per_query: z.number().nonnegative("Cost per query cannot be negative").optional(),
|
||||
input_cost_per_query: z
|
||||
.number()
|
||||
.nonnegative({ error: () => i18n.t("searchTools.createForm.errors.costNegative") })
|
||||
.optional(),
|
||||
};
|
||||
|
||||
const createSearchToolSchema = z.object(createSearchToolShape);
|
||||
|
|
@ -105,6 +114,7 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
isModalVisible,
|
||||
setModalVisible,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(createSearchToolSchema, { defaultValues: EMPTY_VALUES });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isTestModalVisible, setIsTestModalVisible] = useState(false);
|
||||
|
|
@ -144,13 +154,13 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
if (accessToken != null) {
|
||||
const response = await createSearchTool(accessToken, payload);
|
||||
|
||||
toast.success("Search tool created successfully");
|
||||
toast.success(t("searchTools.createForm.toasts.created"));
|
||||
form.reset(EMPTY_VALUES);
|
||||
setModalVisible(false);
|
||||
onCreateSuccess(response);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Error creating search tool: " + error);
|
||||
toast.error(t("searchTools.createForm.toasts.createFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -164,7 +174,7 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
const handleTestConnection = async () => {
|
||||
const isValid = await form.trigger(["search_provider", "api_key"]);
|
||||
if (!isValid) {
|
||||
toast.error("Please fill in Search Provider and API Key before testing");
|
||||
toast.error(t("searchTools.createForm.toasts.testMissingFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +193,9 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
<DialogHeader>
|
||||
<div className="flex items-center space-x-3 pb-4 border-b border-border">
|
||||
<span className="text-2xl">🔍</span>
|
||||
<DialogTitle className="text-xl font-semibold text-foreground">Add New Search Tool</DialogTitle>
|
||||
<DialogTitle className="text-xl font-semibold text-foreground">
|
||||
{t("searchTools.createForm.title")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="mt-6">
|
||||
|
|
@ -193,16 +205,13 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="search_tool_name"
|
||||
label={labelWithHint(
|
||||
"Search Tool Name",
|
||||
"A unique name to identify this search tool configuration (e.g., 'perplexity-search', 'tavily-news-search').",
|
||||
)}
|
||||
label={labelWithHint(t("searchTools.createForm.labels.name"), t("searchTools.createForm.hints.name"))}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="e.g., perplexity-search, my-tavily-tool"
|
||||
placeholder={t("searchTools.createForm.placeholders.name")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -212,8 +221,8 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
control={form.control}
|
||||
name="search_provider"
|
||||
label={labelWithHint(
|
||||
"Search Provider",
|
||||
"Select the search provider you want to use. Each provider has different capabilities and pricing.",
|
||||
t("searchTools.createForm.labels.provider"),
|
||||
t("searchTools.createForm.hints.provider"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
|
|
@ -227,13 +236,13 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
placeholder="Select a search provider"
|
||||
placeholder={t("searchTools.createForm.placeholders.provider")}
|
||||
className="h-10 w-full rounded-lg"
|
||||
disabled={isLoadingProviders}
|
||||
showClear={value != null && value !== ""}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No matching search providers</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("searchTools.createForm.noProviders")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(providerName: string) => (
|
||||
<ComboboxItem key={providerName} value={providerName}>
|
||||
|
|
@ -253,8 +262,8 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
control={form.control}
|
||||
name="api_key"
|
||||
label={labelWithHint(
|
||||
"API Key",
|
||||
"The API key for authenticating with the search provider. This will be securely stored.",
|
||||
t("searchTools.createForm.labels.apiKey"),
|
||||
t("searchTools.createForm.hints.apiKey"),
|
||||
)}
|
||||
>
|
||||
{({ ref, value, ...field }) => (
|
||||
|
|
@ -262,7 +271,7 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
{...field}
|
||||
ref={ref}
|
||||
value={value ?? ""}
|
||||
placeholder="Enter your API key"
|
||||
placeholder={t("searchTools.createForm.placeholders.apiKey")}
|
||||
groupClassName="h-10 rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -271,10 +280,7 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="input_cost_per_query"
|
||||
label={labelWithHint(
|
||||
"Cost per Query (USD, Optional)",
|
||||
"Your negotiated price per search query. It overrides the LiteLLM cost map price for this tool in the response cost header and spend logs. Leave blank to use the provider's default price.",
|
||||
)}
|
||||
label={labelWithHint(t("searchTools.createForm.labels.cost"), t("searchTools.createForm.hints.cost"))}
|
||||
>
|
||||
{({ ref, value, onChange, ...field }) => (
|
||||
<Input
|
||||
|
|
@ -284,20 +290,24 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
step="any"
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange(event.target.value === "" ? undefined : event.target.valueAsNumber)}
|
||||
placeholder="e.g., 0.007"
|
||||
placeholder={t("searchTools.createForm.placeholders.cost")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="description" label="Description (Optional)">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
label={t("searchTools.createForm.labels.description")}
|
||||
>
|
||||
{({ ref, value, ...field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
ref={ref}
|
||||
value={value ?? ""}
|
||||
rows={3}
|
||||
placeholder="Brief description of this search tool's purpose"
|
||||
placeholder={t("searchTools.createForm.placeholders.description")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -314,20 +324,20 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Need Help?
|
||||
{t("searchTools.createForm.help")}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Get help on our github</TooltipContent>
|
||||
<TooltipContent>{t("searchTools.createForm.helpTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" variant="outline" onClick={handleTestConnection} disabled={isTestingConnection}>
|
||||
{isTestingConnection && <UiLoadingSpinner className="size-4" />}
|
||||
Test Connection
|
||||
{t("searchTools.createForm.testConnection")}
|
||||
</Button>
|
||||
<Button type="submit" variant="outline" disabled={isLoading}>
|
||||
{isLoading && <UiLoadingSpinner className="size-4" />}
|
||||
Add Search Tool
|
||||
{t("searchTools.createForm.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -346,7 +356,7 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connection Test Results</DialogTitle>
|
||||
<DialogTitle>{t("searchTools.createForm.testResultsTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{isTestModalVisible && accessToken && (
|
||||
<SearchConnectionTest
|
||||
|
|
@ -369,7 +379,7 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
setIsTestingConnection(false);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
{t("searchTools.createForm.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { AlertTriangle, CheckCircle2, Info } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { testSearchToolConnection } from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -13,6 +15,7 @@ interface SearchConnectionTestProps {
|
|||
}
|
||||
|
||||
const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmParams, accessToken, onTestComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
status: "success" | "error";
|
||||
|
|
@ -30,12 +33,12 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
|
|||
const result = await testSearchToolConnection(accessToken, litellmParams);
|
||||
setTestResult(result);
|
||||
if (result.status === "success") {
|
||||
toast.success("Connection test successful!");
|
||||
toast.success(i18n.t("searchTools.connectionTest.toastSuccess"));
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
message: error instanceof Error ? error.message : i18n.t("searchTools.connectionTest.unknownErrorOccurred"),
|
||||
error_type: "NetworkError",
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -50,7 +53,7 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
|
|||
}, [accessToken, litellmParams, onTestComplete]);
|
||||
|
||||
const getCleanErrorMessage = (errorMsg: string) => {
|
||||
if (!errorMsg) return "Unknown error";
|
||||
if (!errorMsg) return t("searchTools.connectionTest.unknownError");
|
||||
|
||||
const mainError = errorMsg.split("stack trace:")[0].trim();
|
||||
|
||||
|
|
@ -64,9 +67,9 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
|
|||
return titleMatch[1];
|
||||
}
|
||||
if (finalError.includes("401") || finalError.includes("Authorization Required")) {
|
||||
return "Authentication failed: Invalid API key or credentials";
|
||||
return t("searchTools.connectionTest.authFailed");
|
||||
}
|
||||
return "Authentication error - please check your API key";
|
||||
return t("searchTools.connectionTest.authError");
|
||||
}
|
||||
|
||||
if (finalError.length > 200) {
|
||||
|
|
@ -76,7 +79,9 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
|
|||
return finalError;
|
||||
};
|
||||
|
||||
const errorMessage = testResult?.message ? getCleanErrorMessage(testResult.message) : "Unknown error";
|
||||
const errorMessage = testResult?.message
|
||||
? getCleanErrorMessage(testResult.message)
|
||||
: t("searchTools.connectionTest.unknownError");
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -84,7 +89,9 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
|
|||
<div className="flex flex-col items-center justify-center px-5 py-8">
|
||||
<UiLoadingSpinner className="mb-4 size-8 text-primary" />
|
||||
<p className="text-base text-foreground">
|
||||
Testing connection to {litellmParams.search_provider || "search provider"}...
|
||||
{t("searchTools.connectionTest.testing", {
|
||||
provider: litellmParams.search_provider || t("searchTools.connectionTest.fallbackProvider"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -102,15 +109,22 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
|
|||
<CheckCircle2 className="size-6 text-success" />
|
||||
<div className="ml-3">
|
||||
<p className="text-lg font-medium text-success">
|
||||
Connection to {litellmParams.search_provider} successful!
|
||||
{t("searchTools.connectionTest.success", { provider: litellmParams.search_provider })}
|
||||
</p>
|
||||
{testResult.test_query && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Test query: <code className="rounded bg-muted px-1.5 py-0.5">{testResult.test_query}</code>
|
||||
<Trans
|
||||
i18nKey="searchTools.connectionTest.testQuery"
|
||||
components={{
|
||||
query: <code className="rounded bg-muted px-1.5 py-0.5">{testResult.test_query}</code>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{testResult.results_count !== undefined && (
|
||||
<p className="text-sm text-muted-foreground">Results retrieved: {testResult.results_count}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("searchTools.connectionTest.resultsRetrieved", { results: testResult.results_count })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -119,18 +133,20 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
|
|||
<div className="mb-5 flex items-center">
|
||||
<AlertTriangle className="mr-3 size-6 text-destructive" />
|
||||
<p className="text-lg font-medium text-destructive">
|
||||
Connection to {litellmParams.search_provider || "search provider"} failed
|
||||
{t("searchTools.connectionTest.failed", {
|
||||
provider: litellmParams.search_provider || t("searchTools.connectionTest.fallbackProvider"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg border border-destructive/30 bg-destructive/10 p-4">
|
||||
<p className="mb-2 font-semibold text-foreground">Error: </p>
|
||||
<p className="mb-2 font-semibold text-foreground">{t("searchTools.connectionTest.errorLabel")}</p>
|
||||
<p className="text-sm leading-relaxed text-destructive">{errorMessage}</p>
|
||||
|
||||
{testResult.error_type && (
|
||||
<div className="mt-2">
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Error type:{" "}
|
||||
{t("searchTools.connectionTest.errorType")}{" "}
|
||||
<code className="rounded bg-destructive/10 px-1.5 py-0.5 text-destructive">
|
||||
{testResult.error_type}
|
||||
</code>
|
||||
|
|
@ -141,7 +157,9 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
|
|||
{testResult.message && (
|
||||
<div className="mt-3">
|
||||
<Button variant="link" size="sm" className="h-auto p-0" onClick={() => setShowDetails(!showDetails)}>
|
||||
{showDetails ? "Hide Details" : "Show Details"}
|
||||
{showDetails
|
||||
? t("searchTools.connectionTest.hideDetails")
|
||||
: t("searchTools.connectionTest.showDetails")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -149,7 +167,9 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
|
|||
|
||||
{showDetails && (
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 text-[15px] font-semibold text-foreground">Full Error Details</p>
|
||||
<p className="mb-2 text-[15px] font-semibold text-foreground">
|
||||
{t("searchTools.connectionTest.fullDetails")}
|
||||
</p>
|
||||
<pre className="max-h-52 overflow-auto rounded-lg border border-border bg-muted p-4 text-[13px] leading-relaxed break-words whitespace-pre-wrap">
|
||||
{testResult.message}
|
||||
</pre>
|
||||
|
|
@ -157,12 +177,12 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
|
|||
)}
|
||||
|
||||
<div className="rounded-lg border border-warning/20 border-l-4 border-l-amber-500 bg-warning/10 p-4">
|
||||
<p className="mb-2 font-semibold text-warning">Troubleshooting tips:</p>
|
||||
<p className="mb-2 font-semibold text-warning">{t("searchTools.connectionTest.troubleshooting.title")}</p>
|
||||
<ul className="my-2 list-disc pl-5 text-warning">
|
||||
<li className="mb-1.5">Verify your API key is correct and active</li>
|
||||
<li className="mb-1.5">Check if the search provider service is operational</li>
|
||||
<li className="mb-1.5">Ensure you have sufficient credits/quota with the provider</li>
|
||||
<li className="mb-1.5">Review the provider's documentation for any additional requirements</li>
|
||||
<li className="mb-1.5">{t("searchTools.connectionTest.troubleshooting.apiKey")}</li>
|
||||
<li className="mb-1.5">{t("searchTools.connectionTest.troubleshooting.serviceStatus")}</li>
|
||||
<li className="mb-1.5">{t("searchTools.connectionTest.troubleshooting.quota")}</li>
|
||||
<li className="mb-1.5">{t("searchTools.connectionTest.troubleshooting.docs")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -176,7 +196,7 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
|
|||
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
<Info className="size-4" />
|
||||
View Search Documentation
|
||||
{t("searchTools.connectionTest.docsLink")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
||||
|
|
@ -21,13 +22,14 @@ interface SearchToolTableProps {
|
|||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No search tools configured</div>
|
||||
<div className="text-sm text-muted-foreground">Add a search tool to enable web search for your models.</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("searchTools.table.empty.title")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("searchTools.table.empty.description")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,12 +42,13 @@ const SearchToolTable: React.FC<SearchToolTableProps> = ({
|
|||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const deps = { availableProviders, onView, onEdit, onDelete };
|
||||
const deps = { availableProviders, onView, onEdit, onDelete, t };
|
||||
return getSearchToolTableColumns(deps);
|
||||
}, [availableProviders, onView, onEdit, onDelete]);
|
||||
}, [availableProviders, onView, onEdit, onDelete, t]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
@ -57,7 +60,7 @@ const SearchToolTable: React.FC<SearchToolTableProps> = ({
|
|||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading search tools…"
|
||||
loadingMessage={t("searchTools.table.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
|
|
@ -17,8 +19,8 @@ import { cn } from "@/lib/cva.config";
|
|||
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
const CONFIG_EDIT_HINT = "Config search tools cannot be edited on the dashboard. Please edit the config file.";
|
||||
const CONFIG_DELETE_HINT = "Config search tools cannot be deleted on the dashboard. Please edit the config file.";
|
||||
const CONFIG_EDIT_HINT_KEY: ParseKeys = "searchTools.table.configEditHint";
|
||||
const CONFIG_DELETE_HINT_KEY: ParseKeys = "searchTools.table.configDeleteHint";
|
||||
|
||||
export const searchToolKey = (tool: SearchTool): string => tool.search_tool_id || tool.search_tool_name;
|
||||
|
||||
|
|
@ -29,13 +31,14 @@ interface SearchToolRowActionsProps {
|
|||
}
|
||||
|
||||
function SearchToolRowActions({ tool, onEdit, onDelete }: SearchToolRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const isFromConfig = tool.is_from_config ?? false;
|
||||
const toolId = tool.search_tool_id;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open search tool actions"
|
||||
aria-label={t("searchTools.table.actions.open")}
|
||||
data-testid={`search-tool-actions-${searchToolKey(tool)}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -45,22 +48,22 @@ function SearchToolRowActions({ tool, onEdit, onDelete }: SearchToolRowActionsPr
|
|||
<DropdownMenuItem
|
||||
disabled={isFromConfig || !toolId}
|
||||
data-testid="search-tool-action-edit"
|
||||
title={isFromConfig ? CONFIG_EDIT_HINT : undefined}
|
||||
title={isFromConfig ? t(CONFIG_EDIT_HINT_KEY) : undefined}
|
||||
onClick={() => toolId && onEdit(toolId)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit search tool
|
||||
{t("searchTools.table.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isFromConfig || !toolId}
|
||||
data-testid="search-tool-action-delete"
|
||||
title={isFromConfig ? CONFIG_DELETE_HINT : undefined}
|
||||
title={isFromConfig ? t(CONFIG_DELETE_HINT_KEY) : undefined}
|
||||
onClick={() => toolId && onDelete(toolId)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete search tool
|
||||
{t("searchTools.table.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -72,6 +75,7 @@ interface SearchToolTableColumnsDeps {
|
|||
onView: (searchToolId: string) => void;
|
||||
onEdit: (searchToolId: string) => void;
|
||||
onDelete: (searchToolId: string) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export const getSearchToolTableColumns = ({
|
||||
|
|
@ -79,12 +83,13 @@ export const getSearchToolTableColumns = ({
|
|||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
t,
|
||||
}: SearchToolTableColumnsDeps): ColumnDef<SearchTool>[] => [
|
||||
{
|
||||
id: "search_tool_id",
|
||||
accessorKey: "search_tool_id",
|
||||
meta: { title: "Search Tool ID" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Search Tool ID" />,
|
||||
meta: { title: t("searchTools.table.columns.id") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("searchTools.table.columns.id")} />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -101,8 +106,8 @@ export const getSearchToolTableColumns = ({
|
|||
{
|
||||
id: "search_tool_name",
|
||||
accessorKey: "search_tool_name",
|
||||
meta: { title: "Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
|
||||
meta: { title: t("searchTools.table.columns.name") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("searchTools.table.columns.name")} />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -113,8 +118,8 @@ export const getSearchToolTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "provider",
|
||||
meta: { title: "Provider" },
|
||||
header: "Provider",
|
||||
meta: { title: t("searchTools.table.columns.provider") },
|
||||
header: t("searchTools.table.columns.provider"),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -126,8 +131,8 @@ export const getSearchToolTableColumns = ({
|
|||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
meta: { title: t("searchTools.table.columns.createdAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("searchTools.table.columns.createdAt")} />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
|
|
@ -135,27 +140,32 @@ export const getSearchToolTableColumns = ({
|
|||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
meta: { title: "Updated At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
|
||||
meta: { title: t("searchTools.table.columns.updatedAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("searchTools.table.columns.updatedAt")} />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
meta: { title: "Source", skeleton: "badge" },
|
||||
header: "Source",
|
||||
meta: { title: t("searchTools.table.columns.source"), skeleton: "badge" },
|
||||
header: t("searchTools.table.columns.source"),
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const isFromConfig = row.original.is_from_config ?? false;
|
||||
return <StatusBadge tone={isFromConfig ? "neutral" : "info"} label={isFromConfig ? "Config" : "DB"} />;
|
||||
return (
|
||||
<StatusBadge
|
||||
tone={isFromConfig ? "neutral" : "info"}
|
||||
label={isFromConfig ? t("searchTools.table.source.config") : t("searchTools.table.source.db")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("searchTools.table.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { ExternalLink, Search } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { searchToolQueryCall } from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -24,6 +25,7 @@ interface SearchToolTesterProps {
|
|||
}
|
||||
|
||||
export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolName, accessToken, className = "" }) => {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchHistory, setSearchHistory] = useState<
|
||||
|
|
@ -38,7 +40,7 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) {
|
||||
toast.warning("Please enter a search query");
|
||||
toast.warning(t("searchTools.tester.toasts.emptyQuery"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +75,7 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
const clearHistory = () => {
|
||||
setSearchHistory([]);
|
||||
setExpandedResults({});
|
||||
toast.success("Search history cleared");
|
||||
toast.success(t("searchTools.tester.toasts.cleared"));
|
||||
};
|
||||
|
||||
const toggleResultExpansion = (historyIndex: number, resultIndex: number) => {
|
||||
|
|
@ -89,7 +91,7 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
return (
|
||||
<Card className={`mt-6 ${className}`}>
|
||||
<div className="px-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">Test Search Tool</h2>
|
||||
<h2 className="text-lg font-semibold text-foreground">{t("searchTools.tester.title")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[600px] flex-col px-6">
|
||||
|
|
@ -106,14 +108,14 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
handleSearch();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter your search query..."
|
||||
placeholder={t("searchTools.tester.placeholder")}
|
||||
disabled={isLoading}
|
||||
className="h-12 pl-11 text-[15px]"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} disabled={isLoading || !query.trim()} className="h-12 px-6 text-[15px]">
|
||||
{isLoading ? <UiLoadingSpinner className="size-4" /> : <Search className="size-4" />}
|
||||
Search
|
||||
{t("searchTools.tester.search")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -124,15 +126,15 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
<div className="mb-6 flex size-24 items-center justify-center rounded-full bg-muted">
|
||||
<Search className="size-12 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-lg font-medium text-foreground">Test your search tool</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Enter a query above to see search results</p>
|
||||
<p className="text-lg font-medium text-foreground">{t("searchTools.tester.emptyTitle")}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("searchTools.tester.emptyDescription")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<UiLoadingSpinner className="size-8 text-primary" />
|
||||
<p className="mt-4 font-medium text-muted-foreground">Searching...</p>
|
||||
<p className="mt-4 font-medium text-muted-foreground">{t("searchTools.tester.searching")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -142,7 +144,7 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
Search Query
|
||||
{t("searchTools.tester.queryLabel")}
|
||||
</p>
|
||||
<div className="mt-1.5 text-base font-semibold text-foreground">{latestResults.query}</div>
|
||||
</div>
|
||||
|
|
@ -150,13 +152,16 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
<p className="text-xs text-muted-foreground">{formatTimestamp(latestResults.timestamp)}</p>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<div className="text-sm font-semibold text-primary">
|
||||
{latestResults.response?.results?.length || 0}{" "}
|
||||
{latestResults.response?.results?.length === 1 ? "result" : "results"}
|
||||
{t("searchTools.tester.resultCount", {
|
||||
count: latestResults.response?.results?.length || 0,
|
||||
})}
|
||||
</div>
|
||||
{latestResults.latency !== undefined && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<div className="text-sm font-semibold text-success">{latestResults.latency}ms</div>
|
||||
<div className="text-sm font-semibold text-success">
|
||||
{t("searchTools.tester.latency", { latency: latestResults.latency })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -189,7 +194,7 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Open result in new tab"
|
||||
aria-label={t("searchTools.tester.openResult")}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => window.open(result.url, "_blank")}
|
||||
>
|
||||
|
|
@ -212,7 +217,9 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
className="mt-3 h-auto p-0"
|
||||
onClick={() => toggleResultExpansion(0, resultIndex)}
|
||||
>
|
||||
{isResultExpanded ? "Show less" : "Show more"}
|
||||
{isResultExpanded
|
||||
? t("searchTools.tester.showLess")
|
||||
: t("searchTools.tester.showMore")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -225,8 +232,8 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-muted">
|
||||
<Search className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="font-medium text-foreground">No results found</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Try a different search query</p>
|
||||
<p className="font-medium text-foreground">{t("searchTools.tester.noResults")}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("searchTools.tester.noResultsHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -235,9 +242,9 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
{searchHistory.length > 1 && (
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Previous Searches</p>
|
||||
<p className="text-sm font-semibold text-foreground">{t("searchTools.tester.previous")}</p>
|
||||
<Button variant="link" size="sm" className="h-auto p-0" onClick={clearHistory}>
|
||||
Clear All
|
||||
{t("searchTools.tester.clearAll")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -252,13 +259,14 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
|
|||
<div className="truncate text-sm font-medium text-foreground">{entry.query}</div>
|
||||
<div className="mt-1.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-primary">
|
||||
{entry.response?.results?.length || 0}{" "}
|
||||
{entry.response?.results?.length === 1 ? "result" : "results"}
|
||||
{t("searchTools.tester.resultCount", { count: entry.response?.results?.length || 0 })}
|
||||
</span>
|
||||
{entry.latency !== undefined && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="font-medium text-success">{entry.latency}ms</span>
|
||||
<span className="font-medium text-success">
|
||||
{t("searchTools.tester.latency", { latency: entry.latency })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span>•</span>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { ArrowLeft, Check, Copy } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { SearchToolTester } from "./SearchToolTester";
|
||||
|
|
@ -21,6 +22,7 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
|
|||
accessToken,
|
||||
availableProviders,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
const copyToClipboard = async (text: string | null | undefined, key: string) => {
|
||||
|
|
@ -44,14 +46,14 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
|
|||
<div>
|
||||
<Button variant="ghost" size="sm" className="mb-4 -ml-2 text-muted-foreground" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 size-4" />
|
||||
Back to All Search Tools
|
||||
{t("searchTools.view.back")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<h1 className="text-2xl font-semibold text-foreground">{searchTool.search_tool_name}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Copy search tool name"
|
||||
aria-label={t("searchTools.view.copyName")}
|
||||
className="text-muted-foreground"
|
||||
onClick={() => copyToClipboard(searchTool.search_tool_name, "search-tool-name")}
|
||||
>
|
||||
|
|
@ -63,7 +65,7 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Copy search tool ID"
|
||||
aria-label={t("searchTools.view.copyId")}
|
||||
className="text-muted-foreground"
|
||||
onClick={() => copyToClipboard(searchTool.search_tool_id, "search-tool-id")}
|
||||
>
|
||||
|
|
@ -76,7 +78,7 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Provider</p>
|
||||
<p className="text-sm text-muted-foreground">{t("searchTools.view.provider")}</p>
|
||||
<p className="mt-2 text-lg font-semibold text-foreground">
|
||||
{getProviderDisplayName(searchTool.litellm_params.search_provider)}
|
||||
</p>
|
||||
|
|
@ -85,27 +87,31 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
|
|||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">API Key</p>
|
||||
<p className="mt-2 text-foreground">{searchTool.litellm_params.api_key ? "****" : "Not set"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Cost per Query</p>
|
||||
<p className="text-sm text-muted-foreground">{t("searchTools.view.apiKey")}</p>
|
||||
<p className="mt-2 text-foreground">
|
||||
{typeof searchTool.litellm_params.input_cost_per_query === "number"
|
||||
? `$${searchTool.litellm_params.input_cost_per_query}`
|
||||
: "Provider default"}
|
||||
{searchTool.litellm_params.api_key ? "****" : t("searchTools.view.apiKeyNotSet")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Created At</p>
|
||||
<p className="text-sm text-muted-foreground">{t("searchTools.view.cost")}</p>
|
||||
<p className="mt-2 text-foreground">
|
||||
{searchTool.created_at ? new Date(searchTool.created_at).toLocaleString() : "Unknown"}
|
||||
{typeof searchTool.litellm_params.input_cost_per_query === "number"
|
||||
? `$${searchTool.litellm_params.input_cost_per_query}`
|
||||
: t("searchTools.view.costDefault")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t("searchTools.view.createdAt")}</p>
|
||||
<p className="mt-2 text-foreground">
|
||||
{searchTool.created_at
|
||||
? new Date(searchTool.created_at).toLocaleString()
|
||||
: t("searchTools.view.createdUnknown")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -114,7 +120,7 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
|
|||
{searchTool.search_tool_info?.description && (
|
||||
<Card className="mt-6">
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Description</p>
|
||||
<p className="text-sm text-muted-foreground">{t("searchTools.view.description")}</p>
|
||||
<p className="mt-2 text-foreground">{searchTool.search_tool_info.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { isAdminRole } from "@/utils/roles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { z } from "zod";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -33,11 +35,14 @@ interface SearchToolsProps {
|
|||
}
|
||||
|
||||
const editSearchToolShape = {
|
||||
search_tool_name: z.string().min(1, "Please enter a search tool name"),
|
||||
search_provider: z.string().min(1, "Please select a search provider"),
|
||||
search_tool_name: z.string().min(1, { error: () => i18n.t("searchTools.page.editForm.errors.nameRequired") }),
|
||||
search_provider: z.string().min(1, { error: () => i18n.t("searchTools.page.editForm.errors.providerRequired") }),
|
||||
api_key: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
input_cost_per_query: z.number().nonnegative("Cost per query cannot be negative").nullish(),
|
||||
input_cost_per_query: z
|
||||
.number()
|
||||
.nonnegative({ error: () => i18n.t("searchTools.page.editForm.errors.costNegative") })
|
||||
.nullish(),
|
||||
};
|
||||
|
||||
const editSearchToolSchema = z.object(editSearchToolShape);
|
||||
|
|
@ -47,6 +52,7 @@ type EditSearchToolFormValues = z.infer<typeof editSearchToolSchema>;
|
|||
const EMPTY_EDIT_VALUES: EditSearchToolFormValues = { search_tool_name: "", search_provider: "" };
|
||||
|
||||
const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID }) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: searchTools,
|
||||
isLoading: isLoadingTools,
|
||||
|
|
@ -86,7 +92,7 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
};
|
||||
|
||||
const handleEditOpen = (toolId: string) => {
|
||||
const tool = searchTools?.find((t) => t.search_tool_id === toolId);
|
||||
const tool = searchTools?.find((candidate) => candidate.search_tool_id === toolId);
|
||||
if (!tool) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -114,13 +120,13 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteSearchTool(accessToken, toolIdToDelete);
|
||||
toast.success("Deleted search tool successfully");
|
||||
toast.success(t("searchTools.page.toasts.deleted"));
|
||||
setIsDeleteModalOpen(false);
|
||||
setToolToDelete(null);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error("Error deleting the search tool:", error);
|
||||
toast.error("Failed to delete search tool");
|
||||
toast.error(t("searchTools.page.toasts.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
|
|
@ -131,7 +137,7 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
setToolToDelete(null);
|
||||
};
|
||||
|
||||
const toolToDelete = searchTools?.find((t) => t.search_tool_id === toolIdToDelete);
|
||||
const toolToDelete = searchTools?.find((candidate) => candidate.search_tool_id === toolIdToDelete);
|
||||
const providerInfo = toolToDelete
|
||||
? availableProviders.find((p) => p.provider_name === toolToDelete.litellm_params.search_provider)
|
||||
: null;
|
||||
|
|
@ -147,19 +153,19 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
|
||||
try {
|
||||
await updateSearchTool(accessToken, selectedToolId, buildSearchToolPayload(values));
|
||||
toast.success("Search tool updated successfully");
|
||||
toast.success(t("searchTools.page.toasts.updated"));
|
||||
setEditModalVisible(false);
|
||||
form.reset(EMPTY_EDIT_VALUES);
|
||||
setSelectedToolId(null);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error("Failed to update search tool:", error);
|
||||
toast.error("Failed to update search tool");
|
||||
toast.error(t("searchTools.page.toasts.updateFailed"));
|
||||
}
|
||||
},
|
||||
(errors) => {
|
||||
console.error("Failed to update search tool:", errors);
|
||||
toast.error("Failed to update search tool");
|
||||
toast.error(t("searchTools.page.toasts.updateFailed"));
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -171,11 +177,13 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
const renderEditForm = () => (
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="search_tool_name" label="Search Tool Name">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g., my-perplexity-search" />}
|
||||
<FormField control={form.control} name="search_tool_name" label={t("searchTools.page.editForm.labels.name")}>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder={t("searchTools.page.editForm.placeholders.name")} />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="search_provider" label="Search Provider">
|
||||
<FormField control={form.control} name="search_provider" label={t("searchTools.page.editForm.labels.provider")}>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Select
|
||||
items={availableProviders.map((provider) => ({
|
||||
|
|
@ -186,7 +194,7 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
onValueChange={(provider: string | null) => onChange(provider ?? "")}
|
||||
>
|
||||
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy} className="w-full">
|
||||
<SelectValue placeholder="Select a search provider" />
|
||||
<SelectValue placeholder={t("searchTools.page.editForm.placeholders.provider")} />
|
||||
{isLoadingProviders && <UiLoadingSpinner className="size-4" />}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -200,17 +208,27 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="api_key" label="API Key" description="API key for the search provider">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
label={t("searchTools.page.editForm.labels.apiKey")}
|
||||
description={t("searchTools.page.editForm.descriptions.apiKey")}
|
||||
>
|
||||
{({ ref, value, ...field }) => (
|
||||
<PasswordInput {...field} ref={ref} value={value ?? ""} placeholder="Enter API key" />
|
||||
<PasswordInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
value={value ?? ""}
|
||||
placeholder={t("searchTools.page.editForm.placeholders.apiKey")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="input_cost_per_query"
|
||||
label="Cost per Query (USD)"
|
||||
description="Overrides the cost map price for this tool. Leave blank to use the provider's default price"
|
||||
label={t("searchTools.page.editForm.labels.cost")}
|
||||
description={t("searchTools.page.editForm.descriptions.cost")}
|
||||
>
|
||||
{({ ref, value, onChange, ...field }) => (
|
||||
<Input
|
||||
|
|
@ -220,14 +238,20 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
step="any"
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange(event.target.value === "" ? null : event.target.valueAsNumber)}
|
||||
placeholder="e.g., 0.007"
|
||||
placeholder={t("searchTools.page.editForm.placeholders.cost")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="description" label="Description">
|
||||
<FormField control={form.control} name="description" label={t("searchTools.page.editForm.labels.description")}>
|
||||
{({ ref, value, ...field }) => (
|
||||
<Textarea {...field} ref={ref} value={value ?? ""} rows={3} placeholder="Description of this search tool" />
|
||||
<Textarea
|
||||
{...field}
|
||||
ref={ref}
|
||||
value={value ?? ""}
|
||||
rows={3}
|
||||
placeholder={t("searchTools.page.editForm.placeholders.description")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
|
@ -235,7 +259,7 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
);
|
||||
|
||||
if (!accessToken || !userRole || !userID) {
|
||||
return <div className="p-6 text-center text-muted-foreground">Missing required authentication parameters.</div>;
|
||||
return <div className="p-6 text-center text-muted-foreground">{t("searchTools.page.missingAuth")}</div>;
|
||||
}
|
||||
|
||||
const ToolsTab = () =>
|
||||
|
|
@ -276,19 +300,22 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
<div className="w-full h-full p-6">
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Search Tool"
|
||||
message="Are you sure you want to delete this search tool? This action cannot be undone."
|
||||
resourceInformationTitle="Search Tool Information"
|
||||
title={t("searchTools.page.deleteModal.title")}
|
||||
message={t("searchTools.page.deleteModal.message")}
|
||||
resourceInformationTitle={t("searchTools.page.deleteModal.infoTitle")}
|
||||
resourceInformation={
|
||||
toolToDelete
|
||||
? [
|
||||
{ label: "Name", value: toolToDelete.search_tool_name },
|
||||
{ label: "ID", value: toolToDelete.search_tool_id, code: true },
|
||||
{ label: t("searchTools.page.deleteModal.fields.name"), value: toolToDelete.search_tool_name },
|
||||
{ label: t("searchTools.page.deleteModal.fields.id"), value: toolToDelete.search_tool_id, code: true },
|
||||
{
|
||||
label: "Provider",
|
||||
label: t("searchTools.page.deleteModal.fields.provider"),
|
||||
value: providerInfo?.ui_friendly_name || toolToDelete.litellm_params.search_provider,
|
||||
},
|
||||
{ label: "Description", value: toolToDelete.search_tool_info?.description || "-" },
|
||||
{
|
||||
label: t("searchTools.page.deleteModal.fields.description"),
|
||||
value: toolToDelete.search_tool_info?.description || "-",
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
|
|
@ -317,7 +344,7 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Search Tool</DialogTitle>
|
||||
<DialogTitle>{t("searchTools.page.editModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{renderEditForm()}
|
||||
<DialogFooter>
|
||||
|
|
@ -329,18 +356,18 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
setSelectedToolId(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t("searchTools.page.editModal.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleEditSubmit}>OK</Button>
|
||||
<Button onClick={handleEditSubmit}>{t("searchTools.page.editModal.ok")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<h1 className="text-lg font-semibold text-foreground">Search Tools</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Configure and manage your search providers</p>
|
||||
<h1 className="text-lg font-semibold text-foreground">{t("searchTools.page.title")}</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("searchTools.page.subtitle")}</p>
|
||||
{isAdminRole(userRole) && (
|
||||
<Button className="mt-4 mb-4" variant="outline" onClick={() => setCreateModalVisible(true)}>
|
||||
+ Add New Search Tool
|
||||
{t("searchTools.page.addButton")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -23,6 +24,7 @@ interface ClaudeCodePluginsPanelProps {
|
|||
}
|
||||
|
||||
const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessToken, userRole }) => {
|
||||
const { t } = useTranslation();
|
||||
const [pluginsList, setPluginsList] = useState<Plugin[]>([]);
|
||||
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -66,11 +68,11 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
|
|||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteClaudeCodePlugin(accessToken, pluginToDelete.name);
|
||||
toast.success(`Skill "${pluginToDelete.displayName}" deleted successfully`);
|
||||
toast.success(t("skills.panel.toasts.deleted", { name: pluginToDelete.displayName }));
|
||||
fetchPlugins();
|
||||
} catch (error) {
|
||||
console.error("Error deleting skill:", error);
|
||||
toast.error("Failed to delete skill");
|
||||
toast.error(t("skills.panel.toasts.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setPluginToDelete(null);
|
||||
|
|
@ -90,14 +92,18 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
|
|||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<h1 className="text-2xl font-bold">Skills</h1>
|
||||
<h1 className="text-2xl font-bold">{t("skills.panel.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Register Claude Code skills. Published skills appear in the Skill Hub for all users and are served via{" "}
|
||||
<code className="bg-muted px-1 rounded-sm">/claude-code/marketplace.json</code>.
|
||||
<Trans
|
||||
i18nKey="skills.panel.intro"
|
||||
components={{
|
||||
endpoint: <code className="bg-muted px-1 rounded-sm">/claude-code/marketplace.json</code>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button onClick={() => setIsAddModalVisible(true)} disabled={!accessToken || !isAdmin}>
|
||||
+ Add Skill
|
||||
{t("skills.panel.addButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -131,16 +137,19 @@ const ClaudeCodePluginsPanel: React.FC<ClaudeCodePluginsPanelProps> = ({ accessT
|
|||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Skill</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("skills.panel.deleteDialog.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete skill: <strong>{pluginToDelete.displayName}</strong>?
|
||||
<Trans
|
||||
i18nKey="skills.panel.deleteDialog.confirm"
|
||||
components={{ name: <strong>{pluginToDelete.displayName}</strong> }}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
<p className="text-sm text-muted-foreground">This action cannot be undone.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("skills.panel.deleteDialog.irreversible")}</p>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("skills.panel.deleteDialog.cancel")}</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
|
||||
Delete
|
||||
{t("skills.panel.deleteDialog.delete")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { Plugin } from "@/components/claude_code_plugins/types";
|
||||
|
|
@ -20,18 +21,20 @@ interface PluginTableProps {
|
|||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No skills found</div>
|
||||
<div className="text-sm text-muted-foreground">Add one to get started.</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("skills.table.empty.title")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("skills.table.empty.description")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PluginTable: React.FC<PluginTableProps> = ({ pluginsList, isLoading, onDeleteClick, isAdmin, onPluginClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(
|
||||
|
|
@ -49,7 +52,7 @@ const PluginTable: React.FC<PluginTableProps> = ({ pluginsList, isLoading, onDel
|
|||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading skills…"
|
||||
loadingMessage={t("skills.table.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Copy, MoreHorizontal, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { getCategoryBadgeColor } from "@/components/claude_code_plugins/helpers";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
|
@ -31,12 +33,13 @@ const CATEGORY_BADGE_CLASS: Record<ReturnType<typeof getCategoryBadgeColor>, str
|
|||
};
|
||||
|
||||
function PluginCategoryBadge({ category }: { category?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("whitespace-nowrap font-normal", CATEGORY_BADGE_CLASS[getCategoryBadgeColor(category)])}
|
||||
>
|
||||
{category || "Uncategorized"}
|
||||
{category || t("skills.table.uncategorized")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -48,10 +51,11 @@ interface PluginRowActionsProps {
|
|||
}
|
||||
|
||||
function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open skill actions"
|
||||
aria-label={t("skills.table.actions.open")}
|
||||
data-testid={`plugin-actions-${plugin.name}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -60,10 +64,10 @@ function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsPr
|
|||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem
|
||||
data-testid="plugin-action-copy"
|
||||
onClick={() => void copyToClipboard(plugin.id, "Skill ID copied")}
|
||||
onClick={() => void copyToClipboard(plugin.id, t("skills.table.actions.idCopied"))}
|
||||
>
|
||||
<Copy />
|
||||
Copy skill ID
|
||||
{t("skills.table.actions.copyId")}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<>
|
||||
|
|
@ -74,7 +78,7 @@ function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsPr
|
|||
onClick={() => onDeleteClick(plugin.name, plugin.name)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("skills.table.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -97,8 +101,8 @@ export const getPluginTableColumns = ({
|
|||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
meta: { title: "Skill Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Skill Name" />,
|
||||
meta: { title: i18n.t("skills.table.columns.name") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("skills.table.columns.name")} />,
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -113,24 +117,28 @@ export const getPluginTableColumns = ({
|
|||
{
|
||||
id: "version",
|
||||
accessorKey: "version",
|
||||
meta: { title: "Version" },
|
||||
header: "Version",
|
||||
meta: { title: i18n.t("skills.table.columns.version") },
|
||||
header: i18n.t("skills.table.columns.version"),
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <span className="text-sm text-muted-foreground">{row.original.version || "N/A"}</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.version || i18n.t("skills.table.versionMissing")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
meta: { title: "Description" },
|
||||
header: "Description",
|
||||
meta: { title: i18n.t("skills.table.columns.description") },
|
||||
header: i18n.t("skills.table.columns.description"),
|
||||
size: 300,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const description = row.original.description;
|
||||
return (
|
||||
<span className="block max-w-72 truncate text-sm text-muted-foreground" title={description ?? undefined}>
|
||||
{description || "No description"}
|
||||
{description || i18n.t("skills.table.noDescription")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
|
@ -138,8 +146,8 @@ export const getPluginTableColumns = ({
|
|||
{
|
||||
id: "category",
|
||||
accessorKey: "category",
|
||||
meta: { title: "Category", skeleton: "badge" },
|
||||
header: "Category",
|
||||
meta: { title: i18n.t("skills.table.columns.category"), skeleton: "badge" },
|
||||
header: i18n.t("skills.table.columns.category"),
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <PluginCategoryBadge category={row.original.category ?? undefined} />,
|
||||
|
|
@ -147,20 +155,23 @@ export const getPluginTableColumns = ({
|
|||
{
|
||||
id: "enabled",
|
||||
accessorKey: "enabled",
|
||||
meta: { title: "Public", skeleton: "badge" },
|
||||
header: "Public",
|
||||
meta: { title: i18n.t("skills.table.columns.public"), skeleton: "badge" },
|
||||
header: i18n.t("skills.table.columns.public"),
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge tone={row.original.enabled ? "success" : "neutral"} label={row.original.enabled ? "Yes" : "No"} />
|
||||
<StatusBadge
|
||||
tone={row.original.enabled ? "success" : "neutral"}
|
||||
label={row.original.enabled ? i18n.t("skills.table.publicYes") : i18n.t("skills.table.publicNo")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
sortingFn: "datetime",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
meta: { title: i18n.t("skills.table.columns.createdAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={i18n.t("skills.table.columns.createdAt")} />,
|
||||
size: 160,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} />,
|
||||
|
|
@ -168,7 +179,7 @@ export const getPluginTableColumns = ({
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{i18n.t("skills.table.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import React, { useState } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { z } from "zod";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { registerClaudeCodePlugin } from "@/components/networking";
|
||||
|
|
@ -40,18 +43,15 @@ interface AddPluginFormProps {
|
|||
}
|
||||
|
||||
const addPluginShape = {
|
||||
skillUrl: z.string().min(1, "Please enter a repository or zip archive URL"),
|
||||
subPath: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => !value || isValidSubPath(value),
|
||||
"Subfolder must be a relative path like plugins/my-skill (letters, numbers, dots, hyphens, underscores)",
|
||||
),
|
||||
sha256: z.string().refine(isValidSha256, "SHA-256 must be a 64-character hex digest"),
|
||||
skillUrl: z.string().min(1, { error: () => i18n.t("skills.addForm.errors.urlRequired") }),
|
||||
subPath: z.string().refine((value) => !value || isValidSubPath(value), {
|
||||
error: () => i18n.t("skills.addForm.errors.subPathInvalid"),
|
||||
}),
|
||||
sha256: z.string().refine(isValidSha256, { error: () => i18n.t("skills.addForm.errors.sha256Invalid") }),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Please enter skill name")
|
||||
.regex(/^[a-z0-9-]+$/, "Name must be kebab-case (lowercase, numbers, hyphens only)"),
|
||||
.min(1, { error: () => i18n.t("skills.addForm.errors.nameRequired") })
|
||||
.regex(/^[a-z0-9-]+$/, { error: () => i18n.t("skills.addForm.errors.nameInvalid") }),
|
||||
domain: z.string(),
|
||||
namespace: z.string(),
|
||||
description: z.string(),
|
||||
|
|
@ -59,9 +59,9 @@ const addPluginShape = {
|
|||
keywords: z.string(),
|
||||
version: z.string(),
|
||||
authorName: z.string(),
|
||||
authorEmail: z
|
||||
.string()
|
||||
.refine((value) => value === "" || z.email().safeParse(value).success, "Please enter a valid email"),
|
||||
authorEmail: z.string().refine((value) => value === "" || z.email().safeParse(value).success, {
|
||||
error: () => i18n.t("skills.addForm.errors.emailInvalid"),
|
||||
}),
|
||||
};
|
||||
|
||||
const addPluginSchema = z.object(addPluginShape);
|
||||
|
|
@ -126,12 +126,12 @@ const PREDEFINED_CATEGORIES = [
|
|||
"Documentation",
|
||||
];
|
||||
|
||||
const SUB_PATH_LOCK_REASON = {
|
||||
"git-subdir": "The URL already points to a subfolder, so this field is disabled",
|
||||
archive: "A zip archive is installed as a whole, so this field is disabled",
|
||||
} as const;
|
||||
const SUB_PATH_LOCK_REASON_KEY = {
|
||||
"git-subdir": "skills.addForm.subPathLock.gitSubdir",
|
||||
archive: "skills.addForm.subPathLock.archive",
|
||||
} as const satisfies Record<string, ParseKeys>;
|
||||
|
||||
type SubPathLock = keyof typeof SUB_PATH_LOCK_REASON;
|
||||
type SubPathLock = keyof typeof SUB_PATH_LOCK_REASON_KEY;
|
||||
|
||||
const subPathLockFor = (source: PluginSource["source"] | undefined): SubPathLock | null =>
|
||||
source === "git-subdir" || source === "archive" ? source : null;
|
||||
|
|
@ -147,6 +147,7 @@ const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
|||
);
|
||||
|
||||
const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessToken, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(addPluginSchema, { defaultValues: EMPTY_VALUES });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [urlPreview, setUrlPreview] = useState<SkillSourcePreview | null>(null);
|
||||
|
|
@ -170,34 +171,34 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
|
||||
const handleSubmit = async (values: AddPluginFormValues) => {
|
||||
if (!accessToken) {
|
||||
toast.error("No access token available");
|
||||
toast.error(t("skills.addForm.toasts.noToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!urlPreview) {
|
||||
toast.error("Please enter a valid repository or zip archive URL");
|
||||
toast.error(t("skills.addForm.toasts.invalidUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePluginName(values.name)) {
|
||||
toast.error("Skill name must be kebab-case (lowercase letters, numbers, and hyphens only)");
|
||||
toast.error(t("skills.addForm.toasts.invalidName"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.version && !isValidSemanticVersion(values.version)) {
|
||||
toast.error("Version must be in semantic versioning format (e.g., 1.0.0)");
|
||||
toast.error(t("skills.addForm.toasts.invalidVersion"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.authorEmail && !isValidEmail(values.authorEmail)) {
|
||||
toast.error("Invalid email format");
|
||||
toast.error(t("skills.addForm.toasts.invalidEmail"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await registerClaudeCodePlugin(accessToken, buildRegisterRequest(values, urlPreview.parsed));
|
||||
toast.success("Skill registered successfully");
|
||||
toast.success(t("skills.addForm.toasts.registered"));
|
||||
form.reset(EMPTY_VALUES);
|
||||
setUrlPreview(null);
|
||||
setSubPathLock(null);
|
||||
|
|
@ -205,7 +206,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error registering skill:", error);
|
||||
toast.error(error instanceof Error && error.message ? error.message : "Failed to register skill");
|
||||
toast.error(error instanceof Error && error.message ? error.message : t("skills.addForm.toasts.registerFailed"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
|
@ -222,7 +223,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
<Dialog open={visible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Skill</DialogTitle>
|
||||
<DialogTitle>{t("skills.addForm.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} noValidate className="mt-4">
|
||||
|
|
@ -230,16 +231,13 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="skillUrl"
|
||||
label={labelWithHint(
|
||||
"Source URL",
|
||||
"Paste an HTTPS git repository URL from GitHub, GitLab, Bitbucket, or a self-hosted host (e.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill), or an HTTPS link to a .zip archive of the skill hosted on S3 or any static file server. For a private repository use its SSH clone URL (git@ghe.example.com:org/repo.git) so Claude Code clones it with your own SSH key.",
|
||||
)}
|
||||
label={labelWithHint(t("skills.addForm.labels.sourceUrl"), t("skills.addForm.hints.sourceUrl"))}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder="https://github.com/org/repo or https://bucket.s3.amazonaws.com/my-skill.zip"
|
||||
placeholder={t("skills.addForm.placeholders.sourceUrl")}
|
||||
className="rounded-lg"
|
||||
onChange={(event) => {
|
||||
onChange(event);
|
||||
|
|
@ -252,11 +250,8 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="subPath"
|
||||
label={labelWithHint(
|
||||
"Subfolder path (Optional)",
|
||||
"Path within the repository where the skill lives (e.g., plugins/my-skill). Leave empty if the skill is at the repo root.",
|
||||
)}
|
||||
description={subPathLock ? SUB_PATH_LOCK_REASON[subPathLock] : undefined}
|
||||
label={labelWithHint(t("skills.addForm.labels.subPath"), t("skills.addForm.hints.subPath"))}
|
||||
description={subPathLock ? t(SUB_PATH_LOCK_REASON_KEY[subPathLock]) : undefined}
|
||||
>
|
||||
{({ ref, onChange, ...field }) => (
|
||||
<Input
|
||||
|
|
@ -277,27 +272,29 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="sha256"
|
||||
label={labelWithHint(
|
||||
"Archive SHA-256 (Optional)",
|
||||
"Hex digest of the zip file. Claude Code refuses to install the archive if its checksum does not match.",
|
||||
)}
|
||||
label={labelWithHint(t("skills.addForm.labels.sha256"), t("skills.addForm.hints.sha256"))}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="64 hex characters" className="rounded-lg font-mono" />
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("skills.addForm.placeholders.sha256")}
|
||||
className="rounded-lg font-mono"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{urlPreview && (
|
||||
<div className="rounded-lg border border-info/20 bg-info/10 px-3 py-2 text-sm text-info">
|
||||
Detected: {urlPreview.label}
|
||||
{t("skills.addForm.detected", { source: urlPreview.label })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label={labelWithHint("Skill Name", "Unique identifier in kebab-case format (e.g., my-skill)")}
|
||||
label={labelWithHint(t("skills.addForm.labels.name"), t("skills.addForm.hints.name"))}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="my-skill" className="rounded-lg" />}
|
||||
</FormField>
|
||||
|
|
@ -306,17 +303,22 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
label={labelWithHint("Domain (Optional)", "Top-level grouping in the Skill Hub (e.g., Productivity)")}
|
||||
label={labelWithHint(t("skills.addForm.labels.domain"), t("skills.addForm.hints.domain"))}
|
||||
className="flex-1"
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="Productivity" className="rounded-lg" />
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("skills.addForm.placeholders.domain")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="namespace"
|
||||
label={labelWithHint("Namespace (Optional)", "Sub-grouping within domain (e.g., workflows)")}
|
||||
label={labelWithHint(t("skills.addForm.labels.namespace"), t("skills.addForm.hints.namespace"))}
|
||||
className="flex-1"
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="workflows" className="rounded-lg" />}
|
||||
|
|
@ -326,14 +328,14 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
label={labelWithHint("Description (Optional)", "Brief description of what the skill does")}
|
||||
label={labelWithHint(t("skills.addForm.labels.description"), t("skills.addForm.hints.description"))}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
ref={ref}
|
||||
rows={3}
|
||||
placeholder="A skill that helps with..."
|
||||
placeholder={t("skills.addForm.placeholders.description")}
|
||||
maxLength={500}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
|
|
@ -343,7 +345,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
label={labelWithHint("Category (Optional)", "Select a category or enter a custom one")}
|
||||
label={labelWithHint(t("skills.addForm.labels.category"), t("skills.addForm.hints.category"))}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Combobox items={PREDEFINED_CATEGORIES} value={value} onValueChange={onChange}>
|
||||
|
|
@ -351,12 +353,12 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
placeholder="Select or type a category"
|
||||
placeholder={t("skills.addForm.placeholders.category")}
|
||||
className="w-full rounded-lg"
|
||||
showClear={value != null && value !== ""}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No matching categories</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("skills.addForm.noCategories")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(category: string) => (
|
||||
<ComboboxItem key={category} value={category}>
|
||||
|
|
@ -372,17 +374,22 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="keywords"
|
||||
label={labelWithHint("Keywords (Optional)", "Comma-separated list of keywords for search")}
|
||||
label={labelWithHint(t("skills.addForm.labels.keywords"), t("skills.addForm.hints.keywords"))}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="search, web, api" className="rounded-lg" />
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("skills.addForm.placeholders.keywords")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="version"
|
||||
label={labelWithHint("Version (Optional)", "Semantic version (e.g., 1.0.0)")}
|
||||
label={labelWithHint(t("skills.addForm.labels.version"), t("skills.addForm.hints.version"))}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="1.0.0" className="rounded-lg" />}
|
||||
</FormField>
|
||||
|
|
@ -390,17 +397,22 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="authorName"
|
||||
label={labelWithHint("Author Name (Optional)", "Name of the skill author or organization")}
|
||||
label={labelWithHint(t("skills.addForm.labels.authorName"), t("skills.addForm.hints.authorName"))}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="Your Name or Organization" className="rounded-lg" />
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("skills.addForm.placeholders.authorName")}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authorEmail"
|
||||
label={labelWithHint("Author Email (Optional)", "Contact email for the skill author")}
|
||||
label={labelWithHint(t("skills.addForm.labels.authorEmail"), t("skills.addForm.hints.authorEmail"))}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} type="email" placeholder="author@example.com" className="rounded-lg" />
|
||||
|
|
@ -410,11 +422,11 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({ visible, onClose, accessT
|
|||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
{t("skills.addForm.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
|
||||
{isSubmitting && <UiLoadingSpinner className="size-4" />}
|
||||
{isSubmitting ? "Adding..." : "Add Skill"}
|
||||
{isSubmitting ? t("skills.addForm.adding") : t("skills.addForm.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { CircleCheck, CircleHelp, Inbox, X } from "lucide-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
|
@ -48,6 +49,7 @@ const providerItems = Object.entries(VectorStoreProviders)
|
|||
const asText = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||
|
||||
const IngestSuccessAlert: React.FC<{ ingestResults: RAGIngestResponse[] }> = ({ ingestResults }) => {
|
||||
const { t } = useTranslation();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
if (dismissed) {
|
||||
|
|
@ -57,19 +59,24 @@ const IngestSuccessAlert: React.FC<{ ingestResults: RAGIngestResponse[] }> = ({
|
|||
return (
|
||||
<Alert variant="success">
|
||||
<CircleCheck />
|
||||
<AlertTitle>Vector Store Created Successfully</AlertTitle>
|
||||
<AlertTitle>{t("vectorStores.create.success.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div>
|
||||
<p>
|
||||
<strong>Vector Store ID:</strong> {ingestResults[0]?.vector_store_id}
|
||||
<strong>{t("vectorStores.create.success.vectorStoreId")}</strong> {ingestResults[0]?.vector_store_id}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Documents Ingested:</strong> {ingestResults.length}
|
||||
<strong>{t("vectorStores.create.success.documentsIngested")}</strong> {ingestResults.length}
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={() => setDismissed(true)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("vectorStores.create.success.close")}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
|
|
@ -93,6 +100,7 @@ interface CreateVectorStoreProps {
|
|||
}
|
||||
|
||||
const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const [documents, setDocuments] = useState<DocumentUpload[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>("bedrock");
|
||||
|
|
@ -104,11 +112,11 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
|
||||
const isSupportedDocument = (file: File): boolean => {
|
||||
if (!ACCEPTED_DOCUMENT_TYPES.includes(file.type)) {
|
||||
toast.error(`${file.name} is not a supported file type. Please upload PDF, TXT, DOCX, or MD files.`);
|
||||
toast.error(t("vectorStores.create.toasts.unsupportedType", { name: file.name }));
|
||||
return false;
|
||||
}
|
||||
if (file.size >= MAX_DOCUMENT_BYTES) {
|
||||
toast.error(`${file.name} must be smaller than 50MB!`);
|
||||
toast.error(t("vectorStores.create.toasts.tooLarge", { name: file.name }));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
|
@ -135,12 +143,12 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
|
||||
const handleCreateVectorStore = async () => {
|
||||
if (documents.length === 0) {
|
||||
toast.warning("Please upload at least one document");
|
||||
toast.warning(t("vectorStores.create.toasts.noDocuments"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedProvider) {
|
||||
toast.warning("Please select a provider");
|
||||
toast.warning(t("vectorStores.create.toasts.noProvider"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +156,7 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
const requiredFields = getProviderSpecificFields(selectedProvider).filter((field) => field.required);
|
||||
for (const field of requiredFields) {
|
||||
if (!providerParams[field.name]) {
|
||||
toast.warning(`Please provide ${field.label}`);
|
||||
toast.warning(t("vectorStores.create.toasts.fieldRequired", { field: t(field.label) }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -158,17 +166,17 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
const bucketName = asText(providerParams.vector_bucket_name);
|
||||
const indexName = asText(providerParams.index_name);
|
||||
if (bucketName && bucketName.length < 3) {
|
||||
toast.warning("Vector bucket name must be at least 3 characters");
|
||||
toast.warning(t("vectorStores.create.toasts.bucketTooShort"));
|
||||
return;
|
||||
}
|
||||
if (indexName && indexName.length > 0 && indexName.length < 3) {
|
||||
toast.warning("Index name must be at least 3 characters if provided");
|
||||
toast.warning(t("vectorStores.create.toasts.indexTooShort"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
toast.error("No access token available");
|
||||
toast.error(t("vectorStores.create.toasts.noAccessToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -221,13 +229,15 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
setIngestResults(results);
|
||||
if (failure) {
|
||||
toast.warning(
|
||||
`Vector store ${vectorStoreId} was created with ${results.length} document(s), but ${failure.name} failed to ingest`,
|
||||
t("vectorStores.create.toasts.partialFailure", {
|
||||
id: String(vectorStoreId),
|
||||
count: results.length,
|
||||
name: failure.name,
|
||||
}),
|
||||
{ description: failure.error instanceof Error ? failure.error.message : String(failure.error) },
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Successfully created vector store with ${results.length} document(s). Vector Store ID: ${vectorStoreId}`,
|
||||
);
|
||||
toast.success(t("vectorStores.create.toasts.created", { count: results.length, id: String(vectorStoreId) }));
|
||||
}
|
||||
|
||||
if (onSuccess && vectorStoreId) {
|
||||
|
|
@ -245,7 +255,7 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error("Error creating vector store:", error);
|
||||
toast.fromError(`Failed to create vector store: ${error}`);
|
||||
toast.fromError(t("vectorStores.create.toasts.createFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
|
|
@ -255,20 +265,16 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
<TooltipProvider>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Create Vector Store</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload documents and select a provider to create a new vector store with embedded content.
|
||||
</p>
|
||||
<h3 className="text-lg font-medium">{t("vectorStores.create.title")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("vectorStores.create.description")}</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<p className="font-medium">Step 1: Upload Documents</p>
|
||||
<p className="text-sm text-muted-foreground block mt-1">
|
||||
Upload one or more documents (PDF, TXT, DOCX, MD). Maximum file size: 50MB per file.
|
||||
</p>
|
||||
<p className="font-medium">{t("vectorStores.create.upload.title")}</p>
|
||||
<p className="text-sm text-muted-foreground block mt-1">{t("vectorStores.create.upload.description")}</p>
|
||||
</div>
|
||||
<label
|
||||
htmlFor={documentsInputId}
|
||||
|
|
@ -280,10 +286,8 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
}}
|
||||
>
|
||||
<Inbox className="size-12 text-primary" />
|
||||
<span className="text-base">Click or drag files to this area to upload</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Support for single or bulk upload. Supported formats: PDF, TXT, DOCX, MD
|
||||
</span>
|
||||
<span className="text-base">{t("vectorStores.create.upload.dropzone")}</span>
|
||||
<span className="text-sm text-muted-foreground">{t("vectorStores.create.upload.formats")}</span>
|
||||
<input
|
||||
id={documentsInputId}
|
||||
type="file"
|
||||
|
|
@ -304,7 +308,7 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
<Card>
|
||||
<CardContent>
|
||||
<div className="mb-4">
|
||||
<p className="font-medium">Uploaded Documents ({documents.length})</p>
|
||||
<p className="font-medium">{t("vectorStores.create.uploadedDocuments", { count: documents.length })}</p>
|
||||
</div>
|
||||
<DocumentsTable documents={documents} onRemove={handleRemoveDocument} />
|
||||
</CardContent>
|
||||
|
|
@ -315,41 +319,47 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
<Card>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="font-medium">Step 2: Configure Vector Store</p>
|
||||
<p className="font-medium">{t("vectorStores.create.configure.title")}</p>
|
||||
<p className="text-sm text-muted-foreground block mt-1">
|
||||
Choose the provider and optionally provide a name and description for your vector store.
|
||||
{t("vectorStores.create.configure.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="vector-store-name">
|
||||
{labelWithHint("Vector Store Name", "Optional: Give your vector store a meaningful name")}
|
||||
{labelWithHint(t("vectorStores.create.fields.name"), t("vectorStores.create.fields.nameHint"))}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="vector-store-name"
|
||||
value={vectorStoreName}
|
||||
onChange={(e) => setVectorStoreName(e.target.value)}
|
||||
placeholder="e.g., Product Documentation, Customer Support KB"
|
||||
placeholder={t("vectorStores.create.fields.namePlaceholder")}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="vector-store-description">
|
||||
{labelWithHint("Description", "Optional: Describe what this vector store contains")}
|
||||
{labelWithHint(
|
||||
t("vectorStores.create.fields.description"),
|
||||
t("vectorStores.create.fields.descriptionHint"),
|
||||
)}
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id="vector-store-description"
|
||||
value={vectorStoreDescription}
|
||||
onChange={(e) => setVectorStoreDescription(e.target.value)}
|
||||
placeholder="e.g., Contains all product documentation and user guides"
|
||||
placeholder={t("vectorStores.create.fields.descriptionPlaceholder")}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="vector-store-provider">
|
||||
{labelWithHint("Provider", "Select the provider for embedding and vector store operations")}
|
||||
{labelWithHint(
|
||||
t("vectorStores.create.fields.provider"),
|
||||
t("vectorStores.create.fields.providerHint"),
|
||||
)}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
items={providerItems}
|
||||
|
|
@ -357,7 +367,7 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
onValueChange={(value: string | null) => value !== null && setSelectedProvider(value)}
|
||||
>
|
||||
<SelectTrigger id="vector-store-provider" className="w-full">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
<SelectValue placeholder={t("vectorStores.create.fields.providerPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerItems.map((item) => (
|
||||
|
|
@ -384,7 +394,7 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
getProviderSpecificFields(selectedProvider).map((field: VectorStoreFieldConfig) => (
|
||||
<Field key={field.name}>
|
||||
<FieldLabel htmlFor={`vector-store-${field.name}`}>
|
||||
{labelWithHint(field.label, field.tooltip)}
|
||||
{labelWithHint(t(field.label), t(field.tooltip))}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`vector-store-${field.name}`}
|
||||
|
|
@ -404,7 +414,7 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
|
|||
disabled={isCreating || documents.length === 0 || !selectedProvider}
|
||||
>
|
||||
{isCreating && <UiLoadingSpinner className="size-4" />}
|
||||
{isCreating ? "Creating Vector Store..." : "Create Vector Store"}
|
||||
{isCreating ? t("vectorStores.create.buttons.creating") : t("vectorStores.create.buttons.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { DocumentUpload } from "@/components/vector_store_management/types";
|
||||
|
|
@ -14,19 +15,21 @@ interface DocumentsTableProps {
|
|||
}
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No documents uploaded yet</div>
|
||||
<div className="text-sm text-muted-foreground">Upload documents above to get started.</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("vectorStores.documents.empty.title")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("vectorStores.documents.empty.description")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DocumentsTable: React.FC<DocumentsTableProps> = ({ documents, onRemove }) => {
|
||||
const columns = useMemo(() => getDocumentsTableColumns({ onRemove }), [onRemove]);
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo(() => getDocumentsTableColumns({ onRemove }, t), [onRemove, t]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Copy, MoreHorizontal, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
|
@ -30,10 +32,11 @@ function formatFileSize(bytes?: number): string {
|
|||
}
|
||||
|
||||
function DocumentRowActions({ document, onRemove }: { document: DocumentUpload; onRemove: (uid: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open document actions"
|
||||
aria-label={t("vectorStores.documents.actions.open")}
|
||||
data-testid={`document-actions-${document.uid}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -42,10 +45,10 @@ function DocumentRowActions({ document, onRemove }: { document: DocumentUpload;
|
|||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem
|
||||
data-testid="document-action-copy"
|
||||
onClick={() => void copyToClipboard(document.uid, "Document ID copied to clipboard")}
|
||||
onClick={() => void copyToClipboard(document.uid, t("vectorStores.documents.actions.idCopied"))}
|
||||
>
|
||||
<Copy />
|
||||
Copy document ID
|
||||
{t("vectorStores.documents.actions.copyId")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
|
|
@ -53,7 +56,7 @@ function DocumentRowActions({ document, onRemove }: { document: DocumentUpload;
|
|||
onClick={() => onRemove(document.uid)}
|
||||
>
|
||||
<Trash2 />
|
||||
Remove
|
||||
{t("vectorStores.documents.actions.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -64,12 +67,15 @@ interface DocumentsTableColumnsDeps {
|
|||
onRemove: (uid: string) => void;
|
||||
}
|
||||
|
||||
export const getDocumentsTableColumns = ({ onRemove }: DocumentsTableColumnsDeps): ColumnDef<DocumentUpload>[] => [
|
||||
export const getDocumentsTableColumns = (
|
||||
{ onRemove }: DocumentsTableColumnsDeps,
|
||||
t: TFunction,
|
||||
): ColumnDef<DocumentUpload>[] => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
meta: { title: "Name" },
|
||||
header: "Name",
|
||||
meta: { title: t("vectorStores.documents.columns.name") },
|
||||
header: t("vectorStores.documents.columns.name"),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -85,8 +91,8 @@ export const getDocumentsTableColumns = ({ onRemove }: DocumentsTableColumnsDeps
|
|||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
meta: { title: "Status", skeleton: "badge" },
|
||||
header: "Status",
|
||||
meta: { title: t("vectorStores.documents.columns.status"), skeleton: "badge" },
|
||||
header: t("vectorStores.documents.columns.status"),
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -97,7 +103,7 @@ export const getDocumentsTableColumns = ({ onRemove }: DocumentsTableColumnsDeps
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("vectorStores.documents.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
import { toast } from "@/lib/toast";
|
||||
import { indexesListCall } from "@/components/networking";
|
||||
|
|
@ -29,6 +31,7 @@ interface IndexesTabProps {
|
|||
}
|
||||
|
||||
const IndexesTab: React.FC<IndexesTabProps> = ({ accessToken, vectorStores, onViewVectorStore }) => {
|
||||
const { t } = useTranslation();
|
||||
const [indexes, setIndexes] = useState<VectorStoreIndex[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
|
|
@ -55,7 +58,7 @@ const IndexesTab: React.FC<IndexesTabProps> = ({ accessToken, vectorStores, onVi
|
|||
setIndexes(response.data || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching indexes:", error);
|
||||
toast.fromError("Error fetching indexes: " + error);
|
||||
toast.fromError(i18n.t("vectorStores.indexes.toasts.fetchFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -66,26 +69,28 @@ const IndexesTab: React.FC<IndexesTabProps> = ({ accessToken, vectorStores, onVi
|
|||
return (
|
||||
<div className="w-full">
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Vector store indexes registered on this proxy via the <code>/v1/indexes</code> API. See the{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/providers/azure_ai/azure_ai_vector_stores_passthrough"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:underline"
|
||||
>
|
||||
vector store index docs
|
||||
</a>{" "}
|
||||
for how this works. Index passthrough is supported for Azure AI Search and Milvus today; support for more
|
||||
providers can be added, so please{" "}
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:underline"
|
||||
>
|
||||
file a GitHub issue
|
||||
</a>{" "}
|
||||
if you want your provider supported.
|
||||
<Trans
|
||||
i18nKey="vectorStores.indexes.intro"
|
||||
components={{
|
||||
endpoint: <code>/v1/indexes</code>,
|
||||
docs: (
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/providers/azure_ai/azure_ai_vector_stores_passthrough"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:underline"
|
||||
/>
|
||||
),
|
||||
issues: (
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info hover:underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 pt-2 pb-2 w-full">
|
||||
<IndexesTable
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
||||
|
|
@ -19,13 +20,14 @@ interface IndexesTableProps {
|
|||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No indexes registered yet</div>
|
||||
<div className="text-sm text-muted-foreground">Indexes registered on this proxy will appear here.</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("vectorStores.indexes.empty.title")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("vectorStores.indexes.empty.description")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,11 +38,12 @@ const IndexesTable: React.FC<IndexesTableProps> = ({
|
|||
onViewVectorStore,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getIndexesTableColumns({ resolveVectorStoreId, onViewVectorStore }),
|
||||
[resolveVectorStoreId, onViewVectorStore],
|
||||
() => getIndexesTableColumns({ resolveVectorStoreId, onViewVectorStore }, t),
|
||||
[resolveVectorStoreId, onViewVectorStore, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -53,7 +56,7 @@ const IndexesTable: React.FC<IndexesTableProps> = ({
|
|||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading indexes…"
|
||||
loadingMessage={t("vectorStores.indexes.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell } from "@/components/shared/table_cells";
|
||||
|
|
@ -13,15 +14,15 @@ interface IndexesTableColumnsDeps {
|
|||
onViewVectorStore: (vectorStoreId: string) => void;
|
||||
}
|
||||
|
||||
export const getIndexesTableColumns = ({
|
||||
resolveVectorStoreId,
|
||||
onViewVectorStore,
|
||||
}: IndexesTableColumnsDeps): ColumnDef<VectorStoreIndex>[] => [
|
||||
export const getIndexesTableColumns = (
|
||||
{ resolveVectorStoreId, onViewVectorStore }: IndexesTableColumnsDeps,
|
||||
t: TFunction,
|
||||
): ColumnDef<VectorStoreIndex>[] => [
|
||||
{
|
||||
id: "index_name",
|
||||
accessorKey: "index_name",
|
||||
meta: { title: "Index Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Index Name" />,
|
||||
meta: { title: t("vectorStores.indexes.columns.indexName") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("vectorStores.indexes.columns.indexName")} />,
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -33,8 +34,10 @@ export const getIndexesTableColumns = ({
|
|||
{
|
||||
id: "vector_store_name",
|
||||
accessorFn: (row) => row.litellm_params.vector_store_name,
|
||||
meta: { title: "Vector Store" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Vector Store" />,
|
||||
meta: { title: t("vectorStores.indexes.columns.vectorStore") },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("vectorStores.indexes.columns.vectorStore")} />
|
||||
),
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -60,8 +63,8 @@ export const getIndexesTableColumns = ({
|
|||
{
|
||||
id: "vector_store_index",
|
||||
accessorFn: (row) => row.litellm_params.vector_store_index,
|
||||
meta: { title: "Provider Index" },
|
||||
header: "Provider Index",
|
||||
meta: { title: t("vectorStores.indexes.columns.providerIndex") },
|
||||
header: t("vectorStores.indexes.columns.providerIndex"),
|
||||
size: 220,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -76,8 +79,8 @@ export const getIndexesTableColumns = ({
|
|||
{
|
||||
id: "created_by",
|
||||
accessorKey: "created_by",
|
||||
meta: { title: "Created By" },
|
||||
header: "Created By",
|
||||
meta: { title: t("vectorStores.indexes.columns.createdBy") },
|
||||
header: t("vectorStores.indexes.columns.createdBy"),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -99,8 +102,8 @@ export const getIndexesTableColumns = ({
|
|||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
sortingFn: "datetime",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
meta: { title: t("vectorStores.indexes.columns.createdAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("vectorStores.indexes.columns.createdAt")} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CircleHelp, Info } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
|
|
@ -33,6 +34,7 @@ const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
|||
const asText = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||
|
||||
const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({ accessToken, providerParams, onParamsChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [embeddingModels, setEmbeddingModels] = useState<ModelGroup[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
|
||||
|
|
@ -64,33 +66,38 @@ const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({ accessToken, provider
|
|||
|
||||
const bucketName = asText(providerParams.vector_bucket_name);
|
||||
const indexName = asText(providerParams.index_name);
|
||||
const bucketNameError = bucketName && bucketName.length < 3 ? "Bucket name must be at least 3 characters" : undefined;
|
||||
const bucketNameError =
|
||||
bucketName && bucketName.length < 3 ? t("vectorStores.s3Config.errors.bucketTooShort") : undefined;
|
||||
const indexNameError =
|
||||
indexName && indexName.length > 0 && indexName.length < 3
|
||||
? "Index name must be at least 3 characters if provided"
|
||||
? t("vectorStores.s3Config.errors.indexTooShort")
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Alert variant="info" className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>AWS S3 Vectors Setup</AlertTitle>
|
||||
<AlertTitle>{t("vectorStores.s3Config.setup.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div>
|
||||
<p>AWS S3 Vectors allows you to store and query vector embeddings directly in S3:</p>
|
||||
<p>{t("vectorStores.s3Config.setup.intro")}</p>
|
||||
<ul style={{ marginLeft: "16px", marginTop: "8px" }}>
|
||||
<li>Vector buckets and indexes will be automatically created if they don't exist</li>
|
||||
<li>Vector dimensions are auto-detected from your selected embedding model</li>
|
||||
<li>Ensure your AWS credentials have permissions for S3 Vectors operations</li>
|
||||
<li>{t("vectorStores.s3Config.setup.autoCreate")}</li>
|
||||
<li>{t("vectorStores.s3Config.setup.dimensions")}</li>
|
||||
<li>{t("vectorStores.s3Config.setup.permissions")}</li>
|
||||
<li>
|
||||
Learn more:{" "}
|
||||
<a
|
||||
href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vector-buckets.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
AWS S3 Vectors Documentation
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="vectorStores.s3Config.setup.learnMore"
|
||||
components={{
|
||||
docs: (
|
||||
<a
|
||||
href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-vector-buckets.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -100,15 +107,15 @@ const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({ accessToken, provider
|
|||
<Field data-invalid={bucketNameError !== undefined || undefined}>
|
||||
<FieldLabel htmlFor="s3-vector-bucket-name">
|
||||
{labelWithHint(
|
||||
"Vector Bucket Name",
|
||||
"S3 bucket name for vector storage (must be at least 3 characters, lowercase letters, numbers, hyphens, and periods only)",
|
||||
t("vectorStores.s3Config.fields.bucketName"),
|
||||
t("vectorStores.s3Config.fields.bucketNameHint"),
|
||||
)}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="s3-vector-bucket-name"
|
||||
value={bucketName}
|
||||
onChange={(e) => handleFieldChange("vector_bucket_name", e.target.value)}
|
||||
placeholder="my-vector-bucket (min 3 chars)"
|
||||
placeholder={t("vectorStores.s3Config.fields.bucketNamePlaceholder")}
|
||||
aria-invalid={bucketNameError !== undefined || undefined}
|
||||
/>
|
||||
<FieldError>{bucketNameError}</FieldError>
|
||||
|
|
@ -116,16 +123,13 @@ const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({ accessToken, provider
|
|||
|
||||
<Field data-invalid={indexNameError !== undefined || undefined}>
|
||||
<FieldLabel htmlFor="s3-index-name">
|
||||
{labelWithHint(
|
||||
"Index Name",
|
||||
"Name for the vector index (optional, will be auto-generated if not provided). If provided, must be at least 3 characters.",
|
||||
)}
|
||||
{labelWithHint(t("vectorStores.s3Config.fields.indexName"), t("vectorStores.s3Config.fields.indexNameHint"))}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="s3-index-name"
|
||||
value={indexName}
|
||||
onChange={(e) => handleFieldChange("index_name", e.target.value)}
|
||||
placeholder="my-vector-index (optional, min 3 chars)"
|
||||
placeholder={t("vectorStores.s3Config.fields.indexNamePlaceholder")}
|
||||
aria-invalid={indexNameError !== undefined || undefined}
|
||||
/>
|
||||
<FieldError>{indexNameError}</FieldError>
|
||||
|
|
@ -133,7 +137,7 @@ const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({ accessToken, provider
|
|||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="s3-aws-region-name">
|
||||
{labelWithHint("AWS Region", "AWS region where the S3 bucket is located (e.g., us-west-2)")}
|
||||
{labelWithHint(t("vectorStores.s3Config.fields.region"), t("vectorStores.s3Config.fields.regionHint"))}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="s3-aws-region-name"
|
||||
|
|
@ -145,16 +149,26 @@ const S3VectorsConfig: React.FC<S3VectorsConfigProps> = ({ accessToken, provider
|
|||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="s3-embedding-model">
|
||||
{labelWithHint("Embedding Model", "Select the embedding model to use for vector generation")}
|
||||
{labelWithHint(
|
||||
t("vectorStores.s3Config.fields.embeddingModel"),
|
||||
t("vectorStores.s3Config.fields.embeddingModelHint"),
|
||||
)}
|
||||
</FieldLabel>
|
||||
<Combobox
|
||||
value={asText(providerParams.embedding_model) || null}
|
||||
onValueChange={(value: string | null) => value !== null && handleFieldChange("embedding_model", value)}
|
||||
items={embeddingModels.map((model) => model.model_group)}
|
||||
>
|
||||
<ComboboxInput id="s3-embedding-model" placeholder="Select an embedding model" />
|
||||
<ComboboxInput
|
||||
id="s3-embedding-model"
|
||||
placeholder={t("vectorStores.s3Config.fields.embeddingModelPlaceholder")}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{isLoadingModels ? "Loading models..." : "No embedding models found."}</ComboboxEmpty>
|
||||
<ComboboxEmpty>
|
||||
{isLoadingModels
|
||||
? t("vectorStores.s3Config.fields.loadingModels")
|
||||
: t("vectorStores.s3Config.fields.noModels")}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model: string) => (
|
||||
<ComboboxItem key={model} value={model}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Combobox,
|
||||
|
|
@ -19,13 +20,14 @@ interface TestVectorStoreTabProps {
|
|||
const storeLabel = (store: VectorStore) => store.vector_store_name || store.vector_store_id;
|
||||
|
||||
const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, vectorStores }) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedVectorStore, setSelectedVectorStore] = useState<VectorStore | null>(vectorStores[0] ?? null);
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Access token is required to test vector stores.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("vectorStores.testTab.noToken")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
@ -36,7 +38,7 @@ const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, ve
|
|||
<Card>
|
||||
<CardContent>
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No vector stores available. Create one first to test it.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("vectorStores.testTab.noStores")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -48,8 +50,8 @@ const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, ve
|
|||
<Card>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h5 className="text-base font-medium text-foreground">Select Vector Store</h5>
|
||||
<p className="text-sm text-muted-foreground">Choose a vector store to test search queries against</p>
|
||||
<h5 className="text-base font-medium text-foreground">{t("vectorStores.testTab.selectTitle")}</h5>
|
||||
<p className="text-sm text-muted-foreground">{t("vectorStores.testTab.selectDescription")}</p>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
|
|
@ -58,9 +60,9 @@ const TestVectorStoreTab: React.FC<TestVectorStoreTabProps> = ({ accessToken, ve
|
|||
onValueChange={setSelectedVectorStore}
|
||||
itemToStringLabel={storeLabel}
|
||||
>
|
||||
<ComboboxInput className="w-full" placeholder="Select a vector store" />
|
||||
<ComboboxInput className="w-full" placeholder={t("vectorStores.testTab.selectPlaceholder")} />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No matching vector stores</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("vectorStores.testTab.noMatch")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(store: VectorStore) => (
|
||||
<ComboboxItem key={store.vector_store_id} value={store}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CircleHelp, Eye, EyeOff, Info } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { useWatch } from "react-hook-form";
|
||||
|
|
@ -15,6 +17,7 @@ import {
|
|||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { toast } from "@/lib/toast";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -96,8 +99,8 @@ const isProviderFieldName = (name: string): name is ProviderFieldName =>
|
|||
const optionalText = z.string().optional();
|
||||
|
||||
const vectorStoreShape = {
|
||||
custom_llm_provider: z.string().min(1, "Please select a provider"),
|
||||
vector_store_id: z.string().min(1, "Please input the vector store ID from your api provider"),
|
||||
custom_llm_provider: z.string().min(1, { error: () => i18n.t("vectorStores.form.errors.providerRequired") }),
|
||||
vector_store_id: z.string().min(1, { error: () => i18n.t("vectorStores.form.errors.vectorStoreIdRequired") }),
|
||||
vector_store_name: optionalText,
|
||||
vector_store_description: optionalText,
|
||||
litellm_credential_name: z.string().nullable().optional(),
|
||||
|
|
@ -136,25 +139,26 @@ const vectorStoreSchema = z.object(vectorStoreShape).superRefine((values, ctx) =
|
|||
path: [field.name],
|
||||
message:
|
||||
field.type === "select"
|
||||
? `Please select the ${field.label.toLowerCase()}`
|
||||
: `Please input the ${field.label.toLowerCase()}`,
|
||||
? i18n.t("vectorStores.form.errors.selectField", { field: i18n.t(field.label).toLowerCase() })
|
||||
: i18n.t("vectorStores.form.errors.inputField", { field: i18n.t(field.label).toLowerCase() }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
type VectorStoreFormValues = z.output<typeof vectorStoreSchema>;
|
||||
|
||||
const VECTOR_STORE_ID_PLACEHOLDERS: Record<string, string> = {
|
||||
vertex_rag_engine: '6917529027641081856 (corpus ID from Vertex AI / "RAG Engine" console)',
|
||||
"vertex_ai/search_api": 'my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)',
|
||||
valkey: "my-search-index (FT index name in Valkey)",
|
||||
mongodb: "my-vector-index (MongoDB Vector Search index name)",
|
||||
opensearch: "my-knn-index (OpenSearch index name)",
|
||||
const VECTOR_STORE_ID_PLACEHOLDERS: Record<string, ParseKeys> = {
|
||||
vertex_rag_engine: "vectorStores.form.idPlaceholders.vertexRagEngine",
|
||||
"vertex_ai/search_api": "vectorStores.form.idPlaceholders.vertexAiSearch",
|
||||
valkey: "vectorStores.form.idPlaceholders.valkey",
|
||||
mongodb: "vectorStores.form.idPlaceholders.mongodb",
|
||||
opensearch: "vectorStores.form.idPlaceholders.opensearch",
|
||||
};
|
||||
|
||||
const VERTEX_SEARCH_API_WITH_ENGINE_PLACEHOLDER = "Any identifier you'll use to reference this in LiteLLM";
|
||||
const VERTEX_SEARCH_API_WITH_ENGINE_PLACEHOLDER: ParseKeys =
|
||||
"vectorStores.form.idPlaceholders.vertexAiSearchWithEngine";
|
||||
|
||||
const DEFAULT_VECTOR_STORE_ID_PLACEHOLDER = "Enter vector store ID from your provider";
|
||||
const DEFAULT_VECTOR_STORE_ID_PLACEHOLDER: ParseKeys = "vectorStores.form.idPlaceholders.default";
|
||||
|
||||
const EMPTY_VALUES: VectorStoreFormValues = {
|
||||
custom_llm_provider: "bedrock",
|
||||
|
|
@ -182,6 +186,7 @@ const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
|||
|
||||
const PasswordInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<typeof InputGroupInput>>(
|
||||
(props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
return (
|
||||
<InputGroup>
|
||||
|
|
@ -189,7 +194,7 @@ const PasswordInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWit
|
|||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={revealed ? "Hide Password" : "Show Password"}
|
||||
aria-label={revealed ? t("vectorStores.form.password.hide") : t("vectorStores.form.password.show")}
|
||||
onClick={() => setRevealed(!revealed)}
|
||||
>
|
||||
{revealed ? <EyeOff /> : <Eye />}
|
||||
|
|
@ -208,6 +213,7 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
accessToken,
|
||||
credentials,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(vectorStoreSchema, { defaultValues: EMPTY_VALUES });
|
||||
const [metadataJson, setMetadataJson] = useState("{}");
|
||||
const [selectedProvider, setSelectedProvider] = useState("bedrock");
|
||||
|
|
@ -244,7 +250,7 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
try {
|
||||
metadata = metadataJson.trim() ? JSON.parse(metadataJson) : {};
|
||||
} catch (e) {
|
||||
toast.fromError("Invalid JSON in metadata field");
|
||||
toast.fromError(t("vectorStores.form.toasts.invalidMetadata"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -257,13 +263,13 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
litellm_credential_name: formValues.litellm_credential_name,
|
||||
litellm_params: buildVectorStoreLitellmParams(formValues.custom_llm_provider, formValues),
|
||||
});
|
||||
toast.success("Vector store created successfully");
|
||||
toast.success(t("vectorStores.form.toasts.created"));
|
||||
form.reset(EMPTY_VALUES);
|
||||
setMetadataJson("{}");
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Error creating vector store:", error);
|
||||
toast.fromError("Error creating vector store: " + error);
|
||||
toast.fromError(t("vectorStores.form.toasts.createFailed", { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -283,7 +289,7 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
<Dialog open={isVisible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[1000px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Vector Store</DialogTitle>
|
||||
<DialogTitle>{t("vectorStores.form.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleCreate)}>
|
||||
|
|
@ -291,7 +297,10 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="custom_llm_provider"
|
||||
label={labelWithHint("Provider", "Select the provider for this vector store")}
|
||||
label={labelWithHint(
|
||||
t("vectorStores.form.fields.provider"),
|
||||
t("vectorStores.form.fields.providerHint"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Select value={value} onValueChange={makeProviderChangeHandler(onChange)}>
|
||||
|
|
@ -332,19 +341,19 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
{selectedProvider === "pg_vector" && (
|
||||
<Alert variant="info">
|
||||
<Info />
|
||||
<AlertTitle>PG Vector Setup Required</AlertTitle>
|
||||
<AlertTitle>{t("vectorStores.form.pgVector.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>LiteLLM provides a server to connect to PG Vector. To use this provider:</p>
|
||||
<p>{t("vectorStores.form.pgVector.intro")}</p>
|
||||
<ol style={{ marginLeft: "16px", marginTop: "8px", listStyleType: "decimal" }}>
|
||||
<li>
|
||||
Deploy the litellm-pgvector server from:{" "}
|
||||
{t("vectorStores.form.pgVector.steps.deploy")}{" "}
|
||||
<a href="https://github.com/BerriAI/litellm-pgvector" target="_blank" rel="noopener noreferrer">
|
||||
https://github.com/BerriAI/litellm-pgvector
|
||||
</a>
|
||||
</li>
|
||||
<li>Configure your PostgreSQL database with pgvector extension</li>
|
||||
<li>Start the server and note the API base URL and API key</li>
|
||||
<li>Enter those details in the fields below</li>
|
||||
<li>{t("vectorStores.form.pgVector.steps.configure")}</li>
|
||||
<li>{t("vectorStores.form.pgVector.steps.start")}</li>
|
||||
<li>{t("vectorStores.form.pgVector.steps.enter")}</li>
|
||||
</ol>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -353,34 +362,16 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
{selectedProvider === "valkey" && (
|
||||
<Alert variant="info">
|
||||
<Info />
|
||||
<AlertTitle>Valkey Setup Required</AlertTitle>
|
||||
<AlertTitle>{t("vectorStores.form.valkey.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
LiteLLM searches documents you have already stored in Valkey. It does not create the index or
|
||||
upload documents for you. Before creating this vector store, make sure:
|
||||
</p>
|
||||
<p>{t("vectorStores.form.valkey.intro")}</p>
|
||||
<ol style={{ marginLeft: "16px", marginTop: "8px", listStyleType: "decimal" }}>
|
||||
<li>
|
||||
Your Valkey server has vector search enabled (the valkey-search module, included in the
|
||||
valkey-bundle image and in AWS ElastiCache / MemoryDB for Valkey)
|
||||
</li>
|
||||
<li>
|
||||
You have already created a search index and loaded your documents and their embeddings into it.
|
||||
Enter that index name as the Vector Store ID
|
||||
</li>
|
||||
<li>
|
||||
You know which embedding model created those stored embeddings. That model must be added to this
|
||||
proxy under Models so you can pick it below. Using a different model returns wrong results
|
||||
</li>
|
||||
<li>
|
||||
You know the field names your documents use for their text and their embedding. If they are not
|
||||
"text" and "embedding", set them below
|
||||
</li>
|
||||
<li>{t("vectorStores.form.valkey.steps.searchEnabled")}</li>
|
||||
<li>{t("vectorStores.form.valkey.steps.indexLoaded")}</li>
|
||||
<li>{t("vectorStores.form.valkey.steps.embeddingModel")}</li>
|
||||
<li>{t("vectorStores.form.valkey.steps.fieldNames")}</li>
|
||||
</ol>
|
||||
<p style={{ marginTop: "8px" }}>
|
||||
When a query comes in, LiteLLM converts it to an embedding with the model below and returns the
|
||||
closest matching documents from your index.
|
||||
</p>
|
||||
<p style={{ marginTop: "8px" }}>{t("vectorStores.form.valkey.howItWorks")}</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -388,27 +379,13 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
{selectedProvider === "opensearch" && (
|
||||
<Alert variant="info">
|
||||
<Info />
|
||||
<AlertTitle>OpenSearch Setup Required</AlertTitle>
|
||||
<AlertTitle>{t("vectorStores.form.opensearch.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
LiteLLM searches a k-NN index you already have in an OpenSearch Serverless collection or an
|
||||
OpenSearch Service domain. It does not create the index or upload documents. Before creating this
|
||||
vector store, make sure:
|
||||
</p>
|
||||
<p>{t("vectorStores.form.opensearch.intro")}</p>
|
||||
<ol style={{ marginLeft: "16px", marginTop: "8px", listStyleType: "decimal" }}>
|
||||
<li>
|
||||
The index has a knn_vector field holding each document's embedding. Enter the index name as
|
||||
the Vector Store ID, or under Index Name when another endpoint already uses that ID
|
||||
</li>
|
||||
<li>
|
||||
You know which embedding model created those embeddings. That model must be added to this proxy
|
||||
under Models so you can pick it below. Using a different model returns wrong results
|
||||
</li>
|
||||
<li>
|
||||
The proxy has AWS credentials that can read the index, either its own (environment variables or
|
||||
an IAM role) or a saved credential picked under Existing Credentials. A Serverless collection
|
||||
needs a data access policy for them, and a domain needs its access policy to allow them
|
||||
</li>
|
||||
<li>{t("vectorStores.form.opensearch.steps.knnField")}</li>
|
||||
<li>{t("vectorStores.form.opensearch.steps.embeddingModel")}</li>
|
||||
<li>{t("vectorStores.form.opensearch.steps.credentials")}</li>
|
||||
</ol>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -417,30 +394,30 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
{selectedProvider === "vertex_rag_engine" && (
|
||||
<Alert variant="info">
|
||||
<Info />
|
||||
<AlertTitle>Vertex AI RAG Engine Setup</AlertTitle>
|
||||
<AlertTitle>{t("vectorStores.form.vertexRagEngine.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>To use Vertex AI RAG Engine:</p>
|
||||
<p>{t("vectorStores.form.vertexRagEngine.intro")}</p>
|
||||
<p style={{ marginTop: "4px", fontStyle: "italic" }}>
|
||||
Note: Google Cloud has renamed this to "RAG Engine" in its console — the steps below
|
||||
still apply.
|
||||
{t("vectorStores.form.vertexRagEngine.renameNote")}
|
||||
</p>
|
||||
<ol style={{ marginLeft: "16px", marginTop: "8px", listStyleType: "decimal" }}>
|
||||
<li>
|
||||
Set up your Vertex AI RAG Engine corpus following the guide:{" "}
|
||||
<a
|
||||
href="https://cloud.google.com/vertex-ai/generative-ai/docs/rag-engine/rag-overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Vertex AI RAG Engine Overview
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="vectorStores.form.vertexRagEngine.steps.guide"
|
||||
components={{
|
||||
guide: (
|
||||
<a
|
||||
href="https://cloud.google.com/vertex-ai/generative-ai/docs/rag-engine/rag-overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
<li>Create a corpus in your Google Cloud project</li>
|
||||
<li>
|
||||
Note the corpus ID from the Vertex AI console (now labeled "RAG Engine" in Google
|
||||
Cloud)
|
||||
</li>
|
||||
<li>Enter the corpus ID in the Vector Store ID field below</li>
|
||||
<li>{t("vectorStores.form.vertexRagEngine.steps.createCorpus")}</li>
|
||||
<li>{t("vectorStores.form.vertexRagEngine.steps.noteCorpusId")}</li>
|
||||
<li>{t("vectorStores.form.vertexRagEngine.steps.enterCorpusId")}</li>
|
||||
</ol>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -449,36 +426,35 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
{selectedProvider === "vertex_ai/search_api" && (
|
||||
<Alert variant="info">
|
||||
<Info />
|
||||
<AlertTitle>Vertex AI Search Setup</AlertTitle>
|
||||
<AlertTitle>{t("vectorStores.form.vertexAiSearch.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>To use Vertex AI Search (Discovery Engine):</p>
|
||||
<p>{t("vectorStores.form.vertexAiSearch.intro")}</p>
|
||||
<p style={{ marginTop: "4px", fontStyle: "italic" }}>
|
||||
Note: Google Cloud has renamed this to "Agent Search" in its console — the steps below
|
||||
still apply.
|
||||
{t("vectorStores.form.vertexAiSearch.renameNote")}
|
||||
</p>
|
||||
<ol style={{ marginLeft: "16px", marginTop: "8px", listStyleType: "decimal" }}>
|
||||
<li>
|
||||
Enable the Discovery Engine API on your Google Cloud project and create a data store following
|
||||
the guide:{" "}
|
||||
<a
|
||||
href="https://cloud.google.com/generative-ai-app-builder/docs/create-data-store-es"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
Create a Vertex AI Search data store
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="vectorStores.form.vertexAiSearch.steps.guide"
|
||||
components={{
|
||||
guide: (
|
||||
<a
|
||||
href="https://cloud.google.com/generative-ai-app-builder/docs/create-data-store-es"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "underline" }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
<li>Pick a supported location: global, us, or eu</li>
|
||||
<li>{t("vectorStores.form.vertexAiSearch.steps.location")}</li>
|
||||
<li>{t("vectorStores.form.vertexAiSearch.steps.dataStoreId")}</li>
|
||||
<li>
|
||||
For most data store types (Cloud Storage, BigQuery, Media): copy the data store ID and enter it
|
||||
in the Vector Store ID field below.
|
||||
</li>
|
||||
<li>
|
||||
For website, healthcare, and connector-based sources (Drive, Gmail, Slack, Jira, etc.): create a
|
||||
search app on top of the data store, then copy the <strong>Engine ID</strong> and enter it in
|
||||
the Engine ID field. The Vector Store ID is still required as the LiteLLM-side name for this
|
||||
record, but it isn't used in the GCP URL when Engine ID is set.
|
||||
<Trans
|
||||
i18nKey="vectorStores.form.vertexAiSearch.steps.engineId"
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</li>
|
||||
</ol>
|
||||
</AlertDescription>
|
||||
|
|
@ -488,9 +464,12 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="vector_store_id"
|
||||
label={labelWithHint("Vector Store ID", "Enter the vector store ID from your api provider")}
|
||||
label={labelWithHint(
|
||||
t("vectorStores.form.fields.vectorStoreId"),
|
||||
t("vectorStores.form.fields.vectorStoreIdHint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder={vectorStoreIdPlaceholder} />}
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder={t(vectorStoreIdPlaceholder)} />}
|
||||
</FormField>
|
||||
|
||||
{getProviderSpecificFields(selectedProvider)
|
||||
|
|
@ -502,15 +481,16 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="vector_store_name"
|
||||
label={labelWithHint(
|
||||
"Vector Store Name",
|
||||
"Custom name you want to give to the vector store, this name will be rendered on the LiteLLM UI",
|
||||
)}
|
||||
label={labelWithHint(t("vectorStores.form.fields.name"), t("vectorStores.form.fields.nameHint"))}
|
||||
>
|
||||
{({ ref, value, ...field }) => <Input {...field} ref={ref} value={value ?? ""} />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="vector_store_description" label="Description">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vector_store_description"
|
||||
label={t("vectorStores.form.fields.description")}
|
||||
>
|
||||
{({ ref, value, ...field }) => <Textarea {...field} ref={ref} value={value ?? ""} rows={4} />}
|
||||
</FormField>
|
||||
|
||||
|
|
@ -518,14 +498,14 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
control={form.control}
|
||||
name="litellm_credential_name"
|
||||
label={labelWithHint(
|
||||
"Existing Credentials",
|
||||
"Optionally select API provider credentials for this vector store eg. Bedrock API KEY",
|
||||
t("vectorStores.form.fields.credentials"),
|
||||
t("vectorStores.form.fields.credentialsHint"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
placeholder="Select or search for existing credentials"
|
||||
placeholder={t("vectorStores.form.fields.credentialsPlaceholder")}
|
||||
options={credentialOptions(credentials)}
|
||||
value={value ?? ""}
|
||||
onValueChange={(selected) => onChange(selected === "" || selected === null ? undefined : selected)}
|
||||
|
|
@ -535,7 +515,7 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
|
||||
<div role="group" className="flex w-full flex-col gap-3">
|
||||
<span className="flex w-fit gap-2 text-sm leading-snug font-medium">
|
||||
{labelWithHint("Metadata", "JSON metadata for the vector store (optional)")}
|
||||
{labelWithHint(t("vectorStores.form.fields.metadata"), t("vectorStores.form.fields.metadataHint"))}
|
||||
</span>
|
||||
<Textarea
|
||||
rows={4}
|
||||
|
|
@ -548,9 +528,9 @@ const VectorStoreForm: React.FC<VectorStoreFormProps> = ({
|
|||
|
||||
<div className="mt-6 flex justify-end space-x-3">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("vectorStores.form.buttons.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">Create</Button>
|
||||
<Button type="submit">{t("vectorStores.form.buttons.create")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
|
|
@ -571,7 +551,8 @@ interface ProviderFieldProps {
|
|||
}
|
||||
|
||||
const ProviderField: React.FC<ProviderFieldProps> = ({ field, control, modelInfo }) => {
|
||||
const label = labelWithHint(field.label, field.tooltip);
|
||||
const { t } = useTranslation();
|
||||
const label = labelWithHint(t(field.label), t(field.tooltip));
|
||||
|
||||
if (field.type === "select") {
|
||||
const selectOptions =
|
||||
|
|
@ -604,7 +585,7 @@ const ProviderField: React.FC<ProviderFieldProps> = ({ field, control, modelInfo
|
|||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No matching options</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("vectorStores.form.noMatchingOptions")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: { value: string; label: string }) => (
|
||||
<ComboboxItem key={option.value} value={option}>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { VectorStore } from "@/components/vector_store_management/types";
|
||||
|
|
@ -20,23 +21,26 @@ interface VectorStoreTableProps {
|
|||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No vector stores</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Connect a vector store to enable retrieval-augmented generation.
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("vectorStores.table.empty.title")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("vectorStores.table.empty.description")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const VectorStoreTable: React.FC<VectorStoreTableProps> = ({ data, onView, onEdit, onDelete, isLoading = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(() => getVectorStoreTableColumns({ onView, onEdit, onDelete }), [onView, onEdit, onDelete]);
|
||||
const columns = useMemo(
|
||||
() => getVectorStoreTableColumns({ onView, onEdit, onDelete }, t),
|
||||
[onView, onEdit, onDelete, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
@ -48,7 +52,7 @@ const VectorStoreTable: React.FC<VectorStoreTableProps> = ({ data, onView, onEdi
|
|||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading vector stores…"
|
||||
loadingMessage={t("vectorStores.table.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { CellTooltip, DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
|
|
@ -18,8 +20,7 @@ import { VectorStore } from "@/components/vector_store_management/types";
|
|||
import { cn } from "@/lib/cva.config";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
|
||||
const CONFIG_STORE_HINT =
|
||||
"Read only: this vector store is defined in the config file and cannot be edited or deleted on the dashboard.";
|
||||
const CONFIG_STORE_HINT: ParseKeys = "vectorStores.table.actions.configHint";
|
||||
|
||||
function VectorStoreProviderCell({ provider }: { provider: string }) {
|
||||
const { displayName, logo } = getVectorStoreProviderLogoAndName(provider);
|
||||
|
|
@ -41,16 +42,19 @@ function VectorStoreProviderCell({ provider }: { provider: string }) {
|
|||
}
|
||||
|
||||
function VectorStoreFilesCell({ vectorStore }: { vectorStore: VectorStore }) {
|
||||
const { t } = useTranslation();
|
||||
const ingestedFiles = vectorStore.vector_store_metadata?.ingested_files || [];
|
||||
if (ingestedFiles.length === 0) {
|
||||
return <span className="text-sm text-muted-foreground">-</span>;
|
||||
}
|
||||
|
||||
const filenames = ingestedFiles.map((file) => file.filename || file.file_url || "Unknown").join(", ");
|
||||
const filenames = ingestedFiles
|
||||
.map((file) => file.filename || file.file_url || t("vectorStores.table.files.unknown"))
|
||||
.join(", ");
|
||||
const displayText =
|
||||
ingestedFiles.length === 1
|
||||
? ingestedFiles[0].filename || ingestedFiles[0].file_url || "1 file"
|
||||
: `${ingestedFiles.length} files`;
|
||||
? ingestedFiles[0].filename || ingestedFiles[0].file_url || t("vectorStores.table.files.count", { count: 1 })
|
||||
: t("vectorStores.table.files.count", { count: ingestedFiles.length });
|
||||
|
||||
return (
|
||||
<CellTooltip
|
||||
|
|
@ -67,11 +71,12 @@ interface VectorStoreRowActionsProps {
|
|||
}
|
||||
|
||||
function VectorStoreRowActions({ vectorStore, onEdit, onDelete }: VectorStoreRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const isFromConfig = vectorStore.is_config ?? false;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open vector store actions"
|
||||
aria-label={t("vectorStores.table.actions.open")}
|
||||
data-testid={`vector-store-actions-${vectorStore.vector_store_id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -84,14 +89,14 @@ function VectorStoreRowActions({ vectorStore, onEdit, onDelete }: VectorStoreRow
|
|||
onClick={() => onEdit(vectorStore.vector_store_id)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit
|
||||
{t("vectorStores.table.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="vector-store-action-copy"
|
||||
onClick={() => void copyToClipboard(vectorStore.vector_store_id, "Vector store ID copied")}
|
||||
onClick={() => void copyToClipboard(vectorStore.vector_store_id, t("vectorStores.table.actions.idCopied"))}
|
||||
>
|
||||
<Copy />
|
||||
Copy vector store ID
|
||||
{t("vectorStores.table.actions.copyId")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
|
|
@ -101,11 +106,11 @@ function VectorStoreRowActions({ vectorStore, onEdit, onDelete }: VectorStoreRow
|
|||
onClick={() => onDelete(vectorStore.vector_store_id)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("vectorStores.table.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
{isFromConfig && (
|
||||
<div data-testid="vector-store-config-hint" className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{CONFIG_STORE_HINT}
|
||||
{t(CONFIG_STORE_HINT)}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -119,16 +124,17 @@ interface VectorStoreTableColumnsDeps {
|
|||
onDelete: (vectorStoreId: string) => void;
|
||||
}
|
||||
|
||||
export const getVectorStoreTableColumns = ({
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: VectorStoreTableColumnsDeps): ColumnDef<VectorStore>[] => [
|
||||
export const getVectorStoreTableColumns = (
|
||||
{ onView, onEdit, onDelete }: VectorStoreTableColumnsDeps,
|
||||
t: TFunction,
|
||||
): ColumnDef<VectorStore>[] => [
|
||||
{
|
||||
id: "vector_store_id",
|
||||
accessorKey: "vector_store_id",
|
||||
meta: { title: "Vector Store ID" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Vector Store ID" />,
|
||||
meta: { title: t("vectorStores.table.columns.vectorStoreId") },
|
||||
header: ({ column }) => (
|
||||
<DataTableSortHeader column={column} title={t("vectorStores.table.columns.vectorStoreId")} />
|
||||
),
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -143,8 +149,8 @@ export const getVectorStoreTableColumns = ({
|
|||
{
|
||||
id: "vector_store_name",
|
||||
accessorKey: "vector_store_name",
|
||||
meta: { title: "Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
|
||||
meta: { title: t("vectorStores.table.columns.name") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("vectorStores.table.columns.name")} />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -159,8 +165,8 @@ export const getVectorStoreTableColumns = ({
|
|||
{
|
||||
id: "vector_store_description",
|
||||
accessorKey: "vector_store_description",
|
||||
meta: { title: "Description" },
|
||||
header: "Description",
|
||||
meta: { title: t("vectorStores.table.columns.description") },
|
||||
header: t("vectorStores.table.columns.description"),
|
||||
size: 280,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -175,19 +181,24 @@ export const getVectorStoreTableColumns = ({
|
|||
{
|
||||
id: "source",
|
||||
accessorFn: (row) => row.is_config ?? false,
|
||||
meta: { title: "Source", skeleton: "badge" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Source" />,
|
||||
meta: { title: t("vectorStores.table.columns.source"), skeleton: "badge" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("vectorStores.table.columns.source")} />,
|
||||
size: 110,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const isFromConfig = row.original.is_config ?? false;
|
||||
return <StatusBadge tone={isFromConfig ? "neutral" : "info"} label={isFromConfig ? "Config" : "DB"} />;
|
||||
return (
|
||||
<StatusBadge
|
||||
tone={isFromConfig ? "neutral" : "info"}
|
||||
label={isFromConfig ? t("vectorStores.table.source.config") : t("vectorStores.table.source.db")}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "files",
|
||||
meta: { title: "Files" },
|
||||
header: "Files",
|
||||
meta: { title: t("vectorStores.table.columns.files") },
|
||||
header: t("vectorStores.table.columns.files"),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <VectorStoreFilesCell vectorStore={row.original} />,
|
||||
|
|
@ -195,8 +206,8 @@ export const getVectorStoreTableColumns = ({
|
|||
{
|
||||
id: "provider",
|
||||
accessorKey: "custom_llm_provider",
|
||||
meta: { title: "Provider" },
|
||||
header: "Provider",
|
||||
meta: { title: t("vectorStores.table.columns.provider") },
|
||||
header: t("vectorStores.table.columns.provider"),
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <VectorStoreProviderCell provider={row.original.custom_llm_provider} />,
|
||||
|
|
@ -205,8 +216,8 @@ export const getVectorStoreTableColumns = ({
|
|||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
sortingFn: "datetime",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
meta: { title: t("vectorStores.table.columns.createdAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("vectorStores.table.columns.createdAt")} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
|
|
@ -215,8 +226,8 @@ export const getVectorStoreTableColumns = ({
|
|||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
sortingFn: "datetime",
|
||||
meta: { title: "Updated At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
|
||||
meta: { title: t("vectorStores.table.columns.updatedAt") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("vectorStores.table.columns.updatedAt")} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
|
||||
|
|
@ -224,7 +235,7 @@ export const getVectorStoreTableColumns = ({
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("vectorStores.table.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { ChevronDown, ChevronRight, Database, Send } from "lucide-react";
|
||||
import { vectorStoreSearchCall } from "@/components/networking";
|
||||
|
|
@ -34,6 +35,7 @@ interface VectorStoreTesterProps {
|
|||
}
|
||||
|
||||
export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStoreId, accessToken, className = "" }) => {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchHistory, setSearchHistory] = useState<
|
||||
|
|
@ -48,7 +50,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) {
|
||||
toast.warning("Please enter a search query");
|
||||
toast.warning(t("vectorStores.tester.toasts.emptyQuery"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -90,7 +92,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
const clearHistory = () => {
|
||||
setSearchHistory([]);
|
||||
setExpandedResults({});
|
||||
toast.success("Search history cleared");
|
||||
toast.success(t("vectorStores.tester.toasts.historyCleared"));
|
||||
};
|
||||
|
||||
const toggleResultExpansion = (historyIndex: number, resultIndex: number) => {
|
||||
|
|
@ -108,11 +110,11 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div className="flex items-center">
|
||||
<Database className="mr-2 size-4 text-primary" />
|
||||
<h4 className="text-base font-medium text-foreground">Test Vector Store</h4>
|
||||
<h4 className="text-base font-medium text-foreground">{t("vectorStores.tester.title")}</h4>
|
||||
</div>
|
||||
{searchHistory.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={clearHistory}>
|
||||
Clear History
|
||||
{t("vectorStores.tester.clearHistory")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -122,7 +124,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
{searchHistory.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Database className="mb-4 size-12" />
|
||||
<p className="text-sm">Test your vector store by entering a search query below</p>
|
||||
<p className="text-sm">{t("vectorStores.tester.emptyHint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -132,7 +134,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
<div className="text-right">
|
||||
<div className="inline-block max-w-[80%] rounded-lg bg-muted p-3 shadow-xs ring-1 ring-foreground/10">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<strong className="text-sm">Query</strong>
|
||||
<strong className="text-sm">{t("vectorStores.tester.query")}</strong>
|
||||
<span className="text-xs text-muted-foreground">{formatTimestamp(entry.timestamp)}</span>
|
||||
</div>
|
||||
<div className="text-left">{entry.query}</div>
|
||||
|
|
@ -144,10 +146,10 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
<div className="inline-block max-w-[80%] rounded-lg bg-card p-3 shadow-xs ring-1 ring-foreground/10">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Database className="size-4 text-primary" />
|
||||
<strong className="text-sm">Vector Store Results</strong>
|
||||
<strong className="text-sm">{t("vectorStores.tester.results")}</strong>
|
||||
{entry.response && (
|
||||
<span className="rounded-sm bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{entry.response.data?.length || 0} results
|
||||
{t("vectorStores.tester.resultCount", { count: entry.response.data?.length || 0 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -170,7 +172,9 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
) : (
|
||||
<ChevronRight className="mr-2 size-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">Result {resultIndex + 1}</span>
|
||||
<span className="text-sm font-medium">
|
||||
{t("vectorStores.tester.result", { number: resultIndex + 1 })}
|
||||
</span>
|
||||
{/* Show preview of content when collapsed */}
|
||||
{!isExpanded && result.content && result.content[0] && (
|
||||
<span className="ml-2 max-w-md truncate text-xs text-muted-foreground">
|
||||
|
|
@ -179,7 +183,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
)}
|
||||
</div>
|
||||
<span className="rounded-sm bg-muted px-2 py-1 text-xs text-foreground">
|
||||
Score: {result.score.toFixed(4)}
|
||||
{t("vectorStores.tester.score", { score: result.score.toFixed(4) })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -191,7 +195,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
result.content.map((content, contentIndex) => (
|
||||
<div key={contentIndex} className="mb-3">
|
||||
<div className="mb-1 text-xs text-muted-foreground">
|
||||
Content ({content.type})
|
||||
{t("vectorStores.tester.content", { type: content.type })}
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto rounded-sm border bg-muted/50 p-3 text-sm text-foreground">
|
||||
{content.text}
|
||||
|
|
@ -202,21 +206,27 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
{/* Metadata */}
|
||||
{(result.file_id || result.filename || result.attributes) && (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">Metadata</div>
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
{t("vectorStores.tester.metadata")}
|
||||
</div>
|
||||
<div className="space-y-2 text-xs">
|
||||
{result.file_id && (
|
||||
<div className="rounded-sm bg-muted/50 p-2">
|
||||
<span className="font-medium">File ID:</span> {result.file_id}
|
||||
<span className="font-medium">{t("vectorStores.tester.fileId")}</span>{" "}
|
||||
{result.file_id}
|
||||
</div>
|
||||
)}
|
||||
{result.filename && (
|
||||
<div className="rounded-sm bg-muted/50 p-2">
|
||||
<span className="font-medium">Filename:</span> {result.filename}
|
||||
<span className="font-medium">{t("vectorStores.tester.filename")}</span>{" "}
|
||||
{result.filename}
|
||||
</div>
|
||||
)}
|
||||
{result.attributes && Object.keys(result.attributes).length > 0 && (
|
||||
<div className="rounded-sm bg-muted/50 p-2">
|
||||
<span className="mb-1 block font-medium">Attributes:</span>
|
||||
<span className="mb-1 block font-medium">
|
||||
{t("vectorStores.tester.attributes")}
|
||||
</span>
|
||||
<pre className="overflow-x-auto rounded-sm border bg-card p-2 text-xs">
|
||||
{JSON.stringify(result.attributes, null, 2)}
|
||||
</pre>
|
||||
|
|
@ -237,7 +247,9 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
entry.error ? "text-sm break-words text-destructive" : "text-sm text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{entry.error ? `Search failed: ${entry.error}` : "No results found"}
|
||||
{entry.error
|
||||
? t("vectorStores.tester.searchFailed", { error: entry.error })
|
||||
: t("vectorStores.tester.noResults")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -264,7 +276,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter your search query... (Shift+Enter for new line)"
|
||||
placeholder={t("vectorStores.tester.placeholder")}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
className="field-sizing-fixed max-h-24 min-h-9 resize-none"
|
||||
|
|
@ -272,7 +284,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
</div>
|
||||
<Button onClick={handleSearch} disabled={isLoading || !query.trim()}>
|
||||
{isLoading ? <UiLoadingSpinner className="size-4" /> : <Send className="size-4" />}
|
||||
Search
|
||||
{t("vectorStores.tester.search")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import {
|
||||
vectorStoreListCall,
|
||||
|
|
@ -28,6 +29,7 @@ interface VectorStoreProps {
|
|||
}
|
||||
|
||||
const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID, userRole, isViewOnly }) => {
|
||||
const { t } = useTranslation();
|
||||
const [vectorStores, setVectorStores] = useState<VectorStore[]>([]);
|
||||
const [isLoadingVectorStores, setIsLoadingVectorStores] = useState(true);
|
||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||
|
|
@ -52,7 +54,7 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
setVectorStores(response.data || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching vector stores:", error);
|
||||
toast.fromError("Error fetching vector stores: " + error);
|
||||
toast.fromError(t("vectorStores.page.toasts.fetchStoresFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsLoadingVectorStores(false);
|
||||
}
|
||||
|
|
@ -65,7 +67,7 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
setCredentials(response.credentials || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching credentials:", error);
|
||||
toast.fromError("Error fetching credentials: " + error);
|
||||
toast.fromError(t("vectorStores.page.toasts.fetchCredentialsFailed", { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -102,11 +104,11 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
setIsDeleting(true);
|
||||
try {
|
||||
await vectorStoreDeleteCall(accessToken, vectorStoreToDelete);
|
||||
toast.success("Vector store deleted successfully");
|
||||
toast.success(t("vectorStores.page.toasts.deleted"));
|
||||
fetchVectorStores();
|
||||
} catch (error) {
|
||||
console.error("Error deleting vector store:", error);
|
||||
toast.fromError("Error deleting vector store: " + error);
|
||||
toast.fromError(t("vectorStores.page.toasts.deleteFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteModalOpen(false);
|
||||
|
|
@ -143,35 +145,42 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
<div className="mx-4">
|
||||
<div className="gap-2 p-8 w-full mt-2">
|
||||
<div className="flex justify-between mt-2 w-full items-center mb-4">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">Vector Store Management</h1>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">{t("vectorStores.page.title")}</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
{lastRefreshed && <p className="text-sm text-muted-foreground">Last Refreshed: {lastRefreshed}</p>}
|
||||
<Button variant="outline" size="icon-sm" aria-label="Refresh" onClick={handleRefreshClick}>
|
||||
{lastRefreshed && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("vectorStores.page.lastRefreshed", { time: lastRefreshed })}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={t("vectorStores.page.refresh")}
|
||||
onClick={handleRefreshClick}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
You can use vector stores to store and retrieve LLM embeddings.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t("vectorStores.page.description")}</p>
|
||||
|
||||
<Tabs defaultValue={defaultTab} onValueChange={onTabChange}>
|
||||
<TabsList variant="line" className="mb-6 h-auto w-full justify-start rounded-none p-0">
|
||||
{canCreateVectorStores && (
|
||||
<TabsTrigger value="create" className="flex-none rounded-none px-4 py-2">
|
||||
Create Vector Store
|
||||
{t("vectorStores.page.tabs.create")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="manage" className="flex-none rounded-none px-4 py-2">
|
||||
Manage Vector Stores
|
||||
{t("vectorStores.page.tabs.manage")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="test" className="flex-none rounded-none px-4 py-2">
|
||||
Test Vector Store
|
||||
{t("vectorStores.page.tabs.test")}
|
||||
</TabsTrigger>
|
||||
{isProxyAdminRole(userRole || "") && (
|
||||
<TabsTrigger value="indexes" className="flex-none rounded-none px-4 py-2">
|
||||
Indexes
|
||||
{t("vectorStores.page.tabs.indexes")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
|
@ -185,7 +194,7 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
<TabsContent keepMounted={hasVisited("manage")} value="manage">
|
||||
{canCreateVectorStores && (
|
||||
<Button className="mb-4" onClick={() => setIsCreateModalVisible(true)}>
|
||||
+ Add Vector Store
|
||||
{t("vectorStores.page.addVectorStore")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
@ -223,10 +232,12 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID
|
|||
{/* Delete Confirmation Modal */}
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Vector Store"
|
||||
message="Are you sure you want to delete this vector store? This action cannot be undone."
|
||||
resourceInformationTitle="Vector Store Information"
|
||||
resourceInformation={[{ label: "Vector Store ID", value: vectorStoreToDelete, code: true }]}
|
||||
title={t("vectorStores.page.deleteModal.title")}
|
||||
message={t("vectorStores.page.deleteModal.message")}
|
||||
resourceInformationTitle={t("vectorStores.page.deleteModal.resourceTitle")}
|
||||
resourceInformation={[
|
||||
{ label: t("vectorStores.page.deleteModal.vectorStoreId"), value: vectorStoreToDelete, code: true },
|
||||
]}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
onOk={confirmDelete}
|
||||
confirmLoading={isDeleting}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, CircleHelp, Lock } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
|
|
@ -13,6 +14,7 @@ import { getVectorStoreProviderLogoAndName } from "@/components/vector_store_pro
|
|||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import VectorStoreTester from "./VectorStoreTester";
|
||||
import { toast } from "@/lib/toast";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
|
|
@ -38,10 +40,10 @@ interface VectorStoreInfoViewProps {
|
|||
}
|
||||
|
||||
const vectorStoreEditShape = {
|
||||
vector_store_id: z.string().min(1, "Please input a vector store ID"),
|
||||
vector_store_id: z.string().min(1, { error: () => i18n.t("vectorStores.info.errors.idRequired") }),
|
||||
vector_store_name: z.string().nullish(),
|
||||
vector_store_description: z.string().nullish(),
|
||||
custom_llm_provider: z.string().min(1, "Please select a provider"),
|
||||
custom_llm_provider: z.string().min(1, { error: () => i18n.t("vectorStores.info.errors.providerRequired") }),
|
||||
litellm_credential_name: z.string().nullable().optional(),
|
||||
};
|
||||
|
||||
|
|
@ -79,6 +81,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
is_admin,
|
||||
editVectorStore,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(vectorStoreEditSchema, { defaultValues: EMPTY_VALUES });
|
||||
const [vectorStoreDetails, setVectorStoreDetails] = useState<VectorStore | null>(null);
|
||||
const [loadFailed, setLoadFailed] = useState<boolean>(false);
|
||||
|
|
@ -108,7 +111,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
form.reset(toFormValues(response.vector_store));
|
||||
} catch (error) {
|
||||
console.error("Error fetching vector store details:", error);
|
||||
toast.fromError("Error fetching vector store details: " + error);
|
||||
toast.fromError(t("vectorStores.info.toasts.fetchFailed", { error: String(error) }));
|
||||
setLoadFailed(true);
|
||||
}
|
||||
};
|
||||
|
|
@ -142,7 +145,7 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
try {
|
||||
metadata = metadataString ? JSON.parse(metadataString) : {};
|
||||
} catch (e) {
|
||||
toast.fromError("Invalid JSON in metadata field");
|
||||
toast.fromError(t("vectorStores.info.toasts.invalidMetadata"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -155,12 +158,12 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
};
|
||||
|
||||
await vectorStoreUpdateCall(accessToken, updateData);
|
||||
toast.success("Vector store updated successfully");
|
||||
toast.success(t("vectorStores.info.toasts.updated"));
|
||||
setIsEditing(false);
|
||||
fetchVectorStoreDetails();
|
||||
} catch (error) {
|
||||
console.error("Error updating vector store:", error);
|
||||
toast.fromError("Error updating vector store: " + error);
|
||||
toast.fromError(t("vectorStores.info.toasts.updateFailed", { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -169,18 +172,18 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
<div className="p-4 max-w-full">
|
||||
<Button variant="ghost" className="mb-4" onClick={onClose}>
|
||||
<ArrowLeft />
|
||||
Back to Vector Stores
|
||||
{t("vectorStores.info.back")}
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">Vector store not found</h1>
|
||||
<h1 className="text-xl font-semibold">{t("vectorStores.info.notFound.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Vector store {vectorStoreId} could not be loaded. It may have been deleted.
|
||||
{t("vectorStores.info.notFound.description", { id: vectorStoreId })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!vectorStoreDetails) {
|
||||
return <div>Loading...</div>;
|
||||
return <div>{t("vectorStores.info.loading")}</div>;
|
||||
}
|
||||
|
||||
const canEdit = is_admin && !vectorStoreDetails.is_config;
|
||||
|
|
@ -192,40 +195,41 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
<div>
|
||||
<Button variant="ghost" className="mb-4" onClick={onClose}>
|
||||
<ArrowLeft />
|
||||
Back to Vector Stores
|
||||
{t("vectorStores.info.back")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-semibold">Vector Store ID: {vectorStoreDetails.vector_store_id}</h1>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t("vectorStores.info.heading", { id: vectorStoreDetails.vector_store_id })}
|
||||
</h1>
|
||||
<StatusBadge
|
||||
tone={vectorStoreDetails.is_config ? "neutral" : "info"}
|
||||
label={vectorStoreDetails.is_config ? "Config" : "DB"}
|
||||
label={
|
||||
vectorStoreDetails.is_config ? t("vectorStores.info.source.config") : t("vectorStores.info.source.db")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vectorStoreDetails.vector_store_description || "No description"}
|
||||
{vectorStoreDetails.vector_store_description || t("vectorStores.info.noDescription")}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && !isEditing && <Button onClick={startEditing}>Edit Vector Store</Button>}
|
||||
{canEdit && !isEditing && <Button onClick={startEditing}>{t("vectorStores.info.editButton")}</Button>}
|
||||
</div>
|
||||
|
||||
{vectorStoreDetails.is_config && (
|
||||
<Alert variant="info" className="mb-4">
|
||||
<Lock className="size-4" aria-hidden />
|
||||
<AlertTitle>Read only: defined in the config file</AlertTitle>
|
||||
<AlertDescription>
|
||||
This vector store comes from the proxy config YAML, so it cannot be edited or deleted on the dashboard.
|
||||
Change or remove it in the config file and restart the proxy.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("vectorStores.info.readOnly.title")}</AlertTitle>
|
||||
<AlertDescription>{t("vectorStores.info.readOnly.description")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="details">
|
||||
<TabsList variant="line" className="mb-6 h-auto w-full justify-start rounded-none p-0">
|
||||
<TabsTrigger value="details" className="flex-none rounded-none px-4 py-2">
|
||||
Details
|
||||
{t("vectorStores.info.tabs.details")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="test" className="flex-none rounded-none px-4 py-2">
|
||||
Test Vector Store
|
||||
{t("vectorStores.info.tabs.test")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -233,29 +237,44 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
{showEditForm ? (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Edit Vector Store</h3>
|
||||
<h3 className="text-lg font-medium">{t("vectorStores.info.edit.title")}</h3>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={form.handleSubmit(handleSave)}>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="vector_store_id" label="Vector Store ID">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vector_store_id"
|
||||
label={t("vectorStores.info.edit.vectorStoreId")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} disabled />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="vector_store_name" label="Vector Store Name">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vector_store_name"
|
||||
label={t("vectorStores.info.edit.name")}
|
||||
>
|
||||
{({ ref, value, ...field }) => <Input {...field} ref={ref} value={value ?? ""} />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="vector_store_description" label="Description">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vector_store_description"
|
||||
label={t("vectorStores.info.edit.description")}
|
||||
>
|
||||
{({ ref, value, ...field }) => <Textarea {...field} ref={ref} value={value ?? ""} rows={4} />}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_llm_provider"
|
||||
label={labelWithHint("Provider", "Select the provider for this vector store")}
|
||||
label={labelWithHint(
|
||||
t("vectorStores.info.edit.provider"),
|
||||
t("vectorStores.info.edit.providerHint"),
|
||||
)}
|
||||
>
|
||||
{({
|
||||
id,
|
||||
|
|
@ -297,15 +316,17 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
)}
|
||||
</FormField>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Either select existing credentials OR enter provider credentials below
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("vectorStores.info.edit.credentialsHint")}</p>
|
||||
|
||||
<FormField control={form.control} name="litellm_credential_name" label="Existing Credentials">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="litellm_credential_name"
|
||||
label={t("vectorStores.info.edit.credentials")}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
placeholder="Select or search for existing credentials"
|
||||
placeholder={t("vectorStores.info.edit.credentialsPlaceholder")}
|
||||
options={credentialOptions(credentials)}
|
||||
value={value ?? ""}
|
||||
onValueChange={(selected) =>
|
||||
|
|
@ -317,13 +338,16 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
|
||||
<div className="flex items-center">
|
||||
<div className="grow border-t border-border"></div>
|
||||
<span className="px-4 text-muted-foreground text-sm">OR</span>
|
||||
<span className="px-4 text-muted-foreground text-sm">{t("vectorStores.info.edit.or")}</span>
|
||||
<div className="grow border-t border-border"></div>
|
||||
</div>
|
||||
|
||||
<div role="group" className="flex w-full flex-col gap-3">
|
||||
<span className="flex w-fit gap-2 text-sm leading-snug font-medium">
|
||||
{labelWithHint("Metadata", "JSON metadata for the vector store")}
|
||||
{labelWithHint(
|
||||
t("vectorStores.info.edit.metadata"),
|
||||
t("vectorStores.info.edit.metadataHint"),
|
||||
)}
|
||||
</span>
|
||||
<Textarea
|
||||
rows={4}
|
||||
|
|
@ -336,9 +360,9 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
|
||||
<div className="mt-6 flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
{t("vectorStores.info.edit.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
<Button type="submit">{t("vectorStores.info.edit.save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
|
|
@ -348,26 +372,26 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">Vector Store Details</h3>
|
||||
{canEdit && <Button onClick={startEditing}>Edit Vector Store</Button>}
|
||||
<h3 className="text-lg font-medium">{t("vectorStores.info.details.title")}</h3>
|
||||
{canEdit && <Button onClick={startEditing}>{t("vectorStores.info.editButton")}</Button>}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="font-medium">ID</p>
|
||||
<p className="font-medium">{t("vectorStores.info.details.id")}</p>
|
||||
<p>{vectorStoreDetails.vector_store_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Name</p>
|
||||
<p className="font-medium">{t("vectorStores.info.details.name")}</p>
|
||||
<p>{vectorStoreDetails.vector_store_name || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Description</p>
|
||||
<p className="font-medium">{t("vectorStores.info.details.description")}</p>
|
||||
<p>{vectorStoreDetails.vector_store_description || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Provider</p>
|
||||
<p className="font-medium">{t("vectorStores.info.details.provider")}</p>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
{(() => {
|
||||
const provider = vectorStoreDetails.custom_llm_provider || "bedrock";
|
||||
|
|
@ -383,19 +407,19 @@ const VectorStoreInfoView: React.FC<VectorStoreInfoViewProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Metadata</p>
|
||||
<p className="font-medium">{t("vectorStores.info.details.metadata")}</p>
|
||||
<div className="bg-muted p-3 rounded-sm mt-2 font-mono text-xs overflow-auto max-h-48">
|
||||
<pre>{metadataString}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Created</p>
|
||||
<p className="font-medium">{t("vectorStores.info.details.created")}</p>
|
||||
<p>
|
||||
{vectorStoreDetails.created_at ? new Date(vectorStoreDetails.created_at).toLocaleString() : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Last Updated</p>
|
||||
<p className="font-medium">{t("vectorStores.info.details.lastUpdated")}</p>
|
||||
<p>
|
||||
{vectorStoreDetails.updated_at ? new Date(vectorStoreDetails.updated_at).toLocaleString() : "-"}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { Loader2 } 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";
|
||||
|
|
@ -9,7 +11,10 @@ import { enableClaudeCodePlugin, disableClaudeCodePlugin } from "../networking";
|
|||
import { toast } from "@/lib/toast";
|
||||
import { Plugin } from "./types";
|
||||
|
||||
const STEP_TITLES = ["Select Skills", "Confirm"];
|
||||
const STEP_TITLE_KEYS: readonly ParseKeys[] = [
|
||||
"claudeCodePlugins.makePublic.steps.select",
|
||||
"claudeCodePlugins.makePublic.steps.confirm",
|
||||
];
|
||||
|
||||
interface MakeSkillPublicFormProps {
|
||||
visible: boolean;
|
||||
|
|
@ -26,6 +31,7 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
skillsList,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [selectedSkills, setSelectedSkills] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -38,7 +44,7 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
|
||||
const handleNext = () => {
|
||||
if (selectedSkills.size === 0) {
|
||||
toast.fromError("Please select at least one skill");
|
||||
toast.fromError(t("claudeCodePlugins.makePublic.toasts.selectAtLeastOne"));
|
||||
return;
|
||||
}
|
||||
setCurrentStep(1);
|
||||
|
|
@ -71,7 +77,7 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedSkills.size === 0) {
|
||||
toast.fromError("Please select at least one skill");
|
||||
toast.fromError(t("claudeCodePlugins.makePublic.toasts.selectAtLeastOne"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -91,12 +97,12 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
}),
|
||||
);
|
||||
|
||||
toast.success(`Skill Hub updated — ${selectedSkills.size} skill(s) published`);
|
||||
toast.success(t("claudeCodePlugins.makePublic.toasts.published", { count: selectedSkills.size }));
|
||||
handleClose();
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Error publishing skills:", error);
|
||||
toast.fromError("Failed to update skills. Please try again.");
|
||||
toast.fromError(t("claudeCodePlugins.makePublic.toasts.publishFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -108,7 +114,7 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
const renderStep1 = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Select Skills to Publish</h3>
|
||||
<h3 className="text-lg font-semibold">{t("claudeCodePlugins.makePublic.step1.title")}</h3>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
|
|
@ -116,19 +122,17 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
onCheckedChange={(checked) => handleSelectAll(checked === true)}
|
||||
disabled={skillsList.length === 0}
|
||||
/>
|
||||
Select All ({skillsList.length})
|
||||
{t("claudeCodePlugins.makePublic.step1.selectAll", { total: skillsList.length })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selected skills will be visible to all users in the Skill Hub. Deselected skills will be unpublished.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("claudeCodePlugins.makePublic.step1.description")}</p>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
{skillsList.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No skills registered yet.</p>
|
||||
<p>{t("claudeCodePlugins.makePublic.step1.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
skillsList.map((skill) => (
|
||||
|
|
@ -141,7 +145,9 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium font-mono text-sm break-words">{skill.name}</p>
|
||||
{skill.enabled && <Badge variant="secondary">Public</Badge>}
|
||||
{skill.enabled && (
|
||||
<Badge variant="secondary">{t("claudeCodePlugins.makePublic.step1.public")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{skill.description && (
|
||||
<p className="text-xs text-muted-foreground truncate max-w-sm">{skill.description}</p>
|
||||
|
|
@ -157,7 +163,11 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
{selectedSkills.size > 0 && (
|
||||
<div className="bg-info/10 border border-info/20 rounded-lg p-3">
|
||||
<p className="text-sm text-info">
|
||||
<strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
|
||||
<Trans
|
||||
i18nKey="claudeCodePlugins.makePublic.step1.selectedCount"
|
||||
count={selectedSkills.size}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -166,17 +176,16 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
|
||||
const renderStep2 = () => (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Confirm Publish to Skill Hub</h3>
|
||||
<h3 className="text-lg font-semibold">{t("claudeCodePlugins.makePublic.step2.title")}</h3>
|
||||
|
||||
<div className="bg-warning/10 border border-warning/20 rounded-lg p-4">
|
||||
<p className="text-sm text-warning">
|
||||
<strong>Note:</strong> Published skills will be visible to all users in the Skill Hub tab. Skills not in the
|
||||
list below will be unpublished.
|
||||
<Trans i18nKey="claudeCodePlugins.makePublic.step2.note" components={{ strong: <strong /> }} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium">Skills to be published:</p>
|
||||
<p className="font-medium">{t("claudeCodePlugins.makePublic.step2.listTitle")}</p>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
|
||||
<div className="space-y-2">
|
||||
{Array.from(selectedSkills).map((name) => {
|
||||
|
|
@ -194,7 +203,11 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
|
||||
<div className="bg-info/10 border border-info/20 rounded-lg p-3">
|
||||
<p className="text-sm text-info">
|
||||
Total: <strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
|
||||
<Trans
|
||||
i18nKey="claudeCodePlugins.makePublic.step2.total"
|
||||
count={selectedSkills.size}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -204,12 +217,12 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Publish to Skill Hub</DialogTitle>
|
||||
<DialogTitle>{t("claudeCodePlugins.makePublic.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<ol className="mb-6 flex items-center gap-6">
|
||||
{STEP_TITLES.map((title, index) => (
|
||||
{STEP_TITLE_KEYS.map((title, index) => (
|
||||
<li
|
||||
key={title}
|
||||
className="flex items-center gap-2"
|
||||
|
|
@ -226,7 +239,7 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
{index + 1}
|
||||
</span>
|
||||
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
|
||||
{title}
|
||||
{t(title)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -236,18 +249,20 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button variant="outline" onClick={currentStep === 0 ? handleClose : () => setCurrentStep(0)}>
|
||||
{currentStep === 0 ? "Cancel" : "Previous"}
|
||||
{currentStep === 0
|
||||
? t("claudeCodePlugins.makePublic.buttons.cancel")
|
||||
: t("claudeCodePlugins.makePublic.buttons.previous")}
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
{currentStep === 0 && (
|
||||
<Button onClick={handleNext} disabled={selectedSkills.size === 0}>
|
||||
Next
|
||||
{t("claudeCodePlugins.makePublic.buttons.next")}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
Publish to Hub
|
||||
{t("claudeCodePlugins.makePublic.buttons.publish")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Helper utilities for Claude Code Marketplace
|
||||
*/
|
||||
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { Plugin, PluginSource } from "./types";
|
||||
|
||||
export interface SkillSourcePreview {
|
||||
|
|
@ -103,7 +104,7 @@ const parseGitHubSource = (url: URL, subPath?: string): SkillSourcePreview | nul
|
|||
const repoUrl = `https://github.com/${repoFull}`;
|
||||
const repoPreview: SkillSourcePreview = {
|
||||
parsed: { source: "github", repo: repoFull },
|
||||
label: `GitHub repo — ${repoFull}`,
|
||||
label: i18n.t("claudeCodePlugins.sources.githubRepo", { repo: repoFull }),
|
||||
suggestedName: toKebabCase(repoBase),
|
||||
};
|
||||
|
||||
|
|
@ -121,7 +122,7 @@ const parseGitHubSource = (url: URL, subPath?: string): SkillSourcePreview | nul
|
|||
}
|
||||
return {
|
||||
parsed: { source: "git-subdir", url: repoUrl, path },
|
||||
label: `GitHub subdir — ${repoFull} @ ${path}`,
|
||||
label: i18n.t("claudeCodePlugins.sources.githubSubdir", { repo: repoFull, path }),
|
||||
suggestedName: toKebabCase(lastSegment(path)),
|
||||
};
|
||||
}
|
||||
|
|
@ -137,7 +138,7 @@ const parseGitHubSource = (url: URL, subPath?: string): SkillSourcePreview | nul
|
|||
}
|
||||
return {
|
||||
parsed: { source: "git-subdir", url: repoUrl, path: normalized },
|
||||
label: `GitHub subdir — ${repoFull} @ ${normalized}`,
|
||||
label: i18n.t("claudeCodePlugins.sources.githubSubdir", { repo: repoFull, path: normalized }),
|
||||
suggestedName: toKebabCase(lastSegment(normalized)),
|
||||
};
|
||||
}
|
||||
|
|
@ -158,14 +159,14 @@ const buildGitSourcePreview = (
|
|||
}
|
||||
return {
|
||||
parsed: { source: "git-subdir", url: repoUrl, path: normalized },
|
||||
label: `${kind} subdir — ${repoUrl} @ ${normalized}`,
|
||||
label: i18n.t("claudeCodePlugins.sources.gitSubdir", { kind, url: repoUrl, path: normalized }),
|
||||
suggestedName: toKebabCase(lastSegment(normalized)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
parsed: { source: "url", url: repoUrl },
|
||||
label: `${kind} repo — ${repoUrl}`,
|
||||
label: i18n.t("claudeCodePlugins.sources.gitRepo", { kind, url: repoUrl }),
|
||||
suggestedName: toKebabCase(repoName),
|
||||
};
|
||||
};
|
||||
|
|
@ -205,7 +206,7 @@ const parseSshSource = (raw: string, subPath?: string): SkillSourcePreview | nul
|
|||
|
||||
const parseArchiveSource = (url: URL): SkillSourcePreview => ({
|
||||
parsed: { source: "archive", url: url.href },
|
||||
label: `Zip archive — ${url.host}${url.pathname}`,
|
||||
label: i18n.t("claudeCodePlugins.sources.zipArchive", { location: `${url.host}${url.pathname}` }),
|
||||
suggestedName: toKebabCase(lastSegment(url.pathname).replace(ZIP_ARCHIVE_REGEX, "")),
|
||||
});
|
||||
|
||||
|
|
@ -282,7 +283,7 @@ export const validatePluginName = (name: string): boolean => {
|
|||
*/
|
||||
export const getSourceDisplayText = (source: Plugin["source"]): string => {
|
||||
if (source.source === "github" && source.repo) {
|
||||
return `GitHub: ${source.repo}`;
|
||||
return i18n.t("claudeCodePlugins.sources.githubDisplay", { repo: source.repo });
|
||||
}
|
||||
if (source.source === "git-subdir" && source.url && source.path) {
|
||||
return `${source.url} @ ${source.path}`;
|
||||
|
|
@ -290,7 +291,7 @@ export const getSourceDisplayText = (source: Plugin["source"]): string => {
|
|||
if ((source.source === "url" || source.source === "archive") && source.url) {
|
||||
return source.url;
|
||||
}
|
||||
return "Unknown source";
|
||||
return i18n.t("claudeCodePlugins.sources.unknown");
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { ArrowLeft, Check, Copy, Link2 } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useClaudeCodeMarketplaceRequiresKey } from "@/app/(dashboard)/hooks/claudeCodeMarketplace/useClaudeCodeMarketplaceRequiresKey";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import {
|
||||
|
|
@ -12,12 +13,13 @@ import {
|
|||
import { Plugin } from "./types";
|
||||
|
||||
const SkillSource: React.FC<{ source: Plugin["source"] }> = ({ source }) => {
|
||||
const { t } = useTranslation();
|
||||
const link = getSourceLink(source);
|
||||
const href = link && source.source === "git-subdir" && source.path ? `${link}/tree/main/${source.path}` : link;
|
||||
if (href) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 text-xs text-muted-foreground">Source</div>
|
||||
<div className="mb-1 text-xs text-muted-foreground">{t("claudeCodePlugins.skillDetail.overview.source")}</div>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
|
|
@ -35,7 +37,7 @@ const SkillSource: React.FC<{ source: Plugin["source"] }> = ({ source }) => {
|
|||
}
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 text-xs text-muted-foreground">Source</div>
|
||||
<div className="mb-1 text-xs text-muted-foreground">{t("claudeCodePlugins.skillDetail.overview.source")}</div>
|
||||
<div className="break-all text-[13px] text-foreground">{getSourceDisplayText(source)}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -50,6 +52,7 @@ interface SkillDetailProps {
|
|||
}
|
||||
|
||||
const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const marketplaceRequiresKey = useClaudeCodeMarketplaceRequiresKey();
|
||||
|
|
@ -68,17 +71,32 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
);
|
||||
|
||||
const detailRows = [
|
||||
...(skill.category ? [{ property: "Category", value: skill.category }] : []),
|
||||
...(skill.domain ? [{ property: "Domain", value: skill.domain }] : []),
|
||||
...(skill.namespace ? [{ property: "Namespace", value: skill.namespace }] : []),
|
||||
...(skill.version ? [{ property: "Version", value: skill.version }] : []),
|
||||
...(skill.author?.name ? [{ property: "Author", value: skill.author.name }] : []),
|
||||
...(skill.created_at ? [{ property: "Added", value: new Date(skill.created_at).toLocaleDateString() }] : []),
|
||||
...(skill.category
|
||||
? [{ property: t("claudeCodePlugins.skillDetail.properties.category"), value: skill.category }]
|
||||
: []),
|
||||
...(skill.domain ? [{ property: t("claudeCodePlugins.skillDetail.properties.domain"), value: skill.domain }] : []),
|
||||
...(skill.namespace
|
||||
? [{ property: t("claudeCodePlugins.skillDetail.properties.namespace"), value: skill.namespace }]
|
||||
: []),
|
||||
...(skill.version
|
||||
? [{ property: t("claudeCodePlugins.skillDetail.properties.version"), value: skill.version }]
|
||||
: []),
|
||||
...(skill.author?.name
|
||||
? [{ property: t("claudeCodePlugins.skillDetail.properties.author"), value: skill.author.name }]
|
||||
: []),
|
||||
...(skill.created_at
|
||||
? [
|
||||
{
|
||||
property: t("claudeCodePlugins.skillDetail.properties.added"),
|
||||
value: new Date(skill.created_at).toLocaleDateString(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const tabs = [
|
||||
{ key: "overview", label: "Overview" },
|
||||
{ key: "usage", label: "How to Use" },
|
||||
{ key: "overview", label: t("claudeCodePlugins.skillDetail.tabs.overview") },
|
||||
{ key: "usage", label: t("claudeCodePlugins.skillDetail.tabs.usage") },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -89,7 +107,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
className="mb-6 inline-flex cursor-pointer items-center gap-1.5 text-sm text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
<span>Skills</span>
|
||||
<span>{t("claudeCodePlugins.skillDetail.back")}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
|
|
@ -125,12 +143,18 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
<div className="flex gap-16">
|
||||
{/* Left column */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="m-0 mb-1 text-lg font-normal text-foreground">Skill Details</h2>
|
||||
<p className="m-0 mb-4 text-[13px] text-muted-foreground">Metadata registered with this skill</p>
|
||||
<h2 className="m-0 mb-1 text-lg font-normal text-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.overview.title")}
|
||||
</h2>
|
||||
<p className="m-0 mb-4 text-[13px] text-muted-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.overview.subtitle")}
|
||||
</p>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="w-40 py-3 text-left font-medium text-muted-foreground">Property</th>
|
||||
<th className="w-40 py-3 text-left font-medium text-muted-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.overview.propertyHeader")}
|
||||
</th>
|
||||
<th className="py-3 text-left font-medium text-muted-foreground">{skill.name}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -148,14 +172,18 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
{/* Right sidebar */}
|
||||
<div className="w-60 shrink-0">
|
||||
<div className="mb-6">
|
||||
<div className="mb-1 text-xs text-muted-foreground">Status</div>
|
||||
<div className="mb-1 text-xs text-muted-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.overview.status")}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-xl px-2.5 py-[3px] text-xs font-medium",
|
||||
skill.enabled ? "bg-success/10 text-success" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{skill.enabled ? "Public" : "Draft"}
|
||||
{skill.enabled
|
||||
? t("claudeCodePlugins.skillDetail.overview.public")
|
||||
: t("claudeCodePlugins.skillDetail.overview.draft")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -163,7 +191,9 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
|
||||
{skill.keywords && skill.keywords.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 text-xs text-muted-foreground">Tags</div>
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.overview.tags")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{skill.keywords.map((kw) => (
|
||||
<span
|
||||
|
|
@ -178,7 +208,9 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
)}
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-muted-foreground">Skill ID</div>
|
||||
<div className="mb-1 text-xs text-muted-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.overview.skillId")}
|
||||
</div>
|
||||
<div className="break-all font-mono text-xs text-foreground">{skill.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -188,15 +220,19 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
{/* How to Use tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div className="max-w-[640px]">
|
||||
<h2 className="m-0 mb-2 text-lg font-normal text-foreground">Using this skill</h2>
|
||||
<h2 className="m-0 mb-2 text-lg font-normal text-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.usage.title")}
|
||||
</h2>
|
||||
<p className="m-0 mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Once your proxy is set as a marketplace, enable this skill in Claude Code with one command:
|
||||
{t("claudeCodePlugins.skillDetail.usage.intro")}
|
||||
</p>
|
||||
|
||||
{/* Install command */}
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted px-4 py-2.5">
|
||||
<span className="text-[13px] font-medium text-foreground">Run in Claude Code</span>
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.runInClaudeCode")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(installCommand, "install")}
|
||||
className={cn(
|
||||
|
|
@ -205,7 +241,9 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
)}
|
||||
>
|
||||
{copiedKey === "install" ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copiedKey === "install" ? "Copied" : "Copy"}
|
||||
{copiedKey === "install"
|
||||
? t("claudeCodePlugins.skillDetail.copied")
|
||||
: t("claudeCodePlugins.skillDetail.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="m-0 bg-card px-4 py-3.5 font-mono text-sm text-foreground">{installCommand}</pre>
|
||||
|
|
@ -214,7 +252,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
{/* Shown when the marketplace catalog is stale and the plugin isn't found yet */}
|
||||
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 px-4 py-3">
|
||||
<p className="m-0 mb-2 text-[13px] leading-relaxed text-muted-foreground">
|
||||
If you see "Plugin {skill.name} not found in marketplace", update the catalog first:
|
||||
{t("claudeCodePlugins.skillDetail.usage.staleCatalog", { name: skill.name })}
|
||||
</p>
|
||||
<pre className="m-0 bg-transparent font-mono text-[13px] text-foreground">
|
||||
/plugin marketplace update litellm
|
||||
|
|
@ -222,9 +260,9 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
</div>
|
||||
|
||||
<p className="m-0 text-[13px] leading-relaxed text-muted-foreground">
|
||||
Don't have the marketplace configured yet?{" "}
|
||||
{t("claudeCodePlugins.skillDetail.usage.notConfigured")}{" "}
|
||||
<span onClick={() => setActiveTab("setup")} className="cursor-pointer text-info">
|
||||
See one-time setup →
|
||||
{t("claudeCodePlugins.skillDetail.usage.seeSetup")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -233,36 +271,45 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
{/* Setup tab (linked from usage) */}
|
||||
{activeTab === "setup" && (
|
||||
<div className="max-w-[640px]">
|
||||
<h2 className="m-0 mb-2 text-lg font-normal text-foreground">One-time marketplace setup</h2>
|
||||
<h2 className="m-0 mb-2 text-lg font-normal text-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.setup.title")}
|
||||
</h2>
|
||||
|
||||
{marketplaceRequiresKey ? (
|
||||
<>
|
||||
<div className="mb-4 rounded-lg border border-warning/30 bg-warning/10 px-4 py-3">
|
||||
<p className="m-0 mb-2 text-[13px] leading-relaxed text-muted-foreground">
|
||||
If you added this marketplace before without a key, remove it first in Claude Code. Removing it also
|
||||
uninstalls the plugins you installed from it, so install them again from /plugin after you add the
|
||||
snippet:
|
||||
{t("claudeCodePlugins.skillDetail.setup.removeFirst")}
|
||||
</p>
|
||||
<pre className="m-0 bg-transparent font-mono text-[13px] text-foreground">
|
||||
/plugin marketplace remove litellm
|
||||
</pre>
|
||||
</div>
|
||||
<p className="m-0 mb-3 text-sm leading-relaxed text-muted-foreground">
|
||||
This proxy requires a LiteLLM key to read the marketplace. Add this to{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-px text-[13px]">~/.claude/settings.json</code> and replace{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-px text-[13px]">{MARKETPLACE_KEY_PLACEHOLDER}</code> with
|
||||
your key:
|
||||
<Trans
|
||||
i18nKey="claudeCodePlugins.skillDetail.setup.requiresKey"
|
||||
components={{
|
||||
settings: (
|
||||
<code className="rounded bg-muted px-1.5 py-px text-[13px]">~/.claude/settings.json</code>
|
||||
),
|
||||
placeholder: (
|
||||
<code className="rounded bg-muted px-1.5 py-px text-[13px]">{MARKETPLACE_KEY_PLACEHOLDER}</code>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Option 1: single command — fastest path for most users */}
|
||||
<p className="m-0 mb-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Run this command in Claude Code to register the marketplace:
|
||||
{t("claudeCodePlugins.skillDetail.setup.runCommand")}
|
||||
</p>
|
||||
<div className="mb-6 overflow-hidden rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted px-4 py-2.5">
|
||||
<span className="text-[13px] font-medium text-foreground">Run in Claude Code</span>
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
{t("claudeCodePlugins.skillDetail.runInClaudeCode")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||
|
|
@ -277,7 +324,9 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
)}
|
||||
>
|
||||
{copiedKey === "marketplace-cmd" ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copiedKey === "marketplace-cmd" ? "Copied" : "Copy"}
|
||||
{copiedKey === "marketplace-cmd"
|
||||
? t("claudeCodePlugins.skillDetail.copied")
|
||||
: t("claudeCodePlugins.skillDetail.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="m-0 bg-card px-4 py-3.5 font-mono text-[13px] text-foreground">
|
||||
|
|
@ -288,9 +337,14 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
{/* Option 2: settings.json — for persistent config or managed deployments.
|
||||
extraKnownMarketplaces requires source to be a nested object, not a flat string. */}
|
||||
<p className="m-0 mb-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Or add this to{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-px text-[13px]">~/.claude/settings.json</code> for a
|
||||
persistent configuration:
|
||||
<Trans
|
||||
i18nKey="claudeCodePlugins.skillDetail.setup.orSettings"
|
||||
components={{
|
||||
settings: (
|
||||
<code className="rounded bg-muted px-1.5 py-px text-[13px]">~/.claude/settings.json</code>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -305,7 +359,9 @@ const SkillDetail: React.FC<SkillDetailProps> = ({ skill, onBack }) => {
|
|||
)}
|
||||
>
|
||||
{copiedKey === "settings" ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copiedKey === "settings" ? "Copied" : "Copy"}
|
||||
{copiedKey === "settings"
|
||||
? t("claudeCodePlugins.skillDetail.copied")
|
||||
: t("claudeCodePlugins.skillDetail.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="m-0 bg-card px-4 py-3.5 font-mono text-[13px] text-foreground">{settingsSnippet}</pre>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
const MODEL_SELECT_DEBOUNCE_MS = 500;
|
||||
|
||||
|
|
@ -22,14 +24,15 @@ interface ModelSelectorProps {
|
|||
const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
accessToken,
|
||||
value,
|
||||
placeholder = "Select a Model",
|
||||
placeholder = i18n.t("modelSelector.placeholder"),
|
||||
onChange,
|
||||
disabled = false,
|
||||
style,
|
||||
className,
|
||||
showLabel = true,
|
||||
labelText = "Select Model",
|
||||
labelText = i18n.t("modelSelector.label"),
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedModel, setSelectedModel] = useState<string | null>(value ?? null);
|
||||
const [showCustomModelInput, setShowCustomModelInput] = useState<boolean>(false);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
|
|
@ -91,7 +94,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
value: model_group,
|
||||
label: model_group,
|
||||
})),
|
||||
{ value: "custom", label: "Enter custom model" },
|
||||
{ value: "custom", label: t("modelSelector.customOption") },
|
||||
]}
|
||||
value={selectedModel}
|
||||
placeholder={placeholder}
|
||||
|
|
@ -102,7 +105,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
{showCustomModelInput && (
|
||||
<Input
|
||||
className="mt-2"
|
||||
placeholder="Enter custom model name"
|
||||
placeholder={t("modelSelector.customPlaceholder")}
|
||||
onChange={(e) => debouncedSelect(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import { getProviderLogoAndName, Providers, providerLogoMap } from "@/components/provider_info_helpers";
|
||||
import awsLogo from "../../public/assets/logos/aws.svg";
|
||||
import milvusLogo from "../../public/assets/logos/milvus.svg";
|
||||
|
|
@ -51,8 +52,8 @@ export const vectorStoreProviderLogoMap: Record<string, string> = {
|
|||
// Define field types for provider-specific configurations
|
||||
export interface VectorStoreFieldConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
label: ParseKeys;
|
||||
tooltip: ParseKeys;
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
type?: "text" | "password" | "select";
|
||||
|
|
@ -66,16 +67,16 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
pg_vector: [
|
||||
{
|
||||
name: "api_base",
|
||||
label: "API Base",
|
||||
tooltip: "Enter the base URL of your deployed litellm-pgvector server (e.g., http://your-server:8000)",
|
||||
label: "vectorStores.providerFields.labels.apiBase",
|
||||
tooltip: "vectorStores.providerFields.tooltips.pgVector.apiBase",
|
||||
placeholder: "http://your-deployed-server:8000",
|
||||
required: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "api_key",
|
||||
label: "API Key",
|
||||
tooltip: "Enter the API key from your deployed litellm-pgvector server",
|
||||
label: "vectorStores.providerFields.labels.apiKey",
|
||||
tooltip: "vectorStores.providerFields.tooltips.pgVector.apiKey",
|
||||
placeholder: "your-deployed-api-key",
|
||||
required: true,
|
||||
type: "password",
|
||||
|
|
@ -85,16 +86,16 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
"vertex_ai/search_api": [
|
||||
{
|
||||
name: "vertex_project",
|
||||
label: "Vertex Project",
|
||||
tooltip: "Google Cloud project ID that hosts the Vertex AI Search data store.",
|
||||
label: "vectorStores.providerFields.labels.vertexProject",
|
||||
tooltip: "vectorStores.providerFields.tooltips.vertexAiSearch.project",
|
||||
placeholder: "my-gcp-project-id",
|
||||
required: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "vertex_location",
|
||||
label: "Vertex Location",
|
||||
tooltip: "Vertex AI Search data store location. Must be one of global, us, or eu.",
|
||||
label: "vectorStores.providerFields.labels.vertexLocation",
|
||||
tooltip: "vectorStores.providerFields.tooltips.vertexAiSearch.location",
|
||||
required: true,
|
||||
type: "select",
|
||||
options: [
|
||||
|
|
@ -106,17 +107,16 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
},
|
||||
{
|
||||
name: "vertex_collection_id",
|
||||
label: "Collection ID (optional)",
|
||||
tooltip: "Discovery Engine collection ID. Leave blank to use the default collection.",
|
||||
label: "vectorStores.providerFields.labels.collectionIdOptional",
|
||||
tooltip: "vectorStores.providerFields.tooltips.vertexAiSearch.collectionId",
|
||||
placeholder: "e.g. my-custom-collection",
|
||||
required: false,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "vertex_engine_id",
|
||||
label: "Engine ID (optional)",
|
||||
tooltip:
|
||||
"Search app (engine) ID. Required for website, healthcare, and connector-based data stores (Workspace, Slack, Jira, etc.) because these sources route search through an engine. Leave blank to query the data store directly.",
|
||||
label: "vectorStores.providerFields.labels.engineIdOptional",
|
||||
tooltip: "vectorStores.providerFields.tooltips.vertexAiSearch.engineId",
|
||||
placeholder: "e.g. my-search-app_1234567890",
|
||||
required: false,
|
||||
type: "text",
|
||||
|
|
@ -125,8 +125,8 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
openai: [
|
||||
{
|
||||
name: "api_key",
|
||||
label: "API Key",
|
||||
tooltip: "Enter your OpenAI API key",
|
||||
label: "vectorStores.providerFields.labels.apiKey",
|
||||
tooltip: "vectorStores.providerFields.tooltips.openai.apiKey",
|
||||
placeholder: "sk-...",
|
||||
required: true,
|
||||
type: "password",
|
||||
|
|
@ -135,16 +135,16 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
azure: [
|
||||
{
|
||||
name: "api_key",
|
||||
label: "API Key",
|
||||
tooltip: "Enter your Azure OpenAI API key",
|
||||
label: "vectorStores.providerFields.labels.apiKey",
|
||||
tooltip: "vectorStores.providerFields.tooltips.azure.apiKey",
|
||||
placeholder: "your-azure-api-key",
|
||||
required: true,
|
||||
type: "password",
|
||||
},
|
||||
{
|
||||
name: "api_base",
|
||||
label: "API Base",
|
||||
tooltip: "Enter your Azure OpenAI endpoint (e.g., https://your-resource.openai.azure.com/)",
|
||||
label: "vectorStores.providerFields.labels.apiBase",
|
||||
tooltip: "vectorStores.providerFields.tooltips.azure.apiBase",
|
||||
placeholder: "https://your-resource.openai.azure.com/",
|
||||
required: true,
|
||||
type: "text",
|
||||
|
|
@ -153,25 +153,24 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
milvus: [
|
||||
{
|
||||
name: "api_key",
|
||||
label: "API Key",
|
||||
tooltip:
|
||||
"To obtain a token, you should use a colon (:) to concatenate the username and password that you use to access your Milvus instance (e.g., username:password)",
|
||||
label: "vectorStores.providerFields.labels.apiKey",
|
||||
tooltip: "vectorStores.providerFields.tooltips.milvus.apiKey",
|
||||
placeholder: "username:password or api key",
|
||||
required: true,
|
||||
type: "password",
|
||||
},
|
||||
{
|
||||
name: "api_base",
|
||||
label: "API Base",
|
||||
tooltip: "Enter your Milvus endpoint (e.g., https://your-milvus-endpoint.com/)",
|
||||
label: "vectorStores.providerFields.labels.apiBase",
|
||||
tooltip: "vectorStores.providerFields.tooltips.milvus.apiBase",
|
||||
placeholder: "https://your-milvus-endpoint.com/",
|
||||
required: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "embedding_model",
|
||||
label: "Embedding Model",
|
||||
tooltip: "Select the embedding model to use",
|
||||
label: "vectorStores.providerFields.labels.embeddingModel",
|
||||
tooltip: "vectorStores.providerFields.tooltips.milvus.embeddingModel",
|
||||
placeholder: "text-embedding-3-small",
|
||||
required: true,
|
||||
type: "select",
|
||||
|
|
@ -180,50 +179,48 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
mongodb: [
|
||||
{
|
||||
name: "api_base",
|
||||
label: "Sidecar URL",
|
||||
tooltip: "Use HTTPS for a remote sidecar, or HTTP with a loopback IP for a sidecar on the same host or Pod",
|
||||
label: "vectorStores.providerFields.labels.sidecarUrl",
|
||||
tooltip: "vectorStores.providerFields.tooltips.mongodb.apiBase",
|
||||
placeholder: "http://127.0.0.1:8080",
|
||||
required: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "api_key",
|
||||
label: "Sidecar API Key",
|
||||
tooltip: "The MONGODB_SIDECAR_API_KEY configured in your MongoDB sidecar",
|
||||
label: "vectorStores.providerFields.labels.sidecarApiKey",
|
||||
tooltip: "vectorStores.providerFields.tooltips.mongodb.apiKey",
|
||||
placeholder: "Enter sidecar API key",
|
||||
required: true,
|
||||
type: "password",
|
||||
},
|
||||
{
|
||||
name: "mongodb_database",
|
||||
label: "Database",
|
||||
tooltip: "The MongoDB database holding the collection you want to search",
|
||||
label: "vectorStores.providerFields.labels.database",
|
||||
tooltip: "vectorStores.providerFields.tooltips.mongodb.database",
|
||||
placeholder: "sample_mflix",
|
||||
required: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "mongodb_collection",
|
||||
label: "Collection",
|
||||
tooltip: "The collection your MongoDB Vector Search index was built on",
|
||||
label: "vectorStores.providerFields.labels.collection",
|
||||
tooltip: "vectorStores.providerFields.tooltips.mongodb.collection",
|
||||
placeholder: "embedded_movies",
|
||||
required: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "embedding_model",
|
||||
label: "Embedding Model",
|
||||
tooltip:
|
||||
"The embedding model on this proxy that created the vectors already stored in your collection. LiteLLM embeds every search query with it, so it must be the same model. A different model of the same size will not error, it will just return wrong results. Add it under Models first if it is not listed",
|
||||
label: "vectorStores.providerFields.labels.embeddingModel",
|
||||
tooltip: "vectorStores.providerFields.tooltips.mongodb.embeddingModel",
|
||||
placeholder: "text-embedding-3-small",
|
||||
required: true,
|
||||
type: "select",
|
||||
},
|
||||
{
|
||||
name: "mongodb_embedding_field",
|
||||
label: "Vector Field Name",
|
||||
tooltip:
|
||||
"The field in each document that holds its embedding. It must match the path your MongoDB Vector Search index was created on (default: embedding)",
|
||||
label: "vectorStores.providerFields.labels.vectorFieldName",
|
||||
tooltip: "vectorStores.providerFields.tooltips.mongodb.embeddingField",
|
||||
placeholder: "embedding",
|
||||
required: false,
|
||||
type: "text",
|
||||
|
|
@ -231,9 +228,8 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
},
|
||||
{
|
||||
name: "mongodb_text_field",
|
||||
label: "Text Field",
|
||||
tooltip:
|
||||
"The field in each document that holds its readable text. LiteLLM returns this text in search results, and it accepts a dotted path such as metadata.body (default: text)",
|
||||
label: "vectorStores.providerFields.labels.textField",
|
||||
tooltip: "vectorStores.providerFields.tooltips.mongodb.textField",
|
||||
placeholder: "text",
|
||||
required: false,
|
||||
type: "text",
|
||||
|
|
@ -241,9 +237,8 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
},
|
||||
{
|
||||
name: "mongodb_num_candidates",
|
||||
label: "Candidates Considered",
|
||||
tooltip:
|
||||
"How many nearest neighbours MongoDB examines before returning the top results. Higher is more accurate and slower. Leave blank to let LiteLLM scale it with the requested result count",
|
||||
label: "vectorStores.providerFields.labels.candidatesConsidered",
|
||||
tooltip: "vectorStores.providerFields.tooltips.mongodb.numCandidates",
|
||||
placeholder: "100",
|
||||
required: false,
|
||||
type: "text",
|
||||
|
|
@ -252,16 +247,16 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
valkey: [
|
||||
{
|
||||
name: "valkey_host",
|
||||
label: "Valkey Host",
|
||||
tooltip: "Hostname or IP of your Valkey server, without redis:// or a port (e.g. my-valkey.example.com)",
|
||||
label: "vectorStores.providerFields.labels.valkeyHost",
|
||||
tooltip: "vectorStores.providerFields.tooltips.valkey.host",
|
||||
placeholder: "my-valkey.example.com",
|
||||
required: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "valkey_port",
|
||||
label: "Valkey Port",
|
||||
tooltip: "Port your Valkey server listens on. Leave as 6379 unless you changed it",
|
||||
label: "vectorStores.providerFields.labels.valkeyPort",
|
||||
tooltip: "vectorStores.providerFields.tooltips.valkey.port",
|
||||
placeholder: "6379",
|
||||
required: false,
|
||||
type: "text",
|
||||
|
|
@ -269,16 +264,15 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
},
|
||||
{
|
||||
name: "valkey_password",
|
||||
label: "Valkey Password",
|
||||
tooltip: "Password used to log in to your Valkey server. Leave blank if it has no password",
|
||||
label: "vectorStores.providerFields.labels.valkeyPassword",
|
||||
tooltip: "vectorStores.providerFields.tooltips.valkey.password",
|
||||
required: false,
|
||||
type: "password",
|
||||
},
|
||||
{
|
||||
name: "valkey_ssl",
|
||||
label: "Use TLS",
|
||||
tooltip:
|
||||
"Set to true if your Valkey server requires an encrypted (TLS) connection, for example AWS ElastiCache with in-transit encryption turned on",
|
||||
label: "vectorStores.providerFields.labels.useTls",
|
||||
tooltip: "vectorStores.providerFields.tooltips.valkey.ssl",
|
||||
required: false,
|
||||
type: "select",
|
||||
options: [
|
||||
|
|
@ -289,18 +283,16 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
},
|
||||
{
|
||||
name: "embedding_model",
|
||||
label: "Embedding Model",
|
||||
tooltip:
|
||||
"The embedding model on this proxy that was used to create the embeddings already stored in your Valkey index. LiteLLM uses it to embed each search query, so it must be the same model or results will be wrong. Add it under Models first if it is not listed",
|
||||
label: "vectorStores.providerFields.labels.embeddingModel",
|
||||
tooltip: "vectorStores.providerFields.tooltips.valkey.embeddingModel",
|
||||
placeholder: "text-embedding-3-small",
|
||||
required: true,
|
||||
type: "select",
|
||||
},
|
||||
{
|
||||
name: "valkey_text_field",
|
||||
label: "Text Field",
|
||||
tooltip:
|
||||
"The field in each stored document that holds its readable text. LiteLLM returns this text in search results. Must match how your documents were stored (default: text)",
|
||||
label: "vectorStores.providerFields.labels.textField",
|
||||
tooltip: "vectorStores.providerFields.tooltips.valkey.textField",
|
||||
placeholder: "text",
|
||||
required: false,
|
||||
type: "text",
|
||||
|
|
@ -308,9 +300,8 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
},
|
||||
{
|
||||
name: "valkey_embedding_field",
|
||||
label: "Vector Field Name",
|
||||
tooltip:
|
||||
"The field in each stored document that holds its embedding. LiteLLM searches against this field, so it must match the field your index was created on (default: embedding)",
|
||||
label: "vectorStores.providerFields.labels.vectorFieldName",
|
||||
tooltip: "vectorStores.providerFields.tooltips.valkey.embeddingField",
|
||||
placeholder: "embedding",
|
||||
required: false,
|
||||
type: "text",
|
||||
|
|
@ -320,36 +311,32 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
opensearch: [
|
||||
{
|
||||
name: "api_base",
|
||||
label: "OpenSearch Endpoint",
|
||||
tooltip:
|
||||
"The endpoint of your OpenSearch Serverless collection or OpenSearch Service domain, starting with https://",
|
||||
label: "vectorStores.providerFields.labels.opensearchEndpoint",
|
||||
tooltip: "vectorStores.providerFields.tooltips.opensearch.apiBase",
|
||||
placeholder: "https://abc123.us-east-1.aoss.amazonaws.com",
|
||||
required: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "aws_region_name",
|
||||
label: "AWS Region (optional)",
|
||||
tooltip:
|
||||
"The AWS region of the collection or domain. Leave blank to use the region in the endpoint, and set it when the endpoint is a custom domain",
|
||||
label: "vectorStores.providerFields.labels.awsRegionOptional",
|
||||
tooltip: "vectorStores.providerFields.tooltips.opensearch.region",
|
||||
placeholder: "us-east-1",
|
||||
required: false,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "embedding_model",
|
||||
label: "Embedding Model",
|
||||
tooltip:
|
||||
"The embedding model on this proxy that created the vectors already stored in your index. LiteLLM embeds every search query with it, so it must be the same model. Add it under Models first if it is not listed",
|
||||
label: "vectorStores.providerFields.labels.embeddingModel",
|
||||
tooltip: "vectorStores.providerFields.tooltips.opensearch.embeddingModel",
|
||||
placeholder: "text-embedding-3-small",
|
||||
required: true,
|
||||
type: "select",
|
||||
},
|
||||
{
|
||||
name: "opensearch_embedding_field",
|
||||
label: "Vector Field Name",
|
||||
tooltip:
|
||||
"The top-level knn_vector field in your index mapping that holds each document's embedding (default: embedding)",
|
||||
label: "vectorStores.providerFields.labels.vectorFieldName",
|
||||
tooltip: "vectorStores.providerFields.tooltips.opensearch.embeddingField",
|
||||
placeholder: "embedding",
|
||||
required: false,
|
||||
type: "text",
|
||||
|
|
@ -357,9 +344,8 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
},
|
||||
{
|
||||
name: "opensearch_text_field",
|
||||
label: "Text Field",
|
||||
tooltip:
|
||||
"The top-level field in each document that holds its readable text. LiteLLM returns this text in search results (default: text)",
|
||||
label: "vectorStores.providerFields.labels.textField",
|
||||
tooltip: "vectorStores.providerFields.tooltips.opensearch.textField",
|
||||
placeholder: "text",
|
||||
required: false,
|
||||
type: "text",
|
||||
|
|
@ -367,9 +353,8 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
},
|
||||
{
|
||||
name: "opensearch_index_name",
|
||||
label: "Index Name (optional)",
|
||||
tooltip:
|
||||
"The OpenSearch index to search. Leave blank to use the Vector Store ID, and set it to register same-named indexes from two endpoints under different IDs",
|
||||
label: "vectorStores.providerFields.labels.indexNameOptional",
|
||||
tooltip: "vectorStores.providerFields.tooltips.opensearch.indexName",
|
||||
placeholder: "my-knn-index",
|
||||
required: false,
|
||||
type: "text",
|
||||
|
|
@ -378,32 +363,32 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
|
|||
s3_vectors: [
|
||||
{
|
||||
name: "vector_bucket_name",
|
||||
label: "Vector Bucket Name",
|
||||
tooltip: "S3 bucket name for vector storage (will be auto-created if it doesn't exist)",
|
||||
label: "vectorStores.providerFields.labels.vectorBucketName",
|
||||
tooltip: "vectorStores.providerFields.tooltips.s3Vectors.bucketName",
|
||||
placeholder: "my-vector-bucket",
|
||||
required: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "index_name",
|
||||
label: "Index Name",
|
||||
tooltip: "Name for the vector index (optional, will be auto-generated if not provided)",
|
||||
label: "vectorStores.providerFields.labels.indexName",
|
||||
tooltip: "vectorStores.providerFields.tooltips.s3Vectors.indexName",
|
||||
placeholder: "my-vector-index",
|
||||
required: false,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "aws_region_name",
|
||||
label: "AWS Region",
|
||||
tooltip: "AWS region where the S3 bucket is located (e.g., us-west-2)",
|
||||
label: "vectorStores.providerFields.labels.awsRegion",
|
||||
tooltip: "vectorStores.providerFields.tooltips.s3Vectors.region",
|
||||
placeholder: "us-west-2",
|
||||
required: true,
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "embedding_model",
|
||||
label: "Embedding Model",
|
||||
tooltip: "Select the embedding model to use for vector generation",
|
||||
label: "vectorStores.providerFields.labels.embeddingModel",
|
||||
tooltip: "vectorStores.providerFields.tooltips.s3Vectors.embeddingModel",
|
||||
placeholder: "text-embedding-3-small",
|
||||
required: true,
|
||||
type: "select",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue