From 92382c10d33907ab786e8fd249b9e23cfc68d707 Mon Sep 17 00:00:00 2001 From: Louis-7 Date: Thu, 11 Jun 2026 14:19:44 +0800 Subject: [PATCH 1/5] feat(chat): add image attachment support for all providers Enable users to attach images to chat messages via file picker button, clipboard paste, and drag-and-drop. Images are sent as base64 content parts to all provider APIs (OpenAI, Azure, Anthropic, Google, Foundry, Ollama) using each provider's native multimodal format. --- src/components/Icon.tsx | 4 + src/hooks/useChatMessage.ts | 14 +- src/pages/ChatMessage/ChatMessage.tsx | 23 ++- src/pages/ChatPopup/ChatPopup.tsx | 154 +++++++++++++++++- .../adapters/anthropicRequestBuilder.ts | 7 +- .../providers/adapters/azureRequestBuilder.ts | 10 +- .../adapters/foundryRequestBuilder.ts | 16 +- .../adapters/googleRequestBuilder.ts | 17 +- .../adapters/ollamaRequestBuilder.ts | 3 +- .../adapters/openaiRequestBuilder.ts | 10 +- src/services/providers/core/messageUtils.ts | 65 ++++++++ src/types/chat.d.ts | 15 +- 12 files changed, 307 insertions(+), 31 deletions(-) create mode 100644 src/services/providers/core/messageUtils.ts diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index c2d7dbc..328e056 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -8,6 +8,7 @@ import { LuInfo, LuGithub, LuHeart, + LuImage, LuTrash2, LuSquarePen, LuPlus, @@ -57,6 +58,7 @@ type IconType = | 'info' | 'github' | 'heart' + | 'image' | 'trash-2' | 'square-pen' | 'square-dashed' @@ -125,6 +127,8 @@ function Icon({ return ; case 'heart': return ; + case 'image': + return ; case 'trash-2': return ; case 'square-pen': diff --git a/src/hooks/useChatMessage.ts b/src/hooks/useChatMessage.ts index d6c635a..26113b4 100644 --- a/src/hooks/useChatMessage.ts +++ b/src/hooks/useChatMessage.ts @@ -1,4 +1,6 @@ import { useMemo } from 'react'; +import { ContentPart } from '@/types/chat'; +import { getTextContent } from '@/services/providers/core/messageUtils'; export interface ParsedChatMessage { thinking: string; @@ -38,13 +40,17 @@ export function parseThinkingBlocks(content: string): ParsedChatMessage { } /** - * Parse a chat message content string, splitting it into + * Parse a chat message content, splitting it into * thinking blocks (for reasoning models) and main content. */ -export function useChatMessage(content: string, isUser: boolean): ParsedChatMessage { +export function useChatMessage( + content: string | ContentPart[], + isUser: boolean +): ParsedChatMessage { + const text = typeof content === 'string' ? content : getTextContent(content); return useMemo( () => - isUser ? { thinking: '', main: content, isThinking: false } : parseThinkingBlocks(content), - [content, isUser] + isUser ? { thinking: '', main: text, isThinking: false } : parseThinkingBlocks(text), + [text, isUser] ); } diff --git a/src/pages/ChatMessage/ChatMessage.tsx b/src/pages/ChatMessage/ChatMessage.tsx index 39b2b38..ee690f5 100644 --- a/src/pages/ChatMessage/ChatMessage.tsx +++ b/src/pages/ChatMessage/ChatMessage.tsx @@ -4,7 +4,7 @@ import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; import rehypeHighlight from 'rehype-highlight'; -import { Message } from '@/types/chat'; +import { Message, ImageContentPart } from '@/types/chat'; import ThinkingMessage from '@/components/ThinkingMessage'; import MessageWebSources from '@/components/MessageWebSources'; import { useChatMessage } from '@/hooks/useChatMessage'; @@ -13,6 +13,24 @@ interface ChatMessageProps { message: Message; } +function MessageImages({ content }: { content: Message['content'] }) { + if (typeof content === 'string') return null; + const images = content.filter((p): p is ImageContentPart => p.type === 'image'); + if (images.length === 0) return null; + return ( +
+ {images.map((img, i) => ( + Attached image + ))} +
+ ); +} + export default function ChatMessage({ message }: ChatMessageProps) { const isUser = message.role === 'user'; const { thinking, main, isThinking } = useChatMessage(message.content, isUser); @@ -23,7 +41,7 @@ export default function ChatMessage({ message }: ChatMessageProps) { // Specific styles for each role const userBubbleClasses = 'max-w-[80%] rounded-2xl shadow-sm bg-primary text-primary-foreground rounded-br-sm'; - const aiBubbleClasses = 'w-full markdown-body bg-background'; // Added markdown-body class for GitHub styling + const aiBubbleClasses = 'w-full markdown-body bg-background'; return (
{!isUser && } + { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + const base64 = dataUrl.split(',')[1]; + resolve({ data: base64, mimeType: file.type, name: file.name }); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +function isImageFile(file: File): boolean { + return file.type.startsWith('image/'); +} + interface ChatPopupProps { initialMessage?: Message | Message[]; } @@ -35,6 +58,9 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) { const [autoScroll, setAutoScroll] = useState(true); const [reasoningEnabled, setReasoningEnabled] = useState(settings.chat.reasoningEnabled ?? false); const [webSearchEnabled, setWebSearchEnabled] = useState(settings.chat.webSearchEnabled ?? false); + const [images, setImages] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); // Sync local state when settings change externally (e.g. from Settings page) useEffect(() => { @@ -216,12 +242,87 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) { )); }; + const addImages = useCallback(async (files: File[]) => { + const imageFiles = files.filter(isImageFile); + if (imageFiles.length === 0) return; + const newAttachments = await Promise.all(imageFiles.map(readFileAsBase64)); + setImages((prev) => [...prev, ...newAttachments]); + }, []); + + const removeImage = useCallback((index: number) => { + setImages((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + const imageFiles: File[] = []; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) imageFiles.push(file); + } + } + if (imageFiles.length > 0) { + e.preventDefault(); + addImages(imageFiles); + } + }, + [addImages] + ); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const files = Array.from(e.dataTransfer.files); + addImages(files); + }, + [addImages] + ); + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + addImages(files); + e.target.value = ''; + }, + [addImages] + ); + const handleSend = async () => { - if (!input.trim() || loading) return; - const userMsg: Message = { role: 'user', content: input }; + if ((!input.trim() && images.length === 0) || loading) return; + + let content: string | ContentPart[]; + if (images.length > 0) { + const parts: ContentPart[] = []; + if (input.trim()) { + parts.push({ type: 'text', text: input }); + } + for (const img of images) { + parts.push({ type: 'image', data: img.data, mimeType: img.mimeType }); + } + content = parts; + } else { + content = input; + } + + const userMsg: Message = { role: 'user', content }; // First update state to add user message setInput(''); + setImages([]); setMessages((msgs) => [...msgs, userMsg]); // Then call AI with the updated conversation history @@ -292,7 +393,20 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) { {loading && }
-
+
+