kbot tauri ipc

This commit is contained in:
babayaga 2025-09-17 19:41:09 +02:00
parent 599b4ce836
commit 2c5bacfae0
15 changed files with 2945 additions and 210 deletions

BIN
packages/kbot/cat.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

File diff suppressed because one or more lines are too long

57
packages/kbot/dist-in/lib/ipc.d.ts vendored Normal file
View File

@ -0,0 +1,57 @@
export interface IPCMessage {
type: 'counter' | 'debug' | 'image' | 'prompt_submit' | 'error' | 'init_data' | 'gui_message';
data: any;
timestamp?: number;
id?: string;
}
export interface ImagePayload {
base64: string;
mimeType: string;
filename?: string;
}
export interface PromptSubmitPayload {
prompt: string;
files: string[];
dst: string;
}
export interface CounterPayload {
count: number;
message?: string;
}
export interface DebugPayload {
level: 'info' | 'warn' | 'error' | 'debug';
message: string;
data?: any;
}
export interface InitDataPayload {
prompt?: string;
dst?: string;
apiKey?: string;
files?: string[];
}
export interface GuiMessagePayload {
message: string;
timestamp: number;
source: string;
}
export declare class IPCClient {
private guiAppPath;
private process;
private messageHandlers;
private counter;
private isReady;
constructor(guiAppPath: string);
launch(args?: string[]): Promise<void>;
private handleMessage;
onMessage(type: string, handler: (message: IPCMessage) => void): void;
sendMessage(message: IPCMessage): void;
sendDebugMessage(level: DebugPayload['level'], message: string, data?: any): void;
sendCounterMessage(count?: number, message?: string): void;
sendImageMessage(base64: string, mimeType: string, filename?: string): void;
sendInitData(prompt?: string, dst?: string, apiKey?: string, files?: string[]): void;
sendIPCViaTauri(messageType: string, data: any): Promise<void>;
waitForPromptSubmit(): Promise<PromptSubmitPayload | null>;
close(): void;
}
export declare function getGuiAppPath(): string;
export declare function createIPCClient(): IPCClient;

File diff suppressed because one or more lines are too long

Binary file not shown.

235
packages/kbot/docs/ipc.md Normal file
View File

@ -0,0 +1,235 @@
# IPC Communication Documentation
## Overview
This document describes the Inter-Process Communication (IPC) system between the `images.ts` command and the Tauri GUI application.
## Current Architecture
### Components
1. **images.ts** - Node.js CLI command process
2. **tauri-app.exe** - Tauri desktop application (Rust + Web frontend)
3. **IPC Client** - Node.js library for managing communication
4. **Tauri Commands** - Rust functions exposed to frontend
5. **React Frontend** - TypeScript/React UI
## Communication Flows
### 1. Initial Configuration Passing
```mermaid
sequenceDiagram
participant CLI as images.ts CLI
participant IPC as IPC Client
participant Tauri as tauri-app.exe
participant Frontend as React Frontend
participant Rust as Tauri Rust Backend
CLI->>IPC: createIPCClient()
CLI->>IPC: launch([])
IPC->>Tauri: spawn tauri-app.exe
Note over CLI,Rust: Initial data sending
CLI->>IPC: sendInitData(prompt, dst, apiKey, files)
IPC->>Tauri: stdout: {"type":"init_data","data":{...}}
Tauri->>Frontend: IPC message handling
Frontend->>Frontend: setPrompt(), setDst(), setApiKey()
Note over CLI,Rust: Image data sending
CLI->>IPC: sendImageMessage(base64, mimeType, filename)
IPC->>Tauri: stdout: {"type":"image","data":{...}}
Tauri->>Frontend: IPC message handling
Frontend->>Frontend: addFiles([{path, src}])
```
### 2. GUI to CLI Messaging (Current Implementation)
```mermaid
sequenceDiagram
participant Frontend as React Frontend
participant Rust as Tauri Rust Backend
participant Tauri as tauri-app.exe
participant IPC as IPC Client
participant CLI as images.ts CLI
Note over Frontend,CLI: User sends message from GUI
Frontend->>Frontend: sendMessageToImages()
Frontend->>Rust: safeInvoke('send_message_to_stdout', message)
Rust->>Rust: send_message_to_stdout command
Rust->>Tauri: println!(message) to stdout
Tauri->>IPC: stdout data received
IPC->>IPC: parse JSON from stdout
IPC->>CLI: handleMessage() callback
CLI->>CLI: gui_message handler
Note over Frontend,CLI: Echo response
CLI->>IPC: sendDebugMessage('Echo: ...')
IPC->>Tauri: stdout: {"type":"debug","data":{...}}
Tauri->>Frontend: IPC message handling
Frontend->>Frontend: addDebugMessage()
```
### 3. Console Message Forwarding
```mermaid
sequenceDiagram
participant Frontend as React Frontend
participant Console as Console Hijack
participant Rust as Tauri Rust Backend
participant CLI as images.ts CLI
Note over Frontend,CLI: Console messages forwarding
Frontend->>Console: console.log/error/warn()
Console->>Console: hijacked in main.tsx
Console->>Rust: safeInvoke('log_error_to_console')
Rust->>Rust: log_error_to_console command
Rust->>CLI: eprintln! to stderr
CLI->>CLI: stderr logging
```
## Current Issues & Complexity
### Problem 1: Multiple Communication Channels
We have **3 different communication paths**:
1. **IPC Messages** (structured): `{"type": "init_data", "data": {...}}`
2. **Raw GUI Messages** (via Tauri command): `{"message": "hello", "source": "gui"}`
3. **Console Forwarding** (via hijacking): All console.* calls
### Problem 2: Inconsistent Message Formats
- **From CLI to GUI**: Structured IPC messages
- **From GUI to CLI**: Raw JSON via stdout
- **Console logs**: String messages via stderr
### Problem 3: Complex Parsing Logic
The IPC client has to handle multiple message formats:
```typescript
// Structured IPC message
if (parsed.type && parsed.data !== undefined) {
this.handleMessage(parsed as IPCMessage);
}
// Raw GUI message
else if (parsed.message && parsed.source === 'gui') {
const ipcMessage: IPCMessage = {
type: 'gui_message',
data: parsed,
// ...
};
this.handleMessage(ipcMessage);
}
```
## Recommended Simplification
### Option 1: Unified IPC Messages
**All communication should use the same format:**
```typescript
interface IPCMessage {
type: 'init_data' | 'gui_message' | 'debug' | 'image' | 'prompt_submit';
data: any;
timestamp: number;
id: string;
}
```
**Sequence:**
```mermaid
sequenceDiagram
participant Frontend as React Frontend
participant Rust as Tauri Rust Backend
participant CLI as images.ts CLI
Note over Frontend,CLI: Unified messaging
Frontend->>Rust: safeInvoke('send_ipc_message', {type, data})
Rust->>CLI: stdout: {"type":"gui_message","data":{...},"timestamp":...}
CLI->>Rust: stdout: {"type":"debug","data":{...},"timestamp":...}
Rust->>Frontend: handleMessage(message)
```
### Option 2: Direct Tauri IPC (Recommended)
**Use Tauri's built-in event system:**
```mermaid
sequenceDiagram
participant Frontend as React Frontend
participant Rust as Tauri Rust Backend
participant CLI as images.ts CLI
Note over Frontend,CLI: Tauri events
Frontend->>Rust: emit('gui-message', data)
Rust->>CLI: HTTP/WebSocket/Named Pipe
CLI->>Rust: HTTP/WebSocket/Named Pipe response
Rust->>Frontend: emit('cli-response', data)
```
## Current File Structure
```
src/
├── lib/ipc.ts # IPC Client (Node.js side)
├── commands/images.ts # CLI command with IPC integration
gui/tauri-app/
├── src/App.tsx # React frontend with IPC handling
├── src/main.tsx # Console hijacking setup
└── src-tauri/src/lib.rs # Tauri commands and state management
```
## Configuration Passing Methods
### Method 1: CLI Arguments (Original)
```bash
tauri-app.exe --api-key "key" --dst "output.png" --prompt "text" file1.png file2.png
```
### Method 2: IPC Messages (Current)
```typescript
ipcClient.sendInitData(prompt, dst, apiKey, files);
```
### Method 3: Environment Variables
```bash
export API_KEY="key"
export DST="output.png"
tauri-app.exe
```
### Method 4: Temporary Config File
```typescript
// Write config.json
fs.writeFileSync('/tmp/config.json', JSON.stringify({prompt, dst, apiKey}));
// Launch app
spawn('tauri-app.exe', ['--config', '/tmp/config.json']);
```
## Recommendations
1. **Simplify to single communication method** - Either all CLI args OR all IPC messages
2. **Remove console hijacking** - Use proper logging/debug channels
3. **Use consistent message format** - Same structure for all message types
4. **Consider Tauri's built-in IPC** - Events, commands, or invoke system
5. **Separate concerns** - Config passing vs. runtime messaging
## Questions for Review
1. Do we need bidirectional messaging during runtime, or just initial config passing?
2. Should console messages be forwarded, or use proper debug channels?
3. Is the complexity worth it, or should we use simpler CLI args + file output?
4. Could we use Tauri's built-in event system instead of stdout parsing?
## Current Status
- ✅ Config passing works (init_data messages)
- ✅ Image passing works (base64 via IPC)
- ✅ GUI → CLI messaging works (via Tauri command)
- ✅ CLI → GUI messaging works (debug messages)
- ❌ System is overly complex with multiple communication paths
- ❌ Inconsistent message formats
- ❌ Console hijacking adds unnecessary complexity

View File

@ -0,0 +1,379 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Profile, PlotStatus, TemperatureProfileCommand } from '@/types';
import BezierEditor from '@/components/profiles/bezier/BezierEditor';
import { Edit, Trash2, Play, Pause, StopCircle, Copy, CopyPlus } from 'lucide-react';
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { T, translate } from '../../i18n';
import { useModbus } from '../../contexts/ModbusContext';
import { getSlaveIdFromGroup, findCoilForProfile } from '../../lib/controllerUtils';
import { PV_REGISTER_NAME_SUFFIX, PROFILE_REGISTER_NAMES } from '../../constants';
import { useNavigate } from 'react-router-dom';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface ProfileCardProps {
profile: Profile;
onDelete: (id: string) => void;
onCommand: (profileSlot: number, command: TemperatureProfileCommand) => void;
zones?: { id: string, name: string }[];
onApplyToZone?: (profileId: string, zoneId: string) => void;
onDuplicate: (profileToDuplicate: Profile) => void;
onCopyTo: (profileToCopy: Profile) => void;
canDuplicate?: boolean;
}
const formatDuration = (ms: number): string => {
if (isNaN(ms) || ms < 0) return '00h 00min 00s';
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
// For durations less than 1 hour, show minutes and seconds
if (hours === 0) {
const paddedMinutes = minutes.toString().padStart(2, '0');
const paddedSeconds = seconds.toString().padStart(2, '0');
return `${paddedMinutes}min ${paddedSeconds}s`;
}
// For durations 1+ hours, show hours and minutes
const paddedHours = hours.toString().padStart(2, '0');
const paddedMinutes = minutes.toString().padStart(2, '0');
return `${paddedHours}h ${paddedMinutes}min`;
};
// Helper function to get status text
const getStatusText = (status: PlotStatus): string => {
switch (status) {
case PlotStatus.IDLE:
return 'Idle';
case PlotStatus.RUNNING:
return 'Running';
case PlotStatus.INITIALIZING:
return 'Warmup';
case PlotStatus.PAUSED:
return 'Paused';
case PlotStatus.FINISHED:
return 'Finished';
case PlotStatus.STOPPED:
return 'Stopped';
default:
return 'Unknown';
}
};
const ProfileCard: React.FC<ProfileCardProps> = ({
profile,
onDelete,
onCommand,
zones,
onApplyToZone,
onDuplicate,
onCopyTo,
canDuplicate
}) => {
const navigate = useNavigate();
const profileId = String(profile.slot);
const [plainTextDescription, setPlainTextDescription] = useState('');
const { registers, settings, coils, updateCoil } = useModbus();
const [isToggling, setIsToggling] = useState(false);
const enableCoil = useMemo(() => {
if (!coils || !profile.name) return null;
return findCoilForProfile(
coils,
profile.name,
profile.slot,
PROFILE_REGISTER_NAMES.ENABLED
);
}, [coils, profile.name, profile.slot]);
const isEnabled = enableCoil ? enableCoil.value : false;
// console.log("isEnabled",profile.enabled,enableCoil);
const handleToggle = async (newState: boolean) => {
if (!enableCoil) return;
setIsToggling(true);
try {
await updateCoil(enableCoil.address, newState);
} catch (error) {
console.error(`Failed to toggle profile ${profile.slot}`, error);
} finally {
setIsToggling(false);
}
};
// Create a name-to-slaveid map from the partition config
const controllerNameToSlaveIdMap = React.useMemo(() => {
const map = new Map<string, number>();
if (!settings) return map;
settings.partitions.forEach(partition => {
partition.controllers?.forEach(controller => {
if (controller.name) {
map.set(controller.name, controller.slaveid);
}
});
});
return map;
}, [settings]);
const getControllerPv = (controllerName: string): number | string => {
const slaveid = controllerNameToSlaveIdMap.get(controllerName);
if (slaveid === undefined) {
// Fallback or error for controllers not in the static config
// This might happen if profiles are associated with controllers not in PARTITION_CONFIG
const fallbackSlaveId = getSlaveIdFromGroup(controllerName);
if(fallbackSlaveId) {
// You can decide if you want to support this fallback.
// For now, let's just log a warning and return N/A if not in the map.
}
console.warn(`Could not determine slaveid for controller from settings: ${controllerName}`,controllerNameToSlaveIdMap);
return 'N/A';
}
const pvRegister = registers.find(
reg => getSlaveIdFromGroup(reg.group) === slaveid && reg.name.endsWith(PV_REGISTER_NAME_SUFFIX)
);
if (pvRegister && typeof pvRegister.value === 'number') {
return pvRegister.value.toFixed(1);
} else {
// It's possible for the register to exist but the value not be a number yet.
// Or for the register not to be found immediately.
console.error(`PV register not found for controller ${controllerName} (slaveid: ${slaveid})`);
}
return 'N/A';
};
useEffect(() => {
const getPlainTextFromMarkdown = async (markdown: string = '') => {
return markdown;
};
if (profile.description) {
getPlainTextFromMarkdown(profile.description);
} else {
setPlainTextDescription('');
}
}, [profile.description]);
return (
<Card className="w-full flex flex-col glass-card border-0 glass-shimmer hover:shadow-2xl transition-all duration-500">
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-lg font-semibold accent-text">
{profile.name}
</CardTitle>
<div className="flex items-center space-x-3">
<div className={`status-indicator ${isEnabled ? 'status-connected' : 'status-disconnected'}`}></div>
<Switch
id={`enable-profile-${profile.slot}`}
checked={isEnabled}
onCheckedChange={handleToggle}
disabled={isToggling}
className="data-[state=checked]:bg-emerald-500"
/>
<Label htmlFor={`enable-profile-${profile.slot}`} className="text-xs text-slate-600 dark:text-white/70 font-medium">
{isEnabled ? <T>Enabled</T> : <T>Disabled</T>}
</Label>
</div>
</div>
<div className="text-xs text-slate-600 dark:text-white/60 pt-1">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<div>
<span className="text-slate-600 dark:text-slate-300">{formatDuration(profile.duration)} <T>Total</T></span>
</div>
<div>
<span className="font-semibold text-indigo-600 dark:text-indigo-300"><T>{getStatusText(profile.status)}</T></span>
</div>
<div>
<span className="text-slate-600 dark:text-slate-300">{profile.max}°C <T>Max</T></span>
</div>
{(profile.status === PlotStatus.RUNNING || profile.status === PlotStatus.PAUSED || profile.status === PlotStatus.INITIALIZING) && (
<>
{profile.currentTemp !== undefined && (
<div>
<span className="font-semibold text-cyan-600 dark:text-cyan-300">{profile.currentTemp}°C <T>Now</T></span>
</div>
)}
{profile.elapsed !== undefined && (
<div>
<span className="font-semibold text-emerald-600 dark:text-emerald-300">{formatDuration(profile.elapsed)} <T>Elapsed</T></span>
</div>
)}
{profile.remaining !== undefined && (
<div>
<span className="font-semibold text-amber-600 dark:text-amber-300">{formatDuration(profile.remaining)} <T>Remaining</T></span>
</div>
)}
</>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-2 flex-grow">
{plainTextDescription && (
<p className="text-sm text-slate-600 dark:text-white/60 mb-3">
{plainTextDescription}
</p>
)}
{profile.associatedControllerNames && profile.associatedControllerNames.length > 0 && (
<div className="pt-2 glass-card p-3">
<p className="text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">{translate("Associated Controllers:")}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
{profile.associatedControllerNames.map((name, index) => {
const slaveid = controllerNameToSlaveIdMap.get(name);
return (
<div key={index} className="flex justify-between items-center">
<span className="text-slate-600 dark:text-white/70">{name}{slaveid !== undefined ? ` (${slaveid})` : ''}:</span>
<span className="font-semibold text-indigo-600 dark:text-indigo-300">{getControllerPv(name)}°C</span>
</div>
);
})}
</div>
</div>
)}
<div className="flex-grow">
<BezierEditor
controlPoints={profile.controlPoints}
onChange={() => {}}
max={profile.max}
duration={profile.duration}
readonly
showGridLabels={false}
className="h-40 w-full"
elapsedTime={profile.elapsed}
isRunning={profile.status === PlotStatus.RUNNING || profile.status === PlotStatus.PAUSED || profile.status === PlotStatus.INITIALIZING}
currentTemp={profile.currentTemp}
/>
</div>
{zones && zones.length > 0 && onApplyToZone && (
<div className="space-y-2 pt-3 glass-card p-3">
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">Apply to zone:</p>
<Select onValueChange={(zoneId) => onApplyToZone(profileId, zoneId)}>
<SelectTrigger className="w-full glass-input">
<SelectValue placeholder={<T>Select zone</T>} />
</SelectTrigger>
<SelectContent className="glass-panel border-0">
{zones.map((zone) => (
<SelectItem key={zone.id} value={zone.id} className="text-slate-700 dark:text-white/90 hover:bg-slate-100 dark:hover:bg-white/10">
{zone.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between gap-2 pt-4">
<Button
variant="outline"
size="icon"
onClick={() => navigate(`/profiles/edit/${profile.slot}`)}
title={translate("Edit Profile")}
className="glass-button"
>
<Edit className="h-4 w-4" />
</Button>
{(profile.status === PlotStatus.IDLE || profile.status === PlotStatus.FINISHED || profile.status === PlotStatus.STOPPED) && (
<Button
className="flex-1 gap-1 status-gradient-connected text-white border-0 hover:shadow-lg transition-all duration-300"
onClick={() => {
console.log("start profile", profile.slot);
onCommand(profile.slot, TemperatureProfileCommand.START);
}}
title={translate("Start Profile")}
disabled={false}
>
<Play className="h-4 w-4" />
<T>Start</T>
</Button>
)}
{(profile.status === PlotStatus.RUNNING || profile.status === PlotStatus.INITIALIZING) && (
<Button
className="flex-1 gap-1 bg-gradient-to-r from-amber-400 to-orange-500 text-white border-0 hover:shadow-lg transition-all duration-300"
onClick={() => onCommand(profile.slot, TemperatureProfileCommand.PAUSE)}
title={translate("Pause Profile")}
disabled={profile.status === PlotStatus.INITIALIZING}
>
<Pause className="h-4 w-4" />
<T>Pause</T>
</Button>
)}
{profile.status === PlotStatus.PAUSED && (
<Button
className="flex-1 gap-1 bg-gradient-to-r from-cyan-400 to-blue-500 text-white border-0 hover:shadow-lg transition-all duration-300"
onClick={() => onCommand(profile.slot, TemperatureProfileCommand.RESUME)}
title={translate("Resume Profile")}
disabled={false}
>
<Play className="h-4 w-4" />
<T>Resume</T>
</Button>
)}
{(profile.status === PlotStatus.RUNNING || profile.status === PlotStatus.PAUSED || profile.status === PlotStatus.INITIALIZING) && (
<Button
variant="outline"
size="icon"
className="glass-button border-red-400/50 text-red-300 hover:bg-red-500/20"
onClick={() => onCommand(profile.slot, TemperatureProfileCommand.STOP)}
title={translate("Stop Profile")}
disabled={false}
>
<StopCircle className="h-4 w-4" />
</Button>
)}
<Button
variant="outline"
size="icon"
onClick={() => onDuplicate(profile)}
title={translate("Duplicate Profile")}
className="glass-button border-cyan-400/50 text-cyan-300 hover:bg-cyan-500/20"
disabled={!canDuplicate}
>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onCopyTo(profile)}
title={translate("Copy to existing slot...")}
className="glass-button border-indigo-400/50 text-indigo-300 hover:bg-indigo-500/20"
>
<CopyPlus className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onDelete(profileId)}
className="glass-button border-red-400/50 text-red-300 hover:bg-red-500/20"
title={translate("Delete Profile")}
>
<Trash2 className="h-4 w-4" />
</Button>
</CardFooter>
</Card>
);
};
export default ProfileCard;

View File

@ -4046,6 +4046,7 @@ version = "0.1.0"
dependencies = [
"dirs 5.0.1",
"pathdiff",
"rand 0.8.5",
"serde",
"serde_json",
"tauri",

View File

@ -27,4 +27,5 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
pathdiff = "0.2.3"
dirs = "5.0.1"
rand = "0.8"

View File

@ -1,9 +1,8 @@
use tauri::Manager;
use tauri::{Manager, Emitter};
use serde::{Serialize, Deserialize};
use dirs;
struct CliArgs(std::sync::Mutex<Vec<String>>);
struct ApiKey(std::sync::Mutex<Option<String>>);
struct Counter(std::sync::Mutex<u32>);
struct DebugMessages(std::sync::Mutex<Vec<DebugPayload>>);
#[derive(Serialize, Deserialize)]
struct Payload {
@ -12,6 +11,36 @@ struct Payload {
dst: String,
}
#[derive(Serialize, Deserialize)]
struct IPCMessage {
#[serde(rename = "type")]
message_type: String,
data: serde_json::Value,
timestamp: Option<u64>,
id: Option<String>,
}
#[derive(Serialize, Deserialize)]
struct CounterPayload {
count: u32,
message: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
struct DebugPayload {
level: String,
message: String,
data: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize)]
struct ImagePayload {
base64: String,
#[serde(rename = "mimeType")]
mime_type: String,
filename: Option<String>,
}
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn submit_prompt(prompt: &str, files: Vec<String>, dst: &str, window: tauri::Window) {
@ -33,79 +62,321 @@ fn submit_prompt(prompt: &str, files: Vec<String>, dst: &str, window: tauri::Win
let _ = window.app_handle().exit(0);
}
#[tauri::command]
fn get_cli_args(state: tauri::State<'_, CliArgs>) -> Result<Vec<String>, String> {
eprintln!("[RUST LOG]: get_cli_args command called.");
let args = state.0.lock().unwrap().clone();
eprintln!("[RUST LOG]: - Returning args: {:?}", args);
Ok(args)
}
#[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 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)
fn increment_counter(state: tauri::State<'_, Counter>) -> Result<u32, String> {
eprintln!("[RUST LOG]: increment_counter command called.");
let mut counter = state.0.lock().unwrap();
*counter += 1;
let current_value = *counter;
eprintln!("[RUST LOG]: - Counter incremented to: {}", current_value);
Ok(current_value)
}
#[tauri::command]
fn get_counter(state: tauri::State<'_, Counter>) -> Result<u32, String> {
eprintln!("[RUST LOG]: get_counter command called.");
let counter = state.0.lock().unwrap();
let current_value = *counter;
eprintln!("[RUST LOG]: - Current counter value: {}", current_value);
Ok(current_value)
}
#[tauri::command]
fn reset_counter(state: tauri::State<'_, Counter>) -> Result<u32, String> {
eprintln!("[RUST LOG]: reset_counter command called.");
let mut counter = state.0.lock().unwrap();
*counter = 0;
eprintln!("[RUST LOG]: - Counter reset to: 0");
Ok(0)
}
#[tauri::command]
fn add_debug_message(message: String, level: String, data: Option<serde_json::Value>, state: tauri::State<'_, DebugMessages>) -> Result<(), String> {
eprintln!("[RUST LOG]: add_debug_message command called.");
eprintln!("[RUST LOG]: - Level: {}", level);
eprintln!("[RUST LOG]: - Message: {}", message);
let debug_payload = DebugPayload {
level,
message,
data,
};
let mut messages = state.0.lock().unwrap();
messages.push(debug_payload);
// Keep only the last 100 messages to prevent memory issues
if messages.len() > 100 {
let len = messages.len();
messages.drain(0..len - 100);
}
eprintln!("[RUST LOG]: - Debug message added. Total messages: {}", messages.len());
Ok(())
}
#[tauri::command]
fn get_debug_messages(state: tauri::State<'_, DebugMessages>) -> Result<Vec<DebugPayload>, String> {
eprintln!("[RUST LOG]: get_debug_messages command called.");
let messages = state.0.lock().unwrap();
let result = messages.clone();
eprintln!("[RUST LOG]: - Returning {} debug messages", result.len());
Ok(result)
}
#[tauri::command]
fn clear_debug_messages(state: tauri::State<'_, DebugMessages>) -> Result<(), String> {
eprintln!("[RUST LOG]: clear_debug_messages command called.");
let mut messages = state.0.lock().unwrap();
messages.clear();
eprintln!("[RUST LOG]: - Debug messages cleared");
Ok(())
}
#[tauri::command]
fn send_ipc_message(message_type: String, data: serde_json::Value, _window: tauri::Window) -> Result<(), String> {
eprintln!("[RUST LOG]: send_ipc_message command called.");
eprintln!("[RUST LOG]: - Type: {}", message_type);
eprintln!("[RUST LOG]: - Data: {}", data);
let ipc_message = IPCMessage {
message_type,
data,
timestamp: Some(std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64),
id: Some(format!("msg_{}_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis(),
rand::random::<u32>())),
};
let json_message = serde_json::to_string(&ipc_message).unwrap();
eprintln!("[RUST LOG]: - Sending IPC message to stdout: {}", json_message);
println!("{}", json_message);
Ok(())
}
#[tauri::command]
fn send_message_to_stdout(message: String) -> Result<(), String> {
eprintln!("[RUST LOG]: send_message_to_stdout command called.");
eprintln!("[RUST LOG]: - Message: {}", message);
// Send directly to stdout (this will be captured by images.ts)
println!("{}", message);
Ok(())
}
#[tauri::command]
fn generate_image_via_backend(prompt: String, files: Vec<String>, dst: String) -> Result<(), String> {
eprintln!("[RUST LOG]: generate_image_via_backend called");
eprintln!("[RUST LOG]: - Prompt: {}", prompt);
eprintln!("[RUST LOG]: - Files: {:?}", files);
eprintln!("[RUST LOG]: - Dst: {}", dst);
// Send generation request to images.ts via stdout
let request = serde_json::json!({
"type": "generate_request",
"prompt": prompt,
"files": files,
"dst": dst,
"timestamp": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
});
println!("{}", serde_json::to_string(&request).unwrap());
eprintln!("[RUST LOG]: Generation request sent to images.ts");
Ok(())
}
#[tauri::command]
fn request_config_from_images(_app: tauri::AppHandle) -> Result<(), String> {
eprintln!("[RUST LOG]: request_config_from_images called");
// Send request to images.ts via stdout
let request = serde_json::json!({
"type": "config_request",
"timestamp": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
});
println!("{}", serde_json::to_string(&request).unwrap());
eprintln!("[RUST LOG]: Config request sent to images.ts");
Ok(())
}
#[tauri::command]
fn forward_config_to_frontend(prompt: Option<String>, dst: Option<String>, api_key: Option<String>, files: Vec<String>, app: tauri::AppHandle) -> Result<(), String> {
eprintln!("[RUST LOG]: forward_config_to_frontend called");
let config_data = serde_json::json!({
"prompt": prompt,
"dst": dst,
"apiKey": api_key,
"files": files
});
if let Err(e) = app.emit("config-received", &config_data) {
eprintln!("[RUST LOG]: Failed to emit config-received: {}", e);
return Err(format!("Failed to emit config: {}", e));
}
eprintln!("[RUST LOG]: Config forwarded to frontend successfully");
Ok(())
}
#[tauri::command]
fn forward_image_to_frontend(base64: String, mime_type: String, filename: String, app: tauri::AppHandle) -> Result<(), String> {
eprintln!("[RUST LOG]: forward_image_to_frontend called for {}", filename);
let image_data = serde_json::json!({
"base64": base64,
"mimeType": mime_type,
"filename": filename
});
if let Err(e) = app.emit("image-received", &image_data) {
eprintln!("[RUST LOG]: Failed to emit image-received: {}", e);
return Err(format!("Failed to emit image: {}", e));
}
eprintln!("[RUST LOG]: Image forwarded to frontend successfully");
Ok(())
}
#[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(file_args)))
.manage(ApiKey(std::sync::Mutex::new(api_key)))
let app = tauri::Builder::default()
.manage(Counter(std::sync::Mutex::new(0)))
.manage(DebugMessages(std::sync::Mutex::new(Vec::new())))
.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, get_api_key])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.invoke_handler(tauri::generate_handler![
submit_prompt,
log_error_to_console,
increment_counter,
get_counter,
reset_counter,
add_debug_message,
get_debug_messages,
clear_debug_messages,
send_message_to_stdout,
send_ipc_message,
request_config_from_images,
forward_config_to_frontend,
forward_image_to_frontend
])
.setup(|app| {
let app_handle = app.handle().clone();
// Listen for stdin commands from images.ts
std::thread::spawn(move || {
use std::io::{self, BufRead, BufReader};
let stdin = io::stdin();
let reader = BufReader::new(stdin);
eprintln!("[RUST LOG]: Stdin listener thread started");
for line in reader.lines() {
if let Ok(line_content) = line {
if line_content.trim().is_empty() {
continue;
}
// Log stdin command but hide binary data
let log_content = if line_content.contains("\"base64\"") {
format!("[COMMAND WITH BASE64 DATA - {} chars]", line_content.len())
} else {
line_content.clone()
};
eprintln!("[RUST LOG]: Received stdin command: {}", log_content);
// Parse command from images.ts
if let Ok(command) = serde_json::from_str::<serde_json::Value>(&line_content) {
if let Some(cmd) = command.get("cmd").and_then(|v| v.as_str()) {
eprintln!("[RUST LOG]: Processing command: {}", cmd);
match cmd {
"forward_config_to_frontend" => {
eprintln!("[RUST LOG]: Forwarding config to frontend");
eprintln!("[RUST LOG]: - prompt: {:?}", command.get("prompt"));
eprintln!("[RUST LOG]: - dst: {:?}", command.get("dst"));
eprintln!("[RUST LOG]: - apiKey: {:?}", command.get("apiKey").map(|_| "[REDACTED]"));
eprintln!("[RUST LOG]: - files: {:?}", command.get("files"));
let config_data = serde_json::json!({
"prompt": command.get("prompt"),
"dst": command.get("dst"),
"apiKey": command.get("apiKey"),
"files": command.get("files")
});
if let Err(e) = app_handle.emit("config-received", &config_data) {
eprintln!("[RUST LOG]: Failed to emit config-received: {}", e);
} else {
eprintln!("[RUST LOG]: Config emitted successfully to frontend");
}
}
"forward_image_to_frontend" => {
if let (Some(filename), Some(base64), Some(mime_type)) = (
command.get("filename").and_then(|v| v.as_str()),
command.get("base64").and_then(|v| v.as_str()),
command.get("mimeType").and_then(|v| v.as_str())
) {
eprintln!("[RUST LOG]: Forwarding image to frontend: {}", filename);
let image_data = serde_json::json!({
"base64": base64,
"mimeType": mime_type,
"filename": filename
});
if let Err(e) = app_handle.emit("image-received", &image_data) {
eprintln!("[RUST LOG]: Failed to emit image-received: {}", e);
} else {
eprintln!("[RUST LOG]: Image emitted successfully: {}", filename);
}
}
}
_ => {
eprintln!("[RUST LOG]: Unknown command: {}", cmd);
}
}
}
} else {
eprintln!("[RUST LOG]: Failed to parse command as JSON");
}
}
}
eprintln!("[RUST LOG]: Stdin listener thread ended");
});
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building tauri application");
app.run(|_app_handle, event| match event {
tauri::RunEvent::ExitRequested { api, .. } => {
api.prevent_exit();
}
_ => {}
});
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,275 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
/* Glassmorphism effects */
.glass-panel {
@apply backdrop-blur-xl bg-white/10 border border-gray-200/40 shadow-xl;
}
.dark .glass-panel {
@apply backdrop-blur-xl bg-black/20 border border-white/10 shadow-xl;
}
.glass-card {
@apply backdrop-blur-lg bg-white/8 border-2 border-slate-200/50 shadow-xl rounded-xl;
}
.glass-card:hover {
@apply border-slate-300/60 shadow-2xl;
}
.dark .glass-card {
@apply backdrop-blur-lg bg-black/10 border-2 border-white/15 shadow-xl rounded-xl;
}
.dark .glass-card:hover {
@apply border-white/25;
}
.glass-button {
@apply backdrop-blur-md bg-white/10 border-2 border-slate-300/40 hover:bg-white/20 transition-all duration-300 text-slate-700 hover:text-slate-800 shadow-sm;
}
.glass-button:hover {
@apply border-slate-400/50 shadow-md transform scale-[1.02];
}
.glass-button:active {
@apply transform scale-[0.98];
}
.dark .glass-button {
@apply backdrop-blur-md bg-black/10 border-2 border-white/20 hover:bg-black/20 text-white/90 hover:text-white;
}
.dark .glass-button:hover {
@apply border-white/30;
}
.glass-input {
@apply backdrop-blur-md bg-white/25 border-2 border-slate-400/70 focus:border-indigo-500/80 text-slate-800 placeholder:text-slate-500 shadow-lg;
transition: all 0.3s ease;
border-style: solid !important;
outline: none;
}
.glass-input:hover {
@apply border-slate-500/80 shadow-xl;
transform: translateY(-1px);
}
.glass-input:focus {
@apply ring-2 ring-indigo-400/50 border-indigo-500/90;
box-shadow: 0 0 30px rgba(99, 102, 241, 0.25), 0 10px 20px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.dark .glass-input {
@apply backdrop-blur-md bg-slate-800/40 border-2 border-slate-500/80 focus:border-cyan-400/80 text-slate-100 placeholder:text-slate-400;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
}
.dark .glass-input:hover {
@apply border-slate-400/90;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5);
}
.dark .glass-input:focus {
@apply ring-2 ring-cyan-400/50 border-cyan-400/90;
box-shadow: 0 0 30px rgba(34, 211, 238, 0.3), 0 10px 20px rgba(0, 0, 0, 0.5);
}
/* Sophisticated color scheme */
.bezier-curve-path {
@apply stroke-indigo-400;
}
.dark .bezier-curve-path {
@apply stroke-cyan-300;
}
.bezier-control-point {
@apply fill-slate-600 stroke-slate-300;
}
.dark .bezier-control-point {
@apply fill-slate-200 stroke-slate-600;
}
.bezier-control-point-selected {
@apply fill-indigo-500 stroke-indigo-300;
}
.dark .bezier-control-point-selected {
@apply fill-cyan-400 stroke-cyan-200;
}
.bezier-control-line {
@apply stroke-slate-400 stroke-2;
}
.dark .bezier-control-line {
@apply stroke-slate-300;
}
.bezier-handle-point {
@apply fill-indigo-500 stroke-indigo-300;
}
.dark .bezier-handle-point {
@apply fill-cyan-400 stroke-cyan-200;
}
.signal-timeline {
@apply relative h-[60px] rounded-xl glass-card p-0 px-[10px] cursor-cell;
}
.timeline-marker {
@apply absolute top-0 h-full w-[1px] bg-white/30 pointer-events-none;
}
.dark .timeline-marker {
@apply bg-white/20;
}
.timeline-marker-label {
@apply absolute top-[-18px] left-[-8px] text-[11px] text-slate-300 whitespace-nowrap font-medium;
}
.dark .timeline-marker-label {
@apply text-slate-400;
}
.timeline-cp {
@apply absolute top-1/2 rounded-full cursor-grab shadow-xl backdrop-blur-sm bg-indigo-500/80 border border-indigo-300/50 transition-all duration-200 hover:bg-indigo-400/90 hover:scale-110;
transform: translate(-50%, -50%);
}
.dark .timeline-cp {
@apply bg-cyan-400/80 border-cyan-200/50 hover:bg-cyan-300/90;
}
.timeline-playback-head {
@apply absolute top-0 bottom-0 w-2 z-20 bg-gradient-to-b from-indigo-400 to-indigo-600 rounded-full shadow-lg;
transform: translateX(-50%);
}
.dark .timeline-playback-head {
@apply from-cyan-300 to-cyan-500;
}
/* Enhanced glass containers */
.connection-panel {
@apply glass-panel rounded-2xl p-6 m-2;
}
.system-info-card {
@apply glass-card p-4 space-y-3;
}
.control-button {
@apply glass-button rounded-lg px-4 py-2 font-medium text-sm transition-all duration-300 hover:shadow-lg hover:scale-105;
}
/* Sophisticated gradients for accents */
.accent-gradient {
@apply bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500;
}
.dark .accent-gradient {
@apply from-cyan-400 via-blue-500 to-indigo-600;
}
.status-gradient-connected {
@apply bg-gradient-to-r from-emerald-400 to-cyan-500;
}
.status-gradient-disconnected {
@apply bg-gradient-to-r from-slate-400 to-slate-600;
}
.status-gradient-error {
@apply bg-gradient-to-r from-red-400 to-pink-500;
}
/* Advanced glass effects with animation */
.glass-panel-floating {
@apply glass-panel hover:shadow-2xl hover:scale-[1.02] transition-all duration-500;
}
.glass-shimmer {
position: relative;
overflow: hidden;
}
.glass-shimmer::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
transition: left 0.8s;
}
.glass-shimmer:hover::before {
left: 100%;
}
/* Status indicators with glass effect */
.status-indicator {
@apply w-3 h-3 rounded-full backdrop-blur-sm border border-white/30 shadow-lg;
}
.status-connected {
@apply bg-emerald-400/80;
box-shadow: 0 0 10px rgba(52, 211, 153, 0.5);
}
.status-disconnected {
@apply bg-slate-400/80;
box-shadow: 0 0 10px rgba(148, 163, 184, 0.3);
}
.status-error {
@apply bg-red-400/80;
box-shadow: 0 0 10px rgba(248, 113, 113, 0.5);
}
/* Floating elements with gentle animation */
.float-animation {
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
/* Sophisticated text styling */
.glass-text {
@apply text-transparent bg-clip-text bg-gradient-to-r from-slate-600 to-slate-800;
}
.dark .glass-text {
@apply from-slate-100 to-slate-300;
}
.accent-text {
@apply text-transparent bg-clip-text accent-gradient;
}
/* Enhanced input focus effects */
.glass-input:focus {
box-shadow: 0 0 20px rgba(99, 102, 241, 0.3);
}
.dark .glass-input:focus {
box-shadow: 0 0 20px rgba(34, 211, 238, 0.3);
}
/* Progress bar glassmorphism styling */
.glass-progress {
@apply bg-slate-200/50 dark:bg-slate-700/50;
}
.glass-progress [data-state="complete"] {
@apply bg-gradient-to-r from-indigo-500 to-cyan-500;
}
}

View File

@ -1,9 +1,30 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { invoke } from "@tauri-apps/api/core";
import "./styles.css";
import App from "./App";
// Check if we're running in Tauri environment
const isTauri = typeof window !== 'undefined' && '__TAURI__' in window;
let invoke: any;
// Safe invoke function that works in both Tauri and browser environments
const safeInvoke = (command: string, args?: any) => {
if (isTauri && invoke) {
return invoke(command, args);
} else {
console.log(`[Browser Mode] Would invoke: ${command}`, args);
return Promise.resolve(null);
}
};
// Only import Tauri APIs if we're in Tauri environment
if (isTauri) {
import("@tauri-apps/api/core").then(module => {
invoke = module.invoke;
});
}
window.addEventListener('error', event => {
const errorPayload = {
message: event.message,
@ -12,7 +33,7 @@ window.addEventListener('error', event => {
colno: event.colno,
error: event.error ? event.error.stack : 'No stack available',
};
invoke('log_error_to_console', { error: `[JavaScript Error] ${JSON.stringify(errorPayload, null, 2)}` });
safeInvoke('log_error_to_console', { error: `[JavaScript Error] ${JSON.stringify(errorPayload, null, 2)}` });
});
window.addEventListener('unhandledrejection', event => {
@ -22,19 +43,54 @@ window.addEventListener('unhandledrejection', event => {
stack: reason.stack || 'No stack available',
reason: String(reason)
};
invoke('log_error_to_console', { error: `[Unhandled Promise Rejection] ${JSON.stringify(errorPayload, null, 2)}` });
safeInvoke('log_error_to_console', { error: `[Unhandled Promise Rejection] ${JSON.stringify(errorPayload, null, 2)}` });
});
// Hijack console.log to send messages to the Rust backend
const originalConsoleLog = console.log;
console.log = (...args: any[]) => {
// Call the original console.log so messages still appear in the browser devtools
originalConsoleLog(...args);
// Format the arguments into a single string
const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)).join(' ');
// Send the message to the Rust backend
invoke('log_error_to_console', { error: `[WebView CONSOLE.LOG]: ${message}` });
};
// Hijack console.log to send messages to the Rust backend (only in Tauri mode)
if (isTauri) {
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
console.log = (...args: any[]) => {
// Call the original console.log so messages still appear in the browser devtools
originalConsoleLog(...args);
// Format the arguments into a single string
const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)).join(' ');
// Send the message to the Rust backend (with delay to ensure invoke is loaded)
setTimeout(() => {
safeInvoke('log_error_to_console', { error: `[WebView CONSOLE.LOG]: ${message}` });
}, 100);
};
console.error = (...args: any[]) => {
// Call the original console.error so messages still appear in the browser devtools
originalConsoleError(...args);
// Format the arguments into a single string
const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)).join(' ');
// Send the message to the Rust backend (with delay to ensure invoke is loaded)
setTimeout(() => {
safeInvoke('log_error_to_console', { error: `[WebView CONSOLE.ERROR]: ${message}` });
}, 100);
};
console.warn = (...args: any[]) => {
// Call the original console.warn so messages still appear in the browser devtools
originalConsoleWarn(...args);
// Format the arguments into a single string
const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)).join(' ');
// Send the message to the Rust backend (with delay to ensure invoke is loaded)
setTimeout(() => {
safeInvoke('log_error_to_console', { error: `[WebView CONSOLE.WARN]: ${message}` });
}, 100);
};
}
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>

View File

@ -2,6 +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 { readFileSync } from 'node:fs';
import { isArray, isString } from '@polymech/core/primitives';
@ -13,6 +14,7 @@ import { variables } from '../variables.js';
import { resolve } from '@polymech/commons';
import { spawn } from 'node:child_process';
import { loadConfig } from '../config.js';
import { createIPCClient, IPCClient } from '../lib/ipc.js';
function getGuiAppPath(): string {
@ -70,27 +72,42 @@ export const ImageOptionsSchema = () => {
}
async function launchGuiAndGetPrompt(argv: any): Promise<string | null> {
const logger = getLogger(argv);
return new Promise((resolve, reject) => {
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'.`));
}
// Prepare CLI arguments
const args: string[] = [];
// Add include files
if (argv.include) {
const includes = Array.isArray(argv.include) ? argv.include : [argv.include];
// Resolve all paths to absolute paths before passing them to the GUI
const absoluteIncludes = includes.map(p => path.resolve(p));
args.push(...absoluteIncludes);
}
// Pass API key as argument (similar to how we pass include files)
// Add API key
const config = loadConfig(argv);
const apiKey = argv.api_key || config?.google?.key;
if (apiKey) {
args.push('--api-key', apiKey);
}
// Add dst
if (argv.dst) {
args.push('--dst', argv.dst);
}
// Add prompt
if (argv.prompt) {
args.push('--prompt', argv.prompt);
}
const tauriProcess = spawn(guiAppPath, args, { stdio: ['pipe', 'pipe', 'pipe'] });
let output = '';
@ -98,8 +115,71 @@ async function launchGuiAndGetPrompt(argv: any): Promise<string | null> {
tauriProcess.stdout.on('data', (data) => {
const chunk = data.toString();
console.log('GUI stdout chunk:', JSON.stringify(chunk));
output += chunk;
// Check for config requests from the GUI
const lines = chunk.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const message = JSON.parse(line);
if (message.type === 'config_request') {
logger.info('📨 Received config request from GUI');
// Send config data back via Tauri commands
const config = loadConfig(argv);
const apiKey = argv.api_key || config?.google?.key;
const includes = argv.include ? (Array.isArray(argv.include) ? argv.include : [argv.include]) : [];
const absoluteIncludes = includes.map(p => path.resolve(p));
// Send config via stdin (Tauri will call forward_config_to_frontend)
const configResponse = {
cmd: 'forward_config_to_frontend',
prompt: argv.prompt || null,
dst: argv.dst || null,
apiKey: apiKey || null,
files: absoluteIncludes
};
tauriProcess.stdin?.write(JSON.stringify(configResponse) + '\n');
logger.info('📤 Sent config response to GUI');
// Send image data
for (const imagePath of absoluteIncludes) {
try {
if (exists(imagePath)) {
const imageBuffer = readFileSync(imagePath);
const base64 = imageBuffer.toString('base64');
const mimeType = path.extname(imagePath).toLowerCase() === '.png' ? 'image/png' : 'image/jpeg';
const filename = path.basename(imagePath);
// Verify base64 encoding
logger.info(`📸 Image encoding check: ${filename}`, {
bufferSize: imageBuffer.length,
base64Size: base64.length,
base64Sample: base64.substring(0, 50),
isValidBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(base64)
});
const imageResponse = {
cmd: 'forward_image_to_frontend',
base64,
mimeType,
filename
};
tauriProcess.stdin?.write(JSON.stringify(imageResponse) + '\n');
logger.info(`📤 Sent image data: ${filename} (${Math.round(base64.length/1024)}KB)`);
}
} catch (error) {
logger.error(`Failed to send image: ${imagePath}`, error.message);
}
}
}
} catch (e) {
// Not a JSON message, add to regular output
console.log('GUI stdout chunk:', JSON.stringify(line));
output += line + '\n';
}
}
});
tauriProcess.stderr.on('data', (data) => {

View File

@ -0,0 +1,316 @@
import { spawn, ChildProcess } from 'node:child_process';
import * as path from 'node:path';
import { sync as exists } from '@polymech/fs/exists';
export interface IPCMessage {
type: 'counter' | 'debug' | 'image' | 'prompt_submit' | 'error' | 'init_data' | 'gui_message';
data: any;
timestamp?: number;
id?: string;
}
export interface ImagePayload {
base64: string;
mimeType: string;
filename?: string;
}
export interface PromptSubmitPayload {
prompt: string;
files: string[];
dst: string;
}
export interface CounterPayload {
count: number;
message?: string;
}
export interface DebugPayload {
level: 'info' | 'warn' | 'error' | 'debug';
message: string;
data?: any;
}
export interface InitDataPayload {
prompt?: string;
dst?: string;
apiKey?: string;
files?: string[];
}
export interface GuiMessagePayload {
message: string;
timestamp: number;
source: string;
}
export class IPCClient {
private process: ChildProcess | null = null;
private messageHandlers: Map<string, (message: IPCMessage) => void> = new Map();
private counter = 0;
private isReady = false;
constructor(private guiAppPath: string) {}
async launch(args: string[] = []): Promise<void> {
return new Promise((resolve, reject) => {
if (!exists(this.guiAppPath)) {
return reject(new Error(`GUI application not found at: ${this.guiAppPath}`));
}
this.process = spawn(this.guiAppPath, args, {
stdio: ['pipe', 'pipe', 'pipe']
});
let output = '';
let errorOutput = '';
this.process.stdout?.on('data', (data) => {
const chunk = data.toString();
// Try to parse each line as a potential IPC message first
const lines = chunk.split('\n').filter(line => line.trim());
let hasIPCMessage = false;
for (const line of lines) {
try {
const parsed = JSON.parse(line);
// Check if it's a structured IPC message
if (parsed.type && parsed.data !== undefined) {
this.handleMessage(parsed as IPCMessage);
hasIPCMessage = true;
}
// Check if it's a raw GUI message (from console.log in browser mode)
else if (parsed.message && parsed.source === 'gui') {
const ipcMessage: IPCMessage = {
type: 'gui_message',
data: parsed,
timestamp: parsed.timestamp || Date.now(),
id: `gui_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
};
this.handleMessage(ipcMessage);
hasIPCMessage = true;
}
} catch (e) {
// Not a JSON message, continue
}
}
// Only log non-IPC stdout (to avoid binary data spam)
if (!hasIPCMessage && chunk.trim() && !chunk.includes('"base64"')) {
console.log('[IPC] GUI stdout:', chunk);
}
// Also check for GUI messages in stdout
if (!hasIPCMessage && chunk.trim()) {
const lines = chunk.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const possibleMessage = JSON.parse(line);
if (possibleMessage.type === 'gui_message') {
this.handleMessage(possibleMessage);
}
} catch (e) {
// Not a JSON message, ignore
}
}
}
output += chunk;
});
this.process.stderr?.on('data', (data) => {
const chunk = data.toString();
console.log('[IPC] GUI stderr:', chunk);
errorOutput += chunk;
});
this.process.on('close', (code) => {
console.log('[IPC] GUI process closed with code:', code);
if (code === 0) {
const trimmedOutput = output.trim();
resolve();
} else {
reject(new Error(`Tauri app exited with code ${code}. stderr: ${errorOutput}`));
}
});
this.process.on('error', (err) => {
reject(err);
});
// Give the process a moment to start
setTimeout(() => resolve(), 1000);
});
}
private handleMessage(message: IPCMessage) {
// Create a safe version for logging (without binary data)
const safeMessage = { ...message };
if (safeMessage.type === 'image' && safeMessage.data && typeof safeMessage.data === 'object' && 'base64' in safeMessage.data) {
safeMessage.data = {
...safeMessage.data,
base64: `[BASE64 DATA - ${(safeMessage.data.base64 as string).length} chars]`
};
}
console.log('[IPC] Received message:', safeMessage);
const handler = this.messageHandlers.get(message.type);
if (handler) {
handler(message);
} else {
console.log('[IPC] No handler for message type:', message.type);
}
}
onMessage(type: string, handler: (message: IPCMessage) => void) {
this.messageHandlers.set(type, handler);
}
sendMessage(message: IPCMessage) {
if (!this.process || !this.process.stdin) {
console.error('[IPC] Cannot send message: process not available');
return;
}
const messageWithMeta: IPCMessage = {
...message,
timestamp: Date.now(),
id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
};
const jsonMessage = JSON.stringify(messageWithMeta) + '\n';
// Create a safe version for logging (without binary data)
const safeMessage = { ...messageWithMeta };
if (safeMessage.type === 'image' && safeMessage.data && typeof safeMessage.data === 'object' && 'base64' in safeMessage.data) {
safeMessage.data = {
...safeMessage.data,
base64: `[BASE64 DATA - ${(safeMessage.data.base64 as string).length} chars]`
};
}
console.log('[IPC] Sending message:', JSON.stringify(safeMessage));
this.process.stdin.write(jsonMessage);
}
sendDebugMessage(level: DebugPayload['level'], message: string, data?: any) {
this.sendMessage({
type: 'debug',
data: { level, message, data } as DebugPayload
});
}
sendCounterMessage(count?: number, message?: string) {
if (count === undefined) {
this.counter++;
count = this.counter;
}
this.sendMessage({
type: 'counter',
data: { count, message } as CounterPayload
});
}
sendImageMessage(base64: string, mimeType: string, filename?: string) {
this.sendMessage({
type: 'image',
data: { base64, mimeType, filename } as ImagePayload
});
}
sendInitData(prompt?: string, dst?: string, apiKey?: string, files?: string[]) {
this.sendMessage({
type: 'init_data',
data: { prompt, dst, apiKey, files } as InitDataPayload
});
}
// Send IPC message via Tauri command (when GUI is ready)
async sendIPCViaTauri(messageType: string, data: any) {
if (!this.process) {
console.error('[IPC] Cannot send via Tauri: process not available');
return;
}
// Send a special command to tell the GUI to forward this as an event
const command = {
type: 'tauri_command',
command: 'forward_ipc_message',
args: { messageType, data }
};
const jsonMessage = JSON.stringify(command) + '\n';
console.log('[IPC] Sending Tauri command:', JSON.stringify({ ...command, args: { messageType, data: messageType === 'image' ? '[IMAGE DATA]' : data } }));
this.process.stdin?.write(jsonMessage);
}
async waitForPromptSubmit(): Promise<PromptSubmitPayload | null> {
return new Promise((resolve) => {
this.onMessage('prompt_submit', (message) => {
resolve(message.data as PromptSubmitPayload);
});
// Also handle the legacy format for backwards compatibility
this.process?.on('close', (code) => {
if (code === 0) {
// Try to parse the final output as legacy format
// This will be handled by the existing logic in images.ts
resolve(null);
}
});
});
}
close() {
if (this.process) {
this.process.kill();
this.process = null;
}
}
}
export 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);
}
// Utility function to create and configure an IPC client
export function createIPCClient(): IPCClient {
const guiAppPath = getGuiAppPath();
return new IPCClient(guiAppPath);
}