chore(frontend): fix corn task (#3)
This commit is contained in:
4296
ClawX-项目架构与版本大纲.md
4296
ClawX-项目架构与版本大纲.md
File diff suppressed because it is too large
Load Diff
@@ -70,6 +70,9 @@ export function registerIpcHandlers(
|
|||||||
|
|
||||||
// Skill config handlers (direct file access, no Gateway RPC)
|
// Skill config handlers (direct file access, no Gateway RPC)
|
||||||
registerSkillConfigHandlers();
|
registerSkillConfigHandlers();
|
||||||
|
|
||||||
|
// Cron task handlers (proxy to Gateway RPC)
|
||||||
|
registerCronHandlers(gatewayManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +103,183 @@ function registerSkillConfigHandlers(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway CronJob type (as returned by cron.list RPC)
|
||||||
|
*/
|
||||||
|
interface GatewayCronJob {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAtMs: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
|
||||||
|
payload: { kind: string; message?: string; text?: string };
|
||||||
|
delivery?: { mode: string; channel?: string; to?: string };
|
||||||
|
state: {
|
||||||
|
nextRunAtMs?: number;
|
||||||
|
lastRunAtMs?: number;
|
||||||
|
lastStatus?: string;
|
||||||
|
lastError?: string;
|
||||||
|
lastDurationMs?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform a Gateway CronJob to the frontend CronJob format
|
||||||
|
*/
|
||||||
|
function transformCronJob(job: GatewayCronJob) {
|
||||||
|
// Extract message from payload
|
||||||
|
const message = job.payload?.message || job.payload?.text || '';
|
||||||
|
|
||||||
|
// Build target from delivery info
|
||||||
|
const channelType = job.delivery?.channel || 'unknown';
|
||||||
|
const target = {
|
||||||
|
channelType,
|
||||||
|
channelId: channelType,
|
||||||
|
channelName: channelType,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build lastRun from state
|
||||||
|
const lastRun = job.state?.lastRunAtMs
|
||||||
|
? {
|
||||||
|
time: new Date(job.state.lastRunAtMs).toISOString(),
|
||||||
|
success: job.state.lastStatus === 'ok',
|
||||||
|
error: job.state.lastError,
|
||||||
|
duration: job.state.lastDurationMs,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Build nextRun from state
|
||||||
|
const nextRun = job.state?.nextRunAtMs
|
||||||
|
? new Date(job.state.nextRunAtMs).toISOString()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
name: job.name,
|
||||||
|
message,
|
||||||
|
schedule: job.schedule, // Pass the object through; frontend parseCronSchedule handles it
|
||||||
|
target,
|
||||||
|
enabled: job.enabled,
|
||||||
|
createdAt: new Date(job.createdAtMs).toISOString(),
|
||||||
|
updatedAt: new Date(job.updatedAtMs).toISOString(),
|
||||||
|
lastRun,
|
||||||
|
nextRun,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cron task IPC handlers
|
||||||
|
* Proxies cron operations to the Gateway RPC service.
|
||||||
|
* The frontend works with plain cron expression strings, but the Gateway
|
||||||
|
* expects CronSchedule objects ({ kind: "cron", expr: "..." }).
|
||||||
|
* These handlers bridge the two formats.
|
||||||
|
*/
|
||||||
|
function registerCronHandlers(gatewayManager: GatewayManager): void {
|
||||||
|
// List all cron jobs — transforms Gateway CronJob format to frontend CronJob format
|
||||||
|
ipcMain.handle('cron:list', async () => {
|
||||||
|
try {
|
||||||
|
const result = await gatewayManager.rpc('cron.list', { includeDisabled: true });
|
||||||
|
const data = result as { jobs?: GatewayCronJob[] };
|
||||||
|
const jobs = data?.jobs ?? [];
|
||||||
|
// Transform Gateway format to frontend format
|
||||||
|
return jobs.map(transformCronJob);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list cron jobs:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new cron job
|
||||||
|
ipcMain.handle('cron:create', async (_, input: {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
schedule: string;
|
||||||
|
target: { channelType: string; channelId: string; channelName: string };
|
||||||
|
enabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
// Transform frontend input to Gateway cron.add format
|
||||||
|
const gatewayInput = {
|
||||||
|
name: input.name,
|
||||||
|
schedule: { kind: 'cron', expr: input.schedule },
|
||||||
|
payload: { kind: 'agentTurn', message: input.message },
|
||||||
|
enabled: input.enabled ?? true,
|
||||||
|
wakeMode: 'next-heartbeat',
|
||||||
|
sessionTarget: 'isolated',
|
||||||
|
delivery: {
|
||||||
|
mode: 'announce',
|
||||||
|
channel: input.target.channelType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = await gatewayManager.rpc('cron.add', gatewayInput);
|
||||||
|
// Transform the returned job to frontend format
|
||||||
|
if (result && typeof result === 'object') {
|
||||||
|
return transformCronJob(result as GatewayCronJob);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create cron job:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update an existing cron job
|
||||||
|
ipcMain.handle('cron:update', async (_, id: string, input: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
// Transform schedule string to CronSchedule object if present
|
||||||
|
const patch = { ...input };
|
||||||
|
if (typeof patch.schedule === 'string') {
|
||||||
|
patch.schedule = { kind: 'cron', expr: patch.schedule };
|
||||||
|
}
|
||||||
|
// Transform message to payload format if present
|
||||||
|
if (typeof patch.message === 'string') {
|
||||||
|
patch.payload = { kind: 'agentTurn', message: patch.message };
|
||||||
|
delete patch.message;
|
||||||
|
}
|
||||||
|
const result = await gatewayManager.rpc('cron.update', { id, patch });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update cron job:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a cron job
|
||||||
|
ipcMain.handle('cron:delete', async (_, id: string) => {
|
||||||
|
try {
|
||||||
|
const result = await gatewayManager.rpc('cron.remove', { id });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete cron job:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle a cron job enabled/disabled
|
||||||
|
ipcMain.handle('cron:toggle', async (_, id: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const result = await gatewayManager.rpc('cron.update', { id, patch: { enabled } });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle cron job:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger a cron job manually
|
||||||
|
ipcMain.handle('cron:trigger', async (_, id: string) => {
|
||||||
|
try {
|
||||||
|
const result = await gatewayManager.rpc('cron.run', { id, mode: 'force' });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to trigger cron job:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UV-related IPC handlers
|
* UV-related IPC handlers
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ function App() {
|
|||||||
|
|
||||||
{/* Main application routes */}
|
{/* Main application routes */}
|
||||||
<Route element={<MainLayout />}>
|
<Route element={<MainLayout />}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Chat />} />
|
||||||
<Route path="/chat" element={<Chat />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/channels" element={<Channels />} />
|
<Route path="/channels" element={<Channels />} />
|
||||||
<Route path="/skills" element={<Skills />} />
|
<Route path="/skills" element={<Skills />} />
|
||||||
<Route path="/cron" element={<Cron />} />
|
<Route path="/cron" element={<Cron />} />
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { ChatToolbar } from '@/pages/Chat/ChatToolbar';
|
|||||||
|
|
||||||
// Page titles mapping
|
// Page titles mapping
|
||||||
const pageTitles: Record<string, string> = {
|
const pageTitles: Record<string, string> = {
|
||||||
'/': 'Dashboard',
|
'/': 'Chat',
|
||||||
'/chat': 'Chat',
|
'/dashboard': 'Dashboard',
|
||||||
'/channels': 'Channels',
|
'/channels': 'Channels',
|
||||||
'/skills': 'Skills',
|
'/skills': 'Skills',
|
||||||
'/cron': 'Cron Tasks',
|
'/cron': 'Cron Tasks',
|
||||||
@@ -19,7 +19,7 @@ const pageTitles: Record<string, string> = {
|
|||||||
export function Header() {
|
export function Header() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const currentTitle = pageTitles[location.pathname] || 'ClawX';
|
const currentTitle = pageTitles[location.pathname] || 'ClawX';
|
||||||
const isChatPage = location.pathname === '/chat';
|
const isChatPage = location.pathname === '/';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 items-center justify-between border-b bg-background px-6">
|
<header className="flex h-14 items-center justify-between border-b bg-background px-6">
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useSettingsStore } from '@/stores/settings';
|
import { useSettingsStore } from '@/stores/settings';
|
||||||
import { useGatewayStore } from '@/stores/gateway';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
@@ -64,19 +64,17 @@ export function Sidebar() {
|
|||||||
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||||
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||||
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
||||||
const gatewayStatus = useGatewayStore((state) => state.status);
|
|
||||||
|
|
||||||
// Open developer console
|
// Open developer console
|
||||||
const openDevConsole = () => {
|
const openDevConsole = () => {
|
||||||
window.electron.openExternal('http://localhost:18789');
|
window.electron.openExternal('http://localhost:18789');
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: <Home className="h-5 w-5" />, label: 'Dashboard' },
|
{ to: '/', icon: <MessageSquare className="h-5 w-5" />, label: 'Chat' },
|
||||||
{ to: '/chat', icon: <MessageSquare className="h-5 w-5" />, label: 'Chat' },
|
|
||||||
{ to: '/channels', icon: <Radio className="h-5 w-5" />, label: 'Channels' },
|
|
||||||
{ to: '/skills', icon: <Puzzle className="h-5 w-5" />, label: 'Skills' },
|
|
||||||
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: 'Cron Tasks' },
|
{ to: '/cron', icon: <Clock className="h-5 w-5" />, label: 'Cron Tasks' },
|
||||||
|
{ to: '/skills', icon: <Puzzle className="h-5 w-5" />, label: 'Skills' },
|
||||||
|
{ to: '/channels', icon: <Radio className="h-5 w-5" />, label: 'Channels' },
|
||||||
|
{ to: '/dashboard', icon: <Home className="h-5 w-5" />, label: 'Dashboard' },
|
||||||
{ to: '/settings', icon: <Settings className="h-5 w-5" />, label: 'Settings' },
|
{ to: '/settings', icon: <Settings className="h-5 w-5" />, label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -108,30 +106,7 @@ export function Sidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="border-t p-2 space-y-2">
|
<div className="p-2 space-y-2">
|
||||||
{/* Gateway Status */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-2 rounded-lg px-3 py-2',
|
|
||||||
sidebarCollapsed && 'justify-center px-2'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'h-2 w-2 rounded-full',
|
|
||||||
gatewayStatus.state === 'running' && 'bg-green-500',
|
|
||||||
gatewayStatus.state === 'starting' && 'bg-yellow-500 animate-pulse',
|
|
||||||
gatewayStatus.state === 'stopped' && 'bg-gray-400',
|
|
||||||
gatewayStatus.state === 'error' && 'bg-red-500'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{!sidebarCollapsed && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Gateway: {gatewayStatus.state}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Developer Mode Button */}
|
{/* Developer Mode Button */}
|
||||||
{devModeUnlocked && !sidebarCollapsed && (
|
{devModeUnlocked && !sidebarCollapsed && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,21 +1,64 @@
|
|||||||
/**
|
/**
|
||||||
* Chat Input Component
|
* Chat Input Component
|
||||||
* Textarea with send button. Enter to send, Shift+Enter for new line.
|
* Textarea with send button and image upload support.
|
||||||
|
* Enter to send, Shift+Enter for new line.
|
||||||
|
* Supports: file picker, clipboard paste, drag & drop.
|
||||||
*/
|
*/
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { Send, Square } from 'lucide-react';
|
import { Send, Square, ImagePlus, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
export interface ChatAttachment {
|
||||||
|
type: 'image';
|
||||||
|
mimeType: string;
|
||||||
|
fileName: string;
|
||||||
|
content: string; // base64
|
||||||
|
preview: string; // data URL for display
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (text: string) => void;
|
onSend: (text: string, attachments?: ChatAttachment[]) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
sending?: boolean;
|
sending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||||
|
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
|
function fileToAttachment(file: File): Promise<ChatAttachment> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||||
|
reject(new Error(`Unsupported image type: ${file.type}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_SIZE) {
|
||||||
|
reject(new Error('Image too large (max 10MB)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = reader.result as string;
|
||||||
|
// Extract base64 content (remove "data:image/png;base64," prefix)
|
||||||
|
const base64 = dataUrl.split(',')[1];
|
||||||
|
resolve({
|
||||||
|
type: 'image',
|
||||||
|
mimeType: file.type,
|
||||||
|
fileName: file.name,
|
||||||
|
content: base64,
|
||||||
|
preview: dataUrl,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatInput({ onSend, disabled = false, sending = false }: ChatInputProps) {
|
export function ChatInput({ onSend, disabled = false, sending = false }: ChatInputProps) {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Auto-resize textarea
|
// Auto-resize textarea
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -25,16 +68,33 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
|
|||||||
}
|
}
|
||||||
}, [input]);
|
}, [input]);
|
||||||
|
|
||||||
|
const addFiles = useCallback(async (files: FileList | File[]) => {
|
||||||
|
const fileArray = Array.from(files).filter((f) => ACCEPTED_IMAGE_TYPES.includes(f.type));
|
||||||
|
if (fileArray.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newAttachments = await Promise.all(fileArray.map(fileToAttachment));
|
||||||
|
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to process image:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeAttachment = useCallback((index: number) => {
|
||||||
|
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canSend = (input.trim() || attachments.length > 0) && !disabled && !sending;
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
const trimmed = input.trim();
|
if (!canSend) return;
|
||||||
if (!trimmed || disabled || sending) return;
|
onSend(input.trim(), attachments.length > 0 ? attachments : undefined);
|
||||||
onSend(trimmed);
|
|
||||||
setInput('');
|
setInput('');
|
||||||
// Reset textarea height
|
setAttachments([]);
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = 'auto';
|
||||||
}
|
}
|
||||||
}, [input, disabled, sending, onSend]);
|
}, [input, attachments, canSend, onSend]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
@@ -46,37 +106,145 @@ export function ChatInput({ onSend, disabled = false, sending = false }: ChatInp
|
|||||||
[handleSend],
|
[handleSend],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle paste (Ctrl/Cmd+V with image)
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
(e: React.ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
const imageFiles: File[] = [];
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) imageFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
addFiles(imageFiles);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle drag & drop
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOver(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOver(false);
|
||||||
|
if (e.dataTransfer?.files) {
|
||||||
|
addFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFiles],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t bg-background p-4">
|
<div
|
||||||
<div className="flex items-end gap-2 max-w-4xl mx-auto">
|
className="bg-background p-4"
|
||||||
<div className="flex-1 relative">
|
onDragOver={handleDragOver}
|
||||||
<Textarea
|
onDragLeave={handleDragLeave}
|
||||||
ref={textareaRef}
|
onDrop={handleDrop}
|
||||||
value={input}
|
>
|
||||||
onChange={(e) => setInput(e.target.value)}
|
<div className="max-w-4xl mx-auto">
|
||||||
onKeyDown={handleKeyDown}
|
{/* Image Previews */}
|
||||||
placeholder={disabled ? 'Gateway not connected...' : 'Message (Enter to send, Shift+Enter for new line)'}
|
{attachments.length > 0 && (
|
||||||
|
<div className="flex gap-2 mb-2 flex-wrap">
|
||||||
|
{attachments.map((att, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="relative group w-16 h-16 rounded-lg overflow-hidden border border-border"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={att.preview}
|
||||||
|
alt={att.fileName}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeAttachment(idx)}
|
||||||
|
className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input Row */}
|
||||||
|
<div className={`flex items-end gap-2 ${dragOver ? 'ring-2 ring-primary rounded-lg' : ''}`}>
|
||||||
|
{/* Image Upload Button */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 h-[44px] w-[44px] text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className="min-h-[44px] max-h-[200px] resize-none pr-4"
|
title="Attach image"
|
||||||
rows={1}
|
>
|
||||||
|
<ImagePlus className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_IMAGE_TYPES.join(',')}
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
addFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Textarea */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
placeholder={disabled ? 'Gateway not connected...' : 'Message (Enter to send, Shift+Enter for new line)'}
|
||||||
|
disabled={disabled}
|
||||||
|
className="min-h-[44px] max-h-[200px] resize-none pr-4"
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Send Button */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!canSend}
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 h-[44px] w-[44px]"
|
||||||
|
variant={sending ? 'destructive' : 'default'}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={!input.trim() || disabled}
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0 h-10 w-10"
|
|
||||||
variant={sending ? 'destructive' : 'default'}
|
|
||||||
>
|
|
||||||
{sending ? (
|
|
||||||
<Square className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,20 +35,17 @@ export function ChatToolbar() {
|
|||||||
'focus:outline-none focus:ring-2 focus:ring-ring',
|
'focus:outline-none focus:ring-2 focus:ring-ring',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Always show current session */}
|
{/* Render all sessions; if currentSessionKey is not in the list, add it */}
|
||||||
<option value={currentSessionKey}>
|
{!sessions.some((s) => s.key === currentSessionKey) && (
|
||||||
{sessions.find((s) => s.key === currentSessionKey)?.displayName
|
<option value={currentSessionKey}>
|
||||||
|| sessions.find((s) => s.key === currentSessionKey)?.label
|
{currentSessionKey === 'main' ? 'main' : currentSessionKey}
|
||||||
|| currentSessionKey}
|
</option>
|
||||||
</option>
|
)}
|
||||||
{/* Other sessions */}
|
{sessions.map((s) => (
|
||||||
{sessions
|
<option key={s.key} value={s.key}>
|
||||||
.filter((s) => s.key !== currentSessionKey)
|
{s.displayName || s.label || s.key}
|
||||||
.map((s) => (
|
</option>
|
||||||
<option key={s.key} value={s.key}>
|
))}
|
||||||
{s.displayName || s.label || s.key}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,8 +49,45 @@ const schedulePresets: { label: string; value: string; type: ScheduleType }[] =
|
|||||||
{ label: 'Monthly (1st at 9am)', value: '0 9 1 * *', type: 'monthly' },
|
{ label: 'Monthly (1st at 9am)', value: '0 9 1 * *', type: 'monthly' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Parse cron expression to human-readable format
|
// Parse cron schedule to human-readable format
|
||||||
function parseCronSchedule(cron: string): string {
|
// Handles both plain cron strings and Gateway CronSchedule objects:
|
||||||
|
// { kind: "cron", expr: "...", tz?: "..." }
|
||||||
|
// { kind: "every", everyMs: number }
|
||||||
|
// { kind: "at", at: "..." }
|
||||||
|
function parseCronSchedule(schedule: unknown): string {
|
||||||
|
// Handle Gateway CronSchedule object format
|
||||||
|
if (schedule && typeof schedule === 'object') {
|
||||||
|
const s = schedule as { kind?: string; expr?: string; tz?: string; everyMs?: number; at?: string };
|
||||||
|
if (s.kind === 'cron' && typeof s.expr === 'string') {
|
||||||
|
return parseCronExpr(s.expr);
|
||||||
|
}
|
||||||
|
if (s.kind === 'every' && typeof s.everyMs === 'number') {
|
||||||
|
const ms = s.everyMs;
|
||||||
|
if (ms < 60_000) return `Every ${Math.round(ms / 1000)}s`;
|
||||||
|
if (ms < 3_600_000) return `Every ${Math.round(ms / 60_000)} minutes`;
|
||||||
|
if (ms < 86_400_000) return `Every ${Math.round(ms / 3_600_000)} hours`;
|
||||||
|
return `Every ${Math.round(ms / 86_400_000)} days`;
|
||||||
|
}
|
||||||
|
if (s.kind === 'at' && typeof s.at === 'string') {
|
||||||
|
try {
|
||||||
|
return `Once at ${new Date(s.at).toLocaleString()}`;
|
||||||
|
} catch {
|
||||||
|
return `Once at ${s.at}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle plain cron string
|
||||||
|
if (typeof schedule === 'string') {
|
||||||
|
return parseCronExpr(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(schedule ?? 'Unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a plain cron expression string to human-readable text
|
||||||
|
function parseCronExpr(cron: string): string {
|
||||||
const preset = schedulePresets.find((p) => p.value === cron);
|
const preset = schedulePresets.find((p) => p.value === cron);
|
||||||
if (preset) return preset.label;
|
if (preset) return preset.label;
|
||||||
|
|
||||||
@@ -89,7 +126,17 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
|||||||
|
|
||||||
const [name, setName] = useState(job?.name || '');
|
const [name, setName] = useState(job?.name || '');
|
||||||
const [message, setMessage] = useState(job?.message || '');
|
const [message, setMessage] = useState(job?.message || '');
|
||||||
const [schedule, setSchedule] = useState(job?.schedule || '0 9 * * *');
|
// Extract cron expression string from CronSchedule object or use as-is if string
|
||||||
|
const initialSchedule = (() => {
|
||||||
|
const s = job?.schedule;
|
||||||
|
if (!s) return '0 9 * * *';
|
||||||
|
if (typeof s === 'string') return s;
|
||||||
|
if (typeof s === 'object' && 'expr' in s && typeof (s as { expr: string }).expr === 'string') {
|
||||||
|
return (s as { expr: string }).expr;
|
||||||
|
}
|
||||||
|
return '0 9 * * *';
|
||||||
|
})();
|
||||||
|
const [schedule, setSchedule] = useState(initialSchedule);
|
||||||
const [customSchedule, setCustomSchedule] = useState('');
|
const [customSchedule, setCustomSchedule] = useState('');
|
||||||
const [useCustom, setUseCustom] = useState(false);
|
const [useCustom, setUseCustom] = useState(false);
|
||||||
const [channelId, setChannelId] = useState(job?.target.channelId || '');
|
const [channelId, setChannelId] = useState(job?.target.channelId || '');
|
||||||
@@ -280,7 +327,7 @@ interface CronJobCardProps {
|
|||||||
onToggle: (enabled: boolean) => void;
|
onToggle: (enabled: boolean) => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onTrigger: () => void;
|
onTrigger: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
|
function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCardProps) {
|
||||||
@@ -290,7 +337,10 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
|||||||
setTriggering(true);
|
setTriggering(true);
|
||||||
try {
|
try {
|
||||||
await onTrigger();
|
await onTrigger();
|
||||||
toast.success('Task triggered');
|
toast.success('Task triggered successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to trigger cron job:', error);
|
||||||
|
toast.error(`Failed to trigger task: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
setTriggering(false);
|
setTriggering(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function Dashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||||
<Link to="/chat">
|
<Link to="/">
|
||||||
<MessageSquare className="h-5 w-5" />
|
<MessageSquare className="h-5 w-5" />
|
||||||
<span>Open Chat</span>
|
<span>Open Chat</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ interface ChatState {
|
|||||||
switchSession: (key: string) => void;
|
switchSession: (key: string) => void;
|
||||||
newSession: () => void;
|
newSession: () => void;
|
||||||
loadHistory: () => Promise<void>;
|
loadHistory: () => Promise<void>;
|
||||||
sendMessage: (text: string) => Promise<void>;
|
sendMessage: (text: string, attachments?: { type: string; mimeType: string; fileName: string; content: string }[]) => Promise<void>;
|
||||||
handleChatEvent: (event: Record<string, unknown>) => void;
|
handleChatEvent: (event: Record<string, unknown>) => void;
|
||||||
toggleThinking: () => void;
|
toggleThinking: () => void;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
@@ -108,7 +108,35 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
model: s.model ? String(s.model) : undefined,
|
model: s.model ? String(s.model) : undefined,
|
||||||
})).filter((s: ChatSession) => s.key);
|
})).filter((s: ChatSession) => s.key);
|
||||||
|
|
||||||
set({ sessions });
|
// Normalize: the Gateway returns the main session with canonical key
|
||||||
|
// like "agent:main:main", but the frontend uses "main" for all RPC calls.
|
||||||
|
// Map the canonical main session key to "main" so the selector stays consistent.
|
||||||
|
const mainCanonicalPattern = /^agent:[^:]+:main$/;
|
||||||
|
const normalizedSessions = sessions.map((s) => {
|
||||||
|
if (mainCanonicalPattern.test(s.key)) {
|
||||||
|
return { ...s, key: 'main', displayName: s.displayName || 'main' };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduplicate: if both "main" and "agent:X:main" existed, keep only one
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const dedupedSessions = normalizedSessions.filter((s) => {
|
||||||
|
if (seen.has(s.key)) return false;
|
||||||
|
seen.add(s.key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
set({ sessions: dedupedSessions });
|
||||||
|
|
||||||
|
// If currentSessionKey is 'main' and we now have sessions,
|
||||||
|
// ensure we stay on 'main' (no-op, but load history if needed)
|
||||||
|
const { currentSessionKey } = get();
|
||||||
|
if (currentSessionKey === 'main' && !dedupedSessions.find((s) => s.key === 'main') && dedupedSessions.length > 0) {
|
||||||
|
// Main session not found at all — switch to the first available session
|
||||||
|
set({ currentSessionKey: dedupedSessions[0].key });
|
||||||
|
get().loadHistory();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to load sessions:', err);
|
console.warn('Failed to load sessions:', err);
|
||||||
@@ -176,16 +204,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
// ── Send message ──
|
// ── Send message ──
|
||||||
|
|
||||||
sendMessage: async (text: string) => {
|
sendMessage: async (text: string, attachments?: { type: string; mimeType: string; fileName: string; content: string }[]) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed && (!attachments || attachments.length === 0)) return;
|
||||||
|
|
||||||
const { currentSessionKey } = get();
|
const { currentSessionKey } = get();
|
||||||
|
|
||||||
// Add user message optimistically
|
// Add user message optimistically
|
||||||
const userMsg: RawMessage = {
|
const userMsg: RawMessage = {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: trimmed,
|
content: trimmed || '(image)',
|
||||||
timestamp: Date.now() / 1000,
|
timestamp: Date.now() / 1000,
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
};
|
};
|
||||||
@@ -199,15 +227,27 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const idempotencyKey = crypto.randomUUID();
|
const idempotencyKey = crypto.randomUUID();
|
||||||
|
const rpcParams: Record<string, unknown> = {
|
||||||
|
sessionKey: currentSessionKey,
|
||||||
|
message: trimmed || 'Describe this image.',
|
||||||
|
deliver: false,
|
||||||
|
idempotencyKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include image attachments if any
|
||||||
|
if (attachments && attachments.length > 0) {
|
||||||
|
rpcParams.attachments = attachments.map((a) => ({
|
||||||
|
type: a.type,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
fileName: a.fileName,
|
||||||
|
content: a.content,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const result = await window.electron.ipcRenderer.invoke(
|
const result = await window.electron.ipcRenderer.invoke(
|
||||||
'gateway:rpc',
|
'gateway:rpc',
|
||||||
'chat.send',
|
'chat.send',
|
||||||
{
|
rpcParams,
|
||||||
sessionKey: currentSessionKey,
|
|
||||||
message: trimmed,
|
|
||||||
deliver: false,
|
|
||||||
idempotencyKey,
|
|
||||||
}
|
|
||||||
) as { success: boolean; result?: { runId?: string }; error?: string };
|
) as { success: boolean; result?: { runId?: string }; error?: string };
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -89,7 +89,15 @@ export const useCronStore = create<CronState>((set) => ({
|
|||||||
|
|
||||||
triggerJob: async (id) => {
|
triggerJob: async (id) => {
|
||||||
try {
|
try {
|
||||||
await window.electron.ipcRenderer.invoke('cron:trigger', id);
|
const result = await window.electron.ipcRenderer.invoke('cron:trigger', id);
|
||||||
|
console.log('Cron trigger result:', result);
|
||||||
|
// Refresh jobs after trigger to update lastRun/nextRun state
|
||||||
|
try {
|
||||||
|
const jobs = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[];
|
||||||
|
set({ jobs });
|
||||||
|
} catch {
|
||||||
|
// Ignore refresh error
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to trigger cron job:', error);
|
console.error('Failed to trigger cron job:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -24,14 +24,23 @@ export interface CronJobLastRun {
|
|||||||
duration?: number;
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gateway CronSchedule object format
|
||||||
|
*/
|
||||||
|
export type CronSchedule =
|
||||||
|
| { kind: 'at'; at: string }
|
||||||
|
| { kind: 'every'; everyMs: number; anchorMs?: number }
|
||||||
|
| { kind: 'cron'; expr: string; tz?: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cron job data structure
|
* Cron job data structure
|
||||||
|
* schedule can be a plain cron string or a Gateway CronSchedule object
|
||||||
*/
|
*/
|
||||||
export interface CronJob {
|
export interface CronJob {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
message: string;
|
message: string;
|
||||||
schedule: string;
|
schedule: string | CronSchedule;
|
||||||
target: CronJobTarget;
|
target: CronJobTarget;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user