Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
LuInfo,
LuGithub,
LuHeart,
LuImage,
LuTrash2,
LuSquarePen,
LuPlus,
Expand Down Expand Up @@ -57,6 +58,7 @@ type IconType =
| 'info'
| 'github'
| 'heart'
| 'image'
| 'trash-2'
| 'square-pen'
| 'square-dashed'
Expand Down Expand Up @@ -125,6 +127,8 @@ function Icon({
return <LuGithub className={svgClassName} color={color} size={size} />;
case 'heart':
return <LuHeart className={svgClassName} color={color} size={size} />;
case 'image':
return <LuImage className={svgClassName} color={color} size={size} />;
case 'trash-2':
return <LuTrash2 className={svgClassName} color={color} size={size} />;
case 'square-pen':
Expand Down
45 changes: 44 additions & 1 deletion src/hooks/__tests__/useChatMessage.test.ts
Original file line number Diff line number Diff line change
@@ -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 <think> blocks', () => {
Expand Down Expand Up @@ -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('<think>reasoning</think>answer', 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: '<think>step</think>result' }];
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('');
});
});
14 changes: 10 additions & 4 deletions src/hooks/useChatMessage.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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]
);
}
23 changes: 21 additions & 2 deletions src/pages/ChatMessage/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 (
<div className="flex flex-row flex-wrap gap-2 mb-1">
{images.map((img, i) => (
<img
key={i}
src={`data:${img.mimeType};base64,${img.data}`}
alt="Attached image"
className="max-w-[200px] max-h-[200px] rounded-lg object-cover"
/>
))}
</div>
);
}

export default function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === 'user';
const { thinking, main, isThinking } = useChatMessage(message.content, isUser);
Expand All @@ -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 (
<div
Expand All @@ -34,6 +52,7 @@ export default function ChatMessage({ message }: ChatMessageProps) {
className={`relative ${bubbleBaseClasses} ${isUser ? userBubbleClasses : aiBubbleClasses}`}
>
{!isUser && <ThinkingMessage thinking={thinking} isThinking={isThinking} />}
<MessageImages content={message.content} />
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize, rehypeHighlight]}
Expand Down
154 changes: 149 additions & 5 deletions src/pages/ChatPopup/ChatPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,36 @@ import { useSettings } from '../../hooks/useSettings';
import { AIService } from '../../services/AIService';
import ChatMessage from '../ChatMessage/ChatMessage';

import { Message, ChatSource } from '@/types/chat';
import { Message, ChatSource, ContentPart } from '@/types/chat';
import { useTranslation } from 'react-i18next';
import Icon from '../../components/Icon';
import ReasoningToggle from '@/components/ReasoningToggle';
import WebSearchToggle from '@/components/WebSearchToggle';
import { BaseProviderConfig, ProviderType } from '@/types/providers';

interface ImageAttachment {
data: string;
mimeType: string;
name: string;
}

function readFileAsBase64(file: File): Promise<ImageAttachment> {
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);
Comment thread
Louis-7 marked this conversation as resolved.
});
}

function isImageFile(file: File): boolean {
return file.type.startsWith('image/');
}

interface ChatPopupProps {
initialMessage?: Message | Message[];
}
Expand All @@ -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<ImageAttachment[]>([]);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);

// Sync local state when settings change externally (e.g. from Settings page)
useEffect(() => {
Expand Down Expand Up @@ -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]);
}, []);
Comment thread
Louis-7 marked this conversation as resolved.

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<HTMLInputElement>) => {
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
Expand Down Expand Up @@ -292,7 +393,20 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) {
{loading && <ChatMessage message={{ role: 'assistant', content: '...' }} />}
<div ref={chatEndRef} />
</div>
<div className="flex flex-col p-3 bg-default-100 gap-2 shadow-medium mb-3 rounded-2xl w-[calc(100%-var(--spacing)*6)] m-[0_auto]">
<div
className={`flex flex-col p-3 bg-default-100 gap-2 shadow-medium mb-3 rounded-2xl w-[calc(100%-var(--spacing)*6)] m-[0_auto] transition-colors ${isDragging ? 'ring-2 ring-primary bg-primary/10' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<Textarea
className="flex-1"
classNames={{
Expand All @@ -306,9 +420,39 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) {
maxRows={5}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
ref={inputRef}
/>
{images.length > 0 && (
<div className="flex flex-row gap-2 overflow-x-auto p-2">
{images.map((img, i) => (
<div key={i} className="relative flex-shrink-0 group">
<img
src={`data:${img.mimeType};base64,${img.data}`}
alt={img.name}
className="w-16 h-16 object-cover rounded-lg border border-default-200"
/>
<button
className="absolute -top-1.5 -right-1.5 bg-danger text-white rounded-full w-5 h-5 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeImage(i)}
aria-label="Remove image"
>
<Icon icon="circle-x" size={14} />
</button>
</div>
))}
</div>
)}
<div className="flex-shrink-0 flex flex-row justify-end gap-2 items-center">
<Button
isIconOnly
variant="light"
size="sm"
onPress={() => fileInputRef.current?.click()}
aria-label="Attach image"
>
<Icon icon="image" size={18} />
</Button>
<WebSearchToggle
aria-label={t('settings.chat.webSearch')}
isSelected={webSearchEnabled}
Expand Down Expand Up @@ -362,7 +506,7 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) {
isIconOnly
color="primary"
onPress={handleSend}
disabled={loading || !input.trim()}
disabled={loading || (!input.trim() && images.length === 0)}
aria-label="Send message"
>
<Icon icon="arrow-up"></Icon>
Expand Down
Loading
Loading