mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
chore: commit full workspace changes (designer modularization, diagnostics fixes, docs updates, seed script cleanup)
This commit is contained in:
@@ -14,12 +14,12 @@ import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -228,7 +228,9 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
const date = row.getValue("createdAt");
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -306,20 +308,37 @@ export function ExperimentsTable() {
|
||||
const data: Experiment[] = React.useMemo(() => {
|
||||
if (!experimentsData) return [];
|
||||
|
||||
return experimentsData.map((exp: any) => ({
|
||||
interface RawExperiment {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status: Experiment["status"];
|
||||
version: number;
|
||||
estimatedDuration?: number | null;
|
||||
createdAt: string | Date;
|
||||
studyId: string;
|
||||
createdBy?: { name?: string | null; email?: string | null } | null;
|
||||
trialCount?: number | null;
|
||||
stepCount?: number | null;
|
||||
}
|
||||
|
||||
const adapt = (exp: RawExperiment): Experiment => ({
|
||||
id: exp.id,
|
||||
name: exp.name,
|
||||
description: exp.description,
|
||||
description: exp.description ?? "",
|
||||
status: exp.status,
|
||||
version: exp.version,
|
||||
estimatedDuration: exp.estimatedDuration,
|
||||
createdAt: exp.createdAt,
|
||||
estimatedDuration: exp.estimatedDuration ?? 0,
|
||||
createdAt:
|
||||
exp.createdAt instanceof Date ? exp.createdAt : new Date(exp.createdAt),
|
||||
studyId: exp.studyId,
|
||||
studyName: activeStudy?.title || "Unknown Study",
|
||||
createdByName: exp.createdBy?.name || exp.createdBy?.email || "Unknown",
|
||||
trialCount: exp.trialCount || 0,
|
||||
stepCount: exp.stepCount || 0,
|
||||
}));
|
||||
studyName: activeStudy?.title ?? "Unknown Study",
|
||||
createdByName: exp.createdBy?.name ?? exp.createdBy?.email ?? "Unknown",
|
||||
trialCount: exp.trialCount ?? 0,
|
||||
stepCount: exp.stepCount ?? 0,
|
||||
});
|
||||
|
||||
return experimentsData.map((e) => adapt(e as unknown as RawExperiment));
|
||||
}, [experimentsData, activeStudy]);
|
||||
|
||||
if (!activeStudy) {
|
||||
|
||||
236
src/components/experiments/designer/ActionLibrary.tsx
Normal file
236
src/components/experiments/designer/ActionLibrary.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
import {
|
||||
Plus,
|
||||
User,
|
||||
Bot,
|
||||
GitBranch,
|
||||
Eye,
|
||||
GripVertical,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
|
||||
// Local icon map (duplicated minimal map for isolation to avoid circular imports)
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Zap,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
};
|
||||
|
||||
interface DraggableActionProps {
|
||||
action: ActionDefinition;
|
||||
}
|
||||
|
||||
function DraggableAction({ action }: DraggableActionProps) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: `action-${action.id}`,
|
||||
data: { action },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const IconComponent = iconMap[action.icon] ?? Zap;
|
||||
|
||||
const categoryColors: Record<ActionDefinition["category"], string> = {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
"group hover:bg-accent/50 relative flex cursor-grab items-center gap-2 rounded-md border p-2 text-xs transition-colors",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
draggable={false}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-white",
|
||||
categoryColors[action.category],
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 truncate font-medium">
|
||||
{action.source.kind === "plugin" ? (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
|
||||
P
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
|
||||
C
|
||||
</span>
|
||||
)}
|
||||
{action.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{action.description ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
|
||||
{showTooltip && (
|
||||
<div className="bg-popover absolute top-0 left-full z-50 ml-2 max-w-xs rounded-md border p-2 text-xs shadow-md">
|
||||
<div className="font-medium">{action.name}</div>
|
||||
<div className="text-muted-foreground">{action.description}</div>
|
||||
<div className="mt-1 text-xs opacity-75">
|
||||
Category: {action.category} • ID: {action.id}
|
||||
</div>
|
||||
{action.parameters.length > 0 && (
|
||||
<div className="mt-1 text-xs opacity-75">
|
||||
Parameters: {action.parameters.map((p) => p.name).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ActionLibraryProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActionLibrary({ className }: ActionLibraryProps) {
|
||||
const registry = actionRegistry;
|
||||
const [activeCategory, setActiveCategory] =
|
||||
useState<ActionDefinition["category"]>("wizard");
|
||||
|
||||
const categories: Array<{
|
||||
key: ActionDefinition["category"];
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
color: string;
|
||||
}> = [
|
||||
{
|
||||
key: "wizard",
|
||||
label: "Wizard",
|
||||
icon: User,
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
key: "robot",
|
||||
label: "Robot",
|
||||
icon: Bot,
|
||||
color: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
key: "control",
|
||||
label: "Control",
|
||||
icon: GitBranch,
|
||||
color: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
key: "observation",
|
||||
label: "Observe",
|
||||
icon: Eye,
|
||||
color: "bg-purple-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* Category tabs */}
|
||||
<div className="border-b p-2">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{categories.map((category) => {
|
||||
const IconComponent = category.icon;
|
||||
const isActive = activeCategory === category.key;
|
||||
return (
|
||||
<Button
|
||||
key={category.key}
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 justify-start text-xs",
|
||||
isActive && `${category.color} text-white hover:opacity-90`,
|
||||
)}
|
||||
onClick={() => setActiveCategory(category.key)}
|
||||
>
|
||||
<IconComponent className="mr-1 h-3 w-3" />
|
||||
{category.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions list */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{registry.getActionsByCategory(activeCategory).length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<Plus className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm">No actions available</p>
|
||||
<p className="text-xs">Check plugin configuration</p>
|
||||
</div>
|
||||
) : (
|
||||
registry
|
||||
.getActionsByCategory(activeCategory)
|
||||
.map((action) => <DraggableAction key={action.id} action={action} />)
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{registry.getAllActions().length} total
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{registry.getActionsByCategory(activeCategory).length} in view
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
450
src/components/experiments/designer/ActionRegistry.ts
Normal file
450
src/components/experiments/designer/ActionRegistry.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
"use client";
|
||||
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
|
||||
/**
|
||||
* ActionRegistry
|
||||
*
|
||||
* Central singleton for loading and serving action definitions from:
|
||||
* - Core system action JSON manifests (served from /hristudio-core/plugins/*.json)
|
||||
* - Study-installed plugin action definitions (ROS2 / REST / internal transports)
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Lazy, idempotent loading of core and plugin actions
|
||||
* - Provenance retention (core vs plugin, plugin id/version, robot id)
|
||||
* - Parameter schema → UI parameter mapping (primitive only for now)
|
||||
* - Fallback action population if core load fails (ensures minimal functionality)
|
||||
*
|
||||
* Notes:
|
||||
* - The registry is client-side only (designer runtime); server performs its own
|
||||
* validation & compilation using persisted action instances (never trusts client).
|
||||
* - Action IDs for plugins are namespaced: `${plugin.id}.${action.id}`.
|
||||
* - Core actions retain their base IDs (e.g., wait, wizard_speak) for clarity.
|
||||
*/
|
||||
export class ActionRegistry {
|
||||
private static instance: ActionRegistry;
|
||||
private actions = new Map<string, ActionDefinition>();
|
||||
private coreActionsLoaded = false;
|
||||
private pluginActionsLoaded = false;
|
||||
private loadedStudyId: string | null = null;
|
||||
|
||||
static getInstance(): ActionRegistry {
|
||||
if (!ActionRegistry.instance) {
|
||||
ActionRegistry.instance = new ActionRegistry();
|
||||
}
|
||||
return ActionRegistry.instance;
|
||||
}
|
||||
|
||||
/* ---------------- Core Actions ---------------- */
|
||||
|
||||
async loadCoreActions(): Promise<void> {
|
||||
if (this.coreActionsLoaded) return;
|
||||
|
||||
interface CoreBlockParam {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
value?: string | number | boolean;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
interface CoreBlock {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
parameters?: CoreBlockParam[];
|
||||
timeoutMs?: number;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
try {
|
||||
const coreActionSets = ["wizard-actions", "control-flow", "observation"];
|
||||
|
||||
for (const actionSetId of coreActionSets) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/hristudio-core/plugins/${actionSetId}.json`,
|
||||
);
|
||||
// Non-blocking skip if not found
|
||||
if (!response.ok) continue;
|
||||
|
||||
const rawActionSet = (await response.json()) as unknown;
|
||||
const actionSet = rawActionSet as { blocks?: CoreBlock[] };
|
||||
if (!actionSet.blocks || !Array.isArray(actionSet.blocks)) continue;
|
||||
|
||||
// Register each block as an ActionDefinition
|
||||
actionSet.blocks.forEach((block) => {
|
||||
if (!block.id || !block.name) return;
|
||||
|
||||
const actionDef: ActionDefinition = {
|
||||
id: block.id,
|
||||
type: block.id,
|
||||
name: block.name,
|
||||
description: block.description ?? "",
|
||||
category: this.mapBlockCategoryToActionCategory(block.category),
|
||||
icon: block.icon ?? "Zap",
|
||||
color: block.color ?? "#6b7280",
|
||||
parameters: (block.parameters ?? []).map((param) => ({
|
||||
id: param.id,
|
||||
name: param.name,
|
||||
type:
|
||||
(param.type as "text" | "number" | "select" | "boolean") ||
|
||||
"text",
|
||||
placeholder: param.placeholder,
|
||||
options: param.options,
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
value: param.value,
|
||||
required: param.required !== false,
|
||||
description: param.description,
|
||||
step: param.step,
|
||||
})),
|
||||
source: {
|
||||
kind: "core",
|
||||
baseActionId: block.id,
|
||||
},
|
||||
execution: {
|
||||
transport: "internal",
|
||||
timeoutMs: block.timeoutMs,
|
||||
retryable: block.retryable,
|
||||
},
|
||||
parameterSchemaRaw: {
|
||||
parameters: block.parameters ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
this.actions.set(actionDef.id, actionDef);
|
||||
});
|
||||
} catch (error) {
|
||||
// Non-fatal: we will fallback later
|
||||
console.warn(`Failed to load core action set ${actionSetId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.coreActionsLoaded = true;
|
||||
} catch (error) {
|
||||
console.error("Failed to load core actions:", error);
|
||||
this.loadFallbackActions();
|
||||
}
|
||||
}
|
||||
|
||||
private mapBlockCategoryToActionCategory(
|
||||
category: string,
|
||||
): ActionDefinition["category"] {
|
||||
switch (category) {
|
||||
case "wizard":
|
||||
case "event":
|
||||
return "wizard";
|
||||
case "robot":
|
||||
return "robot";
|
||||
case "control":
|
||||
return "control";
|
||||
case "sensor":
|
||||
case "observation":
|
||||
return "observation";
|
||||
default:
|
||||
return "wizard";
|
||||
}
|
||||
}
|
||||
|
||||
private loadFallbackActions(): void {
|
||||
const fallbackActions: ActionDefinition[] = [
|
||||
{
|
||||
id: "wizard_speak",
|
||||
type: "wizard_speak",
|
||||
name: "Wizard Says",
|
||||
description: "Wizard speaks to participant",
|
||||
category: "wizard",
|
||||
icon: "MessageSquare",
|
||||
color: "#3b82f6",
|
||||
parameters: [
|
||||
{
|
||||
id: "text",
|
||||
name: "Text to say",
|
||||
type: "text",
|
||||
placeholder: "Hello, participant!",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wizard_speak" },
|
||||
execution: { transport: "internal", timeoutMs: 30000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string" },
|
||||
},
|
||||
required: ["text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
name: "Wait",
|
||||
description: "Wait for specified time",
|
||||
category: "control",
|
||||
icon: "Clock",
|
||||
color: "#f59e0b",
|
||||
parameters: [
|
||||
{
|
||||
id: "duration",
|
||||
name: "Duration (seconds)",
|
||||
type: "number",
|
||||
min: 0.1,
|
||||
max: 300,
|
||||
value: 2,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wait" },
|
||||
execution: { transport: "internal", timeoutMs: 60000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
duration: {
|
||||
type: "number",
|
||||
minimum: 0.1,
|
||||
maximum: 300,
|
||||
default: 2,
|
||||
},
|
||||
},
|
||||
required: ["duration"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "observe",
|
||||
type: "observe",
|
||||
name: "Observe",
|
||||
description: "Record participant behavior",
|
||||
category: "observation",
|
||||
icon: "Eye",
|
||||
color: "#8b5cf6",
|
||||
parameters: [
|
||||
{
|
||||
id: "behavior",
|
||||
name: "Behavior to observe",
|
||||
type: "select",
|
||||
options: ["facial_expression", "body_language", "verbal_response"],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "observe" },
|
||||
execution: { transport: "internal", timeoutMs: 120000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
behavior: {
|
||||
type: "string",
|
||||
enum: ["facial_expression", "body_language", "verbal_response"],
|
||||
},
|
||||
},
|
||||
required: ["behavior"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
fallbackActions.forEach((action) => this.actions.set(action.id, action));
|
||||
}
|
||||
|
||||
/* ---------------- Plugin Actions ---------------- */
|
||||
|
||||
loadPluginActions(
|
||||
studyId: string,
|
||||
studyPlugins: Array<{
|
||||
plugin: {
|
||||
id: string;
|
||||
robotId: string | null;
|
||||
version: string | null;
|
||||
actionDefinitions?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
timeout?: number;
|
||||
retryable?: boolean;
|
||||
parameterSchema?: unknown;
|
||||
ros2?: {
|
||||
topic?: string;
|
||||
messageType?: string;
|
||||
service?: string;
|
||||
action?: string;
|
||||
payloadMapping?: unknown;
|
||||
qos?: {
|
||||
reliability?: string;
|
||||
durability?: string;
|
||||
history?: string;
|
||||
depth?: number;
|
||||
};
|
||||
};
|
||||
rest?: {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>,
|
||||
): void {
|
||||
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
|
||||
|
||||
if (this.loadedStudyId !== studyId) {
|
||||
this.resetPluginActions();
|
||||
}
|
||||
|
||||
(studyPlugins ?? []).forEach((studyPlugin) => {
|
||||
const { plugin } = studyPlugin;
|
||||
const actionDefs = Array.isArray(plugin.actionDefinitions)
|
||||
? plugin.actionDefinitions
|
||||
: undefined;
|
||||
if (!actionDefs) return;
|
||||
|
||||
actionDefs.forEach((action) => {
|
||||
const category =
|
||||
(action.category as ActionDefinition["category"]) || "robot";
|
||||
|
||||
const execution = action.ros2
|
||||
? {
|
||||
transport: "ros2" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
ros2: {
|
||||
topic: action.ros2.topic,
|
||||
messageType: action.ros2.messageType,
|
||||
service: action.ros2.service,
|
||||
action: action.ros2.action,
|
||||
qos: action.ros2.qos,
|
||||
payloadMapping: action.ros2.payloadMapping,
|
||||
},
|
||||
}
|
||||
: action.rest
|
||||
? {
|
||||
transport: "rest" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
rest: {
|
||||
method: action.rest.method,
|
||||
path: action.rest.path,
|
||||
headers: action.rest.headers,
|
||||
},
|
||||
}
|
||||
: {
|
||||
transport: "internal" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
};
|
||||
|
||||
const actionDef: ActionDefinition = {
|
||||
id: `${plugin.id}.${action.id}`,
|
||||
type: `${plugin.id}.${action.id}`,
|
||||
name: action.name,
|
||||
description: action.description ?? "",
|
||||
category,
|
||||
icon: action.icon ?? "Bot",
|
||||
color: "#10b981",
|
||||
parameters: this.convertParameterSchemaToParameters(
|
||||
action.parameterSchema,
|
||||
),
|
||||
source: {
|
||||
kind: "plugin",
|
||||
pluginId: plugin.id,
|
||||
robotId: plugin.robotId,
|
||||
pluginVersion: plugin.version ?? undefined,
|
||||
baseActionId: action.id,
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: action.parameterSchema ?? undefined,
|
||||
};
|
||||
this.actions.set(actionDef.id, actionDef);
|
||||
});
|
||||
});
|
||||
|
||||
this.pluginActionsLoaded = true;
|
||||
this.loadedStudyId = studyId;
|
||||
}
|
||||
|
||||
private convertParameterSchemaToParameters(
|
||||
parameterSchema: unknown,
|
||||
): ActionDefinition["parameters"] {
|
||||
interface JsonSchemaProperty {
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
enum?: string[];
|
||||
default?: string | number | boolean;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
}
|
||||
interface JsonSchema {
|
||||
properties?: Record<string, JsonSchemaProperty>;
|
||||
required?: string[];
|
||||
}
|
||||
const schema = parameterSchema as JsonSchema | undefined;
|
||||
if (!schema?.properties) return [];
|
||||
|
||||
return Object.entries(schema.properties).map(([key, paramDef]) => {
|
||||
let type: "text" | "number" | "select" | "boolean" = "text";
|
||||
|
||||
if (paramDef.type === "number") {
|
||||
type = "number";
|
||||
} else if (paramDef.type === "boolean") {
|
||||
type = "boolean";
|
||||
} else if (paramDef.enum && Array.isArray(paramDef.enum)) {
|
||||
type = "select";
|
||||
}
|
||||
|
||||
return {
|
||||
id: key,
|
||||
name: paramDef.title ?? key.charAt(0).toUpperCase() + key.slice(1),
|
||||
type,
|
||||
value: paramDef.default,
|
||||
placeholder: paramDef.description,
|
||||
options: paramDef.enum,
|
||||
min: paramDef.minimum,
|
||||
max: paramDef.maximum,
|
||||
required: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private resetPluginActions(): void {
|
||||
this.pluginActionsLoaded = false;
|
||||
this.loadedStudyId = null;
|
||||
// Remove existing plugin actions (retain known core ids + fallback ids)
|
||||
const pluginActionIds = Array.from(this.actions.keys()).filter(
|
||||
(id) =>
|
||||
!id.startsWith("wizard_") &&
|
||||
!id.startsWith("wait") &&
|
||||
!id.startsWith("observe"),
|
||||
);
|
||||
pluginActionIds.forEach((id) => this.actions.delete(id));
|
||||
}
|
||||
|
||||
/* ---------------- Query Helpers ---------------- */
|
||||
|
||||
getActionsByCategory(
|
||||
category: ActionDefinition["category"],
|
||||
): ActionDefinition[] {
|
||||
return Array.from(this.actions.values()).filter(
|
||||
(action) => action.category === category,
|
||||
);
|
||||
}
|
||||
|
||||
getAllActions(): ActionDefinition[] {
|
||||
return Array.from(this.actions.values());
|
||||
}
|
||||
|
||||
getAction(id: string): ActionDefinition | undefined {
|
||||
return this.actions.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
export const actionRegistry = ActionRegistry.getInstance();
|
||||
670
src/components/experiments/designer/BlockDesigner.tsx
Normal file
670
src/components/experiments/designer/BlockDesigner.tsx
Normal file
@@ -0,0 +1,670 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* BlockDesigner (Modular Refactor)
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Own overall experiment design state (steps + actions)
|
||||
* - Coordinate drag & drop between ActionLibrary (source) and StepFlow (targets)
|
||||
* - Persist design via experiments.update mutation (optionally compiling execution graph)
|
||||
* - Trigger server-side validation (experiments.validateDesign) to obtain integrity hash
|
||||
* - Track & surface "hash drift" (design changed since last validation or mismatch with stored integrityHash)
|
||||
*
|
||||
* Extracted Modules:
|
||||
* - ActionRegistry -> ./ActionRegistry.ts
|
||||
* - ActionLibrary -> ./ActionLibrary.tsx
|
||||
* - StepFlow -> ./StepFlow.tsx
|
||||
* - PropertiesPanel -> ./PropertiesPanel.tsx
|
||||
*
|
||||
* Enhancements Added Here:
|
||||
* - Hash drift indicator logic (Validated / Drift / Unvalidated)
|
||||
* - Modular wiring replacing previous monolithic file
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Save, Download, Play, Plus } from "lucide-react";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
|
||||
import {
|
||||
type ExperimentDesign,
|
||||
type ExperimentStep,
|
||||
type ExperimentAction,
|
||||
type ActionDefinition,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import { ActionLibrary } from "./ActionLibrary";
|
||||
import { StepFlow } from "./StepFlow";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utilities */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Build a lightweight JSON string representing the current design for drift checks.
|
||||
* We include full steps & actions; param value churn will intentionally flag drift
|
||||
* (acceptable trade-off for now; can switch to structural signature if too noisy).
|
||||
*/
|
||||
function serializeDesignSteps(steps: ExperimentStep[]): string {
|
||||
return JSON.stringify(
|
||||
steps.map((s) => ({
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
conditionKeys: Object.keys(s.trigger.conditions).sort(),
|
||||
},
|
||||
actions: s.actions.map((a) => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
sourceKind: a.source.kind,
|
||||
pluginId: a.source.pluginId,
|
||||
pluginVersion: a.source.pluginVersion,
|
||||
transport: a.execution.transport,
|
||||
parameterKeys: Object.keys(a.parameters).sort(),
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Props */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface BlockDesignerProps {
|
||||
experimentId: string;
|
||||
initialDesign?: ExperimentDesign;
|
||||
onSave?: (design: ExperimentDesign) => void;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function BlockDesigner({
|
||||
experimentId,
|
||||
initialDesign,
|
||||
onSave,
|
||||
}: BlockDesignerProps) {
|
||||
/* ---------------------------- Experiment Query ---------------------------- */
|
||||
const { data: experiment } = api.experiments.get.useQuery({
|
||||
id: experimentId,
|
||||
});
|
||||
|
||||
/* ------------------------------ Local Design ------------------------------ */
|
||||
const [design, setDesign] = useState<ExperimentDesign>(() => {
|
||||
const defaultDesign: ExperimentDesign = {
|
||||
id: experimentId,
|
||||
name: "New Experiment",
|
||||
description: "",
|
||||
steps: [],
|
||||
version: 1,
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
return initialDesign ?? defaultDesign;
|
||||
});
|
||||
|
||||
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
|
||||
const [selectedActionId, setSelectedActionId] = useState<string | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
/* ------------------------- Validation / Drift Tracking -------------------- */
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [lastValidatedHash, setLastValidatedHash] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [lastValidatedDesignJson, setLastValidatedDesignJson] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
// Recompute drift conditions
|
||||
const currentDesignJson = useMemo(
|
||||
() => serializeDesignSteps(design.steps),
|
||||
[design.steps],
|
||||
);
|
||||
|
||||
const hasIntegrityHash = !!experiment?.integrityHash;
|
||||
const hashMismatch =
|
||||
hasIntegrityHash &&
|
||||
lastValidatedHash &&
|
||||
experiment?.integrityHash !== lastValidatedHash;
|
||||
const designChangedSinceValidation =
|
||||
!!lastValidatedDesignJson && lastValidatedDesignJson !== currentDesignJson;
|
||||
|
||||
const drift =
|
||||
hasIntegrityHash && (hashMismatch ? true : designChangedSinceValidation);
|
||||
|
||||
/* ---------------------------- Active Drag State --------------------------- */
|
||||
// Removed unused activeId state (drag overlay removed in modular refactor)
|
||||
|
||||
/* ------------------------------- tRPC Mutations --------------------------- */
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Experiment saved");
|
||||
setHasUnsavedChanges(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Failed to save: ${err.message}`);
|
||||
},
|
||||
});
|
||||
const trpcUtils = api.useUtils();
|
||||
|
||||
/* ------------------------------- Plugins Load ----------------------------- */
|
||||
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
|
||||
{ studyId: experiment?.studyId ?? "" },
|
||||
{ enabled: !!experiment?.studyId },
|
||||
);
|
||||
|
||||
/* ---------------------------- Registry Loading ---------------------------- */
|
||||
useEffect(() => {
|
||||
actionRegistry.loadCoreActions().catch((err) => {
|
||||
console.error("Core actions load failed:", err);
|
||||
toast.error("Failed to load core action library");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (experiment?.studyId && (studyPlugins?.length ?? 0) > 0) {
|
||||
actionRegistry.loadPluginActions(
|
||||
experiment.studyId,
|
||||
(studyPlugins ?? []).map((sp) => ({
|
||||
plugin: {
|
||||
id: sp.plugin.id,
|
||||
robotId: sp.plugin.robotId,
|
||||
version: sp.plugin.version,
|
||||
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
|
||||
? sp.plugin.actionDefinitions
|
||||
: undefined,
|
||||
},
|
||||
})) ?? [],
|
||||
);
|
||||
}
|
||||
}, [experiment?.studyId, studyPlugins]);
|
||||
|
||||
/* ------------------------------ Breadcrumbs ------------------------------- */
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${experiment?.studyId}`,
|
||||
},
|
||||
{ label: "Experiments", href: `/studies/${experiment?.studyId}` },
|
||||
{ label: design.name, href: `/experiments/${experimentId}` },
|
||||
{ label: "Designer" },
|
||||
]);
|
||||
|
||||
/* ------------------------------ DnD Sensors ------------------------------- */
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((_event: DragStartEvent) => {
|
||||
// activeId tracking removed (drag overlay no longer used)
|
||||
}, []);
|
||||
|
||||
/* ------------------------------ Helpers ----------------------------------- */
|
||||
|
||||
const addActionToStep = useCallback(
|
||||
(stepId: string, def: ActionDefinition) => {
|
||||
const newAction: ExperimentAction = {
|
||||
id: `action_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
type: def.type,
|
||||
name: def.name,
|
||||
parameters: {},
|
||||
category: def.category,
|
||||
source: def.source,
|
||||
execution: def.execution ?? { transport: "internal" },
|
||||
parameterSchemaRaw: def.parameterSchemaRaw,
|
||||
};
|
||||
// Default param values
|
||||
def.parameters.forEach((p) => {
|
||||
if (p.value !== undefined) {
|
||||
newAction.parameters[p.id] = p.value;
|
||||
}
|
||||
});
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId ? { ...s, actions: [...s.actions, newAction] } : s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
toast.success(`Added ${def.name}`);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
// activeId reset removed (no longer tracked)
|
||||
if (!over) return;
|
||||
|
||||
const activeIdStr = active.id.toString();
|
||||
const overIdStr = over.id.toString();
|
||||
|
||||
// From library to step droppable
|
||||
if (activeIdStr.startsWith("action-") && overIdStr.startsWith("step-")) {
|
||||
const actionId = activeIdStr.replace("action-", "");
|
||||
const stepId = overIdStr.replace("step-", "");
|
||||
const def = actionRegistry.getAction(actionId);
|
||||
if (def) {
|
||||
addActionToStep(stepId, def);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step reorder (both plain ids of steps)
|
||||
if (
|
||||
!activeIdStr.startsWith("action-") &&
|
||||
!overIdStr.startsWith("step-") &&
|
||||
!overIdStr.startsWith("action-")
|
||||
) {
|
||||
const oldIndex = design.steps.findIndex((s) => s.id === activeIdStr);
|
||||
const newIndex = design.steps.findIndex((s) => s.id === overIdStr);
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: arrayMove(prev.steps, oldIndex, newIndex).map(
|
||||
(s, index) => ({ ...s, order: index }),
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Action reorder (within same step)
|
||||
if (
|
||||
!activeIdStr.startsWith("action-") &&
|
||||
!overIdStr.startsWith("step-") &&
|
||||
activeIdStr !== overIdStr
|
||||
) {
|
||||
// Identify which step these actions belong to
|
||||
const containingStep = design.steps.find((s) =>
|
||||
s.actions.some((a) => a.id === activeIdStr),
|
||||
);
|
||||
const targetStep = design.steps.find((s) =>
|
||||
s.actions.some((a) => a.id === overIdStr),
|
||||
);
|
||||
if (
|
||||
containingStep &&
|
||||
targetStep &&
|
||||
containingStep.id === targetStep.id
|
||||
) {
|
||||
const oldActionIndex = containingStep.actions.findIndex(
|
||||
(a) => a.id === activeIdStr,
|
||||
);
|
||||
const newActionIndex = containingStep.actions.findIndex(
|
||||
(a) => a.id === overIdStr,
|
||||
);
|
||||
if (
|
||||
oldActionIndex !== -1 &&
|
||||
newActionIndex !== -1 &&
|
||||
oldActionIndex !== newActionIndex
|
||||
) {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === containingStep.id
|
||||
? {
|
||||
...s,
|
||||
actions: arrayMove(
|
||||
s.actions,
|
||||
oldActionIndex,
|
||||
newActionIndex,
|
||||
),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[design.steps, addActionToStep],
|
||||
);
|
||||
|
||||
const addStep = useCallback(() => {
|
||||
const newStep: ExperimentStep = {
|
||||
id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: `Step ${design.steps.length + 1}`,
|
||||
description: "",
|
||||
type: "sequential",
|
||||
order: design.steps.length,
|
||||
trigger: {
|
||||
type: design.steps.length === 0 ? "trial_start" : "previous_step",
|
||||
conditions: {},
|
||||
},
|
||||
actions: [],
|
||||
expanded: true,
|
||||
};
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: [...prev.steps, newStep],
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}, [design.steps.length]);
|
||||
|
||||
const updateStep = useCallback(
|
||||
(stepId: string, updates: Partial<ExperimentStep>) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId ? { ...s, ...updates } : s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteStep = useCallback(
|
||||
(stepId: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.filter((s) => s.id !== stepId),
|
||||
}));
|
||||
if (selectedStepId === stepId) setSelectedStepId(null);
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[selectedStepId],
|
||||
);
|
||||
|
||||
const updateAction = useCallback(
|
||||
(stepId: string, actionId: string, updates: Partial<ExperimentAction>) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: s.actions.map((a) =>
|
||||
a.id === actionId ? { ...a, ...updates } : a,
|
||||
),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteAction = useCallback(
|
||||
(stepId: string, actionId: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: s.actions.filter((a) => a.id !== actionId),
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}));
|
||||
if (selectedActionId === actionId) setSelectedActionId(null);
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[selectedActionId],
|
||||
);
|
||||
|
||||
/* ------------------------------- Validation ------------------------------- */
|
||||
const runValidation = useCallback(async () => {
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const result = await trpcUtils.experiments.validateDesign.fetch({
|
||||
experimentId,
|
||||
visualDesign: { steps: design.steps },
|
||||
});
|
||||
|
||||
if (!result.valid) {
|
||||
toast.error(
|
||||
`Validation failed: ${result.issues.slice(0, 3).join(", ")}${
|
||||
result.issues.length > 3 ? "…" : ""
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.integrityHash) {
|
||||
setLastValidatedHash(result.integrityHash);
|
||||
setLastValidatedDesignJson(currentDesignJson);
|
||||
toast.success(
|
||||
`Validated • Hash: ${result.integrityHash.slice(0, 10)}…`,
|
||||
);
|
||||
} else {
|
||||
toast.success("Validated (no hash produced)");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Validation error: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [experimentId, design.steps, trpcUtils, currentDesignJson]);
|
||||
|
||||
/* --------------------------------- Saving --------------------------------- */
|
||||
const saveDesign = useCallback(() => {
|
||||
const visualDesign = {
|
||||
steps: design.steps,
|
||||
version: design.version,
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
updateExperiment.mutate({
|
||||
id: experimentId,
|
||||
visualDesign,
|
||||
createSteps: true,
|
||||
compileExecution: true,
|
||||
});
|
||||
const updatedDesign = { ...design, lastSaved: new Date() };
|
||||
setDesign(updatedDesign);
|
||||
onSave?.(updatedDesign);
|
||||
}, [design, experimentId, onSave, updateExperiment]);
|
||||
|
||||
/* --------------------------- Selection Resolution ------------------------- */
|
||||
const selectedStep = design.steps.find((s) => s.id === selectedStepId);
|
||||
const selectedAction = selectedStep?.actions.find(
|
||||
(a) => a.id === selectedActionId,
|
||||
);
|
||||
|
||||
/* ------------------------------- Header Badges ---------------------------- */
|
||||
const validationBadge = drift ? (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
title="Design has drifted since last validation or differs from stored hash"
|
||||
>
|
||||
Drift
|
||||
</Badge>
|
||||
) : lastValidatedHash ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-400 text-xs text-green-700 dark:text-green-400"
|
||||
title="Design matches last validated structure"
|
||||
>
|
||||
Validated
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs" title="Not yet validated">
|
||||
Unvalidated
|
||||
</Badge>
|
||||
);
|
||||
|
||||
/* ---------------------------------- Render -------------------------------- */
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={design.name}
|
||||
description="Design your experiment using steps and categorized actions"
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{validationBadge}
|
||||
{experiment?.integrityHash && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Hash: {experiment.integrityHash.slice(0, 10)}…
|
||||
</Badge>
|
||||
)}
|
||||
{experiment?.executionGraphSummary && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Exec: {experiment.executionGraphSummary.steps ?? 0}s /
|
||||
{experiment.executionGraphSummary.actions ?? 0}a
|
||||
</Badge>
|
||||
)}
|
||||
{Array.isArray(experiment?.pluginDependencies) &&
|
||||
experiment.pluginDependencies.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{experiment.pluginDependencies.length} plugins
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{design.steps.length} steps
|
||||
</Badge>
|
||||
{hasUnsavedChanges && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 text-orange-600"
|
||||
>
|
||||
Unsaved
|
||||
</Badge>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={saveDesign}
|
||||
disabled={!hasUnsavedChanges || updateExperiment.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{updateExperiment.isPending ? "Saving…" : "Save"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setHasUnsavedChanges(false); // immediate feedback
|
||||
void runValidation();
|
||||
}}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{isValidating ? "Validating…" : "Revalidate"}
|
||||
</ActionButton>
|
||||
<ActionButton variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Action Library */}
|
||||
<div className="col-span-3">
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Action Library
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ActionLibrary />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Flow */}
|
||||
<div className="col-span-6">
|
||||
<StepFlow
|
||||
steps={design.steps}
|
||||
selectedStepId={selectedStepId}
|
||||
selectedActionId={selectedActionId}
|
||||
onStepSelect={(id) => {
|
||||
setSelectedStepId(id);
|
||||
setSelectedActionId(null);
|
||||
}}
|
||||
onStepDelete={deleteStep}
|
||||
onStepUpdate={updateStep}
|
||||
onActionSelect={(actionId) => setSelectedActionId(actionId)}
|
||||
onActionDelete={deleteAction}
|
||||
emptyState={
|
||||
<div className="py-8 text-center">
|
||||
<Play className="text-muted-foreground/50 mx-auto h-8 w-8" />
|
||||
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Add your first step to begin designing
|
||||
</p>
|
||||
<Button className="mt-2" size="sm" onClick={addStep}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add First Step
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
headerRight={
|
||||
<Button size="sm" onClick={addStep} className="h-6 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add Step
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Properties */}
|
||||
<div className="col-span-3">
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
Properties
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ScrollArea className="h-full pr-1">
|
||||
<PropertiesPanel
|
||||
design={design}
|
||||
selectedStep={selectedStep}
|
||||
selectedAction={selectedAction}
|
||||
onActionUpdate={updateAction}
|
||||
onStepUpdate={updateStep}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
461
src/components/experiments/designer/PropertiesPanel.tsx
Normal file
461
src/components/experiments/designer/PropertiesPanel.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "~/components/ui/select";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Slider } from "~/components/ui/slider";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
TRIGGER_OPTIONS,
|
||||
type ExperimentAction,
|
||||
type ExperimentStep,
|
||||
type StepType,
|
||||
type TriggerType,
|
||||
type ExperimentDesign,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import {
|
||||
Settings,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
* PropertiesPanel
|
||||
*
|
||||
* Extracted modular panel for editing either:
|
||||
* - Action properties (when an action is selected)
|
||||
* - Step properties (when a step is selected and no action selected)
|
||||
* - Empty instructional state otherwise
|
||||
*
|
||||
* Enhancements:
|
||||
* - Boolean parameters render as Switch
|
||||
* - Number parameters with min/max render as Slider (with live value)
|
||||
* - Number parameters without bounds fall back to numeric input
|
||||
* - Select and text remain standard controls
|
||||
* - Provenance + category badges retained
|
||||
*/
|
||||
|
||||
export interface PropertiesPanelProps {
|
||||
design: ExperimentDesign;
|
||||
selectedStep?: ExperimentStep;
|
||||
selectedAction?: ExperimentAction;
|
||||
onActionUpdate: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
updates: Partial<ExperimentAction>,
|
||||
) => void;
|
||||
onStepUpdate: (stepId: string, updates: Partial<ExperimentStep>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PropertiesPanel({
|
||||
design,
|
||||
selectedStep,
|
||||
selectedAction,
|
||||
onActionUpdate,
|
||||
onStepUpdate,
|
||||
className,
|
||||
}: PropertiesPanelProps) {
|
||||
const registry = actionRegistry;
|
||||
|
||||
// Find containing step for selected action (if any)
|
||||
const containingStep =
|
||||
selectedAction &&
|
||||
design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id));
|
||||
|
||||
/* -------------------------- Action Properties View -------------------------- */
|
||||
if (selectedAction && containingStep) {
|
||||
const def = registry.getAction(selectedAction.type);
|
||||
const categoryColors = {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
} as const;
|
||||
// Icon resolution uses statically imported lucide icons (no dynamic require)
|
||||
|
||||
// Icon resolution uses statically imported lucide icons (no dynamic require)
|
||||
const iconComponents: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
};
|
||||
const ResolvedIcon: React.ComponentType<{ className?: string }> =
|
||||
def?.icon && iconComponents[def.icon]
|
||||
? (iconComponents[def.icon] as React.ComponentType<{
|
||||
className?: string;
|
||||
}>)
|
||||
: Zap;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* Header / Metadata */}
|
||||
<div className="border-b pb-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{def && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded text-white",
|
||||
categoryColors[def.category],
|
||||
)}
|
||||
>
|
||||
<ResolvedIcon className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-medium">
|
||||
{selectedAction.name}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{def?.category} • {selectedAction.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
{selectedAction.source.kind === "plugin" ? "Plugin" : "Core"}
|
||||
</Badge>
|
||||
{selectedAction.source.pluginId && (
|
||||
<Badge variant="secondary" className="h-4 text-[10px]">
|
||||
{selectedAction.source.pluginId}
|
||||
{selectedAction.source.pluginVersion
|
||||
? `@${selectedAction.source.pluginVersion}`
|
||||
: ""}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
{selectedAction.execution.transport}
|
||||
</Badge>
|
||||
{selectedAction.execution.retryable && (
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
retryable
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{def?.description && (
|
||||
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
|
||||
{def.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* General Action Fields */}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">Display Name</Label>
|
||||
<Input
|
||||
value={selectedAction.name}
|
||||
onChange={(e) =>
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
{def?.parameters.length ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
Parameters
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{def.parameters.map((param) => {
|
||||
const rawValue = selectedAction.parameters[param.id];
|
||||
const commonLabel = (
|
||||
<Label className="flex items-center gap-2 text-xs">
|
||||
{param.name}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{param.type === "number" &&
|
||||
(param.min !== undefined || param.max !== undefined) &&
|
||||
typeof rawValue === "number" &&
|
||||
`( ${rawValue} )`}
|
||||
</span>
|
||||
</Label>
|
||||
);
|
||||
|
||||
/* ---- Handlers ---- */
|
||||
const updateParamValue = (value: unknown) => {
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
[param.id]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Control Rendering ---- */
|
||||
let control: React.ReactNode = null;
|
||||
|
||||
if (param.type === "text") {
|
||||
control = (
|
||||
<Input
|
||||
value={(rawValue as string) ?? ""}
|
||||
placeholder={param.placeholder}
|
||||
onChange={(e) => updateParamValue(e.target.value)}
|
||||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
} else if (param.type === "select") {
|
||||
control = (
|
||||
<Select
|
||||
value={(rawValue as string) ?? ""}
|
||||
onValueChange={(val) => updateParamValue(val)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||
<SelectValue placeholder="Select…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{param.options?.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else if (param.type === "boolean") {
|
||||
control = (
|
||||
<div className="mt-1 flex h-7 items-center">
|
||||
<Switch
|
||||
checked={Boolean(rawValue)}
|
||||
onCheckedChange={(val) => updateParamValue(val)}
|
||||
aria-label={param.name}
|
||||
/>
|
||||
<span className="text-muted-foreground ml-2 text-[11px]">
|
||||
{Boolean(rawValue) ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else if (param.type === "number") {
|
||||
const numericVal =
|
||||
typeof rawValue === "number"
|
||||
? rawValue
|
||||
: typeof param.value === "number"
|
||||
? param.value
|
||||
: (param.min ?? 0);
|
||||
|
||||
if (param.min !== undefined || param.max !== undefined) {
|
||||
const min = param.min ?? 0;
|
||||
const max =
|
||||
param.max ??
|
||||
Math.max(
|
||||
min + 1,
|
||||
Number.isFinite(numericVal) ? numericVal : min + 1,
|
||||
);
|
||||
// Step heuristic
|
||||
const range = max - min;
|
||||
const step =
|
||||
param.step ??
|
||||
(range <= 5
|
||||
? 0.1
|
||||
: range <= 50
|
||||
? 0.5
|
||||
: Math.max(1, Math.round(range / 100)));
|
||||
control = (
|
||||
<div className="mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[Number(numericVal)]}
|
||||
onValueChange={(vals: number[]) =>
|
||||
updateParamValue(vals[0])
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
control = (
|
||||
<Input
|
||||
type="number"
|
||||
value={numericVal}
|
||||
onChange={(e) =>
|
||||
updateParamValue(parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={param.id} className="space-y-1">
|
||||
{commonLabel}
|
||||
{param.description && (
|
||||
<div className="text-muted-foreground text-[10px]">
|
||||
{param.description}
|
||||
</div>
|
||||
)}
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
No parameters for this action.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------- Step Properties View --------------------------- */
|
||||
if (selectedStep) {
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div className="border-b pb-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
<div
|
||||
className={cn("h-3 w-3 rounded", {
|
||||
"bg-blue-500": selectedStep.type === "sequential",
|
||||
"bg-emerald-500": selectedStep.type === "parallel",
|
||||
"bg-amber-500": selectedStep.type === "conditional",
|
||||
"bg-purple-500": selectedStep.type === "loop",
|
||||
})}
|
||||
/>
|
||||
Step Settings
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input
|
||||
value={selectedStep.name}
|
||||
onChange={(e) =>
|
||||
onStepUpdate(selectedStep.id, { name: e.target.value })
|
||||
}
|
||||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Input
|
||||
value={selectedStep.description ?? ""}
|
||||
placeholder="Optional step description"
|
||||
onChange={(e) =>
|
||||
onStepUpdate(selectedStep.id, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Select
|
||||
value={selectedStep.type}
|
||||
onValueChange={(val) =>
|
||||
onStepUpdate(selectedStep.id, { type: val as StepType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sequential">Sequential</SelectItem>
|
||||
<SelectItem value="parallel">Parallel</SelectItem>
|
||||
<SelectItem value="conditional">Conditional</SelectItem>
|
||||
<SelectItem value="loop">Loop</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Trigger</Label>
|
||||
<Select
|
||||
value={selectedStep.trigger.type}
|
||||
onValueChange={(val) =>
|
||||
onStepUpdate(selectedStep.id, {
|
||||
trigger: {
|
||||
...selectedStep.trigger,
|
||||
type: val as TriggerType,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRIGGER_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------- Empty State ------------------------------- */
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-24 items-center justify-center text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Settings className="text-muted-foreground/50 mx-auto mb-2 h-6 w-6" />
|
||||
<h3 className="mb-1 text-sm font-medium">Select Step or Action</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Click in the flow to edit properties
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
443
src/components/experiments/designer/StepFlow.tsx
Normal file
443
src/components/experiments/designer/StepFlow.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import {
|
||||
useSortable,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import {
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
Zap,
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Icon Map (localized to avoid cross-file re-render dependencies) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
MessageSquare,
|
||||
Hand,
|
||||
Navigation,
|
||||
Volume2,
|
||||
Clock,
|
||||
Eye,
|
||||
Bot,
|
||||
User,
|
||||
Zap,
|
||||
Timer,
|
||||
MousePointer,
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
GitBranch,
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* DroppableStep */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface DroppableStepProps {
|
||||
stepId: string;
|
||||
children: React.ReactNode;
|
||||
isEmpty?: boolean;
|
||||
}
|
||||
|
||||
function DroppableStep({ stepId, children, isEmpty }: DroppableStepProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `step-${stepId}`,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"min-h-[60px] rounded border-2 border-dashed transition-colors",
|
||||
isOver
|
||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
|
||||
: "border-transparent",
|
||||
isEmpty && "bg-muted/20",
|
||||
)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex items-center justify-center p-4 text-center">
|
||||
<div className="text-muted-foreground">
|
||||
<Plus className="mx-auto mb-1 h-5 w-5" />
|
||||
<p className="text-xs">Drop actions here</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SortableAction */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface SortableActionProps {
|
||||
action: ExperimentAction;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function SortableAction({
|
||||
action,
|
||||
index,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: SortableActionProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: action.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const IconComponent = iconMap[def?.icon ?? "Zap"] ?? Zap;
|
||||
|
||||
const categoryColors = {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center justify-between rounded border p-2 text-xs transition-colors",
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950/30"
|
||||
: "hover:bg-accent/50",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
{...listeners}
|
||||
className="text-muted-foreground/80 hover:text-foreground cursor-grab rounded p-0.5"
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
{def && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-white",
|
||||
categoryColors[def.category],
|
||||
)}
|
||||
>
|
||||
<IconComponent className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex items-center gap-1 truncate font-medium">
|
||||
{action.source.kind === "plugin" ? (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
|
||||
P
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
|
||||
C
|
||||
</span>
|
||||
)}
|
||||
{action.name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="h-4 text-[10px] capitalize">
|
||||
{(action.type ?? "").replace(/_/g, " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SortableStep */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface SortableStepProps {
|
||||
step: ExperimentStep;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
selectedActionId: string | null;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdate: (updates: Partial<ExperimentStep>) => void;
|
||||
onActionSelect: (actionId: string) => void;
|
||||
onActionDelete: (actionId: string) => void;
|
||||
}
|
||||
|
||||
function SortableStep({
|
||||
step,
|
||||
index,
|
||||
isSelected,
|
||||
selectedActionId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
onActionSelect,
|
||||
onActionDelete,
|
||||
}: SortableStepProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: step.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const stepTypeColors: Record<ExperimentStep["type"], string> = {
|
||||
sequential: "border-l-blue-500",
|
||||
parallel: "border-l-emerald-500",
|
||||
conditional: "border-l-amber-500",
|
||||
loop: "border-l-purple-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<Card
|
||||
className={cn(
|
||||
"border-l-4 transition-all",
|
||||
stepTypeColors[step.type],
|
||||
isSelected
|
||||
? "bg-blue-50/50 ring-2 ring-blue-500 dark:bg-blue-950/20 dark:ring-blue-400"
|
||||
: "",
|
||||
isDragging && "rotate-2 opacity-50 shadow-lg",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="cursor-pointer pb-2" onClick={() => onSelect()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUpdate({ expanded: !step.expanded });
|
||||
}}
|
||||
>
|
||||
{step.expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Badge variant="outline" className="h-5 text-xs">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{step.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{step.actions.length} actions • {step.type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<div {...listeners} className="cursor-grab p-1">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{step.expanded && (
|
||||
<CardContent className="pt-0">
|
||||
<DroppableStep stepId={step.id} isEmpty={step.actions.length === 0}>
|
||||
{step.actions.length > 0 && (
|
||||
<SortableContext
|
||||
items={step.actions.map((a) => a.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{step.actions.map((action, actionIndex) => (
|
||||
<SortableAction
|
||||
key={action.id}
|
||||
action={action}
|
||||
index={actionIndex}
|
||||
isSelected={selectedActionId === action.id}
|
||||
onSelect={() => onActionSelect(action.id)}
|
||||
onDelete={() => onActionDelete(action.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</DroppableStep>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* StepFlow (Scrollable Container of Steps) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface StepFlowProps {
|
||||
steps: ExperimentStep[];
|
||||
selectedStepId: string | null;
|
||||
selectedActionId: string | null;
|
||||
onStepSelect: (id: string) => void;
|
||||
onStepDelete: (id: string) => void;
|
||||
onStepUpdate: (id: string, updates: Partial<ExperimentStep>) => void;
|
||||
onActionSelect: (actionId: string) => void;
|
||||
onActionDelete: (stepId: string, actionId: string) => void;
|
||||
onActionUpdate?: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
updates: Partial<ExperimentAction>,
|
||||
) => void;
|
||||
emptyState?: React.ReactNode;
|
||||
headerRight?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function StepFlow({
|
||||
steps,
|
||||
selectedStepId,
|
||||
selectedActionId,
|
||||
onStepSelect,
|
||||
onStepDelete,
|
||||
onStepUpdate,
|
||||
onActionSelect,
|
||||
onActionDelete,
|
||||
emptyState,
|
||||
headerRight,
|
||||
}: StepFlowProps) {
|
||||
return (
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Experiment Flow
|
||||
</div>
|
||||
{headerRight}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{steps.length === 0 ? (
|
||||
(emptyState ?? (
|
||||
<div className="py-8 text-center">
|
||||
<GitBranch className="text-muted-foreground/50 mx-auto h-8 w-8" />
|
||||
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Add your first step to begin designing
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<SortableContext
|
||||
items={steps.map((s) => s.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id}>
|
||||
<SortableStep
|
||||
step={step}
|
||||
index={index}
|
||||
isSelected={selectedStepId === step.id}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelect={() => onStepSelect(step.id)}
|
||||
onDelete={() => onStepDelete(step.id)}
|
||||
onUpdate={(updates) => onStepUpdate(step.id, updates)}
|
||||
onActionSelect={onActionSelect}
|
||||
onActionDelete={(actionId) =>
|
||||
onActionDelete(step.id, actionId)
|
||||
}
|
||||
/>
|
||||
{index < steps.length - 1 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="bg-border h-2 w-px" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user