feat: Relocate experiment designer routes under studies, update ROS2 topic paths, and enhance designer hashing and performance.

This commit is contained in:
2025-11-19 18:05:19 -05:00
parent 86b5ed80c4
commit b21ed8e805
19 changed files with 647 additions and 288 deletions

View File

@@ -0,0 +1,61 @@
"use client";
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import type { ExperimentStep } from "~/lib/experiment-designer/types";
interface DesignerPageClientProps {
experiment: {
id: string;
name: string;
description: string | null;
study: {
id: string;
name: string;
};
};
initialDesign?: {
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
};
}
export function DesignerPageClient({
experiment,
initialDesign,
}: DesignerPageClientProps) {
// Set breadcrumbs
useBreadcrumbsEffect([
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: experiment.study.name,
href: `/studies/${experiment.study.id}`,
},
{
label: "Experiments",
href: `/studies/${experiment.study.id}/experiments`,
},
{
label: experiment.name,
href: `/studies/${experiment.study.id}/experiments/${experiment.id}`,
},
{
label: "Designer",
},
]);
return (
<DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />
);
}

View File

@@ -0,0 +1,273 @@
import { notFound } from "next/navigation";
import type {
ExperimentStep,
ExperimentAction,
StepType,
ActionCategory,
ExecutionDescriptor,
} from "~/lib/experiment-designer/types";
import { api } from "~/trpc/server";
import { DesignerPageClient } from "./DesignerPageClient";
interface ExperimentDesignerPageProps {
params: Promise<{
experimentId: string;
}>;
}
export default async function ExperimentDesignerPage({
params,
}: ExperimentDesignerPageProps) {
try {
const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
if (!experiment) {
notFound();
}
// Parse existing visual design if available
const existingDesign = experiment.visualDesign as {
steps?: unknown[];
version?: number;
lastSaved?: string;
} | null;
// Only pass initialDesign if there's existing visual design data
let initialDesign:
| {
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
}
| undefined;
if (existingDesign?.steps && existingDesign.steps.length > 0) {
initialDesign = {
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
steps: existingDesign.steps as ExperimentStep[],
version: existingDesign.version ?? 1,
lastSaved:
typeof existingDesign.lastSaved === "string"
? new Date(existingDesign.lastSaved)
: new Date(),
};
} else {
// Fallback: hydrate from DB steps/actions if visualDesign is empty
const exec = await api.experiments.getExecutionData({
experimentId: experiment.id,
});
if (exec.steps.length > 0) {
type InstalledStudyPlugin = {
plugin: {
id: string;
name: string;
version: string | null;
actionDefinitions: Array<{ id: string }> | null;
};
};
const rawInstalledPluginsUnknown: unknown =
await api.robots.plugins.getStudyPlugins({
studyId: experiment.study.id,
});
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object"
? (v as Record<string, unknown>)
: null;
}
function narrowActionDefs(v: unknown): Array<{ id: string }> | null {
if (!Array.isArray(v)) return null;
const out: Array<{ id: string }> = [];
for (const item of v) {
const rec = asRecord(item);
const id = rec && typeof rec.id === "string" ? rec.id : null;
if (id) out.push({ id });
}
return out.length ? out : null;
}
const installedPlugins: InstalledStudyPlugin[] = (
Array.isArray(rawInstalledPluginsUnknown)
? (rawInstalledPluginsUnknown as unknown[])
: []
).map((entry) => {
const rec = asRecord(entry);
const pluginRec = rec ? asRecord(rec.plugin) : null;
const id =
pluginRec && typeof pluginRec.id === "string" ? pluginRec.id : "";
const name =
pluginRec && typeof pluginRec.name === "string"
? pluginRec.name
: "";
const version =
pluginRec && typeof pluginRec.version === "string"
? pluginRec.version
: null;
const actionDefinitions = narrowActionDefs(
pluginRec ? pluginRec.actionDefinitions : undefined,
);
return {
plugin: { id, name, version, actionDefinitions },
};
});
const mapped: ExperimentStep[] = exec.steps.map((s, idx) => {
const actions: ExperimentAction[] = s.actions.map((a) => {
// Normalize legacy plugin action ids and provenance
const rawType = a.type ?? "";
// Try to resolve alias-style legacy ids using installed study plugins
const dynamicLegacy = (() => {
if (rawType.includes(".")) {
const [alias, base] = rawType.split(".", 2);
if (alias && base) {
const baseMap: Record<string, string> = {
speak: "say_text",
say: "say_text",
walk: "walk_to_position",
animation: "play_animation",
led: "set_led_color",
leds: "set_led_color",
sit: "sit_down",
stand: "stand_up",
head: "turn_head",
turn_head: "turn_head",
};
const mappedBase = baseMap[base] ?? base;
const candidate =
installedPlugins.find(
(p) =>
p.plugin.id.startsWith(alias) ||
p.plugin.name
.toLowerCase()
.includes(alias.toLowerCase()),
) ?? null;
if (
candidate &&
Array.isArray(candidate.plugin.actionDefinitions) &&
candidate.plugin.actionDefinitions.some(
(ad) => ad.id === mappedBase,
)
) {
return {
pluginId: candidate.plugin.id,
baseId: mappedBase,
pluginVersion: candidate.plugin.version ?? undefined,
};
}
}
}
return null;
})();
const legacy = dynamicLegacy;
const isPluginType = Boolean(legacy) || rawType.includes(".");
const typeOut = legacy
? `${legacy.pluginId}.${legacy.baseId}`
: rawType;
const execution: ExecutionDescriptor = { transport: "internal" };
const categoryOut: ActionCategory = isPluginType
? "robot"
: "wizard";
const sourceKind: "core" | "plugin" = isPluginType
? "plugin"
: "core";
const pluginId = legacy?.pluginId;
const pluginVersion = legacy?.pluginVersion;
return {
id: a.id,
type: typeOut,
name: a.name,
parameters: (a.parameters ?? {}) as Record<string, unknown>,
category: categoryOut,
source: {
kind: sourceKind,
pluginId,
pluginVersion,
robotId: null,
baseActionId: legacy?.baseId,
},
execution,
};
});
return {
id: s.id,
name: s.name,
description: s.description ?? "",
type: ((): StepType => {
const raw = (s.type as string) ?? "sequential";
if (raw === "wizard") return "sequential";
const allowed = [
"sequential",
"parallel",
"conditional",
"loop",
] as const;
return (allowed as readonly string[]).includes(raw)
? (raw as StepType)
: "sequential";
})(),
order: s.orderIndex ?? idx,
trigger: { type: "trial_start", conditions: {} },
actions,
expanded: true,
};
});
initialDesign = {
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
steps: mapped,
version: experiment.version ?? 1,
lastSaved: new Date(),
};
}
}
return (
<DesignerPageClient
experiment={experiment}
initialDesign={initialDesign}
/>
);
} catch (error) {
console.error("Error loading experiment:", error);
notFound();
}
}
export async function generateMetadata({
params,
}: ExperimentDesignerPageProps): Promise<{
title: string;
description: string;
}> {
try {
const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
return {
title: `${experiment?.name} - Designer | HRIStudio`,
description: `Design experiment protocol for ${experiment?.name} using step-based editor`,
};
} catch {
return {
title: "Experiment Designer | HRIStudio",
description: "Step-based experiment protocol designer",
};
}
}

