kbot image chat :)

This commit is contained in:
babayaga 2025-09-17 20:57:21 +02:00
parent b34d17191e
commit de926c429d
3 changed files with 337 additions and 69 deletions

Binary file not shown.

View File

@ -1,5 +1,6 @@
use tauri::{Manager, Emitter};
use serde::{Serialize, Deserialize};
use dirs;
struct Counter(std::sync::Mutex<u32>);
struct DebugMessages(std::sync::Mutex<Vec<DebugPayload>>);
@ -67,6 +68,23 @@ fn log_error_to_console(error: &str) {
eprintln!("[WebView ERROR forwarded from JS]: {}", error);
}
#[tauri::command]
fn resolve_path_relative_to_home(absolute_path: String) -> Result<String, String> {
eprintln!("[RUST LOG]: resolve_path_relative_to_home command called.");
eprintln!("[RUST LOG]: - Received absolute path: {}", absolute_path);
let home_dir = dirs::home_dir().ok_or_else(|| "Could not find home directory".to_string())?;
let path_to_resolve = std::path::Path::new(&absolute_path);
let relative_path = pathdiff::diff_paths(path_to_resolve, home_dir)
.ok_or_else(|| "Failed to calculate relative path from home directory".to_string())?;
let result = relative_path.to_string_lossy().to_string();
eprintln!("[RUST LOG]: - Resolved to path relative to home: {}", result);
Ok(result)
}
#[tauri::command]
fn increment_counter(state: tauri::State<'_, Counter>) -> Result<u32, String> {
eprintln!("[RUST LOG]: increment_counter command called.");
@ -272,6 +290,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
submit_prompt,
log_error_to_console,
resolve_path_relative_to_home,
increment_counter,
get_counter,
reset_counter,

View File

@ -87,6 +87,8 @@ interface GeneratedImage {
prompt: string;
timestamp: number;
saved?: boolean;
selectedForNext?: boolean;
timeoutId?: NodeJS.Timeout;
}
function arrayBufferToBase64(buffer: number[]) {
@ -113,6 +115,7 @@ function App() {
const [showDebugPanel, setShowDebugPanel] = useState(true); // Default open for debugging
const [ipcInitialized, setIpcInitialized] = useState(false);
const [messageToSend, setMessageToSend] = useState("");
const [fullscreenImage, setFullscreenImage] = useState<string | null>(null);
const generateDefaultDst = (fileCount: number, firstFilePath?: string) => {
if (fileCount === 1 && firstFilePath) {
@ -195,6 +198,70 @@ function App() {
console.log('Cleared all files');
};
const toggleGeneratedImageSelection = (imageId: string) => {
setGeneratedImages(prev =>
prev.map(img =>
img.id === imageId
? { ...img, selectedForNext: !img.selectedForNext }
: img
)
);
};
const useSelectedImagesForNext = () => {
const selectedImages = generatedImages.filter(img => img.selectedForNext);
if (selectedImages.length === 0) {
addDebugMessage('warn', 'No generated images selected');
return;
}
// Convert generated images to ImageFile format
const newImageFiles: ImageFile[] = selectedImages.map(img => ({
path: `generated_${img.id}.png`,
src: img.src
}));
// Add to current files
setFiles(prevFiles => [...prevFiles, ...newImageFiles]);
// Clear selections
setGeneratedImages(prev =>
prev.map(img => ({ ...img, selectedForNext: false }))
);
addDebugMessage('info', `📁 Added ${selectedImages.length} generated images as input`, {
images: selectedImages.map(img => `generated_${img.id}.png`)
});
};
const clearGeneratedImages = () => {
setGeneratedImages([]);
addDebugMessage('info', '🗑️ Cleared generated images gallery');
};
const openFullscreen = (imageSrc: string) => {
setFullscreenImage(imageSrc);
};
const closeFullscreen = () => {
setFullscreenImage(null);
};
// ESC key handler for fullscreen
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && fullscreenImage) {
closeFullscreen();
}
};
if (fullscreenImage) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [fullscreenImage]);
const generateImage = async (promptText: string, includeImages: ImageFile[] = []) => {
if (!apiKey) {
addDebugMessage('error', 'No API key available for image generation');
@ -204,6 +271,18 @@ function App() {
setIsGenerating(true);
addDebugMessage('info', `🎨 Starting image generation via backend: "${promptText}"`);
// Add placeholder image with spinner
const placeholderImage: GeneratedImage = {
id: `placeholder_${Date.now()}`,
src: '', // Empty src for placeholder
prompt: promptText,
timestamp: Date.now(),
saved: false,
selectedForNext: false
};
setGeneratedImages(prev => [...prev, placeholderImage]);
try {
// Use the images.ts backend instead of direct API calls
const filePaths = includeImages.map(img => img.path);
@ -224,15 +303,24 @@ function App() {
addDebugMessage('info', '📤 Generation request sent to backend');
// Safety timeout to reset generating state (in case something goes wrong)
const timeoutId = setTimeout(() => {
addDebugMessage('warn', '⏰ Generation timeout - resetting state');
setIsGenerating(false);
setGeneratedImages(prev => prev.filter(img => !img.id.startsWith('placeholder_')));
}, 30000); // 30 second timeout
// Store timeout ID so we can clear it when generation completes
placeholderImage.timeoutId = timeoutId;
} catch (error) {
addDebugMessage('error', 'Failed to send generation request', {
error: error instanceof Error ? error.message : JSON.stringify(error)
});
setIsGenerating(false);
// Remove placeholder on error
setGeneratedImages(prev => prev.filter(img => img.id !== placeholderImage.id));
}
// Note: setIsGenerating(false) will be called when we receive the generated image
// or error response from the backend
};
// Theme management
@ -379,22 +467,58 @@ function App() {
};
testImg.src = src;
const newImageFile = { path: imageData.filename, src };
setFiles(prevFiles => {
const exists = prevFiles.some(f => f.path === imageData.filename);
if (!exists) {
addDebugMessage('info', `📁 Adding image to files: ${imageData.filename}`);
return [...prevFiles, newImageFile];
}
addDebugMessage('warn', `🔄 Image already exists: ${imageData.filename}`);
return prevFiles;
});
// Check if this is a generated image (output file) or input image
const isGeneratedImage = imageData.filename.includes('_out') ||
imageData.filename.includes('generated_') ||
isGenerating;
addDebugMessage('info', '📨 Image received from images.ts', {
filename: imageData.filename,
mimeType: imageData.mimeType,
size: `${Math.round(imageData.base64.length/1024)}KB`
});
if (isGeneratedImage) {
// This is a generated image - replace placeholder or add new
const generatedImage: GeneratedImage = {
id: Date.now().toString(),
src,
prompt: prompt || 'Generated image',
timestamp: Date.now(),
saved: false,
selectedForNext: false
};
setGeneratedImages(prev => {
// Find and clear timeout for any placeholder
const placeholder = prev.find(img => img.id.startsWith('placeholder_'));
if (placeholder?.timeoutId) {
clearTimeout(placeholder.timeoutId);
}
// Remove any placeholder and add the real image
const withoutPlaceholder = prev.filter(img => !img.id.startsWith('placeholder_'));
return [...withoutPlaceholder, generatedImage];
});
setIsGenerating(false); // Complete the generation
addDebugMessage('info', '✅ Generated image added to gallery', {
filename: imageData.filename,
prompt: prompt
});
} else {
// This is an input image - add to files
const newImageFile = { path: imageData.filename, src };
setFiles(prevFiles => {
const exists = prevFiles.some(f => f.path === imageData.filename);
if (!exists) {
addDebugMessage('info', `📁 Adding input image: ${imageData.filename}`);
return [...prevFiles, newImageFile];
}
addDebugMessage('warn', `🔄 Image already exists: ${imageData.filename}`);
return prevFiles;
});
addDebugMessage('info', '📨 Input image received from images.ts', {
filename: imageData.filename,
mimeType: imageData.mimeType,
size: `${Math.round(imageData.base64.length/1024)}KB`
});
}
} else {
addDebugMessage('error', '❌ Invalid image data received', {
hasBase64: !!imageData.base64,
@ -404,32 +528,19 @@ function App() {
}
});
// Listen for generation results
await listen('generation-result', (event: any) => {
const resultData = event.payload;
addDebugMessage('info', '🎨 Generation completed', resultData);
// Listen for generation results (generated images come as regular image-received events)
// We'll detect generation completion by checking if it's a generated image
if (resultData.success && resultData.base64) {
const generatedImage: GeneratedImage = {
id: Date.now().toString(),
src: `data:image/png;base64,${resultData.base64}`,
prompt: resultData.prompt || 'Generated image',
timestamp: Date.now(),
saved: false
};
setGeneratedImages(prev => [...prev, generatedImage]);
addDebugMessage('info', '✅ Generated image added to gallery');
}
setIsGenerating(false);
});
// For now, we'll rely on the image-received event and detect if it's a generated result
// The generated image will be added to the generated images gallery
// Listen for generation errors
await listen('generation-error', (event: any) => {
const errorData = event.payload;
addDebugMessage('error', '❌ Generation failed', errorData);
setIsGenerating(false);
// Remove any placeholder images
setGeneratedImages(prev => prev.filter(img => !img.id.startsWith('placeholder_')));
});
addDebugMessage('info', 'Tauri event listeners set up');
@ -512,9 +623,9 @@ function App() {
console.log('==================');
if (chatMode && apiKey) {
// Chat mode: generate image directly in frontend
// Chat mode: generate image via backend
await generateImage(prompt, files);
setPrompt(''); // Clear prompt after generation
// Don't clear prompt immediately - let user decide if they want to keep it
} else {
// Simple mode: send to CLI
try {
@ -949,7 +1060,13 @@ function App() {
{files.map((file) => (
<div key={file.path} className="group">
<div className="relative aspect-square glass-card overflow-hidden shadow-md">
<img src={file.src} alt={file.path} className="object-cover w-full h-full" />
<img
src={file.src}
alt={file.path}
className="object-cover w-full h-full cursor-pointer"
onDoubleClick={() => openFullscreen(file.src)}
title="Double-click to view fullscreen"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200"></div>
<button
type="button"
@ -972,16 +1089,20 @@ function App() {
<div className="w-full mt-8">
<button
type="submit"
className="w-full status-gradient-connected text-white font-bold py-4 px-8 rounded-xl transition-all duration-300 hover:shadow-xl hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 shadow-lg"
className={`w-full font-bold py-4 px-8 rounded-xl transition-all duration-300 shadow-lg ${
isGenerating
? 'bg-slate-400 dark:bg-slate-600 text-white cursor-not-allowed'
: 'status-gradient-connected text-white hover:shadow-xl hover:scale-[1.02]'
}`}
disabled={isGenerating}
>
{isGenerating ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
Generating...
<span className="text-white">Generating...</span>
</span>
) : (
<span className="flex items-center justify-center gap-2">
<span className="flex items-center justify-center gap-2 text-white">
Generate Image
</span>
)}
@ -992,42 +1113,130 @@ function App() {
{/* Generated Images Display (Chat Mode Only) */}
{chatMode && generatedImages.length > 0 && (
<div className="w-full mt-12">
<h2 className="text-3xl font-bold mb-6 accent-text text-center">Generated Images</h2>
<div className="flex justify-between items-center mb-6">
<h2 className="text-3xl font-bold accent-text">Generated Images</h2>
<div className="flex gap-3">
<button
type="button"
onClick={useSelectedImagesForNext}
className="glass-button border-blue-400/50 text-blue-600 hover:bg-blue-500/20 px-4 py-2 rounded-lg text-sm font-semibold"
disabled={!generatedImages.some(img => img.selectedForNext)}
>
📁 Use Selected as Input ({generatedImages.filter(img => img.selectedForNext).length})
</button>
<button
type="button"
onClick={clearGeneratedImages}
className="glass-button border-red-400/50 text-red-600 hover:bg-red-500/20 px-4 py-2 rounded-lg text-sm"
>
🗑 Clear All
</button>
</div>
</div>
<div className="space-y-8">
{generatedImages.map((genImage) => (
<div key={genImage.id} className="glass-card p-6 glass-shimmer shadow-xl">
<div key={genImage.id} className={`glass-card p-6 glass-shimmer shadow-xl transition-all duration-300 cursor-pointer ${
genImage.selectedForNext ? 'ring-4 ring-blue-400 bg-blue-50/30 dark:bg-blue-900/30 border-blue-400' : 'hover:ring-2 hover:ring-slate-300'
}`}
onClick={() => !genImage.id.startsWith('placeholder_') && toggleGeneratedImageSelection(genImage.id)}
>
<div className="flex justify-between items-start mb-6">
<div className="flex-1">
<div className="glass-card p-4 mb-3">
<p className="text-sm font-semibold text-slate-700 mb-1">Prompt:</p>
<p className="text-sm text-slate-600 leading-relaxed">{genImage.prompt}</p>
<div className={`flex items-center gap-4 mb-4 p-4 rounded-lg transition-all duration-200 border-2 ${
genImage.selectedForNext
? 'bg-blue-50 dark:bg-blue-900/30 border-blue-400 dark:border-blue-500 shadow-md'
: 'bg-slate-50 dark:bg-slate-800/50 border-slate-200 dark:border-slate-600 hover:border-blue-300 dark:hover:border-blue-600'
}`}>
<div className={`w-8 h-8 rounded-full border-3 flex items-center justify-center transition-all duration-200 shadow-lg ${
genImage.selectedForNext
? 'bg-blue-500 border-blue-500 text-white scale-110'
: 'border-slate-400 dark:border-slate-400 bg-white dark:bg-slate-700 hover:border-blue-500 hover:bg-blue-50'
}`}>
{genImage.selectedForNext ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<div className="w-3 h-3 rounded-full bg-slate-300 dark:bg-slate-500"></div>
)}
</div>
<div className="flex-1">
<span className={`text-base font-semibold ${
genImage.selectedForNext
? 'text-blue-700 dark:text-blue-300'
: 'text-slate-700 dark:text-slate-300'
}`}>
{genImage.selectedForNext ? '✅ Selected for next generation' : '👆 Click to select for next prompt'}
</span>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Use this image as input for iterative generation
</p>
</div>
</div>
<p className="text-xs text-slate-500 font-medium">
Generated on {new Date(genImage.timestamp).toLocaleString()}
<div className="glass-card p-4 mb-3">
<p className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-1">Prompt:</p>
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">{genImage.prompt}</p>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 font-medium">
{genImage.id.startsWith('placeholder_') ? 'Generating...' : `Generated on ${new Date(genImage.timestamp).toLocaleString()}`}
</p>
</div>
<button
type="button"
onClick={() => saveGeneratedImage(genImage)}
className={`ml-6 px-6 py-3 rounded-xl text-sm font-semibold transition-all duration-300 shadow-md ${
genImage.saved
? 'status-gradient-connected text-white cursor-default'
: 'glass-button border-emerald-400/50 text-emerald-600 hover:bg-emerald-500/20 hover:shadow-lg hover:scale-105'
}`}
disabled={genImage.saved}
title={genImage.saved ? 'Already saved' : 'Save image'}
>
{genImage.saved ? '✅ Saved' : '💾 Save Image'}
</button>
{!genImage.id.startsWith('placeholder_') && (
<button
type="button"
onClick={(e) => {
e.stopPropagation(); // Prevent triggering selection
saveGeneratedImage(genImage);
}}
className={`ml-6 px-6 py-3 rounded-xl text-sm font-semibold transition-all duration-300 shadow-md ${
genImage.saved
? 'status-gradient-connected text-white cursor-default'
: 'glass-button border-emerald-400/50 text-emerald-600 hover:bg-emerald-500/20 hover:shadow-lg hover:scale-105'
}`}
disabled={genImage.saved}
title={genImage.saved ? 'Already saved' : 'Save image'}
>
{genImage.saved ? '✅ Saved' : '💾 Save Image'}
</button>
)}
</div>
<div className="flex justify-center">
<div className="relative">
<img
src={genImage.src}
alt={`Generated: ${genImage.prompt}`}
className="max-w-full max-h-[500px] rounded-xl shadow-2xl border-2 border-white/30"
/>
{genImage.id.startsWith('placeholder_') ? (
// Placeholder with spinner
<div className="w-96 h-96 bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-800 rounded-xl shadow-2xl border-2 border-slate-300 dark:border-slate-600 flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-600 dark:text-slate-400 font-medium">Generating image...</p>
<p className="text-slate-500 dark:text-slate-500 text-sm mt-1">"{genImage.prompt}"</p>
</div>
</div>
) : (
// Actual generated image
<img
src={genImage.src}
alt={`Generated: ${genImage.prompt}`}
className={`max-w-full max-h-[500px] rounded-xl shadow-2xl border-4 transition-all duration-300 cursor-pointer ${
genImage.selectedForNext
? 'border-blue-400 shadow-blue-400/50 scale-[1.02]'
: 'border-white/30 hover:border-slate-300'
}`}
onDoubleClick={(e) => {
e.stopPropagation();
openFullscreen(genImage.src);
}}
title="Double-click to view fullscreen"
/>
)}
<div className="absolute inset-0 rounded-xl ring-1 ring-black/5"></div>
{genImage.selectedForNext && !genImage.id.startsWith('placeholder_') && (
<div className="absolute top-3 left-3 bg-blue-500 text-white px-3 py-2 rounded-lg text-sm font-semibold shadow-lg flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
Selected
</div>
)}
</div>
</div>
</div>
@ -1096,6 +1305,19 @@ function App() {
>
📊 Stats
</button>
<button
onClick={() => {
addDebugMessage('info', '🔄 Manual state reset', {
wasGenerating: isGenerating,
placeholderCount: generatedImages.filter(img => img.id.startsWith('placeholder_')).length
});
setIsGenerating(false);
setGeneratedImages(prev => prev.filter(img => !img.id.startsWith('placeholder_')));
}}
className="glass-button text-sm px-4 py-2 rounded-lg border-orange-400/50 text-orange-600 hover:bg-orange-500/20"
>
🔄 Reset
</button>
<button
onClick={clearDebugMessages}
className="glass-button text-sm px-4 py-2 rounded-lg border-gray-400/50 text-gray-600 hover:bg-gray-500/20"
@ -1241,6 +1463,33 @@ function App() {
</div>
)}
</div>
{/* Fullscreen Image Overlay */}
{fullscreenImage && (
<div
className="fixed inset-0 bg-black/90 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={closeFullscreen}
>
<div className="relative max-w-[95vw] max-h-[95vh]">
<img
src={fullscreenImage}
alt="Fullscreen view"
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
<button
onClick={closeFullscreen}
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white rounded-full w-10 h-10 flex items-center justify-center text-xl font-bold transition-all duration-200 backdrop-blur-sm"
title="Close (ESC)"
>
×
</button>
<div className="absolute bottom-4 left-4 bg-black/50 text-white px-3 py-2 rounded-lg text-sm backdrop-blur-sm">
Press ESC or click outside to close
</div>
</div>
</div>
)}
</main>
);
}