mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-21 16:14:21 +01:00
Add screenshot paste support (#2679)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
88
ui/desktop/src/components/ImagePreview.tsx
Normal file
88
ui/desktop/src/components/ImagePreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
59
ui/desktop/src/utils/imageUtils.ts
Normal file
59
ui/desktop/src/utils/imageUtils.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -14,4 +14,4 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user