mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
refactor(ui): migrate log details drawer off antd to shadcn
Replaces Ant Design across every source file under src/components/view_logs, so the request log drawer and its viewers compose @/components/ui primitives. - Drawer becomes Sheet, Collapse becomes Collapsible, Segmented and Radio.Group become Tabs, Tag becomes Badge, Descriptions becomes a local grid helper - every lucide icon carries an explicit size class, since antd icons render at 1em while lucide defaults to 24px - two tests dropped assertions on antd internal class names in favour of rendered text and roles, and the Pretty/JSON case now proves the toggle actually swaps the body rather than only that both controls render - drops the eslint suppressions these files no longer need
This commit is contained in:
parent
423b791ee0
commit
03d2b16bcb
15 changed files with 1154 additions and 1109 deletions
|
|
@ -3506,29 +3506,15 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/CostBreakdownViewer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/EvalViewer/EvalViewer.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/GuardrailViewer/CompliancePanel.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -3541,31 +3527,17 @@
|
|||
"src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 4
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/DrawerHeader.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 3
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/LogDetailsDrawer.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
|
|
@ -3575,36 +3547,11 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/RealtimePrettyView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/LogDetailsDrawer/useKeyboardNavigation.ts": {
|
||||
"react-hooks/immutability": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/ToolsSection/FormattedToolView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/ToolsSection/ToolExpandedContent.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/ToolsSection/ToolItem.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/VectorStoreViewer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/view_logs/columns.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { Collapse } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
export interface CostBreakdown {
|
||||
|
|
@ -49,6 +50,7 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
cacheReadTokens,
|
||||
cacheCreationTokens,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isCached = cacheHit?.toLowerCase() === "true";
|
||||
const hasTokenCounts = promptTokens !== undefined || completionTokens !== undefined;
|
||||
|
||||
|
|
@ -90,197 +92,195 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h3 className="text-lg font-medium text-gray-900">Cost Breakdown</h3>
|
||||
<div className="flex items-center space-x-2 mr-4">
|
||||
<span className="text-sm text-gray-500">Total:</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{formatCost(totalSpend)}
|
||||
{isCached && " (Cached)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Base Token Costs */}
|
||||
<div className="space-y-2 max-w-2xl">
|
||||
{(() => {
|
||||
const hasCacheBreakdown =
|
||||
costBreakdown?.cache_read_cost !== undefined || costBreakdown?.cache_creation_cost !== undefined;
|
||||
if (hasCacheBreakdown) {
|
||||
// Separate line items: Input / Cache Read / Cache Write
|
||||
const rawCost = isCached
|
||||
? 0
|
||||
: (inputCost ?? 0) -
|
||||
(costBreakdown?.cache_read_cost ?? 0) -
|
||||
(costBreakdown?.cache_creation_cost ?? 0);
|
||||
return (
|
||||
<>
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(rawCost)}
|
||||
{rawInputTokens !== undefined && rawInputTokens !== null && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({rawInputTokens.toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{(costBreakdown?.cache_read_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Read Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_read_cost)}
|
||||
{(cacheReadTokens ?? 0) > 0 && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({(cacheReadTokens ?? 0).toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(costBreakdown?.cache_creation_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Write Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_creation_cost)}
|
||||
{(cacheCreationTokens ?? 0) > 0 && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({(cacheCreationTokens ?? 0).toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
|
||||
)}
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h3 className="text-lg font-medium text-gray-900">Cost Breakdown</h3>
|
||||
<div className="flex items-center space-x-2 mr-4">
|
||||
<span className="text-sm text-gray-500">Total:</span>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{formatCost(totalSpend)}
|
||||
{isCached && " (Cached)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Base Token Costs */}
|
||||
<div className="space-y-2 max-w-2xl">
|
||||
{(() => {
|
||||
const hasCacheBreakdown =
|
||||
costBreakdown?.cache_read_cost !== undefined || costBreakdown?.cache_creation_cost !== undefined;
|
||||
if (hasCacheBreakdown) {
|
||||
// Separate line items: Input / Cache Read / Cache Write
|
||||
const rawCost = isCached
|
||||
? 0
|
||||
: (inputCost ?? 0) -
|
||||
(costBreakdown?.cache_read_cost ?? 0) -
|
||||
(costBreakdown?.cache_creation_cost ?? 0);
|
||||
return (
|
||||
<>
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(inputCost)}
|
||||
{promptTokens !== undefined && (
|
||||
{formatCost(rawCost)}
|
||||
{rawInputTokens !== undefined && rawInputTokens !== null && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({promptTokens.toLocaleString()} prompt tokens)
|
||||
({rawInputTokens.toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{(costBreakdown?.cache_read_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Read Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_read_cost)}
|
||||
{(cacheReadTokens ?? 0) > 0 && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({(cacheReadTokens ?? 0).toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(costBreakdown?.cache_creation_cost ?? 0) > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Prompt Cache Write Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(isCached ? 0 : costBreakdown?.cache_creation_cost)}
|
||||
{(cacheCreationTokens ?? 0) > 0 && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({(cacheCreationTokens ?? 0).toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Output Cost:</span>
|
||||
<span className="text-gray-600 font-medium w-1/3">Input Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(outputCost)}
|
||||
{completionTokens !== undefined && (
|
||||
{formatCost(inputCost)}
|
||||
{promptTokens !== undefined && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({completionTokens.toLocaleString()} completion tokens)
|
||||
({promptTokens.toLocaleString()} prompt tokens)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{costBreakdown?.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Tool Usage Cost:</span>
|
||||
<span className="text-gray-900">{formatCost(costBreakdown.tool_usage_cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown?.additional_costs &&
|
||||
Object.entries(costBreakdown.additional_costs)
|
||||
.filter(([, value]) => value != null && value !== 0)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">{key}:</span>
|
||||
<span className="text-gray-900">{formatCost(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Subtotal / Original Cost - hide when cached since it would be $0 */}
|
||||
{!isCached && (
|
||||
<div className="pt-2 border-t border-gray-100 max-w-2xl">
|
||||
<div className="flex text-sm font-semibold">
|
||||
<span className="text-gray-900 w-1/3">Original LLM Cost:</span>
|
||||
<span className="text-gray-900">{formatCost(originalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Adjustments (Discount & Margin) */}
|
||||
{(hasDiscount || hasMargin) && (
|
||||
<div className="pt-2 space-y-2 max-w-2xl">
|
||||
{/* Discounts */}
|
||||
{hasDiscount && (
|
||||
<div className="space-y-2">
|
||||
{costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">
|
||||
Discount ({formatPercent(costBreakdown.discount_percent)}):
|
||||
</span>
|
||||
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown.discount_amount !== undefined &&
|
||||
costBreakdown.discount_percent === undefined && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">Discount Amount:</span>
|
||||
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Margins */}
|
||||
{hasMargin && (
|
||||
<div className="space-y-2">
|
||||
{costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">
|
||||
Margin ({formatPercent(costBreakdown.margin_percent)}):
|
||||
</span>
|
||||
<span className="text-gray-900">
|
||||
+
|
||||
{formatCost(
|
||||
(costBreakdown.margin_total_amount || 0) - (costBreakdown.margin_fixed_amount || 0),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">Margin:</span>
|
||||
<span className="text-gray-900">+{formatCost(costBreakdown.margin_fixed_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final Summary */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 max-w-2xl">
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold text-sm text-gray-900 w-1/3">Final Calculated Cost:</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{formatCost(totalCost)}
|
||||
{isCached && " (Cached)"}
|
||||
);
|
||||
})()}
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Output Cost:</span>
|
||||
<span className="text-gray-900">
|
||||
{formatCost(outputCost)}
|
||||
{completionTokens !== undefined && (
|
||||
<span className="text-gray-500 font-normal ml-1">
|
||||
({completionTokens.toLocaleString()} completion tokens)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{costBreakdown?.tool_usage_cost !== undefined && costBreakdown.tool_usage_cost > 0 && (
|
||||
<div className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">Tool Usage Cost:</span>
|
||||
<span className="text-gray-900">{formatCost(costBreakdown.tool_usage_cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown?.additional_costs &&
|
||||
Object.entries(costBreakdown.additional_costs)
|
||||
.filter(([, value]) => value != null && value !== 0)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex text-sm">
|
||||
<span className="text-gray-600 font-medium w-1/3">{key}:</span>
|
||||
<span className="text-gray-900">{formatCost(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Subtotal / Original Cost - hide when cached since it would be $0 */}
|
||||
{!isCached && (
|
||||
<div className="pt-2 border-t border-gray-100 max-w-2xl">
|
||||
<div className="flex text-sm font-semibold">
|
||||
<span className="text-gray-900 w-1/3">Original LLM Cost:</span>
|
||||
<span className="text-gray-900">{formatCost(originalCost)}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 2: Adjustments (Discount & Margin) */}
|
||||
{(hasDiscount || hasMargin) && (
|
||||
<div className="pt-2 space-y-2 max-w-2xl">
|
||||
{/* Discounts */}
|
||||
{hasDiscount && (
|
||||
<div className="space-y-2">
|
||||
{costBreakdown.discount_percent !== undefined && costBreakdown.discount_percent !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">
|
||||
Discount ({formatPercent(costBreakdown.discount_percent)}):
|
||||
</span>
|
||||
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown.discount_amount !== undefined && costBreakdown.discount_percent === undefined && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">Discount Amount:</span>
|
||||
<span className="text-gray-900">-{formatCost(costBreakdown.discount_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Margins */}
|
||||
{hasMargin && (
|
||||
<div className="space-y-2">
|
||||
{costBreakdown.margin_percent !== undefined && costBreakdown.margin_percent !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">
|
||||
Margin ({formatPercent(costBreakdown.margin_percent)}):
|
||||
</span>
|
||||
<span className="text-gray-900">
|
||||
+
|
||||
{formatCost(
|
||||
(costBreakdown.margin_total_amount || 0) - (costBreakdown.margin_fixed_amount || 0),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{costBreakdown.margin_fixed_amount !== undefined && costBreakdown.margin_fixed_amount !== 0 && (
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<span className="font-medium w-1/3">Margin:</span>
|
||||
<span className="text-gray-900">+{formatCost(costBreakdown.margin_fixed_amount)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final Summary */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 max-w-2xl">
|
||||
<div className="flex items-center">
|
||||
<span className="font-bold text-sm text-gray-900 w-1/3">Final Calculated Cost:</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{formatCost(totalCost)}
|
||||
{isCached && " (Cached)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React from "react";
|
||||
import { Card, Tag, Table, Typography, Space, Tooltip } from "antd";
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, ExperimentOutlined } from "@ant-design/icons";
|
||||
|
||||
const { Text } = Typography;
|
||||
import { CircleCheck, CircleX, FlaskConical } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface EvalVerdict {
|
||||
criterion_name: string;
|
||||
|
|
@ -36,10 +37,10 @@ export default function EvalViewer({ data }: EvalViewerProps) {
|
|||
return (
|
||||
<div className="mb-6">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<ExperimentOutlined style={{ fontSize: 16, color: "#6366f1" }} />
|
||||
<Text strong style={{ fontSize: 15 }}>
|
||||
<FlaskConical className="size-4" style={{ color: "#6366f1" }} />
|
||||
<span className="font-semibold" style={{ fontSize: 15 }}>
|
||||
LLM Judge Results
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{entries.map((entry, idx) => (
|
||||
|
|
@ -56,151 +57,159 @@ function EvalEntryCard({ entry }: { entry: EvalInformation }) {
|
|||
// Filter out synthetic "Overall" row the judge sometimes appends — it's already in the header
|
||||
const verdicts = (entry.verdicts || []).filter((v) => (v.criterion_name || "").toLowerCase() !== "overall");
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Criterion",
|
||||
dataIndex: "criterion_name",
|
||||
key: "criterion_name",
|
||||
width: 160,
|
||||
render: (v: string) => (
|
||||
<Text strong style={{ whiteSpace: "nowrap" }}>
|
||||
{v}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Weight",
|
||||
dataIndex: "weight",
|
||||
key: "weight",
|
||||
width: 65,
|
||||
render: (v: number) =>
|
||||
v != null ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{v}%
|
||||
</Text>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: "Score",
|
||||
dataIndex: "score",
|
||||
key: "score",
|
||||
width: 65,
|
||||
render: (v: number) => (
|
||||
<Text style={{ color: v >= 70 ? "#52c41a" : v >= 50 ? "#faad14" : "#ff4d4f", fontWeight: 600 }}>{v}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title="Score × Weight — how much each criterion contributes to the final score">
|
||||
<span style={{ borderBottom: "1px dashed #aaa", cursor: "help" }}>Weighted</span>
|
||||
</Tooltip>
|
||||
),
|
||||
key: "weighted",
|
||||
width: 75,
|
||||
render: (_: unknown, row: EvalVerdict) => {
|
||||
if (row.weight == null) return null;
|
||||
const contrib = (row.score * row.weight) / 100;
|
||||
return (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{contrib % 1 === 0 ? contrib : contrib.toFixed(1)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Comment",
|
||||
dataIndex: "reasoning",
|
||||
key: "reasoning",
|
||||
ellipsis: { showTitle: false },
|
||||
render: (v: string) => (
|
||||
<Tooltip title={v}>
|
||||
<span style={{ fontSize: 12 }}>{v}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
const hasWeights = verdicts.some((v) => v.weight != null);
|
||||
const weightedTotal = verdicts.reduce((sum, v) => sum + (v.weight != null ? (v.score * v.weight) / 100 : 0), 0);
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="mb-3"
|
||||
style={{ borderLeft: `3px solid ${scoreColor}` }}
|
||||
title={
|
||||
<Space>
|
||||
{passed ? (
|
||||
<CheckCircleOutlined style={{ color: "#52c41a" }} />
|
||||
) : (
|
||||
<CloseCircleOutlined style={{ color: "#ff4d4f" }} />
|
||||
)}
|
||||
<Text strong>{entry.eval_name}</Text>
|
||||
<Tag color={passed ? "success" : "error"}>{passed ? "PASSED" : "FAILED"}</Tag>
|
||||
<Tooltip
|
||||
title={`Weighted average of all criterion scores. Each criterion has a weight (%) set when the eval was created — higher-weight criteria count more toward the final score.`}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12, cursor: "help", borderBottom: "1px dashed #aaa" }}>
|
||||
{entry.overall_score?.toFixed(0)} / 100
|
||||
{entry.threshold != null && ` (threshold: ${entry.threshold})`}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space size="small">
|
||||
{entry.judge_model && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Judge: {entry.judge_model}
|
||||
</Text>
|
||||
)}
|
||||
{entry.iteration != null && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Iter: {entry.iteration + 1}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{entry.eval_error && (
|
||||
<Text type="warning" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
|
||||
Judge error: {entry.eval_error}
|
||||
</Text>
|
||||
)}
|
||||
<Card size="sm" className="mb-3" style={{ borderLeft: `3px solid ${scoreColor}` }}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{passed ? (
|
||||
<CircleCheck className="size-4" style={{ color: "#52c41a" }} />
|
||||
) : (
|
||||
<CircleX className="size-4" style={{ color: "#ff4d4f" }} />
|
||||
)}
|
||||
<span className="font-semibold">{entry.eval_name}</span>
|
||||
<Badge variant={passed ? "secondary" : "destructive"}>{passed ? "PASSED" : "FAILED"}</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{ fontSize: 12, cursor: "help", borderBottom: "1px dashed #aaa" }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{entry.overall_score?.toFixed(0)} / 100
|
||||
{entry.threshold != null && ` (threshold: ${entry.threshold})`}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Weighted average of all criterion scores. Each criterion has a weight (%) set when the eval was
|
||||
created — higher-weight criteria count more toward the final score.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardAction>
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.judge_model && (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
Judge: {entry.judge_model}
|
||||
</span>
|
||||
)}
|
||||
{entry.iteration != null && (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
Iter: {entry.iteration + 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
{verdicts.length > 0 ? (
|
||||
<Table
|
||||
dataSource={verdicts}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
size="small"
|
||||
rowKey="criterion_name"
|
||||
scroll={{ x: true }}
|
||||
summary={() => {
|
||||
const hasWeights = verdicts.some((v) => v.weight != null);
|
||||
if (!hasWeights) return null;
|
||||
const total = verdicts.reduce((sum, v) => sum + (v.weight != null ? (v.score * v.weight) / 100 : 0), 0);
|
||||
return (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}>
|
||||
<Text strong style={{ fontSize: 12 }}>
|
||||
Total
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} />
|
||||
<Table.Summary.Cell index={2} />
|
||||
<Table.Summary.Cell index={3}>
|
||||
<Text strong style={{ fontSize: 12, color: scoreColor }}>
|
||||
{total % 1 === 0 ? total : total.toFixed(1)}
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4} />
|
||||
</Table.Summary.Row>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Score: {entry.overall_score?.toFixed(1)} — no per-criterion breakdown available.
|
||||
</Text>
|
||||
)}
|
||||
<CardContent>
|
||||
{entry.eval_error && (
|
||||
<span className="text-amber-600" style={{ display: "block", marginBottom: 8, fontSize: 12 }}>
|
||||
Judge error: {entry.eval_error}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{verdicts.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead style={{ width: 160 }}>Criterion</TableHead>
|
||||
<TableHead style={{ width: 65 }}>Weight</TableHead>
|
||||
<TableHead style={{ width: 65 }}>Score</TableHead>
|
||||
<TableHead style={{ width: 75 }}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span style={{ borderBottom: "1px dashed #aaa", cursor: "help" }} />}>
|
||||
Weighted
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Score × Weight — how much each criterion contributes to the final score
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableHead>
|
||||
<TableHead>Comment</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{verdicts.map((row) => {
|
||||
const contrib = row.weight != null ? (row.score * row.weight) / 100 : null;
|
||||
return (
|
||||
<TableRow key={row.criterion_name}>
|
||||
<TableCell>
|
||||
<span className="font-semibold" style={{ whiteSpace: "nowrap" }}>
|
||||
{row.criterion_name}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.weight != null ? (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
{row.weight}%
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
style={{
|
||||
color: row.score >= 70 ? "#52c41a" : row.score >= 50 ? "#faad14" : "#ff4d4f",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{row.score}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{contrib != null ? (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
{contrib % 1 === 0 ? contrib : contrib.toFixed(1)}
|
||||
</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span style={{ fontSize: 12 }} />}>{row.reasoning}</TooltipTrigger>
|
||||
<TooltipContent>{row.reasoning}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
{hasWeights && (
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span className="font-semibold" style={{ fontSize: 12 }}>
|
||||
Total
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<span className="font-semibold" style={{ fontSize: 12, color: scoreColor }}>
|
||||
{weightedTotal % 1 === 0 ? weightedTotal : weightedTotal.toFixed(1)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
) : (
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
Score: {entry.overall_score?.toFixed(1)} — no per-criterion breakdown available.
|
||||
</span>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Tooltip } from "antd";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
checkEuAiActCompliance,
|
||||
checkGdprCompliance,
|
||||
|
|
@ -66,9 +66,12 @@ const ComplianceCard = ({
|
|||
{loading ? (
|
||||
<SpinnerIcon />
|
||||
) : error ? (
|
||||
<Tooltip title={error}>
|
||||
<span className="text-gray-400 text-sm">--</span>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className="text-gray-400 text-sm" />}>--</TooltipTrigger>
|
||||
<TooltipContent>{error}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : data?.compliant ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useMemo } from "react";
|
||||
import { Tooltip } from "antd";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import PresidioDetectedEntities from "./PresidioDetectedEntities";
|
||||
import BedrockGuardrailDetails, {
|
||||
BedrockGuardrailResponse,
|
||||
|
|
@ -517,13 +517,20 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => {
|
|||
)}
|
||||
|
||||
{riskScore != null && success && (
|
||||
<Tooltip title={`Risk score: ${riskScore}/10`}>
|
||||
<span
|
||||
className={`px-2 py-0.5 border rounded-sm text-[11px] font-semibold shrink-0 ${getRiskColor(riskScore)}`}
|
||||
>
|
||||
Risk {riskScore}/10
|
||||
</span>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className={`px-2 py-0.5 border rounded-sm text-[11px] font-semibold shrink-0 ${getRiskColor(riskScore)}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Risk {riskScore}/10
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{`Risk score: ${riskScore}/10`}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { Button, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import { CloseOutlined, UpOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronDown, ChevronUp, Copy, X } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LogEntry } from "../columns";
|
||||
import { AutoRouterTag } from "@/components/shared/table_cells";
|
||||
import { ClassifyTag } from "./ClassifyTag";
|
||||
|
|
@ -10,15 +13,11 @@ import {
|
|||
COLOR_BORDER,
|
||||
COLOR_BACKGROUND,
|
||||
SPACING_MEDIUM,
|
||||
SPACING_LARGE,
|
||||
FONT_SIZE_HEADER,
|
||||
FONT_SIZE_MEDIUM,
|
||||
FONT_FAMILY_MONO,
|
||||
SPACING_SMALL,
|
||||
} from "./constants";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface DrawerHeaderProps {
|
||||
log: LogEntry;
|
||||
onClose: () => void;
|
||||
|
|
@ -96,7 +95,7 @@ function ModelProviderSection({
|
|||
providerName?: string;
|
||||
}) {
|
||||
return (
|
||||
<Space size={SPACING_MEDIUM} style={{ marginBottom: SPACING_MEDIUM }}>
|
||||
<div className="flex items-center gap-2" style={{ marginBottom: SPACING_MEDIUM }}>
|
||||
{providerLogo && (
|
||||
<img
|
||||
src={providerLogo}
|
||||
|
|
@ -108,19 +107,19 @@ function ModelProviderSection({
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
<Space size={SPACING_MEDIUM} direction="horizontal">
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold" style={{ fontSize: 14 }}>
|
||||
{model}
|
||||
</Text>
|
||||
</span>
|
||||
{providerName && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
{providerName}
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
<AutoRouterTag modelGroup={modelGroup} />
|
||||
<ClassifyTag origin={internalCallOrigin} />
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -128,24 +127,50 @@ function ModelProviderSection({
|
|||
* Request ID display with copy functionality
|
||||
*/
|
||||
function RequestIdSection({ requestId }: { requestId: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(requestId);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
/* clipboard unavailable in non-secure contexts */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Tooltip title={requestId}>
|
||||
<Text
|
||||
strong
|
||||
copyable={{ text: requestId, tooltips: ["Copy Request ID", "Copied!"] }}
|
||||
style={{
|
||||
fontSize: FONT_SIZE_HEADER,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{requestId}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{
|
||||
fontSize: FONT_SIZE_HEADER,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{requestId}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copied ? "Copied!" : "Copy Request ID"}
|
||||
onClick={handleCopy}
|
||||
className="ml-1 align-middle text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{requestId}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -172,21 +197,29 @@ function NavigationSection({
|
|||
marginLeft: 4,
|
||||
background: "#fafafa",
|
||||
};
|
||||
const splitStyle = { width: 1, height: 20, background: COLOR_BORDER };
|
||||
|
||||
return (
|
||||
<Space size={SPACING_SMALL} split={<div style={{ width: 1, height: 20, background: COLOR_BORDER }} />}>
|
||||
<Button type="text" size="small" onClick={onPrevious}>
|
||||
<UpOutlined />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={onPrevious}>
|
||||
<ChevronUp className="size-4" />
|
||||
<span style={keyboardShortcutStyle}>K</span>
|
||||
</Button>
|
||||
<Button type="text" size="small" onClick={onNext}>
|
||||
<DownOutlined />
|
||||
<div style={splitStyle} />
|
||||
<Button variant="ghost" size="sm" onClick={onNext}>
|
||||
<ChevronDown className="size-4" />
|
||||
<span style={keyboardShortcutStyle}>J</span>
|
||||
</Button>
|
||||
<Tooltip title="ESC to close">
|
||||
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<div style={splitStyle} />
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Button variant="ghost" size="icon-sm" onClick={onClose} />}>
|
||||
<X className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>ESC to close</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -205,17 +238,17 @@ function StatusBar({
|
|||
environment: string;
|
||||
}) {
|
||||
return (
|
||||
<Space size={SPACING_LARGE}>
|
||||
<Tag color={statusColor}>{statusLabel}</Tag>
|
||||
<Tag>Env: {environment}</Tag>
|
||||
<Space size={SPACING_MEDIUM}>
|
||||
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={statusColor === "error" ? "destructive" : "secondary"}>{statusLabel}</Badge>
|
||||
<Badge variant="outline">Env: {environment}</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
{moment(log.startTime).format("MMM D, YYYY h:mm:ss A")}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
</span>
|
||||
<span className="text-muted-foreground" style={{ fontSize: FONT_SIZE_MEDIUM }}>
|
||||
({moment(log.startTime).fromNow()})
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,12 +177,19 @@ describe("LogDetailContent", () => {
|
|||
expect(screen.getByText("Loading request & response data...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Request & Response section with Pretty and JSON view modes", () => {
|
||||
it("should switch the Request & Response body between the Pretty and JSON view modes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LogDetailContent logEntry={createLogEntry()} />);
|
||||
|
||||
expect(screen.getByText("Request & Response")).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "Pretty" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: "JSON" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Pretty")).toBeInTheDocument();
|
||||
expect(screen.getByText("JSON")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("JSON"));
|
||||
expect(screen.getByRole("tab", { name: "Request" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("Pretty"));
|
||||
expect(screen.queryByRole("tab", { name: "Request" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Request and Response tabs when JSON view is selected", async () => {
|
||||
|
|
@ -259,7 +266,7 @@ describe("LogDetailContent", () => {
|
|||
render(<LogDetailContent logEntry={createLogEntry({ cache_hit: "True" })} />);
|
||||
|
||||
expect(screen.getByText("Response Cache")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hit").closest(".ant-tag")).toHaveClass("ant-tag-green");
|
||||
expect(screen.getByText("Hit").className).toMatch(/green/);
|
||||
});
|
||||
|
||||
it("should show prompt cache tokens without an alarming red tag when only provider prompt caching occurred", () => {
|
||||
|
|
@ -282,7 +289,7 @@ describe("LogDetailContent", () => {
|
|||
expect(screen.getByText("34,462")).toBeInTheDocument();
|
||||
expect(screen.getByText("Prompt Cache Creation Tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("83")).toBeInTheDocument();
|
||||
expect(screen.getByText("Miss").closest(".ant-tag")).not.toHaveClass("ant-tag-red");
|
||||
expect(screen.getByText("Miss").className).not.toMatch(/red|destructive/);
|
||||
expect(screen.queryByText("Cache Hit")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -310,8 +317,12 @@ describe("LogDetailContent", () => {
|
|||
const user = userEvent.setup();
|
||||
render(<LogDetailContent logEntry={createLogEntry({ cache_hit: "True" })} />);
|
||||
|
||||
const label = screen.getByText("Response Cache").closest(".ant-space") as HTMLElement;
|
||||
await user.hover(within(label).getByRole("img", { name: "info-circle" }));
|
||||
expect(screen.getByText("Response Cache")).toBeInTheDocument();
|
||||
// Response Cache is the only metric with an info tooltip in this fixture, so an
|
||||
// unscoped lookup still pins the docs link to that label.
|
||||
const infoIcons = screen.getAllByRole("img", { name: /info/i });
|
||||
expect(infoIcons).toHaveLength(1);
|
||||
await user.hover(infoIcons[0]);
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Docs" })).toHaveAttribute(
|
||||
"href",
|
||||
|
|
@ -333,8 +344,11 @@ describe("LogDetailContent", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const label = screen.getByText("Prompt Cache Read Tokens").closest(".ant-space") as HTMLElement;
|
||||
await user.hover(within(label).getByRole("img", { name: "info-circle" }));
|
||||
expect(screen.getByText("Prompt Cache Read Tokens")).toBeInTheDocument();
|
||||
// Prompt Cache Read Tokens is the only metric with an info tooltip in this fixture.
|
||||
const infoIcons = screen.getAllByRole("img", { name: /info/i });
|
||||
expect(infoIcons).toHaveLength(1);
|
||||
await user.hover(infoIcons[0]);
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Docs" })).toHaveAttribute(
|
||||
"href",
|
||||
|
|
@ -370,7 +384,7 @@ describe("LogDetailContent", () => {
|
|||
expect(screen.queryByText("LiteLLM Overhead")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const retriesItem = () => screen.getByText("Retries").closest(".ant-descriptions-item") as HTMLElement;
|
||||
const retriesItem = () => screen.getByText("Retries").parentElement as HTMLElement;
|
||||
|
||||
it("should display attempted_retries / max_retries for Retries when attempted_retries > 0", () => {
|
||||
render(
|
||||
|
|
@ -386,7 +400,7 @@ describe("LogDetailContent", () => {
|
|||
render(<LogDetailContent logEntry={createLogEntry({ metadata: { status: "success", attempted_retries: 0 } })} />);
|
||||
|
||||
const noneTag = within(retriesItem()).getByText("None");
|
||||
expect(noneTag.closest(".ant-tag")).toHaveClass("ant-tag-green");
|
||||
expect(noneTag.className).toMatch(/green/);
|
||||
});
|
||||
|
||||
it("should display '-' for Retries when attempted_retries is absent from metadata", () => {
|
||||
|
|
@ -444,8 +458,8 @@ describe("LogDetailContent", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const descriptions = screen.getByText("Provider").closest(".ant-descriptions-item");
|
||||
const descriptions = screen.getByText("Provider").parentElement as HTMLElement;
|
||||
expect(descriptions).toBeInTheDocument();
|
||||
expect(within(descriptions as HTMLElement).getByText("-")).toBeInTheDocument();
|
||||
expect(within(descriptions).getByText("-")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { useState } from "react";
|
||||
import { Typography, Descriptions, Card, Tag, Tabs, Alert, Collapse, Radio, Space, Spin, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Check, ChevronDown, ChevronRight, CircleAlert, Copy, Info } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { LogEntry } from "../columns";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { PROMPT_CACHE_CREATION_TOOLTIP, PROMPT_CACHE_READ_TOOLTIP } from "@/utils/promptCacheUsage";
|
||||
|
|
@ -32,13 +38,10 @@ import {
|
|||
FONT_SIZE_SMALL,
|
||||
FONT_FAMILY_MONO,
|
||||
SPACING_XLARGE,
|
||||
SPACING_MEDIUM,
|
||||
} from "./constants";
|
||||
import { ToolsSection } from "../ToolsSection";
|
||||
import { PrettyMessagesView } from "./PrettyMessagesView";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export interface LogDetailContentProps {
|
||||
logEntry: LogEntry;
|
||||
/** When true, log details (messages/response) are still being lazy-loaded. */
|
||||
|
|
@ -100,13 +103,16 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
|
|||
<div style={{ padding: `${DRAWER_CONTENT_PADDING} ${DRAWER_CONTENT_PADDING} 0` }}>
|
||||
{/* Error Alert */}
|
||||
{hasError && errorInfo && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Request Failed"
|
||||
description={<ErrorDescription errorInfo={errorInfo} />}
|
||||
className="mb-6"
|
||||
/>
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-6 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm"
|
||||
>
|
||||
<CircleAlert className="size-4 shrink-0 text-destructive" />
|
||||
<div>
|
||||
<div className="font-medium text-destructive">Request Failed</div>
|
||||
<ErrorDescription errorInfo={errorInfo} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
|
|
@ -116,26 +122,31 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
|
|||
|
||||
{/* Request Details */}
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Card title="Request Details" size="small" bordered={false} style={{ marginBottom: 0 }}>
|
||||
<Descriptions column={2} size="small">
|
||||
<Descriptions.Item label="Model">{logEntry.model}</Descriptions.Item>
|
||||
<Descriptions.Item label="Provider">{logEntry.custom_llm_provider || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Call Type">{logEntry.call_type}</Descriptions.Item>
|
||||
<Descriptions.Item label="Model ID">
|
||||
<TruncatedValue value={logEntry.model_id} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="API Base">
|
||||
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
|
||||
</Descriptions.Item>
|
||||
{logEntry.requester_ip_address && (
|
||||
<Descriptions.Item label="IP Address">{logEntry.requester_ip_address}</Descriptions.Item>
|
||||
)}
|
||||
{hasGuardrailData && (
|
||||
<Descriptions.Item label="Guardrail">
|
||||
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
<Card size="sm" style={{ marginBottom: 0 }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Request Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DescriptionList>
|
||||
<DescriptionItem label="Model">{logEntry.model}</DescriptionItem>
|
||||
<DescriptionItem label="Provider">{logEntry.custom_llm_provider || "-"}</DescriptionItem>
|
||||
<DescriptionItem label="Call Type">{logEntry.call_type}</DescriptionItem>
|
||||
<DescriptionItem label="Model ID">
|
||||
<TruncatedValue value={logEntry.model_id} />
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label="API Base">
|
||||
<TruncatedValue value={logEntry.api_base} maxWidth={API_BASE_MAX_WIDTH} />
|
||||
</DescriptionItem>
|
||||
{logEntry.requester_ip_address && (
|
||||
<DescriptionItem label="IP Address">{logEntry.requester_ip_address}</DescriptionItem>
|
||||
)}
|
||||
{hasGuardrailData && (
|
||||
<DescriptionItem label="Guardrail">
|
||||
<GuardrailLabel label={primaryGuardrailLabel} maskedCount={totalMaskedEntities} />
|
||||
</DescriptionItem>
|
||||
)}
|
||||
</DescriptionList>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
|
@ -170,7 +181,7 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
|
|||
{/* Request/Response JSON */}
|
||||
{isLoadingDetails ? (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6 p-8 text-center">
|
||||
<Spin size="default" />
|
||||
<UiLoadingSpinner className="inline-block size-5" />
|
||||
<div style={{ marginTop: 8, color: "#999" }}>Loading request & response data...</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -221,17 +232,64 @@ export function LogDetailContent({ logEntry, isLoadingDetails = false, accessTok
|
|||
// Helper Components
|
||||
// ============================================================================
|
||||
|
||||
function DescriptionList({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">{children}</div>;
|
||||
}
|
||||
|
||||
function DescriptionItem({ label, children }: { label: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-0.5">
|
||||
<span className="shrink-0 text-muted-foreground after:content-[':']">{label}</span>
|
||||
<span className="min-w-0 break-words">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({
|
||||
getText,
|
||||
label,
|
||||
disabled = false,
|
||||
}: {
|
||||
getText: () => string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(getText());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
} catch {
|
||||
/* clipboard unavailable in non-secure contexts */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleCopy}
|
||||
disabled={disabled}
|
||||
aria-label={copied ? "Copied!" : label}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorDescription({ errorInfo }: { errorInfo: any }) {
|
||||
return (
|
||||
<div>
|
||||
{errorInfo.error_code && (
|
||||
<div>
|
||||
<Text strong>Error Code:</Text> {errorInfo.error_code}
|
||||
<span className="font-semibold">Error Code:</span> {errorInfo.error_code}
|
||||
</div>
|
||||
)}
|
||||
{errorInfo.error_message && (
|
||||
<div>
|
||||
<Text strong>Message:</Text> {errorInfo.error_message}
|
||||
<span className="font-semibold">Message:</span> {errorInfo.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -241,16 +299,16 @@ function ErrorDescription({ errorInfo }: { errorInfo: any }) {
|
|||
function TagsSection({ tags }: { tags: Record<string, any> }) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden p-4 mb-6">
|
||||
<Text strong style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
|
||||
<span className="font-semibold" style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
|
||||
Tags
|
||||
</Text>
|
||||
<Space size={SPACING_MEDIUM} wrap>
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.entries(tags).map(([key, value]) => (
|
||||
<Tag key={key}>
|
||||
<Badge key={key} variant="outline">
|
||||
{key}: {String(value)}
|
||||
</Tag>
|
||||
</Badge>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -262,12 +320,12 @@ function GuardrailLabel({ label, maskedCount }: { label: string; maskedCount: nu
|
|||
};
|
||||
|
||||
return (
|
||||
<Space size={SPACING_MEDIUM}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<a onClick={handleClick} style={{ cursor: "pointer" }}>
|
||||
{label}
|
||||
</a>
|
||||
{maskedCount > 0 && <Tag color="blue">{maskedCount} masked</Tag>}
|
||||
</Space>
|
||||
{maskedCount > 0 && <Badge variant="secondary">{maskedCount} masked</Badge>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -291,26 +349,24 @@ const PROMPT_CACHE_DOCS_URL = "https://docs.litellm.ai/docs/completion/prompt_ca
|
|||
|
||||
function MetricLabel({ label, tooltip, docsUrl }: { label: string; tooltip: string; docsUrl: string }) {
|
||||
return (
|
||||
<Space size={4}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span role="img" aria-label={`${label} info`} className="inline-flex text-muted-foreground" />}
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tooltip}{" "}
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#91caff", textDecoration: "underline" }}
|
||||
>
|
||||
<a href={docsUrl} target="_blank" rel="noreferrer" className="underline">
|
||||
Docs
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<InfoCircleOutlined style={{ color: "#8c8c8c" }} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -333,102 +389,111 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
|
|||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Card title="Metrics" size="small" style={{ marginBottom: 0 }}>
|
||||
<Descriptions column={2} size="small">
|
||||
{showAnthropicMessagesInputOutput ? (
|
||||
<>
|
||||
<Descriptions.Item label="Input Tokens">{formatNumberWithCommas(uncachedInputTokens)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Output Tokens">
|
||||
{formatNumberWithCommas(logEntry.completion_tokens)}
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
) : (
|
||||
<Descriptions.Item label="Tokens">
|
||||
<TokenFlow
|
||||
prompt={logEntry.prompt_tokens}
|
||||
completion={logEntry.completion_tokens}
|
||||
total={logEntry.total_tokens}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Duration">
|
||||
{logEntry.request_duration_ms != null ? (logEntry.request_duration_ms / 1000).toFixed(3) : "-"} s
|
||||
</Descriptions.Item>
|
||||
{ttftMs != null && ttftMs > 0 && (
|
||||
<Descriptions.Item label="Time to First Token">{(ttftMs / 1000).toFixed(3)} s</Descriptions.Item>
|
||||
)}
|
||||
|
||||
{showResponseCache && (
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Response Cache"
|
||||
tooltip={RESPONSE_CACHE_TOOLTIP}
|
||||
docsUrl={RESPONSE_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tag color={isResponseCacheHit ? "green" : "default"}>{isResponseCacheHit ? "Hit" : "Miss"}</Tag>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{promptCacheReadTokens > 0 && (
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Prompt Cache Read Tokens"
|
||||
tooltip={PROMPT_CACHE_READ_TOOLTIP}
|
||||
docsUrl={PROMPT_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{formatNumberWithCommas(promptCacheReadTokens)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{promptCacheCreationTokens > 0 && (
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Prompt Cache Creation Tokens"
|
||||
tooltip={PROMPT_CACHE_CREATION_TOOLTIP}
|
||||
docsUrl={PROMPT_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{formatNumberWithCommas(promptCacheCreationTokens)}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
|
||||
{metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
|
||||
<Descriptions.Item label="LiteLLM Overhead">
|
||||
{metadata.litellm_overhead_time_ms.toFixed(2)} ms
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
|
||||
<Descriptions.Item label="Retries">
|
||||
{metadata?.attempted_retries !== undefined && metadata?.attempted_retries !== null ? (
|
||||
metadata.attempted_retries > 0 ? (
|
||||
<>
|
||||
{metadata.attempted_retries}
|
||||
{metadata.max_retries !== undefined && metadata.max_retries !== null
|
||||
? ` / ${metadata.max_retries}`
|
||||
: ""}
|
||||
</>
|
||||
) : (
|
||||
<Tag color="green">None</Tag>
|
||||
)
|
||||
<Card size="sm" style={{ marginBottom: 0 }}>
|
||||
<CardHeader>
|
||||
<CardTitle>Metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DescriptionList>
|
||||
{showAnthropicMessagesInputOutput ? (
|
||||
<>
|
||||
<DescriptionItem label="Input Tokens">{formatNumberWithCommas(uncachedInputTokens)}</DescriptionItem>
|
||||
<DescriptionItem label="Output Tokens">
|
||||
{formatNumberWithCommas(logEntry.completion_tokens)}
|
||||
</DescriptionItem>
|
||||
</>
|
||||
) : (
|
||||
"-"
|
||||
<DescriptionItem label="Tokens">
|
||||
<TokenFlow
|
||||
prompt={logEntry.prompt_tokens}
|
||||
completion={logEntry.completion_tokens}
|
||||
total={logEntry.total_tokens}
|
||||
/>
|
||||
</DescriptionItem>
|
||||
)}
|
||||
<DescriptionItem label="Cost">${formatNumberWithCommas(logEntry.spend || 0, 8)}</DescriptionItem>
|
||||
<DescriptionItem label="Duration">
|
||||
{logEntry.request_duration_ms != null ? (logEntry.request_duration_ms / 1000).toFixed(3) : "-"} s
|
||||
</DescriptionItem>
|
||||
{ttftMs != null && ttftMs > 0 && (
|
||||
<DescriptionItem label="Time to First Token">{(ttftMs / 1000).toFixed(3)} s</DescriptionItem>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
|
||||
<Descriptions.Item label="Start Time">
|
||||
{moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="End Time">
|
||||
{moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{showResponseCache && (
|
||||
<DescriptionItem
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Response Cache"
|
||||
tooltip={RESPONSE_CACHE_TOOLTIP}
|
||||
docsUrl={RESPONSE_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Badge variant="secondary" className={isResponseCacheHit ? "bg-green-100 text-green-700" : undefined}>
|
||||
{isResponseCacheHit ? "Hit" : "Miss"}
|
||||
</Badge>
|
||||
</DescriptionItem>
|
||||
)}
|
||||
{promptCacheReadTokens > 0 && (
|
||||
<DescriptionItem
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Prompt Cache Read Tokens"
|
||||
tooltip={PROMPT_CACHE_READ_TOOLTIP}
|
||||
docsUrl={PROMPT_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{formatNumberWithCommas(promptCacheReadTokens)}
|
||||
</DescriptionItem>
|
||||
)}
|
||||
{promptCacheCreationTokens > 0 && (
|
||||
<DescriptionItem
|
||||
label={
|
||||
<MetricLabel
|
||||
label="Prompt Cache Creation Tokens"
|
||||
tooltip={PROMPT_CACHE_CREATION_TOOLTIP}
|
||||
docsUrl={PROMPT_CACHE_DOCS_URL}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{formatNumberWithCommas(promptCacheCreationTokens)}
|
||||
</DescriptionItem>
|
||||
)}
|
||||
|
||||
{metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
|
||||
<DescriptionItem label="LiteLLM Overhead">
|
||||
{metadata.litellm_overhead_time_ms.toFixed(2)} ms
|
||||
</DescriptionItem>
|
||||
)}
|
||||
|
||||
<DescriptionItem label="Retries">
|
||||
{metadata?.attempted_retries !== undefined && metadata?.attempted_retries !== null ? (
|
||||
metadata.attempted_retries > 0 ? (
|
||||
<>
|
||||
{metadata.attempted_retries}
|
||||
{metadata.max_retries !== undefined && metadata.max_retries !== null
|
||||
? ` / ${metadata.max_retries}`
|
||||
: ""}
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-700">
|
||||
None
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</DescriptionItem>
|
||||
|
||||
<DescriptionItem label="Start Time">
|
||||
{moment(logEntry.startTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label="End Time">
|
||||
{moment(logEntry.endTime).format("YYYY-MM-DDTHH:mm:ss.SSS[Z]")}
|
||||
</DescriptionItem>
|
||||
</DescriptionList>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -449,6 +514,7 @@ function RequestResponseSection({
|
|||
getFormattedResponse,
|
||||
logEntry,
|
||||
}: RequestResponseSectionProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
|
||||
const [viewMode, setViewMode] = useState<"pretty" | "json">("pretty");
|
||||
|
||||
|
|
@ -476,90 +542,76 @@ function RequestResponseSection({
|
|||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".ant-radio-group")) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h3 className="text-lg font-medium text-gray-900" style={{ margin: 0 }}>
|
||||
Request & Response
|
||||
</h3>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
<Radio.Button value="pretty">Pretty</Radio.Button>
|
||||
<Radio.Button value="json">JSON</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
{viewMode === "pretty" ? (
|
||||
<PrettyMessagesView
|
||||
request={getRawRequest()}
|
||||
response={getFormattedResponse()}
|
||||
metrics={{
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
input_cost: inputCost,
|
||||
output_cost: outputCost,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
|
||||
tabBarExtraContent={
|
||||
<Text
|
||||
copyable={{
|
||||
text: getCopyText(),
|
||||
tooltips: ["Copy JSON", "Copied!"],
|
||||
}}
|
||||
disabled={activeTab === TAB_RESPONSE && !hasResponse && !hasError}
|
||||
/>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
key: TAB_REQUEST,
|
||||
label: "Request",
|
||||
children: (
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
<JsonViewer data={getRawRequest()} mode="formatted" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: TAB_RESPONSE,
|
||||
label: "Response",
|
||||
children: (
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
{hasResponse || hasError ? (
|
||||
<JsonViewer data={getFormattedResponse()} mode="formatted" />
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
|
||||
Response data not available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<Tabs value={viewMode} onValueChange={(value) => setViewMode(value as "pretty" | "json")}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" }}>
|
||||
<CollapsibleTrigger className="flex flex-1 items-center gap-3 px-4 py-3 text-left">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-900" style={{ margin: 0 }}>
|
||||
Request & Response
|
||||
</h3>
|
||||
</CollapsibleTrigger>
|
||||
<TabsList className="mr-4">
|
||||
<TabsTrigger value="pretty">Pretty</TabsTrigger>
|
||||
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div>
|
||||
<TabsContent value="pretty">
|
||||
<PrettyMessagesView
|
||||
request={getRawRequest()}
|
||||
response={getFormattedResponse()}
|
||||
metrics={{
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
input_cost: inputCost,
|
||||
output_cost: outputCost,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="json">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value={TAB_REQUEST}>Request</TabsTrigger>
|
||||
<TabsTrigger value={TAB_RESPONSE}>Response</TabsTrigger>
|
||||
</TabsList>
|
||||
<CopyButton
|
||||
getText={getCopyText}
|
||||
label="Copy JSON"
|
||||
disabled={activeTab === TAB_RESPONSE && !hasResponse && !hasError}
|
||||
/>
|
||||
</div>
|
||||
<TabsContent value={TAB_REQUEST}>
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
<JsonViewer data={getRawRequest()} mode="formatted" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value={TAB_RESPONSE}>
|
||||
<div style={{ paddingTop: SPACING_XLARGE, paddingBottom: SPACING_XLARGE }}>
|
||||
{hasResponse || hasError ? (
|
||||
<JsonViewer data={getFormattedResponse()} mode="formatted" />
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
|
||||
Response data not available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Tabs>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -602,43 +654,40 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
|
|||
}
|
||||
|
||||
function MetadataSection({ metadata }: { metadata: Record<string, any> }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: <h3 className="text-lg font-medium text-gray-900">Metadata</h3>,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
|
||||
<Text
|
||||
copyable={{
|
||||
text: JSON.stringify(metadata, null, 2),
|
||||
tooltips: ["Copy Metadata", "Copied!"],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
maxHeight: METADATA_MAX_HEIGHT,
|
||||
overflowY: "auto",
|
||||
fontSize: FONT_SIZE_SMALL,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-900">Metadata</h3>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 8 }}>
|
||||
<CopyButton getText={() => JSON.stringify(metadata, null, 2)} label="Copy Metadata" />
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
maxHeight: METADATA_MAX_HEIGHT,
|
||||
overflowY: "auto",
|
||||
fontSize: FONT_SIZE_SMALL,
|
||||
fontFamily: FONT_FAMILY_MONO,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Drawer, Segmented } from "antd";
|
||||
import { CheckOutlined, CopyOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { Bot, Sparkles, Wrench } from "lucide-react";
|
||||
import { Bot, Check, ChevronLeft, ChevronRight, Copy, Sparkles, Wrench } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LogEntry } from "../columns";
|
||||
import { AutoRouterIcon, useIsAutoRoutedModelGroup } from "@/components/shared/table_cells";
|
||||
import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "../constants";
|
||||
|
|
@ -297,181 +298,183 @@ export function LogDetailsDrawer({
|
|||
if (!currentLog || !enrichedLog) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={null}
|
||||
placement="right"
|
||||
onClose={onClose}
|
||||
<Sheet
|
||||
open={open}
|
||||
width={DRAWER_WIDTH}
|
||||
closable={false}
|
||||
mask={true}
|
||||
maskClosable={true}
|
||||
styles={{
|
||||
body: { padding: 0, overflow: "hidden" },
|
||||
header: { display: "none" },
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onClose();
|
||||
}}
|
||||
>
|
||||
<div style={{ height: "100%" }} className="flex relative">
|
||||
{!isSidebarCollapsed ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => setIsSidebarCollapsed(true)}
|
||||
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
|
||||
aria-label="Collapse trace sidebar"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
onClick={() => setIsSidebarCollapsed(false)}
|
||||
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
|
||||
aria-label="Expand trace sidebar"
|
||||
/>
|
||||
)}
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="border-r border-slate-200 bg-slate-50 flex flex-col" style={{ width: SIDEBAR_WIDTH_PX }}>
|
||||
<div className="pl-12 pr-3 py-2 border-b border-slate-200 bg-white">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">
|
||||
{isSessionMode ? "Session" : "Trace"}
|
||||
</div>
|
||||
<div className="font-mono text-[12px] text-slate-900 leading-tight flex items-center gap-1">
|
||||
<span className="truncate">{leftPanelDisplayId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLeftPanelId}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
aria-label="Copy trace id"
|
||||
>
|
||||
{copiedLeftPanelId ? (
|
||||
<CheckOutlined className="text-[11px]" />
|
||||
) : (
|
||||
<CopyOutlined className="text-[11px]" />
|
||||
)}
|
||||
</button>
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="gap-0 overflow-hidden p-0 data-[side=right]:sm:max-w-none"
|
||||
style={{ width: DRAWER_WIDTH }}
|
||||
>
|
||||
<div style={{ height: "100%" }} className="flex relative">
|
||||
{!isSidebarCollapsed ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setIsSidebarCollapsed(true)}
|
||||
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
|
||||
aria-label="Collapse trace sidebar"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setIsSidebarCollapsed(false)}
|
||||
className="absolute top-2 left-2 z-20 bg-white! border! border-slate-200! rounded-md!"
|
||||
aria-label="Expand trace sidebar"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="border-r border-slate-200 bg-slate-50 flex flex-col" style={{ width: SIDEBAR_WIDTH_PX }}>
|
||||
<div className="pl-12 pr-3 py-2 border-b border-slate-200 bg-white">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">
|
||||
{isSessionMode ? "Session" : "Trace"}
|
||||
</div>
|
||||
<div className="font-mono text-[12px] text-slate-900 leading-tight flex items-center gap-1">
|
||||
<span className="truncate">{leftPanelDisplayId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLeftPanelId}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
aria-label="Copy trace id"
|
||||
>
|
||||
{copiedLeftPanelId ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-slate-500 font-mono">
|
||||
{logsForList.length} req
|
||||
{[
|
||||
isSessionMode
|
||||
? llmCount
|
||||
: logsForList.filter(
|
||||
(row) => !MCP_CALL_TYPES.includes(row.call_type) && !AGENT_CALL_TYPES.includes(row.call_type),
|
||||
).length,
|
||||
isSessionMode
|
||||
? agentCount
|
||||
: logsForList.filter((row) => AGENT_CALL_TYPES.includes(row.call_type)).length,
|
||||
isSessionMode ? mcpCount : logsForList.filter((row) => MCP_CALL_TYPES.includes(row.call_type)).length,
|
||||
].map((count, i) => {
|
||||
const label = [" LLM", " Agent", " MCP"][i];
|
||||
return count > 0 ? (
|
||||
<span key={label}>
|
||||
<div className="mt-1 text-[11px] text-slate-500 font-mono">
|
||||
{logsForList.length} req
|
||||
{[
|
||||
isSessionMode
|
||||
? llmCount
|
||||
: logsForList.filter(
|
||||
(row) => !MCP_CALL_TYPES.includes(row.call_type) && !AGENT_CALL_TYPES.includes(row.call_type),
|
||||
).length,
|
||||
isSessionMode
|
||||
? agentCount
|
||||
: logsForList.filter((row) => AGENT_CALL_TYPES.includes(row.call_type)).length,
|
||||
isSessionMode
|
||||
? mcpCount
|
||||
: logsForList.filter((row) => MCP_CALL_TYPES.includes(row.call_type)).length,
|
||||
].map((count, i) => {
|
||||
const label = [" LLM", " Agent", " MCP"][i];
|
||||
return count > 0 ? (
|
||||
<span key={label}>
|
||||
<span className="mx-1.5">·</span>
|
||||
{count}
|
||||
{label}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
<span className="mx-1.5">·</span>
|
||||
{isSessionMode ? getSpendString(totalSessionCost) : getSpendString(currentLog.spend || 0)}
|
||||
{isSessionMode && (
|
||||
<>
|
||||
<span className="mx-1.5">·</span>
|
||||
{count}
|
||||
{label}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
<span className="mx-1.5">·</span>
|
||||
{isSessionMode ? getSpendString(totalSessionCost) : getSpendString(currentLog.spend || 0)}
|
||||
{sessionDurationSeconds}s
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isSessionMode && sessionTruncated && (
|
||||
<div className="mt-1 text-[11px] text-amber-600 font-mono">
|
||||
Showing most recent {logsForList.length} of {sessionTotalCount}
|
||||
</div>
|
||||
)}
|
||||
{isSessionMode && (
|
||||
<>
|
||||
<span className="mx-1.5">·</span>
|
||||
{sessionDurationSeconds}s
|
||||
</>
|
||||
<Tabs
|
||||
className="mt-1.5"
|
||||
value={sessionSortMode}
|
||||
onValueChange={(value) => setSessionSortMode(value as SessionLogSortMode)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="duration" className="text-[11px]">
|
||||
Duration
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="start_time" className="text-[11px]">
|
||||
Start time
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
{isSessionMode && sessionTruncated && (
|
||||
<div className="mt-1 text-[11px] text-amber-600 font-mono">
|
||||
Showing most recent {logsForList.length} of {sessionTotalCount}
|
||||
</div>
|
||||
)}
|
||||
{isSessionMode && (
|
||||
<Segmented
|
||||
block
|
||||
size="small"
|
||||
className="mt-1.5 [&_.ant-segmented-item-label]:text-[11px]"
|
||||
options={[
|
||||
{ label: "Duration", value: "duration" },
|
||||
{ label: "Start time", value: "start_time" },
|
||||
]}
|
||||
value={sessionSortMode}
|
||||
onChange={(value) => setSessionSortMode(value as SessionLogSortMode)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && (
|
||||
<div className="px-3 pt-2">
|
||||
<GuardrailJumpLink guardrailEntries={normalizeGuardrailEntries(metadata?.guardrail_information)} />
|
||||
</div>
|
||||
)}
|
||||
{isSessionMode ? (
|
||||
<div className="py-1">
|
||||
{/* Child events — vertical tree line with horizontal connectors */}
|
||||
<div className="relative pl-2">
|
||||
<div className="absolute left-4 top-1 bottom-1 border-l border-slate-300" />
|
||||
{logsForList.map((row, idx) => {
|
||||
const isLast = idx === logsForList.length - 1;
|
||||
return (
|
||||
<div key={row.request_id} className="relative">
|
||||
<div className="absolute left-4 top-3 w-3 border-t border-slate-300" />
|
||||
{isLast && <div className="absolute left-4 top-3 bottom-0 w-px bg-slate-50" />}
|
||||
<TraceEventRow
|
||||
row={row}
|
||||
isSelected={row.request_id === currentLog.request_id}
|
||||
onClick={() => {
|
||||
setSelectedSessionRequestId(row.request_id);
|
||||
onSelectLog?.(row);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{normalizeGuardrailEntries(metadata?.guardrail_information).length > 0 && (
|
||||
<div className="px-3 pt-2">
|
||||
<GuardrailJumpLink guardrailEntries={normalizeGuardrailEntries(metadata?.guardrail_information)} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{logsForList.map((row) => (
|
||||
<TraceEventRow
|
||||
key={row.request_id}
|
||||
row={row}
|
||||
isSelected={row.request_id === currentLog.request_id}
|
||||
onClick={() => onSelectLog?.(row)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{isSessionMode ? (
|
||||
<div className="py-1">
|
||||
{/* Child events — vertical tree line with horizontal connectors */}
|
||||
<div className="relative pl-2">
|
||||
<div className="absolute left-4 top-1 bottom-1 border-l border-slate-300" />
|
||||
{logsForList.map((row, idx) => {
|
||||
const isLast = idx === logsForList.length - 1;
|
||||
return (
|
||||
<div key={row.request_id} className="relative">
|
||||
<div className="absolute left-4 top-3 w-3 border-t border-slate-300" />
|
||||
{isLast && <div className="absolute left-4 top-3 bottom-0 w-px bg-slate-50" />}
|
||||
<TraceEventRow
|
||||
row={row}
|
||||
isSelected={row.request_id === currentLog.request_id}
|
||||
onClick={() => {
|
||||
setSelectedSessionRequestId(row.request_id);
|
||||
onSelectLog?.(row);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-1">
|
||||
{logsForList.map((row) => (
|
||||
<TraceEventRow
|
||||
key={row.request_id}
|
||||
row={row}
|
||||
isSelected={row.request_id === currentLog.request_id}
|
||||
onClick={() => onSelectLog?.(row)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<DrawerHeader
|
||||
log={currentLog}
|
||||
onClose={onClose}
|
||||
onPrevious={selectPreviousLog}
|
||||
onNext={selectNextLog}
|
||||
statusLabel={statusLabel}
|
||||
statusColor={statusColor}
|
||||
environment={environment}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<LogDetailContent
|
||||
logEntry={enrichedLog}
|
||||
isLoadingDetails={isLoadingDetails}
|
||||
accessToken={accessToken ?? null}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<DrawerHeader
|
||||
log={currentLog}
|
||||
onClose={onClose}
|
||||
onPrevious={selectPreviousLog}
|
||||
onNext={selectNextLog}
|
||||
statusLabel={statusLabel}
|
||||
statusColor={statusColor}
|
||||
environment={environment}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<LogDetailContent
|
||||
logEntry={enrichedLog}
|
||||
isLoadingDetails={isLoadingDetails}
|
||||
accessToken={accessToken ?? null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,6 @@ import userEvent from "@testing-library/user-event";
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { RealtimePrettyView, isRealtimeResponse } from "./RealtimePrettyView";
|
||||
|
||||
vi.mock("antd", async () => {
|
||||
const actual = await vi.importActual<typeof import("antd")>("antd");
|
||||
return {
|
||||
...actual,
|
||||
message: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const sampleRealtimeResponse = {
|
||||
usage: {
|
||||
total_tokens: 587,
|
||||
|
|
|
|||
|
|
@ -5,19 +5,11 @@
|
|||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Typography, Tag, Tooltip } from "antd";
|
||||
import {
|
||||
SoundOutlined,
|
||||
MessageOutlined,
|
||||
SettingOutlined,
|
||||
AudioOutlined,
|
||||
DownOutlined,
|
||||
UpOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ChevronDown, ChevronUp, MessageSquare, Mic, Settings, Volume2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface RealtimeEvent {
|
||||
type: string;
|
||||
event_id?: string;
|
||||
|
|
@ -163,34 +155,34 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
|
|||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
{isCollapsed ? (
|
||||
<DownOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
|
||||
<ChevronDown className="size-2.5 text-muted-foreground" />
|
||||
) : (
|
||||
<UpOutlined style={{ fontSize: 10, color: "#8c8c8c" }} />
|
||||
<ChevronUp className="size-2.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<SettingOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
|
||||
<Text style={{ fontWeight: 500, fontSize: 14 }}>Session</Text>
|
||||
<Settings className="size-3.5 text-muted-foreground" />
|
||||
<span style={{ fontWeight: 500, fontSize: 14 }}>Session</span>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||
{session.model}
|
||||
</Text>
|
||||
</span>
|
||||
{turnCount > 0 && (
|
||||
<Tag color="purple" style={{ margin: 0, fontWeight: 500 }}>
|
||||
<Badge variant="secondary" style={{ margin: 0, fontWeight: 500 }}>
|
||||
{turnCount} {turnCount === 1 ? "turn" : "turns"}
|
||||
</Tag>
|
||||
</Badge>
|
||||
)}
|
||||
{session.voice && (
|
||||
<Tag color="blue" style={{ margin: 0 }}>
|
||||
<SoundOutlined /> {session.voice}
|
||||
</Tag>
|
||||
<Badge variant="secondary" style={{ margin: 0 }}>
|
||||
<Volume2 className="size-3" /> {session.voice}
|
||||
</Badge>
|
||||
)}
|
||||
{session.modalities && (
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
{session.modalities.map((m) => (
|
||||
<Tag key={m} style={{ margin: 0 }}>
|
||||
{m === "audio" ? <AudioOutlined /> : <MessageOutlined />} {m}
|
||||
</Tag>
|
||||
<Badge key={m} variant="outline" style={{ margin: 0 }}>
|
||||
{m === "audio" ? <Mic className="size-3" /> : <MessageSquare className="size-3" />} {m}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -228,8 +220,8 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
|
|||
|
||||
{session.instructions && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.5px",
|
||||
|
|
@ -239,7 +231,7 @@ function SessionCard({ session, turnCount }: { session: RealtimeSession; turnCou
|
|||
}}
|
||||
>
|
||||
Instructions
|
||||
</Text>
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
|
|
@ -344,20 +336,25 @@ function ResponseTurn({ response, index }: { response: RealtimeResponse; index:
|
|||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Tag color={response.status === "completed" ? "green" : "orange"} style={{ margin: 0 }}>
|
||||
<Badge variant={response.status === "completed" ? "secondary" : "outline"} style={{ margin: 0 }}>
|
||||
{response.status || "unknown"}
|
||||
</Tag>
|
||||
</Badge>
|
||||
{usage && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||
{usage.input_tokens ?? 0} in / {usage.output_tokens ?? 0} out tokens
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
{response.conversation_id && (
|
||||
<Tooltip title={response.conversation_id}>
|
||||
<Text type="secondary" style={{ fontSize: 11, cursor: "help" }}>
|
||||
conv: {response.conversation_id.slice(0, 12)}...
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span className="text-muted-foreground" style={{ fontSize: 11, cursor: "help" }} />}
|
||||
>
|
||||
conv: {response.conversation_id.slice(0, 12)}...
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{response.conversation_id}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -381,8 +378,8 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) {
|
|||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.5px",
|
||||
|
|
@ -392,7 +389,7 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) {
|
|||
}}
|
||||
>
|
||||
{output.role?.toUpperCase() || "ASSISTANT"}
|
||||
</Text>
|
||||
</span>
|
||||
{contents.map((c, cIdx) => {
|
||||
const text = c.transcript || c.text;
|
||||
if (!text) return null;
|
||||
|
|
@ -407,20 +404,18 @@ function OutputMessage({ output }: { output: RealtimeOutputItem }) {
|
|||
}}
|
||||
>
|
||||
{c.type === "audio" && (
|
||||
<AudioOutlined
|
||||
<Mic
|
||||
className="size-3 text-muted-foreground"
|
||||
style={{
|
||||
color: "#8c8c8c",
|
||||
fontSize: 12,
|
||||
marginTop: 3,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{c.type === "text" && (
|
||||
<MessageOutlined
|
||||
<MessageSquare
|
||||
className="size-3 text-muted-foreground"
|
||||
style={{
|
||||
color: "#8c8c8c",
|
||||
fontSize: 12,
|
||||
marginTop: 3,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
|
|
@ -453,9 +448,12 @@ function TokenBreakdown({ label, details }: { label: string; details: Record<str
|
|||
|
||||
return (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 10, letterSpacing: "0.5px", textTransform: "uppercase" }}>
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{ fontSize: 10, letterSpacing: "0.5px", textTransform: "uppercase" }}
|
||||
>
|
||||
{label} Token Breakdown
|
||||
</Text>
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
|
@ -467,9 +465,9 @@ function TokenBreakdown({ label, details }: { label: string; details: Record<str
|
|||
{entries.map(([key, value]) => {
|
||||
if (typeof value === "number") {
|
||||
return (
|
||||
<Tag key={key} style={{ margin: 0 }}>
|
||||
<Badge key={key} variant="outline" style={{ margin: 0 }}>
|
||||
{formatTokenLabel(key)}: {value.toLocaleString()}
|
||||
</Tag>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
@ -483,9 +481,9 @@ function ConfigRow({ label, value }: { label: string; value: any }) {
|
|||
if (value === undefined || value === null) return null;
|
||||
return (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
<span className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||
{label}
|
||||
</Text>
|
||||
</span>
|
||||
<div style={{ fontSize: 13, color: "#262626" }}>{String(value)}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@
|
|||
* Formatted view of tool definition with parameters table and call data
|
||||
*/
|
||||
|
||||
import { Typography, Table } from "antd";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { ParsedTool, ParameterRow } from "./types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface FormattedToolViewProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
|
@ -23,57 +21,27 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) {
|
|||
}),
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "Parameter",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: (name: string, record: ParameterRow) => (
|
||||
<Text code>
|
||||
{name}
|
||||
{record.required && <Text type="danger">*</Text>}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Type",
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
render: (type: string) => (
|
||||
<Text code style={{ color: "#1890ff" }}>
|
||||
{type}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Description",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
render: (desc: string) => <Text type="secondary">{desc}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Description */}
|
||||
{tool.description && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text
|
||||
<span
|
||||
style={{
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{tool.description}
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parameters Table */}
|
||||
{parameterRows.length > 0 && (
|
||||
<div>
|
||||
<Text
|
||||
type="secondary"
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: "block",
|
||||
|
|
@ -81,16 +49,42 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) {
|
|||
}}
|
||||
>
|
||||
Parameters
|
||||
</Text>
|
||||
<Table dataSource={parameterRows} columns={columns} pagination={false} size="small" bordered />
|
||||
</span>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Parameter</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{parameterRows.map((row) => (
|
||||
<TableRow key={row.key}>
|
||||
<TableCell>
|
||||
<code>
|
||||
{row.name}
|
||||
{row.required && <span className="text-destructive">*</span>}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-blue-600">{row.type}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-muted-foreground">{row.description}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* If tool was called, show the arguments used */}
|
||||
{tool.called && tool.callData && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: "block",
|
||||
|
|
@ -98,7 +92,7 @@ export function FormattedToolView({ tool }: FormattedToolViewProps) {
|
|||
}}
|
||||
>
|
||||
Called With
|
||||
</Text>
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
background: "#f6ffed",
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@
|
|||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Typography, Radio } from "antd";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ParsedTool } from "./types";
|
||||
import { FormattedToolView } from "./FormattedToolView";
|
||||
import { JsonToolView } from "./JsonToolView";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ViewMode = "formatted" | "json";
|
||||
|
||||
interface ToolExpandedContentProps {
|
||||
|
|
@ -29,13 +27,13 @@ export function ToolExpandedContent({ tool }: ToolExpandedContentProps) {
|
|||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Description
|
||||
</Text>
|
||||
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
|
||||
<Radio.Button value="formatted">Formatted</Radio.Button>
|
||||
<Radio.Button value="json">JSON</Radio.Button>
|
||||
</Radio.Group>
|
||||
<span className="text-xs text-muted-foreground">Description</span>
|
||||
<Tabs value={viewMode} onValueChange={(value) => setViewMode(value as ViewMode)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="formatted">Formatted</TabsTrigger>
|
||||
<TabsTrigger value="json">JSON</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{viewMode === "formatted" ? <FormattedToolView tool={tool} /> : <JsonToolView tool={tool} />}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@
|
|||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Typography, Tag } from "antd";
|
||||
import { ToolOutlined, RightOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { ChevronDown, ChevronRight, Wrench } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ParsedTool } from "./types";
|
||||
import { ToolExpandedContent } from "./ToolExpandedContent";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ToolItemProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
|
@ -39,18 +37,18 @@ export function ToolItem({ tool }: ToolItemProps) {
|
|||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<ToolOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
|
||||
<Text style={{ fontSize: 14 }}>
|
||||
<Wrench className="size-3.5 text-muted-foreground" />
|
||||
<span style={{ fontSize: 14 }}>
|
||||
{tool.index}. {tool.name}
|
||||
</Text>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Tag color={tool.called ? "blue" : "default"}>{tool.called ? "called" : "not called"}</Tag>
|
||||
<Badge variant={tool.called ? "default" : "secondary"}>{tool.called ? "called" : "not called"}</Badge>
|
||||
{expanded ? (
|
||||
<DownOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
) : (
|
||||
<RightOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from "react";
|
||||
import { Collapse } from "antd";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { getProviderLogoAndName } from "../provider_info_helpers";
|
||||
|
||||
interface VectorStoreContent {
|
||||
|
|
@ -31,6 +32,7 @@ interface VectorStoreViewerProps {
|
|||
}
|
||||
|
||||
export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
|
|
@ -57,110 +59,110 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
|
|||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: <h3 className="text-lg font-medium text-gray-900">Vector Store Requests</h3>,
|
||||
children: (
|
||||
<div className="p-4">
|
||||
{data.map((request, index) => (
|
||||
<div key={index} className="mb-6 last:mb-0">
|
||||
<div className="bg-white rounded-lg border p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Query:</span>
|
||||
<span className="font-mono">{request.query}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Vector Store ID:</span>
|
||||
<span className="font-mono">{request.vector_store_id}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Provider:</span>
|
||||
<span className="flex items-center">
|
||||
{(() => {
|
||||
const { logo, displayName } = getProviderLogoAndName(request.custom_llm_provider);
|
||||
return (
|
||||
<>
|
||||
{logo && <img src={logo} alt={`${displayName} logo`} className="h-5 w-5 mr-2" />}
|
||||
{displayName}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-3 px-4 py-3 text-left">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5 shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-gray-500" />
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-gray-900">Vector Store Requests</h3>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="p-4">
|
||||
{data.map((request, index) => (
|
||||
<div key={index} className="mb-6 last:mb-0">
|
||||
<div className="bg-white rounded-lg border p-4 mb-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Query:</span>
|
||||
<span className="font-mono">{request.query}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Vector Store ID:</span>
|
||||
<span className="font-mono">{request.vector_store_id}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Provider:</span>
|
||||
<span className="flex items-center">
|
||||
{(() => {
|
||||
const { logo, displayName } = getProviderLogoAndName(request.custom_llm_provider);
|
||||
return (
|
||||
<>
|
||||
{logo && <img src={logo} alt={`${displayName} logo`} className="h-5 w-5 mr-2" />}
|
||||
{displayName}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Start Time:</span>
|
||||
<span>{formatTime(request.start_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">End Time:</span>
|
||||
<span>{formatTime(request.end_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Duration:</span>
|
||||
<span>{calculateDuration(request.start_time, request.end_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium mb-2">Search Results</h4>
|
||||
<div className="space-y-2">
|
||||
{request.vector_store_search_response.data.map((result, resultIndex) => {
|
||||
const isExpanded = expandedResults[`${index}-${resultIndex}`] || false;
|
||||
|
||||
return (
|
||||
<div key={resultIndex} className="border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center p-3 bg-gray-50 cursor-pointer"
|
||||
onClick={() => toggleResult(index, resultIndex)}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 transition-transform ${isExpanded ? "transform rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium mr-2">Result {resultIndex + 1}</span>
|
||||
<span className="text-gray-500 text-sm">
|
||||
Score: <span className="font-mono">{result.score.toFixed(4)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Start Time:</span>
|
||||
<span>{formatTime(request.start_time)}</span>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-3 border-t bg-white">
|
||||
{result.content.map((content, contentIndex) => (
|
||||
<div key={contentIndex} className="mb-2 last:mb-0">
|
||||
<div className="text-xs text-gray-500 mb-1">{content.type}</div>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all bg-gray-50 p-2 rounded-sm">
|
||||
{content.text}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">End Time:</span>
|
||||
<span>{formatTime(request.end_time)}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-medium w-1/3">Duration:</span>
|
||||
<span>{calculateDuration(request.start_time, request.end_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium mb-2">Search Results</h4>
|
||||
<div className="space-y-2">
|
||||
{request.vector_store_search_response.data.map((result, resultIndex) => {
|
||||
const isExpanded = expandedResults[`${index}-${resultIndex}`] || false;
|
||||
|
||||
return (
|
||||
<div key={resultIndex} className="border rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center p-3 bg-gray-50 cursor-pointer"
|
||||
onClick={() => toggleResult(index, resultIndex)}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 transition-transform ${isExpanded ? "transform rotate-90" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium mr-2">Result {resultIndex + 1}</span>
|
||||
<span className="text-gray-500 text-sm">
|
||||
Score: <span className="font-mono">{result.score.toFixed(4)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-3 border-t bg-white">
|
||||
{result.content.map((content, contentIndex) => (
|
||||
<div key={contentIndex} className="mb-2 last:mb-0">
|
||||
<div className="text-xs text-gray-500 mb-1">{content.type}</div>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all bg-gray-50 p-2 rounded-sm">
|
||||
{content.text}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue