kbot image tauri banana chat
This commit is contained in:
parent
4a86eb0929
commit
d2ac7da6e4
BIN
packages/kbot/cat_out.png
Normal file
BIN
packages/kbot/cat_out.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
File diff suppressed because one or more lines are too long
BIN
packages/kbot/dist/win-64/tauri-app.exe
vendored
BIN
packages/kbot/dist/win-64/tauri-app.exe
vendored
Binary file not shown.
5748
packages/kbot/gui/tauri-app/package-lock.json
generated
5748
packages/kbot/gui/tauri-app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,52 +1,53 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"dist": "npm run tauri build",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/cli": "^2.8.4",
|
||||
"@tauri-apps/plugin-barcode-scanner": "^2.4.0",
|
||||
"@tauri-apps/plugin-biometric": "^2.3.0",
|
||||
"@tauri-apps/plugin-cli": "^2.4.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||
"@tauri-apps/plugin-geolocation": "^2.2.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.0",
|
||||
"@tauri-apps/plugin-haptics": "^2.2.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.2",
|
||||
"@tauri-apps/plugin-nfc": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.1",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-os": "^2.3.1",
|
||||
"@tauri-apps/plugin-process": "^2.3.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.1",
|
||||
"@tauri-apps/plugin-store": "^2.4.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@tauri-apps/plugin-upload": "^2.3.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"dist": "npm run tauri build",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/cli": "^2.8.4",
|
||||
"@tauri-apps/plugin-barcode-scanner": "^2.4.0",
|
||||
"@tauri-apps/plugin-biometric": "^2.3.0",
|
||||
"@tauri-apps/plugin-cli": "^2.4.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||
"@tauri-apps/plugin-geolocation": "^2.2.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2.3.0",
|
||||
"@tauri-apps/plugin-haptics": "^2.2.0",
|
||||
"@tauri-apps/plugin-http": "^2.5.2",
|
||||
"@tauri-apps/plugin-nfc": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.3.1",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-os": "^2.3.1",
|
||||
"@tauri-apps/plugin-process": "^2.3.0",
|
||||
"@tauri-apps/plugin-shell": "^2.3.1",
|
||||
"@tauri-apps/plugin-store": "^2.4.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"@tauri-apps/plugin-upload": "^2.3.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,12 @@
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-confirm",
|
||||
"dialog:allow-message",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -16,6 +16,12 @@
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-confirm",
|
||||
"dialog:allow-message",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -3,43 +3,48 @@ use serde::{Serialize, Deserialize};
|
||||
use dirs;
|
||||
|
||||
struct CliArgs(std::sync::Mutex<Vec<String>>);
|
||||
struct ApiKey(std::sync::Mutex<Option<String>>);
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Payload {
|
||||
prompt: String,
|
||||
files: Vec<String>,
|
||||
dst: String,
|
||||
}
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
#[tauri::command]
|
||||
fn submit_prompt(prompt: &str, files: Vec<String>, window: tauri::Window) {
|
||||
println!("[RUST LOG]: submit_prompt command called.");
|
||||
println!("[RUST LOG]: - Prompt: {}", prompt);
|
||||
println!("[RUST LOG]: - Files: {:?}", files);
|
||||
fn submit_prompt(prompt: &str, files: Vec<String>, dst: &str, window: tauri::Window) {
|
||||
// Use eprintln! for debug logs so they go to stderr, not stdout
|
||||
eprintln!("[RUST LOG]: submit_prompt command called.");
|
||||
eprintln!("[RUST LOG]: - Prompt: {}", prompt);
|
||||
eprintln!("[RUST LOG]: - Files: {:?}", files);
|
||||
eprintln!("[RUST LOG]: - Dst: {}", dst);
|
||||
|
||||
let payload = Payload {
|
||||
prompt: prompt.to_string(),
|
||||
files,
|
||||
dst: dst.to_string(),
|
||||
};
|
||||
let json_payload = serde_json::to_string(&payload).unwrap();
|
||||
|
||||
println!("[RUST LOG]: - Sending JSON payload to stdout: {}", json_payload);
|
||||
println!("{}", json_payload); // The actual payload
|
||||
eprintln!("[RUST LOG]: - Sending JSON payload to stdout: {}", json_payload);
|
||||
println!("{}", json_payload); // The actual payload - ONLY this should go to stdout
|
||||
let _ = window.app_handle().exit(0);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_cli_args(state: tauri::State<'_, CliArgs>) -> Result<Vec<String>, String> {
|
||||
println!("[RUST LOG]: get_cli_args command called.");
|
||||
eprintln!("[RUST LOG]: get_cli_args command called.");
|
||||
let args = state.0.lock().unwrap().clone();
|
||||
println!("[RUST LOG]: - Returning args: {:?}", args);
|
||||
eprintln!("[RUST LOG]: - Returning args: {:?}", args);
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn resolve_path_relative_to_home(absolute_path: String) -> Result<String, String> {
|
||||
println!("[RUST LOG]: resolve_path_relative_to_home command called.");
|
||||
println!("[RUST LOG]: - Received absolute path: {}", absolute_path);
|
||||
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())?;
|
||||
|
||||
@ -49,7 +54,7 @@ fn resolve_path_relative_to_home(absolute_path: String) -> Result<String, String
|
||||
.ok_or_else(|| "Failed to calculate relative path from home directory".to_string())?;
|
||||
|
||||
let result = relative_path.to_string_lossy().to_string();
|
||||
println!("[RUST LOG]: - Resolved to path relative to home: {}", result);
|
||||
eprintln!("[RUST LOG]: - Resolved to path relative to home: {}", result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@ -58,17 +63,49 @@ fn log_error_to_console(error: &str) {
|
||||
eprintln!("[WebView ERROR forwarded from JS]: {}", error);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_api_key(state: tauri::State<'_, ApiKey>) -> Result<Option<String>, String> {
|
||||
eprintln!("[RUST LOG]: get_api_key command called.");
|
||||
let api_key = state.0.lock().unwrap().clone();
|
||||
eprintln!("[RUST LOG]: - Returning API key: {:?}", api_key.is_some());
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let cli_args: Vec<String> = std::env::args().skip(1).collect();
|
||||
|
||||
// Parse API key from CLI args (--api-key value)
|
||||
let mut api_key = None;
|
||||
let mut file_args = Vec::new();
|
||||
|
||||
let mut i = 0;
|
||||
while i < cli_args.len() {
|
||||
if cli_args[i] == "--api-key" && i + 1 < cli_args.len() {
|
||||
api_key = Some(cli_args[i + 1].clone());
|
||||
i += 2; // Skip both --api-key and its value
|
||||
} else {
|
||||
file_args.push(cli_args[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to environment variables if not provided via CLI
|
||||
if api_key.is_none() {
|
||||
api_key = std::env::var("GOOGLE_API_KEY")
|
||||
.or_else(|_| std::env::var("GEMINI_API_KEY"))
|
||||
.or_else(|_| std::env::var("API_KEY"))
|
||||
.ok();
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.manage(CliArgs(std::sync::Mutex::new(cli_args)))
|
||||
.manage(CliArgs(std::sync::Mutex::new(file_args)))
|
||||
.manage(ApiKey(std::sync::Mutex::new(api_key)))
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.invoke_handler(tauri::generate_handler![submit_prompt, log_error_to_console, get_cli_args, resolve_path_relative_to_home])
|
||||
.invoke_handler(tauri::generate_handler![submit_prompt, log_error_to_console, get_cli_args, resolve_path_relative_to_home, get_api_key])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { readFile, BaseDirectory } from '@tauri-apps/plugin-fs';
|
||||
import {
|
||||
homeDir, audioDir, cacheDir, configDir, dataDir, localDataDir, desktopDir,
|
||||
documentDir, downloadDir, executableDir, fontDir, pictureDir, publicDir,
|
||||
resourceDir, runtimeDir, templateDir, videoDir, appConfigDir, appDataDir,
|
||||
appLocalDataDir, appCacheDir, appLogDir, tempDir
|
||||
} from '@tauri-apps/api/path';
|
||||
import { open, save } from '@tauri-apps/plugin-dialog';
|
||||
import { readFile, writeFile, BaseDirectory } from '@tauri-apps/plugin-fs';
|
||||
// Path imports commented out since they're not currently used
|
||||
// import {
|
||||
// homeDir, audioDir, cacheDir, configDir, dataDir, localDataDir, desktopDir,
|
||||
// documentDir, downloadDir, executableDir, fontDir, pictureDir, publicDir,
|
||||
// resourceDir, runtimeDir, templateDir, videoDir, appConfigDir, appDataDir,
|
||||
// appLocalDataDir, appCacheDir, appLogDir, tempDir
|
||||
// } from '@tauri-apps/api/path';
|
||||
// import { lookup } from 'mime-types';
|
||||
|
||||
interface ImageFile {
|
||||
@ -15,6 +16,14 @@ interface ImageFile {
|
||||
src: string;
|
||||
}
|
||||
|
||||
interface GeneratedImage {
|
||||
id: string;
|
||||
src: string;
|
||||
prompt: string;
|
||||
timestamp: number;
|
||||
saved?: boolean;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: number[]) {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
@ -28,6 +37,24 @@ function arrayBufferToBase64(buffer: number[]) {
|
||||
function App() {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [files, setFiles] = useState<ImageFile[]>([]);
|
||||
const [dst, setDst] = useState("");
|
||||
const [chatMode, setChatMode] = useState(false);
|
||||
const [generatedImages, setGeneratedImages] = useState<GeneratedImage[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
|
||||
const generateDefaultDst = (fileCount: number, firstFilePath?: string) => {
|
||||
if (fileCount === 1 && firstFilePath) {
|
||||
const parsedPath = firstFilePath.split(/[/\\]/).pop() || 'image';
|
||||
const nameWithoutExt = parsedPath.replace(/\.[^/.]+$/, "");
|
||||
return `${nameWithoutExt}_out.png`;
|
||||
} else {
|
||||
const now = new Date();
|
||||
const hours = now.getHours().toString().padStart(2, '0');
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||
return `data_${hours}_${minutes}.png`;
|
||||
}
|
||||
};
|
||||
|
||||
const addFiles = async (newPaths: string[]) => {
|
||||
const uniqueNewPaths = newPaths.filter(newPath => !files.some(f => f.path === newPath));
|
||||
@ -54,6 +81,130 @@ function App() {
|
||||
setFiles(prevFiles => [...prevFiles, ...newImageFiles]);
|
||||
};
|
||||
|
||||
const removeFile = (pathToRemove: string) => {
|
||||
setFiles(prevFiles => prevFiles.filter(file => file.path !== pathToRemove));
|
||||
console.log('Removed file:', pathToRemove);
|
||||
};
|
||||
|
||||
const clearAllFiles = () => {
|
||||
setFiles([]);
|
||||
console.log('Cleared all files');
|
||||
};
|
||||
|
||||
const generateImage = async (promptText: string, includeImages: ImageFile[] = []) => {
|
||||
if (!apiKey) {
|
||||
console.error('No API key available for image generation');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
console.log('Starting image generation...');
|
||||
console.log('API key available:', !!apiKey);
|
||||
console.log('Include images count:', includeImages.length);
|
||||
|
||||
// Use the same approach as the backend - import GoogleGenerativeAI dynamically
|
||||
console.log('Importing GoogleGenerativeAI...');
|
||||
const { GoogleGenerativeAI } = await import('@google/generative-ai');
|
||||
console.log('GoogleGenerativeAI imported successfully');
|
||||
|
||||
const ai = new GoogleGenerativeAI(apiKey);
|
||||
console.log('GoogleGenerativeAI client created');
|
||||
|
||||
const model = ai.getGenerativeModel({ model: 'gemini-2.5-flash-image-preview' });
|
||||
console.log('Model obtained:', 'gemini-2.5-flash-image-preview');
|
||||
|
||||
if (includeImages.length > 0) {
|
||||
// Image editing - similar to editImage function
|
||||
const imageParts: any[] = [];
|
||||
|
||||
for (const imageFile of includeImages) {
|
||||
// Extract base64 data from the data URL
|
||||
const base64Match = imageFile.src.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (base64Match) {
|
||||
const mimeType = base64Match[1];
|
||||
const base64Data = base64Match[2];
|
||||
imageParts.push({
|
||||
inlineData: {
|
||||
mimeType,
|
||||
data: base64Data
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const textPart = { text: promptText };
|
||||
const promptParts = [...imageParts, textPart];
|
||||
|
||||
console.log('Making API call for image editing with parts:', promptParts.length);
|
||||
const result = await model.generateContent(promptParts);
|
||||
console.log('API call completed for image editing');
|
||||
const response = result.response;
|
||||
const parts = response.candidates?.[0]?.content?.parts;
|
||||
|
||||
for (const part of parts || []) {
|
||||
if ('inlineData' in part) {
|
||||
const inlineData = part.inlineData;
|
||||
if (inlineData) {
|
||||
const generatedImage: GeneratedImage = {
|
||||
id: Date.now().toString(),
|
||||
src: `data:${inlineData.mimeType};base64,${inlineData.data}`,
|
||||
prompt: promptText,
|
||||
timestamp: Date.now(),
|
||||
saved: false
|
||||
};
|
||||
|
||||
setGeneratedImages(prev => [...prev, generatedImage]);
|
||||
console.log('Generated new image (edit):', generatedImage.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Image creation - similar to createImage function
|
||||
console.log('Making API call for image creation with prompt:', promptText);
|
||||
const result = await model.generateContent(promptText);
|
||||
console.log('API call completed for image creation');
|
||||
const response = result.response;
|
||||
const parts = response.candidates?.[0]?.content?.parts;
|
||||
|
||||
for (const part of parts || []) {
|
||||
if ('inlineData' in part) {
|
||||
const inlineData = part.inlineData;
|
||||
if (inlineData) {
|
||||
const generatedImage: GeneratedImage = {
|
||||
id: Date.now().toString(),
|
||||
src: `data:${inlineData.mimeType};base64,${inlineData.data}`,
|
||||
prompt: promptText,
|
||||
timestamp: Date.now(),
|
||||
saved: false
|
||||
};
|
||||
|
||||
setGeneratedImages(prev => [...prev, generatedImage]);
|
||||
console.log('Generated new image (create):', generatedImage.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No image found in API response');
|
||||
} catch (error) {
|
||||
console.error('Image generation failed:', error);
|
||||
const err: any = error;
|
||||
const errorDetails = {
|
||||
message: err?.message || 'Unknown error',
|
||||
name: err?.name || 'Unknown',
|
||||
stack: err?.stack || 'No stack trace',
|
||||
toString: error?.toString() || 'No string representation'
|
||||
};
|
||||
console.error('Error details:', errorDetails);
|
||||
invoke('log_error_to_console', { error: `[Frontend Error] Image generation failed: ${JSON.stringify(errorDetails)}` });
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCliArgs = async () => {
|
||||
try {
|
||||
@ -61,6 +212,17 @@ function App() {
|
||||
if (cliFiles && cliFiles.length > 0) {
|
||||
addFiles(cliFiles);
|
||||
}
|
||||
|
||||
// Try to get API key from CLI environment or args
|
||||
try {
|
||||
const key = await invoke<string>('get_api_key');
|
||||
if (key) {
|
||||
setApiKey(key);
|
||||
setChatMode(true); // Enable chat mode if API key is available
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("No API key provided, using simple mode");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get CLI arguments:", e);
|
||||
}
|
||||
@ -68,9 +230,18 @@ function App() {
|
||||
fetchCliArgs();
|
||||
}, []);
|
||||
|
||||
// Auto-generate default destination file when files change
|
||||
useEffect(() => {
|
||||
if (files.length > 0 && !dst) {
|
||||
const defaultDst = generateDefaultDst(files.length, files[0]?.path);
|
||||
setDst(defaultDst);
|
||||
}
|
||||
}, [files, dst]);
|
||||
|
||||
useEffect(() => {
|
||||
const logBaseDirectories = async () => {
|
||||
console.log('--- Resolving BaseDirectory Paths ---');
|
||||
/*
|
||||
try { console.log('Home:', await homeDir()); } catch (e) { console.error('homeDir:', e); }
|
||||
try { console.log('Audio:', await audioDir()); } catch (e) { console.error('audioDir:', e); }
|
||||
try { console.log('Cache:', await cacheDir()); } catch (e) { console.error('cacheDir:', e); }
|
||||
@ -95,16 +266,74 @@ function App() {
|
||||
try { console.log('AppLocalData:', await appLocalDataDir()); } catch (e) { console.error('appLocalDataDir:', e); }
|
||||
try { console.log('AppCache:', await appCacheDir()); } catch (e) { console.error('appCacheDir:', e); }
|
||||
try { console.log('AppLog:', await appLogDir()); } catch (e) { console.error('appLogDir:', e); }
|
||||
console.log('------------------------------------');
|
||||
*/ console.log('------------------------------------');
|
||||
};
|
||||
|
||||
logBaseDirectories();
|
||||
}, []);
|
||||
|
||||
async function submit() {
|
||||
await invoke("submit_prompt", { prompt, files: files.map(f => f.path) });
|
||||
console.log('=== SUBMIT DEBUG ===');
|
||||
console.log('chatMode:', chatMode);
|
||||
console.log('prompt:', prompt);
|
||||
console.log('files:', files.map(f => f.path));
|
||||
console.log('dst:', dst);
|
||||
console.log('==================');
|
||||
|
||||
if (chatMode && apiKey) {
|
||||
// Chat mode: generate image directly in frontend
|
||||
await generateImage(prompt, files);
|
||||
setPrompt(''); // Clear prompt after generation
|
||||
} else {
|
||||
// Simple mode: send to CLI
|
||||
try {
|
||||
const result = await invoke("submit_prompt", { prompt, files: files.map(f => f.path), dst });
|
||||
console.log('Submit result:', result);
|
||||
} catch (error) {
|
||||
console.error('Submit error:', error);
|
||||
invoke('log_error_to_console', { error: `[Frontend Error] Submit failed: ${JSON.stringify(error)}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveGeneratedImage = async (generatedImage: GeneratedImage) => {
|
||||
try {
|
||||
const defaultFilename = `generated_${generatedImage.id}.png`;
|
||||
const filePath = await save({
|
||||
defaultPath: defaultFilename,
|
||||
filters: [{
|
||||
name: 'Images',
|
||||
extensions: ['png', 'jpg', 'jpeg']
|
||||
}]
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
// Convert base64 to binary data
|
||||
const base64Match = generatedImage.src.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (base64Match) {
|
||||
const base64Data = base64Match[2];
|
||||
const binaryData = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
|
||||
|
||||
await writeFile(filePath, binaryData);
|
||||
|
||||
// Mark as saved
|
||||
setGeneratedImages(prev =>
|
||||
prev.map(img =>
|
||||
img.id === generatedImage.id
|
||||
? { ...img, saved: true }
|
||||
: img
|
||||
)
|
||||
);
|
||||
|
||||
console.log('Image saved to:', filePath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save image:', error);
|
||||
invoke('log_error_to_console', { error: `[Frontend Error] Failed to save image: ${JSON.stringify(error)}` });
|
||||
}
|
||||
};
|
||||
|
||||
async function openFilePicker() {
|
||||
try {
|
||||
const selected = await open({
|
||||
@ -123,10 +352,53 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openSaveDialog() {
|
||||
try {
|
||||
// Extract current filename from dst for default, or use smart default
|
||||
const currentFilename = dst.split(/[/\\]/).pop() || generateDefaultDst(files.length, files[0]?.path);
|
||||
|
||||
const selected = await save({
|
||||
defaultPath: currentFilename,
|
||||
filters: [{
|
||||
name: 'Images',
|
||||
extensions: ['png', 'jpg']
|
||||
}]
|
||||
});
|
||||
|
||||
if (selected) {
|
||||
setDst(selected);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save dialog error:', e);
|
||||
invoke('log_error_to_console', { error: `[Frontend Error] Save dialog error: ${JSON.stringify(e)}` });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="bg-gray-900 text-white min-h-screen flex flex-col items-center justify-center p-4">
|
||||
<div className="w-full max-w-4xl">
|
||||
<h1 className="text-4xl font-bold mb-6 text-center">Image Prompt</h1>
|
||||
<div className="w-full max-w-6xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-4xl font-bold">Image Prompt</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-sm ${!chatMode ? 'text-white' : 'text-gray-400'}`}>Simple</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChatMode(!chatMode)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
chatMode ? 'bg-blue-600' : 'bg-gray-600'
|
||||
}`}
|
||||
disabled={!apiKey}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
chatMode ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className={`text-sm ${chatMode ? 'text-white' : 'text-gray-400'}`}>Chat</span>
|
||||
{!apiKey && <span className="text-xs text-gray-500">(API key required)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
className="flex flex-col items-center"
|
||||
onSubmit={(e) => {
|
||||
@ -142,6 +414,24 @@ function App() {
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={dst}
|
||||
onChange={(e) => setDst(e.target.value)}
|
||||
placeholder="Output file path (e.g., output.png)"
|
||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg p-4 focus:outline-none focus:ring-2 focus:ring-blue-500 transition duration-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openSaveDialog}
|
||||
className="bg-gray-700 hover:bg-gray-600 text-white font-bold py-3 px-4 rounded-lg transition duration-200 whitespace-nowrap"
|
||||
title="Save as..."
|
||||
>
|
||||
💾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openFilePicker}
|
||||
@ -152,11 +442,34 @@ function App() {
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="w-full bg-gray-800 border border-gray-700 rounded-lg p-4 mb-4">
|
||||
<h2 className="text-lg font-semibold mb-2">Selected Images:</h2>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h2 className="text-lg font-semibold">Selected Images:</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAllFiles}
|
||||
className="bg-red-600 hover:bg-red-700 text-white text-sm px-3 py-1 rounded transition duration-200"
|
||||
title="Remove all images"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{files.map((file) => (
|
||||
<div key={file.path} className="relative aspect-square">
|
||||
<img src={file.src} alt={file.path} className="object-cover w-full h-full rounded-md" />
|
||||
<div key={file.path} className="group">
|
||||
<div className="relative aspect-square">
|
||||
<img src={file.src} alt={file.path} className="object-cover w-full h-full rounded-md" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(file.path)}
|
||||
className="absolute top-1 right-1 bg-red-600 hover:bg-red-700 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||||
title={`Remove ${file.path.split(/[/\\]/).pop()}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-300 truncate text-center" title={file.path}>
|
||||
{file.path.split(/[/\\]/).pop()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -166,10 +479,54 @@ function App() {
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg transition duration-200"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
Generate
|
||||
{isGenerating ? 'Generating...' : 'Generate'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Generated Images Display (Chat Mode Only) */}
|
||||
{chatMode && generatedImages.length > 0 && (
|
||||
<div className="w-full mt-8">
|
||||
<h2 className="text-2xl font-bold mb-4">Generated Images</h2>
|
||||
<div className="space-y-6">
|
||||
{generatedImages.map((genImage) => (
|
||||
<div key={genImage.id} className="bg-gray-800 border border-gray-700 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-300 mb-1">
|
||||
<strong>Prompt:</strong> {genImage.prompt}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(genImage.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveGeneratedImage(genImage)}
|
||||
className={`ml-4 px-3 py-1 rounded text-sm font-medium transition duration-200 ${
|
||||
genImage.saved
|
||||
? 'bg-green-600 text-white cursor-default'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
}`}
|
||||
disabled={genImage.saved}
|
||||
title={genImage.saved ? 'Already saved' : 'Save image'}
|
||||
>
|
||||
{genImage.saved ? '✓ Saved' : '💾 Save'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={genImage.src}
|
||||
alt={`Generated: ${genImage.prompt}`}
|
||||
className="max-w-full max-h-96 rounded-md border border-gray-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
import * as path from 'node:path';
|
||||
import { sync as write } from '@polymech/fs/write';
|
||||
import { sync as exists } from '@polymech/fs/exists';
|
||||
import { ILogObj, Logger } from 'tslog';
|
||||
|
||||
import { isArray, isString } from '@polymech/core/primitives';
|
||||
|
||||
import { OptionsSchema } from '../zod_schema.js';
|
||||
@ -12,6 +12,42 @@ import { prompt as resolvePrompt } from '../prompt.js';
|
||||
import { variables } from '../variables.js';
|
||||
import { resolve } from '@polymech/commons';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { loadConfig } from '../config.js';
|
||||
|
||||
function getGuiAppPath(): string {
|
||||
|
||||
// Get the directory of this script file, then navigate to the GUI app
|
||||
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
|
||||
// On Windows, URL.pathname can have an extra leading slash, so we need to handle it
|
||||
const cleanScriptDir = process.platform === 'win32' && scriptDir.startsWith('/')
|
||||
? scriptDir.substring(1)
|
||||
: scriptDir;
|
||||
|
||||
const packageRoot = path.resolve(cleanScriptDir, '..', '..');
|
||||
|
||||
// Determine platform-specific subdirectory and executable name
|
||||
let platformDir: string;
|
||||
let executableName: string;
|
||||
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
platformDir = 'win-64';
|
||||
executableName = 'tauri-app.exe';
|
||||
break;
|
||||
case 'darwin':
|
||||
platformDir = 'osx-64';
|
||||
executableName = 'tauri-app';
|
||||
break;
|
||||
case 'linux':
|
||||
platformDir = 'linux-64';
|
||||
executableName = 'tauri-app';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${process.platform}`);
|
||||
}
|
||||
|
||||
return path.join(packageRoot, 'dist', platformDir, executableName);
|
||||
}
|
||||
|
||||
export const ImageOptionsSchema = () => {
|
||||
const baseSchema = OptionsSchema().pick({
|
||||
@ -35,7 +71,7 @@ export const ImageOptionsSchema = () => {
|
||||
|
||||
async function launchGuiAndGetPrompt(argv: any): Promise<string | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const guiAppPath = path.join(process.cwd(), 'dist', 'win-64', 'tauri-app.exe');
|
||||
const guiAppPath = getGuiAppPath();
|
||||
console.log('guiAppPath', guiAppPath);
|
||||
if (!exists(guiAppPath)) {
|
||||
return reject(new Error(`GUI application not found at: ${guiAppPath}. Please build it first by running 'npm run tauri build' in 'gui/tauri-app'.`));
|
||||
@ -47,23 +83,42 @@ async function launchGuiAndGetPrompt(argv: any): Promise<string | null> {
|
||||
const absoluteIncludes = includes.map(p => path.resolve(p));
|
||||
args.push(...absoluteIncludes);
|
||||
}
|
||||
|
||||
// Pass API key as argument (similar to how we pass include files)
|
||||
const config = loadConfig(argv);
|
||||
const apiKey = argv.api_key || config?.google?.key;
|
||||
if (apiKey) {
|
||||
args.push('--api-key', apiKey);
|
||||
}
|
||||
|
||||
const tauriProcess = spawn(guiAppPath, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
|
||||
tauriProcess.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
const chunk = data.toString();
|
||||
console.log('GUI stdout chunk:', JSON.stringify(chunk));
|
||||
output += chunk;
|
||||
});
|
||||
|
||||
tauriProcess.stderr.on('data', (data) => {
|
||||
console.error(`Tauri app error: ${data}`);
|
||||
const chunk = data.toString();
|
||||
console.log('GUI stderr chunk:', JSON.stringify(chunk));
|
||||
errorOutput += chunk;
|
||||
});
|
||||
|
||||
tauriProcess.on('close', (code) => {
|
||||
console.log('GUI process closed with code:', code);
|
||||
console.log('Final stdout:', JSON.stringify(output));
|
||||
console.log('Final stderr:', JSON.stringify(errorOutput));
|
||||
|
||||
if (code === 0) {
|
||||
resolve(output.trim() || null);
|
||||
const trimmedOutput = output.trim();
|
||||
console.log('Attempting to parse JSON:', JSON.stringify(trimmedOutput));
|
||||
resolve(trimmedOutput || null);
|
||||
} else {
|
||||
reject(new Error(`Tauri app exited with code ${code}`));
|
||||
reject(new Error(`Tauri app exited with code ${code}. stderr: ${errorOutput}`));
|
||||
}
|
||||
});
|
||||
|
||||
@ -86,6 +141,9 @@ export const imageCommand = async (argv: any) => {
|
||||
if (payload.files && payload.files.length > 0) {
|
||||
argv.include = payload.files;
|
||||
}
|
||||
if (payload.dst) {
|
||||
argv.dst = payload.dst;
|
||||
}
|
||||
} else {
|
||||
logger.info('GUI closed without providing a prompt.');
|
||||
return;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user