This commit is contained in:
IvanShang 2026-08-27 18:42:55 -05:00 committed by GitHub
commit 0d4c086727
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 400 additions and 41 deletions

View file

@ -702,15 +702,9 @@
}
},
"src/app/(dashboard)/playground/components/chat_ui/ChatUI.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
},
"max-lines": {
"count": 1
},
"no-nested-ternary": {
"count": 6
},
"react-hooks/set-state-in-effect": {
"count": 4
}

View file

@ -490,6 +490,49 @@ describe("ChatUI", () => {
expect(customProxyInput).toHaveValue(testProxyUrl);
});
it("revokes the active OCR preview URL on unmount", async () => {
const createObjectURL = vi.fn(() => "blob:http://localhost/ocr-preview");
const revokeObjectURL = vi.fn();
URL.createObjectURL = createObjectURL;
URL.revokeObjectURL = revokeObjectURL;
const { getByText, unmount } = render(
<ChatUI
accessToken="1234567890"
token="1234567890"
userRole="user"
userID="1234567890"
disabledPersonalKeyCreation={false}
/>,
);
await waitFor(() => {
expect(getByText("Test Key")).toBeInTheDocument();
});
await selectComboboxOption("Select an endpoint", "/v1/ocr");
await waitFor(() => {
expect(screen.getByText("Click or drag a document or image to upload")).toBeInTheDocument();
});
const uploadInput = screen.getByLabelText(/Click or drag a document or image to upload/);
const imageFile = new File(["ocr"], "ocr.png", { type: "image/png" });
await act(async () => {
fireEvent.change(uploadInput, { target: { files: [imageFile] } });
});
await waitFor(() => {
expect(createObjectURL).toHaveBeenCalledWith(imageFile);
expect(screen.getByAltText("Upload preview")).toBeInTheDocument();
});
unmount();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:http://localhost/ocr-preview");
});
it("should enable search functionality for MCP server selector", async () => {
const user = userEvent.setup();
render(

View file

@ -45,6 +45,7 @@ import { Agent, fetchAvailableAgents } from "../../llm_calls/fetch_agents";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import { makeOpenAIImageEditsRequest } from "../../llm_calls/image_edits";
import { makeOpenAIImageGenerationRequest } from "../../llm_calls/image_generation";
import { makeOpenAIOcrRequest } from "../../llm_calls/OcrApi";
import { makeOpenAIResponsesRequest } from "@/components/llm_calls/responses_api";
import { makeInteractionsRequest } from "../../llm_calls/interactions_api";
import AdditionalModelSettings from "./AdditionalModelSettings";
@ -115,6 +116,63 @@ const MCP_SUPPORTED_ENDPOINTS = new Set<EndpointType>([
const CUSTOM_MODEL_DEBOUNCE_WAIT_MS = 500;
interface SendDisabledOptions {
endpointType: EndpointType;
inputMessage: string;
isLoading: boolean;
selectedMCPDirectTool: string | undefined;
selectedMCPServers: readonly string[];
uploadedAudio: File | null;
uploadedOcrFile: File | null;
}
const getInputPlaceholder = (endpointType: EndpointType): string => {
switch (endpointType) {
case EndpointType.CHAT:
case EndpointType.EMBEDDINGS:
case EndpointType.RESPONSES:
case EndpointType.ANTHROPIC_MESSAGES:
case EndpointType.INTERACTIONS:
return "Type your message... (Shift+Enter for new line)";
case EndpointType.A2A_AGENTS:
return "Send a message to the A2A agent...";
case EndpointType.IMAGE_EDITS:
return "Describe how you want to edit the image...";
case EndpointType.SPEECH:
return "Enter text to convert to speech...";
case EndpointType.TRANSCRIPTION:
return "Optional: Add context or prompt for transcription...";
case EndpointType.OCR:
return "Upload a document or image to run OCR";
default:
return "Describe the image you want to generate...";
}
};
const getSendDisabled = ({
endpointType,
inputMessage,
isLoading,
selectedMCPDirectTool,
selectedMCPServers,
uploadedAudio,
uploadedOcrFile,
}: SendDisabledOptions): boolean => {
if (isLoading) {
return true;
}
if (endpointType === EndpointType.MCP) {
return !(selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__" && selectedMCPDirectTool);
}
if (endpointType === EndpointType.TRANSCRIPTION) {
return !uploadedAudio;
}
if (endpointType === EndpointType.OCR) {
return !uploadedOcrFile;
}
return !inputMessage.trim();
};
const ChatUI: React.FC<ChatUIProps> = ({
accessToken,
token,
@ -263,6 +321,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
const [chatUploadedImage, setChatUploadedImage] = useState<File | null>(null);
const [chatImagePreviewUrl, setChatImagePreviewUrl] = useState<string | null>(null);
const [uploadedAudio, setUploadedAudio] = useState<File | null>(null);
const [uploadedOcrFile, setUploadedOcrFile] = useState<File | null>(null);
const [ocrFilePreviewUrl, setOcrFilePreviewUrl] = useState<string | null>(null);
const [isGetCodeModalVisible, setIsGetCodeModalVisible] = useState(false);
const [generatedCode, setGeneratedCode] = useState("");
const [selectedSdk, setSelectedSdk] = useState<"openai" | "azure">("openai");
@ -280,6 +340,24 @@ const ChatUI: React.FC<ChatUIProps> = ({
const codeInterpreter = useCodeInterpreter();
const chatEndRef = useRef<HTMLDivElement>(null);
const ocrFilePreviewUrlRef = useRef<string | null>(null);
const revokeOcrFilePreviewUrl = () => {
if (ocrFilePreviewUrlRef.current) {
URL.revokeObjectURL(ocrFilePreviewUrlRef.current);
}
ocrFilePreviewUrlRef.current = null;
setOcrFilePreviewUrl(null);
};
useEffect(() => {
return () => {
if (ocrFilePreviewUrlRef.current) {
URL.revokeObjectURL(ocrFilePreviewUrlRef.current);
ocrFilePreviewUrlRef.current = null;
}
};
}, []);
// Fetch MCP servers and toolsets
const loadMCPServers = async () => {
@ -646,6 +724,14 @@ const ChatUI: React.FC<ChatUIProps> = ({
event.target.value = "";
};
const handleAudioFileDrop = (event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) {
handleAudioUpload(file);
}
};
const mcpServerOptions = useMemo((): MultiSelectOption[] => {
const options: MultiSelectOption[] = [];
if (endpointType !== EndpointType.MCP) {
@ -708,8 +794,43 @@ const ChatUI: React.FC<ChatUIProps> = ({
setUploadedAudio(null);
};
const handleOcrFileUpload = (file: File): false => {
revokeOcrFilePreviewUrl();
setUploadedOcrFile(file);
const previewUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
ocrFilePreviewUrlRef.current = previewUrl;
setOcrFilePreviewUrl(previewUrl);
return false;
};
const handleOcrFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleOcrFileUpload(file);
}
event.target.value = "";
};
const handleOcrFileDrop = (event: React.DragEvent<HTMLLabelElement>) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) {
handleOcrFileUpload(file);
}
};
const handleRemoveOcrFile = () => {
setUploadedOcrFile(null);
revokeOcrFilePreviewUrl();
};
const handleSendMessage = async () => {
if (inputMessage.trim() === "" && endpointType !== EndpointType.TRANSCRIPTION && endpointType !== EndpointType.MCP)
if (
inputMessage.trim() === "" &&
endpointType !== EndpointType.TRANSCRIPTION &&
endpointType !== EndpointType.MCP &&
endpointType !== EndpointType.OCR
)
return;
// For image edits, require both image and prompt
@ -724,6 +845,11 @@ const ChatUI: React.FC<ChatUIProps> = ({
return;
}
if (endpointType === EndpointType.OCR && !uploadedOcrFile) {
toast.fromError("Please upload a document or image for OCR");
return;
}
// For A2A agents, require agent selection
if (endpointType === EndpointType.A2A_AGENTS && !selectedAgent) {
toast.fromError("Please select an agent to send a message");
@ -779,6 +905,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
EndpointType.ANTHROPIC_MESSAGES,
EndpointType.EMBEDDINGS,
EndpointType.TRANSCRIPTION,
EndpointType.OCR,
EndpointType.INTERACTIONS,
];
@ -791,7 +918,11 @@ const ChatUI: React.FC<ChatUIProps> = ({
return;
}
const effectiveApiKey = simplified ? accessToken : apiKeySource === "session" ? accessToken : apiKey;
const effectiveApiKey = (() => {
if (simplified) return accessToken;
if (apiKeySource === "session") return accessToken;
return apiKey;
})();
if (!effectiveApiKey) {
toast.fromError("Please provide a Virtual Key or select Current UI Session");
@ -854,6 +985,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
? `🎵 Audio file: ${uploadedAudio.name}\nPrompt: ${inputMessage}`
: `🎵 Audio file: ${uploadedAudio.name}`;
displayMessage = createDisplayMessage(audioMessage, false);
} else if (endpointType === EndpointType.OCR && uploadedOcrFile) {
displayMessage = createDisplayMessage(`OCR file: ${uploadedOcrFile.name}`, false);
} else if (endpointType === EndpointType.MCP && selectedMCPDirectTool) {
// For MCP direct mode, show tool name and arguments from form
const mcpMessage = `🔧 MCP Tool: ${selectedMCPDirectTool}\nArguments: ${JSON.stringify(mcpToolArguments, null, 2)}`;
@ -1051,6 +1184,18 @@ const ChatUI: React.FC<ChatUIProps> = ({
customProxyBaseUrl || undefined,
);
}
} else if (endpointType === EndpointType.OCR) {
if (uploadedOcrFile) {
await makeOpenAIOcrRequest({
file: uploadedOcrFile,
updateUI: (text, model) => updateTextUI("assistant", text, model),
selectedModel,
accessToken: effectiveApiKey,
tags: selectedTags,
signal,
customBaseUrl: customProxyBaseUrl || undefined,
});
}
} else if (endpointType === EndpointType.INTERACTIONS) {
await makeInteractionsRequest(
inputMessage,
@ -1136,6 +1281,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
if (endpointType === EndpointType.TRANSCRIPTION && uploadedAudio) {
handleRemoveAudio();
}
if (endpointType === EndpointType.OCR && uploadedOcrFile) {
handleRemoveOcrFile();
}
}
setInputMessage("");
@ -1147,6 +1295,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
handleRemoveResponsesImage();
handleRemoveChatImage();
handleRemoveAudio();
handleRemoveOcrFile();
toast.success("Chat history cleared.");
};
@ -1187,30 +1336,16 @@ const ChatUI: React.FC<ChatUIProps> = ({
modelEmptyText = "No models available for this endpoint";
}
const inputPlaceholder =
endpointType === EndpointType.CHAT ||
endpointType === EndpointType.EMBEDDINGS ||
endpointType === EndpointType.RESPONSES ||
endpointType === EndpointType.ANTHROPIC_MESSAGES ||
endpointType === EndpointType.INTERACTIONS
? "Type your message... (Shift+Enter for new line)"
: endpointType === EndpointType.A2A_AGENTS
? "Send a message to the A2A agent..."
: endpointType === EndpointType.IMAGE_EDITS
? "Describe how you want to edit the image..."
: endpointType === EndpointType.SPEECH
? "Enter text to convert to speech..."
: endpointType === EndpointType.TRANSCRIPTION
? "Optional: Add context or prompt for transcription..."
: "Describe the image you want to generate...";
const sendDisabled =
isLoading ||
(endpointType === EndpointType.MCP
? !(selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__" && selectedMCPDirectTool)
: endpointType === EndpointType.TRANSCRIPTION
? !uploadedAudio
: !inputMessage.trim());
const inputPlaceholder = getInputPlaceholder(endpointType as EndpointType);
const sendDisabled = getSendDisabled({
endpointType: endpointType as EndpointType,
inputMessage,
isLoading,
selectedMCPDirectTool,
selectedMCPServers,
uploadedAudio,
uploadedOcrFile,
});
return (
<div className={`min-h-0 min-w-0 bg-card ${simplified ? "flex h-full w-full flex-col" : "h-full w-full p-3"}`}>
@ -1887,13 +2022,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
<label
className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted px-4 py-8 text-center hover:border-ring"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) {
handleAudioUpload(file);
}
}}
onDrop={handleAudioFileDrop}
>
<Volume2 className="mb-2 size-6 text-muted-foreground" aria-hidden="true" />
<p className="text-sm">Click or drag audio file to upload</p>
@ -1931,6 +2060,25 @@ const ChatUI: React.FC<ChatUIProps> = ({
</div>
)}
{endpointType === EndpointType.OCR && !uploadedOcrFile && (
<div className="mb-4">
<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={handleOcrFileDrop}
>
<ImageIcon className="mb-2 size-6 text-gray-500" aria-hidden="true" />
<p className="text-sm">Click or drag a document or image to upload</p>
<p className="text-xs text-gray-500">Support for PDF and image files.</p>
<input
type="file"
accept="image/*,application/pdf"
className="sr-only"
onChange={handleOcrFileInputChange}
/>
</label>
</div>
)}
{endpointType === EndpointType.RESPONSES && responsesUploadedImage && (
<FilePreviewCard
file={responsesUploadedImage}
@ -1947,6 +2095,13 @@ const ChatUI: React.FC<ChatUIProps> = ({
/>
)}
{endpointType === EndpointType.OCR && uploadedOcrFile && (
<FilePreviewCard
file={uploadedOcrFile}
previewUrl={ocrFilePreviewUrl}
onRemove={handleRemoveOcrFile}
/>
)}
{endpointType === EndpointType.RESPONSES && codeInterpreter.enabled && (
<div className="mb-2 space-y-2">
<div className="flex items-center justify-between rounded-lg border border-info/20 bg-linear-to-r from-blue-50 to-purple-50 px-3 py-2 dark:from-blue-950 dark:to-purple-950">
@ -1998,10 +2153,15 @@ const ChatUI: React.FC<ChatUIProps> = ({
onSubmit={handleSendMessage}
onCancel={handleCancelRequest}
placeholder={inputPlaceholder}
disabled={isLoading}
disabled={isLoading || endpointType === EndpointType.OCR}
isLoading={isLoading}
submitDisabled={sendDisabled}
showSuggestions={chatHistory.length === 0 && !isLoading && endpointType !== EndpointType.MCP}
showSuggestions={
chatHistory.length === 0 &&
!isLoading &&
endpointType !== EndpointType.MCP &&
endpointType !== EndpointType.OCR
}
suggestions={
endpointType === EndpointType.A2A_AGENTS
? ["What can you help me with?", "Tell me about yourself", "What tasks can you perform?"]

View file

@ -26,4 +26,18 @@ describe("EndpointSelector", () => {
expect(await screen.findByText("/v1/audio/speech")).toBeInTheDocument();
expect(await screen.findByText("/v1/audio/transcriptions")).toBeInTheDocument();
});
it("should filter and show the OCR endpoint when user inputs 'ocr'", async () => {
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");
await user.clear(input);
await user.type(input, "ocr");
expect(await screen.findByText("/v1/ocr")).toBeInTheDocument();
});
});

View file

@ -42,6 +42,7 @@ export const ENDPOINT_OPTIONS = [
{ value: EndpointType.EMBEDDINGS, label: "/v1/embeddings" },
{ value: EndpointType.SPEECH, label: "/v1/audio/speech" },
{ value: EndpointType.TRANSCRIPTION, label: "/v1/audio/transcriptions" },
{ value: EndpointType.OCR, label: "/v1/ocr" },
{ value: EndpointType.A2A_AGENTS, label: "/v1/a2a/message/send" },
{ value: EndpointType.MCP, label: "/mcp-rest/tools/call" },
{ value: EndpointType.REALTIME, label: "/v1/realtime" },

View file

@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeOpenAIOcrRequest } from "./OcrApi";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: vi.fn(() => "https://example.com"),
getGlobalLitellmHeaderName: vi.fn(() => "Authorization"),
}));
describe("ocr_api", () => {
const mockUpdateUI = vi.fn();
const mockFetch = vi.fn();
beforeEach(() => {
const responseBody = JSON.stringify({
pages: [
{
index: 0,
markdown: "# Extracted text",
},
],
model: "mistral-ocr-latest",
});
mockFetch.mockResolvedValue({
ok: true,
text: async () => responseBody,
} as Response);
global.fetch = mockFetch;
});
afterEach(() => {
vi.clearAllMocks();
});
it("posts OCR uploads to /v1/ocr with model and file multipart fields", async () => {
const file = new File(["document data"], "invoice.pdf", { type: "application/pdf" });
await makeOpenAIOcrRequest({
file,
updateUI: mockUpdateUI,
selectedModel: "mistral-ocr-latest",
accessToken: "sk-1234567890",
tags: ["tag1", "tag2"],
});
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith("https://example.com/v1/ocr", {
method: "POST",
headers: {
Authorization: "Bearer sk-1234567890",
"x-litellm-tags": "tag1,tag2",
},
body: expect.any(FormData),
signal: undefined,
});
const formData = mockFetch.mock.calls[0][1].body as FormData;
expect(formData.get("model")).toBe("mistral-ocr-latest");
expect(formData.get("file")).toBe(file);
expect(mockUpdateUI).toHaveBeenCalledWith("# Extracted text", "mistral-ocr-latest");
});
});

View file

@ -0,0 +1,66 @@
import { toast } from "@/lib/toast";
import { getGlobalLitellmHeaderName, getProxyBaseUrl } from "@/components/networking";
import { createApiClient } from "@/lib/http/client";
interface OcrRequestParams {
file: File;
updateUI: (text: string, model: string) => void;
selectedModel: string;
accessToken: string;
tags?: string[];
signal?: AbortSignal;
customBaseUrl?: string;
}
const formatOcrResponse = (response: unknown): string => {
if (typeof response !== "object" || response === null || !("pages" in response)) {
return JSON.stringify(response, null, 2);
}
const pages = (response as { pages?: unknown }).pages;
if (!Array.isArray(pages)) {
return JSON.stringify(response, null, 2);
}
const markdown = pages
.map((page) =>
typeof page === "object" && page !== null && "markdown" in page
? (page as { markdown?: unknown }).markdown
: undefined,
)
.filter((pageMarkdown): pageMarkdown is string => typeof pageMarkdown === "string" && pageMarkdown.length > 0)
.join("\n\n");
return markdown || JSON.stringify(response, null, 2);
};
export async function makeOpenAIOcrRequest({
file,
updateUI,
selectedModel,
accessToken,
tags,
signal,
customBaseUrl,
}: OcrRequestParams) {
const client = createApiClient({
getBaseUrl: () => customBaseUrl || getProxyBaseUrl(),
getAuthHeaderName: getGlobalLitellmHeaderName,
onError: (message) => toast.fromError(`OCR failed: ${message}`),
});
const formData = new FormData();
formData.append("model", selectedModel);
formData.append("file", file);
const responseJson = await client.post<unknown>("/v1/ocr", {
accessToken,
rawBody: formData,
headers: {
...(tags && tags.length > 0 ? { "x-litellm-tags": tags.join(",") } : {}),
},
signal,
});
updateUI(formatOcrResponse(responseJson), selectedModel);
toast.success("OCR completed successfully");
}

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { EndpointType, getEndpointType, litellmModeMapping, ModelMode } from "./mode_endpoint_mapping";
describe("mode_endpoint_mapping", () => {
it("maps OCR model mode to the OCR endpoint", () => {
expect(getEndpointType("ocr")).toBe(EndpointType.OCR);
expect(litellmModeMapping[ModelMode.OCR]).toBe(EndpointType.OCR);
});
it("preserves existing model mode mappings", () => {
expect(getEndpointType("chat")).toBe(EndpointType.CHAT);
expect(getEndpointType("responses")).toBe(EndpointType.RESPONSES);
expect(getEndpointType("image_generation")).toBe(EndpointType.IMAGE);
expect(getEndpointType("audio_transcription")).toBe(EndpointType.TRANSCRIPTION);
});
});

View file

@ -13,6 +13,7 @@ export enum ModelMode {
ANTHROPIC_MESSAGES = "anthropic_messages",
EMBEDDING = "embedding",
REALTIME = "realtime",
OCR = "ocr",
}
// Define an enum for the endpoint types your UI calls
@ -26,6 +27,7 @@ export enum EndpointType {
EMBEDDINGS = "embeddings",
SPEECH = "speech",
TRANSCRIPTION = "transcription",
OCR = "ocr",
A2A_AGENTS = "a2a_agents",
MCP = "mcp",
REALTIME = "realtime",
@ -45,6 +47,7 @@ export const litellmModeMapping: Record<ModelMode, EndpointType> = {
[ModelMode.AUDIO_TRANSCRIPTION]: EndpointType.TRANSCRIPTION,
[ModelMode.EMBEDDING]: EndpointType.EMBEDDINGS,
[ModelMode.REALTIME]: EndpointType.REALTIME,
[ModelMode.OCR]: EndpointType.OCR,
};
export const getEndpointType = (mode: string): EndpointType => {