LiteLLM UI - Test Key Page - allow uploading images for /chat/completions and /responses (#13445)

* add MultimodalContent on chat UI

* add multi modal img on chat ui

* utils for responses API imgs

* add code snippet with imgs

* chat UI add imgs

* add imge upload

* chat ui allow adding images

* fix chat send button

* fix button styles

* fix clear chat
This commit is contained in:
Ishaan Jaff 2025-08-08 16:57:08 -07:00 • committed by GitHub
parent 7e2a00c848
commit 840db3fe48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 677 additions and 104 deletions

View file

@ -3,3 +3,12 @@ model_list:
litellm_params:
model: gemini/*
litellm_settings:
callbacks: ["s3_v2"]
s3_callback_params:
s3_bucket_name: litellm-logs # AWS Bucket Name for S3
s3_region_name: us-west-2
general_settings:
cold_storage_custom_logger: s3_v2
store_prompts_in_cold_storage: true

View file

@ -45,6 +45,12 @@ import { generateCodeSnippet } from "./chat_ui/CodeSnippets";
import { MessageType } from "./chat_ui/types";
import ReasoningContent from "./chat_ui/ReasoningContent";
import ResponseMetrics, { TokenUsage } from "./chat_ui/ResponseMetrics";
import ResponsesImageUpload from "./chat_ui/ResponsesImageUpload";
import ResponsesImageRenderer from "./chat_ui/ResponsesImageRenderer";
import { convertImageToBase64, createMultimodalMessage, createDisplayMessage } from "./chat_ui/ResponsesImageUtils";
import ChatImageUpload from "./chat_ui/ChatImageUpload";
import ChatImageRenderer from "./chat_ui/ChatImageRenderer";
import { createChatMultimodalMessage, createChatDisplayMessage } from "./chat_ui/ChatImageUtils";
import {
SendOutlined,
ApiOutlined,
@ -61,7 +67,9 @@ import {
UploadOutlined,
PictureOutlined,
CodeOutlined,
ToolOutlined
ToolOutlined,
FilePdfOutlined,
ArrowUpOutlined
} from "@ant-design/icons";
const { TextArea } = Input;
@ -157,6 +165,10 @@ const ChatUI: React.FC<ChatUIProps> = ({
const [messageTraceId, setMessageTraceId] = useState<string | null>(() => sessionStorage.getItem('messageTraceId') || null);
const [uploadedImage, setUploadedImage] = useState<File | null>(null);
const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
const [responsesUploadedImage, setResponsesUploadedImage] = useState<File | null>(null);
const [responsesImagePreviewUrl, setResponsesImagePreviewUrl] = useState<string | null>(null);
const [chatUploadedImage, setChatUploadedImage] = useState<File | null>(null);
const [chatImagePreviewUrl, setChatImagePreviewUrl] = useState<string | null>(null);
const [isGetCodeModalVisible, setIsGetCodeModalVisible] = useState(false);
const [generatedCode, setGeneratedCode] = useState("");
const [selectedSdk, setSelectedSdk] = useState<'openai' | 'azure'>('openai');
@ -442,6 +454,36 @@ const ChatUI: React.FC<ChatUIProps> = ({
setImagePreviewUrl(null);
};
const handleResponsesImageUpload = (file: File): false => {
setResponsesUploadedImage(file);
const previewUrl = URL.createObjectURL(file);
setResponsesImagePreviewUrl(previewUrl);
return false; // Prevent default upload behavior
};
const handleRemoveResponsesImage = () => {
if (responsesImagePreviewUrl) {
URL.revokeObjectURL(responsesImagePreviewUrl);
}
setResponsesUploadedImage(null);
setResponsesImagePreviewUrl(null);
};
const handleChatImageUpload = (file: File): false => {
setChatUploadedImage(file);
const previewUrl = URL.createObjectURL(file);
setChatImagePreviewUrl(previewUrl);
return false; // Prevent default upload behavior
};
const handleRemoveChatImage = () => {
if (chatImagePreviewUrl) {
URL.revokeObjectURL(chatImagePreviewUrl);
}
setChatUploadedImage(null);
setChatImagePreviewUrl(null);
};
const handleSendMessage = async () => {
if (inputMessage.trim() === "") return;
@ -467,7 +509,28 @@ const ChatUI: React.FC<ChatUIProps> = ({
const signal = abortControllerRef.current.signal;
// Create message object without model field for API call
const newUserMessage = { role: "user", content: inputMessage };
let newUserMessage: { role: string; content: string | any[] };
// Handle image for responses API
if (endpointType === EndpointType.RESPONSES && responsesUploadedImage) {
try {
newUserMessage = await createMultimodalMessage(inputMessage, responsesUploadedImage);
} catch (error) {
message.error("Failed to process image. Please try again.");
return;
}
}
// Handle image for chat completions API
else if (endpointType === EndpointType.CHAT && chatUploadedImage) {
try {
newUserMessage = await createChatMultimodalMessage(inputMessage, chatUploadedImage);
} catch (error) {
message.error("Failed to process image. Please try again.");
return;
}
} else {
newUserMessage = { role: "user", content: inputMessage };
}
// Generate new trace ID for a new conversation or use existing one
const traceId = messageTraceId || uuidv4();
@ -475,8 +538,17 @@ const ChatUI: React.FC<ChatUIProps> = ({
setMessageTraceId(traceId);
}
// Update UI with full message object
setChatHistory([...chatHistory, newUserMessage]);
// Update UI with full message object (always display as text for UI)
let displayMessage: MessageType;
if (endpointType === EndpointType.RESPONSES && responsesUploadedImage) {
displayMessage = createDisplayMessage(inputMessage, true, responsesImagePreviewUrl || undefined, responsesUploadedImage.name);
} else if (endpointType === EndpointType.CHAT && chatUploadedImage) {
displayMessage = createChatDisplayMessage(inputMessage, true, chatImagePreviewUrl || undefined, chatUploadedImage.name);
} else {
displayMessage = createDisplayMessage(inputMessage, false);
}
setChatHistory([...chatHistory, displayMessage]);
setIsLoading(true);
try {
@ -484,7 +556,11 @@ const ChatUI: React.FC<ChatUIProps> = ({
if (endpointType === EndpointType.CHAT) {
// Create chat history for API call - strip out model field and isImage field
const apiChatHistory = [...chatHistory.filter(msg => !msg.isImage).map(({ role, content }) => ({ role, content })), newUserMessage];
// For chat completions, we preserve the multimodal content structure
const apiChatHistory = [...chatHistory.filter(msg => !msg.isImage).map(({ role, content }) => ({
role,
content: typeof content === 'string' ? content : ''
})), newUserMessage];
await makeOpenAIChatCompletionRequest(
apiChatHistory,
@ -577,6 +653,14 @@ const ChatUI: React.FC<ChatUIProps> = ({
if (endpointType === EndpointType.IMAGE_EDITS) {
handleRemoveImage();
}
// Clear image after successful request for responses API
if (endpointType === EndpointType.RESPONSES && responsesUploadedImage) {
handleRemoveResponsesImage();
}
// Clear image after successful request for chat completions API
if (endpointType === EndpointType.CHAT && chatUploadedImage) {
handleRemoveChatImage();
}
}
setInputMessage("");
@ -585,7 +669,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
const clearChatHistory = () => {
setChatHistory([]);
setMessageTraceId(null);
handleRemoveImage(); // Clear any uploaded images
handleRemoveImage(); // Clear any uploaded images for image edits
handleRemoveResponsesImage(); // Clear any uploaded images for responses
handleRemoveChatImage(); // Clear any uploaded images for chat completions
sessionStorage.removeItem('chatHistory');
sessionStorage.removeItem('messageTraceId');
message.success("Chat history cleared.");
@ -798,16 +884,6 @@ const ChatUI: React.FC<ChatUIProps> = ({
accessToken={accessToken || ""}
/>
</div>
<div className="space-y-2 mt-4">
<TremorButton
onClick={clearChatHistory}
className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-300"
icon={ClearOutlined}
>
Clear Chat
</TremorButton>
</div>
</div>
</div>
@ -816,6 +892,13 @@ const ChatUI: React.FC<ChatUIProps> = ({
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
<Title className="text-xl font-semibold mb-0">Test Key</Title>
<div className="flex gap-2">
<TremorButton
onClick={clearChatHistory}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-300"
icon={ClearOutlined}
>
Clear Chat
</TremorButton>
<TremorButton
onClick={() => setIsGetCodeModalVisible(true)}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-300"
@ -871,44 +954,54 @@ const ChatUI: React.FC<ChatUIProps> = ({
}}>
{message.isImage ? (
<img
src={message.content}
src={typeof message.content === "string" ? message.content : ""}
alt="Generated image"
className="max-w-full rounded-md border border-gray-200 shadow-sm"
style={{ maxHeight: '500px' }}
/>
) : (
<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 bg-gray-100 text-sm font-mono`} style={{ wordBreak: 'break-word' }} {...props}>
{children}
</code>
);
},
pre: ({ node, ...props }) => (
<pre style={{ overflowX: 'auto', maxWidth: '100%' }} {...props} />
)
}}
>
{message.content}
</ReactMarkdown>
<>
{/* Show attached image for user messages based on current endpoint */}
{endpointType === EndpointType.RESPONSES && (
<ResponsesImageRenderer message={message} />
)}
{endpointType === EndpointType.CHAT && (
<ChatImageRenderer message={message} />
)}
<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 bg-gray-100 text-sm font-mono`} style={{ wordBreak: 'break-word' }} {...props}>
{children}
</code>
);
},
pre: ({ node, ...props }) => (
<pre style={{ overflowX: 'auto', maxWidth: '100%' }} {...props} />
)
}}
>
{typeof message.content === "string" ? message.content : ""}
</ReactMarkdown>
</>
)}
{message.role === "assistant" && (message.timeToFirstToken || message.usage) && (
@ -966,53 +1059,142 @@ const ChatUI: React.FC<ChatUIProps> = ({
)}
</div>
)}
{/* Show file previews above input when files are uploaded */}
{(endpointType === EndpointType.RESPONSES && responsesUploadedImage) && (
<div className="mb-2">
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="relative inline-block">
{responsesUploadedImage.name.toLowerCase().endsWith('.pdf') ? (
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
<FilePdfOutlined style={{ fontSize: '16px', color: 'white' }} />
</div>
) : (
<img
src={responsesImagePreviewUrl || ''}
alt="Upload preview"
className="w-10 h-10 rounded-md border border-gray-200 object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{responsesUploadedImage.name}
</div>
<div className="text-xs text-gray-500">
{responsesUploadedImage.name.toLowerCase().endsWith('.pdf') ? '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"
onClick={handleRemoveResponsesImage}
>
<DeleteOutlined style={{ fontSize: '12px' }} />
</button>
</div>
</div>
)}
<div className="flex items-center">
<TextArea
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
endpointType === EndpointType.CHAT ||
endpointType === EndpointType.RESPONSES ||
endpointType === EndpointType.ANTHROPIC_MESSAGES
? "Type your message... (Shift+Enter for new line)"
: endpointType === EndpointType.IMAGE_EDITS
? "Describe how you want to edit the image..."
: "Describe the image you want to generate..."
}
disabled={isLoading}
className="flex-1"
autoSize={{ minRows: 1, maxRows: 6 }}
style={{ resize: 'none', paddingRight: '10px', paddingLeft: '10px' }}
/>
{isLoading ? (
<TremorButton
onClick={handleCancelRequest}
className="ml-2 bg-red-50 hover:bg-red-100 text-red-600 border-red-200"
icon={DeleteOutlined}
>
Cancel
</TremorButton>
) : (
<TremorButton
onClick={handleSendMessage}
className="ml-2 text-white"
icon={
{(endpointType === EndpointType.CHAT && chatUploadedImage) && (
<div className="mb-2">
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="relative inline-block">
{chatUploadedImage.name.toLowerCase().endsWith('.pdf') ? (
<div className="w-10 h-10 rounded-md bg-red-500 flex items-center justify-center">
<FilePdfOutlined style={{ fontSize: '16px', color: 'white' }} />
</div>
) : (
<img
src={chatImagePreviewUrl || ''}
alt="Upload preview"
className="w-10 h-10 rounded-md border border-gray-200 object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{chatUploadedImage.name}
</div>
<div className="text-xs text-gray-500">
{chatUploadedImage.name.toLowerCase().endsWith('.pdf') ? '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"
onClick={handleRemoveChatImage}
>
<DeleteOutlined style={{ fontSize: '12px' }} />
</button>
</div>
</div>
)}
<div className="flex items-center gap-2">
<div className="flex items-center flex-1 bg-white border border-gray-300 rounded-xl px-3 py-1 min-h-[44px]">
{/* Left: paperclip icon */}
<div className="flex-shrink-0 mr-2">
{endpointType === EndpointType.RESPONSES && !responsesUploadedImage && (
<ResponsesImageUpload
responsesUploadedImage={responsesUploadedImage}
responsesImagePreviewUrl={responsesImagePreviewUrl}
onImageUpload={handleResponsesImageUpload}
onRemoveImage={handleRemoveResponsesImage}
/>
)}
{endpointType === EndpointType.CHAT && !chatUploadedImage && (
<ChatImageUpload
chatUploadedImage={chatUploadedImage}
chatImagePreviewUrl={chatImagePreviewUrl}
onImageUpload={handleChatImageUpload}
onRemoveImage={handleRemoveChatImage}
/>
)}
</div>
{/* Middle: input field */}
<TextArea
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
endpointType === EndpointType.CHAT ||
endpointType === EndpointType.RESPONSES ||
endpointType === EndpointType.ANTHROPIC_MESSAGES
? SendOutlined
: RobotOutlined
? "Type your message... (Shift+Enter for new line)"
: endpointType === EndpointType.IMAGE_EDITS
? "Describe how you want to edit the image..."
: "Describe the image you want to generate..."
}
disabled={isLoading}
className="flex-1"
autoSize={{ minRows: 1, maxRows: 4 }}
style={{
resize: 'none',
border: 'none',
boxShadow: 'none',
background: 'transparent',
padding: '4px 0',
fontSize: '14px',
lineHeight: '20px'
}}
/>
{/* Right: send button - matching blue theme */}
<TremorButton
onClick={handleSendMessage}
disabled={isLoading || !inputMessage.trim()}
className="flex-shrink-0 ml-2 !w-8 !h-8 !min-w-8 !p-0 !rounded-full !bg-blue-600 hover:!bg-blue-700 disabled:!bg-gray-300 !border-none !text-white disabled:!text-gray-500 !flex !items-center !justify-center"
>
{endpointType === EndpointType.CHAT ||
endpointType === EndpointType.RESPONSES ||
endpointType === EndpointType.ANTHROPIC_MESSAGES
? "Send"
: endpointType === EndpointType.IMAGE_EDITS
? "Edit"
: "Generate"}
<ArrowUpOutlined style={{ fontSize: '14px' }} />
</TremorButton>
</div>
{isLoading && (
<TremorButton
onClick={handleCancelRequest}
className="bg-red-50 hover:bg-red-100 text-red-600 border-red-200"
icon={DeleteOutlined}
>
Cancel
</TremorButton>
)}
</div>

View file

@ -0,0 +1,35 @@
import React from "react";
import { MessageType } from "./types";
import { shouldShowChatAttachedImage } from "./ChatImageUtils";
import { FilePdfOutlined } from "@ant-design/icons";
interface ChatImageRendererProps {
message: MessageType;
}
const ChatImageRenderer: React.FC<ChatImageRendererProps> = ({ message }) => {
if (!shouldShowChatAttachedImage(message)) {
return null;
}
const isPdf = typeof message.content === "string" && message.content.includes("[PDF attached]");
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>
) : (
<img
src={message.imagePreviewUrl}
alt="User uploaded image"
className="max-w-64 rounded-md border border-gray-200 shadow-sm"
style={{ maxHeight: '200px' }}
/>
)}
</div>
);
};
export default ChatImageRenderer;

View file

@ -0,0 +1,45 @@
import React from "react";
import { Upload, Tooltip } from "antd";
import { PaperClipOutlined, DeleteOutlined } from "@ant-design/icons";
const { Dragger } = Upload;
interface ChatImageUploadProps {
chatUploadedImage: File | null;
chatImagePreviewUrl: string | null;
onImageUpload: (file: File) => false;
onRemoveImage: () => void;
}
const ChatImageUpload: React.FC<ChatImageUploadProps> = ({
chatUploadedImage,
chatImagePreviewUrl,
onImageUpload,
onRemoveImage,
}) => {
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
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>
)}
</>
);
};
export default ChatImageUpload;

View file

@ -0,0 +1,75 @@
import { MessageType } from "./types";
export interface ChatMultimodalContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
detail?: string;
};
}
export const convertImageToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// For chat completions, we keep the full data URI format
resolve(result);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
export const createChatMultimodalMessage = async (
inputMessage: string,
file: File
): Promise<{ role: string; content: ChatMultimodalContent[] }> => {
const base64DataUri = await convertImageToBase64(file);
return {
role: "user",
content: [
{ type: "text", text: inputMessage },
{
type: "image_url",
image_url: {
url: base64DataUri,
},
},
],
};
};
export const createChatDisplayMessage = (
inputMessage: string,
hasFile: boolean,
filePreviewUrl?: string,
fileName?: string
): MessageType => {
let attachmentText = "";
if (hasFile && fileName) {
attachmentText = fileName.toLowerCase().endsWith('.pdf') ? "[PDF attached]" : "[Image attached]";
}
const displayMessage: MessageType = {
role: "user",
content: hasFile ? `${inputMessage} ${attachmentText}` : inputMessage
};
if (hasFile && filePreviewUrl) {
displayMessage.imagePreviewUrl = filePreviewUrl;
}
return displayMessage;
};
export const shouldShowChatAttachedImage = (message: MessageType): boolean => {
return (
message.role === "user" &&
typeof message.content === "string" &&
(message.content.includes("[Image attached]") || message.content.includes("[PDF attached]")) &&
!!message.imagePreviewUrl
);
};

View file

@ -84,14 +84,48 @@ client = openai.OpenAI(
extraBodyCode = `,\n extra_body=${indentedExtraBodyString}`;
}
// Create example for chat completions with optional image support
const messagesExample = messages.length > 0 ? messages : [{ role: "user", content: userPrompt }];
endpointSpecificCode = `
# request sent to model set on litellm proxy, \`litellm --model\`
import base64
# Helper function to encode images to base64
def encode_image(image_path):
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
# Example with text only
response = client.chat.completions.create(
model="${modelNameForCode}",
messages = ${JSON.stringify(messages, null, 4)}${extraBodyCode}
model="${modelNameForCode}",
messages=${JSON.stringify(messagesExample, null, 4)}${extraBodyCode}
)
print(response)
# Example with image or PDF (uncomment and provide file path to use)
# base64_file = encode_image("path/to/your/file.jpg") # or .pdf
# response_with_file = client.chat.completions.create(
# model="${modelNameForCode}",
# messages=[
# {
# "role": "user",
# "content": [
# {
# "type": "text",
# "text": "${safePrompt}"
# },
# {
# "type": "image_url",
# "image_url": {
# "url": f"data:image/jpeg;base64,{base64_file}" # or data:application/pdf;base64,{base64_file}
# }
# }
# ]
# }
# ]${extraBodyCode}
# )
# print(response_with_file)
`;
break;
}
@ -106,14 +140,43 @@ print(response)
extraBodyCode = `,\n extra_body=${indentedExtraBodyString}`;
}
// Create example for responses API with optional image support
const inputExample = messages.length > 0 ? messages : [{ role: "user", content: userPrompt }];
endpointSpecificCode = `
# request sent to model set on litellm proxy, \`litellm --model\`
import base64
# Helper function to encode images to base64
def encode_image(image_path):
with open(image_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
# Example with text only
response = client.responses.create(
model="${modelNameForCode}",
messages = ${JSON.stringify(messages, null, 4)}${extraBodyCode}
model="${modelNameForCode}",
input=${JSON.stringify(inputExample, null, 4)}${extraBodyCode}
)
print(response)
print(response.output_text)
# Example with image or PDF (uncomment and provide file path to use)
# base64_file = encode_image("path/to/your/file.jpg") # or .pdf
# response_with_file = client.responses.create(
# model="${modelNameForCode}",
# input=[
# {
# "role": "user",
# "content": [
# {"type": "input_text", "text": "${safePrompt}"},
# {
# "type": "input_image",
# "image_url": f"data:image/jpeg;base64,{base64_file}", # or data:application/pdf;base64,{base64_file}
# },
# ],
# }
# ]${extraBodyCode}
# )
# print(response_with_file.output_text)
`;
break;
}

View file

@ -0,0 +1,35 @@
import React from "react";
import { MessageType } from "./types";
import { shouldShowAttachedImage } from "./ResponsesImageUtils";
import { FilePdfOutlined } from "@ant-design/icons";
interface ResponsesImageRendererProps {
message: MessageType;
}
const ResponsesImageRenderer: React.FC<ResponsesImageRendererProps> = ({ message }) => {
if (!shouldShowAttachedImage(message)) {
return null;
}
const isPdf = typeof message.content === "string" && message.content.includes("[PDF attached]");
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>
) : (
<img
src={message.imagePreviewUrl}
alt="User uploaded image"
className="max-w-64 rounded-md border border-gray-200 shadow-sm"
style={{ maxHeight: '200px' }}
/>
)}
</div>
);
};
export default ResponsesImageRenderer;

View file

@ -0,0 +1,45 @@
import React from "react";
import { Upload, Tooltip } from "antd";
import { PaperClipOutlined, DeleteOutlined } from "@ant-design/icons";
const { Dragger } = Upload;
interface ResponsesImageUploadProps {
responsesUploadedImage: File | null;
responsesImagePreviewUrl: string | null;
onImageUpload: (file: File) => false;
onRemoveImage: () => void;
}
const ResponsesImageUpload: React.FC<ResponsesImageUploadProps> = ({
responsesUploadedImage,
responsesImagePreviewUrl,
onImageUpload,
onRemoveImage,
}) => {
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
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>
)}
</>
);
};
export default ResponsesImageUpload;

View file

@ -0,0 +1,66 @@
import { MessageType, MultimodalContent } from "./types";
export const convertImageToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// Extract just the base64 data (remove the data:image/...;base64, prefix)
const base64Data = result.split(',')[1];
resolve(base64Data);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
export const createMultimodalMessage = async (
inputMessage: string,
file: File
): Promise<{ role: string; content: MultimodalContent[] }> => {
const base64Data = await convertImageToBase64(file);
const mimeType = file.type || (file.name.toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg');
return {
role: "user",
content: [
{ type: "input_text", text: inputMessage },
{
type: "input_image",
image_url: `data:${mimeType};base64,${base64Data}`,
},
],
};
};
export const createDisplayMessage = (
inputMessage: string,
hasFile: boolean,
filePreviewUrl?: string,
fileName?: string
): MessageType => {
let attachmentText = "";
if (hasFile && fileName) {
attachmentText = fileName.toLowerCase().endsWith('.pdf') ? "[PDF attached]" : "[Image attached]";
}
const displayMessage: MessageType = {
role: "user",
content: hasFile ? `${inputMessage} ${attachmentText}` : inputMessage
};
if (hasFile && filePreviewUrl) {
displayMessage.imagePreviewUrl = filePreviewUrl;
}
return displayMessage;
};
export const shouldShowAttachedImage = (message: MessageType): boolean => {
return (
message.role === "user" &&
typeof message.content === "string" &&
(message.content.includes("[Image attached]") || message.content.includes("[PDF attached]")) &&
!!message.imagePreviewUrl
);
};

View file

@ -5,7 +5,7 @@ import { TokenUsage } from "../ResponseMetrics";
import { getProxyBaseUrl } from "@/components/networking";
export async function makeOpenAIChatCompletionRequest(
chatHistory: { role: string; content: string }[],
chatHistory: { role: string; content: string | any[] }[],
updateUI: (chunk: string, model?: string) => void,
selectedModel: string,
accessToken: string,

View file

@ -49,11 +49,22 @@ export async function makeOpenAIResponsesRequest(
let firstTokenReceived = false;
// Format messages for the API
const formattedInput = messages.map(message => ({
role: message.role,
content: message.content,
type: "message"
}));
const formattedInput = messages.map(message => {
// If content is already an array (multimodal), use it directly
if (Array.isArray(message.content)) {
return {
role: message.role,
content: message.content,
type: "message"
};
}
// Otherwise, wrap text content in the expected format
return {
role: message.role,
content: message.content,
type: "message"
};
});
// Format MCP tool if selected
const tools = selectedMCPTool ? [{

View file

@ -54,7 +54,7 @@ export interface StreamingResponse {
export interface MessageType {
role: string;
content: string;
content: string | MultimodalContent[];
model?: string;
isImage?: boolean;
reasoningContent?: string;
@ -66,4 +66,11 @@ export interface MessageType {
reasoningTokens?: number;
};
toolName?: string;
imagePreviewUrl?: string; // For storing image preview URL in chat history
}
export interface MultimodalContent {
type: "input_text" | "input_image";
text?: string;
image_url?: string;
}