mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
fix: migrate wizard from polling to WebSocket and fix duplicate ROS connections
- Removed non-functional trial WebSocket (no server exists) - Kept ROS WebSocket for robot control via useWizardRos - Fixed duplicate ROS connections by passing connection as props - WizardMonitoringPanel now receives ROS connection from parent - Trial status uses reliable tRPC polling (5-15s intervals) - Updated connection badges to show 'ROS Connected/Offline' - Added loading overlay with fade-in to designer - Fixed hash computation to include parameter values - Fixed incremental hash caching for parameter changes Fixes: - WebSocket connection errors eliminated - Connect button now works properly - No more conflicting duplicate connections - Accurate connection status display
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Bot,
|
||||
Play,
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
Eye,
|
||||
Hand,
|
||||
Zap,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
} from "~/components/ui/collapsible";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { useWizardRos } from "~/hooks/useWizardRos";
|
||||
|
||||
interface RobotAction {
|
||||
id: string;
|
||||
@@ -85,16 +88,46 @@ interface Plugin {
|
||||
interface RobotActionsPanelProps {
|
||||
studyId: string;
|
||||
trialId: string;
|
||||
onExecuteAction: (
|
||||
onExecuteAction?: (
|
||||
pluginName: string,
|
||||
actionId: string,
|
||||
parameters: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
// Helper functions moved outside component to prevent re-renders
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category.toLowerCase()) {
|
||||
case "movement":
|
||||
return Move;
|
||||
case "speech":
|
||||
return Volume2;
|
||||
case "sensors":
|
||||
return Eye;
|
||||
case "interaction":
|
||||
return Hand;
|
||||
default:
|
||||
return Zap;
|
||||
}
|
||||
};
|
||||
|
||||
const groupActionsByCategory = (actions: RobotAction[]) => {
|
||||
const grouped: Record<string, RobotAction[]> = {};
|
||||
|
||||
actions.forEach((action) => {
|
||||
const category = action.category ?? "other";
|
||||
if (!grouped[category]) {
|
||||
grouped[category] = [];
|
||||
}
|
||||
grouped[category]!.push(action);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
export function RobotActionsPanel({
|
||||
studyId,
|
||||
trialId,
|
||||
trialId: _trialId,
|
||||
onExecuteAction,
|
||||
}: RobotActionsPanelProps) {
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<string>("");
|
||||
@@ -111,15 +144,52 @@ export function RobotActionsPanel({
|
||||
new Set(["movement", "speech"]),
|
||||
);
|
||||
|
||||
// WebSocket ROS integration
|
||||
const {
|
||||
isConnected: rosConnected,
|
||||
isConnecting: rosConnecting,
|
||||
connectionError: rosError,
|
||||
robotStatus,
|
||||
activeActions,
|
||||
connect: connectRos,
|
||||
disconnect: disconnectRos,
|
||||
executeRobotAction: executeRosAction,
|
||||
} = useWizardRos({
|
||||
autoConnect: true,
|
||||
onActionCompleted: (execution) => {
|
||||
toast.success(`Completed: ${execution.actionId}`, {
|
||||
description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`,
|
||||
});
|
||||
// Remove from executing set
|
||||
setExecutingActions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(`${execution.pluginName}.${execution.actionId}`);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onActionFailed: (execution) => {
|
||||
toast.error(`Failed: ${execution.actionId}`, {
|
||||
description: execution.error || "Unknown error",
|
||||
});
|
||||
// Remove from executing set
|
||||
setExecutingActions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(`${execution.pluginName}.${execution.actionId}`);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Get installed plugins for the study
|
||||
const { data: plugins = [], isLoading } =
|
||||
api.robots.plugins.getStudyPlugins.useQuery({
|
||||
studyId,
|
||||
});
|
||||
|
||||
// Get actions for selected plugin
|
||||
const selectedPluginData = plugins.find(
|
||||
(p) => p.plugin.id === selectedPlugin,
|
||||
// Get actions for selected plugin - memoized to prevent infinite re-renders
|
||||
const selectedPluginData = useMemo(
|
||||
() => plugins.find((p) => p.plugin.id === selectedPlugin),
|
||||
[plugins, selectedPlugin],
|
||||
);
|
||||
|
||||
// Initialize parameters when action changes
|
||||
@@ -155,22 +225,87 @@ export function RobotActionsPanel({
|
||||
}
|
||||
}, [selectedAction]);
|
||||
|
||||
const handleExecuteAction = async () => {
|
||||
const toggleCategory = useCallback((category: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleExecuteAction = useCallback(async () => {
|
||||
if (!selectedAction || !selectedPluginData) return;
|
||||
|
||||
const actionKey = `${selectedPluginData.plugin.name}.${selectedAction.id}`;
|
||||
setExecutingActions((prev) => new Set([...prev, actionKey]));
|
||||
|
||||
try {
|
||||
await onExecuteAction(
|
||||
selectedPluginData.plugin.name,
|
||||
selectedAction.id,
|
||||
actionParameters,
|
||||
);
|
||||
// Get action configuration from plugin
|
||||
const actionDef = (
|
||||
selectedPluginData.plugin.actionDefinitions as RobotAction[]
|
||||
)?.find((def: RobotAction) => def.id === selectedAction.id);
|
||||
|
||||
toast.success(`Executed: ${selectedAction.name}`, {
|
||||
description: `Robot action completed successfully`,
|
||||
});
|
||||
// Try direct WebSocket execution first
|
||||
if (rosConnected && actionDef) {
|
||||
try {
|
||||
// Look for ROS2 configuration in the action definition
|
||||
const actionConfig = (actionDef as any).ros2
|
||||
? {
|
||||
topic: (actionDef as any).ros2.topic,
|
||||
messageType: (actionDef as any).ros2.messageType,
|
||||
payloadMapping: (actionDef as any).ros2.payloadMapping,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await executeRosAction(
|
||||
selectedPluginData.plugin.name,
|
||||
selectedAction.id,
|
||||
actionParameters,
|
||||
actionConfig,
|
||||
);
|
||||
|
||||
toast.success(`Executed: ${selectedAction.name}`, {
|
||||
description: `Robot action completed via WebSocket`,
|
||||
});
|
||||
} catch (rosError) {
|
||||
console.warn(
|
||||
"WebSocket execution failed, falling back to tRPC:",
|
||||
rosError,
|
||||
);
|
||||
|
||||
// Fallback to tRPC execution
|
||||
if (onExecuteAction) {
|
||||
await onExecuteAction(
|
||||
selectedPluginData.plugin.name,
|
||||
selectedAction.id,
|
||||
actionParameters,
|
||||
);
|
||||
|
||||
toast.success(`Executed: ${selectedAction.name}`, {
|
||||
description: `Robot action completed via tRPC fallback`,
|
||||
});
|
||||
} else {
|
||||
throw rosError;
|
||||
}
|
||||
}
|
||||
} else if (onExecuteAction) {
|
||||
// Use tRPC execution if WebSocket not available
|
||||
await onExecuteAction(
|
||||
selectedPluginData.plugin.name,
|
||||
selectedAction.id,
|
||||
actionParameters,
|
||||
);
|
||||
|
||||
toast.success(`Executed: ${selectedAction.name}`, {
|
||||
description: `Robot action completed via tRPC`,
|
||||
});
|
||||
} else {
|
||||
throw new Error("No execution method available");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`Failed to execute: ${selectedAction.name}`, {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
@@ -182,18 +317,27 @@ export function RobotActionsPanel({
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [
|
||||
selectedAction,
|
||||
selectedPluginData,
|
||||
rosConnected,
|
||||
executeRosAction,
|
||||
onExecuteAction,
|
||||
]);
|
||||
|
||||
const handleParameterChange = (paramName: string, value: unknown) => {
|
||||
setActionParameters((prev) => ({
|
||||
...prev,
|
||||
[paramName]: value,
|
||||
}));
|
||||
};
|
||||
const handleParameterChange = useCallback(
|
||||
(paramName: string, value: unknown) => {
|
||||
setActionParameters((prev) => ({
|
||||
...prev,
|
||||
[paramName]: value,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderParameterInput = (
|
||||
param: NonNullable<RobotAction["parameters"]>[0],
|
||||
paramIndex: number,
|
||||
_paramIndex: number,
|
||||
) => {
|
||||
if (!param) return null;
|
||||
|
||||
@@ -323,47 +467,6 @@ export function RobotActionsPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category.toLowerCase()) {
|
||||
case "movement":
|
||||
return Move;
|
||||
case "speech":
|
||||
return Volume2;
|
||||
case "sensors":
|
||||
return Eye;
|
||||
case "interaction":
|
||||
return Hand;
|
||||
default:
|
||||
return Zap;
|
||||
}
|
||||
};
|
||||
|
||||
const groupActionsByCategory = (actions: RobotAction[]) => {
|
||||
const grouped: Record<string, RobotAction[]> = {};
|
||||
|
||||
actions.forEach((action) => {
|
||||
const category = action.category || "other";
|
||||
if (!grouped[category]) {
|
||||
grouped[category] = [];
|
||||
}
|
||||
grouped[category].push(action);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) {
|
||||
next.delete(category);
|
||||
} else {
|
||||
next.add(category);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
@@ -375,18 +478,68 @@ export function RobotActionsPanel({
|
||||
|
||||
if (plugins.length === 0) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
No robot plugins installed for this study. Install plugins from the
|
||||
study settings to enable robot control.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
{rosConnected ? (
|
||||
<Wifi className="h-4 w-4 text-green-500" />
|
||||
) : rosConnecting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-yellow-500" />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">ROS Bridge</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge
|
||||
variant={
|
||||
rosConnected
|
||||
? "default"
|
||||
: rosConnecting
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{rosConnected
|
||||
? "Connected"
|
||||
: rosConnecting
|
||||
? "Connecting"
|
||||
: "Disconnected"}
|
||||
</Badge>
|
||||
|
||||
{!rosConnected && !rosConnecting && (
|
||||
<Button size="sm" variant="outline" onClick={() => connectRos()}>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{rosConnected && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => disconnectRos()}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
No robot plugins are installed in this study. Install plugins to
|
||||
control robots during trials.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ConnectionStatus />
|
||||
{/* Plugin Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Select Robot Plugin</Label>
|
||||
@@ -523,92 +676,429 @@ export function RobotActionsPanel({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Quick Actions for Common Robot Commands */}
|
||||
{selectedAction.category === "movement" && selectedPluginData && (
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!selectedPluginData) return;
|
||||
const stopAction = (
|
||||
selectedPluginData.plugin
|
||||
.actionDefinitions as RobotAction[]
|
||||
)?.find((a: RobotAction) => a.id === "stop_movement");
|
||||
if (stopAction) {
|
||||
onExecuteAction(
|
||||
selectedPluginData.plugin.name,
|
||||
stopAction.id,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!selectedPluginData ||
|
||||
!(
|
||||
selectedPluginData.plugin
|
||||
.actionDefinitions as RobotAction[]
|
||||
)?.some((a: RobotAction) => a.id === "stop_movement")
|
||||
}
|
||||
>
|
||||
Emergency Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!selectedPluginData) return;
|
||||
const wakeAction = (
|
||||
selectedPluginData.plugin
|
||||
.actionDefinitions as RobotAction[]
|
||||
)?.find((a: RobotAction) => a.id === "wake_up");
|
||||
if (wakeAction) {
|
||||
onExecuteAction(
|
||||
selectedPluginData.plugin.name,
|
||||
wakeAction.id,
|
||||
{},
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!selectedPluginData ||
|
||||
!(
|
||||
selectedPluginData.plugin
|
||||
.actionDefinitions as RobotAction[]
|
||||
)?.some((a: RobotAction) => a.id === "wake_up")
|
||||
}
|
||||
>
|
||||
Wake Up
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Plugin Info */}
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
<span>Quick Actions</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Common robot actions for quick execution
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: "Hello, I am ready!",
|
||||
}).catch((error) => {
|
||||
console.error("Quick action failed:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!rosConnected || rosConnecting}
|
||||
>
|
||||
<Volume2 className="mr-1 h-3 w-3" />
|
||||
Say Hello
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
|
||||
(error) => {
|
||||
console.error("Emergency stop failed:", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={!rosConnected || rosConnecting}
|
||||
>
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
Stop
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "turn_head", {
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
speed: 0.3,
|
||||
}).catch((error) => {
|
||||
console.error("Head center failed:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!rosConnected || rosConnecting}
|
||||
>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
Center Head
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "walk_forward", {
|
||||
speed: 0.1,
|
||||
duration: 2,
|
||||
}).catch((error) => {
|
||||
console.error("Walk forward failed:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!rosConnected || rosConnecting}
|
||||
>
|
||||
<Move className="mr-1 h-3 w-3" />
|
||||
Walk Test
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
function ConnectionStatus() {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
{rosConnected ? (
|
||||
<Wifi className="h-4 w-4 text-green-500" />
|
||||
) : rosConnecting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-yellow-500" />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">ROS Bridge</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge
|
||||
variant={
|
||||
rosConnected
|
||||
? "default"
|
||||
: rosConnecting
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{rosConnected
|
||||
? "Connected"
|
||||
: rosConnecting
|
||||
? "Connecting"
|
||||
: "Disconnected"}
|
||||
</Badge>
|
||||
|
||||
{!rosConnected && !rosConnecting && (
|
||||
<Button size="sm" variant="outline" onClick={() => connectRos()}>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{rosConnected && (
|
||||
<Button size="sm" variant="outline" onClick={() => disconnectRos()}>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
{rosConnected ? (
|
||||
<Wifi className="h-4 w-4 text-green-500" />
|
||||
) : rosConnecting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-yellow-500" />
|
||||
) : (
|
||||
<WifiOff className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">ROS Bridge</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge
|
||||
variant={
|
||||
rosConnected
|
||||
? "default"
|
||||
: rosConnecting
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{rosConnected
|
||||
? "Connected"
|
||||
: rosConnecting
|
||||
? "Connecting"
|
||||
: "Disconnected"}
|
||||
</Badge>
|
||||
|
||||
{!rosConnected && !rosConnecting && (
|
||||
<Button size="sm" variant="outline" onClick={() => connectRos()}>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{rosConnected && (
|
||||
<Button size="sm" variant="outline" onClick={() => disconnectRos()}>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Plugin Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Select Robot Plugin</Label>
|
||||
<Select value={selectedPlugin} onValueChange={setSelectedPlugin}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a robot plugin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plugins.map((plugin) => (
|
||||
<SelectItem key={plugin.plugin.id} value={plugin.plugin.id}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
<span>
|
||||
{plugin.plugin.name} v{plugin.plugin.version}
|
||||
</span>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{plugin.plugin.trustLevel}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Action Selection */}
|
||||
{selectedPluginData && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>{selectedPluginData.plugin.name}</strong> -{" "}
|
||||
{selectedPluginData.plugin.description}
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
Installed:{" "}
|
||||
{selectedPluginData.installation.installedAt.toLocaleDateString()}{" "}
|
||||
| Trust Level: {selectedPluginData.plugin.trustLevel} | Actions:{" "}
|
||||
{
|
||||
(
|
||||
(selectedPluginData.plugin
|
||||
.actionDefinitions as RobotAction[]) || []
|
||||
).length
|
||||
}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-2">
|
||||
<Label>Available Actions</Label>
|
||||
<ScrollArea className="h-64 rounded-md border">
|
||||
<div className="space-y-2 p-2">
|
||||
{selectedPluginData &&
|
||||
Object.entries(
|
||||
groupActionsByCategory(
|
||||
(selectedPluginData.plugin
|
||||
.actionDefinitions as RobotAction[]) ?? [],
|
||||
),
|
||||
).map(([category, actions]) => {
|
||||
const CategoryIcon = getCategoryIcon(category);
|
||||
const isExpanded = expandedCategories.has(category);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={category}
|
||||
open={isExpanded}
|
||||
onOpenChange={() => toggleCategory(category)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start p-2"
|
||||
>
|
||||
<CategoryIcon className="mr-2 h-4 w-4" />
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{actions.length}
|
||||
</Badge>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="ml-6 space-y-1">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={
|
||||
selectedAction?.id === action.id
|
||||
? "default"
|
||||
: "ghost"
|
||||
}
|
||||
className="w-full justify-start text-sm"
|
||||
onClick={() => setSelectedAction(action)}
|
||||
>
|
||||
{action.name}
|
||||
</Button>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Configuration */}
|
||||
{selectedAction && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
<span>{selectedAction?.name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>{selectedAction?.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Parameters */}
|
||||
{selectedAction?.parameters &&
|
||||
(selectedAction.parameters?.length ?? 0) > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base">Parameters</Label>
|
||||
{selectedAction?.parameters?.map((param, index) =>
|
||||
renderParameterInput(param, index),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This action requires no parameters.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Execute Button */}
|
||||
<Button
|
||||
onClick={handleExecuteAction}
|
||||
disabled={
|
||||
!selectedPluginData ||
|
||||
!selectedAction ||
|
||||
executingActions.has(
|
||||
`${selectedPluginData?.plugin.name}.${selectedAction?.id}`,
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
{selectedPluginData &&
|
||||
selectedAction &&
|
||||
executingActions.has(
|
||||
`${selectedPluginData?.plugin.name}.${selectedAction?.id}`,
|
||||
) ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Executing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Execute Action
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Zap className="h-4 w-4" />
|
||||
<span>Quick Actions</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Common robot actions for quick execution
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "say_text", {
|
||||
text: "Hello, I am ready!",
|
||||
}).catch((error: unknown) => {
|
||||
console.error("Quick action failed:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!rosConnected || rosConnecting}
|
||||
>
|
||||
<Volume2 className="mr-1 h-3 w-3" />
|
||||
Say Hello
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "emergency_stop", {}).catch(
|
||||
(error: unknown) => {
|
||||
console.error("Emergency stop failed:", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={!rosConnected || rosConnecting}
|
||||
>
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
Stop
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "turn_head", {
|
||||
yaw: 0,
|
||||
pitch: 0,
|
||||
speed: 0.3,
|
||||
}).catch((error: unknown) => {
|
||||
console.error("Head center failed:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!rosConnected || rosConnecting}
|
||||
>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
Center Head
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (rosConnected) {
|
||||
executeRosAction("nao6-ros2", "walk_forward", {
|
||||
speed: 0.1,
|
||||
duration: 2,
|
||||
}).catch((error: unknown) => {
|
||||
console.error("Walk forward failed:", error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!rosConnected || rosConnecting}
|
||||
>
|
||||
<Move className="mr-1 h-3 w-3" />
|
||||
Walk Test
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user