diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 51d28a69..8767f9a5 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -2,12 +2,24 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import { Button } from './ui/button'; import type { View } from '../App'; import Stop from './ui/Stop'; -import { Attach, Send } from './icons'; +import { Attach, Send, Close } from './icons'; import { debounce } from 'lodash'; import BottomMenu from './bottom_menu/BottomMenu'; import { LocalMessageStorage } from '../utils/localMessageStorage'; 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 { handleSubmit: (e: React.FormEvent) => void; isLoading?: boolean; @@ -37,15 +49,28 @@ export default function ChatInput({ const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [isFocused, setIsFocused] = useState(false); + const [pastedImages, setPastedImages] = useState([]); // Update internal value when initialValue changes useEffect(() => { setValue(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 setHistoryIndex(-1); 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) const [isComposing, setIsComposing] = useState(false); @@ -55,6 +80,48 @@ export default function ChatInput({ const textAreaRef = useRef(null); const [processedFilePaths, setProcessedFilePaths] = useState([]); + 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(() => { if (textAreaRef.current) { textAreaRef.current.focus(); @@ -111,6 +178,89 @@ export default function ChatInput({ debouncedSetValue(val); // Debounce the actual state update }; + const handlePaste = async (evt: React.ClipboardEvent) => { + 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 useEffect(() => { 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) => { // Handle history navigation first handleHistoryNavigation(evt); @@ -207,6 +387,7 @@ export default function ChatInput({ // Allow line break for Shift+Enter, or during IME composition return; } + if (evt.altKey) { const newValue = displayValue + '\n'; setDisplayValue(newValue); @@ -214,44 +395,31 @@ export default function ChatInput({ return; } - // Prevent default Enter behavior when loading or when not loading but has content - // So it won't trigger a new line evt.preventDefault(); - - // Only submit if not loading and has content - if (!isLoading && displayValue.trim()) { - // Always add to global chat storage before submitting - LocalMessageStorage.addMessage(displayValue); - - handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } })); - setDisplayValue(''); - setValue(''); - setHistoryIndex(-1); - setSavedInput(''); - setIsInGlobalHistory(false); + const canSubmit = + !isLoading && + (displayValue.trim() || + pastedImages.some((img) => img.filePath && !img.error && !img.isLoading)); + if (canSubmit) { + performSubmit(); } } }; const onFormSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (displayValue.trim() && !isLoading) { - // Always add to global chat storage before submitting - LocalMessageStorage.addMessage(displayValue); - - handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } })); - setDisplayValue(''); - setValue(''); - setHistoryIndex(-1); - setSavedInput(''); - setIsInGlobalHistory(false); + const canSubmit = + !isLoading && + (displayValue.trim() || + pastedImages.some((img) => img.filePath && !img.error && !img.isLoading)); + if (canSubmit) { + performSubmit(); } }; const handleFileSelect = async () => { const path = await window.electron.selectFileOrDirectory(); if (path) { - // Append the path to existing text, with a space if there's existing text const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path; setDisplayValue(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 (
setIsFocused(true)} onBlur={() => setIsFocused(false)} 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" /> + {pastedImages.length > 0 && ( +
+ {pastedImages.map((img) => ( +
+ {img.dataUrl && ( + {`Pasted + )} + {img.isLoading && ( +
+
+
+ )} + {img.error && !img.isLoading && ( +
+

+ {img.error.substring(0, 50)} +

+ {img.dataUrl && ( + + )} +
+ )} + {!img.isLoading && ( + + )} +
+ ))} +
+ )} + {isLoading ? ( diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index 754cc213..ca0fc0b3 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -296,13 +296,17 @@ function ChatContent({ const handleSubmit = (e: React.FormEvent) => { window.electron.startPowerSaveBlocker(); 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()); + // 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) { - // move current `messages` to `ancestorMessages` and `messages` to `summarizedThread` resetMessagesWithSummary( messages, setMessages, @@ -310,23 +314,21 @@ function ChatContent({ setAncestorMessages, summaryContent ); - - // update the chat with new sessionId - - // now call the llm setTimeout(() => { - append(createUserMessage(content)); + append(userMessage); if (scrollRef.current?.scrollToBottom) { scrollRef.current.scrollToBottom(); } }, 150); } else { - // Normal flow (existing code) - append(createUserMessage(content)); + append(userMessage); if (scrollRef.current?.scrollToBottom) { scrollRef.current.scrollToBottom(); } } + } else { + // If nothing was actually submitted (e.g. empty input and no images pasted) + window.electron.stopPowerSaveBlocker(); } }; diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index 83640241..79f62b97 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -1,7 +1,9 @@ import React, { useEffect, useMemo, useRef } from 'react'; import LinkPreview from './LinkPreview'; +import ImagePreview from './ImagePreview'; import GooseResponseForm from './GooseResponseForm'; import { extractUrls } from '../utils/urlUtils'; +import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils'; import { formatMessageTimestamp } from '../utils/timeUtils'; import MarkdownContent from './MarkdownContent'; import ToolCallWithResponse from './ToolCallWithResponse'; @@ -40,6 +42,13 @@ export default function GooseMessage({ // Extract text content from the 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 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 previousMessage = messageIndex > 0 ? messages[messageIndex - 1] : null; 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 hasToolConfirmation = toolConfirmationContent !== undefined; @@ -106,8 +115,18 @@ export default function GooseMessage({ {textContent && (
-
{}
+
{}
+ + {/* Render images if any */} + {imagePaths.length > 0 && ( +
+ {imagePaths.map((imagePath, index) => ( + + ))} +
+ )} + {/* Only show MessageCopyLink if there's text content and no tool requests/responses */}
{toolRequests.length === 0 && ( @@ -117,7 +136,7 @@ export default function GooseMessage({ )} {textContent && message.content.every((content) => content.type === 'text') && (
- +
)}
diff --git a/ui/desktop/src/components/ImagePreview.tsx b/ui/desktop/src/components/ImagePreview.tsx new file mode 100644 index 00000000..7e6d66f5 --- /dev/null +++ b/ui/desktop/src/components/ImagePreview.tsx @@ -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(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
Invalid image path: {src}
; + } + + if (error) { + return
Unable to load image: {src}
; + } + + return ( +
+ {isLoading && ( +
+ Loading... +
+ )} + {imageData && ( + {alt} + )} + {isExpanded && !error && !isLoading && imageData && ( +
Click to collapse
+ )} + {!isExpanded && !error && !isLoading && imageData && ( +
Click to expand
+ )} +
+ ); +} diff --git a/ui/desktop/src/components/UserMessage.tsx b/ui/desktop/src/components/UserMessage.tsx index 8d03a876..4fad212f 100644 --- a/ui/desktop/src/components/UserMessage.tsx +++ b/ui/desktop/src/components/UserMessage.tsx @@ -1,6 +1,8 @@ import React, { useRef, useMemo } from 'react'; import LinkPreview from './LinkPreview'; +import ImagePreview from './ImagePreview'; import { extractUrls } from '../utils/urlUtils'; +import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils'; import MarkdownContent from './MarkdownContent'; import { Message, getTextContent } from '../types/message'; import MessageCopyLink from './MessageCopyLink'; @@ -16,11 +18,17 @@ export default function UserMessage({ message }: UserMessageProps) { // Extract text content from the 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 const timestamp = useMemo(() => formatMessageTimestamp(message.created), [message.created]); // Extract URLs which explicitly contain the http:// or https:// protocol - const urls = extractUrls(textContent, []); + const urls = extractUrls(displayText, []); return (
@@ -29,17 +37,27 @@ export default function UserMessage({ message }: UserMessageProps) {
+ + {/* Render images if any */} + {imagePaths.length > 0 && ( +
+ {imagePaths.map((imagePath, index) => ( + + ))} +
+ )} +
{timestamp}
- +
diff --git a/ui/desktop/src/components/sessions/SessionViewComponents.tsx b/ui/desktop/src/components/sessions/SessionViewComponents.tsx index 978d67d4..3c0f4dce 100644 --- a/ui/desktop/src/components/sessions/SessionViewComponents.tsx +++ b/ui/desktop/src/components/sessions/SessionViewComponents.tsx @@ -6,9 +6,11 @@ import BackButton from '../ui/BackButton'; import { ScrollArea } from '../ui/scroll-area'; import MarkdownContent from '../MarkdownContent'; import ToolCallWithResponse from '../ToolCallWithResponse'; +import ImagePreview from '../ImagePreview'; import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message'; import { type Message } from '../../types/message'; import { formatMessageTimestamp } from '../../utils/timeUtils'; +import { extractImagePaths, removeImagePathsFromText } from '../../utils/imageUtils'; /** * Get tool responses map from messages @@ -106,11 +108,20 @@ export const SessionMessages: React.FC = ({ messages .map((message, index) => { // Extract text content from the message - const textContent = message.content + let textContent = message.content .filter((c) => c.type === 'text') .map((c) => c.text) .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 const toolRequests = message.content .filter((c) => c.type === 'toolRequest') @@ -148,9 +159,24 @@ export const SessionMessages: React.FC = ({
{/* Text content */} - {textContent && ( -
0 ? 'mb-4' : ''}`}> - + {displayText && ( +
0 || imagePaths.length > 0 ? 'mb-4' : ''}`} + > + +
+ )} + + {/* Render images if any */} + {imagePaths.length > 0 && ( +
+ {imagePaths.map((imagePath, imageIndex) => ( + + ))}
)} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 3c64b362..ea34c3e7 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -13,6 +13,7 @@ import { globalShortcut, } from 'electron'; import { Buffer } from 'node:buffer'; +import fs from 'node:fs/promises'; import started from 'electron-squirrel-startup'; import path from 'node:path'; import { spawn } from 'child_process'; @@ -33,6 +34,81 @@ import * as crypto from 'crypto'; import * as electron from 'electron'; 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 { + 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(); app.setAsDefaultProtocolClient('goose'); @@ -637,6 +713,203 @@ ipcMain.handle('select-file-or-directory', async () => { 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 () => { try { return new Promise((resolve) => { @@ -733,9 +1006,9 @@ ipcMain.handle('write-file', (_event, filePath, content) => { return new Promise((resolve) => { // Create a write stream to the file // 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 { - fs.writeFileSync(filePath, content, { encoding: 'utf8' }); + fsNode.writeFileSync(filePath, content, { encoding: 'utf8' }); resolve(true); } catch (error) { console.error('Error writing to file:', error); @@ -1158,11 +1431,6 @@ app.whenReady().then(async () => { return false; }); - // Handle binary path requests - ipcMain.handle('get-binary-path', (_event, binaryName) => { - return getBinaryPath(app, binaryName); - }); - // Handle metadata fetching from main process ipcMain.handle('fetch-metadata', async (_event, url) => { try { @@ -1284,9 +1552,57 @@ async function getAllowList(): Promise { } } -app.on('will-quit', () => { +app.on('will-quit', async () => { // Unregister all shortcuts when quitting 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. diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index c726f587..9f667aad 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -21,6 +21,12 @@ interface FileResponse { found: boolean; } +interface SaveDataUrlResponse { + id: string; + filePath?: string; + error?: string; +} + const config = JSON.parse(process.argv.find((arg) => arg.startsWith('{')) || '{}'); // Define the API types in a single place @@ -61,6 +67,11 @@ type ElectronAPI = { callback: (event: Electron.IpcRendererEvent, ...args: unknown[]) => void ) => void; emit: (channel: string, ...args: unknown[]) => void; + // Functions for image pasting + saveDataUrlToTemp: (dataUrl: string, uniqueId: string) => Promise; + deleteTempFile: (filePath: string) => void; + // Function to serve temp images + getTempImage: (filePath: string) => Promise; }; type AppConfigAPI = { @@ -121,6 +132,15 @@ const electronAPI: ElectronAPI = { emit: (channel: string, ...args: unknown[]) => { ipcRenderer.emit(channel, ...args); }, + saveDataUrlToTemp: (dataUrl: string, uniqueId: string): Promise => { + return ipcRenderer.invoke('save-data-url-to-temp', dataUrl, uniqueId); + }, + deleteTempFile: (filePath: string): void => { + ipcRenderer.send('delete-temp-file', filePath); + }, + getTempImage: (filePath: string): Promise => { + return ipcRenderer.invoke('get-temp-image', filePath); + }, }; const appConfigAPI: AppConfigAPI = { diff --git a/ui/desktop/src/utils/imageUtils.ts b/ui/desktop/src/utils/imageUtils.ts new file mode 100644 index 00000000..83bb7338 --- /dev/null +++ b/ui/desktop/src/utils/imageUtils.ts @@ -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(); +} diff --git a/ui/desktop/vite.config.mts b/ui/desktop/vite.config.mts index 984755bb..44833cda 100644 --- a/ui/desktop/vite.config.mts +++ b/ui/desktop/vite.config.mts @@ -14,4 +14,4 @@ export default defineConfig({ }, }, }, -}); \ No newline at end of file +});