kbot tauri ipc
This commit is contained in:
parent
599b4ce836
commit
2c5bacfae0
BIN
packages/kbot/cat.jpg
Normal file
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
57
packages/kbot/dist-in/lib/ipc.d.ts
vendored
Normal 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;
|
||||
238
packages/kbot/dist-in/lib/ipc.js
Normal file
238
packages/kbot/dist-in/lib/ipc.js
Normal file
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.
235
packages/kbot/docs/ipc.md
Normal file
235
packages/kbot/docs/ipc.md
Normal 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
|
||||
379
packages/kbot/gui/tauri-app/ref/ProfileCard.tsx
Normal file
379
packages/kbot/gui/tauri-app/ref/ProfileCard.tsx
Normal 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;
|
||||
1
packages/kbot/gui/tauri-app/src-tauri/Cargo.lock
generated
1
packages/kbot/gui/tauri-app/src-tauri/Cargo.lock
generated
@ -4046,6 +4046,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"pathdiff",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@ -27,4 +27,5 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
pathdiff = "0.2.3"
|
||||
dirs = "5.0.1"
|
||||
rand = "0.8"
|
||||
|
||||
|
||||
@ -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
275
packages/kbot/gui/tauri-app/src/components.css
Normal file
275
packages/kbot/gui/tauri-app/src/components.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
316
packages/kbot/src/lib/ipc.ts
Normal file
316
packages/kbot/src/lib/ipc.ts
Normal 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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user