diff --git a/package-lock.json b/package-lock.json index d58d674..c43d16a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "SnapMind", - "version": "0.5.2", + "version": "0.5.3-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "SnapMind", - "version": "0.5.2", + "version": "0.5.3-beta.1", "license": "Apache-2.0", "dependencies": { "@heroui/react": "^2.8.9", diff --git a/package.json b/package.json index 9d634a1..517216e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "SnapMind", "private": true, - "version": "0.5.2", + "version": "0.5.3-beta.1", "description": "A quick prompt launcher.", "author": "Louis Liu", "license": "Apache-2.0", 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/__tests__/useChatMessage.test.ts b/src/hooks/__tests__/useChatMessage.test.ts index bef2add..d91de03 100644 --- a/src/hooks/__tests__/useChatMessage.test.ts +++ b/src/hooks/__tests__/useChatMessage.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { parseThinkingBlocks } from '../useChatMessage'; +import { renderHook } from '@testing-library/react'; +import { parseThinkingBlocks, useChatMessage } from '../useChatMessage'; +import { ContentPart } from '@/types/chat'; describe('parseThinkingBlocks', () => { it('returns plain content when there are no blocks', () => { @@ -114,3 +116,44 @@ describe('parseThinkingBlocks', () => { }); }); }); + +describe('useChatMessage', () => { + it('returns plain text for a user string message', () => { + const { result } = renderHook(() => useChatMessage('hello world', true)); + expect(result.current).toEqual({ thinking: '', main: 'hello world', isThinking: false }); + }); + + it('parses thinking blocks for an assistant string message', () => { + const { result } = renderHook(() => + useChatMessage('reasoninganswer', false) + ); + expect(result.current.thinking).toBe('reasoning'); + expect(result.current.main).toBe('answer'); + }); + + it('extracts text from ContentPart[] for a user message', () => { + const parts: ContentPart[] = [ + { type: 'text', text: 'describe this' }, + { type: 'image', data: 'base64data', mimeType: 'image/png' }, + ]; + const { result } = renderHook(() => useChatMessage(parts, true)); + expect(result.current).toEqual({ + thinking: '', + main: 'describe this', + isThinking: false, + }); + }); + + it('extracts text from ContentPart[] for an assistant message and parses thinking', () => { + const parts: ContentPart[] = [{ type: 'text', text: 'stepresult' }]; + const { result } = renderHook(() => useChatMessage(parts, false)); + expect(result.current.thinking).toBe('step'); + expect(result.current.main).toBe('result'); + }); + + it('returns empty main when ContentPart[] has only images', () => { + const parts: ContentPart[] = [{ type: 'image', data: 'abc', mimeType: 'image/jpeg' }]; + const { result } = renderHook(() => useChatMessage(parts, true)); + expect(result.current.main).toBe(''); + }); +}); 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 && }
-
+
+