mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): keep the playground endpoint, editable number fields, and drag-to-upload
Picking a model reset the endpoint from its mode unconditionally, so choosing a chat model while on /v1/responses, /v1/messages or interactions bounced the playground to /v1/chat/completions. Only switch when the current endpoint cannot serve the picked model. The temperature and max-token boxes parsed and clamped on every keystroke, so a decimal lost its point and clearing the field snapped to a bound. They are now text fields with a numeric input mode that hold what was typed and clamp on blur; the sliders beside them still give the stepped control. The image-edit and transcription areas invited a drag but had no drop handlers after the Ant Design Dragger came out, so drops did nothing. Wire drop through the same validation the file picker uses.
This commit is contained in:
parent
136ef345da
commit
26194b85a4
4 changed files with 189 additions and 16 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ 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();
|
||||
|
|
@ -51,24 +53,46 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
|
|||
|
||||
useEffect(() => {
|
||||
setLocalTemperature(temperature);
|
||||
setTemperatureText(String(temperature));
|
||||
}, [temperature]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalMaxTokens(maxTokens);
|
||||
setMaxTokensText(String(maxTokens));
|
||||
}, [maxTokens]);
|
||||
|
||||
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) => {
|
||||
const newValue = clamp(Number.isFinite(value) ? Math.round(value) : 1000, 1, 32768);
|
||||
setLocalMaxTokens(newValue);
|
||||
setMaxTokensText(String(newValue));
|
||||
onMaxTokensChange?.(newValue);
|
||||
};
|
||||
|
||||
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) {
|
||||
onUseAdvancedParamsChange(checked);
|
||||
|
|
@ -175,14 +199,14 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
|
|||
</div>
|
||||
<Input
|
||||
id={`${temperatureId}-number`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={localTemperature}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
aria-label="Temperature value"
|
||||
value={temperatureText}
|
||||
disabled={!useAdvancedParams}
|
||||
className="h-8 w-20"
|
||||
onChange={(event) => handleTemperatureChange(Number(event.target.value))}
|
||||
onChange={(event) => handleTemperatureTyped(event.target.value)}
|
||||
onBlur={() => handleTemperatureChange(Number(temperatureText))}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
|
|
@ -221,14 +245,14 @@ const AdditionalModelSettings: React.FC<AdditionalModelSettingsProps> = ({
|
|||
</div>
|
||||
<Input
|
||||
id={`${maxTokensId}-number`}
|
||||
type="number"
|
||||
min={1}
|
||||
max={32768}
|
||||
step={1}
|
||||
value={localMaxTokens}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label="Max tokens value"
|
||||
value={maxTokensText}
|
||||
disabled={!useAdvancedParams}
|
||||
className="h-8 w-24"
|
||||
onChange={(event) => handleMaxTokensChange(Number(event.target.value))}
|
||||
onChange={(event) => handleMaxTokensTyped(event.target.value)}
|
||||
onBlur={() => handleMaxTokensChange(Number(maxTokensText))}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -401,4 +401,78 @@ describe("ChatUI", () => {
|
|||
expect(screen.getByText("All MCP Servers")).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 still switch endpoint when the picked model cannot be served by it", async () => {
|
||||
(fetchModelsModule.fetchAvailableModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
|
||||
{ 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 selectComboboxOption("Select a Model", "SpeechModel");
|
||||
|
||||
expect(screen.getByPlaceholderText("Select an endpoint")).toHaveValue("/v1/audio/speech");
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1149,12 +1149,27 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
NotificationsManager.success("Chat history cleared.");
|
||||
};
|
||||
|
||||
const currentEndpointServes = (mode: string): boolean => {
|
||||
const modelEndpoint = getEndpointType(mode);
|
||||
if (
|
||||
endpointType === EndpointType.RESPONSES ||
|
||||
endpointType === EndpointType.ANTHROPIC_MESSAGES ||
|
||||
endpointType === EndpointType.INTERACTIONS
|
||||
) {
|
||||
return modelEndpoint === endpointType || modelEndpoint === EndpointType.CHAT;
|
||||
}
|
||||
if (endpointType === EndpointType.IMAGE_EDITS) {
|
||||
return modelEndpoint === endpointType || modelEndpoint === EndpointType.IMAGE;
|
||||
}
|
||||
return modelEndpoint === endpointType;
|
||||
};
|
||||
|
||||
const onModelChange = (value: string) => {
|
||||
setSelectedModel(value);
|
||||
setShowCustomModelInput(value === "custom");
|
||||
|
||||
const model = modelInfo.find((option) => option.model_group === value);
|
||||
if (model?.mode) {
|
||||
if (model?.mode && !currentEndpointServes(model.mode)) {
|
||||
setEndpointType(getEndpointType(model.mode));
|
||||
}
|
||||
};
|
||||
|
|
@ -1805,7 +1820,14 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
{endpointType === EndpointType.IMAGE_EDITS && (
|
||||
<div className="mb-4">
|
||||
{uploadedImages.length === 0 ? (
|
||||
<label className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 px-4 py-8 text-center hover:border-gray-400">
|
||||
<label
|
||||
className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 px-4 py-8 text-center hover:border-gray-400"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
handleImageFiles(Array.from(event.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
<ImageIcon className="mb-2 size-6 text-gray-500" aria-hidden="true" />
|
||||
<p className="text-sm">Click or drag images to upload</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
|
|
@ -1874,7 +1896,17 @@ const ChatUI: React.FC<ChatUIProps> = ({
|
|||
{endpointType === EndpointType.TRANSCRIPTION && (
|
||||
<div className="mb-4">
|
||||
{!uploadedAudio ? (
|
||||
<label className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 px-4 py-8 text-center hover:border-gray-400">
|
||||
<label
|
||||
className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 px-4 py-8 text-center hover:border-gray-400"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) {
|
||||
handleAudioUpload(file);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Volume2 className="mb-2 size-6 text-gray-500" aria-hidden="true" />
|
||||
<p className="text-sm">Click or drag audio file to upload</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue