feat(ui): i18n for pipeline flow builder

This commit is contained in:
吹雪 2026-06-11 00:11:39 +09:00
parent 1505d4477d
commit aa883b580e
3 changed files with 444 additions and 219 deletions

View file

@ -1,4 +1,6 @@
import React, { useState } from "react";
import React, { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Select, Typography, Spin } from "antd";
import MessageManager from "@/components/molecules/message_manager";
import { Button, TextInput } from "@tremor/react";
@ -30,19 +32,19 @@ function getPromptsForTestSource(source: string): CompliancePrompt[] {
const { Text } = Typography;
const ACTION_OPTIONS = [
{ label: "Next Step", value: "next" },
{ label: "Allow", value: "allow" },
{ label: "Block", value: "block" },
{ label: "Custom Response", value: "modify_response" },
const getActionOptions = (t: TFunction) => [
{ label: t("policies.pipelineFlowBuilder.actionNextStep"), value: "next" },
{ label: t("policies.pipelineFlowBuilder.actionAllow"), value: "allow" },
{ label: t("policies.pipelineFlowBuilder.actionBlock"), value: "block" },
{ label: t("policies.pipelineFlowBuilder.actionCustomResponse"), value: "modify_response" },
];
const ACTION_LABELS: Record<string, string> = {
allow: "Allow",
block: "Block",
next: "Next Step",
modify_response: "Custom Response",
};
const getActionLabels = (t: TFunction): Record<string, string> => ({
allow: t("policies.pipelineFlowBuilder.actionAllow"),
block: t("policies.pipelineFlowBuilder.actionBlock"),
next: t("policies.pipelineFlowBuilder.actionNextStep"),
modify_response: t("policies.pipelineFlowBuilder.actionCustomResponse"),
});
function createDefaultStep(): PipelineStep {
return {
@ -210,37 +212,40 @@ interface ConnectorProps {
onInsert: () => void;
}
const Connector: React.FC<ConnectorProps> = ({ onInsert }) => (
<div className="flex flex-col items-center" style={{ height: 56 }}>
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
<button
onClick={onInsert}
className="flex items-center justify-center"
style={{
width: 24,
height: 24,
borderRadius: "50%",
border: "1px solid #d1d5db",
backgroundColor: "#fff",
cursor: "pointer",
zIndex: 1,
transition: "all 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#6366f1";
e.currentTarget.style.backgroundColor = "#eef2ff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#d1d5db";
e.currentTarget.style.backgroundColor = "#fff";
}}
title="Insert step"
>
<PlusIcon style={{ width: 12, height: 12, color: "#9ca3af" }} />
</button>
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
</div>
);
const Connector: React.FC<ConnectorProps> = ({ onInsert }) => {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center" style={{ height: 56 }}>
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
<button
onClick={onInsert}
className="flex items-center justify-center"
style={{
width: 24,
height: 24,
borderRadius: "50%",
border: "1px solid #d1d5db",
backgroundColor: "#fff",
cursor: "pointer",
zIndex: 1,
transition: "all 0.15s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "#6366f1";
e.currentTarget.style.backgroundColor = "#eef2ff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "#d1d5db";
e.currentTarget.style.backgroundColor = "#fff";
}}
title={t("policies.pipelineFlowBuilder.insertStep")}
>
<PlusIcon style={{ width: 12, height: 12, color: "#9ca3af" }} />
</button>
<div style={{ width: 1, flex: 1, backgroundColor: "#d1d5db" }} />
</div>
);
};
// ─────────────────────────────────────────────────────────────────────────────
// Step Card (editable)
@ -263,6 +268,8 @@ const StepCard: React.FC<StepCardProps> = ({
onDelete,
availableGuardrails,
}) => {
const { t } = useTranslation();
const actionOptions = useMemo(() => getActionOptions(t), [t]);
const guardrailOptions = availableGuardrails.map((g) => ({
label: g.guardrail_name || g.guardrail_id,
value: g.guardrail_name || g.guardrail_id,
@ -292,11 +299,13 @@ const StepCard: React.FC<StepCardProps> = ({
letterSpacing: "0.06em",
}}
>
GUARDRAIL
{t("policies.pipelineFlowBuilder.guardrailLabel")}
</span>
</div>
<div className="flex items-center gap-2">
<span style={{ fontSize: 13, color: "#9ca3af" }}>Step {stepIndex + 1}</span>
<span style={{ fontSize: 13, color: "#9ca3af" }}>
{t("policies.pipelineFlowBuilder.stepN", { n: stepIndex + 1 })}
</span>
<button
onClick={onDelete}
disabled={totalSteps <= 1}
@ -309,7 +318,7 @@ const StepCard: React.FC<StepCardProps> = ({
display: "flex",
alignItems: "center",
}}
title="Delete step"
title={t("policies.pipelineFlowBuilder.deleteStep")}
>
<DotsVerticalIcon style={{ width: 16, height: 16, color: "#9ca3af" }} />
</button>
@ -319,12 +328,12 @@ const StepCard: React.FC<StepCardProps> = ({
{/* Guardrail selector */}
<div style={{ padding: "12px 20px 16px 20px" }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Guardrail
{t("policies.pipelineFlowBuilder.guardrailLabel")}
</label>
<Select
showSearch
style={{ width: "100%" }}
placeholder="Select a guardrail"
placeholder={t("policies.pipelineFlowBuilder.selectGuardrailPlaceholder")}
value={step.guardrail || undefined}
onChange={(value) => onChange({ guardrail: value })}
options={guardrailOptions}
@ -336,24 +345,26 @@ const StepCard: React.FC<StepCardProps> = ({
<div style={{ borderTop: "1px solid #f0f0f0", padding: "14px 20px" }}>
<div className="flex items-center gap-2" style={{ marginBottom: 8 }}>
<PassIcon />
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>ON PASS</span>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>
{t("policies.pipelineFlowBuilder.onPass")}
</span>
</div>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Action
{t("policies.pipelineFlowBuilder.actionLabel")}
</label>
<Select
style={{ width: "100%" }}
value={step.on_pass}
onChange={(value) => onChange({ on_pass: value as PipelineStep["on_pass"] })}
options={ACTION_OPTIONS}
options={actionOptions}
/>
{step.on_pass === "modify_response" && (
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Custom Response Message
{t("policies.pipelineFlowBuilder.customResponseMessage")}
</label>
<TextInput
placeholder="Enter custom response..."
placeholder={t("policies.pipelineFlowBuilder.enterCustomResponsePlaceholder")}
value={step.modify_response_message || ""}
onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
/>
@ -365,24 +376,26 @@ const StepCard: React.FC<StepCardProps> = ({
<div style={{ borderTop: "1px solid #f0f0f0", padding: "14px 20px" }}>
<div className="flex items-center gap-2" style={{ marginBottom: 8 }}>
<FailIcon />
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>ON FAIL</span>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>
{t("policies.pipelineFlowBuilder.onFail")}
</span>
</div>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Action
{t("policies.pipelineFlowBuilder.actionLabel")}
</label>
<Select
style={{ width: "100%" }}
value={step.on_fail}
onChange={(value) => onChange({ on_fail: value as PipelineStep["on_fail"] })}
options={ACTION_OPTIONS}
options={actionOptions}
/>
{step.on_fail === "modify_response" && (
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Custom Response Message
{t("policies.pipelineFlowBuilder.customResponseMessage")}
</label>
<TextInput
placeholder="Enter custom response..."
placeholder={t("policies.pipelineFlowBuilder.enterCustomResponsePlaceholder")}
value={step.modify_response_message || ""}
onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
/>
@ -394,14 +407,16 @@ const StepCard: React.FC<StepCardProps> = ({
<div style={{ borderTop: "1px solid #f0f0f0", padding: "14px 20px" }}>
<div className="flex items-center gap-2" style={{ marginBottom: 8 }}>
<ApiFailureIcon />
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>ON API FAILURE</span>
<span style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>
{t("policies.pipelineFlowBuilder.onApiFailure")}
</span>
</div>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Action
{t("policies.pipelineFlowBuilder.actionLabel")}
</label>
<Select
style={{ width: "100%" }}
placeholder="Same as ON FAIL"
placeholder={t("policies.pipelineFlowBuilder.sameAsOnFailPlaceholder")}
allowClear
value={step.on_error ?? undefined}
onChange={(value) =>
@ -409,15 +424,15 @@ const StepCard: React.FC<StepCardProps> = ({
on_error: value === undefined || value === null ? undefined : (value as PipelineStep["on_error"]),
})
}
options={ACTION_OPTIONS}
options={actionOptions}
/>
{step.on_error === "modify_response" && step.on_fail !== "modify_response" && (
<div style={{ marginTop: 8 }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Custom Response Message
{t("policies.pipelineFlowBuilder.customResponseMessage")}
</label>
<TextInput
placeholder="Enter custom response..."
placeholder={t("policies.pipelineFlowBuilder.enterCustomResponsePlaceholder")}
value={step.modify_response_message || ""}
onChange={(e) => onChange({ modify_response_message: e.target.value || null })}
/>
@ -439,6 +454,7 @@ interface PipelineFlowBuilderProps {
}
const PipelineFlowBuilder: React.FC<PipelineFlowBuilderProps> = ({ pipeline, onChange, availableGuardrails }) => {
const { t } = useTranslation();
const handleInsertStep = (atIndex: number) => {
onChange({ ...pipeline, steps: insertStep(pipeline.steps, atIndex) });
};
@ -481,12 +497,12 @@ const PipelineFlowBuilder: React.FC<PipelineFlowBuilderProps> = ({ pipeline, onC
marginBottom: 2,
}}
>
TRIGGER
{t("policies.pipelineFlowBuilder.triggerLabel")}
</span>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827", display: "block" }}>
Incoming LLM Request
{t("policies.pipelineFlowBuilder.incomingLlmRequest")}
</span>
<span style={{ fontSize: 13, color: "#9ca3af" }}>This flow runs when a request matches this policy</span>
<span style={{ fontSize: 13, color: "#9ca3af" }}>{t("policies.pipelineFlowBuilder.triggerDesc")}</span>
</div>
</div>
</div>
@ -559,10 +575,14 @@ const PipelineFlowBuilder: React.FC<PipelineFlowBuilderProps> = ({ pipeline, onC
marginBottom: 2,
}}
>
END
{t("policies.pipelineFlowBuilder.endLabel")}
</span>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827", display: "block" }}>
{t("policies.pipelineFlowBuilder.continueToLlm")}
</span>
<span style={{ fontSize: 13, color: "#9ca3af" }}>
{t("policies.pipelineFlowBuilder.requestProceedsToModel")}
</span>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827", display: "block" }}>Continue to LLM</span>
<span style={{ fontSize: 13, color: "#9ca3af" }}>Request proceeds to the model</span>
</div>
</div>
</div>
@ -578,102 +598,116 @@ interface PipelineInfoDisplayProps {
pipeline: GuardrailPipeline;
}
export const PipelineInfoDisplay: React.FC<PipelineInfoDisplayProps> = ({ pipeline }) => (
<div className="flex flex-col items-center" style={{ padding: "16px 0" }}>
{/* Trigger */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 20px",
backgroundColor: "#fff",
maxWidth: 720,
width: "100%",
}}
>
<div className="flex items-center gap-3">
<PlayIcon />
<div>
<span
style={{
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
color: "#6b7280",
letterSpacing: "0.06em",
display: "block",
marginBottom: 2,
}}
>
TRIGGER
</span>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>Incoming LLM Request</span>
export const PipelineInfoDisplay: React.FC<PipelineInfoDisplayProps> = ({ pipeline }) => {
const { t } = useTranslation();
const actionLabels = useMemo(() => getActionLabels(t), [t]);
return (
<div className="flex flex-col items-center" style={{ padding: "16px 0" }}>
{/* Trigger */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 20px",
backgroundColor: "#fff",
maxWidth: 720,
width: "100%",
}}
>
<div className="flex items-center gap-3">
<PlayIcon />
<div>
<span
style={{
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
color: "#6b7280",
letterSpacing: "0.06em",
display: "block",
marginBottom: 2,
}}
>
{t("policies.pipelineFlowBuilder.triggerLabel")}
</span>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>
{t("policies.pipelineFlowBuilder.incomingLlmRequest")}
</span>
</div>
</div>
</div>
</div>
{/* Steps */}
{pipeline.steps.map((step, index) => (
<React.Fragment key={index}>
{/* Connector */}
<div style={{ width: 1, height: 32, backgroundColor: "#d1d5db" }} />
{/* Steps */}
{pipeline.steps.map((step, index) => (
<React.Fragment key={index}>
{/* Connector */}
<div style={{ width: 1, height: 32, backgroundColor: "#d1d5db" }} />
{/* Step card */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 20px",
backgroundColor: "#fff",
maxWidth: 720,
width: "100%",
}}
>
{/* Header */}
<div className="flex items-center justify-between" style={{ marginBottom: 8 }}>
<div className="flex items-center gap-2">
<GuardrailIcon />
<span
style={{
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
color: "#6366f1",
letterSpacing: "0.06em",
}}
>
GUARDRAIL
{/* Step card */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 20px",
backgroundColor: "#fff",
maxWidth: 720,
width: "100%",
}}
>
{/* Header */}
<div className="flex items-center justify-between" style={{ marginBottom: 8 }}>
<div className="flex items-center gap-2">
<GuardrailIcon />
<span
style={{
fontSize: 11,
fontWeight: 700,
textTransform: "uppercase",
color: "#6366f1",
letterSpacing: "0.06em",
}}
>
{t("policies.pipelineFlowBuilder.guardrailLabel")}
</span>
</div>
<span style={{ fontSize: 13, color: "#9ca3af" }}>
{t("policies.pipelineFlowBuilder.stepN", { n: index + 1 })}
</span>
</div>
{/* Name */}
<div style={{ fontSize: 15, fontWeight: 600, color: "#111827", marginBottom: 8 }}>{step.guardrail}</div>
{/* Divider */}
<div style={{ borderTop: "1px solid #f3f4f6", marginBottom: 10 }} />
{/* Pass / Fail / API failure */}
<div className="flex flex-col gap-2" style={{ fontSize: 13, color: "#374151" }}>
<span className="flex items-center gap-1.5">
<PassIcon />{" "}
{t("policies.pipelineFlowBuilder.passArrow", { action: actionLabels[step.on_pass] || step.on_pass })}
</span>
<span className="flex items-center gap-1.5">
<FailIcon />{" "}
{t("policies.pipelineFlowBuilder.onFailArrow", { action: actionLabels[step.on_fail] || step.on_fail })}
</span>
<span className="flex items-center gap-1.5">
<ApiFailureIcon />{" "}
{step.on_error != null
? t("policies.pipelineFlowBuilder.onApiFailureArrow", {
action: actionLabels[step.on_error] || step.on_error,
})
: t("policies.pipelineFlowBuilder.onApiFailureArrowSameAsFail", {
action: actionLabels[step.on_fail] || step.on_fail,
})}
</span>
</div>
<span style={{ fontSize: 13, color: "#9ca3af" }}>Step {index + 1}</span>
</div>
{/* Name */}
<div style={{ fontSize: 15, fontWeight: 600, color: "#111827", marginBottom: 8 }}>{step.guardrail}</div>
{/* Divider */}
<div style={{ borderTop: "1px solid #f3f4f6", marginBottom: 10 }} />
{/* Pass / Fail / API failure */}
<div className="flex flex-col gap-2" style={{ fontSize: 13, color: "#374151" }}>
<span className="flex items-center gap-1.5">
<PassIcon /> Pass &#8594; {ACTION_LABELS[step.on_pass] || step.on_pass}
</span>
<span className="flex items-center gap-1.5">
<FailIcon /> On fail &#8594; {ACTION_LABELS[step.on_fail] || step.on_fail}
</span>
<span className="flex items-center gap-1.5">
<ApiFailureIcon /> On API failure &#8594;{" "}
{step.on_error != null
? ACTION_LABELS[step.on_error] || step.on_error
: `${ACTION_LABELS[step.on_fail] || step.on_fail} (same as on fail)`}
</span>
</div>
</div>
</React.Fragment>
))}
</div>
);
</React.Fragment>
))}
</div>
);
};
// ─────────────────────────────────────────────────────────────────────────────
// Pipeline Test Panel (right drawer)
@ -685,11 +719,11 @@ interface PipelineTestPanelProps {
onClose: () => void;
}
const OUTCOME_STYLES: Record<string, { bg: string; color: string; label: string }> = {
pass: { bg: "#f0fdf4", color: "#16a34a", label: "PASS" },
fail: { bg: "#fef2f2", color: "#dc2626", label: "FAIL" },
error: { bg: "#fffbeb", color: "#d97706", label: "ERROR" },
};
const getOutcomeStyles = (t: TFunction): Record<string, { bg: string; color: string; label: string }> => ({
pass: { bg: "#f0fdf4", color: "#16a34a", label: t("policies.pipelineFlowBuilder.outcomePass") },
fail: { bg: "#fef2f2", color: "#dc2626", label: t("policies.pipelineFlowBuilder.outcomeFail") },
error: { bg: "#fffbeb", color: "#d97706", label: t("policies.pipelineFlowBuilder.outcomeError") },
});
const TERMINAL_STYLES: Record<string, { bg: string; color: string }> = {
allow: { bg: "#f0fdf4", color: "#16a34a" },
@ -711,13 +745,17 @@ function complianceMatchExpected(expected: "pass" | "fail", terminalAction: stri
return terminalAction === "block";
}
const testSourceOptions = [
{ value: TEST_SOURCE_QUICK, label: "Quick chat (custom message)" },
const getTestSourceOptions = (t: TFunction) => [
{ value: TEST_SOURCE_QUICK, label: t("policies.pipelineFlowBuilder.testSourceQuickChat") },
...getFrameworks().map((f) => ({ value: f.name, label: f.name })),
{ value: TEST_SOURCE_ALL, label: "All compliance datasets" },
{ value: TEST_SOURCE_ALL, label: t("policies.pipelineFlowBuilder.testSourceAllDatasets") },
];
const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessToken, onClose }) => {
const { t } = useTranslation();
const testSourceOptions = useMemo(() => getTestSourceOptions(t), [t]);
const outcomeStyles = useMemo(() => getOutcomeStyles(t), [t]);
const actionLabels = useMemo(() => getActionLabels(t), [t]);
const [testSource, setTestSource] = useState<string>(TEST_SOURCE_QUICK);
const [testMessage, setTestMessage] = useState("Hello, can you help me?");
const [isRunning, setIsRunning] = useState(false);
@ -734,7 +772,7 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
const emptySteps = pipeline.steps.filter((s) => !s.guardrail);
if (emptySteps.length > 0) {
setError("All steps must have a guardrail selected");
setError(t("policies.pipelineFlowBuilder.allStepsMustHaveGuardrail"));
return;
}
@ -797,7 +835,9 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
justifyContent: "space-between",
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>Test Pipeline</span>
<span style={{ fontSize: 14, fontWeight: 600, color: "#111827" }}>
{t("policies.pipelineFlowBuilder.testPipelineTitle")}
</span>
<button
onClick={onClose}
style={{
@ -816,7 +856,7 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
{/* Input section */}
<div style={{ padding: 16, borderBottom: "1px solid #e5e7eb" }}>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Test with
{t("policies.pipelineFlowBuilder.testWithLabel")}
</label>
<Select
value={testSource}
@ -828,12 +868,12 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
{isQuickChat && (
<>
<label style={{ fontSize: 12, fontWeight: 500, color: "#6b7280", display: "block", marginBottom: 6 }}>
Message
{t("policies.pipelineFlowBuilder.messageLabel")}
</label>
<textarea
value={testMessage}
onChange={(e) => setTestMessage(e.target.value)}
placeholder="Enter a test message..."
placeholder={t("policies.pipelineFlowBuilder.enterTestMessagePlaceholder")}
rows={3}
style={{
width: "100%",
@ -859,12 +899,15 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
}}
>
{testSource === TEST_SOURCE_ALL
? "Run pipeline against all compliance prompts (EU AI Act, GDPR, Topic Blocking, Airline, etc.)."
: `Run pipeline against ${promptsForSource.length} prompts from "${testSource}".`}
? t("policies.pipelineFlowBuilder.runAgainstAllCompliance")
: t("policies.pipelineFlowBuilder.runAgainstDataset", {
count: promptsForSource.length,
source: testSource,
})}
</div>
)}
<Button onClick={handleRunTest} loading={isRunning} style={{ marginTop: 8, width: "100%" }}>
Run Test
{t("policies.pipelineFlowBuilder.runTest")}
</Button>
</div>
@ -890,7 +933,7 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
<div>
{/* Step results */}
{result.step_results.map((step, i) => {
const style = OUTCOME_STYLES[step.outcome] || OUTCOME_STYLES.error;
const style = outcomeStyles[step.outcome] || outcomeStyles.error;
return (
<div
key={i}
@ -903,7 +946,7 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
>
<div className="flex items-center justify-between" style={{ marginBottom: 4 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>
Step {i + 1}: {step.guardrail_name}
{t("policies.pipelineFlowBuilder.stepNGuardrail", { n: i + 1, name: step.guardrail_name })}
</span>
<span
style={{
@ -919,7 +962,9 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
</span>
</div>
<div style={{ fontSize: 12, color: "#6b7280" }}>
Action: {ACTION_LABELS[step.action_taken] || step.action_taken}
{t("policies.pipelineFlowBuilder.actionTaken", {
action: actionLabels[step.action_taken] || step.action_taken,
})}
{step.duration_seconds != null && (
<span style={{ marginLeft: 8 }}>({(step.duration_seconds * 1000).toFixed(0)}ms)</span>
)}
@ -940,7 +985,9 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
}}
>
<div className="flex items-center justify-between">
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>Result</span>
<span style={{ fontSize: 13, fontWeight: 600, color: "#111827" }}>
{t("policies.pipelineFlowBuilder.resultLabel")}
</span>
{(() => {
const ts = TERMINAL_STYLES[result.terminal_action] || TERMINAL_STYLES.block;
return (
@ -955,7 +1002,9 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
textTransform: "uppercase",
}}
>
{result.terminal_action === "modify_response" ? "Custom Response" : result.terminal_action}
{result.terminal_action === "modify_response"
? t("policies.pipelineFlowBuilder.actionCustomResponse")
: result.terminal_action}
</span>
);
})()}
@ -965,7 +1014,7 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
)}
{result.modify_response_message && (
<div style={{ fontSize: 12, color: "#2563eb", marginTop: 6 }}>
Response: {result.modify_response_message}
{t("policies.pipelineFlowBuilder.responseLabel", { msg: result.modify_response_message })}
</div>
)}
</div>
@ -982,7 +1031,7 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
marginBottom: 8,
}}
>
Compliance dataset
{t("policies.pipelineFlowBuilder.complianceDataset")}
</div>
<div
style={{
@ -991,7 +1040,10 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
marginBottom: 10,
}}
>
{complianceResults.filter((e) => e.matched).length} / {complianceResults.length} matched expected
{t("policies.pipelineFlowBuilder.complianceMatchedExpected", {
matched: complianceResults.filter((e) => e.matched).length,
total: complianceResults.length,
})}
</div>
<div
style={{
@ -1035,9 +1087,13 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
flexWrap: "wrap",
}}
>
<span style={{ color: "#6b7280" }}>expected: {entry.prompt.expectedResult}</span>
<span style={{ color: "#6b7280" }}>
{t("policies.pipelineFlowBuilder.expectedLabel", { value: entry.prompt.expectedResult })}
</span>
<span style={{ color: "#9ca3af" }}>→</span>
<span style={{ color: "#6b7280" }}>actual: {actual}</span>
<span style={{ color: "#6b7280" }}>
{t("policies.pipelineFlowBuilder.actualLabel", { value: actual })}
</span>
<span
style={{
backgroundColor: matchStyle.bg,
@ -1060,7 +1116,7 @@ const PipelineTestPanel: React.FC<PipelineTestPanelProps> = ({ pipeline, accessT
{!result && !error && complianceResults.length === 0 && (
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 13, marginTop: 24 }}>
Choose a test source above (quick chat or a compliance dataset) and click &quot;Run Test&quot;
{t("policies.pipelineFlowBuilder.chooseTestSourceHint")}
</div>
)}
</div>
@ -1107,6 +1163,7 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
onPublish,
onPromoteToProduction,
}) => {
const { t } = useTranslation();
const canPublish = editingVersionStatus === "draft" && onPublish;
const canPromote = editingVersionStatus === "published" && onPromoteToProduction;
@ -1136,7 +1193,7 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
marginBottom: 4,
}}
>
Versions
{t("policies.pipelineFlowBuilder.versionsLabel")}
</span>
<span
style={{
@ -1147,7 +1204,7 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
marginBottom: 12,
}}
>
Production = the version used when anyone calls this policy by name.
{t("policies.pipelineFlowBuilder.productionVersionDesc")}
</span>
<Button
onClick={onNewVersion}
@ -1155,14 +1212,14 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
loading={isCreatingVersion}
style={{ width: "100%", marginBottom: 12 }}
>
+ New Version
{t("policies.pipelineFlowBuilder.newVersion")}
</Button>
{isLoading ? (
<div style={{ display: "flex", justifyContent: "center", padding: 16 }}>
<Spin size="small" />
</div>
) : versions.length === 0 ? (
<span style={{ fontSize: 13, color: "#9ca3af" }}>No versions found</span>
<span style={{ fontSize: 13, color: "#9ca3af" }}>{t("policies.pipelineFlowBuilder.noVersionsFound")}</span>
) : (
<div className="flex flex-col gap-1">
{versions.map((v) => {
@ -1217,7 +1274,7 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
loading={isUpdatingStatus}
style={{ width: "100%", marginBottom: 8 }}
>
Publish
{t("policies.pipelineFlowBuilder.publish")}
</Button>
<span
style={{
@ -1228,7 +1285,7 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
marginBottom: canPromote ? 8 : 0,
}}
>
Published versions can be tested in the Playground before promoting to production.
{t("policies.pipelineFlowBuilder.publishedVersionsDesc")}
</span>
</>
)}
@ -1240,7 +1297,7 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
loading={isUpdatingStatus}
style={{ width: "100%", marginBottom: 8 }}
>
Promote to production
{t("policies.pipelineFlowBuilder.promoteToProduction")}
</Button>
<span
style={{
@ -1250,7 +1307,7 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
display: "block",
}}
>
This version will be used when anyone calls this policy by name.
{t("policies.pipelineFlowBuilder.promoteToProductionDesc")}
</span>
</>
)}
@ -1270,7 +1327,7 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
letterSpacing: "0.06em",
}}
>
Silent Mirroring
{t("policies.pipelineFlowBuilder.silentMirroring")}
</span>
<span
style={{
@ -1282,7 +1339,7 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
borderRadius: 4,
}}
>
COMING SOON
{t("common.comingSoon")}
</span>
</div>
<span
@ -1293,8 +1350,7 @@ const PolicyVersionsSidebar: React.FC<PolicyVersionsSidebarProps> = ({
display: "block",
}}
>
Test policy versions on production traffic without blocking requests. Shadow testing helps validate changes
before full rollout.
{t("policies.pipelineFlowBuilder.silentMirroringDesc")}
</span>
</div>
</div>
@ -1331,6 +1387,7 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
onSelectVersion,
onVersionStatusUpdated,
}) => {
const { t } = useTranslation();
const isEditing = !!editingPolicy?.policy_id;
const showVersionsSidebar = !!editingPolicy?.policy_name;
@ -1385,13 +1442,15 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
setIsCreatingVersion(true);
try {
const newPolicy = await createPolicyVersion(accessToken, editingPolicy.policy_name);
NotificationsManager.success("New draft version created");
NotificationsManager.success(t("policies.pipelineFlowBuilder.newDraftVersionCreated"));
onVersionCreated?.(newPolicy);
const list = await listPolicyVersions(accessToken, editingPolicy.policy_name);
setVersions(list.versions ?? []);
} catch (error) {
NotificationsManager.fromBackend(
"Failed to create version: " + (error instanceof Error ? error.message : String(error)),
t("policies.pipelineFlowBuilder.failedToCreateVersion", {
error: error instanceof Error ? error.message : String(error),
}),
);
} finally {
setIsCreatingVersion(false);
@ -1407,15 +1466,15 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
setIsUpdatingStatus(true);
try {
const updated = await updatePolicyVersionStatus(accessToken, editingPolicy.policy_id, "published");
NotificationsManager.success(
"Version published. You can test it in the Playground by selecting this version in the Policies dropdown.",
);
NotificationsManager.success(t("policies.pipelineFlowBuilder.versionPublished"));
const list = await listPolicyVersions(accessToken, editingPolicy.policy_name ?? "");
setVersions(list.versions ?? []);
onVersionStatusUpdated?.(updated);
} catch (error) {
NotificationsManager.fromBackend(
"Failed to publish: " + (error instanceof Error ? error.message : String(error)),
t("policies.pipelineFlowBuilder.failedToPublish", {
error: error instanceof Error ? error.message : String(error),
}),
);
} finally {
setIsUpdatingStatus(false);
@ -1427,13 +1486,15 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
setIsUpdatingStatus(true);
try {
const updated = await updatePolicyVersionStatus(accessToken, editingPolicy.policy_id, "production");
NotificationsManager.success("Version promoted to production");
NotificationsManager.success(t("policies.pipelineFlowBuilder.versionPromotedToProduction"));
const list = await listPolicyVersions(accessToken, editingPolicy.policy_name ?? "");
setVersions(list.versions ?? []);
onVersionStatusUpdated?.(updated);
} catch (error) {
NotificationsManager.fromBackend(
"Failed to promote to production: " + (error instanceof Error ? error.message : String(error)),
t("policies.pipelineFlowBuilder.failedToPromote", {
error: error instanceof Error ? error.message : String(error),
}),
);
} finally {
setIsUpdatingStatus(false);
@ -1442,17 +1503,17 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
const handleSave = async () => {
if (!policyName.trim()) {
MessageManager.error("Please enter a policy name");
MessageManager.error(t("policies.pipelineFlowBuilder.enterPolicyNameError"));
return;
}
if (!accessToken) {
MessageManager.error("No access token available");
MessageManager.error(t("policies.pipelineFlowBuilder.noAccessTokenError"));
return;
}
const emptySteps = pipeline.steps.filter((s) => !s.guardrail);
if (emptySteps.length > 0) {
MessageManager.error("Please select a guardrail for all steps");
MessageManager.error(t("policies.pipelineFlowBuilder.selectGuardrailForAllStepsError"));
return;
}
@ -1470,18 +1531,20 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
if (isEditing && editingPolicy) {
await updatePolicy(accessToken, editingPolicy.policy_id, data as PolicyUpdateRequest);
NotificationsManager.success("Policy updated successfully");
NotificationsManager.success(t("policies.pipelineFlowBuilder.policyUpdatedSuccess"));
onSuccess();
} else {
await createPolicy(accessToken, data as PolicyCreateRequest);
NotificationsManager.success("Policy created successfully");
NotificationsManager.success(t("policies.pipelineFlowBuilder.policyCreatedSuccess"));
onSuccess();
onBack();
}
} catch (error) {
console.error("Failed to save policy:", error);
NotificationsManager.fromBackend(
"Failed to save policy: " + (error instanceof Error ? error.message : String(error)),
t("policies.pipelineFlowBuilder.failedToSavePolicy", {
error: error instanceof Error ? error.message : String(error),
}),
);
} finally {
setIsSubmitting(false);
@ -1529,10 +1592,10 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
>
<ArrowLeftIcon style={{ width: 18, height: 18, color: "#6b7280" }} />
</button>
<span style={{ fontSize: 14, color: "#6b7280" }}>Policies</span>
<span style={{ fontSize: 14, color: "#6b7280" }}>{t("policies.pipelineFlowBuilder.policiesBreadcrumb")}</span>
<span style={{ fontSize: 14, color: "#d1d5db" }}>/</span>
<TextInput
placeholder="Policy name..."
placeholder={t("policies.pipelineFlowBuilder.policyNamePlaceholder")}
value={policyName}
onChange={(e) => setPolicyName(e.target.value)}
disabled={isEditing}
@ -1549,18 +1612,20 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
letterSpacing: "0.02em",
}}
>
Flow
{t("policies.pipelineFlowBuilder.flowBadge")}
</span>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onBack}>
Cancel
{t("common.cancel")}
</Button>
<Button variant="secondary" onClick={() => setShowTestPanel(!showTestPanel)}>
{showTestPanel ? "Hide Test" : "Test Pipeline"}
{showTestPanel
? t("policies.pipelineFlowBuilder.hideTest")
: t("policies.pipelineFlowBuilder.testPipeline")}
</Button>
<Button onClick={handleSave} loading={isSubmitting}>
{isEditing ? "Update Policy" : "Save Policy"}
{isEditing ? t("policies.pipelineFlowBuilder.updatePolicy") : t("policies.pipelineFlowBuilder.savePolicy")}
</Button>
</div>
</div>
@ -1575,7 +1640,7 @@ export const FlowBuilderPage: React.FC<FlowBuilderPageProps> = ({
}}
>
<TextInput
placeholder="Add a description (optional)..."
placeholder={t("policies.pipelineFlowBuilder.addDescriptionPlaceholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
style={{ maxWidth: 500 }}

View file

@ -4898,6 +4898,86 @@
"templateReady": "Template ready! Complete the policy form to save.",
"guardrailsCreateFailed": "Failed to create {{count}} guardrail(s): {{list}}. You may need to create them manually.",
"createGuardrailsFailed": "Failed to create guardrails. Please try again."
},
"pipelineFlowBuilder": {
"actionNextStep": "Next Step",
"actionAllow": "Allow",
"actionBlock": "Block",
"actionCustomResponse": "Custom Response",
"insertStep": "Insert step",
"guardrailLabel": "GUARDRAIL",
"stepN": "Step {{n}}",
"deleteStep": "Delete step",
"selectGuardrailPlaceholder": "Select a guardrail",
"onPass": "ON PASS",
"onFail": "ON FAIL",
"onApiFailure": "ON API FAILURE",
"actionLabel": "Action",
"customResponseMessage": "Custom Response Message",
"enterCustomResponsePlaceholder": "Enter custom response...",
"sameAsOnFailPlaceholder": "Same as ON FAIL",
"triggerLabel": "TRIGGER",
"incomingLlmRequest": "Incoming LLM Request",
"triggerDesc": "This flow runs when a request matches this policy",
"endLabel": "END",
"continueToLlm": "Continue to LLM",
"requestProceedsToModel": "Request proceeds to the model",
"passArrow": "Pass → {{action}}",
"onFailArrow": "On fail → {{action}}",
"onApiFailureArrow": "On API failure → {{action}}",
"onApiFailureArrowSameAsFail": "On API failure → {{action}} (same as on fail)",
"outcomePass": "PASS",
"outcomeFail": "FAIL",
"outcomeError": "ERROR",
"testSourceQuickChat": "Quick chat (custom message)",
"testSourceAllDatasets": "All compliance datasets",
"allStepsMustHaveGuardrail": "All steps must have a guardrail selected",
"testPipelineTitle": "Test Pipeline",
"testWithLabel": "Test with",
"messageLabel": "Message",
"enterTestMessagePlaceholder": "Enter a test message...",
"runAgainstAllCompliance": "Run pipeline against all compliance prompts (EU AI Act, GDPR, Topic Blocking, Airline, etc.).",
"runAgainstDataset": "Run pipeline against {{count}} prompts from \"{{source}}\".",
"runTest": "Run Test",
"stepNGuardrail": "Step {{n}}: {{name}}",
"actionTaken": "Action: {{action}}",
"resultLabel": "Result",
"responseLabel": "Response: {{msg}}",
"complianceDataset": "Compliance dataset",
"complianceMatchedExpected": "{{matched}} / {{total}} matched expected",
"expectedLabel": "expected: {{value}}",
"actualLabel": "actual: {{value}}",
"chooseTestSourceHint": "Choose a test source above (quick chat or a compliance dataset) and click \"Run Test\"",
"versionsLabel": "Versions",
"productionVersionDesc": "Production = the version used when anyone calls this policy by name.",
"newVersion": "+ New Version",
"noVersionsFound": "No versions found",
"publish": "Publish",
"publishedVersionsDesc": "Published versions can be tested in the Playground before promoting to production.",
"promoteToProduction": "Promote to production",
"promoteToProductionDesc": "This version will be used when anyone calls this policy by name.",
"silentMirroring": "Silent Mirroring",
"silentMirroringDesc": "Test policy versions on production traffic without blocking requests. Shadow testing helps validate changes before full rollout.",
"newDraftVersionCreated": "New draft version created",
"failedToCreateVersion": "Failed to create version: {{error}}",
"versionPublished": "Version published. You can test it in the Playground by selecting this version in the Policies dropdown.",
"failedToPublish": "Failed to publish: {{error}}",
"versionPromotedToProduction": "Version promoted to production",
"failedToPromote": "Failed to promote to production: {{error}}",
"enterPolicyNameError": "Please enter a policy name",
"noAccessTokenError": "No access token available",
"selectGuardrailForAllStepsError": "Please select a guardrail for all steps",
"policyUpdatedSuccess": "Policy updated successfully",
"policyCreatedSuccess": "Policy created successfully",
"failedToSavePolicy": "Failed to save policy: {{error}}",
"policiesBreadcrumb": "Policies",
"policyNamePlaceholder": "Policy name...",
"flowBadge": "Flow",
"hideTest": "Hide Test",
"testPipeline": "Test Pipeline",
"updatePolicy": "Update Policy",
"savePolicy": "Save Policy",
"addDescriptionPlaceholder": "Add a description (optional)..."
}
}
}

View file

@ -4898,6 +4898,86 @@
"templateReady": "模板已就绪!请完成策略表单以保存。",
"guardrailsCreateFailed": "{{count}} 个护栏创建失败:{{list}}。您可能需要手动创建。",
"createGuardrailsFailed": "创建护栏失败,请重试。"
},
"pipelineFlowBuilder": {
"actionNextStep": "下一步",
"actionAllow": "允许",
"actionBlock": "拦截",
"actionCustomResponse": "自定义响应",
"insertStep": "插入步骤",
"guardrailLabel": "护栏",
"stepN": "步骤 {{n}}",
"deleteStep": "删除步骤",
"selectGuardrailPlaceholder": "选择护栏",
"onPass": "通过时",
"onFail": "失败时",
"onApiFailure": "API 故障时",
"actionLabel": "动作",
"customResponseMessage": "自定义响应内容",
"enterCustomResponsePlaceholder": "输入自定义响应...",
"sameAsOnFailPlaceholder": "与失败时相同",
"triggerLabel": "触发器",
"incomingLlmRequest": "传入 LLM 请求",
"triggerDesc": "当请求匹配此策略时,该流程将运行",
"endLabel": "结束",
"continueToLlm": "继续至 LLM",
"requestProceedsToModel": "请求将继续发送到模型",
"passArrow": "通过 → {{action}}",
"onFailArrow": "失败时 → {{action}}",
"onApiFailureArrow": "API 故障时 → {{action}}",
"onApiFailureArrowSameAsFail": "API 故障时 → {{action}}(与失败时相同)",
"outcomePass": "通过",
"outcomeFail": "失败",
"outcomeError": "错误",
"testSourceQuickChat": "快速对话(自定义消息)",
"testSourceAllDatasets": "所有合规数据集",
"allStepsMustHaveGuardrail": "所有步骤都必须选择护栏",
"testPipelineTitle": "测试流水线",
"testWithLabel": "测试方式",
"messageLabel": "消息",
"enterTestMessagePlaceholder": "输入测试消息...",
"runAgainstAllCompliance": "对所有合规提示词运行流水线(EU AI Act、GDPR、话题拦截、航空等)。",
"runAgainstDataset": "对「{{source}}」中的 {{count}} 条提示词运行流水线。",
"runTest": "运行测试",
"stepNGuardrail": "步骤 {{n}}:{{name}}",
"actionTaken": "动作:{{action}}",
"resultLabel": "结果",
"responseLabel": "响应:{{msg}}",
"complianceDataset": "合规数据集",
"complianceMatchedExpected": "{{matched}} / {{total}} 条符合预期",
"expectedLabel": "预期:{{value}}",
"actualLabel": "实际:{{value}}",
"chooseTestSourceHint": "请在上方选择测试源(快速对话或合规数据集),然后点击「运行测试」",
"versionsLabel": "版本",
"productionVersionDesc": "生产版本 = 任何人通过名称调用此策略时所使用的版本。",
"newVersion": "+ 新版本",
"noVersionsFound": "未找到版本",
"publish": "发布",
"publishedVersionsDesc": "已发布的版本可在 Playground 中测试后再推广到生产环境。",
"promoteToProduction": "推广到生产",
"promoteToProductionDesc": "任何人通过名称调用此策略时将使用此版本。",
"silentMirroring": "静默镜像",
"silentMirroringDesc": "在生产流量上测试策略版本,且不拦截请求。影子测试有助于在全面发布前验证变更。",
"newDraftVersionCreated": "新草稿版本已创建",
"failedToCreateVersion": "创建版本失败:{{error}}",
"versionPublished": "版本已发布。您可以在 Playground 中通过策略下拉菜单选择此版本进行测试。",
"failedToPublish": "发布失败:{{error}}",
"versionPromotedToProduction": "版本已推广到生产",
"failedToPromote": "推广到生产失败:{{error}}",
"enterPolicyNameError": "请输入策略名称",
"noAccessTokenError": "无可用访问令牌",
"selectGuardrailForAllStepsError": "请为所有步骤选择护栏",
"policyUpdatedSuccess": "策略更新成功",
"policyCreatedSuccess": "策略创建成功",
"failedToSavePolicy": "保存策略失败:{{error}}",
"policiesBreadcrumb": "策略",
"policyNamePlaceholder": "策略名称...",
"flowBadge": "流程",
"hideTest": "隐藏测试",
"testPipeline": "测试流水线",
"updatePolicy": "更新策略",
"savePolicy": "保存策略",
"addDescriptionPlaceholder": "添加描述(可选)..."
}
}
}