View File

@@ -185,7 +185,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</Link>
</Button>
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
<Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" />
New Experiment
</Link>
@@ -232,7 +232,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
description="Design and manage experimental protocols for this study"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/new?studyId=${study.id}`}>
<Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" />
Add Experiment
</Link>
@@ -246,7 +246,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
description="Create your first experiment to start designing research protocols"
action={
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
<Link href={`/studies/${study.id}/experiments/new`}>
Create First Experiment
</Link>
</Button>
@@ -263,20 +263,19 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
<div className="flex items-center space-x-3">
<h4 className="font-medium">
<Link
href={`/experiments/${experiment.id}`}
href={`/studies/${study.id}/experiments/${experiment.id}`}
className="hover:underline"
>
{experiment.name}
</Link>
</h4>
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
experiment.status === "draft"
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft"
? "bg-gray-100 text-gray-800"
: experiment.status === "ready"
? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800"
}`}
}`}
>
{experiment.status}
</span>
@@ -300,12 +299,12 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</div>
<div className="flex items-center space-x-2">
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}/designer`}>
<Link href={`/studies/${study.id}/experiments/${experiment.id}/designer`}>
Design
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}`}>View</Link>
<Link href={`/studies/${study.id}/experiments/${experiment.id}`}>View</Link>
</Button>
</div>
</div>