mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-05-08 05:48:56 -04:00
Compare commits
27 Commits
7c360dc860
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 14182bf078 | |||
| 943c7bd963 | |||
| 6b54724171 | |||
| 86c1f35537 | |||
| 5b5490cb90 | |||
| 6b98cad53e | |||
| 3e2aa894a0 | |||
| 27f633fb4b | |||
| 6243b62d3b | |||
| f16dd4aa22 | |||
| 7483e4a72b | |||
| 426b5e761b | |||
| cf21a27995 | |||
| 74b5507769 | |||
| 5c67fc106e | |||
| 4b04f2c415 | |||
| c959e61f95 | |||
| de1b125b13 | |||
| 143cf2ce50 | |||
| 61c7cc1e94 | |||
| 8f330cf5f0 | |||
| 254805008e | |||
| c923c63099 | |||
| c05384d1a0 | |||
| c0e5a4ffb8 | |||
| 51aaaa5208 | |||
| e402c51483 |
+10
-2
@@ -16,11 +16,19 @@
|
||||
AUTH_SECRET=""
|
||||
|
||||
# Drizzle
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5433/hristudio"
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@localhost:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-hristudio}"
|
||||
|
||||
# PostgreSQL (used by docker-compose)
|
||||
POSTGRES_USER="postgres"
|
||||
POSTGRES_PASSWORD="postgres"
|
||||
POSTGRES_DB="hristudio"
|
||||
POSTGRES_PORT="5432"
|
||||
|
||||
# MinIO/S3 Configuration
|
||||
MINIO_ENDPOINT="http://localhost:9000"
|
||||
MINIO_ENDPOINT="http://localhost:${MINIO_PORT_API:-9000}"
|
||||
MINIO_REGION="us-east-1"
|
||||
MINIO_ACCESS_KEY="minioadmin"
|
||||
MINIO_SECRET_KEY="minioadmin"
|
||||
MINIO_BUCKET_NAME="hristudio-data"
|
||||
MINIO_PORT_API="9000"
|
||||
MINIO_PORT_CONSOLE="9001"
|
||||
|
||||
+11
-11
@@ -2,13 +2,13 @@ services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: hristudio
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-hristudio}
|
||||
PGSSLMODE: disable
|
||||
command: -c ssl=off
|
||||
ports:
|
||||
- "5140:5432"
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -20,11 +20,11 @@ services:
|
||||
minio:
|
||||
image: minio/minio
|
||||
ports:
|
||||
- "9000:9000" # API
|
||||
- "9001:9001" # Console
|
||||
- "${MINIO_PORT_API:-9000}:9000" # API
|
||||
- "${MINIO_PORT_CONSOLE:-9001}:9001" # Console
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
command: server --console-address ":9001" /data
|
||||
@@ -35,9 +35,9 @@ services:
|
||||
- minio
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin;
|
||||
/usr/bin/mc mb myminio/hristudio-data;
|
||||
/usr/bin/mc anonymous set public myminio/hristudio-data;
|
||||
/usr/bin/mc alias set myminio http://minio:9000 ${MINIO_ACCESS_KEY:-minioadmin} ${MINIO_SECRET_KEY:-minioadmin};
|
||||
/usr/bin/mc mb myminio/${MINIO_BUCKET_NAME:-hristudio-data};
|
||||
/usr/bin/mc anonymous set public myminio/${MINIO_BUCKET_NAME:-hristudio-data};
|
||||
exit 0;
|
||||
"
|
||||
|
||||
|
||||
+9
-5
@@ -5,22 +5,26 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"check": "eslint . && tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "bun db:push && bun scripts/seed-dev.ts",
|
||||
"dev": "bun run ws-server.ts & next dev --turbo",
|
||||
"db:reset": "docker compose rm -s -f -v db && docker compose up -d db && sleep 2 && bun db:seed",
|
||||
"db:restart": "docker compose restart db",
|
||||
"dev": "bun run dev:ws & next dev",
|
||||
"dev:ws": "bun run ws-server.ts",
|
||||
"docker:up": "if [ \"$(uname)\" = \"Darwin\" ]; then colima start; fi && docker compose up -d",
|
||||
"docker:down": "docker compose down && if [ \"$(uname)\" = \"Darwin\" ]; then colima stop; fi",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"start": "bun run start:ws & next start",
|
||||
"start:ws": "bun run ws-server.ts",
|
||||
"start:web": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
Submodule robot-plugins updated: d772aecc54...8334b809f2
@@ -21,27 +21,27 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const { action, studyId, robotId, parameters } = body;
|
||||
|
||||
// Verify user has access to the study
|
||||
const membership = await db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || !["owner", "researcher"].includes(membership.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Insufficient permissions" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const robotIp =
|
||||
process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168";
|
||||
const password = process.env.NAO_PASSWORD || "robolab";
|
||||
|
||||
switch (action) {
|
||||
case "initialize": {
|
||||
// Requires study membership
|
||||
const membership = await db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || !["owner", "researcher"].includes(membership.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Insufficient permissions" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Robots API] Initializing robot at ${robotIp}`);
|
||||
|
||||
const disableAlCmd = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; al = naoqi.ALProxy('ALAutonomousLife', '127.0.0.1', 9559); al.setState('disabled')\\""`;
|
||||
@@ -58,6 +58,21 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
case "executeSystemAction": {
|
||||
// Requires study membership
|
||||
const membership = await db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || !["owner", "researcher"].includes(membership.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Insufficient permissions" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const { id, parameters: actionParams } = parameters ?? {};
|
||||
console.log(`[Robots API] Executing system action ${id}`);
|
||||
|
||||
@@ -89,6 +104,50 @@ export async function POST(request: NextRequest) {
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; m = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559); m.rest()\\""`;
|
||||
break;
|
||||
|
||||
case "play_animation_bow":
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/BowShort_1'"`;
|
||||
break;
|
||||
|
||||
case "play_animation_hey":
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/Hey_1'"`;
|
||||
break;
|
||||
|
||||
case "play_animation_show_floor":
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/ShowFloor_1'"`;
|
||||
break;
|
||||
|
||||
case "play_animation_enthusiastic":
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/Enthusiastic_4'"`;
|
||||
break;
|
||||
|
||||
case "play_animation_yes":
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/Yes_1'"`;
|
||||
break;
|
||||
|
||||
case "play_animation_no":
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/No_3'"`;
|
||||
break;
|
||||
|
||||
case "play_animation_idontknow":
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALAnimationPlayer.run 'animations/Stand/Gestures/IDontKnow_1'"`;
|
||||
break;
|
||||
|
||||
case "stand":
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture Stand 0.5"`;
|
||||
break;
|
||||
|
||||
case "stand_init":
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture StandInit 0.5"`;
|
||||
break;
|
||||
|
||||
case "sit":
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture Sit 0.5"`;
|
||||
break;
|
||||
|
||||
case "crouch":
|
||||
command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "qicli call ALRobotPosture.goToPosture Crouch 0.5"`;
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `System action ${id} not implemented` },
|
||||
@@ -100,6 +159,37 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
case "executeSSH": {
|
||||
// Session auth is sufficient — no studyId needed
|
||||
// command may be top-level in body or nested under parameters
|
||||
const { command } = parameters ?? body;
|
||||
if (!command) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing command parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[Robots API] Executing SSH command: ${command}`);
|
||||
|
||||
const sshCmd = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "nao@${robotIp}" "${command}"`;
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(sshCmd);
|
||||
if (stderr && !stderr.includes("null") && stderr.trim()) {
|
||||
console.warn(`[Robots API] SSH stderr: ${stderr}`);
|
||||
}
|
||||
console.log(`[Robots API] SSH result: ${stdout}`);
|
||||
return NextResponse.json({ success: true, stdout, stderr });
|
||||
} catch (error) {
|
||||
console.error(`[Robots API] SSH command failed:`, error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "SSH command failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown action: ${action}` },
|
||||
|
||||
@@ -34,8 +34,8 @@ const experimentSchema = z.object({
|
||||
.max(100, "Name too long"),
|
||||
description: z
|
||||
.string()
|
||||
.min(10, "Description must be at least 10 characters")
|
||||
.max(1000, "Description too long"),
|
||||
.max(1000, "Description too long")
|
||||
.optional(),
|
||||
studyId: z.string().uuid("Please select a study"),
|
||||
estimatedDuration: z
|
||||
.number()
|
||||
@@ -123,7 +123,7 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
||||
if (mode === "edit" && experiment) {
|
||||
form.reset({
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
description: experiment.description ?? undefined,
|
||||
studyId: experiment.studyId,
|
||||
estimatedDuration: experiment.estimatedDuration ?? undefined,
|
||||
status: experiment.status,
|
||||
|
||||
@@ -1079,14 +1079,18 @@ export function DesignerRoot({
|
||||
}
|
||||
}
|
||||
|
||||
const defExec = actionDef.execution as any;
|
||||
const execution: ExperimentAction["execution"] =
|
||||
actionDef.execution &&
|
||||
(actionDef.execution.transport === "internal" ||
|
||||
actionDef.execution.transport === "rest" ||
|
||||
actionDef.execution.transport === "ros2")
|
||||
defExec &&
|
||||
(defExec.transport === "internal" ||
|
||||
defExec.transport === "rest" ||
|
||||
defExec.transport === "ros2")
|
||||
? {
|
||||
transport: actionDef.execution.transport,
|
||||
retryable: actionDef.execution.retryable ?? false,
|
||||
transport: defExec.transport,
|
||||
retryable: defExec.retryable ?? false,
|
||||
timeoutMs: defExec.timeoutMs,
|
||||
ros2: defExec.ros2,
|
||||
rest: defExec.rest,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -43,7 +43,14 @@ import {
|
||||
Plus,
|
||||
GitBranch,
|
||||
Trash2,
|
||||
PlayCircle,
|
||||
Square,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getWizardRosService, initWizardRosService, resetWizardRosService } from "~/lib/ros/wizard-ros-service";
|
||||
|
||||
/**
|
||||
* PropertiesPanel
|
||||
@@ -90,6 +97,10 @@ export function PropertiesPanelBase({
|
||||
const [localStepDescription, setLocalStepDescription] = useState("");
|
||||
const [localParams, setLocalParams] = useState<Record<string, unknown>>({});
|
||||
|
||||
// Test action state
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testStatus, setTestStatus] = useState<"idle" | "running" | "success" | "error">("idle");
|
||||
|
||||
// Debounce timers
|
||||
const actionUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const stepUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
@@ -168,6 +179,74 @@ export function PropertiesPanelBase({
|
||||
selectedAction &&
|
||||
design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id));
|
||||
|
||||
// Test action handler
|
||||
const handleTestAction = useCallback(async () => {
|
||||
if (!selectedAction || !containingStep) return;
|
||||
|
||||
setIsTesting(true);
|
||||
setTestStatus("running");
|
||||
|
||||
try {
|
||||
console.log("[Test Action] Starting test for action:", selectedAction.name, selectedAction.type);
|
||||
console.log("[Test Action] Execution config:", JSON.stringify(selectedAction.execution, null, 2));
|
||||
console.log("[Test Action] Parameters:", selectedAction.parameters);
|
||||
|
||||
// Reset service to ensure clean state for testing
|
||||
resetWizardRosService();
|
||||
|
||||
// Initialize with actual robot connection (not simulation)
|
||||
const rosService = await initWizardRosService(false);
|
||||
console.log("[Test Action] ROS service initialized, connected:", rosService.getConnectionStatus());
|
||||
|
||||
// Build action config from execution descriptor
|
||||
const execution = selectedAction.execution;
|
||||
let actionConfig: {
|
||||
topic: string;
|
||||
messageType: string;
|
||||
payloadMapping: {
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
transformFn?: string;
|
||||
};
|
||||
} | undefined;
|
||||
|
||||
if (execution?.transport === "ros2" && execution.ros2) {
|
||||
const ros2 = execution.ros2 as any;
|
||||
actionConfig = {
|
||||
topic: ros2.topic || "/speech",
|
||||
messageType: ros2.messageType || "std_msgs/msg/String",
|
||||
payloadMapping: {
|
||||
type: ros2.payloadMapping?.type || "static",
|
||||
payload: ros2.payloadMapping?.payload,
|
||||
transformFn: ros2.payloadMapping?.transformFn,
|
||||
},
|
||||
};
|
||||
console.log("[Test Action] Action config built:", JSON.stringify(actionConfig, null, 2));
|
||||
}
|
||||
|
||||
// Execute the action on the real robot
|
||||
const result = await rosService.executeRobotAction(
|
||||
selectedAction.source?.kind === "plugin" ? (selectedAction.source?.pluginId || "core") : "core",
|
||||
selectedAction.type,
|
||||
selectedAction.parameters,
|
||||
actionConfig,
|
||||
);
|
||||
console.log("[Test Action] Execution result:", result);
|
||||
|
||||
setTestStatus("success");
|
||||
toast.success(`Action "${selectedAction.name}" executed on robot`);
|
||||
} catch (error) {
|
||||
setTestStatus("error");
|
||||
const message = error instanceof Error ? error.message : "Action execution failed";
|
||||
toast.error(message);
|
||||
console.error("Test action error:", error);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
// Reset status after a delay
|
||||
setTimeout(() => setTestStatus("idle"), 2000);
|
||||
}
|
||||
}, [selectedAction, containingStep]);
|
||||
|
||||
/* -------------------------- Action Properties View -------------------------- */
|
||||
if (selectedAction && containingStep) {
|
||||
let def = registry.getAction(selectedAction.type);
|
||||
@@ -277,6 +356,41 @@ export function PropertiesPanelBase({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Action Button */}
|
||||
{selectedAction.execution?.transport !== "internal" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full gap-1.5"
|
||||
onClick={handleTestAction}
|
||||
disabled={isTesting}
|
||||
>
|
||||
{testStatus === "running" ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Running...
|
||||
</>
|
||||
) : testStatus === "success" ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
Success!
|
||||
</>
|
||||
) : testStatus === "error" ? (
|
||||
<>
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
Failed
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
Test Action
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
@@ -721,6 +835,40 @@ export function PropertiesPanelBase({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">After this step, go to</Label>
|
||||
<p className="text-muted-foreground mb-1 text-[10px]">
|
||||
Override the next step (use to converge branch paths).
|
||||
</p>
|
||||
<Select
|
||||
value={(selectedStep.trigger.conditions as any)?.nextStepId ?? "__linear__"}
|
||||
onValueChange={(val) => {
|
||||
onStepUpdate(selectedStep.id, {
|
||||
trigger: {
|
||||
...selectedStep.trigger,
|
||||
conditions: {
|
||||
...(selectedStep.trigger.conditions as any),
|
||||
nextStepId: val === "__linear__" ? undefined : val,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||
<SelectValue placeholder="Next step (default)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__linear__">Next step (default)</SelectItem>
|
||||
{design.steps
|
||||
.filter((s) => s.id !== selectedStep.id)
|
||||
.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -803,12 +951,13 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
||||
|
||||
if (param.type === "text") {
|
||||
control = (
|
||||
<Input
|
||||
<textarea
|
||||
value={(localValue as string) ?? ""}
|
||||
placeholder={param.placeholder}
|
||||
onChange={(e) => handleUpdate(e.target.value)}
|
||||
onBlur={handleCommit}
|
||||
className="mt-1 h-7 w-full text-xs"
|
||||
rows={3}
|
||||
className="mt-1 w-full rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
);
|
||||
} else if (param.type === "select") {
|
||||
@@ -868,14 +1017,24 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
||||
max={max}
|
||||
step={step}
|
||||
value={[Number(numericVal)]}
|
||||
onValueChange={(vals) => setLocalValue(vals[0])} // Update only local visual
|
||||
onPointerUp={() => handleUpdate(localValue)} // Commit on release
|
||||
onValueChange={(vals) => setLocalValue(vals[0])}
|
||||
onPointerUp={() => handleUpdate(localValue)}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={Number(numericVal)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
if (!isNaN(v)) {
|
||||
setLocalValue(Math.max(min, Math.min(max, v)));
|
||||
}
|
||||
}}
|
||||
onBlur={handleCommit}
|
||||
className="h-7 w-16 text-xs tabular-nums"
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
||||
{step < 1
|
||||
? Number(numericVal).toFixed(2)
|
||||
: Number(numericVal).toString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
||||
<span>{min}</span>
|
||||
|
||||
+296
-237
@@ -11,6 +11,7 @@ import {
|
||||
EntityForm,
|
||||
FormField,
|
||||
FormSection,
|
||||
NextSteps,
|
||||
Tips,
|
||||
} from "~/components/ui/entity-form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
@@ -26,7 +27,7 @@ import { Textarea } from "~/components/ui/textarea";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
import { Calendar as CalendarIcon, Clock } from "lucide-react";
|
||||
import { Calendar as CalendarIcon, Clock, Clock2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -84,60 +85,61 @@ function DateTimePicker({
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="date-picker" className="text-xs">
|
||||
Date
|
||||
</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
id="date-picker"
|
||||
className={cn(
|
||||
"w-[240px] justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value ? format(value, "PPP") : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={onDateSelect}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
id="date-picker"
|
||||
className={cn(
|
||||
"w-[200px] justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value ? format(value, "MMM d, yyyy") : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={onDateSelect}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="time-picker"
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={onTimeChange}
|
||||
disabled={!value}
|
||||
className="w-[110px]"
|
||||
/>
|
||||
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="time-picker" className="text-xs">
|
||||
Time
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="time-picker"
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={onTimeChange}
|
||||
disabled={!value}
|
||||
className="w-[120px]"
|
||||
/>
|
||||
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange(new Date())}
|
||||
className="h-10 gap-1.5"
|
||||
>
|
||||
<Clock2 className="h-4 w-4" />
|
||||
Now
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trialSchema = z.object({
|
||||
experimentId: z.string().uuid("Please select an experiment"),
|
||||
participantId: z.string().uuid("Please select a participant"),
|
||||
scheduledAt: z.date(),
|
||||
wizardId: z.string().uuid().optional().or(z.literal("")),
|
||||
experimentId: z.string().min(1, "Please select an experiment *"),
|
||||
participantId: z.string().min(1, "Please select a participant *"),
|
||||
scheduledAt: z.date({ message: "Scheduled date and time is required *" }),
|
||||
wizardId: z.string().optional().or(z.literal("")),
|
||||
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
||||
sessionNumber: z
|
||||
.number()
|
||||
@@ -165,7 +167,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
defaultValues: {
|
||||
experimentId: "" as any,
|
||||
participantId: "" as any,
|
||||
scheduledAt: new Date(),
|
||||
scheduledAt: undefined,
|
||||
wizardId: undefined,
|
||||
notes: "",
|
||||
sessionNumber: 1,
|
||||
@@ -329,6 +331,249 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
return <div>Error loading trial: {fetchError.message}</div>;
|
||||
}
|
||||
|
||||
// Sidebar content
|
||||
const sidebar = (
|
||||
<>
|
||||
<NextSteps
|
||||
steps={[
|
||||
{
|
||||
title: "Configure Experiment",
|
||||
description: "Ensure the experiment protocol is designed and ready",
|
||||
completed: !!form.watch("experimentId"),
|
||||
},
|
||||
{
|
||||
title: "Select Participant",
|
||||
description: "Choose a participant for this trial",
|
||||
completed: !!form.watch("participantId"),
|
||||
},
|
||||
{
|
||||
title: "Assign Wizard",
|
||||
description: "Assign a wizard to operate the robot",
|
||||
},
|
||||
{
|
||||
title: "Run Trial",
|
||||
description: "Execute the trial and collect data",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Tips
|
||||
tips={[
|
||||
"Verify experiment status: Only 'Ready' experiments can be used in trials.",
|
||||
"Check participant availability: Ensure participants are available at the scheduled time.",
|
||||
"Assign wizards early: Give wizards time to prepare before the trial.",
|
||||
"Prepare notes: Add any special instructions for the wizard.",
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
// Form fields
|
||||
const formFields = (
|
||||
<>
|
||||
<FormSection
|
||||
title="Trial Configuration"
|
||||
description="Select the experiment and participant for this trial."
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField>
|
||||
<Label htmlFor="experimentId">
|
||||
Experiment <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={form.watch("experimentId") ?? ""}
|
||||
onValueChange={(value) => form.setValue("experimentId", value)}
|
||||
disabled={experimentsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.experimentId ? "border-red-500 ring-1 ring-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
experimentsLoading
|
||||
? "Loading experiments..."
|
||||
: "Select an experiment"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{experimentsData?.map((experiment) => (
|
||||
<SelectItem key={experiment.id} value={experiment.id}>
|
||||
{experiment.name} ({experiment.status})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.experimentId && (
|
||||
<p className="mt-1 text-sm text-red-500 font-medium">
|
||||
{form.formState.errors.experimentId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Experiment cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="participantId">
|
||||
Participant <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={form.watch("participantId") ?? ""}
|
||||
onValueChange={(value) => form.setValue("participantId", value)}
|
||||
disabled={participantsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.participantId ? "border-red-500 ring-1 ring-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
participantsLoading
|
||||
? "Loading participants..."
|
||||
: "Select a participant"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{participantsData?.participants?.map((participant) => (
|
||||
<SelectItem key={participant.id} value={participant.id}>
|
||||
{participant.name ?? participant.participantCode}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.participantId && (
|
||||
<p className="mt-1 text-sm text-red-500 font-medium">
|
||||
{form.formState.errors.participantId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Participant cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="Scheduling"
|
||||
description="Set when this trial should be conducted."
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField>
|
||||
<Label htmlFor="scheduledAt">
|
||||
Scheduled Date & Time <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="scheduledAt"
|
||||
render={({ field }) => (
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.scheduledAt && (
|
||||
<p className="mt-1 text-sm text-red-500 font-medium">
|
||||
{form.formState.errors.scheduledAt.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
When should this trial be conducted?
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="sessionNumber">Session Number</Label>
|
||||
<Input
|
||||
id="sessionNumber"
|
||||
type="number"
|
||||
min="1"
|
||||
{...form.register("sessionNumber", { valueAsNumber: true })}
|
||||
placeholder="1"
|
||||
className={
|
||||
form.formState.errors.sessionNumber ? "border-red-500 ring-1 ring-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.sessionNumber && (
|
||||
<p className="mt-1 text-sm text-red-500 font-medium">
|
||||
{form.formState.errors.sessionNumber.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Auto-incremented based on participant history
|
||||
</p>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="Assignment & Notes"
|
||||
description="Assign a wizard and add any special instructions."
|
||||
>
|
||||
<FormField>
|
||||
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
||||
<Select
|
||||
value={form.watch("wizardId") ?? "none"}
|
||||
onValueChange={(value) =>
|
||||
form.setValue("wizardId", value === "none" ? undefined : value)
|
||||
}
|
||||
disabled={usersLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
usersLoading
|
||||
? "Loading wizards..."
|
||||
: "Select a wizard (optional)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No wizard assigned</SelectItem>
|
||||
{usersData?.map(
|
||||
(user: { id: string; name: string; email: string }) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Who will operate the robot during this trial?
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
{...form.register("notes")}
|
||||
placeholder="Special instructions for the wizard, environmental setup notes, or other relevant information..."
|
||||
rows={4}
|
||||
className={form.formState.errors.notes ? "border-red-500 ring-1 ring-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.notes && (
|
||||
<p className="mt-1 text-sm text-red-500 font-medium">
|
||||
{form.formState.errors.notes.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Optional: Add any special instructions for this trial
|
||||
</p>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<EntityForm
|
||||
mode={mode}
|
||||
@@ -351,196 +596,10 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
onSubmit={onSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
error={error}
|
||||
sidebar={undefined}
|
||||
sidebar={sidebar}
|
||||
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
||||
layout="full-width"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* Left Column: Main Info (Spans 2) */}
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField>
|
||||
<Label htmlFor="experimentId">Experiment *</Label>
|
||||
<Select
|
||||
value={form.watch("experimentId") ?? ""}
|
||||
onValueChange={(value) => form.setValue("experimentId", value)}
|
||||
disabled={experimentsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.experimentId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
experimentsLoading
|
||||
? "Loading experiments..."
|
||||
: "Select an experiment"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{experimentsData?.map((experiment) => (
|
||||
<SelectItem key={experiment.id} value={experiment.id}>
|
||||
{experiment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.experimentId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.experimentId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Experiment cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="participantId">Participant *</Label>
|
||||
<Select
|
||||
value={form.watch("participantId") ?? ""}
|
||||
onValueChange={(value) => form.setValue("participantId", value)}
|
||||
disabled={participantsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.participantId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
participantsLoading
|
||||
? "Loading participants..."
|
||||
: "Select a participant"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{participantsData?.participants?.map((participant) => (
|
||||
<SelectItem key={participant.id} value={participant.id}>
|
||||
{participant.name ?? participant.participantCode} (
|
||||
{participant.participantCode})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.participantId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.participantId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Participant cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField>
|
||||
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="scheduledAt"
|
||||
render={({ field }) => (
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.scheduledAt && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.scheduledAt.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
When should this trial be conducted?
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="sessionNumber">Session Number</Label>
|
||||
<Input
|
||||
id="sessionNumber"
|
||||
type="number"
|
||||
min="1"
|
||||
{...form.register("sessionNumber", { valueAsNumber: true })}
|
||||
placeholder="1"
|
||||
className={
|
||||
form.formState.errors.sessionNumber ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.sessionNumber && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.sessionNumber.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Auto-incremented based on participant history
|
||||
</p>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Assignment & Notes (Spans 1) */}
|
||||
<div className="space-y-6">
|
||||
<FormField>
|
||||
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
||||
<Select
|
||||
value={form.watch("wizardId") ?? "none"}
|
||||
onValueChange={(value) =>
|
||||
form.setValue("wizardId", value === "none" ? undefined : value)
|
||||
}
|
||||
disabled={usersLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
usersLoading
|
||||
? "Loading wizards..."
|
||||
: "Select a wizard (optional)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No wizard assigned</SelectItem>
|
||||
{usersData?.map(
|
||||
(user: { id: string; name: string; email: string }) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Who will operate the robot?
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
{...form.register("notes")}
|
||||
placeholder="Special instructions..."
|
||||
rows={5}
|
||||
className={form.formState.errors.notes ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.notes && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.notes.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
{formFields}
|
||||
</EntityForm>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -430,8 +430,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
order: step.order ?? index,
|
||||
actions:
|
||||
step.actions
|
||||
?.filter((a) => a.type !== "branch")
|
||||
.map((action) => ({
|
||||
?.map((action) => ({
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
@@ -793,8 +792,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
);
|
||||
}
|
||||
|
||||
// Default: Linear progression
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
// Default: Linear progression (skip steps marked as skipped by branching)
|
||||
let nextIndex = currentStepIndex + 1;
|
||||
while (nextIndex < steps.length && skippedSteps.has(nextIndex)) {
|
||||
nextIndex++;
|
||||
}
|
||||
if (nextIndex < steps.length) {
|
||||
// Mark current step as complete
|
||||
setCompletedSteps((prev) => {
|
||||
@@ -923,8 +925,8 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
// Log action execution
|
||||
console.log("Executing action:", actionId, parameters);
|
||||
|
||||
// Handle branching logic (wizard_wait_for_response)
|
||||
if (parameters?.value && parameters?.label) {
|
||||
// Handle branching logic (wizard_wait_for_response / branch)
|
||||
if (parameters?.label || parameters?.nextStepId) {
|
||||
setLastResponse(String(parameters.value));
|
||||
|
||||
// If nextStepId is provided, jump immediately
|
||||
@@ -943,6 +945,24 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
console.log(
|
||||
`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`,
|
||||
);
|
||||
|
||||
// Mark other branch targets as skipped so linear progression bypasses them
|
||||
const branchingStep = steps[currentStepIndex];
|
||||
const allOptions =
|
||||
(branchingStep?.conditions?.options as any[]) ?? [];
|
||||
setSkippedSteps((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const opt of allOptions) {
|
||||
if (opt.nextStepId && opt.nextStepId !== nextId) {
|
||||
const otherIdx = steps.findIndex(
|
||||
(s) => s.id === opt.nextStepId,
|
||||
);
|
||||
if (otherIdx !== -1) next.add(otherIdx);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
handleNextStep(targetIndex);
|
||||
return; // Exit after jump
|
||||
} else {
|
||||
|
||||
@@ -499,6 +499,7 @@ export function WizardActionItem({
|
||||
// Manual/Wizard Actions (Leaf nodes)
|
||||
!isContainer &&
|
||||
action.type !== "wizard_wait_for_response" &&
|
||||
!isBranch &&
|
||||
!isCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -524,7 +525,7 @@ export function WizardActionItem({
|
||||
<div className="grid grid-cols-1 gap-2 pt-3 sm:grid-cols-2">
|
||||
{(action.parameters.options as any[]).map((opt, optIdx) => {
|
||||
const label = typeof opt === "string" ? opt : opt.label;
|
||||
const value = typeof opt === "string" ? opt : opt.value;
|
||||
const value = typeof opt === "string" ? opt : (opt.value ?? opt.label);
|
||||
const nextStepId =
|
||||
typeof opt === "object" ? opt.nextStepId : undefined;
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ const actionSourceSchema = z
|
||||
const executionDescriptorSchema = z
|
||||
.object({
|
||||
transport: z.enum(["ros2", "rest", "internal"]),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
timeoutMs: z.number().int().min(0).optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
ros2: z
|
||||
.object({
|
||||
|
||||
@@ -45,6 +45,21 @@ export interface RobotActionExecution {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single frame in a multi-step joint animation.
|
||||
* @param joint_names - NAO joint names to move (e.g. "LShoulderPitch")
|
||||
* @param joint_angles - Target angles in radians, one per joint_name
|
||||
* @param speed - Movement speed as a fraction of max (0.0–1.0)
|
||||
* @param delay_after - Milliseconds to wait after publishing this frame
|
||||
* before sending the next one. Defaults to 800 ms.
|
||||
*/
|
||||
export interface AnimationMovement {
|
||||
joint_names: string[];
|
||||
joint_angles: number[];
|
||||
speed?: number;
|
||||
delay_after?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified ROS WebSocket service for wizard interface
|
||||
* Manages connection to rosbridge and handles robot action execution
|
||||
@@ -329,6 +344,19 @@ export class WizardRosService extends EventEmitter {
|
||||
|
||||
console.log(`[WizardROS] SIMULATION MODE - Executing ${actionId}:`, parameters);
|
||||
|
||||
// If the action config carries a gesture_sequence payload, run the sim animation handler
|
||||
if (actionConfig?.payloadMapping?.payload) {
|
||||
const payload = actionConfig.payloadMapping.payload as { type?: string; movements?: AnimationMovement[] };
|
||||
if (payload.type === "gesture_sequence" && payload.movements?.length) {
|
||||
await this.executeSimulationAnimationSequence(payload.movements);
|
||||
execution.status = "completed";
|
||||
execution.endTime = new Date();
|
||||
this.emit("action_completed", execution);
|
||||
this.activeActions.set(executionId, execution);
|
||||
return execution;
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate action execution based on action type
|
||||
let duration = 500;
|
||||
|
||||
@@ -336,9 +364,8 @@ export class WizardRosService extends EventEmitter {
|
||||
const text = String(parameters.text || parameters.data || "Hello");
|
||||
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
||||
duration = 1500 + Math.max(1000, wordCount * 300);
|
||||
} else if (actionId.includes("walk") || actionId.includes("turn") || actionConfig?.topic === "/cmd_vel") {
|
||||
} else if (actionId.includes("walk") || actionId === "stop_walking" || actionConfig?.topic === "/cmd_vel") {
|
||||
duration = 500;
|
||||
// Simulate position change
|
||||
const speed = Number(parameters.speed) || 0.1;
|
||||
if (actionId === "walk_forward") {
|
||||
this.robotStatus.position.x += speed * 0.5;
|
||||
@@ -349,8 +376,10 @@ export class WizardRosService extends EventEmitter {
|
||||
} else if (actionId === "turn_right") {
|
||||
this.robotStatus.position.theta += 0.5;
|
||||
}
|
||||
} else if (actionId.includes("head") || actionId.includes("move") || actionConfig?.topic === "/joint_angles") {
|
||||
} else if (actionConfig?.topic === "/joint_angles") {
|
||||
duration = 1000;
|
||||
} else if (actionId === "wake_up" || actionId === "rest" || actionId === "set_posture") {
|
||||
duration = 2000;
|
||||
}
|
||||
|
||||
// Simulate async execution
|
||||
@@ -434,7 +463,7 @@ export class WizardRosService extends EventEmitter {
|
||||
|
||||
// Execute based on action configuration or built-in mappings
|
||||
if (actionConfig) {
|
||||
await this.executeWithConfig(actionConfig, parameters);
|
||||
await this.executeWithConfig(actionConfig, parameters, actionId);
|
||||
} else {
|
||||
await this.executeBuiltinAction(actionId, parameters);
|
||||
}
|
||||
@@ -460,6 +489,60 @@ export class WizardRosService extends EventEmitter {
|
||||
return Array.from(this.activeActions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a multi-frame joint animation by sending each movement in sequence.
|
||||
* Each frame is published to /joint_angles then held for delay_after ms
|
||||
* (default 800 ms) before the next frame is sent.
|
||||
*/
|
||||
async executeAnimationSequence(movements: AnimationMovement[]): Promise<void> {
|
||||
if (!movements.length) {
|
||||
console.warn("[WizardROS] executeAnimationSequence called with empty movements");
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < movements.length; i++) {
|
||||
const movement = movements[i]!;
|
||||
console.log(
|
||||
`[WizardROS] Animation frame ${i + 1}/${movements.length}:`,
|
||||
movement.joint_names,
|
||||
"→",
|
||||
movement.joint_angles,
|
||||
);
|
||||
|
||||
this.publish(
|
||||
"/joint_angles",
|
||||
"naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
|
||||
{
|
||||
joint_names: movement.joint_names,
|
||||
joint_angles: movement.joint_angles,
|
||||
speed: movement.speed ?? 0.3,
|
||||
},
|
||||
);
|
||||
|
||||
// Always wait after each frame (including the last) so the caller
|
||||
// can await this method and know the animation has finished.
|
||||
const delayMs = movement.delay_after ?? 800;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a multi-frame joint animation in simulation mode (logs only).
|
||||
*/
|
||||
private async executeSimulationAnimationSequence(
|
||||
movements: AnimationMovement[],
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < movements.length; i++) {
|
||||
const movement = movements[i]!;
|
||||
console.log(
|
||||
`[WizardROS] SIMULATION animation frame ${i + 1}/${movements.length}:`,
|
||||
movement.joint_names,
|
||||
);
|
||||
const delayMs = movement.delay_after ?? 800;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to robot sensor topics
|
||||
*/
|
||||
@@ -480,6 +563,7 @@ export class WizardRosService extends EventEmitter {
|
||||
|
||||
this.advertise("/speech", "std_msgs/String");
|
||||
this.advertise("/cmd_vel", "geometry_msgs/Twist");
|
||||
this.advertise("/joint_angles", "naoqi_bridge_msgs/msg/JointAnglesWithSpeed");
|
||||
this.advertise("/robot_pose", "geometry_msgs/Pose");
|
||||
this.advertise("/animation", "std_msgs/String");
|
||||
}
|
||||
@@ -632,10 +716,30 @@ export class WizardRosService extends EventEmitter {
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
transformFn?: string;
|
||||
service?: string;
|
||||
args?: Record<string, unknown>;
|
||||
sshCommand?: string;
|
||||
};
|
||||
},
|
||||
parameters: Record<string, unknown>,
|
||||
actionId?: string,
|
||||
): Promise<void> {
|
||||
// SSH command actions
|
||||
if (config.payloadMapping.type === "ssh" && config.payloadMapping.sshCommand) {
|
||||
await this.executeSSHCommand(config.payloadMapping.sshCommand);
|
||||
return;
|
||||
}
|
||||
|
||||
// Service-call actions — no topic publish involved
|
||||
if (config.payloadMapping.type === "service") {
|
||||
const service = config.payloadMapping.service ?? config.topic;
|
||||
const args = config.payloadMapping.args
|
||||
? this.buildTemplatePayload(config.payloadMapping.args, parameters)
|
||||
: parameters;
|
||||
await this.callService(service, args);
|
||||
return;
|
||||
}
|
||||
|
||||
let msg: Record<string, unknown>;
|
||||
|
||||
if (
|
||||
@@ -643,22 +747,31 @@ export class WizardRosService extends EventEmitter {
|
||||
config.payloadMapping.type === "static") &&
|
||||
config.payloadMapping.payload
|
||||
) {
|
||||
// Template-based payload construction
|
||||
msg = this.buildTemplatePayload(
|
||||
config.payloadMapping.payload,
|
||||
parameters,
|
||||
);
|
||||
msg = this.buildTemplatePayload(config.payloadMapping.payload, parameters);
|
||||
} else if (config.payloadMapping.transformFn) {
|
||||
// Custom transform function
|
||||
msg = this.applyTransformFunction(
|
||||
config.payloadMapping.transformFn,
|
||||
parameters,
|
||||
);
|
||||
msg = this.applyTransformFunction(config.payloadMapping.transformFn, parameters);
|
||||
} else {
|
||||
// Direct parameter mapping
|
||||
msg = parameters;
|
||||
}
|
||||
|
||||
// Delegate gesture_sequence payloads to the animation handler
|
||||
if ((msg as { type?: string }).type === "gesture_sequence") {
|
||||
const movements = (msg as { movements?: AnimationMovement[] }).movements;
|
||||
if (!movements?.length) {
|
||||
console.warn("[WizardROS] gesture_sequence payload has no movements");
|
||||
return;
|
||||
}
|
||||
console.log(`[WizardROS] Delegating to animation handler (${movements.length} frames)`);
|
||||
await this.executeAnimationSequence(movements);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route /animation topic through SSH instead of ROS to avoid crashes
|
||||
if (config.topic === "/animation" && actionId?.startsWith("play_animation_")) {
|
||||
await this.executeAnimationSSH(actionId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.publish(config.topic, config.messageType, msg);
|
||||
|
||||
// Wait for action completion based on topic type
|
||||
@@ -747,7 +860,7 @@ export class WizardRosService extends EventEmitter {
|
||||
case "turn_head":
|
||||
this.publish(
|
||||
"/joint_angles",
|
||||
"naoqi_bridge_msgs/JointAnglesWithSpeed",
|
||||
"naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
|
||||
{
|
||||
joint_names: ["HeadYaw", "HeadPitch"],
|
||||
joint_angles: [
|
||||
@@ -760,12 +873,12 @@ export class WizardRosService extends EventEmitter {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
break;
|
||||
|
||||
case "move_arm":
|
||||
case "move_arm": {
|
||||
const arm = String(parameters.arm || "right");
|
||||
const prefix = arm.toLowerCase() === "left" ? "L" : "R";
|
||||
this.publish(
|
||||
"/joint_angles",
|
||||
"naoqi_bridge_msgs/JointAnglesWithSpeed",
|
||||
"naoqi_bridge_msgs/msg/JointAnglesWithSpeed",
|
||||
{
|
||||
joint_names: [
|
||||
`${prefix}ShoulderPitch`,
|
||||
@@ -784,6 +897,40 @@ export class WizardRosService extends EventEmitter {
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
break;
|
||||
}
|
||||
|
||||
case "wake_up":
|
||||
// Try known naoqi_driver2 service names in order
|
||||
for (const svc of [
|
||||
"/naoqi_driver/ALMotion/wakeUp",
|
||||
"/naoqi_driver/motion/wake_up",
|
||||
"/motion/wake_up",
|
||||
]) {
|
||||
try {
|
||||
await this.callService(svc, {});
|
||||
console.log(`[WizardROS] wake_up succeeded via ${svc}`);
|
||||
break;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "rest":
|
||||
for (const svc of [
|
||||
"/naoqi_driver/ALMotion/rest",
|
||||
"/naoqi_driver/motion/rest",
|
||||
"/motion/rest",
|
||||
]) {
|
||||
try {
|
||||
await this.callService(svc, {});
|
||||
console.log(`[WizardROS] rest succeeded via ${svc}`);
|
||||
break;
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "emergency_stop":
|
||||
this.publish("/cmd_vel", "geometry_msgs/Twist", {
|
||||
@@ -792,6 +939,16 @@ export class WizardRosService extends EventEmitter {
|
||||
});
|
||||
break;
|
||||
|
||||
case "play_animation_bow":
|
||||
case "play_animation_hey":
|
||||
case "play_animation_show_floor":
|
||||
case "play_animation_enthusiastic":
|
||||
case "play_animation_yes":
|
||||
case "play_animation_no":
|
||||
case "play_animation_idontknow":
|
||||
await this.executeAnimationSSH(actionId);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown action: ${actionId}. Define this action in your robot plugin.`,
|
||||
@@ -879,6 +1036,56 @@ export class WizardRosService extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute animation via API route (SSH to robot)
|
||||
*/
|
||||
private async executeAnimationSSH(actionId: string): Promise<void> {
|
||||
const animationMap: Record<string, string> = {
|
||||
"play_animation_bow": "animations/Stand/Gestures/BowShort_1",
|
||||
"play_animation_hey": "animations/Stand/Gestures/Hey_1",
|
||||
"play_animation_show_floor": "animations/Stand/Gestures/ShowFloor_1",
|
||||
"play_animation_enthusiastic": "animations/Stand/Gestures/Enthusiastic_4",
|
||||
"play_animation_yes": "animations/Stand/Gestures/Yes_1",
|
||||
"play_animation_no": "animations/Stand/Gestures/No_3",
|
||||
"play_animation_idontknow": "animations/Stand/Gestures/IDontKnow_1",
|
||||
};
|
||||
|
||||
const animation = animationMap[actionId];
|
||||
if (!animation) {
|
||||
throw new Error(`Unknown animation: ${actionId}`);
|
||||
}
|
||||
|
||||
console.log(`[WizardROS] Executing animation via API: ${animation}`);
|
||||
|
||||
// Use executeSSH to run animation via qicli (bypasses studyId requirement)
|
||||
await this.executeSSHCommand(`qicli call ALAnimationPlayer.run '${animation}'`);
|
||||
|
||||
console.log(`[WizardROS] Animation completed: ${animation}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an arbitrary SSH command via the API
|
||||
*/
|
||||
private async executeSSHCommand(command: string): Promise<void> {
|
||||
console.log(`[WizardROS] Executing SSH command: ${command}`);
|
||||
|
||||
const response = await fetch("/api/robots/command", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "executeSSH",
|
||||
command,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`SSH command failed: ${error}`);
|
||||
}
|
||||
|
||||
console.log(`[WizardROS] SSH command completed: ${command}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Autonomous Life state with fallbacks
|
||||
*/
|
||||
@@ -1042,6 +1249,21 @@ export class WizardRosService extends EventEmitter {
|
||||
case "transformToEmotionalSpeech":
|
||||
return this.transformToEmotionalSpeech(parameters);
|
||||
|
||||
case "transformToEmotionSpeech":
|
||||
return this.transformToEmotionalSpeech(parameters);
|
||||
|
||||
case "transformToWaveGoodbye":
|
||||
return this.transformToWaveGoodbye(parameters);
|
||||
|
||||
case "transformToAnimation":
|
||||
return this.transformToAnimation(parameters);
|
||||
|
||||
case "transformToGestureSequence":
|
||||
return this.transformToGestureSequence(parameters);
|
||||
|
||||
case "transformToWaveGoodbye":
|
||||
return this.transformToWaveGoodbye(parameters);
|
||||
|
||||
default:
|
||||
console.warn(`Unknown transform function: ${transformFn}`);
|
||||
return parameters;
|
||||
@@ -1051,34 +1273,26 @@ export class WizardRosService extends EventEmitter {
|
||||
/**
|
||||
* Transform parameters for emotional speech
|
||||
* NAOqi markup: \rspd=<speed>\<text>
|
||||
* For animated speech: ^start(animations/Stand/Gestures/...)
|
||||
* Using pure speech modifiers without animations to avoid sound effects
|
||||
*/
|
||||
private transformToEmotionalSpeech(parameters: Record<string, unknown>): {
|
||||
data: string;
|
||||
} {
|
||||
const text = String(parameters.text || "Hello");
|
||||
const emotion = String(parameters.emotion || "neutral");
|
||||
const speed = Number(parameters.speed || 1.0);
|
||||
const speedPercent = Math.round(speed * 100);
|
||||
|
||||
let markedText = text;
|
||||
|
||||
switch (emotion) {
|
||||
case "happy":
|
||||
markedText = `\\rspd=120\\^start(animations/Stand/Gestures/Happy_4) ${text}`;
|
||||
break;
|
||||
case "excited":
|
||||
markedText = `\\rspd=140\\^start(animations/Stand/Gestures/Enthusiastic_1) ${text}`;
|
||||
markedText = `\\rspd=120\\ ${text}`;
|
||||
break;
|
||||
case "sad":
|
||||
markedText = `\\rspd=80\\vct=80\\${text}`;
|
||||
break;
|
||||
case "calm":
|
||||
markedText = `\\rspd=90\\${text}`;
|
||||
markedText = `\\rspd=80\\ ${text}`;
|
||||
break;
|
||||
case "neutral":
|
||||
default:
|
||||
markedText = `\\rspd=${speedPercent}\\${text}`;
|
||||
markedText = text;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1086,14 +1300,13 @@ export class WizardRosService extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform for wave goodbye - animated speech with waving
|
||||
* Transform for wave goodbye - speech without animation sound
|
||||
*/
|
||||
private transformToWaveGoodbye(parameters: Record<string, unknown>): {
|
||||
data: string;
|
||||
} {
|
||||
const text = String(parameters.text || "Goodbye!");
|
||||
const markedText = `\\rspd=110\\^start(animations/Stand/Gestures/Hey_1) ${text} ^start(animations/Stand/Gestures/Hey_1)`;
|
||||
return { data: markedText };
|
||||
return { data: text };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1107,6 +1320,38 @@ export class WizardRosService extends EventEmitter {
|
||||
return { data: markedText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform for gesture sequences - sends multiple joint angle movements
|
||||
* Parameters: movements = [{joints: string[], angles: number[], duration: number}, ...]
|
||||
*/
|
||||
private transformToGestureSequence(parameters: Record<string, unknown>): {
|
||||
type: string;
|
||||
movements: Array<{
|
||||
joint_names: string[];
|
||||
joint_angles: number[];
|
||||
speed: number;
|
||||
}>;
|
||||
} {
|
||||
const movements = parameters.movements as Array<{
|
||||
joints?: string[];
|
||||
angles?: number[];
|
||||
duration?: number;
|
||||
speed?: number;
|
||||
}>;
|
||||
|
||||
if (!Array.isArray(movements)) {
|
||||
return { type: "gesture_sequence", movements: [] };
|
||||
}
|
||||
|
||||
const parsedMovements = movements.map((m, index) => ({
|
||||
joint_names: m.joints || [],
|
||||
joint_angles: m.angles || [],
|
||||
speed: m.speed || (m.duration ? 1 / (m.duration / 1000) : 0.3),
|
||||
}));
|
||||
|
||||
return { type: "gesture_sequence", movements: parsedMovements };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection attempt
|
||||
*/
|
||||
|
||||
@@ -675,8 +675,11 @@ export const experimentsRouter = createTRPCRouter({
|
||||
// Delete existing steps and actions for this experiment
|
||||
await ctx.db.delete(steps).where(eq(steps.experimentId, id));
|
||||
|
||||
// Map from designer temp step ID → new DB UUID (for branch nextStepId fix-up)
|
||||
const stepIdMap = new Map<string, string>();
|
||||
|
||||
// Create new steps and actions
|
||||
for (const convertedStep of convertedSteps) {
|
||||
for (const [i, convertedStep] of convertedSteps.entries()) {
|
||||
const [newStep] = await ctx.db
|
||||
.insert(steps)
|
||||
.values({
|
||||
@@ -698,6 +701,10 @@ export const experimentsRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Record temp ID → real UUID so branch nextStepId refs can be fixed up
|
||||
const tempId = normalizedSteps[i]?.id;
|
||||
if (tempId) stepIdMap.set(tempId, newStep.id);
|
||||
|
||||
// Create actions for this step
|
||||
for (const convertedAction of convertedStep.actions) {
|
||||
await ctx.db.insert(actions).values({
|
||||
@@ -724,6 +731,25 @@ export const experimentsRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fix-up branch nextStepId: replace temp designer IDs with real DB UUIDs
|
||||
// in both action parameters and step conditions
|
||||
for (const [tempId, dbId] of stepIdMap) {
|
||||
await ctx.db.execute(
|
||||
sql`UPDATE ${actions}
|
||||
SET parameters = replace(parameters::text, ${tempId}, ${dbId})::jsonb
|
||||
WHERE step_id IN (
|
||||
SELECT id FROM ${steps} WHERE experiment_id = ${id}
|
||||
)
|
||||
AND parameters::text LIKE ${"%" + tempId + "%"}`,
|
||||
);
|
||||
await ctx.db.execute(
|
||||
sql`UPDATE ${steps}
|
||||
SET conditions = replace(conditions::text, ${tempId}, ${dbId})::jsonb
|
||||
WHERE experiment_id = ${id}
|
||||
AND conditions::text LIKE ${"%" + tempId + "%"}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
|
||||
@@ -593,7 +593,7 @@ export const trialsRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
// Broadcast trial status update
|
||||
await wsManager.broadcast(input.id, {
|
||||
await wsManager.broadcastExternal(input.id, {
|
||||
type: "trial_status",
|
||||
data: {
|
||||
trial: trial[0],
|
||||
@@ -655,7 +655,7 @@ export const trialsRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
// Broadcast trial status update
|
||||
await wsManager.broadcast(input.id, {
|
||||
await wsManager.broadcastExternal(input.id, {
|
||||
type: "trial_status",
|
||||
data: {
|
||||
trial,
|
||||
@@ -718,7 +718,7 @@ export const trialsRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
// Broadcast trial status update
|
||||
await wsManager.broadcast(input.id, {
|
||||
await wsManager.broadcastExternal(input.id, {
|
||||
type: "trial_status",
|
||||
data: {
|
||||
trial: trial[0],
|
||||
@@ -878,7 +878,7 @@ export const trialsRouter = createTRPCRouter({
|
||||
.returning();
|
||||
|
||||
// Broadcast new event to all subscribers
|
||||
await wsManager.broadcast(input.trialId, {
|
||||
await wsManager.broadcastExternal(input.trialId, {
|
||||
type: "trial_event",
|
||||
data: {
|
||||
event,
|
||||
@@ -922,7 +922,7 @@ export const trialsRouter = createTRPCRouter({
|
||||
.returning();
|
||||
|
||||
// Broadcast intervention to all subscribers
|
||||
await wsManager.broadcast(input.trialId, {
|
||||
await wsManager.broadcastExternal(input.trialId, {
|
||||
type: "intervention_logged",
|
||||
data: {
|
||||
intervention,
|
||||
@@ -986,7 +986,7 @@ export const trialsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Broadcast annotation to all subscribers
|
||||
await wsManager.broadcast(input.trialId, {
|
||||
await wsManager.broadcastExternal(input.trialId, {
|
||||
type: "annotation_added",
|
||||
data: {
|
||||
annotation,
|
||||
@@ -1380,7 +1380,7 @@ export const trialsRouter = createTRPCRouter({
|
||||
.returning();
|
||||
|
||||
// Broadcast robot action to all subscribers
|
||||
await wsManager.broadcast(input.trialId, {
|
||||
await wsManager.broadcastExternal(input.trialId, {
|
||||
type: "trial_action_executed",
|
||||
data: {
|
||||
action_type: `${input.pluginName}.${input.actionId}`,
|
||||
@@ -1439,7 +1439,7 @@ export const trialsRouter = createTRPCRouter({
|
||||
.returning();
|
||||
|
||||
// Broadcast robot action to all subscribers
|
||||
await wsManager.broadcast(input.trialId, {
|
||||
await wsManager.broadcastExternal(input.trialId, {
|
||||
type: "trial_action_executed",
|
||||
data: {
|
||||
action_type: `${input.pluginName}.${input.actionId}`,
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
import WebSocket from "ws";
|
||||
import { EventEmitter } from "events";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface RobotCommunicationConfig {
|
||||
rosBridgeUrl: string;
|
||||
@@ -22,6 +26,24 @@ export interface RobotAction {
|
||||
topic: string;
|
||||
messageType: string;
|
||||
messageTemplate: Record<string, unknown>;
|
||||
payloadMapping?: {
|
||||
type?: string;
|
||||
transformFn?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
sshCommand?: string;
|
||||
};
|
||||
ros2?: {
|
||||
topic?: string;
|
||||
messageType?: string;
|
||||
service?: string;
|
||||
action?: string;
|
||||
payloadMapping?: {
|
||||
type?: string;
|
||||
transformFn?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
sshCommand?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,41 +183,71 @@ export class RobotCommunicationService extends EventEmitter {
|
||||
* Execute a robot action
|
||||
*/
|
||||
async executeAction(action: RobotAction): Promise<RobotActionResult> {
|
||||
const actionId = `action_${this.messageId++}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Check if this is an SSH-only action (animations, posture, arbitrary SSH commands)
|
||||
const { implementation, actionId: actionType } = action;
|
||||
const baseActionId = actionType.includes(".")
|
||||
? actionType.split(".").pop()
|
||||
: actionType;
|
||||
|
||||
const isAnimationAction = baseActionId?.startsWith("play_animation_");
|
||||
const sshCommand = implementation.payloadMapping?.sshCommand
|
||||
|| implementation.ros2?.payloadMapping?.sshCommand;
|
||||
|
||||
// SSH actions don't require ROS connection
|
||||
if (isAnimationAction || sshCommand) {
|
||||
const timeout = setTimeout(() => {
|
||||
throw new Error(`SSH action timeout: ${action.actionId}`);
|
||||
}, 30000);
|
||||
|
||||
try {
|
||||
console.log(`[RobotComm] Executing SSH action: ${action.actionId}`);
|
||||
const result = await this.executeRobotActionInternal(action, actionId, startTime);
|
||||
clearTimeout(timeout);
|
||||
return result;
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-SSH actions require ROS connection
|
||||
if (!this.isConnected) {
|
||||
throw new Error("Not connected to ROS bridge");
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const actionId = `action_${this.messageId++}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Set up timeout
|
||||
const timeout = setTimeout(() => {
|
||||
// Store pending action
|
||||
const pending = {
|
||||
resolve: (() => {}) as (result: RobotActionResult) => void,
|
||||
reject: (() => {}) as (error: Error) => void,
|
||||
timeout: setTimeout(() => {
|
||||
this.pendingActions.delete(actionId);
|
||||
reject(new Error(`Action timeout: ${action.actionId}`));
|
||||
}, 30000); // 30 second timeout
|
||||
pending.reject(new Error(`Action timeout: ${action.actionId}`));
|
||||
}, 30000),
|
||||
startTime,
|
||||
};
|
||||
|
||||
// Store pending action
|
||||
this.pendingActions.set(actionId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
startTime,
|
||||
});
|
||||
this.pendingActions.set(actionId, pending);
|
||||
|
||||
try {
|
||||
// Log the action we're about to execute
|
||||
console.log(`[RobotComm] Executing robot action: ${action.actionId}`);
|
||||
console.log(`[RobotComm] Topic: ${action.implementation.topic}`);
|
||||
console.log(`[RobotComm] Parameters:`, action.parameters);
|
||||
// Wrap the pending resolve/reject in a way that works with async method
|
||||
return new Promise<RobotActionResult>((resolve, reject) => {
|
||||
pending.resolve = resolve;
|
||||
pending.reject = reject;
|
||||
|
||||
// Execute action based on type and platform
|
||||
this.executeRobotActionInternal(action, actionId);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
this.pendingActions.delete(actionId);
|
||||
reject(error);
|
||||
}
|
||||
// Execute action
|
||||
this.executeRobotActionInternal(action, actionId, startTime)
|
||||
.then((result) => {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingActions.delete(actionId);
|
||||
resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingActions.delete(actionId);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,17 +260,54 @@ export class RobotCommunicationService extends EventEmitter {
|
||||
|
||||
// Private methods
|
||||
|
||||
private executeRobotActionInternal(
|
||||
private async executeRobotActionInternal(
|
||||
action: RobotAction,
|
||||
actionId: string,
|
||||
): void {
|
||||
const { implementation, parameters } = action;
|
||||
startTime: number,
|
||||
): Promise<RobotActionResult> {
|
||||
const { implementation, parameters, actionId: actionType } = action;
|
||||
|
||||
// Build ROS message from template
|
||||
const message = this.buildRosMessage(
|
||||
implementation.messageTemplate,
|
||||
parameters,
|
||||
);
|
||||
// Use SSH for play_animation actions (check both namespaced and non-namespaced)
|
||||
const baseActionId = actionType.includes(".")
|
||||
? actionType.split(".").pop()
|
||||
: actionType;
|
||||
|
||||
if (baseActionId?.startsWith("play_animation_")) {
|
||||
await this.executeAnimationViaSSH(baseActionId);
|
||||
return {
|
||||
success: true,
|
||||
duration: Date.now() - startTime,
|
||||
data: { method: "ssh", action: baseActionId },
|
||||
};
|
||||
}
|
||||
|
||||
// Check for SSH command type
|
||||
const sshCommand = implementation.payloadMapping?.sshCommand
|
||||
|| implementation.ros2?.payloadMapping?.sshCommand;
|
||||
|
||||
if (sshCommand) {
|
||||
await this.executeSSHCommand(sshCommand);
|
||||
return {
|
||||
success: true,
|
||||
duration: Date.now() - startTime,
|
||||
data: { method: "ssh", command: sshCommand },
|
||||
};
|
||||
}
|
||||
|
||||
// Apply transform if specified
|
||||
let message: Record<string, unknown>;
|
||||
const transformFn = implementation.payloadMapping?.transformFn
|
||||
|| implementation.ros2?.payloadMapping?.transformFn;
|
||||
|
||||
if (transformFn) {
|
||||
message = this.applyTransform(transformFn, parameters);
|
||||
} else {
|
||||
// Build ROS message from template
|
||||
message = this.buildRosMessage(
|
||||
implementation.messageTemplate,
|
||||
parameters,
|
||||
);
|
||||
}
|
||||
|
||||
// Publish to ROS topic
|
||||
this.publishToTopic(
|
||||
@@ -229,19 +318,94 @@ export class RobotCommunicationService extends EventEmitter {
|
||||
|
||||
// For actions that complete immediately (like movement commands),
|
||||
// we simulate completion after a short delay
|
||||
setTimeout(() => {
|
||||
this.completeAction(actionId, {
|
||||
success: true,
|
||||
duration:
|
||||
Date.now() -
|
||||
(this.pendingActions.get(actionId)?.startTime || Date.now()),
|
||||
data: {
|
||||
topic: implementation.topic,
|
||||
messageType: implementation.messageType,
|
||||
message,
|
||||
},
|
||||
});
|
||||
}, 100);
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
success: true,
|
||||
duration: Date.now() - startTime,
|
||||
data: {
|
||||
topic: implementation.topic,
|
||||
messageType: implementation.messageType,
|
||||
message,
|
||||
},
|
||||
});
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
private async executeSSHCommand(command: string): Promise<void> {
|
||||
const robotIp = process.env.NAO_IP || "134.82.159.168";
|
||||
const password = process.env.NAO_PASSWORD || "robolab";
|
||||
|
||||
console.log(`[RobotComm] Executing SSH command: ${command}`);
|
||||
|
||||
const sshCommand = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 "nao@${robotIp}" "${command}"`;
|
||||
|
||||
const { stdout, stderr } = await execAsync(sshCommand);
|
||||
|
||||
if (stderr && !stderr.includes("null") && stderr.trim()) {
|
||||
console.warn(`[RobotComm] SSH stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
console.log(`[RobotComm] SSH result: ${stdout}`);
|
||||
}
|
||||
|
||||
private async executeAnimationViaSSH(actionType: string): Promise<void> {
|
||||
const animationMap: Record<string, string> = {
|
||||
"play_animation_bow": "animations/Stand/Gestures/BowShort_1",
|
||||
"play_animation_hey": "animations/Stand/Gestures/Hey_1",
|
||||
"play_animation_show_floor": "animations/Stand/Gestures/ShowFloor_1",
|
||||
"play_animation_enthusiastic": "animations/Stand/Gestures/Enthusiastic_4",
|
||||
"play_animation_yes": "animations/Stand/Gestures/Yes_1",
|
||||
"play_animation_no": "animations/Stand/Gestures/No_3",
|
||||
"play_animation_idontknow": "animations/Stand/Gestures/IDontKnow_1",
|
||||
};
|
||||
|
||||
const animation = animationMap[actionType];
|
||||
if (!animation) {
|
||||
throw new Error(`Unknown animation: ${actionType}`);
|
||||
}
|
||||
|
||||
const robotIp = process.env.NAO_IP || "134.82.159.168";
|
||||
const password = process.env.NAO_PASSWORD || "robolab";
|
||||
|
||||
console.log(`[RobotComm] Executing animation via SSH: ${animation}`);
|
||||
|
||||
const command = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 "nao@${robotIp}" "qicli call ALAnimationPlayer.run '${animation}'"`;
|
||||
|
||||
const { stdout, stderr } = await execAsync(command);
|
||||
|
||||
if (stderr && !stderr.includes("null")) {
|
||||
console.warn(`[RobotComm] SSH stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
console.log(`[RobotComm] Animation result: ${stdout}`);
|
||||
}
|
||||
|
||||
private transformToEmotionalSpeech(parameters: Record<string, unknown>): { data: string } {
|
||||
const text = String(parameters.text || "Hello");
|
||||
const emotion = String(parameters.emotion || "neutral");
|
||||
|
||||
switch (emotion) {
|
||||
case "happy":
|
||||
return { data: `\\rspd=120\\ ${text}` };
|
||||
case "sad":
|
||||
return { data: `\\rspd=80\\ ${text}` };
|
||||
case "neutral":
|
||||
default:
|
||||
return { data: text };
|
||||
}
|
||||
}
|
||||
|
||||
private applyTransform(transformFn: string, parameters: Record<string, unknown>): Record<string, unknown> {
|
||||
switch (transformFn) {
|
||||
case "transformToEmotionalSpeech":
|
||||
case "transformToEmotionSpeech":
|
||||
return this.transformToEmotionalSpeech(parameters);
|
||||
default:
|
||||
console.warn(`[RobotComm] Unknown transform: ${transformFn}`);
|
||||
return parameters;
|
||||
}
|
||||
}
|
||||
|
||||
private buildRosMessage(
|
||||
|
||||
@@ -441,10 +441,6 @@ export class TrialExecutionEngine {
|
||||
case "hristudio-core.loop":
|
||||
return await this.executeLoopAction(trialId, action);
|
||||
|
||||
case "branch":
|
||||
case "hristudio-core.branch":
|
||||
return await this.executeBranchAction(trialId, action);
|
||||
|
||||
default:
|
||||
// Check if it's a robot action (contains plugin prefix)
|
||||
if (
|
||||
@@ -799,8 +795,18 @@ export class TrialExecutionEngine {
|
||||
parameters: Record<string, unknown>,
|
||||
trialId: string,
|
||||
): Promise<string> {
|
||||
// Ensure robot communication service is available
|
||||
if (!this.robotComm.getConnectionStatus()) {
|
||||
// Plugin JSON uses a top-level "ros2" key; fall back to it if "implementation" is absent
|
||||
const impl = actionDefinition.implementation ?? actionDefinition.ros2;
|
||||
|
||||
// Determine if this action uses SSH (animations or explicit sshCommand)
|
||||
const sshCommand =
|
||||
impl?.payloadMapping?.sshCommand ||
|
||||
impl?.ros2?.payloadMapping?.sshCommand;
|
||||
const isSSHAction =
|
||||
actionDefinition.id?.startsWith("play_animation_") || !!sshCommand;
|
||||
|
||||
// SSH actions bypass ROS bridge — only connect for ROS-dependent actions
|
||||
if (!isSSHAction && !this.robotComm.getConnectionStatus()) {
|
||||
try {
|
||||
await this.robotComm.connect();
|
||||
} catch (error) {
|
||||
@@ -813,9 +819,9 @@ export class TrialExecutionEngine {
|
||||
// Prepare robot action
|
||||
const robotAction: RobotAction = {
|
||||
pluginName: plugin.name,
|
||||
actionId: actionDefinition.id,
|
||||
actionId: actionDefinition.id, // e.g., "play_animation_yes"
|
||||
parameters,
|
||||
implementation: actionDefinition.implementation,
|
||||
implementation: impl,
|
||||
};
|
||||
|
||||
// Execute action through robot communication service
|
||||
|
||||
@@ -146,6 +146,24 @@ class WebSocketManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Called from Next.js tRPC router — POSTs to the Bun ws-server process
|
||||
// which holds the actual client connections.
|
||||
async broadcastExternal(
|
||||
trialId: string,
|
||||
message: OutgoingMessage,
|
||||
): Promise<void> {
|
||||
const wsPort = process.env.WS_PORT ?? "3001";
|
||||
try {
|
||||
await fetch(`http://localhost:${wsPort}/internal/broadcast`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trialId, message }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[WS] Failed to broadcast externally for trial ${trialId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async broadcastToAll(message: OutgoingMessage): Promise<void> {
|
||||
const messageStr = JSON.stringify(message);
|
||||
const disconnectedClients: string[] = [];
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@
|
||||
],
|
||||
"incremental": true,
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
// "baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
|
||||
+17
-4
@@ -46,9 +46,22 @@ console.log(`Starting WebSocket server on port ${port}...`);
|
||||
|
||||
serve<WSData>({
|
||||
port,
|
||||
fetch(req, server) {
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Internal broadcast endpoint — called by Next.js tRPC router
|
||||
if (url.pathname === "/internal/broadcast") {
|
||||
if (req.method !== "POST") {
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
const { trialId, message } = (await req.json()) as {
|
||||
trialId: string;
|
||||
message: { type: string; data: Record<string, unknown> };
|
||||
};
|
||||
await wsManager.broadcast(trialId, message);
|
||||
return new Response("OK", { status: 200 });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/websocket") {
|
||||
if (req.headers.get("upgrade") !== "websocket") {
|
||||
return new Response("WebSocket upgrade required", { status: 426 });
|
||||
@@ -114,7 +127,7 @@ serve<WSData>({
|
||||
}),
|
||||
);
|
||||
},
|
||||
message(ws: ServerWebSocket<WSData>, message) {
|
||||
async message(ws: ServerWebSocket<WSData>, message) {
|
||||
const { clientId, trialId } = ws.data;
|
||||
|
||||
try {
|
||||
@@ -131,7 +144,7 @@ serve<WSData>({
|
||||
break;
|
||||
|
||||
case "request_trial_status": {
|
||||
const status = wsManager.getTrialStatusSync(trialId);
|
||||
const status = await wsManager.getTrialStatus(trialId);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "trial_status",
|
||||
@@ -146,7 +159,7 @@ serve<WSData>({
|
||||
}
|
||||
|
||||
case "request_trial_events": {
|
||||
const events = wsManager.getTrialEventsSync(
|
||||
const events = await wsManager.getTrialEvents(
|
||||
trialId,
|
||||
msg.data?.limit ?? 100,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user