feat: Add Test Action button, fix ros2 config copying, fix transform functions

- Add Test Action button in experiment designer properties panel
- Fix DesignerRoot to copy full ros2 config when adding actions
- Add transformToWaveGoodbye and transformToAnimation cases
- Fix escape sequences for NAOqi markup
- Update TrialForm with FormSection, sidebar, and visible validation
- Add db:reset and db:restart scripts
- Update docker-compose with configurable PostgreSQL and MinIO vars
This commit is contained in:
2026-04-01 17:00:03 -04:00
parent c0e5a4ffb8
commit c05384d1a0
7 changed files with 464 additions and 269 deletions
+10 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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": {
@@ -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">
+134 -75
View File
@@ -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,22 +85,18 @@ 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",
"w-[200px] 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>}
{value ? format(value, "MMM d, yyyy") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
@@ -111,12 +108,7 @@ function DateTimePicker({
/>
</PopoverContent>
</Popover>
</div>
<div className="grid gap-1.5">
<Label htmlFor="time-picker" className="text-xs">
Time
</Label>
<div className="relative">
<Input
id="time-picker"
@@ -124,20 +116,30 @@ function DateTimePicker({
value={timeValue}
onChange={onTimeChange}
disabled={!value}
className="w-[120px]"
className="w-[110px]"
/>
<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,38 +331,54 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
return <div>Error loading trial: {fetchError.message}</div>;
}
return (
<EntityForm
mode={mode}
entityName="Trial"
entityNamePlural="Trials"
backUrl={`/studies/${contextStudyId}/trials`}
listUrl={`/studies/${contextStudyId}/trials`}
title={
mode === "create"
? "Schedule New Trial"
: `Edit ${trial ? `Trial ${trial.sessionNumber || trial.id.slice(-8)}` : "Trial"}`
}
description={
mode === "create"
? "Schedule a new experimental trial with a participant"
: "Update trial scheduling and assignment details"
}
icon={TestTube}
form={form}
onSubmit={onSubmit}
isSubmitting={isSubmitting}
error={error}
sidebar={undefined}
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
layout="full-width"
// 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-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>
<Label htmlFor="experimentId">
Experiment <span className="text-red-500">*</span>
</Label>
<Select
value={form.watch("experimentId") ?? ""}
onValueChange={(value) => form.setValue("experimentId", value)}
@@ -368,7 +386,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
>
<SelectTrigger
className={
form.formState.errors.experimentId ? "border-red-500" : ""
form.formState.errors.experimentId ? "border-red-500 ring-1 ring-red-500" : ""
}
>
<SelectValue
@@ -382,25 +400,27 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
<SelectContent>
{experimentsData?.map((experiment) => (
<SelectItem key={experiment.id} value={experiment.id}>
{experiment.name}
{experiment.name} ({experiment.status})
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.experimentId && (
<p className="text-sm text-red-600">
<p className="mt-1 text-sm text-red-500 font-medium">
{form.formState.errors.experimentId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
<p className="text-muted-foreground mt-1 text-xs">
Experiment cannot be changed after creation
</p>
)}
</FormField>
<FormField>
<Label htmlFor="participantId">Participant *</Label>
<Label htmlFor="participantId">
Participant <span className="text-red-500">*</span>
</Label>
<Select
value={form.watch("participantId") ?? ""}
onValueChange={(value) => form.setValue("participantId", value)}
@@ -408,7 +428,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
>
<SelectTrigger
className={
form.formState.errors.participantId ? "border-red-500" : ""
form.formState.errors.participantId ? "border-red-500 ring-1 ring-red-500" : ""
}
>
<SelectValue
@@ -422,28 +442,34 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
<SelectContent>
{participantsData?.participants?.map((participant) => (
<SelectItem key={participant.id} value={participant.id}>
{participant.name ?? participant.participantCode} (
{participant.participantCode})
{participant.name ?? participant.participantCode}
</SelectItem>
))}
</SelectContent>
</Select>
{form.formState.errors.participantId && (
<p className="text-sm text-red-600">
<p className="mt-1 text-sm text-red-500 font-medium">
{form.formState.errors.participantId.message}
</p>
)}
{mode === "edit" && (
<p className="text-muted-foreground text-xs">
<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 *</Label>
<Label htmlFor="scheduledAt">
Scheduled Date & Time <span className="text-red-500">*</span>
</Label>
<Controller
control={form.control}
name="scheduledAt"
@@ -455,11 +481,11 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
)}
/>
{form.formState.errors.scheduledAt && (
<p className="text-sm text-red-600">
<p className="mt-1 text-sm text-red-500 font-medium">
{form.formState.errors.scheduledAt.message}
</p>
)}
<p className="text-muted-foreground text-xs">
<p className="text-muted-foreground mt-1 text-xs">
When should this trial be conducted?
</p>
</FormField>
@@ -473,23 +499,25 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
{...form.register("sessionNumber", { valueAsNumber: true })}
placeholder="1"
className={
form.formState.errors.sessionNumber ? "border-red-500" : ""
form.formState.errors.sessionNumber ? "border-red-500 ring-1 ring-red-500" : ""
}
/>
{form.formState.errors.sessionNumber && (
<p className="text-sm text-red-600">
<p className="mt-1 text-sm text-red-500 font-medium">
{form.formState.errors.sessionNumber.message}
</p>
)}
<p className="text-muted-foreground text-xs">
<p className="text-muted-foreground mt-1 text-xs">
Auto-incremented based on participant history
</p>
</FormField>
</div>
</div>
</FormSection>
{/* Right Column: Assignment & Notes (Spans 1) */}
<div className="space-y-6">
<FormSection
title="Assignment & Notes"
description="Assign a wizard and add any special instructions."
>
<FormField>
<Label htmlFor="wizardId">Assigned Wizard</Label>
<Select
@@ -519,8 +547,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Who will operate the robot?
<p className="text-muted-foreground mt-1 text-xs">
Who will operate the robot during this trial?
</p>
</FormField>
@@ -529,18 +557,49 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
<Textarea
id="notes"
{...form.register("notes")}
placeholder="Special instructions..."
rows={5}
className={form.formState.errors.notes ? "border-red-500" : ""}
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="text-sm text-red-600">
<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>
</div>
</div>
</FormSection>
</>
);
return (
<EntityForm
mode={mode}
entityName="Trial"
entityNamePlural="Trials"
backUrl={`/studies/${contextStudyId}/trials`}
listUrl={`/studies/${contextStudyId}/trials`}
title={
mode === "create"
? "Schedule New Trial"
: `Edit ${trial ? `Trial ${trial.sessionNumber || trial.id.slice(-8)}` : "Trial"}`
}
description={
mode === "create"
? "Schedule a new experimental trial with a participant"
: "Update trial scheduling and assignment details"
}
icon={TestTube}
form={form}
onSubmit={onSubmit}
isSubmitting={isSubmitting}
error={error}
sidebar={sidebar}
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
>
{formFields}
</EntityForm>
);
}
+14 -8
View File
@@ -1042,6 +1042,12 @@ export class WizardRosService extends EventEmitter {
case "transformToEmotionalSpeech":
return this.transformToEmotionalSpeech(parameters);
case "transformToWaveGoodbye":
return this.transformToWaveGoodbye(parameters);
case "transformToAnimation":
return this.transformToAnimation(parameters);
default:
console.warn(`Unknown transform function: ${transformFn}`);
return parameters;
@@ -1051,7 +1057,7 @@ 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;
@@ -1065,20 +1071,20 @@ export class WizardRosService extends EventEmitter {
switch (emotion) {
case "happy":
markedText = `\\rspd=120\\^start(animations/Stand/Gestures/Happy_4) ${text}`;
markedText = `\\\\rspd=120\\\\vct=100\\\\ ${text}`;
break;
case "excited":
markedText = `\\rspd=140\\^start(animations/Stand/Gestures/Enthusiastic_1) ${text}`;
markedText = `\\\\rspd=140\\\\vct=110\\\\ ${text}`;
break;
case "sad":
markedText = `\\rspd=80\\vct=80\\${text}`;
markedText = `\\\\rspd=80\\\\vct=80\\\\ ${text}`;
break;
case "calm":
markedText = `\\rspd=90\\${text}`;
markedText = `\\\\rspd=90\\\\vct=90\\\\ ${text}`;
break;
case "neutral":
default:
markedText = `\\rspd=${speedPercent}\\${text}`;
markedText = `\\\\rspd=${speedPercent}\\\\vct=100\\\\ ${text}`;
break;
}
@@ -1086,13 +1092,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)`;
const markedText = `\\\\rspd=110\\\\ ${text}`;
return { data: markedText };
}