mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
7e2a00c848
commit
840db3fe48
12 changed files with 677 additions and 104 deletions
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ? [{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue