mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge litellm_add_pretty_view_logs into litellm_v2_logs_view_original
Resolved conflicts by favoring refactor changes: - Kept refactored DrawerHeader.tsx with Antd Space and copyable prop - Kept refactored LogDetailsDrawer.tsx with utils imports - Kept refactored constants.ts without message constants - Removed clipboardUtils.ts (replaced with Antd's built-in copy) - Used incoming CostBreakdownViewer.tsx with Antd Collapse component Added from litellm_add_pretty_view_logs: - New ToolsSection components for displaying tools in logs - Updated GuardrailViewer with tests - Updated VectorStoreViewer - Prometheus integration updates Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
2570f3c0fe
16 changed files with 1100 additions and 108 deletions
|
|
@ -105,6 +105,11 @@ class PrometheusServicesLogger:
|
|||
return metrics
|
||||
|
||||
def is_metric_registered(self, metric_name) -> bool:
|
||||
# Use _names_to_collectors (O(1)) instead of REGISTRY.collect() (O(n)) to avoid
|
||||
# perf regression when a new Router is created per request (e.g. router_settings in DB).
|
||||
names_to_collectors = getattr(self.REGISTRY, "_names_to_collectors", None)
|
||||
if names_to_collectors is not None:
|
||||
return metric_name in names_to_collectors
|
||||
for metric in self.REGISTRY.collect():
|
||||
if metric_name == metric.name:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -17,6 +18,63 @@ sys.path.insert(
|
|||
) # Adds the parent directory to the system path
|
||||
|
||||
|
||||
def test_is_metric_registered_does_not_use_registry_collect():
|
||||
"""is_metric_registered() must use _names_to_collectors, not REGISTRY.collect() (perf; #19921)."""
|
||||
from prometheus_client import CollectorRegistry, Counter, Histogram
|
||||
|
||||
registry = CollectorRegistry()
|
||||
for i in range(80):
|
||||
Counter(
|
||||
f"litellm_service_{i}_total_requests",
|
||||
"Total requests",
|
||||
labelnames=["service"],
|
||||
registry=registry,
|
||||
)
|
||||
Histogram(
|
||||
f"litellm_service_{i}_latency",
|
||||
"Latency",
|
||||
labelnames=["service"],
|
||||
registry=registry,
|
||||
)
|
||||
|
||||
pl = PrometheusServicesLogger()
|
||||
pl.REGISTRY = registry
|
||||
|
||||
original_collect = registry.collect
|
||||
collect_called = []
|
||||
|
||||
def track_collect(*args, **kwargs):
|
||||
collect_called.append(1)
|
||||
return original_collect(*args, **kwargs)
|
||||
|
||||
registry.collect = track_collect
|
||||
|
||||
n_calls = 30 * 2
|
||||
start = time.perf_counter()
|
||||
for _ in range(30):
|
||||
pl.is_metric_registered("litellm_service_0_latency")
|
||||
pl.is_metric_registered("litellm_service_79_total_requests")
|
||||
elapsed_s = time.perf_counter() - start
|
||||
elapsed_ms = elapsed_s * 1000
|
||||
per_call_us = (elapsed_s / n_calls) * 1_000_000 if n_calls else 0
|
||||
n_collect = len(collect_called)
|
||||
|
||||
path = "slow (REGISTRY.collect)" if n_collect else "fast (_names_to_collectors)"
|
||||
print(
|
||||
f"\n is_metric_registered: {elapsed_ms:.2f} ms total | "
|
||||
f"{per_call_us:.1f} µs/call | {n_calls} calls | {n_collect} collect() | {path}\n"
|
||||
)
|
||||
|
||||
assert n_collect == 0, (
|
||||
f"is_metric_registered() must not use REGISTRY.collect() when _names_to_collectors "
|
||||
f"is available. Latency: {elapsed_ms:.2f} ms, {per_call_us:.1f} µs/call, {n_calls} calls, "
|
||||
f"collect() called {n_collect} times."
|
||||
)
|
||||
assert elapsed_s < 0.05, (
|
||||
f"is_metric_registered() took {elapsed_ms:.2f} ms for {n_calls} calls; expected <50 ms."
|
||||
)
|
||||
|
||||
|
||||
def test_create_gauge_new():
|
||||
"""Test creating a new gauge"""
|
||||
pl = PrometheusServicesLogger()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import { Accordion, AccordionHeader, AccordionBody } from "@tremor/react";
|
||||
import { Collapse } from "antd";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
export interface CostBreakdown {
|
||||
|
|
@ -7,7 +7,6 @@ export interface CostBreakdown {
|
|||
output_cost?: number;
|
||||
total_cost?: number;
|
||||
tool_usage_cost?: number;
|
||||
additional_costs?: Record<string, number>;
|
||||
original_cost?: number;
|
||||
discount_percent?: number;
|
||||
discount_amount?: number;
|
||||
|
|
@ -61,18 +60,22 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Accordion>
|
||||
<AccordionHeader className="p-4 border-b hover:bg-gray-50 transition-colors text-left">
|
||||
<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)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionBody className="px-0">
|
||||
<div className="p-6 space-y-4">
|
||||
<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)}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Base Token Costs */}
|
||||
<div className="space-y-2 max-w-2xl">
|
||||
<div className="flex text-sm">
|
||||
|
|
@ -89,17 +92,6 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
<span className="text-gray-900">{formatCost(costBreakdown.tool_usage_cost)}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Additional Costs (free-form) */}
|
||||
{costBreakdown.additional_costs && Object.keys(costBreakdown.additional_costs).length > 0 && (
|
||||
<>
|
||||
{Object.entries(costBreakdown.additional_costs).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 */}
|
||||
|
|
@ -161,8 +153,10 @@ export const CostBreakdownViewer: React.FC<CostBreakdownViewerProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders, screen } from "../../../../tests/test-utils";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
|
||||
import {
|
||||
makeBedrockResponse,
|
||||
makeEntity,
|
||||
|
|
@ -62,20 +62,26 @@ describe("GuardrailViewer", () => {
|
|||
it("toggles main section open/closed and chevron rotation class", async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = makeGuardrailInformation();
|
||||
renderWithProviders(<GuardrailViewer data={data} />);
|
||||
const { container } = renderWithProviders(<GuardrailViewer data={data} />);
|
||||
|
||||
const header = screen.getByText("Guardrail Information").closest("div")!;
|
||||
// Initially expanded
|
||||
expect(screen.getByText("Click to collapse")).toBeInTheDocument();
|
||||
const header = screen.getByText("Guardrail Information").closest(".ant-collapse-header")!;
|
||||
// Initially expanded (content is visible)
|
||||
expect(screen.getByText("Masked Entity Summary")).toBeInTheDocument();
|
||||
|
||||
// Click to collapse
|
||||
await user.click(header);
|
||||
expect(screen.getByText("Click to expand")).toBeInTheDocument();
|
||||
// Details gone
|
||||
expect(screen.queryByText("Masked Entity Summary")).not.toBeInTheDocument();
|
||||
// Wait for collapse animation and content to be hidden
|
||||
await waitFor(() => {
|
||||
const contentBox = container.querySelector(".ant-collapse-content-box");
|
||||
expect(contentBox).not.toBeVisible();
|
||||
});
|
||||
|
||||
// Click to expand again
|
||||
await user.click(header);
|
||||
expect(screen.getByText("Click to collapse")).toBeInTheDocument();
|
||||
// Wait for expand animation
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Masked Entity Summary")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to presidio provider when guardrail_provider is undefined", async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { Tooltip } from "antd";
|
||||
import { Tooltip, Collapse } from "antd";
|
||||
import PresidioDetectedEntities from "./PresidioDetectedEntities";
|
||||
import BedrockGuardrailDetails, {
|
||||
BedrockGuardrailResponse,
|
||||
|
|
@ -207,8 +207,6 @@ const GuardrailViewer = ({ data }: GuardrailViewerProps) => {
|
|||
? [data]
|
||||
: [];
|
||||
|
||||
const [sectionExpanded, setSectionExpanded] = useState(true);
|
||||
|
||||
const primaryName =
|
||||
guardrailEntries.length === 1 ? guardrailEntries[0].guardrail_name : `${guardrailEntries.length} guardrails`;
|
||||
const statuses = Array.from(new Set(guardrailEntries.map((entry) => entry.guardrail_status)));
|
||||
|
|
@ -231,55 +229,51 @@ const GuardrailViewer = ({ data }: GuardrailViewerProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center p-4 border-b cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setSectionExpanded(!sectionExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-600 transition-transform ${sectionExpanded ? "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>
|
||||
<h3 className="text-lg font-medium">Guardrail Information</h3>
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
defaultActiveKey={["1"]}
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">Guardrail Information</h3>
|
||||
|
||||
<Tooltip title={tooltipTitle} placement="top" arrow destroyTooltipOnHide>
|
||||
<span
|
||||
className={`ml-2 px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
allSucceeded ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800 cursor-help"
|
||||
}`}
|
||||
>
|
||||
{aggregatedStatus}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={tooltipTitle} placement="top" arrow destroyTooltipOnHide>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium inline-block ${
|
||||
allSucceeded ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800 cursor-help"
|
||||
}`}
|
||||
>
|
||||
{aggregatedStatus}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<span className="ml-2 font-mono text-sm text-gray-600">{primaryName}</span>
|
||||
<span className="font-mono text-sm text-gray-600">{primaryName}</span>
|
||||
|
||||
{totalMaskedEntities > 0 && (
|
||||
<span className="ml-2 px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{sectionExpanded ? "Click to collapse" : "Click to expand"}</span>
|
||||
</div>
|
||||
|
||||
{sectionExpanded && (
|
||||
<div className="p-4 space-y-6">
|
||||
{guardrailEntries.map((entry, index) => (
|
||||
<GuardrailDetails
|
||||
key={`${entry.guardrail_name ?? "guardrail"}-${index}`}
|
||||
entry={entry}
|
||||
index={index}
|
||||
total={guardrailEntries.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{totalMaskedEntities > 0 && (
|
||||
<span className="px-2 py-1 bg-blue-50 text-blue-700 rounded-md text-xs font-medium">
|
||||
{totalMaskedEntities} masked {totalMaskedEntities === 1 ? "entity" : "entities"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="p-4 space-y-6">
|
||||
{guardrailEntries.map((entry, index) => (
|
||||
<GuardrailDetails
|
||||
key={`${entry.guardrail_name ?? "guardrail"}-${index}`}
|
||||
entry={entry}
|
||||
index={index}
|
||||
total={guardrailEntries.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Formatted view of tool definition with parameters table and call data
|
||||
*/
|
||||
|
||||
import { Typography, Table } from "antd";
|
||||
import { ParsedTool, ParameterRow } from "./types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface FormattedToolViewProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
||||
export function FormattedToolView({ tool }: FormattedToolViewProps) {
|
||||
// Parse parameters for table display
|
||||
const parameterRows: ParameterRow[] = Object.entries(
|
||||
tool.parameters?.properties || {}
|
||||
).map(([name, schema]: [string, any]) => ({
|
||||
key: name,
|
||||
name: name,
|
||||
type: schema.type || "any",
|
||||
description: schema.description || "-",
|
||||
required: tool.parameters?.required?.includes(name) || false,
|
||||
}));
|
||||
|
||||
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 style={{ lineHeight: 1.6 }}>{tool.description}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parameters Table */}
|
||||
{parameterRows.length > 0 && (
|
||||
<div>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: "block",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Parameters
|
||||
</Text>
|
||||
<Table
|
||||
dataSource={parameterRows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* If tool was called, show the arguments used */}
|
||||
{tool.called && tool.callData && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
display: "block",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Called With
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
background: "#f6ffed",
|
||||
border: "1px solid #b7eb8f",
|
||||
borderRadius: 4,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 12,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(tool.callData.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* JSON view of tool definition
|
||||
*/
|
||||
|
||||
import { ParsedTool } from "./types";
|
||||
|
||||
interface JsonToolViewProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
||||
export function JsonToolView({ tool }: JsonToolViewProps) {
|
||||
// Reconstruct the original tool definition
|
||||
const toolJson = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
fontSize: 12,
|
||||
background: "#fafafa",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
maxHeight: 300,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(toolJson, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Expanded content for a tool with view mode toggle
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Typography, Radio } from "antd";
|
||||
import { ParsedTool } from "./types";
|
||||
import { FormattedToolView } from "./FormattedToolView";
|
||||
import { JsonToolView } from "./JsonToolView";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ViewMode = "formatted" | "json";
|
||||
|
||||
interface ToolExpandedContentProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
||||
export function ToolExpandedContent({ tool }: ToolExpandedContentProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("formatted");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* View Mode Toggle - Top Right */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
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>
|
||||
</div>
|
||||
|
||||
{viewMode === "formatted" ? (
|
||||
<FormattedToolView tool={tool} />
|
||||
) : (
|
||||
<JsonToolView tool={tool} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Individual tool item component with expandable details
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Typography, Tag } from "antd";
|
||||
import { ToolOutlined, RightOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { ParsedTool } from "./types";
|
||||
import { ToolExpandedContent } from "./ToolExpandedContent";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ToolItemProps {
|
||||
tool: ParsedTool;
|
||||
}
|
||||
|
||||
export function ToolItem({ tool }: ToolItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid #f0f0f0",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header Row - Always Visible */}
|
||||
<div
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 16px",
|
||||
cursor: "pointer",
|
||||
background: expanded ? "#fafafa" : "#fff",
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<ToolOutlined style={{ color: "#8c8c8c", fontSize: 14 }} />
|
||||
<Text style={{ fontSize: 14 }}>
|
||||
{tool.index}. {tool.name}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<Tag color={tool.called ? "blue" : "default"}>
|
||||
{tool.called ? "called" : "not called"}
|
||||
</Tag>
|
||||
{expanded ? (
|
||||
<DownOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
|
||||
) : (
|
||||
<RightOutlined style={{ fontSize: 12, color: "#8c8c8c" }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px",
|
||||
borderTop: "1px solid #f0f0f0",
|
||||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
<ToolExpandedContent tool={tool} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Core tests for Tools section
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseToolsFromLog } from "./utils";
|
||||
import { LogEntry } from "../columns";
|
||||
|
||||
describe("ToolsSection", () => {
|
||||
it("should parse tools from request and match with response tool calls", () => {
|
||||
const mockLog: LogEntry = {
|
||||
request_id: "test-123",
|
||||
api_key: "key",
|
||||
team_id: "team",
|
||||
model: "gpt-4",
|
||||
model_id: "gpt-4",
|
||||
call_type: "completion",
|
||||
spend: 0.01,
|
||||
total_tokens: 100,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 50,
|
||||
startTime: "2024-01-01T00:00:00Z",
|
||||
endTime: "2024-01-01T00:00:01Z",
|
||||
cache_hit: "none",
|
||||
messages: JSON.stringify({
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "What's the weather?" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get the current weather",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["location"],
|
||||
properties: {
|
||||
location: { type: "string", description: "City name" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_web",
|
||||
description: "Search the web",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["query"],
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
response: JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location": "San Francisco"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const tools = parseToolsFromLog(mockLog);
|
||||
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools[0].name).toBe("get_weather");
|
||||
expect(tools[0].called).toBe(true);
|
||||
expect(tools[0].callData?.arguments).toEqual({ location: "San Francisco" });
|
||||
expect(tools[1].name).toBe("search_web");
|
||||
expect(tools[1].called).toBe(false);
|
||||
});
|
||||
|
||||
it("should return empty array when no tools in request", () => {
|
||||
const mockLog: LogEntry = {
|
||||
request_id: "test-456",
|
||||
api_key: "key",
|
||||
team_id: "team",
|
||||
model: "gpt-4",
|
||||
model_id: "gpt-4",
|
||||
call_type: "completion",
|
||||
spend: 0.01,
|
||||
total_tokens: 100,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 50,
|
||||
startTime: "2024-01-01T00:00:00Z",
|
||||
endTime: "2024-01-01T00:00:01Z",
|
||||
cache_hit: "none",
|
||||
messages: JSON.stringify({
|
||||
model: "gpt-4",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
}),
|
||||
response: JSON.stringify({
|
||||
choices: [{ message: { content: "Hi there!" } }],
|
||||
}),
|
||||
};
|
||||
|
||||
const tools = parseToolsFromLog(mockLog);
|
||||
|
||||
expect(tools).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Tools section component that displays all available tools from the request
|
||||
* and indicates which ones were actually called in the response
|
||||
*/
|
||||
|
||||
import { Collapse, Typography } from "antd";
|
||||
import { LogEntry } from "../columns";
|
||||
import { parseToolsFromLog } from "./utils";
|
||||
import { ToolItem } from "./ToolItem";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface ToolsSectionProps {
|
||||
log: LogEntry;
|
||||
}
|
||||
|
||||
export function ToolsSection({ log }: ToolsSectionProps) {
|
||||
const tools = parseToolsFromLog(log);
|
||||
|
||||
// Don't render if no tools
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
// Calculate summary stats
|
||||
const totalTools = tools.length;
|
||||
const calledTools = tools.filter((t) => t.called).length;
|
||||
|
||||
// Get preview of first 2 tool names
|
||||
const toolNamePreview = tools
|
||||
.slice(0, 2)
|
||||
.map((t) => t.name)
|
||||
.join(", ");
|
||||
const hasMoreTools = tools.length > 2;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden mb-6">
|
||||
<Collapse
|
||||
expandIconPosition="start"
|
||||
items={[
|
||||
{
|
||||
key: "1",
|
||||
label: (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
||||
<h3 className="text-lg font-medium text-gray-900">Tools</h3>
|
||||
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||
{totalTools} provided, {calledTools} called
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||
• {toolNamePreview}
|
||||
{hasMoreTools && "..."}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{tools.map((tool) => (
|
||||
<ToolItem key={tool.name} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Export main components and utilities for the Tools section
|
||||
*/
|
||||
|
||||
export { ToolsSection } from "./ToolsSection";
|
||||
export { parseToolsFromLog, hasTools } from "./utils";
|
||||
export type { ParsedTool, ToolDefinition, ToolCall } from "./types";
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Type definitions for the Tools section
|
||||
*/
|
||||
|
||||
export interface ToolDefinition {
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
type: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParsedTool {
|
||||
index: number;
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, any>;
|
||||
called: boolean;
|
||||
callData?: {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParameterRow {
|
||||
key: string;
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* Tests for tool parsing utilities
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseToolsFromLog, hasTools } from "./utils";
|
||||
import { LogEntry } from "../columns";
|
||||
|
||||
describe("ToolsSection utils", () => {
|
||||
describe("parseToolsFromLog", () => {
|
||||
it("should return empty array when no tools in request", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-1",
|
||||
messages: [],
|
||||
response: {},
|
||||
};
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should parse tools from proxy_server_request", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-2",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get the current weather",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: { type: "string" },
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
index: 1,
|
||||
name: "get_weather",
|
||||
description: "Get the current weather",
|
||||
called: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse tools from messages object format", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-3",
|
||||
messages: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search_web",
|
||||
description: "Search the web",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("search_web");
|
||||
});
|
||||
|
||||
it("should mark tools as called when present in response", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-4",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get weather",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "send_email",
|
||||
description: "Send email",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location": "San Francisco"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].called).toBe(true);
|
||||
expect(result[0].callData).toBeDefined();
|
||||
expect(result[0].callData?.arguments).toEqual({
|
||||
location: "San Francisco",
|
||||
});
|
||||
expect(result[1].called).toBe(false);
|
||||
expect(result[1].callData).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle string format request and response", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-5",
|
||||
proxy_server_request: JSON.stringify({
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "calculate",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
response: JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_456",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "calculate",
|
||||
arguments: '{"x": 5}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].called).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle tools with no description or parameters", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-6",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "minimal_tool",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
index: 1,
|
||||
name: "minimal_tool",
|
||||
description: "",
|
||||
parameters: {},
|
||||
called: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle invalid JSON in tool call arguments gracefully", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-7",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_789",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
arguments: "invalid json",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].called).toBe(true);
|
||||
expect(result[0].callData?.arguments).toEqual({});
|
||||
});
|
||||
|
||||
it("should assign correct indices to multiple tools", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-8",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{ type: "function", function: { name: "tool1" } },
|
||||
{ type: "function", function: { name: "tool2" } },
|
||||
{ type: "function", function: { name: "tool3" } },
|
||||
],
|
||||
},
|
||||
response: {},
|
||||
} as any;
|
||||
|
||||
const result = parseToolsFromLog(log as LogEntry);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].index).toBe(1);
|
||||
expect(result[1].index).toBe(2);
|
||||
expect(result[2].index).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasTools", () => {
|
||||
it("should return false when no tools in request", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-9",
|
||||
messages: [],
|
||||
response: {},
|
||||
};
|
||||
|
||||
expect(hasTools(log as LogEntry)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when tools present in request", () => {
|
||||
const log: Partial<LogEntry> = {
|
||||
request_id: "test-10",
|
||||
proxy_server_request: {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
response: {},
|
||||
} as any;
|
||||
|
||||
expect(hasTools(log as LogEntry)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Utility functions for parsing and processing tool data from log entries
|
||||
*/
|
||||
|
||||
import { LogEntry } from "../columns";
|
||||
import { ParsedTool, ToolDefinition, ToolCall } from "./types";
|
||||
|
||||
/**
|
||||
* Parse raw data that might be a string or object
|
||||
*/
|
||||
function parseData(input: any): any {
|
||||
if (typeof input === "string") {
|
||||
try {
|
||||
return JSON.parse(input);
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tools array from request data
|
||||
*/
|
||||
function extractToolsFromRequest(log: LogEntry): ToolDefinition[] {
|
||||
// Check proxy_server_request first (most complete), then messages
|
||||
const requestData = parseData(log.proxy_server_request || log.messages);
|
||||
|
||||
if (!requestData) return [];
|
||||
|
||||
// Handle array format (messages array)
|
||||
if (Array.isArray(requestData)) {
|
||||
// Tools are not typically in messages array, return empty
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle object format (request body)
|
||||
if (typeof requestData === "object" && requestData.tools) {
|
||||
return Array.isArray(requestData.tools) ? requestData.tools : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool calls from response data
|
||||
*/
|
||||
function extractToolCallsFromResponse(log: LogEntry): ToolCall[] {
|
||||
const responseData = parseData(log.response);
|
||||
|
||||
if (!responseData || typeof responseData !== "object") return [];
|
||||
|
||||
// OpenAI format: response.choices[0].message.tool_calls
|
||||
const choices = responseData.choices;
|
||||
if (Array.isArray(choices) && choices.length > 0) {
|
||||
const firstChoice = choices[0];
|
||||
const message = firstChoice.message;
|
||||
if (message && Array.isArray(message.tool_calls)) {
|
||||
return message.tool_calls;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse safe JSON with fallback
|
||||
*/
|
||||
function parseSafeJson(jsonString: string): Record<string, any> {
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to parse tools from a log entry
|
||||
* Returns an array of tools with their definition and call status
|
||||
*/
|
||||
export function parseToolsFromLog(log: LogEntry): ParsedTool[] {
|
||||
// Get tools from request
|
||||
const requestTools = extractToolsFromRequest(log);
|
||||
|
||||
if (requestTools.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get tool calls from response
|
||||
const toolCalls = extractToolCallsFromResponse(log);
|
||||
const calledToolNames = new Set(
|
||||
toolCalls.map((tc: ToolCall) => tc.function?.name).filter(Boolean)
|
||||
);
|
||||
|
||||
// Map tool calls by name for quick lookup
|
||||
const toolCallMap = new Map<string, any>();
|
||||
toolCalls.forEach((tc: ToolCall) => {
|
||||
const name = tc.function?.name;
|
||||
if (name) {
|
||||
toolCallMap.set(name, {
|
||||
id: tc.id,
|
||||
name: name,
|
||||
arguments: parseSafeJson(tc.function?.arguments || "{}"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Parse each tool definition
|
||||
return requestTools.map((tool: ToolDefinition, index: number) => {
|
||||
const func = tool.function || { name: `Tool ${index + 1}` };
|
||||
const name = func.name || `Tool ${index + 1}`;
|
||||
|
||||
return {
|
||||
index: index + 1,
|
||||
name: name,
|
||||
description: func.description || "",
|
||||
parameters: func.parameters || {},
|
||||
called: calledToolNames.has(name),
|
||||
callData: toolCallMap.get(name),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a log entry has any tools
|
||||
*/
|
||||
export function hasTools(log: LogEntry): boolean {
|
||||
const requestTools = extractToolsFromRequest(log);
|
||||
return requestTools.length > 0;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { Collapse } from "antd";
|
||||
import { getProviderLogoAndName } from "../provider_info_helpers";
|
||||
|
||||
interface VectorStoreContent {
|
||||
|
|
@ -30,7 +31,6 @@ interface VectorStoreViewerProps {
|
|||
}
|
||||
|
||||
export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
|
||||
const [sectionExpanded, setSectionExpanded] = useState(true);
|
||||
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
|
|
@ -56,27 +56,16 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center p-4 border-b cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setSectionExpanded(!sectionExpanded)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className={`w-5 h-5 mr-2 text-gray-600 transition-transform ${sectionExpanded ? "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>
|
||||
<h3 className="text-lg font-medium">Vector Store Requests</h3>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{sectionExpanded ? "Click to collapse" : "Click to expand"}</span>
|
||||
</div>
|
||||
|
||||
{sectionExpanded && (
|
||||
<div className="p-4">
|
||||
<div className="bg-white rounded-lg shadow 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">
|
||||
|
|
@ -168,7 +157,10 @@ export function VectorStoreViewer({ data }: VectorStoreViewerProps) {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue