Add screenshot paste support (#2679)

This commit is contained in:
Max Novich
2025-05-28 11:11:22 -07:00
committed by GitHub
parent d8fa740067
commit feb7b15c76
10 changed files with 828 additions and 58 deletions

View File

@@ -2,12 +2,24 @@ import React, { useRef, useState, useEffect, useCallback } from 'react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import type { View } from '../App'; import type { View } from '../App';
import Stop from './ui/Stop'; import Stop from './ui/Stop';
import { Attach, Send } from './icons'; import { Attach, Send, Close } from './icons';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import BottomMenu from './bottom_menu/BottomMenu'; import BottomMenu from './bottom_menu/BottomMenu';
import { LocalMessageStorage } from '../utils/localMessageStorage'; import { LocalMessageStorage } from '../utils/localMessageStorage';
import { Message } from '../types/message'; import { Message } from '../types/message';
interface PastedImage {
id: string;
dataUrl: string; // For immediate preview
filePath?: string; // Path on filesystem after saving
isLoading: boolean;
error?: string;
}
// Constants for image handling
const MAX_IMAGES_PER_MESSAGE = 5;
const MAX_IMAGE_SIZE_MB = 5;
interface ChatInputProps { interface ChatInputProps {
handleSubmit: (e: React.FormEvent) => void; handleSubmit: (e: React.FormEvent) => void;
isLoading?: boolean; isLoading?: boolean;
@@ -37,15 +49,28 @@ export default function ChatInput({
const [_value, setValue] = useState(initialValue); const [_value, setValue] = useState(initialValue);
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [pastedImages, setPastedImages] = useState<PastedImage[]>([]);
// Update internal value when initialValue changes // Update internal value when initialValue changes
useEffect(() => { useEffect(() => {
setValue(initialValue); setValue(initialValue);
setDisplayValue(initialValue); setDisplayValue(initialValue);
// Use a functional update to get the current pastedImages
// and perform cleanup. This avoids needing pastedImages in the deps.
setPastedImages((currentPastedImages) => {
currentPastedImages.forEach((img) => {
if (img.filePath) {
window.electron.deleteTempFile(img.filePath);
}
});
return []; // Return a new empty array
});
// Reset history index when input is cleared // Reset history index when input is cleared
setHistoryIndex(-1); setHistoryIndex(-1);
setIsInGlobalHistory(false); setIsInGlobalHistory(false);
}, [initialValue]); }, [initialValue]); // Keep only initialValue as a dependency
// State to track if the IME is composing (i.e., in the middle of Japanese IME input) // State to track if the IME is composing (i.e., in the middle of Japanese IME input)
const [isComposing, setIsComposing] = useState(false); const [isComposing, setIsComposing] = useState(false);
@@ -55,6 +80,48 @@ export default function ChatInput({
const textAreaRef = useRef<HTMLTextAreaElement>(null); const textAreaRef = useRef<HTMLTextAreaElement>(null);
const [processedFilePaths, setProcessedFilePaths] = useState<string[]>([]); const [processedFilePaths, setProcessedFilePaths] = useState<string[]>([]);
const handleRemovePastedImage = (idToRemove: string) => {
const imageToRemove = pastedImages.find((img) => img.id === idToRemove);
if (imageToRemove?.filePath) {
window.electron.deleteTempFile(imageToRemove.filePath);
}
setPastedImages((currentImages) => currentImages.filter((img) => img.id !== idToRemove));
};
const handleRetryImageSave = async (imageId: string) => {
const imageToRetry = pastedImages.find((img) => img.id === imageId);
if (!imageToRetry || !imageToRetry.dataUrl) return;
// Set the image to loading state
setPastedImages((prev) =>
prev.map((img) =>
img.id === imageId
? { ...img, isLoading: true, error: undefined }
: img
)
);
try {
const result = await window.electron.saveDataUrlToTemp(imageToRetry.dataUrl, imageId);
setPastedImages((prev) =>
prev.map((img) =>
img.id === result.id
? { ...img, filePath: result.filePath, error: result.error, isLoading: false }
: img
)
);
} catch (err) {
console.error('Error retrying image save:', err);
setPastedImages((prev) =>
prev.map((img) =>
img.id === imageId
? { ...img, error: 'Failed to save image via Electron.', isLoading: false }
: img
)
);
}
};
useEffect(() => { useEffect(() => {
if (textAreaRef.current) { if (textAreaRef.current) {
textAreaRef.current.focus(); textAreaRef.current.focus();
@@ -111,6 +178,89 @@ export default function ChatInput({
debouncedSetValue(val); // Debounce the actual state update debouncedSetValue(val); // Debounce the actual state update
}; };
const handlePaste = async (evt: React.ClipboardEvent<HTMLTextAreaElement>) => {
const files = Array.from(evt.clipboardData.files || []);
const imageFiles = files.filter(file => file.type.startsWith('image/'));
if (imageFiles.length === 0) return;
// Check if adding these images would exceed the limit
if (pastedImages.length + imageFiles.length > MAX_IMAGES_PER_MESSAGE) {
// Show error message to user
setPastedImages((prev) => [
...prev,
{
id: `error-${Date.now()}`,
dataUrl: '',
isLoading: false,
error: `Cannot paste ${imageFiles.length} image(s). Maximum ${MAX_IMAGES_PER_MESSAGE} images per message allowed.`
}
]);
// Remove the error message after 3 seconds
setTimeout(() => {
setPastedImages((prev) => prev.filter(img => !img.id.startsWith('error-')));
}, 3000);
return;
}
evt.preventDefault();
for (const file of imageFiles) {
// Check individual file size before processing
if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) {
const errorId = `error-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
setPastedImages((prev) => [
...prev,
{
id: errorId,
dataUrl: '',
isLoading: false,
error: `Image too large (${Math.round(file.size / (1024 * 1024))}MB). Maximum ${MAX_IMAGE_SIZE_MB}MB allowed.`
}
]);
// Remove the error message after 3 seconds
setTimeout(() => {
setPastedImages((prev) => prev.filter(img => img.id !== errorId));
}, 3000);
continue;
}
const reader = new FileReader();
reader.onload = async (e) => {
const dataUrl = e.target?.result as string;
if (dataUrl) {
const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
setPastedImages((prev) => [...prev, { id: imageId, dataUrl, isLoading: true }]);
try {
const result = await window.electron.saveDataUrlToTemp(dataUrl, imageId);
setPastedImages((prev) =>
prev.map((img) =>
img.id === result.id
? { ...img, filePath: result.filePath, error: result.error, isLoading: false }
: img
)
);
} catch (err) {
console.error('Error saving pasted image:', err);
setPastedImages((prev) =>
prev.map((img) =>
img.id === imageId
? { ...img, error: 'Failed to save image via Electron.', isLoading: false }
: img
)
);
}
}
};
reader.readAsDataURL(file);
}
};
// Cleanup debounced functions on unmount // Cleanup debounced functions on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -197,6 +347,36 @@ export default function ChatInput({
} }
}; };
const performSubmit = () => {
const validPastedImageFilesPaths = pastedImages
.filter((img) => img.filePath && !img.error && !img.isLoading)
.map((img) => img.filePath as string);
let textToSend = displayValue.trim();
if (validPastedImageFilesPaths.length > 0) {
const pathsString = validPastedImageFilesPaths.join(' ');
textToSend = textToSend ? `${textToSend} ${pathsString}` : pathsString;
}
if (textToSend) {
if (displayValue.trim()) {
LocalMessageStorage.addMessage(displayValue);
} else if (validPastedImageFilesPaths.length > 0) {
LocalMessageStorage.addMessage(validPastedImageFilesPaths.join(' '));
}
handleSubmit(new CustomEvent('submit', { detail: { value: textToSend } }));
setDisplayValue('');
setValue('');
setPastedImages([]);
setHistoryIndex(-1);
setSavedInput('');
setIsInGlobalHistory(false);
}
};
const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Handle history navigation first // Handle history navigation first
handleHistoryNavigation(evt); handleHistoryNavigation(evt);
@@ -207,6 +387,7 @@ export default function ChatInput({
// Allow line break for Shift+Enter, or during IME composition // Allow line break for Shift+Enter, or during IME composition
return; return;
} }
if (evt.altKey) { if (evt.altKey) {
const newValue = displayValue + '\n'; const newValue = displayValue + '\n';
setDisplayValue(newValue); setDisplayValue(newValue);
@@ -214,44 +395,31 @@ export default function ChatInput({
return; return;
} }
// Prevent default Enter behavior when loading or when not loading but has content
// So it won't trigger a new line
evt.preventDefault(); evt.preventDefault();
const canSubmit =
// Only submit if not loading and has content !isLoading &&
if (!isLoading && displayValue.trim()) { (displayValue.trim() ||
// Always add to global chat storage before submitting pastedImages.some((img) => img.filePath && !img.error && !img.isLoading));
LocalMessageStorage.addMessage(displayValue); if (canSubmit) {
performSubmit();
handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } }));
setDisplayValue('');
setValue('');
setHistoryIndex(-1);
setSavedInput('');
setIsInGlobalHistory(false);
} }
} }
}; };
const onFormSubmit = (e: React.FormEvent) => { const onFormSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (displayValue.trim() && !isLoading) { const canSubmit =
// Always add to global chat storage before submitting !isLoading &&
LocalMessageStorage.addMessage(displayValue); (displayValue.trim() ||
pastedImages.some((img) => img.filePath && !img.error && !img.isLoading));
handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } })); if (canSubmit) {
setDisplayValue(''); performSubmit();
setValue('');
setHistoryIndex(-1);
setSavedInput('');
setIsInGlobalHistory(false);
} }
}; };
const handleFileSelect = async () => { const handleFileSelect = async () => {
const path = await window.electron.selectFileOrDirectory(); const path = await window.electron.selectFileOrDirectory();
if (path) { if (path) {
// Append the path to existing text, with a space if there's existing text
const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path; const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path;
setDisplayValue(newValue); setDisplayValue(newValue);
setValue(newValue); setValue(newValue);
@@ -259,6 +427,10 @@ export default function ChatInput({
} }
}; };
const hasSubmittableContent =
displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading);
const isAnyImageLoading = pastedImages.some((img) => img.isLoading);
return ( return (
<div <div
className={`flex flex-col relative h-auto border rounded-lg transition-colors ${ className={`flex flex-col relative h-auto border rounded-lg transition-colors ${
@@ -278,6 +450,7 @@ export default function ChatInput({
onCompositionStart={handleCompositionStart} onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd} onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
ref={textAreaRef} ref={textAreaRef}
@@ -290,6 +463,54 @@ export default function ChatInput({
className="w-full pl-4 pr-[68px] outline-none border-none focus:ring-0 bg-transparent pt-3 pb-1.5 text-sm resize-none text-textStandard placeholder:text-textPlaceholder" className="w-full pl-4 pr-[68px] outline-none border-none focus:ring-0 bg-transparent pt-3 pb-1.5 text-sm resize-none text-textStandard placeholder:text-textPlaceholder"
/> />
{pastedImages.length > 0 && (
<div className="flex flex-wrap gap-2 p-2 border-t border-borderSubtle">
{pastedImages.map((img) => (
<div key={img.id} className="relative group w-20 h-20">
{img.dataUrl && (
<img
src={img.dataUrl} // Use dataUrl for instant preview
alt={`Pasted image ${img.id}`}
className={`w-full h-full object-cover rounded border ${img.error ? 'border-red-500' : 'border-borderStandard'}`}
/>
)}
{img.isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 rounded">
<div className="animate-spin rounded-full h-6 w-6 border-t-2 border-b-2 border-white"></div>
</div>
)}
{img.error && !img.isLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black bg-opacity-75 rounded p-1 text-center">
<p className="text-red-400 text-[10px] leading-tight break-all mb-1">
{img.error.substring(0, 50)}
</p>
{img.dataUrl && (
<button
type="button"
onClick={() => handleRetryImageSave(img.id)}
className="bg-blue-600 hover:bg-blue-700 text-white rounded px-1 py-0.5 text-[8px] leading-none"
title="Retry saving image"
>
Retry
</button>
)}
</div>
)}
{!img.isLoading && (
<button
type="button"
onClick={() => handleRemovePastedImage(img.id)}
className="absolute -top-1 -right-1 bg-gray-700 hover:bg-red-600 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs leading-none opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity z-10"
aria-label="Remove image"
>
<Close size={14} />
</button>
)}
</div>
))}
</div>
)}
{isLoading ? ( {isLoading ? (
<Button <Button
type="button" type="button"
@@ -309,12 +530,13 @@ export default function ChatInput({
type="submit" type="submit"
size="icon" size="icon"
variant="ghost" variant="ghost"
disabled={!displayValue.trim()} disabled={!hasSubmittableContent || isAnyImageLoading} // Disable if no content or if images are still loading/saving
className={`absolute right-3 top-2 transition-colors rounded-full w-7 h-7 [&_svg]:size-4 ${ className={`absolute right-3 top-2 transition-colors rounded-full w-7 h-7 [&_svg]:size-4 ${
!displayValue.trim() !hasSubmittableContent || isAnyImageLoading
? 'text-textSubtle cursor-not-allowed' ? 'text-textSubtle cursor-not-allowed'
: 'bg-bgAppInverse text-textProminentInverse hover:cursor-pointer' : 'bg-bgAppInverse text-textProminentInverse hover:cursor-pointer'
}`} }`}
title={isAnyImageLoading ? 'Waiting for images to save...' : 'Send'}
> >
<Send /> <Send />
</Button> </Button>

View File

@@ -296,13 +296,17 @@ function ChatContent({
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
window.electron.startPowerSaveBlocker(); window.electron.startPowerSaveBlocker();
const customEvent = e as unknown as CustomEvent; const customEvent = e as unknown as CustomEvent;
const content = customEvent.detail?.value || ''; // ChatInput now sends a single 'value' field with text and appended image paths
const combinedTextFromInput = customEvent.detail?.value || '';
if (content.trim()) { if (combinedTextFromInput.trim()) {
setLastInteractionTime(Date.now()); setLastInteractionTime(Date.now());
// createUserMessage was reverted to only accept text.
// It will create a Message with a single TextContent part containing text + paths.
const userMessage = createUserMessage(combinedTextFromInput.trim());
if (summarizedThread.length > 0) { if (summarizedThread.length > 0) {
// move current `messages` to `ancestorMessages` and `messages` to `summarizedThread`
resetMessagesWithSummary( resetMessagesWithSummary(
messages, messages,
setMessages, setMessages,
@@ -310,23 +314,21 @@ function ChatContent({
setAncestorMessages, setAncestorMessages,
summaryContent summaryContent
); );
// update the chat with new sessionId
// now call the llm
setTimeout(() => { setTimeout(() => {
append(createUserMessage(content)); append(userMessage);
if (scrollRef.current?.scrollToBottom) { if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom(); scrollRef.current.scrollToBottom();
} }
}, 150); }, 150);
} else { } else {
// Normal flow (existing code) append(userMessage);
append(createUserMessage(content));
if (scrollRef.current?.scrollToBottom) { if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom(); scrollRef.current.scrollToBottom();
} }
} }
} else {
// If nothing was actually submitted (e.g. empty input and no images pasted)
window.electron.stopPowerSaveBlocker();
} }
}; };

View File

@@ -1,7 +1,9 @@
import React, { useEffect, useMemo, useRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import LinkPreview from './LinkPreview'; import LinkPreview from './LinkPreview';
import ImagePreview from './ImagePreview';
import GooseResponseForm from './GooseResponseForm'; import GooseResponseForm from './GooseResponseForm';
import { extractUrls } from '../utils/urlUtils'; import { extractUrls } from '../utils/urlUtils';
import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils';
import { formatMessageTimestamp } from '../utils/timeUtils'; import { formatMessageTimestamp } from '../utils/timeUtils';
import MarkdownContent from './MarkdownContent'; import MarkdownContent from './MarkdownContent';
import ToolCallWithResponse from './ToolCallWithResponse'; import ToolCallWithResponse from './ToolCallWithResponse';
@@ -40,6 +42,13 @@ export default function GooseMessage({
// Extract text content from the message // Extract text content from the message
let textContent = getTextContent(message); let textContent = getTextContent(message);
// Extract image paths from the message
const imagePaths = extractImagePaths(textContent);
// Remove image paths from text for display
const displayText =
imagePaths.length > 0 ? removeImagePathsFromText(textContent, imagePaths) : textContent;
// Memoize the timestamp // Memoize the timestamp
const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]); const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]);
@@ -53,7 +62,7 @@ export default function GooseMessage({
const messageIndex = messages?.findIndex((msg) => msg.id === message.id); const messageIndex = messages?.findIndex((msg) => msg.id === message.id);
const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null; const previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null;
const previousUrls = previousMessage ? extractUrls(getTextContent(previousMessage)) : []; const previousUrls = previousMessage ? extractUrls(getTextContent(previousMessage)) : [];
const urls = toolRequests.length === 0 ? extractUrls(textContent, previousUrls) : []; const urls = toolRequests.length === 0 ? extractUrls(displayText, previousUrls) : [];
const toolConfirmationContent = getToolConfirmationContent(message); const toolConfirmationContent = getToolConfirmationContent(message);
const hasToolConfirmation = toolConfirmationContent !== undefined; const hasToolConfirmation = toolConfirmationContent !== undefined;
@@ -106,8 +115,18 @@ export default function GooseMessage({
{textContent && ( {textContent && (
<div className="flex flex-col group"> <div className="flex flex-col group">
<div className={`goose-message-content pt-2`}> <div className={`goose-message-content pt-2`}>
<div ref={contentRef}>{<MarkdownContent content={textContent} />}</div> <div ref={contentRef}>{<MarkdownContent content={displayText} />}</div>
</div> </div>
{/* Render images if any */}
{imagePaths.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 mb-2">
{imagePaths.map((imagePath, index) => (
<ImagePreview key={index} src={imagePath} alt={`Image ${index + 1}`} />
))}
</div>
)}
{/* Only show MessageCopyLink if there's text content and no tool requests/responses */} {/* Only show MessageCopyLink if there's text content and no tool requests/responses */}
<div className="relative flex justify-start"> <div className="relative flex justify-start">
{toolRequests.length === 0 && ( {toolRequests.length === 0 && (
@@ -117,7 +136,7 @@ export default function GooseMessage({
)} )}
{textContent && message.content.every((content) => content.type === 'text') && ( {textContent && message.content.every((content) => content.type === 'text') && (
<div className="absolute left-0 pt-1"> <div className="absolute left-0 pt-1">
<MessageCopyLink text={textContent} contentRef={contentRef} /> <MessageCopyLink text={displayText} contentRef={contentRef} />
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,88 @@
import React, { useState, useEffect } from 'react';
interface ImagePreviewProps {
src: string;
alt?: string;
className?: string;
}
export default function ImagePreview({
src,
alt = 'Pasted image',
className = '',
}: ImagePreviewProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [error, setError] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [imageData, setImageData] = useState<string | null>(null);
useEffect(() => {
const loadImage = async () => {
try {
// Use the IPC handler to get the image data
const data = await window.electron.getTempImage(src);
if (data) {
setImageData(data);
setIsLoading(false);
} else {
setError(true);
setIsLoading(false);
}
} catch (err) {
console.error('Error loading image:', err);
setError(true);
setIsLoading(false);
}
};
loadImage();
}, [src]);
const handleError = () => {
setError(true);
setIsLoading(false);
};
const toggleExpand = () => {
if (!error) {
setIsExpanded(!isExpanded);
}
};
// Validate that this is a safe file path (should contain goose-pasted-images)
if (!src.includes('goose-pasted-images')) {
return <div className="text-red-500 text-xs italic mt-1 mb-1">Invalid image path: {src}</div>;
}
if (error) {
return <div className="text-red-500 text-xs italic mt-1 mb-1">Unable to load image: {src}</div>;
}
return (
<div className={`image-preview mt-2 mb-2 ${className}`}>
{isLoading && (
<div className="animate-pulse bg-gray-200 rounded w-40 h-40 flex items-center justify-center">
<span className="text-gray-500 text-xs">Loading...</span>
</div>
)}
{imageData && (
<img
src={imageData}
alt={alt}
onError={handleError}
onClick={toggleExpand}
className={`rounded border border-borderSubtle cursor-pointer hover:border-borderStandard transition-all ${
isExpanded ? 'max-w-full max-h-96' : 'max-h-40 max-w-40'
} ${isLoading ? 'hidden' : ''}`}
style={{ objectFit: 'contain' }}
/>
)}
{isExpanded && !error && !isLoading && imageData && (
<div className="text-xs text-textSubtle mt-1">Click to collapse</div>
)}
{!isExpanded && !error && !isLoading && imageData && (
<div className="text-xs text-textSubtle mt-1">Click to expand</div>
)}
</div>
);
}

View File

@@ -1,6 +1,8 @@
import React, { useRef, useMemo } from 'react'; import React, { useRef, useMemo } from 'react';
import LinkPreview from './LinkPreview'; import LinkPreview from './LinkPreview';
import ImagePreview from './ImagePreview';
import { extractUrls } from '../utils/urlUtils'; import { extractUrls } from '../utils/urlUtils';
import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils';
import MarkdownContent from './MarkdownContent'; import MarkdownContent from './MarkdownContent';
import { Message, getTextContent } from '../types/message'; import { Message, getTextContent } from '../types/message';
import MessageCopyLink from './MessageCopyLink'; import MessageCopyLink from './MessageCopyLink';
@@ -16,11 +18,17 @@ export default function UserMessage({ message }: UserMessageProps) {
// Extract text content from the message // Extract text content from the message
const textContent = getTextContent(message); const textContent = getTextContent(message);
// Extract image paths from the message
const imagePaths = extractImagePaths(textContent);
// Remove image paths from text for display
const displayText = removeImagePathsFromText(textContent, imagePaths);
// Memoize the timestamp // Memoize the timestamp
const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]); const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]);
// Extract URLs which explicitly contain the http:// or https:// protocol // Extract URLs which explicitly contain the http:// or https:// protocol
const urls = extractUrls(textContent, []); const urls = extractUrls(displayText, []);
return ( return (
<div className="flex justify-end mt-[16px] w-full opacity-0 animate-[appear_150ms_ease-in_forwards]"> <div className="flex justify-end mt-[16px] w-full opacity-0 animate-[appear_150ms_ease-in_forwards]">
@@ -29,17 +37,27 @@ export default function UserMessage({ message }: UserMessageProps) {
<div className="flex bg-slate text-white rounded-xl rounded-br-none py-2 px-3"> <div className="flex bg-slate text-white rounded-xl rounded-br-none py-2 px-3">
<div ref={contentRef}> <div ref={contentRef}>
<MarkdownContent <MarkdownContent
content={textContent} content={displayText}
className="text-white prose-a:text-white user-message" className="text-white prose-a:text-white user-message"
/> />
</div> </div>
</div> </div>
{/* Render images if any */}
{imagePaths.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{imagePaths.map((imagePath, index) => (
<ImagePreview key={index} src={imagePath} alt={`Pasted image ${index + 1}`} />
))}
</div>
)}
<div className="relative h-[22px] flex justify-end"> <div className="relative h-[22px] flex justify-end">
<div className="absolute right-0 text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0"> <div className="absolute right-0 text-xs text-textSubtle pt-1 transition-all duration-200 group-hover:-translate-y-4 group-hover:opacity-0">
{timestamp} {timestamp}
</div> </div>
<div className="absolute right-0 pt-1"> <div className="absolute right-0 pt-1">
<MessageCopyLink text={textContent} contentRef={contentRef} /> <MessageCopyLink text={displayText} contentRef={contentRef} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,9 +6,11 @@ import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area'; import { ScrollArea } from '../ui/scroll-area';
import MarkdownContent from '../MarkdownContent'; import MarkdownContent from '../MarkdownContent';
import ToolCallWithResponse from '../ToolCallWithResponse'; import ToolCallWithResponse from '../ToolCallWithResponse';
import ImagePreview from '../ImagePreview';
import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message'; import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message';
import { type Message } from '../../types/message'; import { type Message } from '../../types/message';
import { formatMessageTimestamp } from '../../utils/timeUtils'; import { formatMessageTimestamp } from '../../utils/timeUtils';
import { extractImagePaths, removeImagePathsFromText } from '../../utils/imageUtils';
/** /**
* Get tool responses map from messages * Get tool responses map from messages
@@ -106,11 +108,20 @@ export const SessionMessages: React.FC<SessionMessagesProps> = ({
messages messages
.map((message, index) => { .map((message, index) => {
// Extract text content from the message // Extract text content from the message
const textContent = message.content let textContent = message.content
.filter((c) => c.type === 'text') .filter((c) => c.type === 'text')
.map((c) => c.text) .map((c) => c.text)
.join('\n'); .join('\n');
// Extract image paths from the message
const imagePaths = extractImagePaths(textContent);
// Remove image paths from text for display
const displayText =
imagePaths.length > 0
? removeImagePathsFromText(textContent, imagePaths)
: textContent;
// Get tool requests from the message // Get tool requests from the message
const toolRequests = message.content const toolRequests = message.content
.filter((c) => c.type === 'toolRequest') .filter((c) => c.type === 'toolRequest')
@@ -148,9 +159,24 @@ export const SessionMessages: React.FC<SessionMessagesProps> = ({
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
{/* Text content */} {/* Text content */}
{textContent && ( {displayText && (
<div className={`${toolRequests.length > 0 ? 'mb-4' : ''}`}> <div
<MarkdownContent content={textContent} /> className={`${toolRequests.length > 0 || imagePaths.length > 0 ? 'mb-4' : ''}`}
>
<MarkdownContent content={displayText} />
</div>
)}
{/* Render images if any */}
{imagePaths.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2 mb-2">
{imagePaths.map((imagePath, imageIndex) => (
<ImagePreview
key={imageIndex}
src={imagePath}
alt={`Image ${imageIndex + 1}`}
/>
))}
</div> </div>
)} )}

View File

@@ -13,6 +13,7 @@ import {
globalShortcut, globalShortcut,
} from 'electron'; } from 'electron';
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import fs from 'node:fs/promises';
import started from 'electron-squirrel-startup'; import started from 'electron-squirrel-startup';
import path from 'node:path'; import path from 'node:path';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
@@ -33,6 +34,81 @@ import * as crypto from 'crypto';
import * as electron from 'electron'; import * as electron from 'electron';
import * as yaml from 'yaml'; import * as yaml from 'yaml';
// Define temp directory for pasted images
const gooseTempDir = path.join(app.getPath('temp'), 'goose-pasted-images');
// Function to ensure the temporary directory exists
async function ensureTempDirExists(): Promise<string> {
try {
// Check if the path already exists
try {
const stats = await fs.stat(gooseTempDir);
// If it exists but is not a directory, remove it and recreate
if (!stats.isDirectory()) {
await fs.unlink(gooseTempDir);
await fs.mkdir(gooseTempDir, { recursive: true });
}
// Startup cleanup: remove old files and any symlinks
const files = await fs.readdir(gooseTempDir);
const now = Date.now();
const MAX_AGE = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
for (const file of files) {
const filePath = path.join(gooseTempDir, file);
try {
const fileStats = await fs.lstat(filePath);
// Always remove symlinks
if (fileStats.isSymbolicLink()) {
console.warn(
`[Main] Found symlink in temp directory during startup: ${filePath}. Removing it.`
);
await fs.unlink(filePath);
continue;
}
// Remove old files (older than 24 hours)
if (fileStats.isFile()) {
const fileAge = now - fileStats.mtime.getTime();
if (fileAge > MAX_AGE) {
console.log(
`[Main] Removing old temp file during startup: ${filePath} (age: ${Math.round(fileAge / (60 * 60 * 1000))} hours)`
);
await fs.unlink(filePath);
}
}
} catch (fileError) {
// If we can't stat the file, try to remove it anyway
console.warn(`[Main] Could not stat file ${filePath}, attempting to remove:`, fileError);
try {
await fs.unlink(filePath);
} catch (unlinkError) {
console.error(`[Main] Failed to remove problematic file ${filePath}:`, unlinkError);
}
}
}
} catch (error) {
if (error.code === 'ENOENT') {
// Directory doesn't exist, create it
await fs.mkdir(gooseTempDir, { recursive: true });
} else {
throw error;
}
}
// Set proper permissions on the directory (0755 = rwxr-xr-x)
await fs.chmod(gooseTempDir, 0o755);
console.log('[Main] Temporary directory for pasted images ensured:', gooseTempDir);
} catch (error) {
console.error('[Main] Failed to create temp directory:', gooseTempDir, error);
throw error; // Propagate error
}
return gooseTempDir;
}
if (started) app.quit(); if (started) app.quit();
app.setAsDefaultProtocolClient('goose'); app.setAsDefaultProtocolClient('goose');
@@ -637,6 +713,203 @@ ipcMain.handle('select-file-or-directory', async () => {
return null; return null;
}); });
// IPC handler to save data URL to a temporary file
ipcMain.handle('save-data-url-to-temp', async (event, dataUrl: string, uniqueId: string) => {
console.log(`[Main] Received save-data-url-to-temp for ID: ${uniqueId}`);
try {
// Input validation for uniqueId - only allow alphanumeric characters and hyphens
if (!uniqueId || !/^[a-zA-Z0-9-]+$/.test(uniqueId) || uniqueId.length > 50) {
console.error('[Main] Invalid uniqueId format received.');
return { id: uniqueId, error: 'Invalid uniqueId format' };
}
// Input validation for dataUrl
if (!dataUrl || typeof dataUrl !== 'string' || dataUrl.length > 10 * 1024 * 1024) {
// 10MB limit
console.error('[Main] Invalid or too large data URL received.');
return { id: uniqueId, error: 'Invalid or too large data URL' };
}
const tempDir = await ensureTempDirExists();
const matches = dataUrl.match(/^data:(image\/(png|jpeg|jpg|gif|webp));base64,(.*)$/);
if (!matches || matches.length < 4) {
console.error('[Main] Invalid data URL format received.');
return { id: uniqueId, error: 'Invalid data URL format or unsupported image type' };
}
const imageExtension = matches[2]; // e.g., "png", "jpeg"
const base64Data = matches[3];
// Validate base64 data
if (!base64Data || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64Data)) {
console.error('[Main] Invalid base64 data received.');
return { id: uniqueId, error: 'Invalid base64 data' };
}
const buffer = Buffer.from(base64Data, 'base64');
// Validate image size (max 5MB)
if (buffer.length > 5 * 1024 * 1024) {
console.error('[Main] Image too large.');
return { id: uniqueId, error: 'Image too large (max 5MB)' };
}
const randomString = crypto.randomBytes(8).toString('hex');
const fileName = `pasted-${uniqueId}-${randomString}.${imageExtension}`;
const filePath = path.join(tempDir, fileName);
// Ensure the resolved path is still within the temp directory
const resolvedPath = path.resolve(filePath);
const resolvedTempDir = path.resolve(tempDir);
if (!resolvedPath.startsWith(resolvedTempDir + path.sep)) {
console.error('[Main] Attempted path traversal detected.');
return { id: uniqueId, error: 'Invalid file path' };
}
await fs.writeFile(filePath, buffer);
console.log(`[Main] Saved image for ID ${uniqueId} to: ${filePath}`);
return { id: uniqueId, filePath: filePath };
} catch (error) {
console.error(`[Main] Failed to save image to temp for ID ${uniqueId}:`, error);
return { id: uniqueId, error: error.message || 'Failed to save image' };
}
});
// IPC handler to serve temporary image files
ipcMain.handle('get-temp-image', async (event, filePath: string) => {
console.log(`[Main] Received get-temp-image for path: ${filePath}`);
// Input validation
if (!filePath || typeof filePath !== 'string') {
console.warn('[Main] Invalid file path provided for image serving');
return null;
}
// Ensure the path is within the designated temp directory
const resolvedPath = path.resolve(filePath);
const resolvedTempDir = path.resolve(gooseTempDir);
if (!resolvedPath.startsWith(resolvedTempDir + path.sep)) {
console.warn(`[Main] Attempted to access file outside designated temp directory: ${filePath}`);
return null;
}
try {
// Check if it's a regular file first, before trying realpath
const stats = await fs.lstat(filePath);
if (!stats.isFile()) {
console.warn(`[Main] Not a regular file, refusing to serve: ${filePath}`);
return null;
}
// Get the real paths for both the temp directory and the file to handle symlinks properly
let realTempDir: string;
let actualPath = filePath;
try {
realTempDir = await fs.realpath(gooseTempDir);
const realPath = await fs.realpath(filePath);
// Double-check that the real path is still within our real temp directory
if (!realPath.startsWith(realTempDir + path.sep)) {
console.warn(
`[Main] Real path is outside designated temp directory: ${realPath} not in ${realTempDir}`
);
return null;
}
actualPath = realPath;
} catch (realpathError) {
// If realpath fails, use the original path validation
console.log(
`[Main] realpath failed for ${filePath}, using original path validation:`,
realpathError.message
);
}
// Read the file and return as base64 data URL
const fileBuffer = await fs.readFile(actualPath);
const fileExtension = path.extname(actualPath).toLowerCase().substring(1);
// Validate file extension
const allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
if (!allowedExtensions.includes(fileExtension)) {
console.warn(`[Main] Unsupported file extension: ${fileExtension}`);
return null;
}
const mimeType = fileExtension === 'jpg' ? 'image/jpeg' : `image/${fileExtension}`;
const base64Data = fileBuffer.toString('base64');
const dataUrl = `data:${mimeType};base64,${base64Data}`;
console.log(`[Main] Served temp image: ${filePath}`);
return dataUrl;
} catch (error) {
console.error(`[Main] Failed to serve temp image: ${filePath}`, error);
return null;
}
});
ipcMain.on('delete-temp-file', async (event, filePath: string) => {
console.log(`[Main] Received delete-temp-file for path: ${filePath}`);
// Input validation
if (!filePath || typeof filePath !== 'string') {
console.warn('[Main] Invalid file path provided for deletion');
return;
}
// Ensure the path is within the designated temp directory
const resolvedPath = path.resolve(filePath);
const resolvedTempDir = path.resolve(gooseTempDir);
if (!resolvedPath.startsWith(resolvedTempDir + path.sep)) {
console.warn(`[Main] Attempted to delete file outside designated temp directory: ${filePath}`);
return;
}
try {
// Check if it's a regular file first, before trying realpath
const stats = await fs.lstat(filePath);
if (!stats.isFile()) {
console.warn(`[Main] Not a regular file, refusing to delete: ${filePath}`);
return;
}
// Get the real paths for both the temp directory and the file to handle symlinks properly
let actualPath = filePath;
try {
const realTempDir = await fs.realpath(gooseTempDir);
const realPath = await fs.realpath(filePath);
// Double-check that the real path is still within our real temp directory
if (!realPath.startsWith(realTempDir + path.sep)) {
console.warn(
`[Main] Real path is outside designated temp directory: ${realPath} not in ${realTempDir}`
);
return;
}
actualPath = realPath;
} catch (realpathError) {
// If realpath fails, use the original path validation
console.log(
`[Main] realpath failed for ${filePath}, using original path validation:`,
realpathError.message
);
}
await fs.unlink(actualPath);
console.log(`[Main] Deleted temp file: ${filePath}`);
} catch (error) {
if (error.code !== 'ENOENT') {
// ENOENT means file doesn't exist, which is fine
console.error(`[Main] Failed to delete temp file: ${filePath}`, error);
} else {
console.log(`[Main] Temp file already deleted or not found: ${filePath}`);
}
}
});
ipcMain.handle('check-ollama', async () => { ipcMain.handle('check-ollama', async () => {
try { try {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -733,9 +1006,9 @@ ipcMain.handle('write-file', (_event, filePath, content) => {
return new Promise((resolve) => { return new Promise((resolve) => {
// Create a write stream to the file // Create a write stream to the file
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs'); const fsNode = require('fs'); // Using require for fs in this specific handler from original
try { try {
fs.writeFileSync(filePath, content, { encoding: 'utf8' }); fsNode.writeFileSync(filePath, content, { encoding: 'utf8' });
resolve(true); resolve(true);
} catch (error) { } catch (error) {
console.error('Error writing to file:', error); console.error('Error writing to file:', error);
@@ -1158,11 +1431,6 @@ app.whenReady().then(async () => {
return false; return false;
}); });
// Handle binary path requests
ipcMain.handle('get-binary-path', (_event, binaryName) => {
return getBinaryPath(app, binaryName);
});
// Handle metadata fetching from main process // Handle metadata fetching from main process
ipcMain.handle('fetch-metadata', async (_event, url) => { ipcMain.handle('fetch-metadata', async (_event, url) => {
try { try {
@@ -1284,9 +1552,57 @@ async function getAllowList(): Promise<string[]> {
} }
} }
app.on('will-quit', () => { app.on('will-quit', async () => {
// Unregister all shortcuts when quitting // Unregister all shortcuts when quitting
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
// Clean up the temp directory on app quit
console.log('[Main] App "will-quit". Cleaning up temporary image directory...');
try {
await fs.access(gooseTempDir); // Check if directory exists to avoid error on fs.rm if it doesn't
// First, check for any symlinks in the directory and refuse to delete them
let hasSymlinks = false;
try {
const files = await fs.readdir(gooseTempDir);
for (const file of files) {
const filePath = path.join(gooseTempDir, file);
const stats = await fs.lstat(filePath);
if (stats.isSymbolicLink()) {
console.warn(`[Main] Found symlink in temp directory: ${filePath}. Skipping deletion.`);
hasSymlinks = true;
// Delete the individual file but leave the symlink
continue;
}
// Delete regular files individually
if (stats.isFile()) {
await fs.unlink(filePath);
}
}
// If no symlinks were found, it's safe to remove the directory
if (!hasSymlinks) {
await fs.rm(gooseTempDir, { recursive: true, force: true });
console.log('[Main] Pasted images temp directory cleaned up successfully.');
} else {
console.log(
'[Main] Cleaned up files in temp directory but left directory intact due to symlinks.'
);
}
} catch (err) {
console.error('[Main] Error while cleaning up temp directory contents:', err);
}
} catch (error) {
if (error.code === 'ENOENT') {
console.log('[Main] Temp directory did not exist during "will-quit", no cleanup needed.');
} else {
console.error(
'[Main] Failed to clean up pasted images temp directory during "will-quit":',
error
);
}
}
}); });
// Quit when all windows are closed, except on macOS or if we have a tray icon. // Quit when all windows are closed, except on macOS or if we have a tray icon.

View File

@@ -21,6 +21,12 @@ interface FileResponse {
found: boolean; found: boolean;
} }
interface SaveDataUrlResponse {
id: string;
filePath?: string;
error?: string;
}
const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}'); const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}');
// Define the API types in a single place // Define the API types in a single place
@@ -61,6 +67,11 @@ type ElectronAPI = {
callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
) => void; ) => void;
emit: (channel: string, ...args: unknown[]) => void; emit: (channel: string, ...args: unknown[]) => void;
// Functions for image pasting
saveDataUrlToTemp: (dataUrl: string, uniqueId: string) => Promise<SaveDataUrlResponse>;
deleteTempFile: (filePath: string) => void;
// Function to serve temp images
getTempImage: (filePath: string) => Promise<string | null>;
}; };
type AppConfigAPI = { type AppConfigAPI = {
@@ -121,6 +132,15 @@ const electronAPI: ElectronAPI = {
emit: (channel: string, ...args: unknown[]) => { emit: (channel: string, ...args: unknown[]) => {
ipcRenderer.emit(channel, ...args); ipcRenderer.emit(channel, ...args);
}, },
saveDataUrlToTemp: (dataUrl: string, uniqueId: string): Promise<SaveDataUrlResponse> => {
return ipcRenderer.invoke('save-data-url-to-temp', dataUrl, uniqueId);
},
deleteTempFile: (filePath: string): void => {
ipcRenderer.send('delete-temp-file', filePath);
},
getTempImage: (filePath: string): Promise<string | null> => {
return ipcRenderer.invoke('get-temp-image', filePath);
},
}; };
const appConfigAPI: AppConfigAPI = { const appConfigAPI: AppConfigAPI = {

View File

@@ -0,0 +1,59 @@
/**
* Utility functions for detecting and handling image paths in messages
*/
/**
* Extracts image file paths from a message text
* Looks for paths that match the pattern of pasted images from the temp directory
*
* @param text The message text to extract image paths from
* @returns An array of image file paths found in the message
*/
export function extractImagePaths(text: string): string[] {
if (!text) return [];
// Match paths that look like pasted image paths from the temp directory
// Pattern: /path/to/goose-pasted-images/pasted-img-TIMESTAMP-RANDOM.ext
// This regex looks for:
// - Word boundary or start of string
// - A path containing "goose-pasted-images"
// - Followed by a filename starting with "pasted-"
// - Ending with common image extensions
// - Word boundary or end of string
const regex =
/(?:^|\s)((?:[^\s]*\/)?goose-pasted-images\/pasted-[^\s]+\.(png|jpg|jpeg|gif|webp))(?=\s|$)/gi;
const matches = [];
let match;
while ((match = regex.exec(text)) !== null) {
matches.push(match[1]);
}
return matches;
}
/**
* Removes image paths from the text
*
* @param text The original text
* @param imagePaths Array of image paths to remove
* @returns Text with image paths removed
*/
export function removeImagePathsFromText(text: string, imagePaths: string[]): string {
if (!text || imagePaths.length === 0) return text;
let result = text;
// Remove each image path from the text
imagePaths.forEach((path) => {
// Escape special regex characters in the path
const escapedPath = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Create a regex that matches the path with optional surrounding whitespace
const pathRegex = new RegExp(`(^|\\s)${escapedPath}(?=\\s|$)`, 'g');
result = result.replace(pathRegex, '$1');
});
// Clean up any extra whitespace
return result.replace(/\s+/g, ' ').trim();
}

View File

@@ -14,4 +14,4 @@ export default defineConfig({
}, },
}, },
}, },
}); });