diff --git a/packages/kbot/dist/win-64/tauri-app.exe b/packages/kbot/dist/win-64/tauri-app.exe index f239bf68..4bd13128 100644 Binary files a/packages/kbot/dist/win-64/tauri-app.exe and b/packages/kbot/dist/win-64/tauri-app.exe differ diff --git a/packages/kbot/gui/tauri-app/src-tauri/Cargo.lock b/packages/kbot/gui/tauri-app/src-tauri/Cargo.lock index 90a2e6b9..8bded590 100644 --- a/packages/kbot/gui/tauri-app/src-tauri/Cargo.lock +++ b/packages/kbot/gui/tauri-app/src-tauri/Cargo.lock @@ -5314,6 +5314,7 @@ dependencies = [ "dirs 5.0.1", "glob", "image", + "log", "nanoid", "pathdiff", "rand 0.8.5", diff --git a/packages/kbot/gui/tauri-app/src-tauri/Cargo.toml b/packages/kbot/gui/tauri-app/src-tauri/Cargo.toml index 5526f147..8777bafc 100644 --- a/packages/kbot/gui/tauri-app/src-tauri/Cargo.toml +++ b/packages/kbot/gui/tauri-app/src-tauri/Cargo.toml @@ -44,4 +44,5 @@ image = "0.25.8" glob = "0.3.3" tauri-plugin-notification = "2.3.1" tauri-plugin-clipboard-manager = "2.3.0" +log = "0.4" diff --git a/packages/kbot/gui/tauri-app/src-tauri/src/clipboard.rs b/packages/kbot/gui/tauri-app/src-tauri/src/clipboard.rs index 029b1b2a..b07849bc 100644 --- a/packages/kbot/gui/tauri-app/src-tauri/src/clipboard.rs +++ b/packages/kbot/gui/tauri-app/src-tauri/src/clipboard.rs @@ -1,50 +1,9 @@ use arboard::Clipboard; -use image::{ImageBuffer, ImageFormat, ImageReader, Rgba}; +use image::{ImageBuffer, ImageFormat, Rgba}; use log::{error, info}; -use reqwest; -use std::fs; -use std::io::{Cursor, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tauri::ipc::Response; use tauri::{AppHandle, Runtime}; -use url::Url; - -fn detect_image_format(image_data: &[u8]) -> Option { - let cursor = Cursor::new(image_data); - let reader = ImageReader::new(cursor); - if let Ok(reader_with_format) = reader.with_guessed_format() { - return reader_with_format.format(); - } - None -} - -fn format_to_extension(format: ImageFormat) -> &'static str { - match format { - ImageFormat::Png => "png", - ImageFormat::Jpeg => "jpg", - ImageFormat::Gif => "gif", - ImageFormat::WebP => "webp", - ImageFormat::Tiff => "tiff", - ImageFormat::Avif => "avif", - _ => "png", - } -} - -// BGRA -> RGBA -fn convert_bgra_to_rgba(bgra_data: &[u8]) -> Vec { - let mut rgba_data = Vec::with_capacity(bgra_data.len()); - - for chunk in bgra_data.chunks_exact(4) { - if chunk.len() == 4 { - rgba_data.push(chunk[0]); // R - rgba_data.push(chunk[1]); // G - rgba_data.push(chunk[2]); // B - rgba_data.push(chunk[3]); // A - } - } - - rgba_data -} fn save_rgba_image_to_temp( image_data: &arboard::ImageData, @@ -67,8 +26,6 @@ fn save_rgba_image_to_temp( .into()); } - // let processed_data = convert_bgra_to_rgba(rgba_data); - let Some(img_buffer) = ImageBuffer::, _>::from_raw(width, height, rgba_data.to_vec()) else { let error_message = format!("Failed to create ImageBuffer from raw data"); @@ -77,7 +34,7 @@ fn save_rgba_image_to_temp( }; let file_name = format!( - "PicSharp_Clipboard_{}.{}", + "kbot_clipboard_{}.{}", nanoid::nanoid!(), candidate_format ); @@ -91,148 +48,32 @@ fn save_rgba_image_to_temp( Ok(temp_path.to_string_lossy().to_string()) } -fn save_image_data_to_temp(image_data: &[u8]) -> Result> { - let detected_format = detect_image_format(image_data); - - let cursor = Cursor::new(image_data); - let reader = ImageReader::new(cursor).with_guessed_format()?; - let dynamic_image = reader.decode()?; - - let (save_format, extension) = match detected_format { - Some(format) => (format, format_to_extension(format)), - None => (ImageFormat::Png, "png"), - }; - - let temp_dir = std::env::temp_dir(); - let file_name = format!("picsharp_clipboard_{}.{}", nanoid::nanoid!(), extension); - let temp_path = temp_dir.join(file_name); - - dynamic_image.save_with_format(&temp_path, save_format)?; - - Ok(temp_path.to_string_lossy().to_string()) -} - -async fn download_image_from_url(url: &Url) -> Result> { - let response = reqwest::get(url.as_str()).await?; - - if !response.status().is_success() { - return Err(format!("Failed to download image from URL: {}", response.status()).into()); - } - - let image_data = response.bytes().await?; - info!( - "[download_image_from_url] Downloaded image from URL: {} (size: {} bytes)", - url, - image_data.len() - ); - - save_image_data_to_temp(&image_data) -} - -fn is_image_url(url: &Url) -> bool { - let path = url.path().to_lowercase(); - let image_extensions = ["jpg", "jpeg", "png", "gif", "webp", "tiff", "avif"]; - - if let Some(extension) = path.split('.').last() { - if image_extensions.contains(&extension) { - return true; - } - } - - // check common image hosts - if let Some(host) = url.host_str() { - let image_hosts = [ - "imgur.com", - "i.imgur.com", - "github.com", - "raw.githubusercontent.com", - "unsplash.com", - "images.unsplash.com", - "pixabay.com", - "cdn.pixabay.com", - "pexels.com", - "images.pexels.com", - ]; - - for host_pattern in &image_hosts { - if host.contains(host_pattern) { - return true; - } - } - } - - false -} - -// parse file paths (support multiple paths, separated by newlines) -fn parse_file_paths(text: &str) -> Vec { - text.lines() - .map(|line| line.trim()) - .filter(|line| !line.is_empty()) - .map(|line| line.to_string()) - .collect() -} - -// canonicalize path -fn canonicalize_path(path_str: &str) -> Result> { - let path = Path::new(path_str); - - // handle UNC paths and drive letters on Windows - #[cfg(target_os = "windows")] - { - if path_str.starts_with("\\\\") || path_str.contains(":\\") { - return Ok(dunce::canonicalize(path)?); - } - } - - // for relative paths, convert to absolute paths - if path.is_relative() { - let current_dir = std::env::current_dir()?; - Ok(dunce::canonicalize(current_dir.join(path))?) - } else { - Ok(dunce::canonicalize(path)?) - } -} - -fn is_image_file(path: &Path) -> bool { - if !path.exists() || !path.is_file() { - return false; - } - - if let Some(extension) = path.extension() { - if let Some(ext_str) = extension.to_str() { - let ext_lower = ext_str.to_lowercase(); - let image_extensions = ["jpg", "jpeg", "png", "gif", "webp", "tiff", "avif"]; - if image_extensions.contains(&ext_lower.as_str()) { - return true; - } - } - } - - if let Ok(file_data) = fs::read(path) { - if detect_image_format(&file_data).is_some() { - return true; - } - } - - false -} - async fn parse_clipboard_images( candidate_format: Option, temp_dir: Option, ) -> Result, Box> { let mut clipboard = Clipboard::new()?; let mut result: Vec = Vec::new(); - let mut candidate_format = candidate_format.unwrap_or_else(|| "png".to_string()); + let candidate_format = candidate_format.unwrap_or_else(|| "png".to_string()); let temp_dir = temp_dir.unwrap_or_else(|| std::env::temp_dir()); + // Try to get file list first (drag and drop from explorer/finder) if let Ok(file_list) = clipboard.get().file_list() { info!("[parse_clipboard_images] File list: {:?}", file_list); for file in file_list { - result.push(file.to_string_lossy().to_string()); + let path_str = file.to_string_lossy().to_string(); + // Filter for image files only + if path_str.to_lowercase().ends_with(".png") + || path_str.to_lowercase().ends_with(".jpg") + || path_str.to_lowercase().ends_with(".jpeg") + || path_str.to_lowercase().ends_with(".gif") + || path_str.to_lowercase().ends_with(".webp") { + result.push(path_str); + } } - } else if let Ok(image_data) = clipboard.get().image() { + } + // If no files, try to get image data (screenshot, copied image) + else if let Ok(image_data) = clipboard.get().image() { info!( "[parse_clipboard_images] Image data: {}x{}, {} bytes", image_data.width, @@ -242,40 +83,13 @@ async fn parse_clipboard_images( let temp_path = save_rgba_image_to_temp(&image_data, &candidate_format, &temp_dir)?; result.push(temp_path); } - // else if let Ok(text) = clipboard.get().text() { - // info!("[parse_clipboard_images] Text: {}", text); - // let text = text.trim(); - - // if text.starts_with("http://") || text.starts_with("https://") { - // if let Ok(url) = Url::parse(text) { - // if is_image_url(&url) { - // match download_image_from_url(&url).await { - // Ok(temp_path) => result.push(temp_path), - // Err(e) => error!( - // "[parse_clipboard_images] Failed to download image from URL: {}", - // e - // ), - // } - // } - // } - // } else { - // let paths = parse_file_paths(text); - // for path_str in paths { - // if let Ok(path) = canonicalize_path(&path_str) { - // if is_image_file(&path) { - // result.push(path.to_string_lossy().to_string()); - // } - // } - // } - // } - // } Ok(result) } #[tauri::command] pub async fn ipc_parse_clipboard_images( - app: AppHandle, + _app: AppHandle, candidate_format: String, temp_dir: String, ) -> Response { diff --git a/packages/kbot/gui/tauri-app/src/components/PromptForm.tsx b/packages/kbot/gui/tauri-app/src/components/PromptForm.tsx index 3345c7ff..bc9c4196 100644 --- a/packages/kbot/gui/tauri-app/src/components/PromptForm.tsx +++ b/packages/kbot/gui/tauri-app/src/components/PromptForm.tsx @@ -120,6 +120,34 @@ const PromptForm: React.FC = ({ const [historyImages, setHistoryImages] = useState([]); const [historyCurrentIndex, setHistoryCurrentIndex] = useState(0); + // Handle clipboard paste for images + const handlePaste = async (e: React.ClipboardEvent) => { + try { + log.info('📋 Paste event detected, checking for images...'); + + // Try to get images from clipboard using our Tauri command + const result = await tauriApi.parseClipboardImages('png'); + + if (result?.success && result.paths && result.paths.length > 0) { + log.info(`📋 Found ${result.paths.length} image(s) in clipboard`, { + paths: result.paths.map(p => p.split(/[/\\]/).pop()) + }); + + // Add the clipboard images to the files + addFiles(result.paths); + + // Prevent default paste behavior since we handled it + e.preventDefault(); + } else if (result?.error) { + log.warn('📋 No images found in clipboard or error occurred', { error: result.error }); + } else { + log.debug('📋 No images in clipboard, allowing normal paste'); + } + } catch (error) { + log.error('📋 Failed to parse clipboard', { error: (error as Error).message }); + } + }; + // Load images for file history when modal opens useEffect(() => { if (showFileHistory && fileHistory.length > 0) { @@ -196,7 +224,8 @@ const PromptForm: React.FC = ({ id="prompt-input" value={prompt} onChange={(e) => setPrompt(e.currentTarget.value)} - placeholder="Describe the image you want to generate or edit..." + onPaste={handlePaste} + placeholder="Describe the image you want to generate or edit... (Ctrl+V to paste images)" className="w-full bg-transparent border-none outline-none min-h-[120px] resize-none text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400" rows={5} onKeyDown={(e) => { diff --git a/packages/kbot/gui/tauri-app/src/lib/tauriApi.ts b/packages/kbot/gui/tauri-app/src/lib/tauriApi.ts index dcd3937e..288ea229 100644 --- a/packages/kbot/gui/tauri-app/src/lib/tauriApi.ts +++ b/packages/kbot/gui/tauri-app/src/lib/tauriApi.ts @@ -236,4 +236,10 @@ export const tauriApi = { safeInvoke(TauriCommand.SEND_IPC_MESSAGE, { messageType, data }), requestFileDeletion: (data: { path: string }) => safeInvoke(TauriCommand.REQUEST_FILE_DELETION, data), + + parseClipboardImages: (candidateFormat: string = 'png', tempDir?: string) => + safeInvoke<{ success: boolean; paths?: string[]; error?: string }>('ipc_parse_clipboard_images', { + candidate_format: candidateFormat, + temp_dir: tempDir || '' + }), };