mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-05 09:05:58 +00:00
feat(policy): test playground for AI policy suggestions (#21608)
* fix aviation safety topic filter: remove overly broad exceptions, add cockpit access block words * fix airline brand protection filter: add identifier words, competitor/ops block words, tighten exceptions * feat(policy): add POST /policy/templates/test endpoint for testing guardrails before creating them * feat(ui): add testPolicyTemplate networking function * feat(ui): add test playground to AI policy suggestion modal * test(policy): add tests for POST /policy/templates/test endpoint
This commit is contained in:
@@ -18,13 +18,13 @@ from typing import (
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
TypedDict,
|
||||
cast,
|
||||
)
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import (
|
||||
@@ -993,3 +993,150 @@ async def suggest_policy_templates(
|
||||
description=data.description,
|
||||
model=data.model,
|
||||
)
|
||||
|
||||
|
||||
class GuardrailTestResultEntry(TypedDict):
|
||||
guardrail_name: str
|
||||
action: str # "passed" | "blocked" | "masked" | "unsupported"
|
||||
output_text: str
|
||||
details: str
|
||||
|
||||
|
||||
class TestPolicyTemplateRequest(BaseModel):
|
||||
guardrail_definitions: List[dict] = Field(
|
||||
description="All guardrailDefinitions from the policy template"
|
||||
)
|
||||
text: str = Field(description="Test input text to run guardrails against")
|
||||
|
||||
|
||||
class TestPolicyTemplateResponse(TypedDict):
|
||||
overall_action: str # worst-case across all guardrails
|
||||
results: List[GuardrailTestResultEntry]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/policy/templates/test",
|
||||
tags=["policy management"],
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
@management_endpoint_wrapper
|
||||
async def test_policy_template(
|
||||
data: TestPolicyTemplateRequest,
|
||||
request: Request,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
) -> TestPolicyTemplateResponse:
|
||||
"""
|
||||
Test a policy template's guardrails against a text input without creating them.
|
||||
|
||||
Instantiates temporary guardrails from the template definitions, runs them
|
||||
against the provided text, and returns per-guardrail results so users can
|
||||
verify the template solves their problem before creating it.
|
||||
"""
|
||||
from litellm.proxy.utils import handle_exception_on_proxy
|
||||
|
||||
try:
|
||||
results = await _test_guardrail_definitions(
|
||||
guardrail_definitions=data.guardrail_definitions,
|
||||
text=data.text,
|
||||
)
|
||||
overall = _compute_overall_action(results)
|
||||
return TestPolicyTemplateResponse(
|
||||
overall_action=overall,
|
||||
results=results,
|
||||
)
|
||||
except Exception as e:
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
||||
|
||||
async def _test_guardrail_definitions(
|
||||
guardrail_definitions: List[dict],
|
||||
text: str,
|
||||
) -> List[GuardrailTestResultEntry]:
|
||||
"""Instantiate and run each guardrail definition against the text."""
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
|
||||
results: List[GuardrailTestResultEntry] = []
|
||||
|
||||
for guardrail_def in guardrail_definitions:
|
||||
guardrail_name = guardrail_def.get("guardrail_name", "unknown")
|
||||
litellm_params = guardrail_def.get("litellm_params", {})
|
||||
guardrail_type = litellm_params.get("guardrail", "")
|
||||
|
||||
if guardrail_type != "litellm_content_filter":
|
||||
results.append(
|
||||
GuardrailTestResultEntry(
|
||||
guardrail_name=guardrail_name,
|
||||
action="unsupported",
|
||||
output_text=text,
|
||||
details=f"Preview not available for guardrail type: {guardrail_type}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
guardrail = ContentFilterGuardrail(
|
||||
guardrail_name=guardrail_name,
|
||||
patterns=litellm_params.get("patterns"),
|
||||
blocked_words=litellm_params.get("blocked_words"),
|
||||
categories=litellm_params.get("categories"),
|
||||
pattern_redaction_format=litellm_params.get("pattern_redaction_format"),
|
||||
default_on=litellm_params.get("default_on", False),
|
||||
)
|
||||
|
||||
output = await guardrail.apply_guardrail(
|
||||
inputs={"texts": [text]},
|
||||
request_data={},
|
||||
input_type="request",
|
||||
)
|
||||
output_text = output.get("texts", [text])[0] if output.get("texts") else text
|
||||
|
||||
if output_text != text:
|
||||
action = "masked"
|
||||
details = "Content was modified (masked)"
|
||||
else:
|
||||
action = "passed"
|
||||
details = "No issues detected"
|
||||
|
||||
results.append(
|
||||
GuardrailTestResultEntry(
|
||||
guardrail_name=guardrail_name,
|
||||
action=action,
|
||||
output_text=output_text,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
except HTTPException as e:
|
||||
detail = e.detail if hasattr(e, "detail") else str(e)
|
||||
if isinstance(detail, dict):
|
||||
detail = detail.get("error", str(detail))
|
||||
results.append(
|
||||
GuardrailTestResultEntry(
|
||||
guardrail_name=guardrail_name,
|
||||
action="blocked",
|
||||
output_text="",
|
||||
details=str(detail),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
results.append(
|
||||
GuardrailTestResultEntry(
|
||||
guardrail_name=guardrail_name,
|
||||
action="error",
|
||||
output_text=text,
|
||||
details=str(e),
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _compute_overall_action(results: List[GuardrailTestResultEntry]) -> str:
|
||||
"""Return the worst-case action: blocked > masked > error > unsupported > passed."""
|
||||
priority = {"blocked": 4, "masked": 3, "error": 2, "unsupported": 1, "passed": 0}
|
||||
worst = "passed"
|
||||
for r in results:
|
||||
if priority.get(r["action"], 0) > priority.get(worst, 0):
|
||||
worst = r["action"]
|
||||
return worst
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Tests for POST /policy/templates/test endpoint logic.
|
||||
|
||||
Tests _test_guardrail_definitions and _compute_overall_action directly
|
||||
without needing a running proxy.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.management_endpoints.policy_endpoints.endpoints import (
|
||||
GuardrailTestResultEntry,
|
||||
_compute_overall_action,
|
||||
_test_guardrail_definitions,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pattern_based_guardrail_masks_pii():
|
||||
"""A pattern-based guardrail should mask matching PII."""
|
||||
guardrail_defs = [
|
||||
{
|
||||
"guardrail_name": "test-ssn-masker",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "us_ssn",
|
||||
"action": "MASK",
|
||||
}
|
||||
],
|
||||
"pattern_redaction_format": "[{pattern_name}_REDACTED]",
|
||||
},
|
||||
"guardrail_info": {"description": "Masks US SSNs"},
|
||||
}
|
||||
]
|
||||
|
||||
results = await _test_guardrail_definitions(
|
||||
guardrail_definitions=guardrail_defs,
|
||||
text="My SSN is 123-45-6789",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["guardrail_name"] == "test-ssn-masker"
|
||||
assert results[0]["action"] == "masked"
|
||||
assert "123-45-6789" not in results[0]["output_text"]
|
||||
assert "REDACTED" in results[0]["output_text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_words_guardrail_blocks():
|
||||
"""A blocked_words guardrail should block matching text."""
|
||||
guardrail_defs = [
|
||||
{
|
||||
"guardrail_name": "test-word-blocker",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"blocked_words": [
|
||||
{
|
||||
"keyword": "forbidden_word",
|
||||
"action": "BLOCK",
|
||||
"description": "test block",
|
||||
}
|
||||
],
|
||||
},
|
||||
"guardrail_info": {"description": "Blocks forbidden words"},
|
||||
}
|
||||
]
|
||||
|
||||
results = await _test_guardrail_definitions(
|
||||
guardrail_definitions=guardrail_defs,
|
||||
text="This contains forbidden_word in it",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["guardrail_name"] == "test-word-blocker"
|
||||
assert results[0]["action"] == "blocked"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clean_text_passes():
|
||||
"""Clean text should pass all guardrails."""
|
||||
guardrail_defs = [
|
||||
{
|
||||
"guardrail_name": "test-ssn-masker",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "us_ssn",
|
||||
"action": "MASK",
|
||||
}
|
||||
],
|
||||
},
|
||||
"guardrail_info": {"description": "Masks US SSNs"},
|
||||
}
|
||||
]
|
||||
|
||||
results = await _test_guardrail_definitions(
|
||||
guardrail_definitions=guardrail_defs,
|
||||
text="Hello, this is a perfectly clean message.",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["action"] == "passed"
|
||||
assert results[0]["output_text"] == "Hello, this is a perfectly clean message."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_guardrail_type():
|
||||
"""Non-litellm_content_filter types should return unsupported."""
|
||||
guardrail_defs = [
|
||||
{
|
||||
"guardrail_name": "test-mcp",
|
||||
"litellm_params": {
|
||||
"guardrail": "mcp_security",
|
||||
"mode": "pre_call",
|
||||
},
|
||||
"guardrail_info": {"description": "MCP guardrail"},
|
||||
}
|
||||
]
|
||||
|
||||
results = await _test_guardrail_definitions(
|
||||
guardrail_definitions=guardrail_defs,
|
||||
text="Any text",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["action"] == "unsupported"
|
||||
assert "mcp_security" in results[0]["details"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_guardrails_mixed_results():
|
||||
"""Multiple guardrails with different outcomes."""
|
||||
guardrail_defs = [
|
||||
{
|
||||
"guardrail_name": "ssn-masker",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "us_ssn",
|
||||
"action": "MASK",
|
||||
}
|
||||
],
|
||||
"pattern_redaction_format": "[{pattern_name}_REDACTED]",
|
||||
},
|
||||
"guardrail_info": {"description": "Masks SSNs"},
|
||||
},
|
||||
{
|
||||
"guardrail_name": "email-masker",
|
||||
"litellm_params": {
|
||||
"guardrail": "litellm_content_filter",
|
||||
"mode": "pre_call",
|
||||
"patterns": [
|
||||
{
|
||||
"pattern_type": "prebuilt",
|
||||
"pattern_name": "email",
|
||||
"action": "MASK",
|
||||
}
|
||||
],
|
||||
"pattern_redaction_format": "[{pattern_name}_REDACTED]",
|
||||
},
|
||||
"guardrail_info": {"description": "Masks emails"},
|
||||
},
|
||||
]
|
||||
|
||||
results = await _test_guardrail_definitions(
|
||||
guardrail_definitions=guardrail_defs,
|
||||
text="My SSN is 123-45-6789 but no email here",
|
||||
)
|
||||
|
||||
assert len(results) == 2
|
||||
ssn_result = next(r for r in results if r["guardrail_name"] == "ssn-masker")
|
||||
email_result = next(r for r in results if r["guardrail_name"] == "email-masker")
|
||||
assert ssn_result["action"] == "masked"
|
||||
assert email_result["action"] == "passed"
|
||||
|
||||
|
||||
def test_compute_overall_action_blocked_wins():
|
||||
results: list[GuardrailTestResultEntry] = [
|
||||
GuardrailTestResultEntry(guardrail_name="a", action="passed", output_text="", details=""),
|
||||
GuardrailTestResultEntry(guardrail_name="b", action="blocked", output_text="", details=""),
|
||||
GuardrailTestResultEntry(guardrail_name="c", action="masked", output_text="", details=""),
|
||||
]
|
||||
assert _compute_overall_action(results) == "blocked"
|
||||
|
||||
|
||||
def test_compute_overall_action_masked_wins_over_passed():
|
||||
results: list[GuardrailTestResultEntry] = [
|
||||
GuardrailTestResultEntry(guardrail_name="a", action="passed", output_text="", details=""),
|
||||
GuardrailTestResultEntry(guardrail_name="b", action="masked", output_text="", details=""),
|
||||
]
|
||||
assert _compute_overall_action(results) == "masked"
|
||||
|
||||
|
||||
def test_compute_overall_action_all_passed():
|
||||
results: list[GuardrailTestResultEntry] = [
|
||||
GuardrailTestResultEntry(guardrail_name="a", action="passed", output_text="", details=""),
|
||||
GuardrailTestResultEntry(guardrail_name="b", action="passed", output_text="", details=""),
|
||||
]
|
||||
assert _compute_overall_action(results) == "passed"
|
||||
|
||||
|
||||
def test_compute_overall_action_empty():
|
||||
assert _compute_overall_action([]) == "passed"
|
||||
@@ -5627,6 +5627,41 @@ export const suggestPolicyTemplates = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const testPolicyTemplate = async (
|
||||
accessToken: string,
|
||||
guardrailDefinitions: any[],
|
||||
text: string
|
||||
) => {
|
||||
try {
|
||||
const url = proxyBaseUrl
|
||||
? `${proxyBaseUrl}/policy/templates/test`
|
||||
: `/policy/templates/test`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
guardrail_definitions: guardrailDefinitions,
|
||||
text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to test policy template:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const enrichPolicyTemplateStream = async (
|
||||
accessToken: string,
|
||||
templateId: string,
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Spin, Checkbox, Select } from "antd";
|
||||
import { Button } from "@tremor/react";
|
||||
import { suggestPolicyTemplates, modelHubCall } from "../networking";
|
||||
import { Modal, Spin, Checkbox, Select, Input, Typography, Tooltip } from "antd";
|
||||
import { Button, Card } from "@tremor/react";
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, InfoCircleOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { suggestPolicyTemplates, modelHubCall, testPolicyTemplate, enrichPolicyTemplate } from "../networking";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface SuggestedTemplate {
|
||||
template_id: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface GuardrailTestResult {
|
||||
guardrail_name: string;
|
||||
action: string;
|
||||
output_text: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
interface AiSuggestionModalProps {
|
||||
visible: boolean;
|
||||
onSelectTemplates: (templates: any[]) => void;
|
||||
@@ -34,6 +45,17 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>(undefined);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
// Test panel state
|
||||
const [showTestPanel, setShowTestPanel] = useState(false);
|
||||
const [testInputText, setTestInputText] = useState("");
|
||||
const [isTestLoading, setIsTestLoading] = useState(false);
|
||||
const [testResults, setTestResults] = useState<GuardrailTestResult[] | null>(null);
|
||||
const [testOverallAction, setTestOverallAction] = useState<string | null>(null);
|
||||
const [collapsedResults, setCollapsedResults] = useState<Set<string>>(new Set());
|
||||
// Enrichment state for competitor templates
|
||||
const [enrichedDefs, setEnrichedDefs] = useState<Record<string, any[]>>({});
|
||||
const [isEnriching, setIsEnriching] = useState(false);
|
||||
const [enrichBrandName, setEnrichBrandName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && availableModels.length === 0) {
|
||||
@@ -67,6 +89,15 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
||||
setExplanation(null);
|
||||
setSelectedIds(new Set());
|
||||
setSelectedModel(undefined);
|
||||
setShowTestPanel(false);
|
||||
setTestInputText("");
|
||||
setIsTestLoading(false);
|
||||
setTestResults(null);
|
||||
setTestOverallAction(null);
|
||||
setCollapsedResults(new Set());
|
||||
setEnrichedDefs({});
|
||||
setIsEnriching(false);
|
||||
setEnrichBrandName("");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -126,6 +157,11 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
||||
setSuggestions(null);
|
||||
setExplanation(null);
|
||||
setSelectedIds(new Set());
|
||||
setShowTestPanel(false);
|
||||
setTestInputText("");
|
||||
setTestResults(null);
|
||||
setTestOverallAction(null);
|
||||
setCollapsedResults(new Set());
|
||||
};
|
||||
|
||||
const handleUseSelected = () => {
|
||||
@@ -149,14 +185,433 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
||||
const getTemplateById = (id: string) =>
|
||||
allTemplates.find((t) => t.id === id);
|
||||
|
||||
const toggleResultCollapse = (name: string) => {
|
||||
setCollapsedResults((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getSelectedTemplatesNeedingEnrichment = (): any[] => {
|
||||
return Array.from(selectedIds)
|
||||
.map((id) => getTemplateById(id))
|
||||
.filter((t) => t?.llm_enrichment);
|
||||
};
|
||||
|
||||
const needsEnrichment = getSelectedTemplatesNeedingEnrichment().length > 0;
|
||||
|
||||
const getAllSelectedGuardrailDefs = (): any[] => {
|
||||
const defs: any[] = [];
|
||||
for (const id of selectedIds) {
|
||||
// Use enriched defs if available, otherwise use template's original
|
||||
if (enrichedDefs[id]) {
|
||||
defs.push(...enrichedDefs[id]);
|
||||
} else {
|
||||
const t = getTemplateById(id);
|
||||
if (t?.guardrailDefinitions) {
|
||||
defs.push(...t.guardrailDefinitions);
|
||||
}
|
||||
}
|
||||
}
|
||||
return defs;
|
||||
};
|
||||
|
||||
const handleEnrichCompetitors = async () => {
|
||||
if (!accessToken || !selectedModel) return;
|
||||
const templatesToEnrich = getSelectedTemplatesNeedingEnrichment();
|
||||
if (templatesToEnrich.length === 0) return;
|
||||
|
||||
setIsEnriching(true);
|
||||
try {
|
||||
for (const template of templatesToEnrich) {
|
||||
const paramName = template.llm_enrichment.parameter;
|
||||
const result = await enrichPolicyTemplate(
|
||||
accessToken,
|
||||
template.id,
|
||||
{ [paramName]: enrichBrandName },
|
||||
selectedModel,
|
||||
);
|
||||
setEnrichedDefs((prev) => ({
|
||||
...prev,
|
||||
[template.id]: result.guardrailDefinitions,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to enrich templates:", e);
|
||||
} finally {
|
||||
setIsEnriching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunTest = async () => {
|
||||
if (!accessToken || !testInputText.trim()) return;
|
||||
const allDefs = getAllSelectedGuardrailDefs();
|
||||
if (allDefs.length === 0) return;
|
||||
|
||||
setIsTestLoading(true);
|
||||
setTestResults(null);
|
||||
setTestOverallAction(null);
|
||||
setCollapsedResults(new Set());
|
||||
|
||||
try {
|
||||
const result = await testPolicyTemplate(accessToken, allDefs, testInputText);
|
||||
setTestResults(result.results || []);
|
||||
setTestOverallAction(result.overall_action || "passed");
|
||||
} catch {
|
||||
setTestResults([]);
|
||||
setTestOverallAction("error");
|
||||
} finally {
|
||||
setIsTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||
e.preventDefault();
|
||||
handleRunTest();
|
||||
}
|
||||
};
|
||||
|
||||
const showResults = suggestions !== null && !isLoading;
|
||||
|
||||
// Helper to render the suggestions list (reused in both layouts)
|
||||
const renderSuggestionsList = () => {
|
||||
if (!suggestions || suggestions.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="font-medium">No matching templates found</p>
|
||||
<p className="text-sm mt-1">Try adjusting your examples or description.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{suggestions.map((suggestion) => {
|
||||
const template = getTemplateById(suggestion.template_id);
|
||||
if (!template) return null;
|
||||
const isSelected = selectedIds.has(suggestion.template_id);
|
||||
return (
|
||||
<div
|
||||
key={suggestion.template_id}
|
||||
className={`rounded-xl border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-blue-400 bg-blue-50/60 shadow-sm"
|
||||
: "border-gray-200 hover:border-gray-300 hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="p-4 cursor-pointer"
|
||||
onClick={() => toggleTemplate(suggestion.template_id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => toggleTemplate(suggestion.template_id)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm text-gray-900">
|
||||
{template.title}
|
||||
</span>
|
||||
{template.complexity && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${
|
||||
template.complexity === "Low"
|
||||
? "bg-gray-50 text-gray-500 border-gray-200"
|
||||
: template.complexity === "Medium"
|
||||
? "bg-blue-50 text-blue-500 border-blue-100"
|
||||
: "bg-purple-50 text-purple-500 border-purple-100"
|
||||
}`}>
|
||||
{template.complexity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
{template.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
{template.guardrails && template.guardrails.slice(0, 4).map((g: string) => (
|
||||
<span key={g} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600">
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
{template.guardrails && template.guardrails.length > 4 && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
+{template.guardrails.length - 4} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-start gap-1.5">
|
||||
<InfoCircleOutlined className="text-blue-500 mt-0.5 text-xs flex-shrink-0" />
|
||||
<p className="text-xs text-blue-600 leading-relaxed">
|
||||
{suggestion.reason}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Explanation */}
|
||||
{explanation && (
|
||||
<div className="p-3 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<InfoCircleOutlined className="text-gray-400 text-xs" />
|
||||
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Why these templates
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 leading-relaxed">{explanation}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to render the test panel
|
||||
const renderTestPanel = () => (
|
||||
<div className="space-y-4 h-full flex flex-col">
|
||||
{/* Test header */}
|
||||
<div className="pb-3 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="text-base font-semibold text-gray-900">Test Guardrails</h3>
|
||||
<button
|
||||
onClick={() => { setShowTestPanel(false); setTestResults(null); setTestOverallAction(null); }}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-1.5">
|
||||
{Array.from(selectedIds).map((id) => {
|
||||
const t = getTemplateById(id);
|
||||
return t ? (
|
||||
<span key={id} className="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-medium bg-blue-50 text-blue-700 border border-blue-200">
|
||||
{t.title}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{getAllSelectedGuardrailDefs().length} guardrails across {selectedIds.size} template{selectedIds.size !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enrichment section for competitor templates */}
|
||||
{needsEnrichment && Object.keys(enrichedDefs).length > 0 && (
|
||||
<div className="p-3 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleOutlined className="text-green-600" />
|
||||
<span className="text-xs font-medium text-green-800">
|
||||
Competitor names loaded for {enrichBrandName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{needsEnrichment && Object.keys(enrichedDefs).length === 0 && (
|
||||
<div className="p-3 bg-amber-50 rounded-lg border border-amber-200 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-amber-600 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-xs font-medium text-amber-800">
|
||||
Competitor template requires your brand name to discover competitors
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="e.g. Emirates Airlines"
|
||||
value={enrichBrandName}
|
||||
onChange={(e) => setEnrichBrandName(e.target.value)}
|
||||
onPressEnter={() => enrichBrandName.trim() && handleEnrichCompetitors()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={handleEnrichCompetitors}
|
||||
loading={isEnriching}
|
||||
disabled={!enrichBrandName.trim() || isEnriching}
|
||||
>
|
||||
{isEnriching ? "Discovering..." : "Discover"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Input Text</label>
|
||||
<Tooltip title="Press Enter to submit. Use Shift+Enter for new line.">
|
||||
<InfoCircleOutlined className="text-gray-400 cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Text className="text-xs text-gray-500">Characters: {testInputText.length}</Text>
|
||||
</div>
|
||||
<TextArea
|
||||
value={testInputText}
|
||||
onChange={(e) => setTestInputText(e.target.value)}
|
||||
onKeyDown={handleTestKeyDown}
|
||||
placeholder="Enter text to test against all selected policy guardrails..."
|
||||
rows={4}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<Text className="text-xs text-gray-500">
|
||||
Press <kbd className="px-1 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">Enter</kbd> to submit
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRunTest}
|
||||
loading={isTestLoading}
|
||||
disabled={!testInputText.trim() || isTestLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isTestLoading
|
||||
? `Testing ${getAllSelectedGuardrailDefs().length} guardrails...`
|
||||
: `Test ${getAllSelectedGuardrailDefs().length} guardrails`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{testResults && testResults.length > 0 && (() => {
|
||||
const blockedCount = testResults.filter((r) => r.action === "blocked").length;
|
||||
const maskedCount = testResults.filter((r) => r.action === "masked").length;
|
||||
const passedCount = testResults.filter((r) => r.action === "passed").length;
|
||||
const otherCount = testResults.length - blockedCount - maskedCount - passedCount;
|
||||
return (
|
||||
<div className="space-y-2 pt-3 border-t border-gray-200 flex-1 overflow-y-auto">
|
||||
{/* Summary bar */}
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900">Results</h4>
|
||||
<span className="text-[10px] text-gray-500">{testResults.length} guardrails tested</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{blockedCount > 0 && (
|
||||
<div className="flex-1 rounded-md bg-red-50 border border-red-200 px-3 py-2 text-center">
|
||||
<div className="text-lg font-bold text-red-700">{blockedCount}</div>
|
||||
<div className="text-[10px] font-medium text-red-600">Blocked</div>
|
||||
</div>
|
||||
)}
|
||||
{maskedCount > 0 && (
|
||||
<div className="flex-1 rounded-md bg-amber-50 border border-amber-200 px-3 py-2 text-center">
|
||||
<div className="text-lg font-bold text-amber-700">{maskedCount}</div>
|
||||
<div className="text-[10px] font-medium text-amber-600">Masked</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 rounded-md bg-green-50 border border-green-200 px-3 py-2 text-center">
|
||||
<div className="text-lg font-bold text-green-700">{passedCount}</div>
|
||||
<div className="text-[10px] font-medium text-green-600">Passed</div>
|
||||
</div>
|
||||
{otherCount > 0 && (
|
||||
<div className="flex-1 rounded-md bg-gray-100 border border-gray-200 px-3 py-2 text-center">
|
||||
<div className="text-lg font-bold text-gray-600">{otherCount}</div>
|
||||
<div className="text-[10px] font-medium text-gray-500">Other</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResults.map((result) => {
|
||||
const isBlocked = result.action === "blocked";
|
||||
const isMasked = result.action === "masked";
|
||||
const isPassed = result.action === "passed";
|
||||
const isCollapsed = collapsedResults.has(result.guardrail_name);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={result.guardrail_name}
|
||||
className={`!p-3 ${
|
||||
isBlocked
|
||||
? "bg-red-50 border-red-200"
|
||||
: isMasked
|
||||
? "bg-amber-50 border-amber-200"
|
||||
: isPassed
|
||||
? "bg-green-50 border-green-200"
|
||||
: "bg-gray-50 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => toggleResultCollapse(result.guardrail_name)}
|
||||
>
|
||||
<div className="flex items-center space-x-1.5">
|
||||
{isCollapsed ? <RightOutlined className="text-gray-500 text-[10px]" /> : <DownOutlined className="text-gray-500 text-[10px]" />}
|
||||
{isBlocked ? (
|
||||
<CloseCircleOutlined className="text-red-600" />
|
||||
) : isMasked ? (
|
||||
<svg className="w-4 h-4 text-amber-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<CheckCircleOutlined className="text-green-600" />
|
||||
)}
|
||||
<span className={`text-xs font-medium ${isBlocked ? "text-red-800" : isMasked ? "text-amber-800" : "text-green-800"}`}>
|
||||
{result.guardrail_name}
|
||||
</span>
|
||||
<span className={`px-1.5 py-0.5 rounded-full text-[10px] font-semibold ${
|
||||
isBlocked ? "bg-red-100 text-red-700" : isMasked ? "bg-amber-100 text-amber-700" : isPassed ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{result.action.charAt(0).toUpperCase() + result.action.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{isMasked && result.output_text && (
|
||||
<div className="bg-white border border-amber-200 rounded p-2">
|
||||
<label className="text-[10px] font-medium text-gray-600 mb-1 block">Output Text</label>
|
||||
<div className="font-mono text-xs text-gray-900 whitespace-pre-wrap break-words">{result.output_text}</div>
|
||||
</div>
|
||||
)}
|
||||
{isBlocked && result.details && (
|
||||
<div className="bg-white border border-red-200 rounded p-2">
|
||||
<label className="text-[10px] font-medium text-gray-600 mb-1 block">Details</label>
|
||||
<p className="text-xs text-red-700">{result.details}</p>
|
||||
</div>
|
||||
)}
|
||||
{isPassed && (
|
||||
<div className="text-[10px] text-green-700">Passed unchanged.</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{testResults && testResults.length === 0 && !isTestLoading && (
|
||||
<p className="text-xs text-gray-400 text-center py-3">No testable guardrails in selected templates.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={null}
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
width={820}
|
||||
width={showTestPanel ? 1200 : 820}
|
||||
footer={null}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
@@ -290,26 +745,14 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<Spin size="small" />
|
||||
<span className="text-sm text-gray-600">
|
||||
Analyzing your requirements...
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">Analyzing your requirements...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSuggest}
|
||||
loading={isLoading}
|
||||
disabled={!hasInput || !selectedModel || isLoading}
|
||||
>
|
||||
<Button variant="secondary" onClick={handleCancel} disabled={isLoading}>Cancel</Button>
|
||||
<Button onClick={handleSuggest} loading={isLoading} disabled={!hasInput || !selectedModel || isLoading}>
|
||||
{isLoading ? "Analyzing..." : "Suggest Policies"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -317,130 +760,35 @@ const AiSuggestionModal: React.FC<AiSuggestionModalProps> = ({
|
||||
) : (
|
||||
/* ── Results phase ── */
|
||||
<div className="px-8 py-6">
|
||||
{suggestions && suggestions.length > 0 ? (
|
||||
<div className="space-y-3 max-h-[450px] overflow-y-auto pr-1">
|
||||
{suggestions.map((suggestion) => {
|
||||
const template = getTemplateById(suggestion.template_id);
|
||||
if (!template) return null;
|
||||
const isSelected = selectedIds.has(suggestion.template_id);
|
||||
return (
|
||||
<div
|
||||
key={suggestion.template_id}
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? "border-blue-400 bg-blue-50/60 shadow-sm"
|
||||
: "border-gray-200 hover:border-gray-300 hover:shadow-sm"
|
||||
}`}
|
||||
onClick={() => toggleTemplate(suggestion.template_id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => toggleTemplate(suggestion.template_id)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm text-gray-900">
|
||||
{template.title}
|
||||
</span>
|
||||
{template.complexity && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium border ${
|
||||
template.complexity === "Low"
|
||||
? "bg-gray-50 text-gray-500 border-gray-200"
|
||||
: template.complexity === "Medium"
|
||||
? "bg-blue-50 text-blue-500 border-blue-100"
|
||||
: "bg-purple-50 text-purple-500 border-purple-100"
|
||||
}`}>
|
||||
{template.complexity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
{template.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
{template.guardrails && template.guardrails.slice(0, 4).map((g: string) => (
|
||||
<span key={g} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600">
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
{template.guardrails && template.guardrails.length > 4 && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
+{template.guardrails.length - 4} more
|
||||
</span>
|
||||
)}
|
||||
{template.estimated_latency && (
|
||||
<>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
template.estimated_latency.includes("<1ms")
|
||||
? "bg-green-50 text-green-600"
|
||||
: "bg-amber-50 text-amber-600"
|
||||
}`}>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{template.estimated_latency}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2.5 flex items-start gap-1.5">
|
||||
<svg className="w-3.5 h-3.5 text-blue-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<p className="text-xs text-blue-600 leading-relaxed">
|
||||
{suggestion.reason}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{showTestPanel && selectedIds.size > 0 ? (
|
||||
/* Side-by-side layout: suggestions left, test panel right */
|
||||
<div className="flex gap-6" style={{ minHeight: "500px", maxHeight: "70vh" }}>
|
||||
{/* Left: suggestions */}
|
||||
<div className="w-1/2 overflow-y-auto pr-2">
|
||||
{renderSuggestionsList()}
|
||||
</div>
|
||||
{/* Right: test panel */}
|
||||
<div className="w-1/2 border-l border-gray-200 pl-6 overflow-y-auto">
|
||||
{renderTestPanel()}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="font-medium">No matching templates found</p>
|
||||
<p className="text-sm mt-1">
|
||||
Try adjusting your examples or description.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explanation */}
|
||||
{explanation && suggestions && suggestions.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Why these templates
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{explanation}</p>
|
||||
/* Normal single-column layout */
|
||||
<div className="max-h-[520px] overflow-y-auto pr-1">
|
||||
{renderSuggestionsList()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-6">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUseSelected}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
Use {selectedIds.size} Selected Template
|
||||
{selectedIds.size !== 1 ? "s" : ""}
|
||||
<div className="flex justify-end gap-3 pt-6 border-t border-gray-100 mt-4">
|
||||
<Button variant="secondary" onClick={handleBack}>Back</Button>
|
||||
{suggestions && suggestions.length > 0 && selectedIds.size > 0 && !showTestPanel && (
|
||||
<Button variant="secondary" onClick={() => setShowTestPanel(true)}>
|
||||
Test Suggestions
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleUseSelected} disabled={selectedIds.size === 0}>
|
||||
Use {selectedIds.size} Selected Template{selectedIds.size !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user