mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #36129 from BerriAI/litellm_playground_shadcn
feat(ui): migrate playground chat controls to shadcn
This commit is contained in:
commit
fa498391a4
39 changed files with 2869 additions and 2181 deletions
|
|
@ -341,7 +341,7 @@
|
|||
},
|
||||
"src/app/(dashboard)/guardrails/_components/GuardrailTestResults.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx": {
|
||||
|
|
@ -1046,15 +1046,7 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/A2AMetrics.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/AdditionalModelSettings.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
|
|
@ -1070,11 +1062,6 @@
|
|||
"count": 5
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/ChatImageUpload.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/ChatImageUtils.test.tsx": {
|
||||
"max-nested-callbacks": {
|
||||
"count": 1
|
||||
|
|
@ -1087,48 +1074,26 @@
|
|||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx": {
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 2
|
||||
"count": 1
|
||||
},
|
||||
"max-lines": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 7
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
"count": 6
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 4
|
||||
},
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 13
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterOutput.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-syntax": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/CodeInterpreterTool.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/EndpointSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/RealtimePlayground.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
|
|
@ -1143,21 +1108,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/ResponsesImageUpload.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/SearchResultsDisplay.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/chat_ui/SessionManagement.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/components/compareUI/CompareUI.tsx": {
|
||||
"max-lines": {
|
||||
"count": 1
|
||||
|
|
@ -1295,11 +1245,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/playground/page.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/policies/_components/add_attachment_form.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
@ -2468,9 +2413,6 @@
|
|||
"src/components/agent_management/AgentSelector.test.tsx": {
|
||||
"react/display-name": {
|
||||
"count": 1
|
||||
},
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/agent_management/AgentSelector.tsx": {
|
||||
|
|
@ -2544,21 +2486,6 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/chat_ui/MCPEventsDisplay.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/chat_ui/ReasoningContent.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/chat_ui/ResponseMetrics.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/chat_ui/mode_endpoint_mapping.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
@ -2842,11 +2769,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/guardrails/GuardrailSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/key_info_utils.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
@ -3064,9 +2986,6 @@
|
|||
"src/components/navbar.test.tsx": {
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
},
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/navbar.tsx": {
|
||||
|
|
@ -3199,9 +3118,6 @@
|
|||
"src/components/policies/PolicySelector.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/price_data_reload.tsx": {
|
||||
|
|
@ -3312,7 +3228,7 @@
|
|||
"count": 3
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 7
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/components/shared/CreatedKeyDisplay.tsx": {
|
||||
|
|
@ -3437,11 +3353,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/tag_management/TagSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/tag_management/types.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
@ -3734,16 +3645,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/vector_store_management/VectorStoreSelector.test.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/vector_store_management/VectorStoreSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/vector_store_management/types.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import React, { useState } from "react";
|
||||
import { Tooltip, Button } from "antd";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
LoadingOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CopyOutlined,
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
LinkOutlined,
|
||||
FileTextOutlined,
|
||||
RobotOutlined,
|
||||
} from "@ant-design/icons";
|
||||
Bot,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Clock,
|
||||
Copy,
|
||||
FileText,
|
||||
Link,
|
||||
LoaderCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export interface A2ATaskMetadata {
|
||||
taskId?: string;
|
||||
|
|
@ -21,7 +23,7 @@ export interface A2ATaskMetadata {
|
|||
timestamp?: string;
|
||||
message?: string;
|
||||
};
|
||||
metadata?: Record<string, any>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface A2AMetricsProps {
|
||||
|
|
@ -33,15 +35,15 @@ interface A2AMetricsProps {
|
|||
const getStatusIcon = (state?: string) => {
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return <CheckCircleOutlined className="text-green-500" />;
|
||||
return <CheckCircle className="size-3 text-green-500" />;
|
||||
case "working":
|
||||
case "submitted":
|
||||
return <LoadingOutlined className="text-blue-500" />;
|
||||
return <LoaderCircle className="size-3 animate-spin text-blue-500" />;
|
||||
case "failed":
|
||||
case "canceled":
|
||||
return <ExclamationCircleOutlined className="text-red-500" />;
|
||||
return <CircleAlert className="size-3 text-red-500" />;
|
||||
default:
|
||||
return <ClockCircleOutlined className="text-gray-500" />;
|
||||
return <Clock className="size-3 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -91,7 +93,7 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
|
|||
<div className="a2a-metrics mt-3 pt-2 border-t border-gray-200 text-xs">
|
||||
{/* A2A Metadata Header */}
|
||||
<div className="flex items-center mb-2 text-gray-600">
|
||||
<RobotOutlined className="mr-1.5 text-blue-500" />
|
||||
<Bot className="mr-1.5 size-4 text-blue-500" />
|
||||
<span className="font-medium text-gray-700">A2A Metadata</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -109,28 +111,33 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
|
|||
|
||||
{/* Timestamp */}
|
||||
{formattedTime && (
|
||||
<Tooltip title={status?.timestamp}>
|
||||
<span className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className="flex items-center" />}>
|
||||
<Clock className="mr-1 size-3" />
|
||||
{formattedTime}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{status?.timestamp}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Latency */}
|
||||
{totalLatency !== undefined && (
|
||||
<Tooltip title="Total latency">
|
||||
<span className="flex items-center text-blue-600">
|
||||
<ClockCircleOutlined className="mr-1" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className="flex items-center text-blue-600" />}>
|
||||
<Clock className="mr-1 size-3" />
|
||||
{(totalLatency / 1000).toFixed(2)}s
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Total latency</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Time to first token */}
|
||||
{timeToFirstToken !== undefined && (
|
||||
<Tooltip title="Time to first token">
|
||||
<span className="flex items-center text-green-600">TTFT: {(timeToFirstToken / 1000).toFixed(2)}s</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className="flex items-center text-green-600" />}>
|
||||
TTFT: {(timeToFirstToken / 1000).toFixed(2)}s
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Time to first token</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -139,95 +146,133 @@ const A2AMetrics: React.FC<A2AMetricsProps> = ({ a2aMetadata, timeToFirstToken,
|
|||
<div className="flex flex-wrap items-center gap-3 text-gray-500 ml-4 mt-1.5">
|
||||
{/* Task ID */}
|
||||
{taskId && (
|
||||
<Tooltip title={`Click to copy: ${taskId}`}>
|
||||
<span
|
||||
className="flex items-center cursor-pointer hover:text-gray-700"
|
||||
onClick={() => copyToClipboard(taskId)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto p-0 font-normal text-gray-500 hover:bg-transparent hover:text-gray-700"
|
||||
onClick={() => copyToClipboard(taskId)}
|
||||
aria-label={`Copy task ID ${taskId}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FileTextOutlined className="mr-1" />
|
||||
<FileText className="size-3" />
|
||||
Task: {truncateId(taskId)}
|
||||
<CopyOutlined className="ml-1 text-gray-400 hover:text-gray-600" />
|
||||
</span>
|
||||
<Copy className="size-3 text-gray-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Click to copy: {taskId}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Context/Session ID */}
|
||||
{contextId && (
|
||||
<Tooltip title={`Click to copy: ${contextId}`}>
|
||||
<span
|
||||
className="flex items-center cursor-pointer hover:text-gray-700"
|
||||
onClick={() => copyToClipboard(contextId)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto p-0 font-normal text-gray-500 hover:bg-transparent hover:text-gray-700"
|
||||
onClick={() => copyToClipboard(contextId)}
|
||||
aria-label={`Copy session ID ${contextId}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LinkOutlined className="mr-1" />
|
||||
<Link className="size-3" />
|
||||
Session: {truncateId(contextId)}
|
||||
<CopyOutlined className="ml-1 text-gray-400 hover:text-gray-600" />
|
||||
</span>
|
||||
<Copy className="size-3 text-gray-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Click to copy: {contextId}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Details toggle */}
|
||||
{(metadata || status?.message) && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="text-xs text-blue-500 hover:text-blue-700 p-0 h-auto"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
>
|
||||
{showDetails ? <DownOutlined /> : <RightOutlined />}
|
||||
<span className="ml-1">Details</span>
|
||||
</Button>
|
||||
<Collapsible open={showDetails} onOpenChange={setShowDetails}>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto p-0 text-xs text-blue-500 hover:bg-transparent hover:text-blue-700"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{showDetails ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
Details
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable details panel */}
|
||||
{showDetails && (
|
||||
<div className="mt-2 ml-4 p-3 bg-gray-50 rounded-md text-gray-600 border border-gray-200">
|
||||
{/* Status message */}
|
||||
{status?.message && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">Status Message:</span>
|
||||
<span className="ml-2">{status.message}</span>
|
||||
</div>
|
||||
)}
|
||||
<Collapsible open={showDetails} onOpenChange={setShowDetails}>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-2 ml-4 p-3 bg-gray-50 rounded-md text-gray-600 border border-gray-200">
|
||||
{/* Status message */}
|
||||
{status?.message && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">Status Message:</span>
|
||||
<span className="ml-2">{status.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full IDs */}
|
||||
{taskId && (
|
||||
<div className="mb-1.5 flex items-center">
|
||||
<span className="font-medium text-gray-700 w-24">Task ID:</span>
|
||||
<code className="ml-2 px-2 py-1 bg-white border border-gray-200 rounded-sm text-xs font-mono">
|
||||
{taskId}
|
||||
</code>
|
||||
<CopyOutlined
|
||||
className="ml-2 cursor-pointer text-gray-400 hover:text-blue-500"
|
||||
onClick={() => copyToClipboard(taskId)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Full IDs */}
|
||||
{taskId && (
|
||||
<div className="mb-1.5 flex items-center">
|
||||
<span className="font-medium text-gray-700 w-24">Task ID:</span>
|
||||
<code className="ml-2 px-2 py-1 bg-white border border-gray-200 rounded-sm text-xs font-mono">
|
||||
{taskId}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-2 text-gray-400 hover:text-blue-500"
|
||||
onClick={() => copyToClipboard(taskId)}
|
||||
aria-label={`Copy task ID ${taskId}`}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextId && (
|
||||
<div className="mb-1.5 flex items-center">
|
||||
<span className="font-medium text-gray-700 w-24">Session ID:</span>
|
||||
<code className="ml-2 px-2 py-1 bg-white border border-gray-200 rounded-sm text-xs font-mono">
|
||||
{contextId}
|
||||
</code>
|
||||
<CopyOutlined
|
||||
className="ml-2 cursor-pointer text-gray-400 hover:text-blue-500"
|
||||
onClick={() => copyToClipboard(contextId)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{contextId && (
|
||||
<div className="mb-1.5 flex items-center">
|
||||
<span className="font-medium text-gray-700 w-24">Session ID:</span>
|
||||
<code className="ml-2 px-2 py-1 bg-white border border-gray-200 rounded-sm text-xs font-mono">
|
||||
{contextId}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="ml-2 text-gray-400 hover:text-blue-500"
|
||||
onClick={() => copyToClipboard(contextId)}
|
||||
aria-label={`Copy session ID ${contextId}`}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata fields */}
|
||||
{metadata && Object.keys(metadata).length > 0 && (
|
||||
<div className="mt-3">
|
||||
<span className="font-medium text-gray-700">Custom Metadata:</span>
|
||||
<pre className="mt-1.5 p-2 bg-white border border-gray-200 rounded-sm text-xs font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Metadata fields */}
|
||||
{metadata && Object.keys(metadata).length > 0 && (
|
||||
<div className="mt-3">
|
||||
<span className="font-medium text-gray-700">Custom Metadata:</span>
|
||||
<pre className="mt-1.5 p-2 bg-white border border-gray-200 rounded-sm text-xs font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import AdditionalModelSettings from "./AdditionalModelSettings";
|
||||
|
|
@ -128,4 +128,47 @@ describe("AdditionalModelSettings", () => {
|
|||
expect(onMockTestFallbacksChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep a half-typed decimal temperature instead of rewriting it", async () => {
|
||||
const onTemperatureChange = vi.fn();
|
||||
|
||||
render(<AdditionalModelSettings useAdvancedParams onTemperatureChange={onTemperatureChange} />);
|
||||
|
||||
const temperatureField = screen.getByLabelText("Temperature value") as HTMLInputElement;
|
||||
fireEvent.change(temperatureField, { target: { value: "0." } });
|
||||
|
||||
expect(temperatureField.value).toBe("0.");
|
||||
|
||||
fireEvent.change(temperatureField, { target: { value: "0.5" } });
|
||||
|
||||
expect(temperatureField.value).toBe("0.5");
|
||||
expect(onTemperatureChange).toHaveBeenLastCalledWith(0.5);
|
||||
});
|
||||
|
||||
it("should let the max tokens field be cleared instead of snapping to a value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMaxTokensChange = vi.fn();
|
||||
|
||||
render(<AdditionalModelSettings useAdvancedParams onMaxTokensChange={onMaxTokensChange} />);
|
||||
|
||||
const maxTokensField = screen.getByLabelText("Max tokens value");
|
||||
await user.clear(maxTokensField);
|
||||
|
||||
expect((maxTokensField as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("should clamp an out-of-range temperature once the field is left", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTemperatureChange = vi.fn();
|
||||
|
||||
render(<AdditionalModelSettings useAdvancedParams onTemperatureChange={onTemperatureChange} />);
|
||||
|
||||
const temperatureField = screen.getByLabelText("Temperature value");
|
||||
await user.clear(temperatureField);
|
||||
await user.type(temperatureField, "9");
|
||||
await user.tab();
|
||||
|
||||
expect((temperatureField as HTMLInputElement).value).toBe("2");
|
||||
expect(onTemperatureChange).toHaveBeenLastCalledWith(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Text } from "@tremor/react";
|
||||
import { Checkbox, InputNumber, Popover, Slider, Tooltip, Typography } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Info } from "lucide-react";
|
||||
import React, { useEffect, useId, useState } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
interface AdditionalModelSettingsProps {
|
||||
temperature?: number;
|
||||
|
|
@ -17,6 +20,10 @@ interface AdditionalModelSettingsProps {
|
|||
showAdvancedParams?: boolean;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
|
||||
temperature = 1.0,
|
||||
maxTokens = 2048,
|
||||
|
|
@ -35,30 +42,56 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
|
|||
externalUseAdvancedParams !== undefined ? externalUseAdvancedParams : internalUseAdvancedParams;
|
||||
const [localTemperature, setLocalTemperature] = useState(temperature);
|
||||
const [localMaxTokens, setLocalMaxTokens] = useState(maxTokens);
|
||||
const [temperatureText, setTemperatureText] = useState(String(temperature));
|
||||
const [maxTokensText, setMaxTokensText] = useState(String(maxTokens));
|
||||
|
||||
const streamingId = useId();
|
||||
const advancedId = useId();
|
||||
const fallbacksId = useId();
|
||||
const temperatureId = useId();
|
||||
const maxTokensId = useId();
|
||||
|
||||
// Sync local state with props when they change
|
||||
useEffect(() => {
|
||||
setLocalTemperature(temperature);
|
||||
setTemperatureText(String(temperature));
|
||||
}, [temperature]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalMaxTokens(maxTokens);
|
||||
setMaxTokensText(String(maxTokens));
|
||||
}, [maxTokens]);
|
||||
|
||||
const handleTemperatureChange = (value: number | null) => {
|
||||
const newValue = value ?? 1.0;
|
||||
const handleTemperatureChange = (value: number) => {
|
||||
const newValue = clamp(Number.isFinite(value) ? value : 1.0, 0, 2);
|
||||
setLocalTemperature(newValue);
|
||||
setTemperatureText(String(newValue));
|
||||
onTemperatureChange?.(newValue);
|
||||
};
|
||||
|
||||
const handleMaxTokensChange = (value: number | null) => {
|
||||
const newValue = value ?? 1000;
|
||||
const handleMaxTokensChange = (value: number) => {
|
||||
const newValue = clamp(Number.isFinite(value) ? Math.round(value) : 1000, 1, 32768);
|
||||
setLocalMaxTokens(newValue);
|
||||
setMaxTokensText(String(newValue));
|
||||
onMaxTokensChange?.(newValue);
|
||||
};
|
||||
|
||||
const disabledOpacity = useAdvancedParams ? 1 : 0.4;
|
||||
const disabledTextColor = useAdvancedParams ? "text-gray-700" : "text-gray-400";
|
||||
const handleTemperatureTyped = (raw: string) => {
|
||||
setTemperatureText(raw);
|
||||
const parsed = Number(raw);
|
||||
if (raw.trim() !== "" && Number.isFinite(parsed) && parsed >= 0 && parsed <= 2) {
|
||||
setLocalTemperature(parsed);
|
||||
onTemperatureChange?.(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMaxTokensTyped = (raw: string) => {
|
||||
setMaxTokensText(raw);
|
||||
const parsed = Number(raw);
|
||||
if (raw.trim() !== "" && Number.isInteger(parsed) && parsed >= 1 && parsed <= 32768) {
|
||||
setLocalMaxTokens(parsed);
|
||||
onMaxTokensChange?.(parsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseAdvancedParamsChange = (checked: boolean) => {
|
||||
if (onUseAdvancedParamsChange) {
|
||||
|
|
@ -68,129 +101,176 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const disabledTextColor = useAdvancedParams ? "text-gray-700" : "text-gray-400";
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4 w-80">
|
||||
<div className="w-80 space-y-4 p-4">
|
||||
{onStreamingChange && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox checked={streamingEnabled} onChange={(e) => onStreamingChange(e.target.checked)}>
|
||||
<span className="font-medium">Stream responses</span>
|
||||
</Checkbox>
|
||||
<Tooltip title="Streams the answer token by token. Uncheck to send a non-streaming request and render the full response at once.">
|
||||
<InfoCircleOutlined
|
||||
className="text-xs text-gray-400 cursor-pointer shrink-0 hover:text-gray-600"
|
||||
aria-label="Help: Stream responses"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={streamingId}
|
||||
checked={streamingEnabled}
|
||||
onCheckedChange={(checked) => onStreamingChange(checked === true)}
|
||||
aria-label="Stream responses"
|
||||
/>
|
||||
<label htmlFor={streamingId} className="cursor-pointer text-sm font-medium">
|
||||
Stream responses
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger aria-label="Help: Stream responses">
|
||||
<Info className="size-3 shrink-0 cursor-pointer text-gray-400 hover:text-gray-600" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
Streams the answer token by token. Uncheck to send a non-streaming request and render the full response at
|
||||
once.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdvancedParams && (
|
||||
<Checkbox checked={useAdvancedParams} onChange={(e) => handleUseAdvancedParamsChange(e.target.checked)}>
|
||||
<span className="font-medium">Use Advanced Parameters</span>
|
||||
</Checkbox>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={advancedId}
|
||||
checked={useAdvancedParams}
|
||||
onCheckedChange={(checked) => handleUseAdvancedParamsChange(checked === true)}
|
||||
aria-label="Use Advanced Parameters"
|
||||
/>
|
||||
<label htmlFor={advancedId} className="cursor-pointer text-sm font-medium">
|
||||
Use Advanced Parameters
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onMockTestFallbacksChange && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox checked={mockTestFallbacks ?? false} onChange={(e) => onMockTestFallbacksChange(e.target.checked)}>
|
||||
<span className="font-medium">Simulate failure to test fallbacks</span>
|
||||
</Checkbox>
|
||||
<Popover
|
||||
trigger="hover"
|
||||
placement="right"
|
||||
content={
|
||||
<div style={{ maxWidth: 340 }}>
|
||||
<Typography.Paragraph className="text-sm" style={{ marginBottom: 8 }}>
|
||||
Causes the first request to fail so the router tries fallbacks (if configured). Use this to verify
|
||||
your fallback setup.
|
||||
</Typography.Paragraph>
|
||||
<Typography.Paragraph className="text-sm" style={{ marginBottom: 0 }}>
|
||||
Behavior can differ when keys, teams, or router settings are configured.{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/keys_teams_router_settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<InfoCircleOutlined
|
||||
className="text-xs text-gray-400 cursor-pointer shrink-0 hover:text-gray-600"
|
||||
aria-label="Help: Simulate failure to test fallbacks"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={fallbacksId}
|
||||
checked={mockTestFallbacks ?? false}
|
||||
onCheckedChange={(checked) => onMockTestFallbacksChange(checked === true)}
|
||||
aria-label="Simulate failure to test fallbacks"
|
||||
/>
|
||||
<label htmlFor={fallbacksId} className="cursor-pointer text-sm font-medium">
|
||||
Simulate failure to test fallbacks
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger aria-label="Help: Simulate failure to test fallbacks">
|
||||
<Info className="size-3 shrink-0 cursor-pointer text-gray-400 hover:text-gray-600" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="right" className="max-w-[340px] gap-2 p-3 text-sm">
|
||||
<p>
|
||||
Causes the first request to fail so the router tries fallbacks (if configured). Use this to verify your
|
||||
fallback setup.
|
||||
</p>
|
||||
<p>
|
||||
Behavior can differ when keys, teams, or router settings are configured.{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/keys_teams_router_settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdvancedParams && (
|
||||
<div className="space-y-4 transition-opacity duration-200" style={{ opacity: disabledOpacity }}>
|
||||
<div
|
||||
className={cn("space-y-4 transition-opacity duration-200", useAdvancedParams ? "opacity-100" : "opacity-40")}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Text className={`text-sm ${disabledTextColor}`}>Temperature</Text>
|
||||
<Tooltip title="Controls randomness. Lower values make output more deterministic, higher values more creative.">
|
||||
<InfoCircleOutlined className={`text-xs ${disabledTextColor} cursor-help`} />
|
||||
<label htmlFor={temperatureId} className={cn("text-sm", disabledTextColor)}>
|
||||
Temperature
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger aria-label="Help: Temperature">
|
||||
<Info className={cn("size-3 cursor-help", disabledTextColor)} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
Controls randomness. Lower values make output more deterministic, higher values more creative.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={localTemperature}
|
||||
onChange={handleTemperatureChange}
|
||||
<Input
|
||||
id={`${temperatureId}-number`}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
aria-label="Temperature value"
|
||||
value={temperatureText}
|
||||
disabled={!useAdvancedParams}
|
||||
precision={1}
|
||||
className="w-20"
|
||||
className="h-8 w-20"
|
||||
onChange={(event) => handleTemperatureTyped(event.target.value)}
|
||||
onBlur={() => handleTemperatureChange(Number(temperatureText))}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
<input
|
||||
id={temperatureId}
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={localTemperature}
|
||||
onChange={handleTemperatureChange}
|
||||
disabled={!useAdvancedParams}
|
||||
marks={{
|
||||
0: "0",
|
||||
1: "1.0",
|
||||
2: "2.0",
|
||||
}}
|
||||
aria-label="Temperature"
|
||||
className="w-full accent-primary disabled:cursor-not-allowed"
|
||||
onChange={(event) => handleTemperatureChange(Number(event.target.value))}
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-400">
|
||||
<span>0</span>
|
||||
<span>1.0</span>
|
||||
<span>2.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Text className={`text-sm ${disabledTextColor}`}>Max Tokens</Text>
|
||||
<Tooltip title="Maximum number of tokens to generate in the response.">
|
||||
<InfoCircleOutlined className={`text-xs ${disabledTextColor} cursor-help`} />
|
||||
<label htmlFor={maxTokensId} className={cn("text-sm", disabledTextColor)}>
|
||||
Max Tokens
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger aria-label="Help: Max Tokens">
|
||||
<Info className={cn("size-3 cursor-help", disabledTextColor)} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
Maximum number of tokens to generate in the response.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={32768}
|
||||
step={1}
|
||||
value={localMaxTokens}
|
||||
onChange={handleMaxTokensChange}
|
||||
<Input
|
||||
id={`${maxTokensId}-number`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label="Max tokens value"
|
||||
value={maxTokensText}
|
||||
disabled={!useAdvancedParams}
|
||||
className="h-8 w-24"
|
||||
onChange={(event) => handleMaxTokensTyped(event.target.value)}
|
||||
onBlur={() => handleMaxTokensChange(Number(maxTokensText))}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
<input
|
||||
id={maxTokensId}
|
||||
type="range"
|
||||
min={1}
|
||||
max={32768}
|
||||
step={1}
|
||||
value={localMaxTokens}
|
||||
onChange={handleMaxTokensChange}
|
||||
disabled={!useAdvancedParams}
|
||||
marks={{
|
||||
1: "1",
|
||||
32768: "32768",
|
||||
}}
|
||||
aria-label="Max Tokens"
|
||||
className="w-full accent-primary disabled:cursor-not-allowed"
|
||||
onChange={(event) => handleMaxTokensChange(Number(event.target.value))}
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-400">
|
||||
<span>1</span>
|
||||
<span>32768</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ChatComposer } from "./ChatComposer";
|
||||
|
||||
const renderComposer = (props: Partial<React.ComponentProps<typeof ChatComposer>> = {}) =>
|
||||
render(<ChatComposer value="" onChange={vi.fn()} onSubmit={vi.fn()} placeholder="Send a message" {...props} />);
|
||||
|
||||
const addonOf = (container: HTMLElement) =>
|
||||
container.querySelector<HTMLElement>("[data-slot=input-group-addon]") as HTMLElement;
|
||||
|
||||
describe("ChatComposer", () => {
|
||||
it("should submit on Enter", () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderComposer({ onSubmit });
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter" });
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not submit on Shift+Enter", () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderComposer({ onSubmit });
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter", shiftKey: true });
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not submit while an IME composition is active", () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderComposer({ onSubmit });
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter", isComposing: true });
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not submit on Enter or click when submitDisabled", () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderComposer({ onSubmit, submitDisabled: true });
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId("chat-composer-input"), { key: "Enter" });
|
||||
fireEvent.click(screen.getByTestId("chat-send-button"));
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should submit when the send button is clicked", () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderComposer({ onSubmit });
|
||||
|
||||
fireEvent.click(screen.getByTestId("chat-send-button"));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should swap send for a stop button that cancels while loading", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
renderComposer({ onSubmit, onCancel, isLoading: true });
|
||||
|
||||
expect(screen.queryByTestId("chat-send-button")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("chat-stop-button"));
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should render suggestions only when asked and report the chosen one", () => {
|
||||
const onSuggestionSelect = vi.fn();
|
||||
const { rerender } = renderComposer({ suggestions: ["Summarize this"], onSuggestionSelect });
|
||||
|
||||
expect(screen.queryByTestId("chat-suggested-actions")).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ChatComposer
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
placeholder="Send a message"
|
||||
suggestions={["Summarize this"]}
|
||||
showSuggestions
|
||||
onSuggestionSelect={onSuggestionSelect}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText("Summarize this"));
|
||||
|
||||
expect(onSuggestionSelect).toHaveBeenCalledWith("Summarize this");
|
||||
});
|
||||
|
||||
it("should not nest a form inside the composer when body renders one", () => {
|
||||
const { container } = renderComposer({
|
||||
body: (
|
||||
<form data-testid="body-form">
|
||||
<input aria-label="tool argument" />
|
||||
</form>
|
||||
),
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll("form")).toHaveLength(1);
|
||||
expect(screen.getByTestId("body-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should focus the message box, not a tool input, when the toolbar gap is clicked", () => {
|
||||
const { container } = renderComposer({
|
||||
tools: <input type="file" aria-label="Attach file" />,
|
||||
});
|
||||
|
||||
fireEvent.click(addonOf(container));
|
||||
|
||||
expect(document.activeElement).toBe(screen.getByTestId("chat-composer-input"));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
import React from "react";
|
||||
import { ArrowUp, Code2, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
interface ChatComposerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onCancel?: () => void;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
submitDisabled?: boolean;
|
||||
tools?: React.ReactNode;
|
||||
body?: React.ReactNode;
|
||||
suggestions?: string[];
|
||||
showSuggestions?: boolean;
|
||||
onSuggestionSelect?: (suggestion: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
submitDisabled = false,
|
||||
tools,
|
||||
body,
|
||||
suggestions = [],
|
||||
showSuggestions = false,
|
||||
onSuggestionSelect,
|
||||
className,
|
||||
}: ChatComposerProps) {
|
||||
const submitIfAllowed = () => {
|
||||
if (!submitDisabled && !isLoading) {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
submitIfAllowed();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex w-full flex-col gap-3", className)}>
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div
|
||||
className="flex w-full gap-2 overflow-x-auto pb-1 sm:grid sm:grid-cols-2 sm:overflow-visible"
|
||||
data-testid="chat-suggested-actions"
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="min-w-[200px] shrink-0 rounded-xl border border-border/50 bg-card/30 px-4 py-3 text-left text-[12px] leading-relaxed text-muted-foreground transition-all duration-200 hover:-translate-y-0.5 hover:bg-card/60 hover:text-foreground sm:min-w-0 sm:whitespace-normal sm:p-4 sm:text-[13px]"
|
||||
onClick={() => onSuggestionSelect?.(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full">
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"h-auto min-h-[7.5rem] flex-col overflow-hidden rounded-2xl border border-border bg-card",
|
||||
"shadow-[0_1px_2px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.08)] ring-1 ring-black/5",
|
||||
"transition-[box-shadow,border-color,ring] duration-200",
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring",
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:shadow-[0_2px_8px_rgba(0,0,0,0.08),0_12px_32px_rgba(0,0,0,0.12)]",
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/40",
|
||||
)}
|
||||
>
|
||||
{body ? (
|
||||
<div className="max-h-48 min-h-24 w-full overflow-y-auto px-3 pt-3">{body}</div>
|
||||
) : (
|
||||
<InputGroupTextarea
|
||||
data-testid="chat-composer-input"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
className="min-h-24 max-h-48 resize-none overflow-y-auto border-0 bg-transparent px-4 pt-3.5 pb-1.5 text-[13px] leading-relaxed shadow-none placeholder:text-muted-foreground/50 focus-visible:ring-0 [field-sizing:content]"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InputGroupAddon align="block-end" className="justify-between gap-2 px-3 pb-3 pt-1">
|
||||
<div className="flex min-w-0 items-center gap-1">{tools}</div>
|
||||
|
||||
{isLoading && onCancel ? (
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
aria-label="Stop request"
|
||||
data-testid="chat-stop-button"
|
||||
className="size-8 rounded-xl bg-foreground text-background hover:bg-foreground/90"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<Square className="size-3.5 fill-current" />
|
||||
</InputGroupButton>
|
||||
) : (
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
aria-label="Send message"
|
||||
data-testid="chat-send-button"
|
||||
disabled={submitDisabled || isLoading}
|
||||
onClick={submitIfAllowed}
|
||||
className={cn(
|
||||
"size-8 rounded-xl transition-all duration-200",
|
||||
!submitDisabled && !isLoading
|
||||
? "bg-foreground text-background hover:opacity-90 active:scale-95"
|
||||
: "cursor-not-allowed bg-muted text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CodeInterpreterToggleProps {
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function CodeInterpreterToggle({ enabled, onToggle }: CodeInterpreterToggleProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-8 rounded-lg border border-border/40",
|
||||
enabled
|
||||
? "border-blue-200 bg-blue-50 text-blue-600 hover:bg-blue-100"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label={enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Code2 className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatComposer;
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { FileText } from "lucide-react";
|
||||
import { MessageType } from "@/components/chat_ui/types";
|
||||
import { shouldShowChatAttachedImage } from "./ChatImageUtils";
|
||||
import { FilePdfOutlined } from "@ant-design/icons";
|
||||
|
||||
interface ChatImageRendererProps {
|
||||
message: MessageType;
|
||||
|
|
@ -18,8 +18,8 @@ const ChatImageRenderer: React.FC<ChatImageRendererProps> = ({ message }) => {
|
|||
return (
|
||||
<div className="mb-2">
|
||||
{isPdf ? (
|
||||
<div className="w-64 h-32 rounded-md border border-gray-200 bg-red-50 flex items-center justify-center">
|
||||
<FilePdfOutlined style={{ fontSize: "48px", color: "#dc2626" }} />
|
||||
<div className="flex h-32 w-64 items-center justify-center rounded-md border border-gray-200 bg-red-50">
|
||||
<FileText className="size-12 text-red-600" aria-label="PDF attachment" />
|
||||
</div>
|
||||
) : (
|
||||
<Image
|
||||
|
|
|
|||
|
|
@ -1,43 +1,70 @@
|
|||
import React from "react";
|
||||
import { Upload, Tooltip } from "antd";
|
||||
import { PaperClipOutlined } from "@ant-design/icons";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
import React, { useId, useRef } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { CHAT_ATTACHMENT_ACCEPT, validateChatAttachment } from "./uploadValidation";
|
||||
|
||||
interface ChatImageUploadProps {
|
||||
chatUploadedImage: File | null;
|
||||
chatImagePreviewUrl: string | null;
|
||||
onImageUpload: (file: File) => false;
|
||||
onImageUpload: (file: File) => void;
|
||||
onRemoveImage: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ChatImageUpload: React.FC<ChatImageUploadProps> = ({
|
||||
chatUploadedImage,
|
||||
chatImagePreviewUrl,
|
||||
onImageUpload,
|
||||
onRemoveImage,
|
||||
}) => {
|
||||
const ChatImageUpload: React.FC<ChatImageUploadProps> = ({ chatUploadedImage, onImageUpload, disabled = false }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputId = useId();
|
||||
|
||||
if (chatUploadedImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const result = validateChatAttachment(file);
|
||||
if (!result.ok) {
|
||||
NotificationsManager.error(result.error);
|
||||
return;
|
||||
}
|
||||
onImageUpload(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Subtle upload button - only show when no image */}
|
||||
{!chatUploadedImage && (
|
||||
<Dragger
|
||||
beforeUpload={onImageUpload}
|
||||
accept="image/*,.pdf"
|
||||
showUploadList={false}
|
||||
className="inline-block"
|
||||
style={{ padding: 0, border: "none", background: "none" }}
|
||||
>
|
||||
<Tooltip title="Attach image or PDF">
|
||||
<button
|
||||
<input
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={CHAT_ATTACHMENT_ACCEPT}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
className="flex items-center justify-center w-8 h-8 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
<PaperClipOutlined style={{ fontSize: "16px" }} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Dragger>
|
||||
)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={disabled}
|
||||
aria-label="Attach image or PDF"
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Attach image or PDF</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { RobotOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Bot, User } from "lucide-react";
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
|
|
@ -41,9 +41,9 @@ function ChatMessageBubble({
|
|||
const isUser = message.role === "user";
|
||||
|
||||
return (
|
||||
<div className={`mb-4 ${isUser ? "text-right" : "text-left"}`}>
|
||||
<div className={`mb-4 min-w-0 ${isUser ? "text-right" : "text-left"}`}>
|
||||
<div
|
||||
className="inline-block max-w-[80%] rounded-lg shadow-xs p-3.5 px-4"
|
||||
className="inline-block min-w-0 max-w-[92%] overflow-hidden rounded-lg p-3 shadow-xs sm:max-w-[85%] sm:px-4"
|
||||
style={{
|
||||
backgroundColor: isUser ? "#f0f8ff" : "#ffffff",
|
||||
border: isUser ? "1px solid #e6f0fa" : "1px solid #f0f0f0",
|
||||
|
|
@ -51,7 +51,7 @@ function ChatMessageBubble({
|
|||
}}
|
||||
>
|
||||
{/* Header: role icon + name + model badge */}
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className="flex items-center justify-center w-6 h-6 rounded-full mr-1"
|
||||
style={{
|
||||
|
|
@ -59,14 +59,14 @@ function ChatMessageBubble({
|
|||
}}
|
||||
>
|
||||
{isUser ? (
|
||||
<UserOutlined style={{ fontSize: "12px", color: "#2563eb" }} />
|
||||
<User className="size-3 text-blue-600" aria-hidden="true" />
|
||||
) : (
|
||||
<RobotOutlined style={{ fontSize: "12px", color: "#4b5563" }} />
|
||||
<Bot className="size-3 text-gray-600" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<strong className="text-sm capitalize">{message.role}</strong>
|
||||
{message.role === "assistant" && message.model && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-sm bg-gray-100 text-gray-600 font-normal">
|
||||
<span className="max-w-48 truncate rounded-sm bg-gray-100 px-2 py-0.5 text-xs font-normal text-gray-600 sm:max-w-80">
|
||||
{message.model}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders as render } from "@/../tests/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ChatUI from "./ChatUI";
|
||||
import * as fetchModelsModule from "@/components/llm_calls/fetch_models";
|
||||
import { makeOpenAIChatCompletionRequest } from "@/components/llm_calls/chat_completion";
|
||||
|
||||
// Mock the fetchAvailableModels function
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn(),
|
||||
}));
|
||||
|
|
@ -14,15 +14,18 @@ vi.mock("@/components/llm_calls/chat_completion", () => ({
|
|||
makeOpenAIChatCompletionRequest: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock other networking functions that cause errors
|
||||
vi.mock("@/components/networking", () => ({
|
||||
tagListCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
tagListCall: vi.fn().mockResolvedValue({}),
|
||||
vectorStoreListCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getGuardrailsList: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getPoliciesList: vi.fn().mockResolvedValue({ data: [] }),
|
||||
modelHubCall: vi.fn().mockResolvedValue({ data: [] }),
|
||||
fetchMCPServers: vi.fn().mockResolvedValue([]),
|
||||
fetchMCPToolsets: vi.fn().mockResolvedValue([]),
|
||||
listMCPTools: vi.fn().mockResolvedValue({ tools: [] }),
|
||||
callMCPTool: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock scrollIntoView which is not available in jsdom
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
});
|
||||
|
|
@ -30,17 +33,27 @@ beforeEach(() => {
|
|||
const CHAT_REQUEST_ARG_COUNT = 26;
|
||||
const STREAMING_ENABLED_ARG_INDEX = 25;
|
||||
|
||||
async function openComboboxByPlaceholder(placeholder: string) {
|
||||
const user = userEvent.setup();
|
||||
const combobox = screen.getByPlaceholderText(placeholder);
|
||||
await user.click(combobox);
|
||||
return combobox;
|
||||
}
|
||||
|
||||
async function selectComboboxOption(placeholder: string, optionLabel: string) {
|
||||
const user = userEvent.setup();
|
||||
await openComboboxByPlaceholder(placeholder);
|
||||
const option = await screen.findByText(optionLabel);
|
||||
await user.click(option);
|
||||
}
|
||||
|
||||
describe("ChatUI", () => {
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
vi.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
|
||||
// Mock scrollIntoView which is not available in JSDOM
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
// Mock the fetchAvailableModels to return test models
|
||||
(fetchModelsModule.fetchAvailableModels as any).mockResolvedValue([
|
||||
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ model_group: "Model 1", mode: "chat" },
|
||||
{ model_group: "Model 2", mode: "chat" },
|
||||
{ model_group: "Model 3", mode: "chat" },
|
||||
|
|
@ -48,7 +61,7 @@ describe("ChatUI", () => {
|
|||
});
|
||||
|
||||
it("should render the chat UI", async () => {
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
token="1234567890"
|
||||
|
|
@ -57,11 +70,11 @@ describe("ChatUI", () => {
|
|||
disabledPersonalKeyCreation={false}
|
||||
/>,
|
||||
);
|
||||
expect(getByText("Test Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the voice selector when the endpoint type is audio_speech", async () => {
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
token="1234567890"
|
||||
|
|
@ -71,47 +84,20 @@ describe("ChatUI", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
// Wait for the component to render
|
||||
await waitFor(() => {
|
||||
expect(getByText("Test Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the endpoint selector by looking for the "Endpoint Type:" text and its associated Select
|
||||
const endpointTypeText = getByText("Endpoint Type");
|
||||
const selectContainer = endpointTypeText.parentElement;
|
||||
const selectElement = selectContainer?.querySelector(".ant-select-selector");
|
||||
await selectComboboxOption("Select an endpoint", "/v1/audio/speech");
|
||||
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
|
||||
// Click on the select to open the dropdown
|
||||
if (selectElement) {
|
||||
fireEvent.mouseDown(selectElement);
|
||||
}
|
||||
|
||||
// Wait for the dropdown to appear and find the audio_speech option
|
||||
await waitFor(() => {
|
||||
const audioSpeechOption = screen.getByText("/v1/audio/speech");
|
||||
expect(audioSpeechOption).toBeInTheDocument();
|
||||
expect(screen.getByText("Voice")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Voice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the audio_speech option
|
||||
const audioSpeechOption = screen.getByText("/v1/audio/speech");
|
||||
fireEvent.click(audioSpeechOption);
|
||||
|
||||
// Verify the voice selector appears
|
||||
await waitFor(() => {
|
||||
expect(getByText("Voice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify the voice select component is present
|
||||
const voiceText = getByText("Voice");
|
||||
const voiceSelectContainer = voiceText.parentElement;
|
||||
const voiceSelectElement = voiceSelectContainer?.querySelector(".ant-select");
|
||||
expect(voiceSelectElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should allow the user to select a model", async () => {
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
token="1234567890"
|
||||
|
|
@ -121,35 +107,28 @@ describe("ChatUI", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
// Wait for the component to render
|
||||
await waitFor(() => {
|
||||
expect(getByText("Test Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open the "Select Model" dropdown (AntD renders options in a portal)
|
||||
const selectModelLabel = getByText("Select Model");
|
||||
// The Select component is a sibling of the Text component, so we need to find it in the parent container
|
||||
const modelSelectContainer = selectModelLabel.closest("div");
|
||||
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
|
||||
expect(modelSelect).toBeTruthy();
|
||||
|
||||
fireEvent.mouseDown(modelSelect!);
|
||||
await openComboboxByPlaceholder("Select a Model");
|
||||
|
||||
await waitFor(() => {
|
||||
const model1Label = screen.getAllByText("Model 1");
|
||||
expect(model1Label.length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows only chat-compatible models when chat endpoint is selected", async () => {
|
||||
(fetchModelsModule.fetchAvailableModels as any).mockResolvedValueOnce([
|
||||
it("shows only endpoint-compatible models when chat endpoint is selected", async () => {
|
||||
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ model_group: "ChatModel", mode: "chat" },
|
||||
{ model_group: "SpeechModel", mode: "audio_speech" },
|
||||
{ model_group: "ImageModel", mode: "image_generation" },
|
||||
{ model_group: "ResponsesModel", mode: "responses" },
|
||||
{ model_group: "RealtimeModel", mode: "realtime" },
|
||||
{ model_group: "NoModeModel" },
|
||||
]);
|
||||
|
||||
const { getByText } = render(
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
token="1234567890"
|
||||
|
|
@ -160,43 +139,30 @@ describe("ChatUI", () => {
|
|||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText("Test Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open endpoint selector and explicitly select /v1/chat/completions
|
||||
const endpointTypeText = getByText("Endpoint Type");
|
||||
const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector");
|
||||
expect(endpointSelect).toBeTruthy();
|
||||
act(() => {
|
||||
fireEvent.mouseDown(endpointSelect!);
|
||||
fireEvent.click(screen.getByText("/v1/chat/completions"));
|
||||
});
|
||||
|
||||
// Open model selector
|
||||
const selectModelLabel = getByText("Select Model");
|
||||
// The Select component is a sibling of the Text component, so we need to find it in the parent container
|
||||
const modelSelectContainer = selectModelLabel.closest("div");
|
||||
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
|
||||
expect(modelSelect).toBeTruthy();
|
||||
act(() => {
|
||||
fireEvent.mouseDown(modelSelect!);
|
||||
});
|
||||
await selectComboboxOption("Select an endpoint", "/v1/chat/completions");
|
||||
await openComboboxByPlaceholder("Select a Model");
|
||||
|
||||
await waitFor(() => {
|
||||
// Chat-compatible: ChatModel should be visible
|
||||
expect(screen.getAllByText("ChatModel").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("NoModeModel").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("SpeechModel")).toBeNull();
|
||||
expect(screen.queryByText("ImageModel")).toBeNull();
|
||||
expect(screen.queryByText("ResponsesModel")).toBeNull();
|
||||
expect(screen.queryByText("RealtimeModel")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests that the 'Enter custom model' option is available in the model selector dropdown.
|
||||
* This ensures users can manually enter a model name if it's not in the list.
|
||||
*/
|
||||
it("should show 'Enter custom model' option in model selector", async () => {
|
||||
const { getByText } = render(
|
||||
it("shows only realtime models when realtime endpoint is selected", async () => {
|
||||
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ model_group: "ChatModel", mode: "chat" },
|
||||
{ model_group: "RealtimeModel", mode: "realtime" },
|
||||
{ model_group: "NoModeModel" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
token="1234567890"
|
||||
|
|
@ -206,24 +172,39 @@ describe("ChatUI", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
// Wait for the component to render
|
||||
await waitFor(() => {
|
||||
expect(getByText("Test Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open the "Select Model" dropdown
|
||||
const selectModelLabel = getByText("Select Model");
|
||||
const modelSelectContainer = selectModelLabel.closest("div");
|
||||
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
|
||||
|
||||
fireEvent.mouseDown(modelSelect!);
|
||||
await selectComboboxOption("Select an endpoint", "/v1/realtime");
|
||||
await openComboboxByPlaceholder("Select a Model");
|
||||
|
||||
await waitFor(() => {
|
||||
// Get all options in the dropdown (Ant Design renders these in a portal)
|
||||
const options = document.querySelectorAll(".ant-select-item-option-content");
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
// Check if the first option is 'Enter custom model'
|
||||
expect(options[0]).toHaveTextContent("Enter custom model");
|
||||
expect(screen.getAllByText("RealtimeModel").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("NoModeModel").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText("ChatModel")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show 'Enter custom model' option in model selector", async () => {
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
token="1234567890"
|
||||
userRole="user"
|
||||
userID="1234567890"
|
||||
disabledPersonalKeyCreation={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await openComboboxByPlaceholder("Select a Model");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Enter custom model")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -242,44 +223,23 @@ describe("ChatUI", () => {
|
|||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const endpointTypeText = screen.getByText("Endpoint Type");
|
||||
const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector") as HTMLElement | null;
|
||||
expect(endpointSelect).not.toBeNull();
|
||||
const mcpInput = () => screen.getByLabelText("Select MCP servers");
|
||||
|
||||
const selectEndpointOption = async (label: string) => {
|
||||
act(() => {
|
||||
fireEvent.mouseDown(endpointSelect!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText(label));
|
||||
});
|
||||
};
|
||||
|
||||
const getMcpSelect = () =>
|
||||
screen.getByText("MCP Servers").closest("div")?.querySelector(".ant-select") as HTMLElement | null;
|
||||
|
||||
await selectEndpointOption("/v1/embeddings");
|
||||
|
||||
const mcpSelect = getMcpSelect();
|
||||
expect(mcpSelect).not.toBeNull();
|
||||
await selectComboboxOption("Select an endpoint", "/v1/embeddings");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mcpSelect).toHaveClass("ant-select-disabled");
|
||||
expect(mcpInput()).toBeDisabled();
|
||||
});
|
||||
|
||||
await selectEndpointOption("/v1/chat/completions");
|
||||
await selectComboboxOption("Select an endpoint", "/v1/chat/completions");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mcpSelect).not.toHaveClass("ant-select-disabled");
|
||||
expect(mcpInput()).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show Simulate failure to test fallbacks in Model Settings when chat endpoint is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
|
|
@ -294,35 +254,13 @@ describe("ChatUI", () => {
|
|||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Model Settings button only appears when a chat model is selected; select "Model 1" first
|
||||
const selectModelLabel = screen.getByText("Select Model");
|
||||
const modelSelectContainer = selectModelLabel.closest("div");
|
||||
const modelSelect = modelSelectContainer?.querySelector(".ant-select-selector");
|
||||
expect(modelSelect).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(modelSelect!);
|
||||
});
|
||||
await selectComboboxOption("Select a Model", "Model 1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId("model-settings-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Ant Design Select options may not have role="option"; click the dropdown option by text
|
||||
const model1Options = screen.getAllByText("Model 1");
|
||||
await act(async () => {
|
||||
fireEvent.click(model1Options[model1Options.length - 1]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const modelSettingsButton = screen.getByTestId("model-settings-button");
|
||||
expect(modelSettingsButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const modelSettingsButton = screen.getByTestId("model-settings-button");
|
||||
await act(async () => {
|
||||
fireEvent.click(modelSettingsButton);
|
||||
});
|
||||
await user.click(screen.getByTestId("model-settings-button"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Model Settings")).toBeInTheDocument();
|
||||
|
|
@ -334,9 +272,7 @@ describe("ChatUI", () => {
|
|||
});
|
||||
expect(fallbacksCheckbox).not.toBeChecked();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(fallbacksCheckbox);
|
||||
});
|
||||
await user.click(fallbacksCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("checkbox", { name: /Simulate failure to test fallbacks/i })).toBeChecked();
|
||||
|
|
@ -344,6 +280,7 @@ describe("ChatUI", () => {
|
|||
});
|
||||
|
||||
it("should send the chat request non-streaming after Stream responses is unchecked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
|
|
@ -358,35 +295,18 @@ describe("ChatUI", () => {
|
|||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const selectModelLabel = screen.getByText("Select Model");
|
||||
const modelSelect = selectModelLabel.closest("div")?.querySelector(".ant-select-selector");
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(modelSelect!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Model 1").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const model1Options = screen.getAllByText("Model 1");
|
||||
await act(async () => {
|
||||
fireEvent.click(model1Options[model1Options.length - 1]);
|
||||
});
|
||||
await selectComboboxOption("Select a Model", "Model 1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("model-settings-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("model-settings-button"));
|
||||
});
|
||||
await user.click(screen.getByTestId("model-settings-button"));
|
||||
|
||||
const streamingCheckbox = await screen.findByRole("checkbox", { name: /Stream responses/i });
|
||||
expect(streamingCheckbox).toBeChecked();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(streamingCheckbox);
|
||||
});
|
||||
await user.click(streamingCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("checkbox", { name: /Stream responses/i })).not.toBeChecked();
|
||||
|
|
@ -447,7 +367,8 @@ describe("ChatUI", () => {
|
|||
});
|
||||
|
||||
it("should offer the streaming toggle for a responses-only model without advanced params", async () => {
|
||||
(fetchModelsModule.fetchAvailableModels as any).mockResolvedValue([
|
||||
const user = userEvent.setup();
|
||||
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ model_group: "ResponsesModel", mode: "responses" },
|
||||
]);
|
||||
|
||||
|
|
@ -465,37 +386,14 @@ describe("ChatUI", () => {
|
|||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const endpointTypeText = screen.getByText("Endpoint Type");
|
||||
const endpointSelect = endpointTypeText.parentElement?.querySelector(".ant-select-selector");
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(endpointSelect!);
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("/v1/responses"));
|
||||
});
|
||||
|
||||
const selectModelLabel = screen.getByText("Select Model");
|
||||
const modelSelect = selectModelLabel.closest("div")?.querySelector(".ant-select-selector");
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(modelSelect!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("ResponsesModel").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const modelOptions = screen.getAllByText("ResponsesModel");
|
||||
await act(async () => {
|
||||
fireEvent.click(modelOptions[modelOptions.length - 1]);
|
||||
});
|
||||
await selectComboboxOption("Select an endpoint", "/v1/responses");
|
||||
await selectComboboxOption("Select a Model", "ResponsesModel");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("model-settings-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("model-settings-button"));
|
||||
});
|
||||
await user.click(screen.getByTestId("model-settings-button"));
|
||||
|
||||
expect(await screen.findByRole("checkbox", { name: /Stream responses/i })).toBeChecked();
|
||||
expect(screen.queryByText("Temperature")).not.toBeInTheDocument();
|
||||
|
|
@ -544,6 +442,7 @@ describe("ChatUI", () => {
|
|||
});
|
||||
|
||||
it("should enable search functionality for MCP server selector", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
|
|
@ -558,27 +457,161 @@ describe("ChatUI", () => {
|
|||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const mcpServersText = screen.queryByText("MCP Servers");
|
||||
expect(mcpServersText).toBeInTheDocument();
|
||||
expect(screen.getByText("MCP Servers")).toBeInTheDocument();
|
||||
|
||||
if (mcpServersText) {
|
||||
const selectContainer = mcpServersText.parentElement?.nextElementSibling;
|
||||
const selectElement = selectContainer?.querySelector(".ant-select-selector");
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
const mcpInput = screen.getByLabelText("Select MCP servers");
|
||||
expect(mcpInput).toBeInTheDocument();
|
||||
expect(mcpInput).not.toBeDisabled();
|
||||
|
||||
if (selectElement) {
|
||||
fireEvent.mouseDown(selectElement);
|
||||
await user.click(mcpInput);
|
||||
|
||||
await waitFor(() => {
|
||||
const allServersOption = screen.queryByText("All MCP Servers");
|
||||
if (allServersOption) {
|
||||
expect(allServersOption).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All MCP Servers")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const searchInput = document.querySelector(".ant-select-selection-search-input");
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
}
|
||||
}
|
||||
it("should keep the chosen endpoint when a model that endpoint can serve is picked", async () => {
|
||||
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ model_group: "ChatModel", mode: "chat" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
token="1234567890"
|
||||
userRole="user"
|
||||
userID="1234567890"
|
||||
disabledPersonalKeyCreation={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectComboboxOption("Select an endpoint", "/v1/responses");
|
||||
await selectComboboxOption("Select a Model", "ChatModel");
|
||||
|
||||
expect(screen.getByPlaceholderText("Select an endpoint")).toHaveValue("/v1/responses");
|
||||
});
|
||||
|
||||
it("should not offer a model the selected endpoint cannot serve", async () => {
|
||||
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ model_group: "ChatModel", mode: "chat" },
|
||||
{ model_group: "SpeechModel", mode: "audio_speech" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
token="1234567890"
|
||||
userRole="user"
|
||||
userID="1234567890"
|
||||
disabledPersonalKeyCreation={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectComboboxOption("Select an endpoint", "/v1/responses");
|
||||
await openComboboxByPlaceholder("Select a Model");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("ChatModel").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(screen.queryByText("SpeechModel")).toBeNull();
|
||||
});
|
||||
|
||||
it("should attach an audio file dropped on the transcription upload area", async () => {
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
token="1234567890"
|
||||
userRole="user"
|
||||
userID="1234567890"
|
||||
disabledPersonalKeyCreation={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectComboboxOption("Select an endpoint", "/v1/audio/transcriptions");
|
||||
|
||||
const dropZone = (await screen.findByText("Click or drag audio file to upload")).closest("label");
|
||||
const file = new File(["clip"], "clip.wav", { type: "audio/wav" });
|
||||
fireEvent.drop(dropZone as HTMLElement, { dataTransfer: { files: [file] } });
|
||||
|
||||
expect(await screen.findByText("clip.wav")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should name the virtual key source options instead of showing raw values", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
token="1234567890"
|
||||
userRole="user"
|
||||
userID="1234567890"
|
||||
disabledPersonalKeyCreation={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const keySourceTrigger = screen.getByLabelText("Virtual Key Source");
|
||||
expect(keySourceTrigger).toHaveTextContent("Current UI Session");
|
||||
expect(keySourceTrigger).not.toHaveTextContent("session");
|
||||
|
||||
await user.click(keySourceTrigger);
|
||||
await user.click(await screen.findByRole("option", { name: "Virtual Key" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Virtual Key Source")).toHaveTextContent("Virtual Key");
|
||||
});
|
||||
expect(screen.getByLabelText("Virtual Key Source")).not.toHaveTextContent("custom");
|
||||
});
|
||||
|
||||
it("should re-enable the model selector when the virtual key is cleared mid-load", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
|
||||
render(
|
||||
<ChatUI
|
||||
accessToken="1234567890"
|
||||
token="1234567890"
|
||||
userRole="user"
|
||||
userID="1234567890"
|
||||
disabledPersonalKeyCreation={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByLabelText("Virtual Key Source"));
|
||||
await user.click(await screen.findByRole("option", { name: "Virtual Key" }));
|
||||
|
||||
const keyField = await screen.findByPlaceholderText("Enter custom Virtual Key");
|
||||
await user.type(keyField, "sk-test");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Loading models...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.clear(keyField);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Select a Model")).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,15 +1,10 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Collapse, Spin } from "antd";
|
||||
import {
|
||||
CodeOutlined,
|
||||
DownloadOutlined,
|
||||
FileImageOutlined,
|
||||
FileTextOutlined,
|
||||
LoadingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Code, Download, FileImage, FileText, Loader2 } from "lucide-react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { getProxyBaseUrl, getGlobalLitellmHeaderName } from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
||||
interface ContainerFileCitation {
|
||||
type: "container_file_citation";
|
||||
|
|
@ -27,48 +22,60 @@ interface CodeInterpreterOutputProps {
|
|||
accessToken: string;
|
||||
}
|
||||
|
||||
const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
|
||||
code,
|
||||
containerId,
|
||||
annotations = [],
|
||||
accessToken,
|
||||
}) => {
|
||||
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif"] as const;
|
||||
|
||||
function isImageFilename(filename: string | undefined): boolean {
|
||||
if (!filename) {
|
||||
return false;
|
||||
}
|
||||
const lower = filename.toLowerCase();
|
||||
return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
|
||||
const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({ code, annotations = [], accessToken }) => {
|
||||
const [imageUrls, setImageUrls] = useState<Record<string, string>>({});
|
||||
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>({});
|
||||
const [codeOpen, setCodeOpen] = useState(false);
|
||||
const proxyBaseUrl = getProxyBaseUrl();
|
||||
|
||||
// Fetch images from container files API
|
||||
useEffect(() => {
|
||||
const createdUrls: string[] = [];
|
||||
let cancelled = false;
|
||||
|
||||
const fetchImages = async () => {
|
||||
for (const annotation of annotations) {
|
||||
const isImage =
|
||||
annotation.filename?.toLowerCase().endsWith(".png") ||
|
||||
annotation.filename?.toLowerCase().endsWith(".jpg") ||
|
||||
annotation.filename?.toLowerCase().endsWith(".jpeg") ||
|
||||
annotation.filename?.toLowerCase().endsWith(".gif");
|
||||
if (!isImageFilename(annotation.filename) || !annotation.container_id || !annotation.file_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isImage && annotation.container_id && annotation.file_id) {
|
||||
if (!cancelled) {
|
||||
setLoadingImages((prev) => ({ ...prev, [annotation.file_id]: true }));
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch image content from container files API
|
||||
const response = await fetch(
|
||||
`${proxyBaseUrl}/v1/containers/${annotation.container_id}/files/${annotation.file_id}/content`,
|
||||
{
|
||||
headers: {
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
},
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${proxyBaseUrl}/v1/containers/${annotation.container_id}/files/${annotation.file_id}/content`,
|
||||
{
|
||||
headers: {
|
||||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
createdUrls.push(url);
|
||||
if (!cancelled) {
|
||||
setImageUrls((prev) => ({ ...prev, [annotation.file_id]: url }));
|
||||
} else {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching image:", error);
|
||||
} finally {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching image:", error);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoadingImages((prev) => ({ ...prev, [annotation.file_id]: false }));
|
||||
}
|
||||
}
|
||||
|
|
@ -76,12 +83,12 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
|
|||
};
|
||||
|
||||
if (annotations.length > 0 && accessToken) {
|
||||
fetchImages();
|
||||
void fetchImages();
|
||||
}
|
||||
|
||||
// Cleanup URLs on unmount
|
||||
return () => {
|
||||
Object.values(imageUrls).forEach((url) => URL.revokeObjectURL(url));
|
||||
cancelled = true;
|
||||
createdUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [annotations, accessToken, proxyBaseUrl]);
|
||||
|
||||
|
|
@ -112,22 +119,8 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Separate images and other files
|
||||
const imageAnnotations = annotations.filter(
|
||||
(a) =>
|
||||
a.filename?.toLowerCase().endsWith(".png") ||
|
||||
a.filename?.toLowerCase().endsWith(".jpg") ||
|
||||
a.filename?.toLowerCase().endsWith(".jpeg") ||
|
||||
a.filename?.toLowerCase().endsWith(".gif"),
|
||||
);
|
||||
|
||||
const fileAnnotations = annotations.filter(
|
||||
(a) =>
|
||||
!a.filename?.toLowerCase().endsWith(".png") &&
|
||||
!a.filename?.toLowerCase().endsWith(".jpg") &&
|
||||
!a.filename?.toLowerCase().endsWith(".jpeg") &&
|
||||
!a.filename?.toLowerCase().endsWith(".gif"),
|
||||
);
|
||||
const imageAnnotations = annotations.filter((a) => isImageFilename(a.filename));
|
||||
const fileAnnotations = annotations.filter((a) => !isImageFilename(a.filename));
|
||||
|
||||
if (!code && annotations.length === 0) {
|
||||
return null;
|
||||
|
|
@ -135,44 +128,46 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
|
|||
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* Executed Code - Collapsible */}
|
||||
{code && (
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: "code",
|
||||
label: (
|
||||
<span className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<CodeOutlined /> Python Code Executed
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<SyntaxHighlighter
|
||||
language="python"
|
||||
style={coy}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
maxHeight: "300px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Collapsible open={codeOpen} onOpenChange={setCodeOpen} className="rounded-md border border-gray-200">
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-sm text-gray-600"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Code className="size-4" />
|
||||
Python Code Executed
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-gray-200 p-2">
|
||||
<SyntaxHighlighter
|
||||
language="python"
|
||||
style={coy}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: "6px",
|
||||
fontSize: "12px",
|
||||
maxHeight: "300px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Generated Images */}
|
||||
{imageAnnotations.map((annotation) => (
|
||||
<div key={annotation.file_id} className="rounded-lg border border-gray-200 overflow-hidden">
|
||||
<div key={annotation.file_id} className="overflow-hidden rounded-lg border border-gray-200">
|
||||
{loadingImages[annotation.file_id] ? (
|
||||
<div className="flex items-center justify-center p-8 bg-gray-50">
|
||||
<Spin indicator={<LoadingOutlined spin />} />
|
||||
<div className="flex items-center justify-center bg-gray-50 p-8">
|
||||
<Loader2 className="size-4 animate-spin text-gray-500" aria-hidden="true" />
|
||||
<span className="ml-2 text-sm text-gray-500">Loading image...</span>
|
||||
</div>
|
||||
) : imageUrls[annotation.file_id] ? (
|
||||
|
|
@ -180,42 +175,48 @@ const CodeInterpreterOutput: React.FC<CodeInterpreterOutputProps> = ({
|
|||
<img
|
||||
src={imageUrls[annotation.file_id]}
|
||||
alt={annotation.filename || "Generated chart"}
|
||||
className="max-w-full"
|
||||
style={{ maxHeight: "400px" }}
|
||||
className="max-h-[400px] max-w-full"
|
||||
/>
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-t border-gray-200">
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<FileImageOutlined /> {annotation.filename}
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<span className="flex items-center gap-1 text-xs text-gray-500">
|
||||
<FileImage className="size-3" aria-hidden="true" />
|
||||
{annotation.filename}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleDownload(annotation)}
|
||||
className="text-xs text-blue-500 hover:text-blue-700 flex items-center gap-1"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-auto gap-1 px-1 py-0 text-xs text-blue-500 hover:text-blue-700"
|
||||
onClick={() => void handleDownload(annotation)}
|
||||
>
|
||||
<DownloadOutlined /> Download
|
||||
</button>
|
||||
<Download className="size-3" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center p-4 bg-gray-50">
|
||||
<div className="flex items-center justify-center bg-gray-50 p-4">
|
||||
<span className="text-sm text-gray-400">Image not available</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Download Links for Other Files */}
|
||||
{fileAnnotations.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fileAnnotations.map((annotation) => (
|
||||
<button
|
||||
<Button
|
||||
key={annotation.file_id}
|
||||
onClick={() => handleDownload(annotation)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto gap-2 border-gray-200 bg-gray-50 px-3 py-2 hover:bg-gray-100"
|
||||
onClick={() => void handleDownload(annotation)}
|
||||
>
|
||||
<FileTextOutlined className="text-blue-500" />
|
||||
<FileText className="size-4 text-blue-500" aria-hidden="true" />
|
||||
<span className="text-sm">{annotation.filename}</span>
|
||||
<DownloadOutlined className="text-gray-400" />
|
||||
</button>
|
||||
<Download className="size-3 text-gray-400" aria-hidden="true" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React from "react";
|
||||
import { Switch, Tooltip } from "antd";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { CodeOutlined, InfoCircleOutlined, ExclamationCircleOutlined } from "@ant-design/icons";
|
||||
import { Text } from "@tremor/react";
|
||||
import { Code, Info, TriangleAlert } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface CodeInterpreterToolProps {
|
||||
accessToken: string;
|
||||
|
|
@ -49,25 +49,30 @@ const CodeInterpreterTool: React.FC<CodeInterpreterToolProps> = ({
|
|||
<div className="border border-gray-200 rounded-lg p-3 bg-linear-to-r from-blue-50 to-purple-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CodeOutlined className="text-blue-500" />
|
||||
<Text className="font-medium text-gray-700">Code Interpreter</Text>
|
||||
<Tooltip title="Run Python code to generate files, charts, and analyze data. Container is created automatically.">
|
||||
<InfoCircleOutlined className="text-gray-400 text-xs" />
|
||||
<Code className="size-4 text-blue-500" />
|
||||
<span className="font-medium text-gray-700">Code Interpreter</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger aria-label="About Code Interpreter">
|
||||
<Info className="size-3 text-gray-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Run Python code to generate files, charts, and analyze data. Container is created automatically.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled && isOpenAI}
|
||||
onChange={handleToggle}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isDisabled}
|
||||
size="small"
|
||||
className={enabled && isOpenAI ? "bg-blue-500" : ""}
|
||||
size="sm"
|
||||
aria-label="Enable Code Interpreter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isOpenAI && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<ExclamationCircleOutlined className="text-amber-500 mt-0.5" />
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-amber-500" />
|
||||
<div className="text-xs text-gray-600">
|
||||
<span>Code Interpreter is currently only supported for OpenAI models. </span>
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import { ENDPOINT_OPTIONS } from "./chatConstants";
|
|||
describe("EndpointSelector", () => {
|
||||
Object.values(ENDPOINT_OPTIONS).forEach((endpointType) => {
|
||||
it(`should render the endpoint selector for ${endpointType.value}`, async () => {
|
||||
const { getByText } = render(<EndpointSelector endpointType={endpointType.value} onEndpointChange={() => {}} />);
|
||||
render(<EndpointSelector endpointType={endpointType.value} onEndpointChange={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(getByText(endpointType.label)).toBeInTheDocument();
|
||||
expect(screen.getByRole("combobox")).toHaveValue(endpointType.label);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -18,10 +18,9 @@ describe("EndpointSelector", () => {
|
|||
const user = userEvent.setup();
|
||||
render(<EndpointSelector endpointType={ENDPOINT_OPTIONS[0].value} onEndpointChange={() => {}} />);
|
||||
|
||||
const combobox = screen.getByRole("combobox");
|
||||
await user.click(combobox);
|
||||
|
||||
const input = await screen.findByRole("combobox");
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.click(input);
|
||||
await user.clear(input);
|
||||
await user.type(input, "audio");
|
||||
|
||||
expect(await screen.findByText("/v1/audio/speech")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Select } from "antd";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import React from "react";
|
||||
import { ENDPOINT_OPTIONS } from "./chatConstants";
|
||||
|
||||
|
|
@ -11,17 +11,11 @@ interface EndpointSelectorProps {
|
|||
const EndpointSelector: React.FC<EndpointSelectorProps> = ({ endpointType, onEndpointChange, className }) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Select
|
||||
showSearch
|
||||
<SearchSelect
|
||||
value={endpointType}
|
||||
style={{ width: "100%" }}
|
||||
onChange={onEndpointChange}
|
||||
onValueChange={onEndpointChange}
|
||||
options={ENDPOINT_OPTIONS}
|
||||
className="rounded-md"
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase()) ||
|
||||
(option?.value ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
placeholder="Select an endpoint"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,37 +1,16 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { determineEndpointType } from "./EndpointUtils";
|
||||
import { determineEndpointType, filterModelsForEndpoint, isModelCompatibleWithEndpoint } from "./EndpointUtils";
|
||||
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
|
||||
// Mock the getEndpointType function
|
||||
vi.mock("@/components/chat_ui/mode_endpoint_mapping", () => ({
|
||||
EndpointType: {
|
||||
IMAGE: "image",
|
||||
VIDEO: "video",
|
||||
CHAT: "chat",
|
||||
RESPONSES: "responses",
|
||||
IMAGE_EDITS: "image_edits",
|
||||
ANTHROPIC_MESSAGES: "anthropic_messages",
|
||||
EMBEDDINGS: "embeddings",
|
||||
SPEECH: "speech",
|
||||
TRANSCRIPTION: "transcription",
|
||||
A2A_AGENTS: "a2a_agents",
|
||||
},
|
||||
getEndpointType: vi.fn(),
|
||||
ModelMode: {
|
||||
AUDIO_SPEECH: "audio_speech",
|
||||
AUDIO_TRANSCRIPTION: "audio_transcription",
|
||||
IMAGE_GENERATION: "image_generation",
|
||||
VIDEO_GENERATION: "video_generation",
|
||||
CHAT: "chat",
|
||||
RESPONSES: "responses",
|
||||
IMAGE_EDITS: "image_edits",
|
||||
ANTHROPIC_MESSAGES: "anthropic_messages",
|
||||
EMBEDDING: "embedding",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/components/chat_ui/mode_endpoint_mapping", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/components/chat_ui/mode_endpoint_mapping")>();
|
||||
return {
|
||||
...actual,
|
||||
getEndpointType: vi.fn(actual.getEndpointType),
|
||||
};
|
||||
});
|
||||
|
||||
// Import the mocked function
|
||||
import { getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
|
||||
describe("determineEndpointType", () => {
|
||||
|
|
@ -210,10 +189,71 @@ describe("determineEndpointType", () => {
|
|||
|
||||
vi.mocked(getEndpointType).mockReturnValue(EndpointType.CHAT);
|
||||
|
||||
// Test with different case - should not match
|
||||
const result = determineEndpointType("gpt-3.5-turbo", mockModelInfo);
|
||||
|
||||
expect(getEndpointType).not.toHaveBeenCalled();
|
||||
expect(result).toBe(EndpointType.CHAT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isModelCompatibleWithEndpoint / filterModelsForEndpoint", () => {
|
||||
beforeEach(async () => {
|
||||
const actual = await vi.importActual<typeof import("@/components/chat_ui/mode_endpoint_mapping")>(
|
||||
"@/components/chat_ui/mode_endpoint_mapping",
|
||||
);
|
||||
vi.mocked(getEndpointType).mockImplementation(actual.getEndpointType);
|
||||
});
|
||||
|
||||
it("keeps models with no mode for every endpoint", () => {
|
||||
const model: ModelGroup = { model_group: "custom-proxy-model" };
|
||||
expect(isModelCompatibleWithEndpoint(model, EndpointType.CHAT)).toBe(true);
|
||||
expect(isModelCompatibleWithEndpoint(model, EndpointType.REALTIME)).toBe(true);
|
||||
expect(isModelCompatibleWithEndpoint(model, EndpointType.SPEECH)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps chat models for responses, anthropic messages, and interactions", () => {
|
||||
const chatModel: ModelGroup = { model_group: "gpt-4o", mode: "chat" };
|
||||
expect(isModelCompatibleWithEndpoint(chatModel, EndpointType.RESPONSES)).toBe(true);
|
||||
expect(isModelCompatibleWithEndpoint(chatModel, EndpointType.ANTHROPIC_MESSAGES)).toBe(true);
|
||||
expect(isModelCompatibleWithEndpoint(chatModel, EndpointType.INTERACTIONS)).toBe(true);
|
||||
expect(isModelCompatibleWithEndpoint(chatModel, EndpointType.SPEECH)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps image models for image_edits", () => {
|
||||
const imageModel: ModelGroup = { model_group: "dall-e-3", mode: "image_generation" };
|
||||
expect(isModelCompatibleWithEndpoint(imageModel, EndpointType.IMAGE_EDITS)).toBe(true);
|
||||
expect(isModelCompatibleWithEndpoint(imageModel, EndpointType.IMAGE)).toBe(true);
|
||||
expect(isModelCompatibleWithEndpoint(imageModel, EndpointType.CHAT)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps only realtime models for the realtime endpoint", () => {
|
||||
const models: ModelGroup[] = [
|
||||
{ model_group: "gpt-4o", mode: "chat" },
|
||||
{ model_group: "gpt-realtime", mode: "realtime" },
|
||||
{ model_group: "no-mode" },
|
||||
];
|
||||
|
||||
expect(filterModelsForEndpoint(models, EndpointType.REALTIME).map((m) => m.model_group)).toEqual([
|
||||
"gpt-realtime",
|
||||
"no-mode",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes unknown modes from conversational endpoints", () => {
|
||||
const batchModel: ModelGroup = { model_group: "batch-job", mode: "batch" };
|
||||
const rerankModel: ModelGroup = { model_group: "reranker", mode: "rerank" };
|
||||
expect(isModelCompatibleWithEndpoint(batchModel, EndpointType.CHAT)).toBe(false);
|
||||
expect(isModelCompatibleWithEndpoint(rerankModel, EndpointType.RESPONSES)).toBe(false);
|
||||
expect(isModelCompatibleWithEndpoint(batchModel, EndpointType.REALTIME)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps image-edit models for the image-edits endpoint using the mode the backend sends", () => {
|
||||
const imageEditModel: ModelGroup = { model_group: "gpt-image-1", mode: "image_edit" };
|
||||
const imageModel: ModelGroup = { model_group: "dall-e-3", mode: "image_generation" };
|
||||
|
||||
expect(isModelCompatibleWithEndpoint(imageEditModel, EndpointType.IMAGE_EDITS)).toBe(true);
|
||||
expect(
|
||||
filterModelsForEndpoint([imageEditModel, imageModel], EndpointType.IMAGE_EDITS).map((m) => m.model_group),
|
||||
).toEqual(["gpt-image-1", "dall-e-3"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,22 +1,43 @@
|
|||
import { ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { EndpointType, getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import { EndpointType, getEndpointType, ModelMode } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
|
||||
const KNOWN_MODEL_MODES = new Set<string>(Object.values(ModelMode));
|
||||
|
||||
/**
|
||||
* Determines the appropriate endpoint type based on the selected model
|
||||
*
|
||||
* @param selectedModel - The model identifier string
|
||||
* @param modelInfo - Array of model information
|
||||
* @returns The appropriate endpoint type
|
||||
*/
|
||||
export const determineEndpointType = (selectedModel: string, modelInfo: ModelGroup[]): EndpointType => {
|
||||
// Find the model information for the selected model
|
||||
const selectedModelInfo = modelInfo.find((option) => option.model_group === selectedModel);
|
||||
|
||||
// If model info is found and it has a mode, determine the endpoint type
|
||||
if (selectedModelInfo?.mode) {
|
||||
return getEndpointType(selectedModelInfo.mode);
|
||||
}
|
||||
|
||||
// Default to chat endpoint if no match is found
|
||||
return EndpointType.CHAT;
|
||||
};
|
||||
|
||||
export const isModelCompatibleWithEndpoint = (model: ModelGroup, endpointType: EndpointType): boolean => {
|
||||
if (!model.mode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!KNOWN_MODEL_MODES.has(model.mode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const optionEndpoint = getEndpointType(model.mode);
|
||||
|
||||
if (
|
||||
endpointType === EndpointType.RESPONSES ||
|
||||
endpointType === EndpointType.ANTHROPIC_MESSAGES ||
|
||||
endpointType === EndpointType.INTERACTIONS
|
||||
) {
|
||||
return optionEndpoint === endpointType || optionEndpoint === EndpointType.CHAT;
|
||||
}
|
||||
|
||||
if (endpointType === EndpointType.IMAGE_EDITS) {
|
||||
return optionEndpoint === endpointType || optionEndpoint === EndpointType.IMAGE;
|
||||
}
|
||||
|
||||
return optionEndpoint === endpointType;
|
||||
};
|
||||
|
||||
export const filterModelsForEndpoint = (models: ModelGroup[], endpointType: EndpointType): ModelGroup[] =>
|
||||
models.filter((model) => isModelCompatibleWithEndpoint(model, endpointType));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { DeleteOutlined, FilePdfOutlined } from "@ant-design/icons";
|
||||
import { FileText, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface FilePreviewCardProps {
|
||||
file: File;
|
||||
|
|
@ -15,7 +16,7 @@ function FilePreviewCard({ file, previewUrl, onRemove }: FilePreviewCardProps) {
|
|||
<div className="relative inline-block">
|
||||
{isPdf ? (
|
||||
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
|
||||
<FilePdfOutlined style={{ fontSize: "16px", color: "white" }} />
|
||||
<FileText className="size-4 text-white" aria-hidden="true" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
|
|
@ -29,12 +30,16 @@ function FilePreviewCard({ file, previewUrl, onRemove }: FilePreviewCardProps) {
|
|||
<div className="text-sm font-medium text-gray-900 truncate">{file.name}</div>
|
||||
<div className="text-xs text-gray-500">{isPdf ? "PDF" : "Image"}</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center justify-center w-6 h-6 text-gray-400 hover:text-gray-600 hover:bg-gray-200 rounded-full transition-colors"
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
className="text-gray-400 hover:text-gray-600 hover:bg-gray-200"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<DeleteOutlined style={{ fontSize: "12px" }} />
|
||||
</button>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
import { MessageType } from "@/components/chat_ui/types";
|
||||
import { shouldShowAttachedImage } from "./ResponsesImageUtils";
|
||||
import { FilePdfOutlined } from "@ant-design/icons";
|
||||
|
||||
interface ResponsesImageRendererProps {
|
||||
message: MessageType;
|
||||
|
|
@ -17,15 +17,14 @@ const ResponsesImageRenderer: React.FC<ResponsesImageRendererProps> = ({ message
|
|||
return (
|
||||
<div className="mb-2">
|
||||
{isPdf ? (
|
||||
<div className="w-64 h-32 rounded-md border border-gray-200 bg-red-50 flex items-center justify-center">
|
||||
<FilePdfOutlined style={{ fontSize: "48px", color: "#dc2626" }} />
|
||||
<div className="flex h-32 w-64 items-center justify-center rounded-md border border-gray-200 bg-red-50">
|
||||
<FileText className="size-12 text-red-600" aria-label="PDF attachment" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={message.imagePreviewUrl}
|
||||
alt="User uploaded image"
|
||||
className="max-w-64 rounded-md border border-gray-200 shadow-xs"
|
||||
style={{ maxHeight: "200px" }}
|
||||
className="max-h-[200px] max-w-64 rounded-md border border-gray-200 shadow-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,43 +1,74 @@
|
|||
import React from "react";
|
||||
import { Upload, Tooltip } from "antd";
|
||||
import { PaperClipOutlined } from "@ant-design/icons";
|
||||
|
||||
const { Dragger } = Upload;
|
||||
import React, { useId, useRef } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { CHAT_ATTACHMENT_ACCEPT, validateChatAttachment } from "./uploadValidation";
|
||||
|
||||
interface ResponsesImageUploadProps {
|
||||
responsesUploadedImage: File | null;
|
||||
responsesImagePreviewUrl: string | null;
|
||||
onImageUpload: (file: File) => false;
|
||||
onImageUpload: (file: File) => void;
|
||||
onRemoveImage: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ResponsesImageUpload: React.FC<ResponsesImageUploadProps> = ({
|
||||
responsesUploadedImage,
|
||||
responsesImagePreviewUrl,
|
||||
onImageUpload,
|
||||
onRemoveImage,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputId = useId();
|
||||
|
||||
if (responsesUploadedImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const result = validateChatAttachment(file);
|
||||
if (!result.ok) {
|
||||
NotificationsManager.error(result.error);
|
||||
return;
|
||||
}
|
||||
onImageUpload(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Subtle upload button - only show when no image */}
|
||||
{!responsesUploadedImage && (
|
||||
<Dragger
|
||||
beforeUpload={onImageUpload}
|
||||
accept="image/*,.pdf"
|
||||
showUploadList={false}
|
||||
className="inline-block"
|
||||
style={{ padding: 0, border: "none", background: "none" }}
|
||||
>
|
||||
<Tooltip title="Attach image or PDF">
|
||||
<button
|
||||
<input
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={CHAT_ATTACHMENT_ACCEPT}
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
className="flex items-center justify-center w-8 h-8 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors"
|
||||
>
|
||||
<PaperClipOutlined style={{ fontSize: "16px" }} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</Dragger>
|
||||
)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={disabled}
|
||||
aria-label="Attach image or PDF"
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Attach image or PDF</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import React, { useState } from "react";
|
||||
import { Button } from "antd";
|
||||
import { VectorStoreSearchResponse } from "@/components/chat_ui/types";
|
||||
import { DatabaseOutlined, FileTextOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import { ChevronDown, ChevronRight, Database, FileText } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
||||
interface SearchResultsDisplayProps {
|
||||
searchResults: VectorStoreSearchResponse[];
|
||||
|
|
@ -27,95 +28,94 @@ export function SearchResultsDisplay({ searchResults }: SearchResultsDisplayProp
|
|||
|
||||
return (
|
||||
<div className="search-results-content mt-1 mb-2">
|
||||
<Button
|
||||
type="text"
|
||||
className="flex items-center text-xs text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
icon={<DatabaseOutlined />}
|
||||
>
|
||||
{isExpanded ? "Hide sources" : `Show sources (${totalResults})`}
|
||||
{isExpanded ? <DownOutlined className="ml-1" /> : <RightOutlined className="ml-1" />}
|
||||
</Button>
|
||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button type="button" variant="ghost" size="sm" className="text-xs text-gray-500 hover:text-gray-700" />
|
||||
}
|
||||
>
|
||||
<Database className="size-4" />
|
||||
{isExpanded ? "Hide sources" : `Show sources (${totalResults})`}
|
||||
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-md text-sm">
|
||||
<div className="space-y-3">
|
||||
{searchResults.map((resultPage, pageIndex) => (
|
||||
<div key={pageIndex}>
|
||||
<div className="text-xs text-gray-600 mb-2 flex items-center gap-2">
|
||||
<span className="font-medium">Query:</span>
|
||||
<span className="italic">"{resultPage.search_query}"</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="text-gray-500">
|
||||
{resultPage.data.length} result{resultPage.data.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-md text-sm">
|
||||
<div className="space-y-3">
|
||||
{searchResults.map((resultPage, pageIndex) => (
|
||||
<div key={pageIndex}>
|
||||
<div className="text-xs text-gray-600 mb-2 flex items-center gap-2">
|
||||
<span className="font-medium">Query:</span>
|
||||
<span className="italic">"{resultPage.search_query}"</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="text-gray-500">
|
||||
{resultPage.data.length} result{resultPage.data.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{resultPage.data.map((result, resultIndex) => {
|
||||
const isResultExpanded = expandedResults[`${pageIndex}-${resultIndex}`] || false;
|
||||
<div className="space-y-2">
|
||||
{resultPage.data.map((result, resultIndex) => {
|
||||
const isResultExpanded = expandedResults[`${pageIndex}-${resultIndex}`] || false;
|
||||
|
||||
return (
|
||||
<div key={resultIndex} className="border border-gray-200 rounded-md overflow-hidden bg-white">
|
||||
<div
|
||||
className="flex items-center justify-between p-2 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
onClick={() => toggleResult(pageIndex, resultIndex)}
|
||||
return (
|
||||
<Collapsible
|
||||
key={resultIndex}
|
||||
open={isResultExpanded}
|
||||
onOpenChange={() => toggleResult(pageIndex, resultIndex)}
|
||||
className="overflow-hidden rounded-md border border-gray-200 bg-white"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform shrink-0 ${isResultExpanded ? "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>
|
||||
<FileTextOutlined className="text-gray-400 shrink-0" style={{ fontSize: "12px" }} />
|
||||
<span className="text-xs font-medium text-gray-700 truncate">
|
||||
{result.filename || result.file_id || `Result ${resultIndex + 1}`}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-sm bg-blue-100 text-blue-700 font-mono shrink-0">
|
||||
{result.score.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isResultExpanded && (
|
||||
<div className="border-t border-gray-200 bg-white">
|
||||
<div className="p-3 space-y-2">
|
||||
{result.content.map((content, contentIndex) => (
|
||||
<div key={contentIndex}>
|
||||
<div className="text-xs font-mono bg-gray-50 p-2 rounded-sm text-gray-800 whitespace-pre-wrap wrap-break-word">
|
||||
{content.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{result.attributes && Object.keys(result.attributes).length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-500 mb-1 font-medium">Metadata:</div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(result.attributes).map(([key, value]) => (
|
||||
<div key={key} className="text-xs flex gap-2">
|
||||
<span className="text-gray-500 font-medium">{key}:</span>
|
||||
<span className="text-gray-700 font-mono break-all">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between p-2 text-left transition-colors hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<ChevronRight
|
||||
className={`size-4 shrink-0 text-gray-400 transition-transform ${isResultExpanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
<FileText className="size-3 shrink-0 text-gray-400" />
|
||||
<span className="text-xs font-medium text-gray-700 truncate">
|
||||
{result.filename || result.file_id || `Result ${resultIndex + 1}`}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-sm bg-blue-100 text-blue-700 font-mono shrink-0">
|
||||
{result.score.toFixed(3)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-gray-200 bg-white">
|
||||
<div className="p-3 space-y-2">
|
||||
{result.content.map((content, contentIndex) => (
|
||||
<div key={contentIndex}>
|
||||
<div className="text-xs font-mono bg-gray-50 p-2 rounded-sm text-gray-800 whitespace-pre-wrap wrap-break-word">
|
||||
{content.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{result.attributes && Object.keys(result.attributes).length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-500 mb-1 font-medium">Metadata:</div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(result.attributes).map(([key, value]) => (
|
||||
<div key={key} className="text-xs flex gap-2">
|
||||
<span className="text-gray-500 font-medium">{key}:</span>
|
||||
<span className="text-gray-700 font-mono break-all">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import React from "react";
|
||||
import { Switch, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined, CopyOutlined } from "@ant-design/icons";
|
||||
import { Copy, Info } from "lucide-react";
|
||||
import { EndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface SessionManagementProps {
|
||||
endpointType: string;
|
||||
|
|
@ -21,10 +23,14 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
const handleCopySessionId = () => {
|
||||
const handleCopySessionId = async () => {
|
||||
if (responsesSessionId) {
|
||||
navigator.clipboard.writeText(responsesSessionId);
|
||||
NotificationsManager.success("Response ID copied to clipboard!");
|
||||
try {
|
||||
await navigator.clipboard.writeText(responsesSessionId);
|
||||
NotificationsManager.success("Response ID copied to clipboard!");
|
||||
} catch {
|
||||
NotificationsManager.error("Unable to copy response ID");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -56,17 +62,26 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
|
|||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">Session Management</span>
|
||||
<Tooltip title="Choose between LiteLLM API session management (using previous_response_id) or UI-based session management (using chat history)">
|
||||
<InfoCircleOutlined className="text-gray-400" style={{ fontSize: "12px" }} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger aria-label="About session management">
|
||||
<Info className="size-3 text-gray-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Choose between LiteLLM API session management (using previous_response_id) or UI-based session management
|
||||
(using chat history)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
checked={useApiSessionManagement}
|
||||
onChange={onToggleSessionManagement}
|
||||
checkedChildren="API"
|
||||
unCheckedChildren="UI"
|
||||
size="small"
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<span aria-hidden="true">UI</span>
|
||||
<Switch
|
||||
checked={useApiSessionManagement}
|
||||
onCheckedChange={onToggleSessionManagement}
|
||||
aria-label="Use API session management"
|
||||
size="sm"
|
||||
/>
|
||||
<span aria-hidden="true">API</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Status Indicator */}
|
||||
|
|
@ -79,12 +94,26 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
|
|||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<InfoCircleOutlined style={{ fontSize: "12px" }} />
|
||||
<Info className="size-3" />
|
||||
{getSessionDisplay()}
|
||||
</div>
|
||||
{responsesSessionId && (
|
||||
<Tooltip
|
||||
title={
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={handleCopySessionId}
|
||||
aria-label="Copy response ID"
|
||||
className="ml-2 hover:bg-green-100"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-lg">
|
||||
<div className="text-xs">
|
||||
<div className="mb-1">Copy response ID to continue session:</div>
|
||||
<div className="bg-gray-800 text-gray-100 p-2 rounded-sm font-mono text-xs whitespace-pre-wrap">
|
||||
|
|
@ -99,15 +128,7 @@ const SessionManagement: React.FC<SessionManagementProps> = ({
|
|||
}'`}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
overlayStyle={{ maxWidth: "500px" }}
|
||||
>
|
||||
<button
|
||||
onClick={handleCopySessionId}
|
||||
className="ml-2 p-1 hover:bg-green-100 rounded-sm transition-colors"
|
||||
>
|
||||
<CopyOutlined style={{ fontSize: "12px" }} />
|
||||
</button>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_AUDIO_BYTES,
|
||||
MAX_CHAT_ATTACHMENT_BYTES,
|
||||
MAX_IMAGE_EDIT_COUNT,
|
||||
validateAudioFile,
|
||||
validateChatAttachment,
|
||||
validateImageEditFile,
|
||||
} from "./uploadValidation";
|
||||
|
||||
function makeFile(name: string, type: string, sizeBytes = 1024): File {
|
||||
const content = new Uint8Array(sizeBytes);
|
||||
return new File([content], name, { type });
|
||||
}
|
||||
|
||||
describe("validateChatAttachment", () => {
|
||||
it("accepts supported image types", () => {
|
||||
expect(validateChatAttachment(makeFile("a.png", "image/png"))).toEqual({ ok: true });
|
||||
expect(validateChatAttachment(makeFile("a.jpg", "image/jpeg"))).toEqual({ ok: true });
|
||||
expect(validateChatAttachment(makeFile("a.webp", "image/webp"))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("accepts PDF by mime type or extension", () => {
|
||||
expect(validateChatAttachment(makeFile("doc.pdf", "application/pdf"))).toEqual({ ok: true });
|
||||
expect(validateChatAttachment(makeFile("doc.PDF", ""))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects unsupported types", () => {
|
||||
const result = validateChatAttachment(makeFile("notes.txt", "text/plain"));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("not a supported attachment");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects files over the size limit", () => {
|
||||
const result = validateChatAttachment(makeFile("huge.png", "image/png", MAX_CHAT_ATTACHMENT_BYTES + 1));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("too large");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateImageEditFile", () => {
|
||||
it("accepts images under the count limit", () => {
|
||||
expect(validateImageEditFile(makeFile("a.png", "image/png"), 0)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects when the count limit is reached", () => {
|
||||
const result = validateImageEditFile(makeFile("a.png", "image/png"), MAX_IMAGE_EDIT_COUNT);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain(`at most ${MAX_IMAGE_EDIT_COUNT}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects PDFs for image edits", () => {
|
||||
const result = validateImageEditFile(makeFile("doc.pdf", "application/pdf"), 0);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("not a supported image");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAudioFile", () => {
|
||||
it("accepts common audio types", () => {
|
||||
expect(validateAudioFile(makeFile("a.mp3", "audio/mpeg"))).toEqual({ ok: true });
|
||||
expect(validateAudioFile(makeFile("a.wav", "audio/wav"))).toEqual({ ok: true });
|
||||
expect(validateAudioFile(makeFile("a.webm", ""))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects non-audio files and oversized files", () => {
|
||||
expect(validateAudioFile(makeFile("a.png", "image/png")).ok).toBe(false);
|
||||
expect(validateAudioFile(makeFile("a.mp3", "audio/mpeg", MAX_AUDIO_BYTES + 1)).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
export type UploadValidationResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export const CHAT_ATTACHMENT_ACCEPT = "image/png,image/jpeg,image/jpg,image/gif,image/webp,application/pdf,.pdf";
|
||||
export const IMAGE_EDIT_ACCEPT = "image/png,image/jpeg,image/jpg,image/gif,image/webp";
|
||||
export const AUDIO_ACCEPT = "audio/*,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm";
|
||||
|
||||
export const MAX_CHAT_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
export const MAX_IMAGE_EDIT_BYTES = 20 * 1024 * 1024;
|
||||
export const MAX_AUDIO_BYTES = 25 * 1024 * 1024;
|
||||
export const MAX_IMAGE_EDIT_COUNT = 10;
|
||||
export const MAX_CHAT_ATTACHMENT_COUNT = 1;
|
||||
|
||||
const IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp"]);
|
||||
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
||||
const PDF_MIME_TYPES = new Set(["application/pdf"]);
|
||||
const PDF_EXTENSIONS = new Set([".pdf"]);
|
||||
const AUDIO_MIME_PREFIX = "audio/";
|
||||
const AUDIO_EXTENSIONS = new Set([".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm"]);
|
||||
|
||||
function getExtension(fileName: string): string {
|
||||
const dot = fileName.lastIndexOf(".");
|
||||
if (dot < 0) {
|
||||
return "";
|
||||
}
|
||||
return fileName.slice(dot).toLowerCase();
|
||||
}
|
||||
|
||||
function formatMb(bytes: number): string {
|
||||
return `${Math.round(bytes / (1024 * 1024))} MB`;
|
||||
}
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
if (IMAGE_MIME_TYPES.has(file.type)) {
|
||||
return true;
|
||||
}
|
||||
return IMAGE_EXTENSIONS.has(getExtension(file.name));
|
||||
}
|
||||
|
||||
function isPdfFile(file: File): boolean {
|
||||
if (PDF_MIME_TYPES.has(file.type)) {
|
||||
return true;
|
||||
}
|
||||
return PDF_EXTENSIONS.has(getExtension(file.name));
|
||||
}
|
||||
|
||||
function isAudioFile(file: File): boolean {
|
||||
if (file.type.startsWith(AUDIO_MIME_PREFIX)) {
|
||||
return true;
|
||||
}
|
||||
return AUDIO_EXTENSIONS.has(getExtension(file.name));
|
||||
}
|
||||
|
||||
function validateSize(file: File, maxBytes: number): UploadValidationResult {
|
||||
if (file.size <= maxBytes) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: `"${file.name}" is too large. Maximum size is ${formatMb(maxBytes)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateChatAttachment(file: File): UploadValidationResult {
|
||||
if (!isImageFile(file) && !isPdfFile(file)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `"${file.name}" is not a supported attachment. Use PNG, JPEG, GIF, WebP, or PDF.`,
|
||||
};
|
||||
}
|
||||
return validateSize(file, MAX_CHAT_ATTACHMENT_BYTES);
|
||||
}
|
||||
|
||||
export function validateImageEditFile(file: File, currentCount: number): UploadValidationResult {
|
||||
if (currentCount >= MAX_IMAGE_EDIT_COUNT) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `You can upload at most ${MAX_IMAGE_EDIT_COUNT} images.`,
|
||||
};
|
||||
}
|
||||
if (!isImageFile(file)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `"${file.name}" is not a supported image. Use PNG, JPEG, GIF, or WebP.`,
|
||||
};
|
||||
}
|
||||
return validateSize(file, MAX_IMAGE_EDIT_BYTES);
|
||||
}
|
||||
|
||||
export function validateAudioFile(file: File): UploadValidationResult {
|
||||
if (!isAudioFile(file)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `"${file.name}" is not a supported audio file. Use MP3, MP4, MPEG, MPGA, M4A, WAV, or WEBM.`,
|
||||
};
|
||||
}
|
||||
return validateSize(file, MAX_AUDIO_BYTES);
|
||||
}
|
||||
|
|
@ -5,10 +5,10 @@ import AgentBuilderView from "@/app/(dashboard)/playground/components/chat_ui/Ag
|
|||
import ChatUI from "@/app/(dashboard)/playground/components/chat_ui/ChatUI";
|
||||
import CompareUI from "@/app/(dashboard)/playground/components/compareUI/CompareUI";
|
||||
import ComplianceUI from "@/app/(dashboard)/playground/components/complianceUI/ComplianceUI";
|
||||
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
|
||||
import { DeprecationBanner } from "@/components/DeprecationBanner";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { fetchProxySettings } from "@/utils/proxyUtils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface ProxySettings {
|
||||
PROXY_BASE_URL?: string;
|
||||
|
|
@ -47,45 +47,54 @@ export default function PlaygroundPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<TabGroup className="w-full" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<TabList className="mb-0">
|
||||
<Tab>Chat</Tab>
|
||||
<Tab>Compare</Tab>
|
||||
<Tab>Compliance</Tab>
|
||||
<Tab>Agent Builder (Experimental)</Tab>
|
||||
</TabList>
|
||||
<TabPanels className="h-full">
|
||||
<TabPanel className="h-full">
|
||||
<ChatUI
|
||||
accessToken={accessToken}
|
||||
token={token}
|
||||
userRole={userRole}
|
||||
userID={userId}
|
||||
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel className="h-full">
|
||||
<CompareUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
|
||||
</TabPanel>
|
||||
<TabPanel className="h-full">
|
||||
<ComplianceUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
|
||||
</TabPanel>
|
||||
<TabPanel className="h-full">
|
||||
<DeprecationBanner featureName="The Playground's Agent Builder" />
|
||||
<AgentBuilderView
|
||||
accessToken={accessToken}
|
||||
token={token}
|
||||
userID={userId}
|
||||
userRole={userRole}
|
||||
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
|
||||
proxySettings={proxySettings}
|
||||
customProxyBaseUrl={proxySettings?.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings?.PROXY_BASE_URL}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden">
|
||||
<Tabs defaultValue="chat" className="flex min-h-0 min-w-0 flex-1 flex-col gap-0 overflow-hidden">
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="mb-0 h-auto w-full shrink-0 justify-start overflow-x-auto rounded-none border-b border-border bg-transparent p-0"
|
||||
>
|
||||
<TabsTrigger value="chat" className="flex-none rounded-none px-4 py-2">
|
||||
Chat
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="compare" className="flex-none rounded-none px-4 py-2">
|
||||
Compare
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="compliance" className="flex-none rounded-none px-4 py-2">
|
||||
Compliance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agent-builder" className="flex-none rounded-none px-4 py-2">
|
||||
Agent Builder (Experimental)
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="chat" className="mt-0 h-full min-h-0 min-w-0 overflow-hidden data-hidden:hidden">
|
||||
<ChatUI
|
||||
accessToken={accessToken}
|
||||
token={token}
|
||||
userRole={userRole}
|
||||
userID={userId}
|
||||
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
|
||||
proxySettings={proxySettings}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="compare" className="mt-0 h-full data-hidden:hidden">
|
||||
<CompareUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
|
||||
</TabsContent>
|
||||
<TabsContent value="compliance" className="mt-0 h-full data-hidden:hidden">
|
||||
<ComplianceUI accessToken={accessToken} disabledPersonalKeyCreation={disabledPersonalKeyCreation} />
|
||||
</TabsContent>
|
||||
<TabsContent value="agent-builder" className="mt-0 h-full data-hidden:hidden">
|
||||
<DeprecationBanner featureName="The Playground's Agent Builder" />
|
||||
<AgentBuilderView
|
||||
accessToken={accessToken}
|
||||
token={token}
|
||||
userID={userId}
|
||||
userRole={userRole}
|
||||
disabledPersonalKeyCreation={disabledPersonalKeyCreation}
|
||||
proxySettings={proxySettings}
|
||||
customProxyBaseUrl={proxySettings?.LITELLM_UI_API_DOC_BASE_URL ?? proxySettings?.PROXY_BASE_URL}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,224 +1,175 @@
|
|||
import React from "react";
|
||||
import { Collapse } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { MCPEvent } from "@/components/mcp_tools/types";
|
||||
|
||||
const { Panel } = Collapse;
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
interface MCPEventsDisplayProps {
|
||||
events: MCPEvent[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatArguments(raw: string | undefined): string {
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
const MCPEventsDisplay: React.FC<MCPEventsDisplayProps> = ({ events, className }) => {
|
||||
if (!events || events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the list tools event
|
||||
const toolsEvent = events.find(
|
||||
(event) =>
|
||||
event.type === "response.output_item.done" &&
|
||||
event.item?.type === "mcp_list_tools" &&
|
||||
event.item.tools &&
|
||||
event.item.tools.length > 0,
|
||||
);
|
||||
const isListToolsEvent = (event: MCPEvent): boolean => {
|
||||
if (event.type !== "response.output_item.done") {
|
||||
return false;
|
||||
}
|
||||
if (event.item?.type !== "mcp_list_tools") {
|
||||
return false;
|
||||
}
|
||||
return Boolean(event.item.tools && event.item.tools.length > 0);
|
||||
};
|
||||
|
||||
// Find MCP call events
|
||||
const mcpCallEvents = events.filter(
|
||||
(event) => event.type === "response.output_item.done" && event.item?.type === "mcp_call",
|
||||
);
|
||||
const isMcpCallEvent = (event: MCPEvent): boolean =>
|
||||
event.type === "response.output_item.done" && event.item?.type === "mcp_call";
|
||||
|
||||
const toolsEvent = events.find(isListToolsEvent);
|
||||
const mcpCallEvents = events.filter(isMcpCallEvent);
|
||||
|
||||
if (!toolsEvent && mcpCallEvents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mcp-events-display ${className || ""}`}>
|
||||
<style jsx>{`
|
||||
.openai-mcp-tools {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.openai-mcp-tools .ant-collapse {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
.openai-mcp-tools .ant-collapse-item {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.openai-mcp-tools .ant-collapse-header {
|
||||
padding: 0 0 0 20px !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
font-size: 14px !important;
|
||||
color: #9ca3af !important;
|
||||
font-weight: 400 !important;
|
||||
line-height: 20px !important;
|
||||
min-height: 20px !important;
|
||||
}
|
||||
.openai-mcp-tools .ant-collapse-header:hover {
|
||||
background: transparent !important;
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
.openai-mcp-tools .ant-collapse-content {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.openai-mcp-tools .ant-collapse-content-box {
|
||||
padding: 4px 0 0 20px !important;
|
||||
}
|
||||
.openai-mcp-tools .ant-collapse-expand-icon {
|
||||
position: absolute !important;
|
||||
left: 2px !important;
|
||||
top: 2px !important;
|
||||
color: #9ca3af !important;
|
||||
font-size: 10px !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.openai-mcp-tools .ant-collapse-expand-icon:hover {
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
.openai-vertical-line {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 18px;
|
||||
bottom: 0;
|
||||
width: 0.5px;
|
||||
background-color: #f3f4f6;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.tool-item {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
line-height: 18px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: white;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.mcp-section {
|
||||
margin-bottom: 12px;
|
||||
background: white;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.mcp-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.mcp-section-header {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.mcp-code-block {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mcp-json {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.mcp-approved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.mcp-checkmark {
|
||||
color: #10b981;
|
||||
margin-right: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.mcp-response-content {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New",
|
||||
monospace;
|
||||
}
|
||||
`}</style>
|
||||
<div className="openai-mcp-tools">
|
||||
<div className="openai-vertical-line"></div>
|
||||
<Collapse
|
||||
ghost
|
||||
size="small"
|
||||
expandIconPosition="start"
|
||||
defaultActiveKey={toolsEvent ? ["list-tools"] : mcpCallEvents.map((_, index) => `mcp-call-${index}`)}
|
||||
>
|
||||
{/* List Tools Panel */}
|
||||
{toolsEvent && (
|
||||
<Panel header="List tools" key="list-tools">
|
||||
<div>
|
||||
{toolsEvent.item?.tools?.map((tool, index) => (
|
||||
<div key={index} className="tool-item">
|
||||
{tool.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
const defaultOpenKeys = new Set<string>(
|
||||
toolsEvent ? ["list-tools"] : mcpCallEvents.map((_, index) => `mcp-call-${index}`),
|
||||
);
|
||||
|
||||
{/* MCP Call Panels */}
|
||||
{mcpCallEvents.map((callEvent, index) => (
|
||||
<Panel header={callEvent.item?.name || "Tool call"} key={`mcp-call-${index}`}>
|
||||
return (
|
||||
<div className={cn("mcp-events-display", className)}>
|
||||
<MCPEventsPanels toolsEvent={toolsEvent} mcpCallEvents={mcpCallEvents} defaultOpenKeys={defaultOpenKeys} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface MCPEventsPanelsProps {
|
||||
toolsEvent: MCPEvent | undefined;
|
||||
mcpCallEvents: MCPEvent[];
|
||||
defaultOpenKeys: Set<string>;
|
||||
}
|
||||
|
||||
function MCPEventsPanels({ toolsEvent, mcpCallEvents, defaultOpenKeys }: MCPEventsPanelsProps) {
|
||||
const [openKeys, setOpenKeys] = useState<Set<string>>(defaultOpenKeys);
|
||||
|
||||
const toggleKey = (key: string, open: boolean) => {
|
||||
setOpenKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative m-0 p-0">
|
||||
<div className="absolute bottom-0 left-[9px] top-[18px] w-px bg-gray-100 opacity-80" aria-hidden="true" />
|
||||
|
||||
<div className="space-y-1">
|
||||
{toolsEvent && (
|
||||
<MCPEventPanel
|
||||
panelKey="list-tools"
|
||||
title="List tools"
|
||||
open={openKeys.has("list-tools")}
|
||||
onOpenChange={(open) => toggleKey("list-tools", open)}
|
||||
>
|
||||
<div>
|
||||
{toolsEvent.item?.tools?.map((tool, index) => (
|
||||
<div key={index} className="relative z-[1] bg-white font-mono text-[13px] leading-[18px] text-gray-600">
|
||||
{tool.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MCPEventPanel>
|
||||
)}
|
||||
|
||||
{mcpCallEvents.map((callEvent, index) => {
|
||||
const key = `mcp-call-${index}`;
|
||||
return (
|
||||
<MCPEventPanel
|
||||
key={key}
|
||||
panelKey={key}
|
||||
title={callEvent.item?.name || "Tool call"}
|
||||
open={openKeys.has(key)}
|
||||
onOpenChange={(open) => toggleKey(key, open)}
|
||||
>
|
||||
<div>
|
||||
{/* Request section */}
|
||||
<div className="mcp-section">
|
||||
<div className="mcp-section-header">Request</div>
|
||||
<div className="mcp-code-block">
|
||||
<div className="relative z-[1] mb-3 bg-white last:mb-0">
|
||||
<div className="mb-1 text-[13px] font-medium text-gray-500">Request</div>
|
||||
<div className="rounded-md border border-gray-100 bg-gray-50 p-2 text-xs">
|
||||
{callEvent.item?.arguments && (
|
||||
<pre className="mcp-json">
|
||||
{(() => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(callEvent.item.arguments), null, 2);
|
||||
} catch (e) {
|
||||
return callEvent.item.arguments;
|
||||
}
|
||||
})()}
|
||||
<pre className="m-0 whitespace-pre-wrap break-words font-mono text-gray-700">
|
||||
{formatArguments(callEvent.item.arguments)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approved section */}
|
||||
<div className="mcp-section">
|
||||
<div className="mcp-approved">
|
||||
<span className="mcp-checkmark">✓</span> Approved
|
||||
<div className="relative z-[1] mb-3 bg-white last:mb-0">
|
||||
<div className="flex items-center text-[13px] text-gray-500">
|
||||
<span className="mr-1.5 font-bold text-emerald-500" aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
Approved
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response section */}
|
||||
{callEvent.item?.output && (
|
||||
<div className="mcp-section">
|
||||
<div className="mcp-section-header">Response</div>
|
||||
<div className="mcp-response-content">{callEvent.item.output}</div>
|
||||
<div className="relative z-[1] mb-3 bg-white last:mb-0">
|
||||
<div className="mb-1 text-[13px] font-medium text-gray-500">Response</div>
|
||||
<div className="whitespace-pre-wrap font-mono text-[13px] leading-normal text-gray-700">
|
||||
{callEvent.item.output}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
</MCPEventPanel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
interface MCPEventPanelProps {
|
||||
panelKey: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function MCPEventPanel({ title, open, onOpenChange, children }: MCPEventPanelProps) {
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={onOpenChange}>
|
||||
<CollapsibleTrigger className="relative flex min-h-5 w-full items-center gap-1 pl-5 text-left text-sm font-normal leading-5 text-gray-400 hover:text-gray-500">
|
||||
<ChevronRight
|
||||
className={cn("absolute left-0.5 top-0.5 size-4 text-gray-400 transition-transform", open && "rotate-90")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{title}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="pt-1 pl-5">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export default MCPEventsDisplay;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useState } from "react";
|
||||
import { Button } from "antd";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { DownOutlined, RightOutlined, BulbOutlined } from "@ant-design/icons";
|
||||
import { ChevronDown, ChevronRight, Lightbulb } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
|
||||
interface ReasoningContentProps {
|
||||
reasoningContent: string;
|
||||
|
|
@ -16,63 +17,65 @@ const ReasoningContent: React.FC<ReasoningContentProps> = ({ reasoningContent })
|
|||
|
||||
return (
|
||||
<div className="reasoning-content mt-1 mb-2">
|
||||
<Button
|
||||
type="text"
|
||||
className="flex items-center text-xs text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
icon={<BulbOutlined />}
|
||||
>
|
||||
{isExpanded ? "Hide reasoning" : "Show reasoning"}
|
||||
{isExpanded ? <DownOutlined className="ml-1" /> : <RightOutlined className="ml-1" />}
|
||||
</Button>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-md text-sm text-gray-700 max-w-full overflow-x-auto whitespace-pre-wrap break-words"
|
||||
style={{ wordBreak: "break-word", overflowWrap: "break-word" }}
|
||||
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button type="button" variant="ghost" size="sm" className="text-xs text-gray-500 hover:text-gray-700" />
|
||||
}
|
||||
>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code({
|
||||
node,
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"code"> & {
|
||||
inline?: boolean;
|
||||
node?: any;
|
||||
}) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={coy as any}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded-md my-2"
|
||||
wrapLines={true}
|
||||
wrapLongLines={true}
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className={`${className} px-1.5 py-0.5 rounded-sm bg-gray-100 text-sm font-mono`}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ node, ...props }) => <pre style={{ overflowX: "auto", maxWidth: "100%" }} {...props} />,
|
||||
}}
|
||||
<Lightbulb className="size-3.5" />
|
||||
{isExpanded ? "Hide reasoning" : "Show reasoning"}
|
||||
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className="mt-2 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700"
|
||||
style={{ wordBreak: "break-word", overflowWrap: "break-word" }}
|
||||
>
|
||||
{reasoningContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code({
|
||||
node,
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"code"> & {
|
||||
inline?: boolean;
|
||||
node?: unknown;
|
||||
}) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="my-2 rounded-md"
|
||||
wrapLines={true}
|
||||
wrapLongLines={true}
|
||||
{...props}
|
||||
style={coy as { [key: string]: React.CSSProperties }}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className={`${className ?? ""} rounded-sm bg-gray-100 px-1.5 py-0.5 font-mono text-sm`}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: ({ node, ...props }) => <pre style={{ overflowX: "auto", maxWidth: "100%" }} {...props} />,
|
||||
}}
|
||||
>
|
||||
{reasoningContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,6 @@
|
|||
import React from "react";
|
||||
import { Tooltip } from "antd";
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
NumberOutlined,
|
||||
ImportOutlined,
|
||||
ExportOutlined,
|
||||
BulbOutlined,
|
||||
ToolOutlined,
|
||||
DollarOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { ArrowDownToLine, ArrowUpFromLine, Clock, DollarSign, Hash, Lightbulb, Wrench } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export interface TokenUsage {
|
||||
completionTokens?: number;
|
||||
|
|
@ -25,81 +17,102 @@ interface ResponseMetricsProps {
|
|||
toolName?: string;
|
||||
}
|
||||
|
||||
interface MetricItemProps {
|
||||
label: string;
|
||||
tooltip: string;
|
||||
icon: React.ReactNode;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function MetricItem({ label, tooltip, icon, value }: MetricItemProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className="flex items-center gap-1" aria-label={`${label}: ${value}`} />}>
|
||||
{icon}
|
||||
<span>
|
||||
{label}: {value}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const ResponseMetrics: React.FC<ResponseMetricsProps> = ({ timeToFirstToken, totalLatency, usage, toolName }) => {
|
||||
if (!timeToFirstToken && !totalLatency && !usage) return null;
|
||||
|
||||
return (
|
||||
<div className="response-metrics mt-2 pt-2 border-t border-gray-100 text-xs text-gray-500 flex flex-wrap gap-3">
|
||||
<div className="response-metrics mt-2 flex flex-wrap gap-3 border-t border-gray-100 pt-2 text-xs text-gray-500">
|
||||
{timeToFirstToken !== undefined && (
|
||||
<Tooltip title="Time to first token">
|
||||
<div className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-1" />
|
||||
<span>TTFT: {(timeToFirstToken / 1000).toFixed(2)}s</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<MetricItem
|
||||
label="TTFT"
|
||||
tooltip="Time to first token"
|
||||
icon={<Clock className="size-3" aria-hidden="true" />}
|
||||
value={`${(timeToFirstToken / 1000).toFixed(2)}s`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{totalLatency !== undefined && (
|
||||
<Tooltip title="Total latency">
|
||||
<div className="flex items-center">
|
||||
<ClockCircleOutlined className="mr-1" />
|
||||
<span>Total Latency: {(totalLatency / 1000).toFixed(2)}s</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<MetricItem
|
||||
label="Total Latency"
|
||||
tooltip="Total latency"
|
||||
icon={<Clock className="size-3" aria-hidden="true" />}
|
||||
value={`${(totalLatency / 1000).toFixed(2)}s`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{usage?.promptTokens !== undefined && (
|
||||
<Tooltip title="Prompt tokens">
|
||||
<div className="flex items-center">
|
||||
<ImportOutlined className="mr-1" />
|
||||
<span>In: {usage.promptTokens}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<MetricItem
|
||||
label="In"
|
||||
tooltip="Prompt tokens"
|
||||
icon={<ArrowDownToLine className="size-3" aria-hidden="true" />}
|
||||
value={String(usage.promptTokens)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{usage?.completionTokens !== undefined && (
|
||||
<Tooltip title="Completion tokens">
|
||||
<div className="flex items-center">
|
||||
<ExportOutlined className="mr-1" />
|
||||
<span>Out: {usage.completionTokens}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<MetricItem
|
||||
label="Out"
|
||||
tooltip="Completion tokens"
|
||||
icon={<ArrowUpFromLine className="size-3" aria-hidden="true" />}
|
||||
value={String(usage.completionTokens)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{usage?.reasoningTokens !== undefined && (
|
||||
<Tooltip title="Reasoning tokens">
|
||||
<div className="flex items-center">
|
||||
<BulbOutlined className="mr-1" />
|
||||
<span>Reasoning: {usage.reasoningTokens}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<MetricItem
|
||||
label="Reasoning"
|
||||
tooltip="Reasoning tokens"
|
||||
icon={<Lightbulb className="size-3" aria-hidden="true" />}
|
||||
value={String(usage.reasoningTokens)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{usage?.totalTokens !== undefined && (
|
||||
<Tooltip title="Total tokens">
|
||||
<div className="flex items-center">
|
||||
<NumberOutlined className="mr-1" />
|
||||
<span>Total: {usage.totalTokens}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<MetricItem
|
||||
label="Total"
|
||||
tooltip="Total tokens"
|
||||
icon={<Hash className="size-3" aria-hidden="true" />}
|
||||
value={String(usage.totalTokens)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{usage?.cost !== undefined && (
|
||||
<Tooltip title="Cost">
|
||||
<div className="flex items-center">
|
||||
<DollarOutlined className="mr-1" />
|
||||
<span>${usage.cost.toFixed(6)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<MetricItem
|
||||
label="Cost"
|
||||
tooltip="Cost"
|
||||
icon={<DollarSign className="size-3" aria-hidden="true" />}
|
||||
value={`$${usage.cost.toFixed(6)}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toolName && (
|
||||
<Tooltip title="Tool used">
|
||||
<div className="flex items-center">
|
||||
<ToolOutlined className="mr-1" />
|
||||
<span>Tool: {toolName}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<MetricItem
|
||||
label="Tool"
|
||||
tooltip="Tool used"
|
||||
icon={<Wrench className="size-3" aria-hidden="true" />}
|
||||
value={toolName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ export enum ModelMode {
|
|||
VIDEO_GENERATION = "video_generation",
|
||||
CHAT = "chat",
|
||||
RESPONSES = "responses",
|
||||
IMAGE_EDITS = "image_edits",
|
||||
IMAGE_EDITS = "image_edit",
|
||||
ANTHROPIC_MESSAGES = "anthropic_messages",
|
||||
EMBEDDING = "embedding",
|
||||
// add additional modes as needed
|
||||
REALTIME = "realtime",
|
||||
}
|
||||
|
||||
// Define an enum for the endpoint types your UI calls
|
||||
|
|
@ -42,6 +42,7 @@ export const litellmModeMapping: Record<ModelMode, EndpointType> = {
|
|||
[ModelMode.AUDIO_SPEECH]: EndpointType.SPEECH,
|
||||
[ModelMode.AUDIO_TRANSCRIPTION]: EndpointType.TRANSCRIPTION,
|
||||
[ModelMode.EMBEDDING]: EndpointType.EMBEDDINGS,
|
||||
[ModelMode.REALTIME]: EndpointType.REALTIME,
|
||||
};
|
||||
|
||||
export const getEndpointType = (mode: string): EndpointType => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import { Guardrail } from "./types";
|
||||
import { getGuardrailsList } from "../networking";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
|
||||
interface GuardrailSelectorProps {
|
||||
onChange: (selectedGuardrails: string[]) => void;
|
||||
|
|
@ -40,25 +40,21 @@ const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({ onChange, value,
|
|||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
mode="multiple"
|
||||
<div className="min-w-0">
|
||||
<MultiSelect
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? "Setting guardrails is a premium feature." : "Select guardrails"}
|
||||
onChange={handleGuardrailChange}
|
||||
onValueChange={handleGuardrailChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
className={className}
|
||||
allowClear
|
||||
options={guardrails.map((guardrail) => {
|
||||
return {
|
||||
label: `${guardrail.guardrail_name}`,
|
||||
value: guardrail.guardrail_name,
|
||||
};
|
||||
options={guardrails.flatMap((guardrail) => {
|
||||
const name = guardrail.guardrail_name;
|
||||
if (name == null || name === "") {
|
||||
return [];
|
||||
}
|
||||
return [{ label: name, value: name }];
|
||||
})}
|
||||
optionFilterProp="label"
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@ export interface ModelGroup {
|
|||
mode?: string;
|
||||
}
|
||||
|
||||
interface AvailableModel {
|
||||
model_group?: string | null;
|
||||
model_name?: string | null;
|
||||
id?: string | null;
|
||||
mode?: string | null;
|
||||
}
|
||||
|
||||
export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: string): Promise<ModelGroup[]> => {
|
||||
const response = await modelAvailableCall(accessToken, "", "", false, teamId);
|
||||
const modelNames: string[] = (response?.data ?? []).map((model: { id: string }) => model.id);
|
||||
|
|
@ -25,14 +32,15 @@ export const fetchAvailableModels = async (accessToken: string): Promise<ModelGr
|
|||
const fetchedModels = await modelHubCall(accessToken);
|
||||
|
||||
if (fetchedModels?.data.length > 0) {
|
||||
const models: ModelGroup[] = fetchedModels.data.map((item: any) => ({
|
||||
model_group: item.model_group, // Display the model_group to the user
|
||||
mode: item?.mode, // Save the mode for auto-selection of endpoint type
|
||||
}));
|
||||
const models: ModelGroup[] = fetchedModels.data
|
||||
.map((item: AvailableModel) => ({
|
||||
model_group: item.model_group || item.id || item.model_name || "",
|
||||
mode: item.mode || undefined,
|
||||
}))
|
||||
.filter((model: ModelGroup) => model.model_group !== "");
|
||||
|
||||
// Sort models alphabetically by label
|
||||
models.sort((a, b) => a.model_group.localeCompare(b.model_group));
|
||||
return models;
|
||||
return Array.from(new Map(models.map((model) => [model.model_group, model])).values());
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import { Policy } from "./types";
|
||||
import { getPoliciesList } from "../networking";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
|
||||
/** Prefix for policy version IDs in request body; must match backend POLICY_VERSION_ID_PREFIX. */
|
||||
export const POLICY_VERSION_ID_PREFIX = "policy_";
|
||||
|
|
@ -86,22 +86,17 @@ const PolicySelector: React.FC<PolicySelectorProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
mode="multiple"
|
||||
<div className="min-w-0">
|
||||
<MultiSelect
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
disabled ? "Setting policies is a premium feature." : "Select policies (production or published versions)"
|
||||
}
|
||||
onChange={handlePolicyChange}
|
||||
onValueChange={handlePolicyChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
className={className}
|
||||
allowClear
|
||||
options={getPolicyOptionEntries(policies)}
|
||||
optionFilterProp="label"
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
125
ui/litellm-dashboard/src/components/shared/MultiSelect.tsx
Normal file
125
ui/litellm-dashboard/src/components/shared/MultiSelect.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
} from "@/components/ui/combobox";
|
||||
|
||||
export interface MultiSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: MultiSelectOption[];
|
||||
value?: string[];
|
||||
onValueChange: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
allowCustomValues?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const matchesQuery = (option: MultiSelectOption, query: string): boolean => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return (
|
||||
!normalizedQuery ||
|
||||
option.label.toLowerCase().includes(normalizedQuery) ||
|
||||
option.value.toLowerCase().includes(normalizedQuery) ||
|
||||
(option.description?.toLowerCase().includes(normalizedQuery) ?? false)
|
||||
);
|
||||
};
|
||||
|
||||
export function MultiSelect({
|
||||
options,
|
||||
value = [],
|
||||
onValueChange,
|
||||
placeholder = "Select options",
|
||||
emptyText = "No options found",
|
||||
disabled = false,
|
||||
loading = false,
|
||||
allowCustomValues = false,
|
||||
className,
|
||||
}: MultiSelectProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const safeOptions = options.filter(
|
||||
(option): option is MultiSelectOption =>
|
||||
option != null && typeof option.value === "string" && option.value.length > 0,
|
||||
);
|
||||
const selectedOptions = value
|
||||
.filter((selectedValue): selectedValue is string => typeof selectedValue === "string" && selectedValue.length > 0)
|
||||
.map(
|
||||
(selectedValue) =>
|
||||
safeOptions.find((option) => option.value === selectedValue) ?? {
|
||||
label: selectedValue,
|
||||
value: selectedValue,
|
||||
},
|
||||
);
|
||||
const customOption = query.trim();
|
||||
const customOptionExists = safeOptions.some((option) => option.value.toLowerCase() === customOption.toLowerCase());
|
||||
const items =
|
||||
allowCustomValues && customOption && !customOptionExists
|
||||
? [...safeOptions, { label: `Create "${customOption}"`, value: customOption }]
|
||||
: safeOptions;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
multiple
|
||||
items={items}
|
||||
value={selectedOptions}
|
||||
onValueChange={(selected: MultiSelectOption[]) => {
|
||||
onValueChange(selected.map((option) => option.value));
|
||||
setQuery("");
|
||||
}}
|
||||
inputValue={query}
|
||||
onInputValueChange={setQuery}
|
||||
isItemEqualToValue={(option: MultiSelectOption, selected: MultiSelectOption) => option.value === selected.value}
|
||||
itemToStringLabel={(option: MultiSelectOption) => option.label}
|
||||
filter={matchesQuery}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<ComboboxChips className={`min-h-8 py-1 text-sm ${className ?? ""}`}>
|
||||
<ComboboxValue>
|
||||
{(selected: MultiSelectOption[]) =>
|
||||
selected.map((option) => (
|
||||
<ComboboxChip key={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput
|
||||
placeholder={loading ? "Loading..." : placeholder}
|
||||
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: MultiSelectOption) => (
|
||||
<ComboboxItem key={option.value} value={option}>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="block truncate text-xs text-muted-foreground">{option.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
|
@ -56,9 +56,9 @@ export function SearchSelect({
|
|||
<ComboboxInput
|
||||
placeholder={placeholder}
|
||||
showClear={value != null && value !== ""}
|
||||
className={`w-full ${className ?? ""}`}
|
||||
className={`h-8 w-full text-sm ${className ?? ""}`}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxContent side="bottom" collisionAvoidance={{ side: "shift", align: "shift", fallbackAxisSide: "none" }}>
|
||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item: SearchSelectOption) => (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import { Tag } from "./types";
|
||||
import { tagListCall } from "../networking";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
|
||||
interface TagSelectorProps {
|
||||
onChange: (selectedTags: string[]) => void;
|
||||
|
|
@ -17,6 +17,7 @@ const TagSelector: React.FC<TagSelectorProps> = ({ onChange, value, className, a
|
|||
useEffect(() => {
|
||||
const fetchTags = async () => {
|
||||
if (!accessToken) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await tagListCall(accessToken);
|
||||
setTags(Object.values(response));
|
||||
|
|
@ -31,24 +32,18 @@ const TagSelector: React.FC<TagSelectorProps> = ({ onChange, value, className, a
|
|||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
mode="tags"
|
||||
showSearch
|
||||
<MultiSelect
|
||||
placeholder="Select or create tags"
|
||||
onChange={onChange}
|
||||
onValueChange={onChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
className={className}
|
||||
allowCustomValues
|
||||
options={tags.map((tag) => ({
|
||||
label: tag.name,
|
||||
value: tag.name,
|
||||
title: tag.description || tag.name,
|
||||
description: tag.description || undefined,
|
||||
}))}
|
||||
optionFilterProp="label"
|
||||
tokenSeparators={[","]}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -83,10 +83,14 @@ function ComboboxContent({
|
|||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
collisionAvoidance,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<ComboboxPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "anchor">) {
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "collisionAvoidance" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
|
|
@ -94,6 +98,7 @@ function ComboboxContent({
|
|||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
collisionAvoidance={collisionAvoidance}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ function InputGroupAddon({
|
|||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
e.currentTarget.parentElement?.querySelector<HTMLElement>("[data-slot=input-group-control]")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,78 +3,64 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { VectorStore } from "./types";
|
||||
import VectorStoreSelector from "./VectorStoreSelector";
|
||||
|
||||
// Mock dependencies
|
||||
const mockVectorStoreListCall = vi.fn();
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
vectorStoreListCall: (...args: any[]) => mockVectorStoreListCall(...args),
|
||||
vectorStoreListCall: (...args: unknown[]) => mockVectorStoreListCall(...args),
|
||||
}));
|
||||
|
||||
// Mock antd Select component
|
||||
vi.mock("antd", () => ({
|
||||
Select: vi.fn(),
|
||||
vi.mock("@/components/shared/MultiSelect", () => ({
|
||||
MultiSelect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the mocked Select
|
||||
import { Select as MockedSelect } from "antd";
|
||||
import { MultiSelect as MockedMultiSelect } from "@/components/shared/MultiSelect";
|
||||
|
||||
// Configure the mock to render a simple div with data attributes
|
||||
(MockedSelect as any).mockImplementation((props: any) => {
|
||||
const {
|
||||
onChange,
|
||||
value,
|
||||
placeholder,
|
||||
loading,
|
||||
className,
|
||||
disabled,
|
||||
options,
|
||||
mode,
|
||||
showSearch,
|
||||
optionFilterProp,
|
||||
style,
|
||||
} = props;
|
||||
(MockedMultiSelect as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(props: {
|
||||
onValueChange?: (value: string[]) => void;
|
||||
value?: string[];
|
||||
placeholder?: string;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
options?: Array<{ value: string; label: string; description?: string }>;
|
||||
}) => {
|
||||
const { onValueChange, value, placeholder, loading, className, disabled, options } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="vector-store-select"
|
||||
data-loading={loading}
|
||||
data-disabled={disabled}
|
||||
data-mode={mode}
|
||||
data-show-search={showSearch}
|
||||
data-option-filter-prop={optionFilterProp}
|
||||
data-placeholder={placeholder}
|
||||
data-value={value !== undefined ? JSON.stringify(value) : undefined}
|
||||
data-options={JSON.stringify(options)}
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={(e: any) => {
|
||||
// For testing purposes, allow simulating different selection behaviors
|
||||
// The test can control this by setting data attributes on the element
|
||||
const testSelection = e.target.getAttribute("data-test-selection");
|
||||
if (testSelection && onChange) {
|
||||
onChange(JSON.parse(testSelection));
|
||||
} else if (onChange && options?.length > 0) {
|
||||
// Default behavior: select first option
|
||||
onChange([options[0].value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options?.map((opt: any) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
data-option-value={opt.value}
|
||||
data-option-label={opt.label}
|
||||
data-option-title={opt.title}
|
||||
data-testid={`option-${opt.value}`}
|
||||
>
|
||||
{opt.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div
|
||||
data-testid="vector-store-select"
|
||||
data-loading={loading}
|
||||
data-disabled={disabled}
|
||||
data-placeholder={placeholder}
|
||||
data-value={value !== undefined ? JSON.stringify(value) : undefined}
|
||||
data-options={JSON.stringify(options)}
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
const testSelection = (e.target as HTMLElement).getAttribute("data-test-selection");
|
||||
if (testSelection && onValueChange) {
|
||||
onValueChange(JSON.parse(testSelection) as string[]);
|
||||
} else if (onValueChange && options && options.length > 0) {
|
||||
onValueChange([options[0].value]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options?.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
data-option-value={opt.value}
|
||||
data-option-label={opt.label}
|
||||
data-option-description={opt.description}
|
||||
data-testid={`option-${opt.value}`}
|
||||
>
|
||||
{opt.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Test helpers
|
||||
const mockOnChange = vi.fn();
|
||||
const mockAccessToken = "test-token";
|
||||
|
||||
|
|
@ -98,7 +84,6 @@ const mockVectorStores: VectorStore[] = [
|
|||
{
|
||||
vector_store_id: "store-3",
|
||||
custom_llm_provider: "pg_vector",
|
||||
// No vector_store_name to test fallback to vector_store_id
|
||||
vector_store_description: "Store without name",
|
||||
created_at: "2024-01-03T00:00:00Z",
|
||||
updated_at: "2024-01-03T00:00:00Z",
|
||||
|
|
@ -110,7 +95,6 @@ const defaultProps = {
|
|||
accessToken: mockAccessToken,
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const renderComponent = (props = {}) => {
|
||||
return render(<VectorStoreSelector {...defaultProps} {...props} />);
|
||||
};
|
||||
|
|
@ -124,7 +108,7 @@ const waitForDataFetch = async () => {
|
|||
const getSelectElement = () => screen.getByTestId("vector-store-select");
|
||||
|
||||
const getOptionElements = () =>
|
||||
screen.getAllByTestId(/^vector-store-select/).filter((el) => el.hasAttribute("data-option-value"));
|
||||
screen.queryAllByTestId(/^option-/).filter((el) => el.hasAttribute("data-option-value"));
|
||||
|
||||
describe("VectorStoreSelector", () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -142,56 +126,27 @@ describe("VectorStoreSelector", () => {
|
|||
|
||||
it("should render with default placeholder", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-placeholder", "Select vector stores");
|
||||
expect(getSelectElement()).toHaveAttribute("data-placeholder", "Select vector stores");
|
||||
});
|
||||
|
||||
it("should render with custom placeholder", () => {
|
||||
renderComponent({ placeholder: "Choose stores" });
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-placeholder", "Choose stores");
|
||||
expect(getSelectElement()).toHaveAttribute("data-placeholder", "Choose stores");
|
||||
});
|
||||
|
||||
it("should apply custom className", () => {
|
||||
renderComponent({ className: "custom-class" });
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveClass("custom-class");
|
||||
expect(getSelectElement()).toHaveClass("custom-class");
|
||||
});
|
||||
|
||||
it("should render with disabled state", () => {
|
||||
renderComponent({ disabled: true });
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-disabled", "true");
|
||||
expect(getSelectElement()).toHaveAttribute("data-disabled", "true");
|
||||
});
|
||||
|
||||
it("should render with enabled state by default", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-disabled", "false");
|
||||
});
|
||||
|
||||
it("should render with multiple mode", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-mode", "multiple");
|
||||
});
|
||||
|
||||
it("should render with showSearch enabled", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-show-search", "true");
|
||||
});
|
||||
|
||||
it("should render with optionFilterProp set to label", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-option-filter-prop", "label");
|
||||
});
|
||||
|
||||
it("should render with full width style", () => {
|
||||
renderComponent();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveStyle({ width: "100%" });
|
||||
expect(getSelectElement()).toHaveAttribute("data-disabled", "false");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -207,10 +162,10 @@ describe("VectorStoreSelector", () => {
|
|||
const { rerender } = render(<VectorStoreSelector {...defaultProps} accessToken="" />);
|
||||
expect(mockVectorStoreListCall).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<VectorStoreSelector {...defaultProps} accessToken={null as any} />);
|
||||
rerender(<VectorStoreSelector {...defaultProps} accessToken={null as unknown as string} />);
|
||||
expect(mockVectorStoreListCall).not.toHaveBeenCalled();
|
||||
|
||||
rerender(<VectorStoreSelector {...defaultProps} accessToken={undefined as any} />);
|
||||
rerender(<VectorStoreSelector {...defaultProps} accessToken={undefined as unknown as string} />);
|
||||
expect(mockVectorStoreListCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -228,7 +183,7 @@ describe("VectorStoreSelector", () => {
|
|||
});
|
||||
|
||||
it("should set loading state while fetching", async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
let resolvePromise: (value: unknown) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
|
@ -247,8 +202,7 @@ describe("VectorStoreSelector", () => {
|
|||
it("should clear loading state after successful fetch", async () => {
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-loading", "false");
|
||||
expect(getSelectElement()).toHaveAttribute("data-loading", "false");
|
||||
});
|
||||
|
||||
it("should clear loading state after failed fetch", async () => {
|
||||
|
|
@ -258,8 +212,7 @@ describe("VectorStoreSelector", () => {
|
|||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
expect(select).toHaveAttribute("data-loading", "false");
|
||||
expect(getSelectElement()).toHaveAttribute("data-loading", "false");
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -280,7 +233,7 @@ describe("VectorStoreSelector", () => {
|
|||
|
||||
const option1 = screen.getByText("My Store (store-1)");
|
||||
expect(option1).toBeInTheDocument();
|
||||
expect(option1).toHaveAttribute("data-option-title", "A test store");
|
||||
expect(option1).toHaveAttribute("data-option-description", "A test store");
|
||||
});
|
||||
|
||||
it("should fallback to vector_store_id when vector_store_name is missing", async () => {
|
||||
|
|
@ -289,19 +242,18 @@ describe("VectorStoreSelector", () => {
|
|||
|
||||
const option3 = screen.getByText("store-3 (store-3)");
|
||||
expect(option3).toBeInTheDocument();
|
||||
// When vector_store_name is missing, title uses vector_store_description if available, otherwise vector_store_id
|
||||
expect(option3).toHaveAttribute("data-option-title", "Store without name");
|
||||
expect(option3).toHaveAttribute("data-option-description", "Store without name");
|
||||
});
|
||||
|
||||
it("should use vector_store_description as title when available", async () => {
|
||||
it("should use vector_store_description as description when available", async () => {
|
||||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const option1 = screen.getByText("My Store (store-1)");
|
||||
expect(option1).toHaveAttribute("data-option-title", "A test store");
|
||||
expect(option1).toHaveAttribute("data-option-description", "A test store");
|
||||
});
|
||||
|
||||
it("should fallback to vector_store_id as title when vector_store_description is missing", async () => {
|
||||
it("should omit description when vector_store_description is missing", async () => {
|
||||
const storesWithoutDescription: VectorStore[] = [
|
||||
{
|
||||
vector_store_id: "store-no-desc",
|
||||
|
|
@ -318,7 +270,7 @@ describe("VectorStoreSelector", () => {
|
|||
await waitForDataFetch();
|
||||
|
||||
const option = screen.getByText("store-no-desc (store-no-desc)");
|
||||
expect(option).toHaveAttribute("data-option-title", "store-no-desc");
|
||||
expect(option).not.toHaveAttribute("data-option-description");
|
||||
});
|
||||
|
||||
it("should use vector_store_id as option value", async () => {
|
||||
|
|
@ -337,8 +289,7 @@ describe("VectorStoreSelector", () => {
|
|||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const options = getOptionElements();
|
||||
expect(options.length).toBe(0);
|
||||
expect(getOptionElements().length).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle response without data property", async () => {
|
||||
|
|
@ -347,8 +298,7 @@ describe("VectorStoreSelector", () => {
|
|||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const options = getOptionElements();
|
||||
expect(options.length).toBe(0);
|
||||
expect(getOptionElements().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -357,27 +307,21 @@ describe("VectorStoreSelector", () => {
|
|||
renderComponent({ value: ["store-1", "store-2"] });
|
||||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
const dataValue = select.getAttribute("data-value");
|
||||
expect(dataValue).toBe(JSON.stringify(["store-1", "store-2"]));
|
||||
expect(getSelectElement().getAttribute("data-value")).toBe(JSON.stringify(["store-1", "store-2"]));
|
||||
});
|
||||
|
||||
it("should handle empty value array", async () => {
|
||||
renderComponent({ value: [] });
|
||||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
const dataValue = select.getAttribute("data-value");
|
||||
expect(dataValue).toBe(JSON.stringify([]));
|
||||
expect(getSelectElement().getAttribute("data-value")).toBe(JSON.stringify([]));
|
||||
});
|
||||
|
||||
it("should handle undefined value", async () => {
|
||||
renderComponent({ value: undefined });
|
||||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
const dataValue = select.getAttribute("data-value");
|
||||
expect(dataValue).toBeNull(); // undefined value results in no data-value attribute
|
||||
expect(getSelectElement().getAttribute("data-value")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -387,7 +331,6 @@ describe("VectorStoreSelector", () => {
|
|||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
// Simulate selecting store-1 by setting test data attribute
|
||||
select.setAttribute("data-test-selection", '["store-1"]');
|
||||
fireEvent.click(select);
|
||||
|
||||
|
|
@ -399,7 +342,6 @@ describe("VectorStoreSelector", () => {
|
|||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
// Simulate selecting multiple values
|
||||
select.setAttribute("data-test-selection", '["store-1", "store-2"]');
|
||||
fireEvent.click(select);
|
||||
|
||||
|
|
@ -411,7 +353,6 @@ describe("VectorStoreSelector", () => {
|
|||
await waitForDataFetch();
|
||||
|
||||
const select = getSelectElement();
|
||||
// Simulate deselecting store-1
|
||||
select.setAttribute("data-test-selection", '["store-2"]');
|
||||
fireEvent.click(select);
|
||||
|
||||
|
|
@ -450,7 +391,6 @@ describe("VectorStoreSelector", () => {
|
|||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
// Component should still render
|
||||
expect(getSelectElement()).toBeInTheDocument();
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
|
@ -474,8 +414,7 @@ describe("VectorStoreSelector", () => {
|
|||
await waitForDataFetch();
|
||||
|
||||
expect(screen.getByText("minimal-store (minimal-store)")).toBeInTheDocument();
|
||||
const option = screen.getByText("minimal-store (minimal-store)");
|
||||
expect(option).toHaveAttribute("data-option-title", "minimal-store");
|
||||
expect(screen.getByText("minimal-store (minimal-store)")).not.toHaveAttribute("data-option-description");
|
||||
});
|
||||
|
||||
it("should handle very long vector store names", async () => {
|
||||
|
|
@ -495,8 +434,7 @@ describe("VectorStoreSelector", () => {
|
|||
renderComponent();
|
||||
await waitForDataFetch();
|
||||
|
||||
const expectedLabel = `${"A".repeat(200)} (store-long)`;
|
||||
expect(screen.getByText(expectedLabel)).toBeInTheDocument();
|
||||
expect(screen.getByText(`${"A".repeat(200)} (store-long)`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle special characters in vector store names", async () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import { VectorStore } from "./types";
|
||||
import { vectorStoreListCall } from "../networking";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
interface VectorStoreSelectorProps {
|
||||
onChange: (selectedVectorStores: string[]) => void;
|
||||
value?: string[];
|
||||
|
|
@ -43,24 +43,19 @@ const VectorStoreSelector: React.FC<VectorStoreSelectorProps> = ({
|
|||
}, [accessToken]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
mode="multiple"
|
||||
<div className="min-w-0">
|
||||
<MultiSelect
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
onValueChange={onChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
className={className}
|
||||
allowClear
|
||||
disabled={disabled}
|
||||
options={vectorStores.map((store) => ({
|
||||
label: `${store.vector_store_name || store.vector_store_id} (${store.vector_store_id})`,
|
||||
value: store.vector_store_id,
|
||||
title: store.vector_store_description || store.vector_store_id,
|
||||
description: store.vector_store_description || undefined,
|
||||
}))}
|
||||
optionFilterProp="label"
|
||||
showSearch
|
||||
style={{ width: "100%" }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue