mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-05-08 05:48:56 -04:00
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:
+10
-2
@@ -16,11 +16,19 @@
|
|||||||
AUTH_SECRET=""
|
AUTH_SECRET=""
|
||||||
|
|
||||||
# Drizzle
|
# 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/S3 Configuration
|
||||||
MINIO_ENDPOINT="http://localhost:9000"
|
MINIO_ENDPOINT="http://localhost:${MINIO_PORT_API:-9000}"
|
||||||
MINIO_REGION="us-east-1"
|
MINIO_REGION="us-east-1"
|
||||||
MINIO_ACCESS_KEY="minioadmin"
|
MINIO_ACCESS_KEY="minioadmin"
|
||||||
MINIO_SECRET_KEY="minioadmin"
|
MINIO_SECRET_KEY="minioadmin"
|
||||||
MINIO_BUCKET_NAME="hristudio-data"
|
MINIO_BUCKET_NAME="hristudio-data"
|
||||||
|
MINIO_PORT_API="9000"
|
||||||
|
MINIO_PORT_CONSOLE="9001"
|
||||||
|
|||||||
+11
-11
@@ -2,13 +2,13 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
POSTGRES_DB: hristudio
|
POSTGRES_DB: ${POSTGRES_DB:-hristudio}
|
||||||
PGSSLMODE: disable
|
PGSSLMODE: disable
|
||||||
command: -c ssl=off
|
command: -c ssl=off
|
||||||
ports:
|
ports:
|
||||||
- "5140:5432"
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -20,11 +20,11 @@ services:
|
|||||||
minio:
|
minio:
|
||||||
image: minio/minio
|
image: minio/minio
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000" # API
|
- "${MINIO_PORT_API:-9000}:9000" # API
|
||||||
- "9001:9001" # Console
|
- "${MINIO_PORT_CONSOLE:-9001}:9001" # Console
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: minioadmin
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||||
MINIO_ROOT_PASSWORD: minioadmin
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
|
||||||
volumes:
|
volumes:
|
||||||
- minio_data:/data
|
- minio_data:/data
|
||||||
command: server --console-address ":9001" /data
|
command: server --console-address ":9001" /data
|
||||||
@@ -35,9 +35,9 @@ services:
|
|||||||
- minio
|
- minio
|
||||||
entrypoint: >
|
entrypoint: >
|
||||||
/bin/sh -c "
|
/bin/sh -c "
|
||||||
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin;
|
/usr/bin/mc alias set myminio http://minio:9000 ${MINIO_ACCESS_KEY:-minioadmin} ${MINIO_SECRET_KEY:-minioadmin};
|
||||||
/usr/bin/mc mb myminio/hristudio-data;
|
/usr/bin/mc mb myminio/${MINIO_BUCKET_NAME:-hristudio-data};
|
||||||
/usr/bin/mc anonymous set public myminio/hristudio-data;
|
/usr/bin/mc anonymous set public myminio/${MINIO_BUCKET_NAME:-hristudio-data};
|
||||||
exit 0;
|
exit 0;
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|||||||
+9
-5
@@ -5,22 +5,26 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"check": "next lint && tsc --noEmit",
|
"check": "eslint . && tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:seed": "bun db:push && bun scripts/seed-dev.ts",
|
"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",
|
"dev:ws": "bun run ws-server.ts",
|
||||||
"docker:up": "if [ \"$(uname)\" = \"Darwin\" ]; then colima start; fi && docker compose up -d",
|
"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",
|
"docker:down": "docker compose down && if [ \"$(uname)\" = \"Darwin\" ]; then colima stop; fi",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"preview": "next build && next start",
|
"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"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1079,14 +1079,18 @@ export function DesignerRoot({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defExec = actionDef.execution as any;
|
||||||
const execution: ExperimentAction["execution"] =
|
const execution: ExperimentAction["execution"] =
|
||||||
actionDef.execution &&
|
defExec &&
|
||||||
(actionDef.execution.transport === "internal" ||
|
(defExec.transport === "internal" ||
|
||||||
actionDef.execution.transport === "rest" ||
|
defExec.transport === "rest" ||
|
||||||
actionDef.execution.transport === "ros2")
|
defExec.transport === "ros2")
|
||||||
? {
|
? {
|
||||||
transport: actionDef.execution.transport,
|
transport: defExec.transport,
|
||||||
retryable: actionDef.execution.retryable ?? false,
|
retryable: defExec.retryable ?? false,
|
||||||
|
timeoutMs: defExec.timeoutMs,
|
||||||
|
ros2: defExec.ros2,
|
||||||
|
rest: defExec.rest,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,14 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
PlayCircle,
|
||||||
|
Square,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getWizardRosService, initWizardRosService, resetWizardRosService } from "~/lib/ros/wizard-ros-service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PropertiesPanel
|
* PropertiesPanel
|
||||||
@@ -90,6 +97,10 @@ export function PropertiesPanelBase({
|
|||||||
const [localStepDescription, setLocalStepDescription] = useState("");
|
const [localStepDescription, setLocalStepDescription] = useState("");
|
||||||
const [localParams, setLocalParams] = useState<Record<string, unknown>>({});
|
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
|
// Debounce timers
|
||||||
const actionUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
const actionUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
const stepUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
const stepUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
@@ -168,6 +179,74 @@ export function PropertiesPanelBase({
|
|||||||
selectedAction &&
|
selectedAction &&
|
||||||
design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id));
|
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 -------------------------- */
|
/* -------------------------- Action Properties View -------------------------- */
|
||||||
if (selectedAction && containingStep) {
|
if (selectedAction && containingStep) {
|
||||||
let def = registry.getAction(selectedAction.type);
|
let def = registry.getAction(selectedAction.type);
|
||||||
@@ -277,6 +356,41 @@ export function PropertiesPanelBase({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* General */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||||
|
|||||||
+296
-237
@@ -11,6 +11,7 @@ import {
|
|||||||
EntityForm,
|
EntityForm,
|
||||||
FormField,
|
FormField,
|
||||||
FormSection,
|
FormSection,
|
||||||
|
NextSteps,
|
||||||
Tips,
|
Tips,
|
||||||
} from "~/components/ui/entity-form";
|
} from "~/components/ui/entity-form";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
@@ -26,7 +27,7 @@ import { Textarea } from "~/components/ui/textarea";
|
|||||||
import { useStudyContext } from "~/lib/study-context";
|
import { useStudyContext } from "~/lib/study-context";
|
||||||
import { api } from "~/trpc/react";
|
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 { format } from "date-fns";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
@@ -84,60 +85,61 @@ function DateTimePicker({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="grid gap-1.5">
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<Label htmlFor="date-picker" className="text-xs">
|
<PopoverTrigger asChild>
|
||||||
Date
|
<Button
|
||||||
</Label>
|
variant={"outline"}
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
id="date-picker"
|
||||||
<PopoverTrigger asChild>
|
className={cn(
|
||||||
<Button
|
"w-[200px] justify-start text-left font-normal",
|
||||||
variant={"outline"}
|
!value && "text-muted-foreground",
|
||||||
id="date-picker"
|
)}
|
||||||
className={cn(
|
>
|
||||||
"w-[240px] justify-start text-left font-normal",
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
!value && "text-muted-foreground",
|
{value ? format(value, "MMM d, yyyy") : <span>Pick a date</span>}
|
||||||
)}
|
</Button>
|
||||||
>
|
</PopoverTrigger>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
{value ? format(value, "PPP") : <span>Pick a date</span>}
|
<Calendar
|
||||||
</Button>
|
mode="single"
|
||||||
</PopoverTrigger>
|
selected={value}
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
onSelect={onDateSelect}
|
||||||
<Calendar
|
initialFocus
|
||||||
mode="single"
|
/>
|
||||||
selected={value}
|
</PopoverContent>
|
||||||
onSelect={onDateSelect}
|
</Popover>
|
||||||
initialFocus
|
|
||||||
/>
|
<div className="relative">
|
||||||
</PopoverContent>
|
<Input
|
||||||
</Popover>
|
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>
|
||||||
|
|
||||||
<div className="grid gap-1.5">
|
<Button
|
||||||
<Label htmlFor="time-picker" className="text-xs">
|
type="button"
|
||||||
Time
|
variant="outline"
|
||||||
</Label>
|
size="sm"
|
||||||
<div className="relative">
|
onClick={() => onChange(new Date())}
|
||||||
<Input
|
className="h-10 gap-1.5"
|
||||||
id="time-picker"
|
>
|
||||||
type="time"
|
<Clock2 className="h-4 w-4" />
|
||||||
value={timeValue}
|
Now
|
||||||
onChange={onTimeChange}
|
</Button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const trialSchema = z.object({
|
const trialSchema = z.object({
|
||||||
experimentId: z.string().uuid("Please select an experiment"),
|
experimentId: z.string().min(1, "Please select an experiment *"),
|
||||||
participantId: z.string().uuid("Please select a participant"),
|
participantId: z.string().min(1, "Please select a participant *"),
|
||||||
scheduledAt: z.date(),
|
scheduledAt: z.date({ message: "Scheduled date and time is required *" }),
|
||||||
wizardId: z.string().uuid().optional().or(z.literal("")),
|
wizardId: z.string().optional().or(z.literal("")),
|
||||||
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
||||||
sessionNumber: z
|
sessionNumber: z
|
||||||
.number()
|
.number()
|
||||||
@@ -165,7 +167,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
experimentId: "" as any,
|
experimentId: "" as any,
|
||||||
participantId: "" as any,
|
participantId: "" as any,
|
||||||
scheduledAt: new Date(),
|
scheduledAt: undefined,
|
||||||
wizardId: undefined,
|
wizardId: undefined,
|
||||||
notes: "",
|
notes: "",
|
||||||
sessionNumber: 1,
|
sessionNumber: 1,
|
||||||
@@ -329,6 +331,249 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
return <div>Error loading trial: {fetchError.message}</div>;
|
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 (
|
return (
|
||||||
<EntityForm
|
<EntityForm
|
||||||
mode={mode}
|
mode={mode}
|
||||||
@@ -351,196 +596,10 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
error={error}
|
error={error}
|
||||||
sidebar={undefined}
|
sidebar={sidebar}
|
||||||
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
||||||
layout="full-width"
|
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
{formFields}
|
||||||
{/* 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>
|
|
||||||
</EntityForm>
|
</EntityForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1042,6 +1042,12 @@ export class WizardRosService extends EventEmitter {
|
|||||||
case "transformToEmotionalSpeech":
|
case "transformToEmotionalSpeech":
|
||||||
return this.transformToEmotionalSpeech(parameters);
|
return this.transformToEmotionalSpeech(parameters);
|
||||||
|
|
||||||
|
case "transformToWaveGoodbye":
|
||||||
|
return this.transformToWaveGoodbye(parameters);
|
||||||
|
|
||||||
|
case "transformToAnimation":
|
||||||
|
return this.transformToAnimation(parameters);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn(`Unknown transform function: ${transformFn}`);
|
console.warn(`Unknown transform function: ${transformFn}`);
|
||||||
return parameters;
|
return parameters;
|
||||||
@@ -1051,7 +1057,7 @@ export class WizardRosService extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Transform parameters for emotional speech
|
* Transform parameters for emotional speech
|
||||||
* NAOqi markup: \rspd=<speed>\<text>
|
* 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>): {
|
private transformToEmotionalSpeech(parameters: Record<string, unknown>): {
|
||||||
data: string;
|
data: string;
|
||||||
@@ -1065,20 +1071,20 @@ export class WizardRosService extends EventEmitter {
|
|||||||
|
|
||||||
switch (emotion) {
|
switch (emotion) {
|
||||||
case "happy":
|
case "happy":
|
||||||
markedText = `\\rspd=120\\^start(animations/Stand/Gestures/Happy_4) ${text}`;
|
markedText = `\\\\rspd=120\\\\vct=100\\\\ ${text}`;
|
||||||
break;
|
break;
|
||||||
case "excited":
|
case "excited":
|
||||||
markedText = `\\rspd=140\\^start(animations/Stand/Gestures/Enthusiastic_1) ${text}`;
|
markedText = `\\\\rspd=140\\\\vct=110\\\\ ${text}`;
|
||||||
break;
|
break;
|
||||||
case "sad":
|
case "sad":
|
||||||
markedText = `\\rspd=80\\vct=80\\${text}`;
|
markedText = `\\\\rspd=80\\\\vct=80\\\\ ${text}`;
|
||||||
break;
|
break;
|
||||||
case "calm":
|
case "calm":
|
||||||
markedText = `\\rspd=90\\${text}`;
|
markedText = `\\\\rspd=90\\\\vct=90\\\\ ${text}`;
|
||||||
break;
|
break;
|
||||||
case "neutral":
|
case "neutral":
|
||||||
default:
|
default:
|
||||||
markedText = `\\rspd=${speedPercent}\\${text}`;
|
markedText = `\\\\rspd=${speedPercent}\\\\vct=100\\\\ ${text}`;
|
||||||
break;
|
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>): {
|
private transformToWaveGoodbye(parameters: Record<string, unknown>): {
|
||||||
data: string;
|
data: string;
|
||||||
} {
|
} {
|
||||||
const text = String(parameters.text || "Goodbye!");
|
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 };
|
return { data: markedText };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user