mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 06:34:44 -05:00
chore: commit full workspace changes (designer modularization, diagnostics fixes, docs updates, seed script cleanup)
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -23,6 +23,7 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
@@ -419,6 +420,8 @@
|
||||
|
||||
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
|
||||
|
||||
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="],
|
||||
|
||||
253
docs/experiment-designer-step-integration.md
Normal file
253
docs/experiment-designer-step-integration.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Experiment Designer Step Integration (Modular Architecture + Drift Handling)
|
||||
|
||||
## Overview
|
||||
|
||||
The HRIStudio experiment designer has been redesigned with a step-based + provenance-aware architecture that provides intuitive experiment creation, transparent plugin usage, and reproducible execution through integrity hashing and a compiled execution graph.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Design Philosophy
|
||||
|
||||
The designer follows a clear hierarchy that matches database structure, runtime execution, and reproducibility tracking:
|
||||
- **Experiment** → **Steps** → **Actions** (with provenance + execution descriptors)
|
||||
- Steps are primary containers in the flow (Step 1 → Step 2 → Step 3) with sortable ordering
|
||||
- Actions are dragged from a categorized library into step containers (core vs plugin clearly labeled)
|
||||
- Direct 1:1 mapping to database `steps` and `actions` tables, persisting provenance & transport metadata
|
||||
|
||||
### Key Components (Post-Modularization)
|
||||
|
||||
#### ActionRegistry (`ActionRegistry.ts`)
|
||||
- Loads actions from core plugin repositories (`hristudio-core/plugins/`)
|
||||
- Integrates study-scoped robot plugins (namespaced: `pluginId.actionId`)
|
||||
- Provides fallback actions if plugin loading fails (ensures minimal operability)
|
||||
- Maps plugin parameter schemas (primitive: text/number/select/boolean) to UI fields
|
||||
- Retains provenance + execution descriptors (transport, timeout, retryable)
|
||||
|
||||
#### Step-Based Flow (`StepFlow.tsx`)
|
||||
- Sortable step containers with drag-and-drop reordering (via `@dnd-kit`)
|
||||
- Color-coded step types (sequential, parallel, conditional, loop) with left border accent
|
||||
- Expandable/collapsible view for managing complex experiments
|
||||
- Visual connectors between steps (light vertical separators)
|
||||
- Isolated from parameter/editor logic for performance and clarity
|
||||
|
||||
#### Action Library (`ActionLibrary.tsx`)
|
||||
- Categorized tabs: Wizard (blue), Robot (emerald), Control (amber), Observation (purple)
|
||||
- Tooltips show description, parameters, provenance badge (C core / P plugin)
|
||||
- Drag-and-drop from library directly into specific step droppable zones
|
||||
- Footer statistics (total actions / category count)
|
||||
- Empty + fallback guidance when plugin actions absent
|
||||
- Guarantees availability: once the experiment's study context and its installed plugins are loaded, all corresponding plugin actions are registered and appear (guarded against duplicate loads / stale study mismatch)
|
||||
- Plugin availability is study-scoped: only plugins installed for the experiment's parent study (via Plugin Store installation) are loaded and exposed; this ensures experiments cannot reference uninstalled or unauthorized plugin actions.
|
||||
|
||||
#### Properties Panel (`PropertiesPanel.tsx`)
|
||||
- Context-aware: action selection → action parameters; step selection → step metadata; otherwise instructional state
|
||||
- Boolean parameters now render as accessible Switch
|
||||
- Number parameters with `min`/`max` render as Slider (shows live value + bounds)
|
||||
- Number parameters without bounds fall back to numeric input
|
||||
- Select/text unchanged; future: enum grouping + advanced editors
|
||||
- Displays provenance + transport badges (plugin id@version, transport, retryable)
|
||||
|
||||
## User Experience
|
||||
|
||||
### Visual Design
|
||||
- **Tightened Spacing**: Compact UI with efficient screen real estate usage
|
||||
- **Dark Mode Support**: Proper selection states and theme-aware colors
|
||||
- **Color Consistency**: Category colors used throughout for visual coherence
|
||||
- **Micro-interactions**: Hover states, drag overlays, smooth transitions
|
||||
|
||||
### Interaction Patterns
|
||||
- **Direct Action Editing**: Click any action to immediately edit properties (no step selection required)
|
||||
- **Multi-level Sorting**: Reorder steps in flow, reorder actions within steps
|
||||
- **Visual Feedback**: Drop zones highlight, selection states clear, drag handles intuitive
|
||||
- **Touch-friendly**: Proper activation constraints for mobile/touch devices
|
||||
|
||||
### Properties Panel (Enhanced Parameter Controls)
|
||||
- **Action-First Workflow**: Immediate property editing on action selection
|
||||
- **Rich Metadata**: Icon, category color, provenance badges (Core/Plugin, transport)
|
||||
- **Switch for Boolean**: Improves clarity vs checkbox in dense layouts
|
||||
- **Slider for Ranged Number**: Applies when `min` or `max` present (live formatted value)
|
||||
- **Graceful Fallbacks**: Plain number input if no bounds; text/select unchanged
|
||||
- **Context-Aware**: Step editing (name/type/trigger) isolated from action editing
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Drag and Drop System
|
||||
Built with `@dnd-kit` for robust, accessible drag-and-drop:
|
||||
|
||||
```typescript
|
||||
// Multi-context sorting support
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
// Action from library to step
|
||||
if (activeId.startsWith("action-") && overId.startsWith("step-")) {
|
||||
// Add action to step
|
||||
}
|
||||
|
||||
// Step reordering in flow
|
||||
if (!activeId.startsWith("action-") && !overId.startsWith("step-")) {
|
||||
// Reorder steps
|
||||
}
|
||||
|
||||
// Action reordering within step
|
||||
if (!activeId.startsWith("action-") && !overId.startsWith("step-")) {
|
||||
// Reorder actions in step
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Plugin Integration (Registry-Centric)
|
||||
Actions are loaded dynamically from multiple sources with provenance & version retention:
|
||||
|
||||
```typescript
|
||||
class ActionRegistry {
|
||||
async loadCoreActions() {
|
||||
// Load from hristudio-core/plugins/
|
||||
const coreActionSets = ["wizard-actions", "control-flow", "observation"];
|
||||
// Process and register actions
|
||||
}
|
||||
|
||||
loadPluginActions(studyId: string, studyPlugins: Array<{plugin: any}>) {
|
||||
// Load robot-specific actions from study plugins
|
||||
// Map parameter schemas to form controls
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Database & Execution Conversion
|
||||
Two-layer conversion:
|
||||
1. Visual design → DB steps/actions with provenance & execution metadata
|
||||
2. Visual design → Compiled execution graph (normalized actions + transport summary + integrity hash)
|
||||
|
||||
```typescript
|
||||
function convertStepsToDatabase(steps: ExperimentStep[]): ConvertedStep[] {
|
||||
return steps.map((step, index) => ({
|
||||
name: step.name,
|
||||
type: mapStepTypeToDatabase(step.type),
|
||||
orderIndex: index,
|
||||
conditions: step.trigger.conditions,
|
||||
actions: step.actions.map((action, actionIndex) => ({
|
||||
name: action.name,
|
||||
type: action.type,
|
||||
orderIndex: actionIndex,
|
||||
parameters: action.parameters,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## Validation & Hash Drift Handling
|
||||
A validation workflow now surfaces structural integrity + reproducibility signals:
|
||||
|
||||
### Validation Endpoint
|
||||
- `experiments.validateDesign` returns:
|
||||
- `valid` + `issues[]`
|
||||
- `integrityHash` (deterministic structural hash from compiled execution graph)
|
||||
- `pluginDependencies` (sorted, namespaced with versions)
|
||||
- Execution summary (steps/actions/transport mix)
|
||||
|
||||
### Drift Detection (Client-Side)
|
||||
- Local state caches: `lastValidatedHash` + serialized design snapshot
|
||||
- Drift conditions:
|
||||
1. Stored experiment `integrityHash` ≠ last validated hash
|
||||
2. Design snapshot changed since last validation (structural or param changes)
|
||||
- Badge States:
|
||||
- `Validated` (green outline): design unchanged since last validation and matches stored hash
|
||||
- `Drift` (destructive): mismatch or post-validation edits
|
||||
- `Unvalidated`: no validation performed yet
|
||||
|
||||
### Rationale
|
||||
- Encourages explicit revalidation after structural edits
|
||||
- Prevents silent divergence from compiled execution artifact
|
||||
- Future: differentiate structural vs param-only drift (hash currently parameter-key-based)
|
||||
|
||||
### Planned Enhancements
|
||||
- Hash stability tuning (exclude mutable free-text values if needed)
|
||||
- Inline warnings on mutated steps/actions
|
||||
- Optional auto-validate on save (configurable)
|
||||
|
||||
## Plugin System Integration
|
||||
|
||||
### Core Actions
|
||||
Loaded from `hristudio-core/plugins/` repositories:
|
||||
- **wizard-actions.json**: Wizard speech, gestures, instructions
|
||||
- **control-flow.json**: Wait, conditional logic, loops
|
||||
- **observation.json**: Behavioral coding, data collection, measurements
|
||||
|
||||
### Robot Actions
|
||||
Dynamically loaded based on study configuration:
|
||||
- Robot-specific actions from plugin repositories
|
||||
- Parameter schemas automatically converted to form controls
|
||||
- Platform-specific validation and constraints
|
||||
|
||||
### Fallback System
|
||||
Essential actions available even if plugin loading fails:
|
||||
- Basic wizard speech and gesture actions
|
||||
- Core control flow (wait, conditional)
|
||||
- Simple observation and data collection
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Creating an Experiment
|
||||
1. **Add Steps**: Click "Add Step" to create containers in the experiment flow
|
||||
2. **Configure Steps**: Set name, type (sequential/parallel/conditional/loop), triggers
|
||||
3. **Drag Actions**: Drag from categorized library into step drop zones
|
||||
4. **Edit Properties**: Click actions to immediately edit parameters
|
||||
5. **Reorder**: Drag steps in flow, drag actions within steps
|
||||
6. **Save**: Direct conversion to database step/action records (provenance & execution metadata persisted)
|
||||
|
||||
### Visual Workflow
|
||||
```
|
||||
Action Library Experiment Flow Properties
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
|
||||
│ [Wizard] │ │ Step 1: Welcome │ │ Action: │
|
||||
│ [Robot] │ -----> │ ├ Wizard Says │ ------> │ Wizard Says │
|
||||
│ [Control] │ │ ├ Wait 2s │ │ Text: ... │
|
||||
│ [Observe] │ │ └ Observe │ │ Tone: ... │
|
||||
└─────────────┘ └──────────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### For Researchers
|
||||
- **Intuitive Design**: Natural workflow matching experimental thinking
|
||||
- **Visual Clarity**: Clear step-by-step experiment structure
|
||||
- **Plugin Integration**: Access to full ecosystem of robot platforms
|
||||
- **Direct Editing**: No complex nested selections required
|
||||
|
||||
### For System Architecture
|
||||
- **Clean Separation**: Visual design vs execution logic clearly separated
|
||||
- **Database Integrity**: Direct 1:1 mapping maintains relationships
|
||||
- **Plugin Extensibility**: Easy integration of new robot platforms
|
||||
- **Type Safety**: Complete TypeScript integration throughout
|
||||
|
||||
### For Development
|
||||
- **Maintainable Code**: Clean component architecture with clear responsibilities
|
||||
- **Performance**: Efficient rendering with proper React patterns
|
||||
- **Error Handling**: Graceful degradation when plugins fail
|
||||
- **Accessibility**: Built on accessible `@dnd-kit` foundation
|
||||
|
||||
## Modular Architecture Summary
|
||||
| Module | Responsibility | Notes |
|
||||
|--------|----------------|-------|
|
||||
| `BlockDesigner.tsx` | Orchestration (state, save, validation, drift badges) | Thin controller after refactor |
|
||||
| `ActionRegistry.ts` | Core + plugin action loading, provenance, fallback | Stateless across renders (singleton) |
|
||||
| `ActionLibrary.tsx` | Categorized draggable palette | Performance-isolated |
|
||||
| `StepFlow.tsx` | Sortable steps & actions, structural UI | No parameter logic |
|
||||
| `PropertiesPanel.tsx` | Parameter + metadata editing (enhanced controls) | Switch + Slider integration |
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features (Updated Roadmap)
|
||||
- **Step Templates**: Reusable step patterns for common workflows
|
||||
- **Visual Debugging**: Inline structural + provenance validation markers
|
||||
- **Collaborative Editing**: Real-time multi-user design sessions
|
||||
- **Advanced Conditionals**: Branching logic & guard editors (visual condition builder)
|
||||
- **Structural Drift Granularity**: Distinguish param-value vs structural changes
|
||||
- **Version Pin Diffing**: Detect plugin version upgrades vs design-pinned versions
|
||||
|
||||
### Integration Opportunities
|
||||
- **Version Control**: Track experiment changes across iterations
|
||||
- **A/B Testing**: Support for experimental variations within single design
|
||||
- **Analytics Integration**: Step-level performance monitoring
|
||||
- **Export Formats**: Convert to external workflow systems
|
||||
|
||||
This redesigned experiment designer now combines step-based structure, provenance tracking, transport-aware execution compilation, integrity hashing, and validation workflows to deliver reproducible, extensible, and transparent experimental protocols.
|
||||
@@ -21,9 +21,9 @@ bun run docker:up
|
||||
bun db:push
|
||||
bun db:seed
|
||||
|
||||
# Sync plugin repositories (admin only)
|
||||
# This populates the plugin store with robot plugins
|
||||
# from https://repo.hristudio.com
|
||||
# Single command now syncs all repositories:
|
||||
# - Core blocks from localhost:3000/hristudio-core
|
||||
# - Robot plugins from https://repo.hristudio.com
|
||||
|
||||
# Start development
|
||||
bun dev
|
||||
@@ -89,7 +89,7 @@ Study → Experiment → Trial → Step → Action
|
||||
| `bun typecheck` | TypeScript validation |
|
||||
| `bun lint` | Code quality checks |
|
||||
| `bun db:push` | Push schema changes |
|
||||
| `bun db:seed` | Seed test data |
|
||||
| `bun db:seed` | Seed data & sync repositories |
|
||||
| `bun db:studio` | Open database GUI |
|
||||
|
||||
---
|
||||
@@ -223,16 +223,16 @@ const hasRole = (role: string) => session?.user.role === role;
|
||||
|
||||
## 🤖 **Robot Integration**
|
||||
|
||||
### Core Blocks System
|
||||
### Core Block System
|
||||
```typescript
|
||||
// Core blocks loaded from hristudio-core repository
|
||||
await registry.loadCoreBlocks();
|
||||
// Core blocks loaded from local repository during development
|
||||
// Repository sync: localhost:3000/hristudio-core → database
|
||||
|
||||
// Block categories:
|
||||
// Block categories (27 total blocks in 4 groups):
|
||||
// - Events (4): when_trial_starts, when_participant_speaks, etc.
|
||||
// - Wizard Actions (6): wizard_say, wizard_gesture, etc.
|
||||
// - Control Flow (8): wait, repeat, if_condition, etc.
|
||||
// - Observation (8): observe_behavior, record_audio, etc.
|
||||
// - Observation (9): observe_behavior, record_audio, etc.
|
||||
```
|
||||
|
||||
### Plugin Repository System
|
||||
@@ -269,9 +269,9 @@ interface Plugin {
|
||||
```
|
||||
|
||||
### Repository Integration
|
||||
- **Live Repository**: `https://repo.hristudio.com`
|
||||
- **Core Repository**: `https://core.hristudio.com`
|
||||
- **Auto-sync**: Admin dashboard → Repositories → Sync
|
||||
- **Robot Plugins**: `https://repo.hristudio.com` (live)
|
||||
- **Core Blocks**: `localhost:3000/hristudio-core` (development)
|
||||
- **Auto-sync**: Integrated into `bun db:seed` command
|
||||
- **Plugin Store**: Browse → Install → Use in experiments
|
||||
|
||||
---
|
||||
@@ -408,7 +408,7 @@ bun typecheck
|
||||
3. Follow the established component patterns
|
||||
4. Add proper error boundaries for new features
|
||||
5. Test with multiple user roles
|
||||
6. Sync plugin repositories after setup for full functionality
|
||||
6. Use single `bun db:seed` for complete setup
|
||||
|
||||
### Code Standards
|
||||
- Use TypeScript strict mode
|
||||
|
||||
@@ -2,6 +2,94 @@
|
||||
|
||||
## Recent Changes Summary (February 2025)
|
||||
|
||||
### Experiment Designer Iteration (February 2025)
|
||||
|
||||
#### **Current Focus: Modularized Designer (ActionLibrary / StepFlow / PropertiesPanel / Registry) & Validation Drift Handling**
|
||||
**Status**: In active iteration (not stable)
|
||||
|
||||
The experiment designer has been refactored into modular components (registry + library + flow + properties panel). Active iteration now targets validation drift visibility, richer parameter controls, and continued provenance/compilation integrity.
|
||||
|
||||
**Implemented (baseline):**
|
||||
- Step-first container model (steps hold ordered actions) (stable)
|
||||
- Drag-and-drop of actions into steps and reordering within steps (stable)
|
||||
- Conversion utility to DB step/action structures (provenance & execution metadata flattening) (stable)
|
||||
- Plugin action loading with namespaced IDs (pluginId.actionId) and provenance retention (stable)
|
||||
- Execution compilation pipeline (deterministic graph + integrity hash) integrated in update mutation (stable)
|
||||
|
||||
**UI State (updated):**
|
||||
- Compact spacing (h-6/7 inputs) and category coloring
|
||||
- Dark mode variants applied (will audit accessibility)
|
||||
- Category color system (may refine semantic tokens; now reused across extracted components)
|
||||
- Step containers with colored left borders for type (moved into `StepFlow`)
|
||||
- Drag overlays + hover states (keyboard reordering & provenance badge a11y still pending)
|
||||
|
||||
**Interaction Features (present / modularized):**
|
||||
- Multi-level drag & drop (steps + actions) via `StepFlow`
|
||||
- Direct action selection for inline parameter editing (now inside `PropertiesPanel`)
|
||||
- Action tooltip (plugin/source details; provenance badges present; enrichment still planned)
|
||||
- Drop zone highlighting
|
||||
- Pointer sensor activation threshold (mobile/touch review pending)
|
||||
|
||||
**Plugin Integration (status):**
|
||||
- Registry loads core + study plugin actions (version stored; pin drift resolution still pending; designer now guarantees installed study plugin actions appear once experiment + study resolved)
|
||||
- Fallback actions present (unchanged)
|
||||
- Param schema → UI field mapping (primitive only; now enhanced for boolean + ranged number)
|
||||
- Provenance & execution metadata embedded in action instances (server persists) (stable)
|
||||
- Structured error surfaces for plugin load/validation failures still TODO
|
||||
|
||||
**Technical Notes (updated):**
|
||||
- Types expanded: provenance, execution descriptors, integrity hashing structures (stable)
|
||||
- Acceptable performance; profiling after slider & drift logic integration still pending
|
||||
- Accessibility review pending (keyboard reorder, focus indicators, slider a11y labels)
|
||||
- Monolith split complete: `ActionRegistry.ts`, `ActionLibrary.tsx`, `StepFlow.tsx`, `PropertiesPanel.tsx`
|
||||
- Need unit tests for registry + conversion + compiler + drift serializer; none present
|
||||
|
||||
**Key Files (current iteration - post modularization):**
|
||||
- `~/components/experiments/designer/BlockDesigner.tsx` - Orchestrator (state, validation, drift)
|
||||
- `~/components/experiments/designer/ActionRegistry.ts` - Registry (core + plugin + fallback)
|
||||
- `~/components/experiments/designer/ActionLibrary.tsx` - Categorized draggables
|
||||
- `~/components/experiments/designer/StepFlow.tsx` - Sortable steps & actions
|
||||
|
||||
- `~/components/experiments/designer/PropertiesPanel.tsx` - Parameter & metadata editor
|
||||
**Current State Summary:**
|
||||
Functional baseline with modular extraction complete. Parameter UI upgraded (boolean → Switch, ranged number → Slider). Hash drift indicator implemented (Validated / Drift / Unvalidated). Still pending: enum grouping polish, keyboard DnD accessibility, version pin drift resolution, structured plugin load error surfaces.
|
||||
|
||||
### Seed Script Simplification & Repository Integration (stable)
|
||||
|
||||
#### **Unified Repository-Based Plugin Loading**
|
||||
Simplified and unified the seed scripts to load all plugins (core and robot) through the same repository sync mechanism.
|
||||
|
||||
**Seed Script Consolidation:**
|
||||
- **Before**: 5 separate seed scripts (`seed.ts`, `seed-dev.ts`, `seed-simple.ts`, `seed-plugins.ts`, `seed-core-blocks.ts`)
|
||||
- **After**: Single `seed-dev.ts` script with integrated repository sync
|
||||
- **Benefits**: Consistent plugin loading, easier maintenance, real repository testing
|
||||
|
||||
**Core Repository Integration:**
|
||||
- Core blocks now loaded from `http://localhost:3000/hristudio-core` during development
|
||||
- Same repository sync logic as robot plugins from `https://repo.hristudio.com`
|
||||
- Eliminates hardcoded core blocks - everything comes from repositories
|
||||
- Local core repository served from `public/hristudio-core/` directory
|
||||
|
||||
**Simplified Setup Process:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
bun db:push
|
||||
bun db:seed # Single command loads everything
|
||||
```
|
||||
|
||||
**Repository Sync Integration:**
|
||||
- Core system blocks loaded as single plugin with 4 block groups (27 total blocks)
|
||||
- Robot plugins loaded individually (TurtleBot3 Burger/Waffle, NAO)
|
||||
- All repositories use same sync validation and error handling
|
||||
- Proper metadata storage with repository references
|
||||
|
||||
**Package.json Cleanup:**
|
||||
- Removed `db:seed:simple`, `db:seed:plugins`, `db:seed:core-blocks`, `db:seed:full`
|
||||
- Single `db:seed` command for all seeding needs
|
||||
- Simplified development workflow with fewer script options
|
||||
|
||||
---
|
||||
|
||||
### Plugin Store Repository Integration
|
||||
|
||||
#### **Complete Repository Synchronization System**
|
||||
@@ -32,9 +120,7 @@ Fixed and implemented full repository synchronization for dynamic plugin loading
|
||||
- Repository names displayed for all plugins from proper metadata
|
||||
- Study-scoped plugin installation working correctly
|
||||
|
||||
---
|
||||
|
||||
## Recent Changes Summary (December 2024)
|
||||
## Recent Changes Summary (December 2024) (historical reference)
|
||||
|
||||
### Plugin System Implementation
|
||||
|
||||
@@ -171,7 +257,7 @@ The experiment designer was completely redesigned to integrate seamlessly with t
|
||||
```
|
||||
|
||||
#### **Files Modified**
|
||||
- `src/components/experiments/designer/EnhancedBlockDesigner.tsx` - Complete redesign
|
||||
- `src/components/experiments/designer/BlockDesigner.tsx` - Current iterative version
|
||||
- `src/components/ui/data-table.tsx` - Fixed control heights
|
||||
- `src/components/experiments/experiments-data-table.tsx` - Fixed select styling
|
||||
- `src/components/participants/participants-data-table.tsx` - Fixed select styling
|
||||
@@ -279,24 +365,24 @@ The overall system theme was too monochromatic with insufficient color personali
|
||||
|
||||
---
|
||||
|
||||
### Current Status
|
||||
### Current Status (Deprecated Section - To Be Rewritten)
|
||||
|
||||
#### **Completed**
|
||||
- Complete experiment designer redesign with unified components
|
||||
- (Remove) Designer not yet in a stable/complete state
|
||||
- All data table control styling standardized
|
||||
- System theme enhanced with better colors
|
||||
- Breadcrumb navigation completely fixed
|
||||
- Technical debt resolved
|
||||
|
||||
#### **Production Ready**
|
||||
- All TypeScript errors resolved
|
||||
- TypeScript surface improving; more types to add for provenance/execution
|
||||
- Consistent styling throughout application
|
||||
- Proper error handling and user feedback
|
||||
- Excellent dark mode support
|
||||
- Mobile/tablet friendly drag and drop
|
||||
|
||||
#### **Improvements Achieved**
|
||||
- **Visual Consistency**: Complete - All components now use unified design system
|
||||
- Visual consistency improved; still refactoring designer component size
|
||||
- **User Experience**: Significant improvement in navigation and usability
|
||||
- **Code Quality**: Clean, maintainable code with proper patterns
|
||||
- **Performance**: Optimized drag and drop with better collision detection
|
||||
@@ -304,7 +390,7 @@ The overall system theme was too monochromatic with insufficient color personali
|
||||
|
||||
---
|
||||
|
||||
### Core Block System Implementation (February 2024)
|
||||
### Core Block System Implementation (February 2024) (archived)
|
||||
|
||||
**Complete documentation available in [`docs/core-blocks-system.md`](core-blocks-system.md)**
|
||||
|
||||
@@ -388,26 +474,37 @@ hristudio-core/
|
||||
|
||||
---
|
||||
|
||||
### Documentation Status
|
||||
### Documentation Status (Updated With Provenance, Compilation, Pending Modularization)
|
||||
|
||||
All changes have been documented and the codebase is ready for production deployment. The system now features:
|
||||
|
||||
1. **Complete Plugin Architecture**: Both core blocks and robot actions loaded from repositories
|
||||
2. **Working Repository Sync**: Live synchronization from `https://repo.hristudio.com`
|
||||
3. **Proper Installation States**: Plugin store correctly shows installed vs available plugins
|
||||
4. **TypeScript Compliance**: All unsafe `any` types replaced with proper typing
|
||||
5. **Admin Access**: Full administrator role and permission system operational
|
||||
1. Unified repository loading in place (integrity hash now captured; version drift alerts pending)
|
||||
2. **Simplified Seed Scripts**: Single command setup with automatic repository synchronization
|
||||
3. **Local Core Repository**: Core blocks served from `public/hristudio-core/` during development
|
||||
4. **Working Repository Sync**: Live synchronization from `https://repo.hristudio.com` for robot plugins
|
||||
5. **Proper Installation States**: Plugin store correctly shows installed vs available plugins
|
||||
6. **TypeScript Compliance**: All unsafe `any` types replaced with proper typing
|
||||
7. **Admin Access**: Full administrator role and permission system operational
|
||||
|
||||
**Core Documentation Files:**
|
||||
**Core Documentation Files (Pending Updates):**
|
||||
- [`docs/core-blocks-system.md`](core-blocks-system.md) - Complete core blocks implementation guide
|
||||
- [`docs/plugin-system-implementation-guide.md`](plugin-system-implementation-guide.md) - Robot plugin system guide
|
||||
- [`docs/work_in_progress.md`](work_in_progress.md) - Current development status
|
||||
- [`docs/work_in_progress.md`](work_in_progress.md) - Current development status (now includes provenance & compiler updates)
|
||||
|
||||
**Production Readiness:**
|
||||
- ✅ All TypeScript errors resolved (except documentation LaTeX)
|
||||
- ✅ Repository synchronization fully functional
|
||||
**Readiness Caveats:**
|
||||
- Pending UI: provenance badges (partially done), re-validation triggers (implemented), drift indicator (TODO), modular split (TODO), parameter control upgrades (TODO), version drift resolution (TODO)
|
||||
- ✅ Simplified seed scripts with unified repository loading
|
||||
- ✅ Core repository integration via localhost during development
|
||||
- ✅ Repository synchronization fully functional for both core and robot plugins
|
||||
- ✅ Plugin store with proper installation state detection
|
||||
- ✅ Admin dashboard with repository management
|
||||
- ✅ Complete user authentication and authorization
|
||||
- ✅ Study-scoped plugin installation working
|
||||
- ✅ 98% feature completion maintained
|
||||
- ✅ 98% feature completion maintained
|
||||
|
||||
**Development Workflow (Stable Pieces):**
|
||||
- ✅ Single `bun db:seed` command for complete setup
|
||||
- ✅ Core blocks loaded from local repository structure
|
||||
- ✅ Robot plugins synchronized from live repository
|
||||
- ✅ Consistent plugin architecture across all block types
|
||||
- ✅ Real repository testing during development
|
||||
@@ -11,10 +11,6 @@
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "bun scripts/seed-dev.ts",
|
||||
"db:seed:simple": "bun scripts/seed-simple.ts",
|
||||
"db:seed:plugins": "bun scripts/seed-plugins.ts",
|
||||
"db:seed:core-blocks": "bun scripts/seed-core-blocks.ts",
|
||||
"db:seed:full": "bun scripts/seed.ts",
|
||||
"dev": "next dev --turbo",
|
||||
"docker:up": "colima start && docker-compose up -d",
|
||||
"docker:down": "docker-compose down && colima stop",
|
||||
@@ -46,6 +42,7 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
|
||||
1
robot-plugins
Submodule
1
robot-plugins
Submodule
Submodule robot-plugins added at 334dc68a22
@@ -1,589 +0,0 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../src/server/db/schema";
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://postgres:password@localhost:5140/hristudio";
|
||||
const client = postgres(connectionString);
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
async function seedCoreRepository() {
|
||||
console.log("🏗️ Seeding core system repository...");
|
||||
|
||||
// Check if core repository already exists
|
||||
const existingCoreRepo = await db
|
||||
.select()
|
||||
.from(schema.pluginRepositories)
|
||||
.where(eq(schema.pluginRepositories.url, "https://core.hristudio.com"));
|
||||
|
||||
if (existingCoreRepo.length > 0) {
|
||||
console.log("⚠️ Core repository already exists, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the first user to use as creator
|
||||
const users = await db.select().from(schema.users);
|
||||
const adminUser =
|
||||
users.find((u) => u.email?.includes("sarah.chen")) ?? users[0];
|
||||
|
||||
if (!adminUser) {
|
||||
console.log("⚠️ No users found. Please run basic seeding first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const coreRepository = {
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
name: "HRIStudio Core System Blocks",
|
||||
url: "https://core.hristudio.com",
|
||||
description:
|
||||
"Essential system blocks for experiment design including events, control flow, wizard actions, and logic operations",
|
||||
trustLevel: "official" as const,
|
||||
isEnabled: true,
|
||||
isOfficial: true,
|
||||
lastSyncAt: new Date(),
|
||||
syncStatus: "completed" as const,
|
||||
syncError: null,
|
||||
metadata: {
|
||||
apiVersion: "1.0",
|
||||
pluginApiVersion: "1.0",
|
||||
categories: ["core", "wizard", "control", "logic", "events"],
|
||||
compatibility: {
|
||||
hristudio: { min: "0.1.0", recommended: "0.1.0" },
|
||||
},
|
||||
isCore: true,
|
||||
},
|
||||
createdAt: new Date("2024-01-01T00:00:00"),
|
||||
updatedAt: new Date(),
|
||||
createdBy: adminUser.id,
|
||||
};
|
||||
|
||||
await db.insert(schema.pluginRepositories).values([coreRepository]);
|
||||
console.log("✅ Created core system repository");
|
||||
}
|
||||
|
||||
async function seedCorePlugin() {
|
||||
console.log("🧱 Seeding core system plugin...");
|
||||
|
||||
// Check if core plugin already exists
|
||||
const existingCorePlugin = await db
|
||||
.select()
|
||||
.from(schema.plugins)
|
||||
.where(eq(schema.plugins.name, "HRIStudio Core System"));
|
||||
|
||||
if (existingCorePlugin.length > 0) {
|
||||
console.log("⚠️ Core plugin already exists, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const corePlugin = {
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
robotId: null, // Core plugin doesn't need a specific robot
|
||||
name: "HRIStudio Core System",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"Essential system blocks for experiment design including events, control flow, wizard actions, and logic operations",
|
||||
author: "HRIStudio Team",
|
||||
repositoryUrl: "https://core.hristudio.com",
|
||||
trustLevel: "official" as const,
|
||||
status: "active" as const,
|
||||
configurationSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enableAdvancedBlocks: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
description: "Enable advanced control flow blocks",
|
||||
},
|
||||
wizardInterface: {
|
||||
type: "string",
|
||||
enum: ["basic", "advanced"],
|
||||
default: "basic",
|
||||
description: "Wizard interface complexity level",
|
||||
},
|
||||
},
|
||||
},
|
||||
actionDefinitions: [
|
||||
// Event Blocks
|
||||
{
|
||||
id: "when_trial_starts",
|
||||
name: "when trial starts",
|
||||
description: "Triggered when the trial begins",
|
||||
category: "logic",
|
||||
icon: "Play",
|
||||
timeout: 0,
|
||||
retryable: false,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
blockType: "hat",
|
||||
color: "#22c55e",
|
||||
nestable: false,
|
||||
},
|
||||
{
|
||||
id: "when_participant_speaks",
|
||||
name: "when participant speaks",
|
||||
description: "Triggered when participant says something",
|
||||
category: "logic",
|
||||
icon: "Mic",
|
||||
timeout: 0,
|
||||
retryable: false,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
keywords: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
default: [],
|
||||
description: "Optional keywords to listen for",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
blockType: "hat",
|
||||
color: "#22c55e",
|
||||
nestable: false,
|
||||
},
|
||||
|
||||
// Wizard Actions
|
||||
{
|
||||
id: "wizard_say",
|
||||
name: "say",
|
||||
description: "Wizard speaks to participant",
|
||||
category: "interaction",
|
||||
icon: "Users",
|
||||
timeout: 30000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
default: "",
|
||||
description: "What should the wizard say?",
|
||||
},
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
blockType: "action",
|
||||
color: "#a855f7",
|
||||
nestable: false,
|
||||
},
|
||||
{
|
||||
id: "wizard_gesture",
|
||||
name: "gesture",
|
||||
description: "Wizard performs a gesture",
|
||||
category: "interaction",
|
||||
icon: "Users",
|
||||
timeout: 10000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["wave", "point", "nod", "thumbs_up", "clap"],
|
||||
default: "wave",
|
||||
description: "Type of gesture to perform",
|
||||
},
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
blockType: "action",
|
||||
color: "#a855f7",
|
||||
nestable: false,
|
||||
},
|
||||
{
|
||||
id: "wizard_note",
|
||||
name: "take note",
|
||||
description: "Wizard records an observation",
|
||||
category: "sensors",
|
||||
icon: "FileText",
|
||||
timeout: 15000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
category: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"behavior",
|
||||
"performance",
|
||||
"engagement",
|
||||
"technical",
|
||||
"other",
|
||||
],
|
||||
default: "behavior",
|
||||
description: "Category of observation",
|
||||
},
|
||||
note: {
|
||||
type: "string",
|
||||
default: "",
|
||||
description: "Observation details",
|
||||
},
|
||||
},
|
||||
required: ["note"],
|
||||
},
|
||||
blockType: "action",
|
||||
color: "#f59e0b",
|
||||
nestable: false,
|
||||
},
|
||||
|
||||
// Control Flow
|
||||
{
|
||||
id: "wait",
|
||||
name: "wait",
|
||||
description: "Pause execution for specified time",
|
||||
category: "logic",
|
||||
icon: "Clock",
|
||||
timeout: 0,
|
||||
retryable: false,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
seconds: {
|
||||
type: "number",
|
||||
minimum: 0.1,
|
||||
maximum: 300,
|
||||
default: 1,
|
||||
description: "Time to wait in seconds",
|
||||
},
|
||||
},
|
||||
required: ["seconds"],
|
||||
},
|
||||
blockType: "action",
|
||||
color: "#f97316",
|
||||
nestable: false,
|
||||
},
|
||||
{
|
||||
id: "repeat",
|
||||
name: "repeat",
|
||||
description: "Execute contained blocks multiple times",
|
||||
category: "logic",
|
||||
icon: "GitBranch",
|
||||
timeout: 0,
|
||||
retryable: false,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
times: {
|
||||
type: "number",
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
default: 3,
|
||||
description: "Number of times to repeat",
|
||||
},
|
||||
},
|
||||
required: ["times"],
|
||||
},
|
||||
blockType: "control",
|
||||
color: "#f97316",
|
||||
nestable: true,
|
||||
},
|
||||
{
|
||||
id: "if_condition",
|
||||
name: "if",
|
||||
description: "Conditional execution based on conditions",
|
||||
category: "logic",
|
||||
icon: "GitBranch",
|
||||
timeout: 0,
|
||||
retryable: false,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
condition: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"participant_speaks",
|
||||
"time_elapsed",
|
||||
"wizard_signal",
|
||||
"custom_condition",
|
||||
],
|
||||
default: "participant_speaks",
|
||||
description: "Condition to evaluate",
|
||||
},
|
||||
value: {
|
||||
type: "string",
|
||||
default: "",
|
||||
description: "Value to compare against (if applicable)",
|
||||
},
|
||||
},
|
||||
required: ["condition"],
|
||||
},
|
||||
blockType: "control",
|
||||
color: "#f97316",
|
||||
nestable: true,
|
||||
},
|
||||
{
|
||||
id: "parallel",
|
||||
name: "do together",
|
||||
description: "Execute multiple blocks simultaneously",
|
||||
category: "logic",
|
||||
icon: "Layers",
|
||||
timeout: 0,
|
||||
retryable: false,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
blockType: "control",
|
||||
color: "#f97316",
|
||||
nestable: true,
|
||||
},
|
||||
|
||||
// Data Collection
|
||||
{
|
||||
id: "start_recording",
|
||||
name: "start recording",
|
||||
description: "Begin recording specified data streams",
|
||||
category: "sensors",
|
||||
icon: "Circle",
|
||||
timeout: 5000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
streams: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"video",
|
||||
"audio",
|
||||
"screen",
|
||||
"robot_data",
|
||||
"wizard_actions",
|
||||
],
|
||||
},
|
||||
default: ["video", "audio"],
|
||||
description: "Data streams to record",
|
||||
},
|
||||
quality: {
|
||||
type: "string",
|
||||
enum: ["low", "medium", "high"],
|
||||
default: "medium",
|
||||
description: "Recording quality",
|
||||
},
|
||||
},
|
||||
required: ["streams"],
|
||||
},
|
||||
blockType: "action",
|
||||
color: "#dc2626",
|
||||
nestable: false,
|
||||
},
|
||||
{
|
||||
id: "stop_recording",
|
||||
name: "stop recording",
|
||||
description: "Stop recording and save data",
|
||||
category: "sensors",
|
||||
icon: "Square",
|
||||
timeout: 10000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
save_location: {
|
||||
type: "string",
|
||||
default: "default",
|
||||
description: "Where to save the recording",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
blockType: "action",
|
||||
color: "#dc2626",
|
||||
nestable: false,
|
||||
},
|
||||
{
|
||||
id: "mark_event",
|
||||
name: "mark event",
|
||||
description: "Add a timestamped marker to the data",
|
||||
category: "sensors",
|
||||
icon: "MapPin",
|
||||
timeout: 1000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
event_name: {
|
||||
type: "string",
|
||||
default: "",
|
||||
description: "Name of the event to mark",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
default: "",
|
||||
description: "Optional event description",
|
||||
},
|
||||
},
|
||||
required: ["event_name"],
|
||||
},
|
||||
blockType: "action",
|
||||
color: "#f59e0b",
|
||||
nestable: false,
|
||||
},
|
||||
|
||||
// Study Flow Control
|
||||
{
|
||||
id: "show_instructions",
|
||||
name: "show instructions",
|
||||
description: "Display instructions to the participant",
|
||||
category: "interaction",
|
||||
icon: "FileText",
|
||||
timeout: 60000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
default: "Instructions",
|
||||
description: "Instruction title",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
default: "",
|
||||
description: "Instruction content (supports markdown)",
|
||||
},
|
||||
require_acknowledgment: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
description: "Require participant to acknowledge reading",
|
||||
},
|
||||
},
|
||||
required: ["content"],
|
||||
},
|
||||
blockType: "action",
|
||||
color: "#3b82f6",
|
||||
nestable: false,
|
||||
},
|
||||
{
|
||||
id: "collect_response",
|
||||
name: "collect response",
|
||||
description: "Collect a response from the participant",
|
||||
category: "sensors",
|
||||
icon: "MessageCircle",
|
||||
timeout: 120000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
question: {
|
||||
type: "string",
|
||||
default: "",
|
||||
description: "Question to ask the participant",
|
||||
},
|
||||
response_type: {
|
||||
type: "string",
|
||||
enum: ["text", "scale", "choice", "voice"],
|
||||
default: "text",
|
||||
description: "Type of response to collect",
|
||||
},
|
||||
options: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
default: [],
|
||||
description: "Options for choice responses",
|
||||
},
|
||||
},
|
||||
required: ["question"],
|
||||
},
|
||||
blockType: "action",
|
||||
color: "#8b5cf6",
|
||||
nestable: false,
|
||||
},
|
||||
],
|
||||
createdAt: new Date("2024-01-01T00:00:00"),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await db.insert(schema.plugins).values([corePlugin]);
|
||||
console.log("✅ Created core system plugin");
|
||||
}
|
||||
|
||||
async function seedCoreStudyPlugins() {
|
||||
console.log("🔗 Installing core plugin in all studies...");
|
||||
|
||||
// Get all studies
|
||||
const studies = await db.select().from(schema.studies);
|
||||
|
||||
if (studies.length === 0) {
|
||||
console.log("⚠️ No studies found. Please run basic seeding first.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if core plugin installations already exist
|
||||
const existingInstallation = await db
|
||||
.select()
|
||||
.from(schema.studyPlugins)
|
||||
.where(
|
||||
eq(schema.studyPlugins.pluginId, "00000000-0000-0000-0000-000000000001"),
|
||||
);
|
||||
|
||||
if (existingInstallation.length > 0) {
|
||||
console.log("⚠️ Core plugin already installed in studies, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const coreInstallations = studies.map((study, index) => ({
|
||||
id: `00000000-0000-0000-0000-00000000000${index + 2}`, // Start from 2 to avoid conflicts
|
||||
studyId: study.id,
|
||||
pluginId: "00000000-0000-0000-0000-000000000001",
|
||||
configuration: {
|
||||
enableAdvancedBlocks: true,
|
||||
wizardInterface: "advanced",
|
||||
recordingDefaults: {
|
||||
video: true,
|
||||
audio: true,
|
||||
quality: "high",
|
||||
},
|
||||
},
|
||||
installedAt: new Date("2024-01-01T00:00:00"),
|
||||
installedBy: study.createdBy,
|
||||
}));
|
||||
|
||||
await db.insert(schema.studyPlugins).values(coreInstallations);
|
||||
console.log(`✅ Installed core plugin in ${studies.length} studies`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log("🏗️ HRIStudio Core System Seeding Started");
|
||||
console.log("📍 Database:", connectionString.replace(/:[^:]*@/, ":***@"));
|
||||
|
||||
await seedCoreRepository();
|
||||
await seedCorePlugin();
|
||||
await seedCoreStudyPlugins();
|
||||
|
||||
console.log("✅ Core system seeding completed successfully!");
|
||||
console.log("\n📋 Core System Summary:");
|
||||
console.log(" 🏗️ Core Repository: 1 (HRIStudio Core System)");
|
||||
console.log(" 🧱 Core Plugin: 1 (with 15 essential blocks)");
|
||||
console.log(" 🔗 Study Installations: Installed in all studies");
|
||||
console.log("\n🧱 Core Blocks Available:");
|
||||
console.log(" 🎯 Events: when trial starts, when participant speaks");
|
||||
console.log(" 🧙 Wizard: say, gesture, take note");
|
||||
console.log(" ⏳ Control: wait, repeat, if condition, do together");
|
||||
console.log(" 📊 Data: start/stop recording, mark event");
|
||||
console.log(" 📋 Study: show instructions, collect response");
|
||||
console.log("\n🎨 Block Designer Integration:");
|
||||
console.log(" • All core blocks now come from the plugin system");
|
||||
console.log(" • Consistent with robot plugin architecture");
|
||||
console.log(" • Easy to extend and version core functionality");
|
||||
console.log(" • Unified block management across all categories");
|
||||
console.log("\n🚀 Ready to test unified block system!");
|
||||
} catch (error) {
|
||||
console.error("❌ Core system seeding failed:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
1231
scripts/seed-dev.ts
1231
scripts/seed-dev.ts
File diff suppressed because it is too large
Load Diff
@@ -1,690 +0,0 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../src/server/db/schema";
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://postgres:password@localhost:5140/hristudio";
|
||||
const client = postgres(connectionString);
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
async function seedRobots() {
|
||||
console.log("🤖 Seeding robots...");
|
||||
|
||||
// Check if robots already exist
|
||||
const existingRobots = await db.select().from(schema.robots);
|
||||
if (existingRobots.length > 0) {
|
||||
console.log(
|
||||
`⚠️ ${existingRobots.length} robots already exist, skipping robot seeding`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const robots = [
|
||||
{
|
||||
id: "31234567-89ab-cdef-0123-456789abcde1",
|
||||
name: "TurtleBot3 Burger",
|
||||
manufacturer: "ROBOTIS",
|
||||
model: "TurtleBot3 Burger",
|
||||
description:
|
||||
"A compact, affordable, programmable, ROS2-based mobile robot for education and research",
|
||||
capabilities: [
|
||||
"differential_drive",
|
||||
"lidar",
|
||||
"imu",
|
||||
"odometry",
|
||||
"autonomous_navigation",
|
||||
],
|
||||
communicationProtocol: "ros2" as const,
|
||||
createdAt: new Date("2024-01-01T00:00:00"),
|
||||
updatedAt: new Date("2024-01-01T00:00:00"),
|
||||
},
|
||||
{
|
||||
id: "31234567-89ab-cdef-0123-456789abcde2",
|
||||
name: "NAO Humanoid Robot",
|
||||
manufacturer: "SoftBank Robotics",
|
||||
model: "NAO v6",
|
||||
description:
|
||||
"Autonomous, programmable humanoid robot designed for education, research, and human-robot interaction studies",
|
||||
capabilities: [
|
||||
"bipedal_walking",
|
||||
"speech_synthesis",
|
||||
"speech_recognition",
|
||||
"computer_vision",
|
||||
"gestures",
|
||||
"led_control",
|
||||
"touch_sensors",
|
||||
],
|
||||
communicationProtocol: "custom" as const,
|
||||
createdAt: new Date("2024-01-01T00:00:00"),
|
||||
updatedAt: new Date("2024-01-01T00:00:00"),
|
||||
},
|
||||
{
|
||||
id: "31234567-89ab-cdef-0123-456789abcde3",
|
||||
name: "TurtleBot3 Waffle Pi",
|
||||
manufacturer: "ROBOTIS",
|
||||
model: "TurtleBot3 Waffle Pi",
|
||||
description:
|
||||
"Extended TurtleBot3 platform with additional sensors and computing power for advanced research applications",
|
||||
capabilities: [
|
||||
"differential_drive",
|
||||
"lidar",
|
||||
"imu",
|
||||
"odometry",
|
||||
"camera",
|
||||
"manipulation",
|
||||
"autonomous_navigation",
|
||||
],
|
||||
communicationProtocol: "ros2" as const,
|
||||
createdAt: new Date("2024-01-01T00:00:00"),
|
||||
updatedAt: new Date("2024-01-01T00:00:00"),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(schema.robots).values(robots);
|
||||
console.log(`✅ Created ${robots.length} robots`);
|
||||
}
|
||||
|
||||
async function seedPluginRepositories() {
|
||||
console.log("📦 Seeding plugin repositories...");
|
||||
|
||||
// Check if repositories already exist
|
||||
const existingRepos = await db.select().from(schema.pluginRepositories);
|
||||
if (existingRepos.length > 0) {
|
||||
console.log(
|
||||
`⚠️ ${existingRepos.length} plugin repositories already exist, skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the first user to use as creator
|
||||
const users = await db.select().from(schema.users);
|
||||
const adminUser =
|
||||
users.find((u) => u.email?.includes("sean@soconnor.dev")) ?? users[0];
|
||||
|
||||
if (!adminUser) {
|
||||
console.log("⚠️ No users found. Please run basic seeding first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const repositories = [
|
||||
{
|
||||
id: "41234567-89ab-cdef-0123-456789abcde1",
|
||||
name: "HRIStudio Official Robot Plugins",
|
||||
url: "https://repo.hristudio.com",
|
||||
description:
|
||||
"Official collection of robot plugins maintained by the HRIStudio team",
|
||||
trustLevel: "official" as const,
|
||||
isEnabled: true,
|
||||
isOfficial: true,
|
||||
lastSyncAt: new Date("2024-01-10T12:00:00"),
|
||||
syncStatus: "completed" as const,
|
||||
syncError: null,
|
||||
metadata: {
|
||||
apiVersion: "1.0",
|
||||
pluginApiVersion: "1.0",
|
||||
categories: [
|
||||
"mobile-robots",
|
||||
"humanoid-robots",
|
||||
"manipulators",
|
||||
"drones",
|
||||
],
|
||||
compatibility: {
|
||||
hristudio: { min: "0.1.0", recommended: "0.1.0" },
|
||||
ros2: { distributions: ["humble", "iron"], recommended: "iron" },
|
||||
},
|
||||
},
|
||||
createdAt: new Date("2024-01-01T00:00:00"),
|
||||
updatedAt: new Date("2024-01-10T12:00:00"),
|
||||
createdBy: adminUser.id,
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(schema.pluginRepositories).values(repositories);
|
||||
console.log(`✅ Created ${repositories.length} plugin repositories`);
|
||||
}
|
||||
|
||||
async function seedPlugins() {
|
||||
console.log("🔌 Seeding robot plugins...");
|
||||
|
||||
// Check if plugins already exist
|
||||
const existingPlugins = await db.select().from(schema.plugins);
|
||||
if (existingPlugins.length > 0) {
|
||||
console.log(
|
||||
`⚠️ ${existingPlugins.length} plugins already exist, skipping plugin seeding`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const plugins = [
|
||||
{
|
||||
id: "51234567-89ab-cdef-0123-456789abcde1",
|
||||
robotId: "31234567-89ab-cdef-0123-456789abcde1",
|
||||
name: "TurtleBot3 Burger",
|
||||
version: "2.0.0",
|
||||
description:
|
||||
"A compact, affordable, programmable, ROS2-based mobile robot for education and research",
|
||||
author: "ROBOTIS",
|
||||
repositoryUrl: "https://repo.hristudio.com",
|
||||
trustLevel: "official" as const,
|
||||
status: "active" as const,
|
||||
configurationSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
namespace: { type: "string", default: "turtlebot3" },
|
||||
topics: {
|
||||
type: "object",
|
||||
properties: {
|
||||
cmd_vel: { type: "string", default: "/cmd_vel" },
|
||||
odom: { type: "string", default: "/odom" },
|
||||
scan: { type: "string", default: "/scan" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
actionDefinitions: [
|
||||
{
|
||||
id: "move_velocity",
|
||||
name: "Set Velocity",
|
||||
description: "Control the robot's linear and angular velocity",
|
||||
category: "movement",
|
||||
icon: "navigation",
|
||||
timeout: 30000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
linear: {
|
||||
type: "number",
|
||||
minimum: -0.22,
|
||||
maximum: 0.22,
|
||||
default: 0,
|
||||
description: "Forward/backward velocity in m/s",
|
||||
},
|
||||
angular: {
|
||||
type: "number",
|
||||
minimum: -2.84,
|
||||
maximum: 2.84,
|
||||
default: 0,
|
||||
description: "Rotational velocity in rad/s",
|
||||
},
|
||||
},
|
||||
required: ["linear", "angular"],
|
||||
},
|
||||
ros2: {
|
||||
messageType: "geometry_msgs/msg/Twist",
|
||||
topic: "/cmd_vel",
|
||||
payloadMapping: {
|
||||
type: "transform",
|
||||
transformFn: "transformToTwist",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "move_to_pose",
|
||||
name: "Navigate to Position",
|
||||
description:
|
||||
"Navigate to a specific position on the map using autonomous navigation",
|
||||
category: "movement",
|
||||
icon: "target",
|
||||
timeout: 120000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
x: {
|
||||
type: "number",
|
||||
default: 0,
|
||||
description: "X coordinate in meters",
|
||||
},
|
||||
y: {
|
||||
type: "number",
|
||||
default: 0,
|
||||
description: "Y coordinate in meters",
|
||||
},
|
||||
theta: {
|
||||
type: "number",
|
||||
default: 0,
|
||||
description: "Final orientation in radians",
|
||||
},
|
||||
},
|
||||
required: ["x", "y", "theta"],
|
||||
},
|
||||
ros2: {
|
||||
messageType: "geometry_msgs/msg/PoseStamped",
|
||||
action: "/navigate_to_pose",
|
||||
payloadMapping: {
|
||||
type: "transform",
|
||||
transformFn: "transformToPoseStamped",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "stop_robot",
|
||||
name: "Stop Robot",
|
||||
description: "Immediately stop all robot movement",
|
||||
category: "movement",
|
||||
icon: "square",
|
||||
timeout: 5000,
|
||||
retryable: false,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
ros2: {
|
||||
messageType: "geometry_msgs/msg/Twist",
|
||||
topic: "/cmd_vel",
|
||||
payloadMapping: {
|
||||
type: "static",
|
||||
payload: {
|
||||
linear: { x: 0.0, y: 0.0, z: 0.0 },
|
||||
angular: { x: 0.0, y: 0.0, z: 0.0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
createdAt: new Date("2024-01-01T00:00:00"),
|
||||
updatedAt: new Date("2024-01-10T12:00:00"),
|
||||
},
|
||||
{
|
||||
id: "51234567-89ab-cdef-0123-456789abcde2",
|
||||
robotId: "31234567-89ab-cdef-0123-456789abcde2",
|
||||
name: "NAO Humanoid Robot",
|
||||
version: "1.0.0",
|
||||
description:
|
||||
"Autonomous, programmable humanoid robot designed for education, research, and human-robot interaction studies",
|
||||
author: "SoftBank Robotics",
|
||||
repositoryUrl: "https://repo.hristudio.com",
|
||||
trustLevel: "verified" as const,
|
||||
status: "active" as const,
|
||||
configurationSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ip: { type: "string", default: "nao.local" },
|
||||
port: { type: "number", default: 9559 },
|
||||
modules: {
|
||||
type: "array",
|
||||
default: [
|
||||
"ALMotion",
|
||||
"ALTextToSpeech",
|
||||
"ALAnimationPlayer",
|
||||
"ALLeds",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
actionDefinitions: [
|
||||
{
|
||||
id: "say_text",
|
||||
name: "Say Text",
|
||||
description: "Make the robot speak using text-to-speech",
|
||||
category: "interaction",
|
||||
icon: "volume-2",
|
||||
timeout: 15000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
text: {
|
||||
type: "string",
|
||||
default: "Hello, I am NAO!",
|
||||
description: "Text to speak",
|
||||
},
|
||||
volume: {
|
||||
type: "number",
|
||||
minimum: 0.1,
|
||||
maximum: 1.0,
|
||||
default: 0.7,
|
||||
description: "Speech volume (0.1 to 1.0)",
|
||||
},
|
||||
},
|
||||
required: ["text"],
|
||||
},
|
||||
naoqi: {
|
||||
module: "ALTextToSpeech",
|
||||
method: "say",
|
||||
parameters: ["text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "play_animation",
|
||||
name: "Play Animation",
|
||||
description: "Play a predefined animation or gesture",
|
||||
category: "interaction",
|
||||
icon: "zap",
|
||||
timeout: 20000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
animation: {
|
||||
type: "string",
|
||||
enum: ["Hello", "Goodbye", "Excited", "Thinking"],
|
||||
default: "Hello",
|
||||
description: "Animation to play",
|
||||
},
|
||||
},
|
||||
required: ["animation"],
|
||||
},
|
||||
naoqi: {
|
||||
module: "ALAnimationPlayer",
|
||||
method: "run",
|
||||
parameters: ["animations/Stand/Gestures/{animation}"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "walk_to_position",
|
||||
name: "Walk to Position",
|
||||
description:
|
||||
"Walk to a specific position relative to current location",
|
||||
category: "movement",
|
||||
icon: "footprints",
|
||||
timeout: 30000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
x: {
|
||||
type: "number",
|
||||
minimum: -2.0,
|
||||
maximum: 2.0,
|
||||
default: 0.5,
|
||||
description: "Forward distance in meters",
|
||||
},
|
||||
y: {
|
||||
type: "number",
|
||||
minimum: -1.0,
|
||||
maximum: 1.0,
|
||||
default: 0.0,
|
||||
description: "Sideways distance in meters (left is positive)",
|
||||
},
|
||||
theta: {
|
||||
type: "number",
|
||||
minimum: -3.14159,
|
||||
maximum: 3.14159,
|
||||
default: 0.0,
|
||||
description: "Turn angle in radians",
|
||||
},
|
||||
},
|
||||
required: ["x", "y", "theta"],
|
||||
},
|
||||
naoqi: {
|
||||
module: "ALMotion",
|
||||
method: "walkTo",
|
||||
parameters: ["x", "y", "theta"],
|
||||
},
|
||||
},
|
||||
],
|
||||
createdAt: new Date("2024-01-01T00:00:00"),
|
||||
updatedAt: new Date("2024-01-10T12:00:00"),
|
||||
},
|
||||
{
|
||||
id: "51234567-89ab-cdef-0123-456789abcde3",
|
||||
robotId: "31234567-89ab-cdef-0123-456789abcde3",
|
||||
name: "TurtleBot3 Waffle Pi",
|
||||
version: "2.0.0",
|
||||
description:
|
||||
"Extended TurtleBot3 platform with additional sensors and computing power for advanced research applications",
|
||||
author: "ROBOTIS",
|
||||
repositoryUrl: "https://repo.hristudio.com",
|
||||
trustLevel: "official" as const,
|
||||
status: "active" as const,
|
||||
configurationSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
namespace: { type: "string", default: "turtlebot3" },
|
||||
topics: {
|
||||
type: "object",
|
||||
properties: {
|
||||
cmd_vel: { type: "string", default: "/cmd_vel" },
|
||||
odom: { type: "string", default: "/odom" },
|
||||
scan: { type: "string", default: "/scan" },
|
||||
camera: { type: "string", default: "/camera/image_raw" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
actionDefinitions: [
|
||||
{
|
||||
id: "move_velocity",
|
||||
name: "Set Velocity",
|
||||
description: "Control the robot's linear and angular velocity",
|
||||
category: "movement",
|
||||
icon: "navigation",
|
||||
timeout: 30000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
linear: {
|
||||
type: "number",
|
||||
minimum: -0.26,
|
||||
maximum: 0.26,
|
||||
default: 0,
|
||||
description: "Forward/backward velocity in m/s",
|
||||
},
|
||||
angular: {
|
||||
type: "number",
|
||||
minimum: -1.82,
|
||||
maximum: 1.82,
|
||||
default: 0,
|
||||
description: "Rotational velocity in rad/s",
|
||||
},
|
||||
},
|
||||
required: ["linear", "angular"],
|
||||
},
|
||||
ros2: {
|
||||
messageType: "geometry_msgs/msg/Twist",
|
||||
topic: "/cmd_vel",
|
||||
payloadMapping: {
|
||||
type: "transform",
|
||||
transformFn: "transformToTwist",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "capture_image",
|
||||
name: "Capture Image",
|
||||
description: "Capture an image from the robot's camera",
|
||||
category: "sensors",
|
||||
icon: "camera",
|
||||
timeout: 10000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
filename: {
|
||||
type: "string",
|
||||
default: "image_{timestamp}.jpg",
|
||||
description: "Filename for the captured image",
|
||||
},
|
||||
quality: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 85,
|
||||
description: "JPEG quality (1-100)",
|
||||
},
|
||||
},
|
||||
required: ["filename"],
|
||||
},
|
||||
ros2: {
|
||||
messageType: "sensor_msgs/msg/Image",
|
||||
topic: "/camera/image_raw",
|
||||
payloadMapping: {
|
||||
type: "transform",
|
||||
transformFn: "captureAndSaveImage",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "scan_environment",
|
||||
name: "Scan Environment",
|
||||
description:
|
||||
"Perform a 360-degree scan of the environment using LIDAR",
|
||||
category: "sensors",
|
||||
icon: "radar",
|
||||
timeout: 15000,
|
||||
retryable: true,
|
||||
parameterSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
duration: {
|
||||
type: "number",
|
||||
minimum: 1.0,
|
||||
maximum: 10.0,
|
||||
default: 3.0,
|
||||
description: "Scan duration in seconds",
|
||||
},
|
||||
save_data: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
description: "Save scan data to file",
|
||||
},
|
||||
},
|
||||
required: ["duration"],
|
||||
},
|
||||
ros2: {
|
||||
messageType: "sensor_msgs/msg/LaserScan",
|
||||
topic: "/scan",
|
||||
payloadMapping: {
|
||||
type: "transform",
|
||||
transformFn: "collectLaserScan",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
createdAt: new Date("2024-01-01T00:00:00"),
|
||||
updatedAt: new Date("2024-01-10T12:00:00"),
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(schema.plugins).values(plugins);
|
||||
console.log(`✅ Created ${plugins.length} robot plugins`);
|
||||
}
|
||||
|
||||
async function seedStudyPlugins() {
|
||||
console.log("🔌 Seeding study plugin installations...");
|
||||
|
||||
// Check if study plugins already exist
|
||||
const existingStudyPlugins = await db.select().from(schema.studyPlugins);
|
||||
if (existingStudyPlugins.length > 0) {
|
||||
console.log(
|
||||
`⚠️ ${existingStudyPlugins.length} study plugin installations already exist, skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get study IDs from the existing studies
|
||||
const studies = await db.select().from(schema.studies);
|
||||
|
||||
if (studies.length === 0) {
|
||||
console.log("⚠️ No studies found. Please run basic seeding first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const studyPlugins = [
|
||||
{
|
||||
id: "61234567-89ab-cdef-0123-456789abcde1",
|
||||
studyId: studies[0]!.id, // First study (navigation)
|
||||
pluginId: "51234567-89ab-cdef-0123-456789abcde1", // TurtleBot3 Burger
|
||||
configuration: {
|
||||
namespace: "navigation_study",
|
||||
topics: {
|
||||
cmd_vel: "/navigation_study/cmd_vel",
|
||||
odom: "/navigation_study/odom",
|
||||
scan: "/navigation_study/scan",
|
||||
},
|
||||
max_speed: 0.15,
|
||||
safety_distance: 0.3,
|
||||
},
|
||||
installedAt: new Date("2024-01-05T10:00:00"),
|
||||
installedBy: studies[0]!.createdBy,
|
||||
},
|
||||
{
|
||||
id: "61234567-89ab-cdef-0123-456789abcde2",
|
||||
studyId: studies[1]?.id ?? studies[0]!.id, // Second study (social robots) or fallback
|
||||
pluginId: "51234567-89ab-cdef-0123-456789abcde2", // NAO Humanoid
|
||||
configuration: {
|
||||
ip: "192.168.1.100",
|
||||
port: 9559,
|
||||
modules: [
|
||||
"ALMotion",
|
||||
"ALTextToSpeech",
|
||||
"ALAnimationPlayer",
|
||||
"ALLeds",
|
||||
"ALSpeechRecognition",
|
||||
],
|
||||
language: "English",
|
||||
speech_speed: 100,
|
||||
volume: 0.8,
|
||||
},
|
||||
installedAt: new Date("2024-01-05T11:00:00"),
|
||||
installedBy: studies[1]?.createdBy ?? studies[0]!.createdBy,
|
||||
},
|
||||
{
|
||||
id: "61234567-89ab-cdef-0123-456789abcde3",
|
||||
studyId: studies[0]!.id, // First study also gets Waffle for advanced tasks
|
||||
pluginId: "51234567-89ab-cdef-0123-456789abcde3", // TurtleBot3 Waffle
|
||||
configuration: {
|
||||
namespace: "advanced_navigation",
|
||||
topics: {
|
||||
cmd_vel: "/advanced_navigation/cmd_vel",
|
||||
odom: "/advanced_navigation/odom",
|
||||
scan: "/advanced_navigation/scan",
|
||||
camera: "/advanced_navigation/camera/image_raw",
|
||||
},
|
||||
max_speed: 0.2,
|
||||
camera_enabled: true,
|
||||
lidar_enabled: true,
|
||||
},
|
||||
installedAt: new Date("2024-01-05T12:00:00"),
|
||||
installedBy: studies[0]!.createdBy,
|
||||
},
|
||||
];
|
||||
|
||||
await db.insert(schema.studyPlugins).values(studyPlugins);
|
||||
console.log(`✅ Created ${studyPlugins.length} study plugin installations`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log("🔌 HRIStudio Plugin System Seeding Started");
|
||||
console.log("📍 Database:", connectionString.replace(/:[^:]*@/, ":***@"));
|
||||
|
||||
await seedRobots();
|
||||
await seedPluginRepositories();
|
||||
await seedPlugins();
|
||||
await seedStudyPlugins();
|
||||
|
||||
console.log("✅ Plugin system seeding completed successfully!");
|
||||
console.log("\n📋 Plugin System Summary:");
|
||||
console.log(" 🤖 Robots: 3 (TurtleBot3 Burger, NAO, TurtleBot3 Waffle)");
|
||||
console.log(" 📦 Plugin Repositories: 1 (official HRIStudio repo)");
|
||||
console.log(" 🔌 Robot Plugins: 3 (with complete action definitions)");
|
||||
console.log(" 📱 Study Plugin Installations: 3 (active configurations)");
|
||||
console.log("\n🎯 Plugin Actions Available:");
|
||||
console.log(
|
||||
" 📍 TurtleBot3 Burger: 3 actions (movement, navigation, stop)",
|
||||
);
|
||||
console.log(" 🤖 NAO Humanoid: 3 actions (speech, animations, walking)");
|
||||
console.log(" 📊 TurtleBot3 Waffle: 3 actions (movement, camera, LIDAR)");
|
||||
console.log("\n🧪 Test Plugin Integration:");
|
||||
console.log(" 1. Navigate to any experiment designer");
|
||||
console.log(" 2. Check 'Robot' category in block library");
|
||||
console.log(" 3. Plugin actions should appear alongside core blocks");
|
||||
console.log(" 4. Actions are configured per study installation");
|
||||
console.log("\n🚀 Ready to test robot plugin integration!");
|
||||
} catch (error) {
|
||||
console.error("❌ Plugin seeding failed:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
void main();
|
||||
}
|
||||
@@ -1,729 +0,0 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* HRIStudio Database Seed Script (Simplified)
|
||||
*
|
||||
* This script seeds the database with comprehensive test data for the experiment designer,
|
||||
* using raw SQL to avoid NextAuth import issues.
|
||||
*/
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import postgres from "postgres";
|
||||
|
||||
// Database connection
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://postgres:postgres@localhost:5140/hristudio";
|
||||
const sql = postgres(connectionString);
|
||||
|
||||
console.log("🌱 Starting HRIStudio database seeding...");
|
||||
|
||||
async function clearDatabase() {
|
||||
console.log("🧹 Clearing existing data...");
|
||||
|
||||
// Delete in reverse dependency order
|
||||
await sql`DELETE FROM hs_trial_event`;
|
||||
await sql`DELETE FROM hs_action`;
|
||||
await sql`DELETE FROM hs_step`;
|
||||
await sql`DELETE FROM hs_trial`;
|
||||
await sql`DELETE FROM hs_participant`;
|
||||
await sql`DELETE FROM hs_experiment`;
|
||||
await sql`DELETE FROM hs_study_member`;
|
||||
await sql`DELETE FROM hs_study`;
|
||||
await sql`DELETE FROM hs_user_system_role`;
|
||||
await sql`DELETE FROM hs_user`;
|
||||
|
||||
console.log("✅ Database cleared");
|
||||
}
|
||||
|
||||
async function seedUsers() {
|
||||
console.log("👥 Seeding users...");
|
||||
|
||||
// Hash password "password123" for all test users
|
||||
const hashedPassword = await bcrypt.hash("password123", 12);
|
||||
|
||||
const users = [
|
||||
{
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Dr. Sarah Chen",
|
||||
email: "sarah.chen@university.edu",
|
||||
emailVerified: new Date(),
|
||||
password: hashedPassword,
|
||||
},
|
||||
{
|
||||
id: "550e8400-e29b-41d4-a716-446655440002",
|
||||
name: "Dr. Michael Rodriguez",
|
||||
email: "m.rodriguez@research.org",
|
||||
emailVerified: new Date(),
|
||||
password: hashedPassword,
|
||||
},
|
||||
{
|
||||
id: "550e8400-e29b-41d4-a716-446655440003",
|
||||
name: "Emma Thompson",
|
||||
email: "emma.thompson@university.edu",
|
||||
emailVerified: new Date(),
|
||||
password: hashedPassword,
|
||||
},
|
||||
{
|
||||
id: "550e8400-e29b-41d4-a716-446655440004",
|
||||
name: "Dr. James Wilson",
|
||||
email: "james.wilson@university.edu",
|
||||
emailVerified: new Date(),
|
||||
password: hashedPassword,
|
||||
},
|
||||
];
|
||||
|
||||
for (const user of users) {
|
||||
await sql`
|
||||
INSERT INTO hs_user (id, name, email, email_verified, password, created_at, updated_at)
|
||||
VALUES (${user.id}, ${user.name}, ${user.email}, ${user.emailVerified}, ${user.password}, NOW(), NOW())
|
||||
`;
|
||||
}
|
||||
|
||||
// Add user roles
|
||||
const userRoles = [
|
||||
{
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
role: "administrator",
|
||||
},
|
||||
{
|
||||
userId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
role: "researcher",
|
||||
},
|
||||
{
|
||||
userId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
role: "wizard",
|
||||
},
|
||||
{
|
||||
userId: "550e8400-e29b-41d4-a716-446655440004",
|
||||
role: "observer",
|
||||
},
|
||||
];
|
||||
|
||||
for (const userRole of userRoles) {
|
||||
await sql`
|
||||
INSERT INTO hs_user_system_role (user_id, role, granted_at)
|
||||
VALUES (${userRole.userId}, ${userRole.role}, NOW())
|
||||
`;
|
||||
}
|
||||
|
||||
console.log(`✅ Created ${users.length} users with roles`);
|
||||
}
|
||||
|
||||
async function seedStudies() {
|
||||
console.log("📚 Seeding studies...");
|
||||
|
||||
const studies = [
|
||||
{
|
||||
id: "650e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Robot Navigation Assistance Study",
|
||||
description:
|
||||
"Investigating how robots can effectively assist humans with indoor navigation tasks using multimodal interaction.",
|
||||
institution: "MIT Computer Science",
|
||||
irbProtocolNumber: "IRB-2024-001",
|
||||
status: "active",
|
||||
createdBy: "550e8400-e29b-41d4-a716-446655440002",
|
||||
metadata: {
|
||||
duration: "6 months",
|
||||
targetParticipants: 50,
|
||||
robotPlatform: "TurtleBot3",
|
||||
environment: "Indoor office building",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "650e8400-e29b-41d4-a716-446655440002",
|
||||
name: "Social Robot Interaction Patterns",
|
||||
description:
|
||||
"Exploring how different personality traits in robots affect human-robot collaboration in workplace settings.",
|
||||
institution: "Stanford HCI Lab",
|
||||
irbProtocolNumber: "IRB-2024-002",
|
||||
status: "draft",
|
||||
createdBy: "550e8400-e29b-41d4-a716-446655440002",
|
||||
metadata: {
|
||||
duration: "4 months",
|
||||
targetParticipants: 30,
|
||||
robotPlatform: "Pepper",
|
||||
environment: "Office collaboration space",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "650e8400-e29b-41d4-a716-446655440003",
|
||||
name: "Elderly Care Assistant Robot Study",
|
||||
description:
|
||||
"Evaluating the effectiveness of companion robots in assisted living facilities for elderly residents.",
|
||||
institution: "MIT Computer Science",
|
||||
irbProtocolNumber: "IRB-2024-003",
|
||||
status: "completed",
|
||||
createdBy: "550e8400-e29b-41d4-a716-446655440001",
|
||||
metadata: {
|
||||
duration: "8 months",
|
||||
targetParticipants: 25,
|
||||
robotPlatform: "NAO",
|
||||
environment: "Assisted living facility",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const study of studies) {
|
||||
await sql`
|
||||
INSERT INTO hs_study (id, name, description, institution, irb_protocol, status, created_by, metadata, created_at, updated_at)
|
||||
VALUES (${study.id}, ${study.name}, ${study.description}, ${study.institution}, ${study.irbProtocolNumber}, ${study.status}, ${study.createdBy}, ${JSON.stringify(study.metadata)}, NOW(), NOW())
|
||||
`;
|
||||
}
|
||||
|
||||
// Add study members
|
||||
const studyMembers = [
|
||||
// Navigation Study Team
|
||||
{
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440001",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
role: "owner",
|
||||
},
|
||||
{
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440001",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
role: "wizard",
|
||||
},
|
||||
{
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440001",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440004",
|
||||
role: "observer",
|
||||
},
|
||||
// Social Robots Study Team
|
||||
{
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440002",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
role: "owner",
|
||||
},
|
||||
{
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440002",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
role: "researcher",
|
||||
},
|
||||
// Elderly Care Study Team
|
||||
{
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440003",
|
||||
userId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
role: "owner",
|
||||
},
|
||||
];
|
||||
|
||||
for (const member of studyMembers) {
|
||||
await sql`
|
||||
INSERT INTO hs_study_member (study_id, user_id, role, joined_at)
|
||||
VALUES (${member.studyId}, ${member.userId}, ${member.role}, NOW())
|
||||
`;
|
||||
}
|
||||
|
||||
console.log(`✅ Created ${studies.length} studies with team members`);
|
||||
}
|
||||
|
||||
async function seedExperiments() {
|
||||
console.log("🧪 Seeding experiments...");
|
||||
|
||||
const experiments = [
|
||||
{
|
||||
id: "750e8400-e29b-41d4-a716-446655440001",
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Baseline Navigation Task",
|
||||
description:
|
||||
"Participants navigate independently without robot assistance to establish baseline performance metrics.",
|
||||
version: 1,
|
||||
status: "ready",
|
||||
estimatedDuration: 15,
|
||||
createdBy: "550e8400-e29b-41d4-a716-446655440002",
|
||||
metadata: {
|
||||
condition: "control",
|
||||
environment: "Building A, Floor 2",
|
||||
equipment: ["motion capture", "eye tracker"],
|
||||
instructions: "Find the conference room using only building signs",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "750e8400-e29b-41d4-a716-446655440002",
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Robot-Assisted Navigation",
|
||||
description:
|
||||
"Participants navigate with robot providing verbal and gestural guidance to test effectiveness of robot assistance.",
|
||||
version: 2,
|
||||
status: "testing",
|
||||
estimatedDuration: 20,
|
||||
createdBy: "550e8400-e29b-41d4-a716-446655440002",
|
||||
metadata: {
|
||||
condition: "robot_assistance",
|
||||
environment: "Building A, Floor 2",
|
||||
equipment: ["motion capture", "eye tracker", "TurtleBot3"],
|
||||
instructions: "Follow robot guidance to find the conference room",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "750e8400-e29b-41d4-a716-446655440003",
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440002",
|
||||
name: "Robot Personality Variants",
|
||||
description:
|
||||
"Testing different robot personality types (friendly, professional, neutral) in collaborative tasks.",
|
||||
version: 1,
|
||||
status: "draft",
|
||||
estimatedDuration: 30,
|
||||
createdBy: "550e8400-e29b-41d4-a716-446655440002",
|
||||
metadata: {
|
||||
condition: "personality_comparison",
|
||||
personalities: ["friendly", "professional", "neutral"],
|
||||
tasks: ["document review", "scheduling", "problem solving"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "750e8400-e29b-41d4-a716-446655440004",
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440003",
|
||||
name: "Daily Companion Interaction",
|
||||
description:
|
||||
"Evaluating robot as daily companion for elderly residents including conversation and activity reminders.",
|
||||
version: 3,
|
||||
status: "ready",
|
||||
estimatedDuration: 45,
|
||||
createdBy: "550e8400-e29b-41d4-a716-446655440001",
|
||||
metadata: {
|
||||
condition: "companion_interaction",
|
||||
activities: ["conversation", "medication reminder", "exercise prompts"],
|
||||
duration_days: 14,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const experiment of experiments) {
|
||||
await sql`
|
||||
INSERT INTO hs_experiment (id, study_id, name, description, version, status, estimated_duration, created_by, metadata, created_at, updated_at)
|
||||
VALUES (${experiment.id}, ${experiment.studyId}, ${experiment.name}, ${experiment.description}, ${experiment.version}, ${experiment.status}, ${experiment.estimatedDuration}, ${experiment.createdBy}, ${JSON.stringify(experiment.metadata)}, NOW(), NOW())
|
||||
`;
|
||||
}
|
||||
|
||||
console.log(`✅ Created ${experiments.length} experiments`);
|
||||
}
|
||||
|
||||
async function seedStepsAndActions() {
|
||||
console.log("📋 Seeding experiment steps and actions...");
|
||||
|
||||
// Baseline Navigation Experiment Steps
|
||||
const steps = [
|
||||
{
|
||||
id: "850e8400-e29b-41d4-a716-446655440001",
|
||||
experimentId: "750e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Welcome & Consent",
|
||||
description:
|
||||
"Greet participant, explain study, and obtain informed consent",
|
||||
type: "wizard",
|
||||
orderIndex: 0,
|
||||
durationEstimate: 300,
|
||||
required: true,
|
||||
conditions: {
|
||||
environment: "lab_room",
|
||||
setup: "consent_forms_ready",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "850e8400-e29b-41d4-a716-446655440002",
|
||||
experimentId: "750e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Equipment Setup",
|
||||
description: "Attach motion capture markers and calibrate eye tracker",
|
||||
type: "wizard",
|
||||
orderIndex: 1,
|
||||
durationEstimate: 180,
|
||||
required: true,
|
||||
conditions: {
|
||||
equipment: ["motion_capture", "eye_tracker"],
|
||||
calibration_required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "850e8400-e29b-41d4-a716-446655440003",
|
||||
experimentId: "750e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Task Instructions",
|
||||
description: "Explain navigation task and destination to participant",
|
||||
type: "wizard",
|
||||
orderIndex: 2,
|
||||
durationEstimate: 120,
|
||||
required: true,
|
||||
conditions: {
|
||||
destination: "Conference Room B-201",
|
||||
starting_point: "Building A Lobby",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "850e8400-e29b-41d4-a716-446655440004",
|
||||
experimentId: "750e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Independent Navigation",
|
||||
description:
|
||||
"Participant navigates independently while data is collected",
|
||||
type: "parallel",
|
||||
orderIndex: 3,
|
||||
durationEstimate: 600,
|
||||
required: true,
|
||||
conditions: {
|
||||
data_collection: ["position", "gaze", "time"],
|
||||
assistance: "none",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "850e8400-e29b-41d4-a716-446655440005",
|
||||
experimentId: "750e8400-e29b-41d4-a716-446655440002",
|
||||
name: "Robot Introduction",
|
||||
description:
|
||||
"Robot introduces itself and explains its role as navigation assistant",
|
||||
type: "robot",
|
||||
orderIndex: 0,
|
||||
durationEstimate: 180,
|
||||
required: true,
|
||||
conditions: {
|
||||
robot_behavior: "friendly_introduction",
|
||||
voice_enabled: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "850e8400-e29b-41d4-a716-446655440006",
|
||||
experimentId: "750e8400-e29b-41d4-a716-446655440002",
|
||||
name: "Guided Navigation",
|
||||
description:
|
||||
"Robot provides turn-by-turn navigation guidance with gestures and speech",
|
||||
type: "robot",
|
||||
orderIndex: 1,
|
||||
durationEstimate: 480,
|
||||
required: true,
|
||||
conditions: {
|
||||
guidance_type: "multimodal",
|
||||
gestures: true,
|
||||
speech: true,
|
||||
adaptation: "user_pace",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "850e8400-e29b-41d4-a716-446655440007",
|
||||
experimentId: "750e8400-e29b-41d4-a716-446655440003",
|
||||
name: "Personality Calibration",
|
||||
description:
|
||||
"Robot adjusts behavior based on assigned personality condition",
|
||||
type: "conditional",
|
||||
orderIndex: 0,
|
||||
durationEstimate: 60,
|
||||
required: true,
|
||||
conditions: {
|
||||
personality_variants: ["friendly", "professional", "neutral"],
|
||||
behavior_parameters: {
|
||||
friendly: { warmth: 0.8, formality: 0.3 },
|
||||
professional: { warmth: 0.4, formality: 0.9 },
|
||||
neutral: { warmth: 0.5, formality: 0.5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "850e8400-e29b-41d4-a716-446655440008",
|
||||
experimentId: "750e8400-e29b-41d4-a716-446655440003",
|
||||
name: "Collaborative Task",
|
||||
description: "Human and robot work together on document review task",
|
||||
type: "parallel",
|
||||
orderIndex: 1,
|
||||
durationEstimate: 1200,
|
||||
required: true,
|
||||
conditions: {
|
||||
task_type: "document_review",
|
||||
collaboration_level: "equal_partners",
|
||||
performance_metrics: ["accuracy", "efficiency", "satisfaction"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const step of steps) {
|
||||
await sql`
|
||||
INSERT INTO hs_step (id, experiment_id, name, description, type, order_index, duration_estimate, required, conditions, created_at, updated_at)
|
||||
VALUES (${step.id}, ${step.experimentId}, ${step.name}, ${step.description}, ${step.type}, ${step.orderIndex}, ${step.durationEstimate}, ${step.required}, ${JSON.stringify(step.conditions)}, NOW(), NOW())
|
||||
`;
|
||||
}
|
||||
|
||||
console.log("✅ Created experiment steps");
|
||||
|
||||
// Create actions for each step
|
||||
const actions = [
|
||||
{
|
||||
id: "950e8400-e29b-41d4-a716-446655440001",
|
||||
stepId: "850e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Greet Participant",
|
||||
description: "Welcome participant and introduce research team",
|
||||
type: "wizard_speech",
|
||||
orderIndex: 0,
|
||||
parameters: {
|
||||
script:
|
||||
"Hello! Welcome to our navigation study. I'm [NAME] and I'll be guiding you through today's session.",
|
||||
tone: "friendly_professional",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "950e8400-e29b-41d4-a716-446655440002",
|
||||
stepId: "850e8400-e29b-41d4-a716-446655440001",
|
||||
name: "Explain Study",
|
||||
description: "Provide overview of study purpose and procedures",
|
||||
type: "wizard_speech",
|
||||
orderIndex: 1,
|
||||
parameters: {
|
||||
script:
|
||||
"Today we're studying how people navigate indoor environments. You'll be asked to find a specific location in the building.",
|
||||
documentation_required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "950e8400-e29b-41d4-a716-446655440003",
|
||||
stepId: "850e8400-e29b-41d4-a716-446655440005",
|
||||
name: "Robot Self-Introduction",
|
||||
description: "Robot introduces itself with friendly demeanor",
|
||||
type: "robot_speech",
|
||||
orderIndex: 0,
|
||||
parameters: {
|
||||
text: "Hello! I'm your navigation assistant. My name is Robi and I'm here to help you find your destination.",
|
||||
gesture: "wave",
|
||||
eye_contact: true,
|
||||
voice_parameters: {
|
||||
pitch: 0.7,
|
||||
speed: 0.8,
|
||||
emotion: "friendly",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "950e8400-e29b-41d4-a716-446655440004",
|
||||
stepId: "850e8400-e29b-41d4-a716-446655440006",
|
||||
name: "Start Navigation",
|
||||
description: "Robot begins guiding participant toward destination",
|
||||
type: "robot_movement",
|
||||
orderIndex: 0,
|
||||
parameters: {
|
||||
movement_type: "lead",
|
||||
speed: "slow_human_pace",
|
||||
path_planning: "optimal_with_explanations",
|
||||
safety_distance: 1.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "950e8400-e29b-41d4-a716-446655440005",
|
||||
stepId: "850e8400-e29b-41d4-a716-446655440007",
|
||||
name: "Load Personality Profile",
|
||||
description: "Configure robot behavior based on personality condition",
|
||||
type: "robot_config",
|
||||
orderIndex: 0,
|
||||
parameters: {
|
||||
config_type: "personality_parameters",
|
||||
profiles: {
|
||||
friendly: {
|
||||
greeting_style: "warm",
|
||||
speech_patterns: "casual",
|
||||
gesture_frequency: "high",
|
||||
},
|
||||
professional: {
|
||||
greeting_style: "formal",
|
||||
speech_patterns: "business",
|
||||
gesture_frequency: "moderate",
|
||||
},
|
||||
neutral: {
|
||||
greeting_style: "standard",
|
||||
speech_patterns: "neutral",
|
||||
gesture_frequency: "low",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const action of actions) {
|
||||
await sql`
|
||||
INSERT INTO hs_action (id, step_id, name, description, type, order_index, parameters, created_at, updated_at)
|
||||
VALUES (${action.id}, ${action.stepId}, ${action.name}, ${action.description}, ${action.type}, ${action.orderIndex}, ${JSON.stringify(action.parameters)}, NOW(), NOW())
|
||||
`;
|
||||
}
|
||||
|
||||
console.log(`✅ Created ${actions.length} actions for steps`);
|
||||
}
|
||||
|
||||
async function seedParticipants() {
|
||||
console.log("👤 Seeding participants...");
|
||||
|
||||
const participants = [
|
||||
{
|
||||
id: "a50e8400-e29b-41d4-a716-446655440001",
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440001",
|
||||
participantCode: "NAV001",
|
||||
name: "Alex Johnson",
|
||||
email: "alex.johnson@email.com",
|
||||
demographics: {
|
||||
age: 28,
|
||||
gender: "non-binary",
|
||||
education: "bachelor",
|
||||
tech_experience: "high",
|
||||
robot_experience: "medium",
|
||||
mobility: "none",
|
||||
},
|
||||
consentGiven: true,
|
||||
consentDate: new Date("2024-01-15"),
|
||||
notes: "Interested in robotics, works in tech industry",
|
||||
},
|
||||
{
|
||||
id: "a50e8400-e29b-41d4-a716-446655440002",
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440001",
|
||||
participantCode: "NAV002",
|
||||
name: "Maria Santos",
|
||||
email: "maria.santos@email.com",
|
||||
demographics: {
|
||||
age: 34,
|
||||
gender: "female",
|
||||
education: "master",
|
||||
tech_experience: "medium",
|
||||
robot_experience: "low",
|
||||
mobility: "none",
|
||||
},
|
||||
consentGiven: true,
|
||||
consentDate: new Date("2024-01-16"),
|
||||
notes: "Architecture background, good spatial reasoning",
|
||||
},
|
||||
{
|
||||
id: "a50e8400-e29b-41d4-a716-446655440003",
|
||||
studyId: "650e8400-e29b-41d4-a716-446655440002",
|
||||
participantCode: "SOC001",
|
||||
name: "Jennifer Liu",
|
||||
email: "jennifer.liu@email.com",
|
||||
demographics: {
|
||||
age: 29,
|
||||
gender: "female",
|
||||
education: "bachelor",
|
||||
tech_experience: "medium",
|
||||
robot_experience: "low",
|
||||
work_environment: "office",
|
||||
},
|
||||
consentGiven: true,
|
||||
consentDate: new Date("2024-01-20"),
|
||||
notes: "Project manager, interested in workplace automation",
|
||||
},
|
||||
];
|
||||
|
||||
for (const participant of participants) {
|
||||
await sql`
|
||||
INSERT INTO hs_participant (id, study_id, participant_code, name, email, demographics, consent_given, consent_date, notes, created_at, updated_at)
|
||||
VALUES (${participant.id}, ${participant.studyId}, ${participant.participantCode}, ${participant.name}, ${participant.email}, ${JSON.stringify(participant.demographics)}, ${participant.consentGiven}, ${participant.consentDate}, ${participant.notes}, NOW(), NOW())
|
||||
`;
|
||||
}
|
||||
|
||||
console.log(`✅ Created ${participants.length} participants`);
|
||||
}
|
||||
|
||||
async function seedTrials() {
|
||||
console.log("🎯 Seeding trials...");
|
||||
|
||||
const trials = [
|
||||
{
|
||||
id: "b50e8400-e29b-41d4-a716-446655440001",
|
||||
experimentId: "750e8400-e29b-41d4-a716-446655440001",
|
||||
participantId: "a50e8400-e29b-41d4-a716-446655440001",
|
||||
wizardId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
sessionNumber: 1,
|
||||
status: "completed",
|
||||
scheduledAt: new Date("2024-01-15T10:00:00"),
|
||||
startedAt: new Date("2024-01-15T10:05:00"),
|
||||
completedAt: new Date("2024-01-15T10:20:00"),
|
||||
notes: "Participant completed successfully, good baseline performance",
|
||||
metadata: {
|
||||
condition: "control",
|
||||
completion_time: 893,
|
||||
errors: 1,
|
||||
assistance_requests: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "b50e8400-e29b-41d4-a716-446655440002",
|
||||
experimentId: "750e8400-e29b-41d4-a716-446655440002",
|
||||
participantId: "a50e8400-e29b-41d4-a716-446655440001",
|
||||
wizardId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
sessionNumber: 2,
|
||||
status: "completed",
|
||||
scheduledAt: new Date("2024-01-15T10:30:00"),
|
||||
startedAt: new Date("2024-01-15T10:35:00"),
|
||||
completedAt: new Date("2024-01-15T10:58:00"),
|
||||
notes: "Robot assistance worked well, participant very satisfied",
|
||||
metadata: {
|
||||
condition: "robot_assistance",
|
||||
completion_time: 654,
|
||||
errors: 0,
|
||||
assistance_requests: 2,
|
||||
robot_performance: "excellent",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "b50e8400-e29b-41d4-a716-446655440003",
|
||||
experimentId: "750e8400-e29b-41d4-a716-446655440003",
|
||||
participantId: "a50e8400-e29b-41d4-a716-446655440003",
|
||||
wizardId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
sessionNumber: 1,
|
||||
status: "scheduled",
|
||||
scheduledAt: new Date("2024-01-25T11:00:00"),
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
notes: "Personality condition: friendly",
|
||||
metadata: {
|
||||
condition: "friendly_personality",
|
||||
personality_type: "friendly",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const trial of trials) {
|
||||
await sql`
|
||||
INSERT INTO hs_trial (id, experiment_id, participant_id, wizard_id, session_number, status, scheduled_at, started_at, completed_at, notes, metadata, created_at, updated_at)
|
||||
VALUES (${trial.id}, ${trial.experimentId}, ${trial.participantId}, ${trial.wizardId}, ${trial.sessionNumber}, ${trial.status}, ${trial.scheduledAt}, ${trial.startedAt}, ${trial.completedAt}, ${trial.notes}, ${JSON.stringify(trial.metadata)}, NOW(), NOW())
|
||||
`;
|
||||
}
|
||||
|
||||
console.log(`✅ Created ${trials.length} trials`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log("🚀 HRIStudio Database Seeding Started");
|
||||
console.log("📍 Database:", connectionString.replace(/:[^:]*@/, ":***@"));
|
||||
|
||||
await clearDatabase();
|
||||
await seedUsers();
|
||||
await seedStudies();
|
||||
await seedExperiments();
|
||||
await seedStepsAndActions();
|
||||
await seedParticipants();
|
||||
await seedTrials();
|
||||
|
||||
console.log("✅ Database seeding completed successfully!");
|
||||
console.log("\n📋 Summary:");
|
||||
console.log(" 👥 Users: 4 (admin, researcher, wizard, observer)");
|
||||
console.log(" 📚 Studies: 3 (navigation, social robots, elderly care)");
|
||||
console.log(" 🧪 Experiments: 4 (with comprehensive test scenarios)");
|
||||
console.log(" 📋 Steps: 8 (covering all experiment types)");
|
||||
console.log(" ⚡ Actions: 5 (detailed robot and wizard actions)");
|
||||
console.log(" 👤 Participants: 3 (diverse demographics)");
|
||||
console.log(" 🎯 Trials: 3 (completed, scheduled)");
|
||||
console.log("🔑 Test Login Credentials:");
|
||||
console.log(" Admin: sarah.chen@university.edu / password123");
|
||||
console.log(" Researcher: m.rodriguez@research.org / password123");
|
||||
console.log(" Wizard: emma.thompson@university.edu / password123");
|
||||
console.log(" Observer: james.wilson@university.edu / password123");
|
||||
console.log("\n🧪 Test Experiment Designer with:");
|
||||
console.log(
|
||||
" 📍 /experiments/750e8400-e29b-41d4-a716-446655440001/designer",
|
||||
);
|
||||
console.log(
|
||||
" 📍 /experiments/750e8400-e29b-41d4-a716-446655440002/designer",
|
||||
);
|
||||
console.log(
|
||||
" 📍 /experiments/750e8400-e29b-41d4-a716-446655440003/designer",
|
||||
);
|
||||
console.log("\n🚀 Ready to test the experiment designer!");
|
||||
} catch (error) {
|
||||
console.error("❌ Seeding failed:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the seeding
|
||||
main().catch(console.error);
|
||||
1077
scripts/seed.ts
1077
scripts/seed.ts
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
@@ -28,55 +29,20 @@ import { api } from "~/trpc/react";
|
||||
|
||||
// Dashboard Overview Cards
|
||||
function OverviewCards() {
|
||||
const utils = api.useUtils();
|
||||
|
||||
// Auto-refresh overview data when component mounts to catch external changes
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void utils.studies.list.invalidate();
|
||||
void utils.experiments.getUserExperiments.invalidate();
|
||||
void utils.trials.getUserTrials.invalidate();
|
||||
}, 60000); // Refresh every minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [utils]);
|
||||
|
||||
const { data: studiesData } = api.studies.list.useQuery(
|
||||
{ page: 1, limit: 1 },
|
||||
{
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
);
|
||||
const { data: experimentsData } = api.experiments.getUserExperiments.useQuery(
|
||||
{ page: 1, limit: 1 },
|
||||
{
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
);
|
||||
const { data: trialsData } = api.trials.getUserTrials.useQuery(
|
||||
{ page: 1, limit: 1 },
|
||||
{
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
);
|
||||
// TODO: Fix participants API call - needs actual study ID
|
||||
const participantsData = { pagination: { total: 0 } };
|
||||
const { data: stats, isLoading } = api.dashboard.getStats.useQuery();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Active Studies",
|
||||
value: studiesData?.pagination?.total ?? 0,
|
||||
description: "Research studies in progress",
|
||||
value: stats?.totalStudies ?? 0,
|
||||
description: "Research studies you have access to",
|
||||
icon: Building,
|
||||
color: "text-blue-600",
|
||||
bg: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
title: "Experiments",
|
||||
value: experimentsData?.pagination?.total ?? 0,
|
||||
value: stats?.totalExperiments ?? 0,
|
||||
description: "Experiment protocols designed",
|
||||
icon: FlaskConical,
|
||||
color: "text-green-600",
|
||||
@@ -84,7 +50,7 @@ function OverviewCards() {
|
||||
},
|
||||
{
|
||||
title: "Participants",
|
||||
value: participantsData?.pagination?.total ?? 0,
|
||||
value: stats?.totalParticipants ?? 0,
|
||||
description: "Enrolled participants",
|
||||
icon: Users,
|
||||
color: "text-purple-600",
|
||||
@@ -92,14 +58,33 @@ function OverviewCards() {
|
||||
},
|
||||
{
|
||||
title: "Trials",
|
||||
value: trialsData?.pagination?.total ?? 0,
|
||||
description: "Completed trials",
|
||||
value: stats?.totalTrials ?? 0,
|
||||
description: "Total trials conducted",
|
||||
icon: TestTube,
|
||||
color: "text-orange-600",
|
||||
bg: "bg-orange-50",
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div className="bg-muted h-4 w-20 animate-pulse rounded" />
|
||||
<div className="bg-muted h-8 w-8 animate-pulse rounded" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-muted h-8 w-12 animate-pulse rounded" />
|
||||
<div className="bg-muted mt-2 h-3 w-24 animate-pulse rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
@@ -122,41 +107,10 @@ function OverviewCards() {
|
||||
|
||||
// Recent Activity Component
|
||||
function RecentActivity() {
|
||||
// Mock data - replace with actual API calls
|
||||
const activities = [
|
||||
{
|
||||
id: "1",
|
||||
type: "trial_completed",
|
||||
title: "Trial #142 completed",
|
||||
description: "Memory retention study - Participant P001",
|
||||
time: "2 hours ago",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "experiment_created",
|
||||
title: "New experiment protocol",
|
||||
description: "Social interaction study v2.1",
|
||||
time: "4 hours ago",
|
||||
status: "info",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "participant_enrolled",
|
||||
title: "New participant enrolled",
|
||||
description: "P045 added to cognitive study",
|
||||
time: "6 hours ago",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
type: "trial_started",
|
||||
title: "Trial #143 started",
|
||||
description: "Attention span experiment",
|
||||
time: "8 hours ago",
|
||||
status: "pending",
|
||||
},
|
||||
];
|
||||
const { data: activities = [], isLoading } =
|
||||
api.dashboard.getRecentActivity.useQuery({
|
||||
limit: 8,
|
||||
});
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -180,24 +134,46 @@ function RecentActivity() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-center space-x-4">
|
||||
{getStatusIcon(activity.status)}
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{activity.description}
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center space-x-4">
|
||||
<div className="bg-muted h-4 w-4 animate-pulse rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="bg-muted h-4 w-3/4 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-1/2 animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{activity.time}
|
||||
))}
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<AlertCircle className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
No recent activity
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-center space-x-4">
|
||||
{getStatusIcon(activity.status)}
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{activity.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(activity.time, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -262,33 +238,10 @@ function QuickActions() {
|
||||
|
||||
// Study Progress Component
|
||||
function StudyProgress() {
|
||||
// Mock data - replace with actual API calls
|
||||
const studies = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Cognitive Load Study",
|
||||
progress: 75,
|
||||
participants: 24,
|
||||
totalParticipants: 30,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Social Interaction Research",
|
||||
progress: 45,
|
||||
participants: 18,
|
||||
totalParticipants: 40,
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Memory Retention Analysis",
|
||||
progress: 90,
|
||||
participants: 45,
|
||||
totalParticipants: 50,
|
||||
status: "completing",
|
||||
},
|
||||
];
|
||||
const { data: studies = [], isLoading } =
|
||||
api.dashboard.getStudyProgress.useQuery({
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="col-span-3">
|
||||
@@ -299,31 +252,62 @@ function StudyProgress() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{studies.map((study) => (
|
||||
<div key={study.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{study.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{study.participants}/{study.totalParticipants} participants
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="bg-muted h-4 w-32 animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-24 animate-pulse rounded" />
|
||||
</div>
|
||||
<div className="bg-muted h-5 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
<Badge
|
||||
variant={study.status === "active" ? "default" : "secondary"}
|
||||
>
|
||||
{study.status}
|
||||
</Badge>
|
||||
<div className="bg-muted h-2 w-full animate-pulse rounded" />
|
||||
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
|
||||
</div>
|
||||
<Progress value={study.progress} className="h-2" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{study.progress}% complete
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : studies.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<Building className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
No active studies found
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Create a study to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{studies.map((study) => (
|
||||
<div key={study.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{study.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{study.participants}/{study.totalParticipants} completed
|
||||
trials
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
study.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{study.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress value={study.progress} className="h-2" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{study.progress}% complete
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { EnhancedBlockDesigner } from "~/components/experiments/designer/EnhancedBlockDesigner";
|
||||
import type { ExperimentBlock } from "~/components/experiments/designer/EnhancedBlockDesigner";
|
||||
import { BlockDesigner } from "~/components/experiments/designer/BlockDesigner";
|
||||
import type { ExperimentStep } from "~/lib/experiment-designer/types";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
interface ExperimentDesignerPageProps {
|
||||
@@ -22,19 +22,19 @@ export default async function ExperimentDesignerPage({
|
||||
|
||||
// Parse existing visual design if available
|
||||
const existingDesign = experiment.visualDesign as {
|
||||
blocks?: unknown[];
|
||||
steps?: unknown[];
|
||||
version?: number;
|
||||
lastSaved?: string;
|
||||
} | null;
|
||||
|
||||
// Only pass initialDesign if there's existing visual design data
|
||||
const initialDesign =
|
||||
existingDesign?.blocks && existingDesign.blocks.length > 0
|
||||
existingDesign?.steps && existingDesign.steps.length > 0
|
||||
? {
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
blocks: existingDesign.blocks as ExperimentBlock[],
|
||||
steps: existingDesign.steps as ExperimentStep[],
|
||||
version: existingDesign.version ?? 1,
|
||||
lastSaved:
|
||||
typeof existingDesign.lastSaved === "string"
|
||||
@@ -44,7 +44,7 @@ export default async function ExperimentDesignerPage({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<EnhancedBlockDesigner
|
||||
<BlockDesigner
|
||||
experimentId={experiment.id}
|
||||
initialDesign={initialDesign}
|
||||
/>
|
||||
@@ -66,13 +66,13 @@ export async function generateMetadata({
|
||||
const experiment = await api.experiments.get({ id: resolvedParams.id });
|
||||
|
||||
return {
|
||||
title: `${experiment?.name} - Flow Designer | HRIStudio`,
|
||||
description: `Design experiment protocol for ${experiment?.name} using visual flow editor`,
|
||||
title: `${experiment?.name} - Designer | HRIStudio`,
|
||||
description: `Design experiment protocol for ${experiment?.name} using step-based editor`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
title: "Experiment Flow Designer | HRIStudio",
|
||||
description: "Immersive visual experiment protocol designer",
|
||||
title: "Experiment Designer | HRIStudio",
|
||||
description: "Step-based experiment protocol designer",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ interface DashboardContentProps {
|
||||
completedToday: number;
|
||||
canControl: boolean;
|
||||
canManage: boolean;
|
||||
recentTrials: any[];
|
||||
_recentTrials: unknown[];
|
||||
}
|
||||
|
||||
export function DashboardContent({
|
||||
@@ -24,7 +24,7 @@ export function DashboardContent({
|
||||
completedToday,
|
||||
canControl,
|
||||
canManage,
|
||||
recentTrials,
|
||||
_recentTrials,
|
||||
}: DashboardContentProps) {
|
||||
const getWelcomeMessage = () => {
|
||||
switch (userRole) {
|
||||
@@ -105,7 +105,7 @@ export function DashboardContent({
|
||||
},
|
||||
];
|
||||
|
||||
const alerts: any[] = [];
|
||||
const alerts: never[] = [];
|
||||
|
||||
const recentActivity = null;
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
TestTube,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useSidebar } from "~/components/ui/sidebar";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -27,6 +29,12 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -44,6 +52,8 @@ import { Avatar, AvatarImage, AvatarFallback } from "~/components/ui/avatar";
|
||||
import { Logo } from "~/components/ui/logo";
|
||||
|
||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
// Navigation items
|
||||
const navigationItems = [
|
||||
@@ -103,9 +113,17 @@ export function AppSidebar({
|
||||
const { data: session } = useSession();
|
||||
const pathname = usePathname();
|
||||
const isAdmin = userRole === "administrator";
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const isCollapsed = sidebarState === "collapsed";
|
||||
const { selectedStudyId, userStudies, selectStudy, refreshStudyData } =
|
||||
useStudyManagement();
|
||||
|
||||
// Debug API call
|
||||
const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
|
||||
enabled: process.env.NODE_ENV === "development",
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
});
|
||||
|
||||
type Study = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -130,6 +148,11 @@ export function AppSidebar({
|
||||
await selectStudy(studyId);
|
||||
} catch (error) {
|
||||
console.error("Failed to select study:", error);
|
||||
// Handle auth errors first
|
||||
if (isAuthError(error)) {
|
||||
await handleAuthError(error, "Session expired while selecting study");
|
||||
return;
|
||||
}
|
||||
// If study selection fails (e.g., study not found), clear the selection
|
||||
await selectStudy(null);
|
||||
}
|
||||
@@ -139,6 +162,18 @@ export function AppSidebar({
|
||||
(study: Study) => study.id === selectedStudyId,
|
||||
);
|
||||
|
||||
// Debug logging for study data
|
||||
React.useEffect(() => {
|
||||
console.log("Sidebar debug - User studies:", {
|
||||
count: userStudies.length,
|
||||
studies: userStudies.map((s) => ({ id: s.id, name: s.name })),
|
||||
selectedStudyId,
|
||||
selectedStudy: selectedStudy
|
||||
? { id: selectedStudy.id, name: selectedStudy.name }
|
||||
: null,
|
||||
});
|
||||
}, [userStudies, selectedStudyId, selectedStudy]);
|
||||
|
||||
// If we have a selectedStudyId but can't find the study, clear the selection
|
||||
React.useEffect(() => {
|
||||
if (selectedStudyId && userStudies.length > 0 && !selectedStudy) {
|
||||
@@ -152,12 +187,24 @@ export function AppSidebar({
|
||||
// Auto-refresh studies list when component mounts to catch external changes
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
void refreshStudyData();
|
||||
void (async () => {
|
||||
try {
|
||||
await refreshStudyData();
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh study data:", error);
|
||||
if (isAuthError(error)) {
|
||||
void handleAuthError(error, "Session expired during data refresh");
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshStudyData]);
|
||||
|
||||
// Show debug info in development
|
||||
const showDebug = process.env.NODE_ENV === "development";
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="sidebar" {...props}>
|
||||
<SidebarHeader>
|
||||
@@ -165,7 +212,7 @@ export function AppSidebar({
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link href="/dashboard">
|
||||
<Logo iconSize="md" showText={true} />
|
||||
<Logo iconSize="md" showText={!isCollapsed} />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -179,52 +226,110 @@ export function AppSidebar({
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="w-full">
|
||||
<Building className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="w-full">
|
||||
<Building className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedStudy?.name ?? "Select Study"}
|
||||
</span>
|
||||
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width]"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>Studies</DropdownMenuLabel>
|
||||
{userStudies.map((study: Study) => (
|
||||
<DropdownMenuItem
|
||||
key={study.id}
|
||||
onClick={() => handleStudySelect(study.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate" title={study.name}>
|
||||
{study.name}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{selectedStudyId && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await selectStudy(null);
|
||||
}}
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 opacity-50" />
|
||||
Clear selection
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/studies/new">
|
||||
<Building className="mr-2 h-4 w-4" />
|
||||
Create study
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-sm">
|
||||
{selectedStudy?.name ?? "Select Study"}
|
||||
</span>
|
||||
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width]"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>Studies</DropdownMenuLabel>
|
||||
{userStudies.map((study: Study) => (
|
||||
<DropdownMenuItem
|
||||
key={study.id}
|
||||
onClick={() => handleStudySelect(study.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate" title={study.name}>
|
||||
{study.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="w-full">
|
||||
<Building className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedStudy?.name ?? "Select Study"}
|
||||
</span>
|
||||
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width]"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>Studies</DropdownMenuLabel>
|
||||
{userStudies.map((study: Study) => (
|
||||
<DropdownMenuItem
|
||||
key={study.id}
|
||||
onClick={() => handleStudySelect(study.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate" title={study.name}>
|
||||
{study.name}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{selectedStudyId && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await selectStudy(null);
|
||||
}}
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 opacity-50" />
|
||||
Clear selection
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/studies/new">
|
||||
<Building className="mr-2 h-4 w-4" />
|
||||
Create study
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{selectedStudyId && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await selectStudy(null);
|
||||
}}
|
||||
>
|
||||
<Building className="mr-2 h-4 w-4 opacity-50" />
|
||||
Clear selection
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/studies/new">
|
||||
<Building className="mr-2 h-4 w-4" />
|
||||
Create study
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
@@ -240,14 +345,29 @@ export function AppSidebar({
|
||||
pathname === item.url ||
|
||||
(item.url !== "/dashboard" && pathname.startsWith(item.url));
|
||||
|
||||
const menuButton = (
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{menuButton}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-sm">
|
||||
{item.title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
menuButton
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
@@ -256,7 +376,7 @@ export function AppSidebar({
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Study-specific items hint */}
|
||||
{!selectedStudyId && (
|
||||
{!selectedStudyId && !isCollapsed && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">
|
||||
@@ -276,14 +396,31 @@ export function AppSidebar({
|
||||
{adminItems.map((item) => {
|
||||
const isActive = pathname.startsWith(item.url);
|
||||
|
||||
const menuButton = (
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{menuButton}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-sm">
|
||||
{item.title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
menuButton
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
@@ -293,46 +430,135 @@ export function AppSidebar({
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
{/* Debug Info */}
|
||||
{showDebug && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Debug Info</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="text-muted-foreground space-y-1 px-3 py-2 text-xs">
|
||||
<div>Session: {session?.user?.email ?? "No session"}</div>
|
||||
<div>Role: {userRole ?? "No role"}</div>
|
||||
<div>Studies: {userStudies.length}</div>
|
||||
<div>Selected: {selectedStudy?.name ?? "None"}</div>
|
||||
<div>Auth: {session ? "✓" : "✗"}</div>
|
||||
{debugData && (
|
||||
<>
|
||||
<div>DB User: {debugData.user?.email ?? "None"}</div>
|
||||
<div>
|
||||
System Roles: {debugData.systemRoles.join(", ") || "None"}
|
||||
</div>
|
||||
<div>Memberships: {debugData.studyMemberships.length}</div>
|
||||
<div>All Studies: {debugData.allStudies.length}</div>
|
||||
<div>
|
||||
Session ID: {debugData.session.userId.slice(0, 8)}...
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="bg-slate-600 text-xs text-white">
|
||||
{(session?.user?.name ?? session?.user?.email ?? "U")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs">
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 border-2 border-slate-300">
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="bg-slate-600 text-xs text-white">
|
||||
{(
|
||||
session?.user?.name ??
|
||||
session?.user?.email ??
|
||||
"U"
|
||||
)
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs">
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 border-2 border-slate-300">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="bg-slate-600 text-xs text-white">
|
||||
{(
|
||||
session?.user?.name ??
|
||||
session?.user?.email ??
|
||||
"U"
|
||||
)
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs">
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Profile & Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-sm">
|
||||
{session?.user?.name ?? "User Menu"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
@@ -343,7 +569,7 @@ export function AppSidebar({
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
@@ -351,22 +577,53 @@ export function AppSidebar({
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Profile & Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 border-2 border-slate-300">
|
||||
<AvatarImage
|
||||
src={session?.user?.image ?? undefined}
|
||||
alt={session?.user?.name ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="bg-slate-600 text-xs text-white">
|
||||
{(session?.user?.name ?? session?.user?.email ?? "U")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{session?.user?.name ?? "User"}
|
||||
</span>
|
||||
<span className="truncate text-xs">
|
||||
{session?.user?.email ?? ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Profile & Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,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";
|
||||
@@ -164,7 +164,9 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
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>
|
||||
);
|
||||
},
|
||||
@@ -238,25 +240,27 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
|
||||
// Refetch when active study changes
|
||||
useEffect(() => {
|
||||
if (activeStudy?.id || studyId) {
|
||||
refetch();
|
||||
void refetch();
|
||||
}
|
||||
}, [activeStudy?.id, studyId, refetch]);
|
||||
|
||||
const data: Participant[] = React.useMemo(() => {
|
||||
if (!participantsData?.participants) return [];
|
||||
|
||||
return participantsData.participants.map((p) => ({
|
||||
id: p.id,
|
||||
participantCode: p.participantCode,
|
||||
email: p.email,
|
||||
name: p.name,
|
||||
consentGiven: p.hasConsent,
|
||||
consentDate: p.latestConsent?.signedAt
|
||||
? new Date(p.latestConsent.signedAt as unknown as string)
|
||||
: null,
|
||||
createdAt: p.createdAt,
|
||||
trialCount: p.trialCount,
|
||||
}));
|
||||
return participantsData.participants.map(
|
||||
(p): Participant => ({
|
||||
id: p.id,
|
||||
participantCode: p.participantCode,
|
||||
email: p.email,
|
||||
name: p.name,
|
||||
consentGiven: p.hasConsent,
|
||||
consentDate: p.latestConsent?.signedAt
|
||||
? new Date(p.latestConsent.signedAt as unknown as string)
|
||||
: null,
|
||||
createdAt: p.createdAt,
|
||||
trialCount: p.trialCount,
|
||||
}),
|
||||
);
|
||||
}, [participantsData]);
|
||||
|
||||
if (!studyId && !activeStudy) {
|
||||
|
||||
@@ -212,7 +212,6 @@ export function PluginStoreBrowse() {
|
||||
data: availablePlugins,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.robots.plugins.list.useQuery(
|
||||
{
|
||||
status:
|
||||
@@ -227,10 +226,14 @@ export function PluginStoreBrowse() {
|
||||
},
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const installPluginMutation = api.robots.plugins.install.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Plugin installed successfully!");
|
||||
void refetch();
|
||||
// Invalidate both plugin queries to refresh the UI
|
||||
void utils.robots.plugins.list.invalidate();
|
||||
void utils.robots.plugins.getStudyPlugins.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to install plugin");
|
||||
@@ -426,7 +429,10 @@ export function PluginStoreBrowse() {
|
||||
{error.message ||
|
||||
"An error occurred while loading the plugin store."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
<Button
|
||||
onClick={() => void utils.robots.plugins.list.refetch()}
|
||||
variant="outline"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export type Plugin = {
|
||||
plugin: {
|
||||
@@ -85,18 +87,45 @@ const statusConfig = {
|
||||
};
|
||||
|
||||
function PluginActionsCell({ plugin }: { plugin: Plugin }) {
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const uninstallMutation = api.robots.plugins.uninstall.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Plugin uninstalled successfully");
|
||||
// Invalidate plugin queries to refresh the UI
|
||||
void utils.robots.plugins.getStudyPlugins.invalidate();
|
||||
void utils.robots.plugins.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "Failed to uninstall plugin");
|
||||
},
|
||||
});
|
||||
|
||||
const isCorePlugin = plugin.plugin.name === "HRIStudio Core System";
|
||||
|
||||
const handleUninstall = async () => {
|
||||
if (isCorePlugin) {
|
||||
toast.error(
|
||||
"Cannot uninstall the core system plugin - it's required for experiment design",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
window.confirm(
|
||||
`Are you sure you want to uninstall "${plugin.plugin.name}"?`,
|
||||
`Are you sure you want to uninstall "${plugin.plugin.name}"? This will remove all plugin blocks from experiments in this study.`,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement uninstall mutation
|
||||
toast.success("Plugin uninstalled successfully");
|
||||
} catch {
|
||||
toast.error("Failed to uninstall plugin");
|
||||
if (!selectedStudyId) {
|
||||
toast.error("No study selected");
|
||||
return;
|
||||
}
|
||||
|
||||
uninstallMutation.mutate({
|
||||
studyId: selectedStudyId,
|
||||
pluginId: plugin.plugin.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,10 +174,17 @@ function PluginActionsCell({ plugin }: { plugin: Plugin }) {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleUninstall}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
disabled={uninstallMutation.isPending || isCorePlugin}
|
||||
className={
|
||||
isCorePlugin ? "text-gray-400" : "text-red-600 focus:text-red-600"
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Uninstall
|
||||
{isCorePlugin
|
||||
? "Core Plugin"
|
||||
: uninstallMutation.isPending
|
||||
? "Uninstalling..."
|
||||
: "Uninstall"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
63
src/components/ui/slider.tsx
Normal file
63
src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max],
|
||||
);
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
|
||||
|
||||
/**
|
||||
* Custom hook for centralized study management across the platform.
|
||||
@@ -52,6 +53,15 @@ export function useStudyManagement() {
|
||||
|
||||
return newStudy;
|
||||
} catch (error) {
|
||||
console.error("Failed to create study:", error);
|
||||
// Handle auth errors
|
||||
if (isAuthError(error)) {
|
||||
await handleAuthError(
|
||||
error,
|
||||
"Authentication failed while creating study",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to create study";
|
||||
toast.error(message);
|
||||
@@ -90,6 +100,15 @@ export function useStudyManagement() {
|
||||
|
||||
return updatedStudy;
|
||||
} catch (error) {
|
||||
console.error("Failed to update study:", error);
|
||||
// Handle auth errors
|
||||
if (isAuthError(error)) {
|
||||
await handleAuthError(
|
||||
error,
|
||||
"Authentication failed while updating study",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to update study";
|
||||
toast.error(message);
|
||||
@@ -121,6 +140,15 @@ export function useStudyManagement() {
|
||||
// Navigate to studies list
|
||||
router.push("/studies");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete study:", error);
|
||||
// Handle auth errors
|
||||
if (isAuthError(error)) {
|
||||
await handleAuthError(
|
||||
error,
|
||||
"Authentication failed while deleting study",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to delete study";
|
||||
toast.error(message);
|
||||
@@ -253,6 +281,12 @@ export function useStudyManagement() {
|
||||
enabled: !!selectedStudyId,
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
retry: (failureCount, error) => {
|
||||
console.log("Selected study query error:", error);
|
||||
// Handle auth errors first
|
||||
if (isAuthError(error)) {
|
||||
void handleAuthError(error, "Session expired while loading study");
|
||||
return false;
|
||||
}
|
||||
// Don't retry if study not found (404-like errors)
|
||||
if (
|
||||
error.message?.includes("not found") ||
|
||||
@@ -290,6 +324,15 @@ export function useStudyManagement() {
|
||||
{
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
retry: (failureCount, error) => {
|
||||
console.log("Studies query error:", error);
|
||||
// Handle auth errors
|
||||
if (isAuthError(error)) {
|
||||
void handleAuthError(error, "Session expired while loading studies");
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
181
src/lib/auth-error-handler.ts
Normal file
181
src/lib/auth-error-handler.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "next-auth/react";
|
||||
import { toast } from "sonner";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
/**
|
||||
* Auth error codes that should trigger automatic logout
|
||||
*/
|
||||
const AUTH_ERROR_CODES = [
|
||||
"UNAUTHORIZED",
|
||||
"FORBIDDEN",
|
||||
"UNAUTHENTICATED",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Auth error messages that should trigger automatic logout
|
||||
*/
|
||||
const AUTH_ERROR_MESSAGES = [
|
||||
"unauthorized",
|
||||
"unauthenticated",
|
||||
"forbidden",
|
||||
"invalid token",
|
||||
"token expired",
|
||||
"session expired",
|
||||
"authentication failed",
|
||||
"access denied",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Checks if an error is an authentication/authorization error that should trigger logout
|
||||
*/
|
||||
export function isAuthError(error: unknown): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
// Check TRPC errors
|
||||
if (error instanceof TRPCClientError) {
|
||||
// Check error code
|
||||
const trpcErrorData = error.data as
|
||||
| { code?: string; httpStatus?: number }
|
||||
| undefined;
|
||||
const errorCode = trpcErrorData?.code;
|
||||
if (
|
||||
errorCode &&
|
||||
AUTH_ERROR_CODES.includes(errorCode as (typeof AUTH_ERROR_CODES)[number])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check HTTP status codes
|
||||
const httpStatus = trpcErrorData?.httpStatus;
|
||||
if (httpStatus === 401 || httpStatus === 403) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check error message
|
||||
const message = error.message?.toLowerCase() ?? "";
|
||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||
}
|
||||
|
||||
// Check generic errors
|
||||
if (error instanceof Error) {
|
||||
const message = error.message?.toLowerCase() || "";
|
||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||
}
|
||||
|
||||
// Check error objects with message property
|
||||
if (typeof error === "object" && error !== null) {
|
||||
if ("message" in error) {
|
||||
const errorObj = error as { message: unknown };
|
||||
const message = String(errorObj.message).toLowerCase();
|
||||
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
|
||||
}
|
||||
|
||||
// Check for status codes in error objects
|
||||
if ("status" in error) {
|
||||
const statusObj = error as { status: unknown };
|
||||
const status = statusObj.status as number;
|
||||
return status === 401 || status === 403;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles authentication errors by logging out the user
|
||||
*/
|
||||
export async function handleAuthError(
|
||||
error: unknown,
|
||||
customMessage?: string,
|
||||
): Promise<void> {
|
||||
if (!isAuthError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn("Authentication error detected, logging out user:", error);
|
||||
|
||||
// Show user-friendly message
|
||||
const message = customMessage ?? "Session expired. Please log in again.";
|
||||
toast.error(message);
|
||||
|
||||
// Small delay to let the toast show
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await signOut({
|
||||
callbackUrl: "/",
|
||||
redirect: true,
|
||||
});
|
||||
} catch (signOutError) {
|
||||
console.error("Error during sign out:", signOutError);
|
||||
// Force redirect if signOut fails
|
||||
window.location.href = "/";
|
||||
}
|
||||
})();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* React Query error handler that automatically handles auth errors
|
||||
*/
|
||||
export function createAuthErrorHandler(customMessage?: string) {
|
||||
return (error: unknown) => {
|
||||
void handleAuthError(error, customMessage);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* tRPC error handler that automatically handles auth errors
|
||||
*/
|
||||
export function handleTRPCError(error: unknown, customMessage?: string): void {
|
||||
void handleAuthError(error, customMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic error handler for any error type
|
||||
*/
|
||||
export function handleGenericError(
|
||||
error: unknown,
|
||||
customMessage?: string,
|
||||
): void {
|
||||
void handleAuthError(error, customMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook-style error handler for use in React components
|
||||
*/
|
||||
export function useAuthErrorHandler() {
|
||||
return {
|
||||
handleAuthError: (error: unknown, customMessage?: string) => {
|
||||
void handleAuthError(error, customMessage);
|
||||
},
|
||||
isAuthError,
|
||||
createErrorHandler: createAuthErrorHandler,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order function to wrap API calls with automatic auth error handling
|
||||
*/
|
||||
export function withAuthErrorHandling<
|
||||
T extends (...args: unknown[]) => Promise<unknown>,
|
||||
>(fn: T, customMessage?: string): T {
|
||||
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
|
||||
try {
|
||||
return (await fn(...args)) as ReturnType<T>;
|
||||
} catch (error) {
|
||||
await handleAuthError(error, customMessage);
|
||||
throw error; // Re-throw so calling code can handle it too
|
||||
}
|
||||
}) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to check if current error should show a generic error message
|
||||
* (i.e., it's not an auth error that will auto-logout)
|
||||
*/
|
||||
export function shouldShowGenericError(error: unknown): boolean {
|
||||
return !isAuthError(error);
|
||||
}
|
||||
160
src/lib/experiment-designer/block-converter.ts
Normal file
160
src/lib/experiment-designer/block-converter.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
ExecutionDescriptor,
|
||||
} from "./types";
|
||||
|
||||
// Convert step-based design to database records
|
||||
export function convertStepsToDatabase(
|
||||
steps: ExperimentStep[],
|
||||
): ConvertedStep[] {
|
||||
return steps.map((step, index) => ({
|
||||
name: step.name,
|
||||
description: step.description,
|
||||
type: mapStepTypeToDatabase(step.type),
|
||||
orderIndex: index,
|
||||
durationEstimate: calculateStepDuration(step.actions),
|
||||
required: true,
|
||||
conditions: step.trigger.conditions,
|
||||
actions: step.actions.map((action, actionIndex) =>
|
||||
convertActionToDatabase(action, actionIndex),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
// Map designer step types to database step types
|
||||
function mapStepTypeToDatabase(
|
||||
stepType: ExperimentStep["type"],
|
||||
): "wizard" | "robot" | "parallel" | "conditional" {
|
||||
switch (stepType) {
|
||||
case "sequential":
|
||||
return "wizard"; // Default to wizard for sequential
|
||||
case "parallel":
|
||||
return "parallel";
|
||||
case "conditional":
|
||||
case "loop":
|
||||
return "conditional";
|
||||
default:
|
||||
return "wizard";
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate step duration from actions
|
||||
function calculateStepDuration(actions: ExperimentAction[]): number {
|
||||
let total = 0;
|
||||
|
||||
for (const action of actions) {
|
||||
switch (action.type) {
|
||||
case "wizard_speak":
|
||||
case "robot_speak":
|
||||
// Estimate based on text length if available
|
||||
const text = action.parameters.text as string;
|
||||
if (text) {
|
||||
total += Math.max(2, text.length / 10); // ~10 chars per second
|
||||
} else {
|
||||
total += 3;
|
||||
}
|
||||
break;
|
||||
case "wait":
|
||||
total += (action.parameters.duration as number) || 2;
|
||||
break;
|
||||
case "robot_move":
|
||||
total += 5; // Movement takes longer
|
||||
break;
|
||||
case "wizard_gesture":
|
||||
total += 2;
|
||||
break;
|
||||
case "observe":
|
||||
total += (action.parameters.duration as number) || 5;
|
||||
break;
|
||||
default:
|
||||
total += 2; // Default duration
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(1, Math.round(total));
|
||||
}
|
||||
|
||||
// Estimate action timeout
|
||||
function estimateActionTimeout(action: ExperimentAction): number {
|
||||
switch (action.type) {
|
||||
case "wizard_speak":
|
||||
case "robot_speak":
|
||||
return 30;
|
||||
case "robot_move":
|
||||
return 60;
|
||||
case "wait": {
|
||||
const duration = action.parameters.duration as number | undefined;
|
||||
return (duration ?? 2) + 10; // Add buffer
|
||||
}
|
||||
case "observe":
|
||||
return 120; // Observation can take longer
|
||||
default:
|
||||
return 30;
|
||||
}
|
||||
}
|
||||
|
||||
// Database conversion types (same as before)
|
||||
export interface ConvertedStep {
|
||||
name: string;
|
||||
description?: string;
|
||||
type: "wizard" | "robot" | "parallel" | "conditional";
|
||||
orderIndex: number;
|
||||
durationEstimate?: number;
|
||||
required: boolean;
|
||||
conditions: Record<string, unknown>;
|
||||
actions: ConvertedAction[];
|
||||
}
|
||||
|
||||
export interface ConvertedAction {
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
orderIndex: number;
|
||||
parameters: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
// Provenance & execution metadata (flattened for now)
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
transport?: ExecutionDescriptor["transport"];
|
||||
ros2?: ExecutionDescriptor["ros2"];
|
||||
rest?: ExecutionDescriptor["rest"];
|
||||
retryable?: boolean;
|
||||
parameterSchemaRaw?: unknown;
|
||||
sourceKind?: "core" | "plugin";
|
||||
category?: string;
|
||||
}
|
||||
|
||||
// Deprecated legacy compatibility function removed to eliminate unsafe any usage.
|
||||
// If needed, implement a proper migration path elsewhere.
|
||||
|
||||
/**
|
||||
* Convert a single experiment action into a ConvertedAction (DB shape),
|
||||
* preserving provenance and execution metadata for reproducibility.
|
||||
*/
|
||||
export function convertActionToDatabase(
|
||||
action: ExperimentAction,
|
||||
orderIndex: number,
|
||||
): ConvertedAction {
|
||||
return {
|
||||
name: action.name,
|
||||
description: `${action.type} action`,
|
||||
type: action.type,
|
||||
orderIndex,
|
||||
parameters: action.parameters,
|
||||
timeout: estimateActionTimeout(action),
|
||||
pluginId: action.source.pluginId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
robotId: action.source.robotId,
|
||||
baseActionId: action.source.baseActionId,
|
||||
transport: action.execution.transport,
|
||||
ros2: action.execution.ros2,
|
||||
rest: action.execution.rest,
|
||||
retryable: action.execution.retryable,
|
||||
parameterSchemaRaw: action.parameterSchemaRaw,
|
||||
sourceKind: action.source.kind,
|
||||
category: action.category,
|
||||
};
|
||||
}
|
||||
314
src/lib/experiment-designer/execution-compiler.ts
Normal file
314
src/lib/experiment-designer/execution-compiler.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Execution Compiler Utilities
|
||||
*
|
||||
* Purpose:
|
||||
* - Produce a deterministic execution graph snapshot from the visual design
|
||||
* - Generate an integrity hash capturing provenance & structural identity
|
||||
* - Extract normalized plugin dependency list (pluginId@version)
|
||||
*
|
||||
* These utilities are used on the server prior to saving an experiment so that
|
||||
* trial execution can rely on an immutable compiled artifact. This helps ensure
|
||||
* reproducibility by decoupling future plugin updates from already designed
|
||||
* experiment protocols.
|
||||
*
|
||||
* NOTE:
|
||||
* - This module intentionally performs only pure / synchronous operations.
|
||||
* - Any plugin resolution or database queries should happen in a higher layer
|
||||
* before invoking these functions.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExperimentDesign,
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
ExecutionDescriptor,
|
||||
} from "./types";
|
||||
|
||||
/* ---------- Public Types ---------- */
|
||||
|
||||
export interface CompiledExecutionGraph {
|
||||
version: number;
|
||||
generatedAt: string; // ISO timestamp
|
||||
steps: CompiledExecutionStep[];
|
||||
pluginDependencies: string[];
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface CompiledExecutionStep {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
type: ExperimentStep["type"];
|
||||
trigger: {
|
||||
type: string;
|
||||
conditions: Record<string, unknown>;
|
||||
};
|
||||
actions: CompiledExecutionAction[];
|
||||
estimatedDuration?: number;
|
||||
}
|
||||
|
||||
export interface CompiledExecutionAction {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
category: string;
|
||||
provenance: {
|
||||
sourceKind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
};
|
||||
execution: ExecutionDescriptor;
|
||||
parameters: Record<string, unknown>;
|
||||
parameterSchemaRaw?: unknown;
|
||||
timeout?: number;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
/* ---------- Compile Entry Point ---------- */
|
||||
|
||||
/**
|
||||
* Compile an ExperimentDesign into a reproducible execution graph + hash.
|
||||
*/
|
||||
export function compileExecutionDesign(
|
||||
design: ExperimentDesign,
|
||||
opts: { hashAlgorithm?: "sha256" | "sha1" } = {},
|
||||
): CompiledExecutionGraph {
|
||||
const pluginDependencies = collectPluginDependencies(design);
|
||||
|
||||
const compiledSteps: CompiledExecutionStep[] = design.steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((step) => compileStep(step));
|
||||
|
||||
const structuralSignature = buildStructuralSignature(
|
||||
design,
|
||||
compiledSteps,
|
||||
pluginDependencies,
|
||||
);
|
||||
|
||||
const hash = stableHash(structuralSignature, opts.hashAlgorithm ?? "sha256");
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
steps: compiledSteps,
|
||||
pluginDependencies,
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- Step / Action Compilation ---------- */
|
||||
|
||||
function compileStep(step: ExperimentStep): CompiledExecutionStep {
|
||||
const compiledActions: CompiledExecutionAction[] = step.actions.map(
|
||||
(action, index) => compileAction(action, index),
|
||||
);
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
order: step.order,
|
||||
type: step.type,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
conditions: step.trigger.conditions ?? {},
|
||||
},
|
||||
actions: compiledActions,
|
||||
estimatedDuration: step.estimatedDuration,
|
||||
};
|
||||
}
|
||||
|
||||
function compileAction(
|
||||
action: ExperimentAction,
|
||||
_index: number, // index currently unused (reserved for future ordering diagnostics)
|
||||
): CompiledExecutionAction {
|
||||
return {
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
type: action.type,
|
||||
category: action.category,
|
||||
provenance: {
|
||||
sourceKind: action.source.kind,
|
||||
pluginId: action.source.pluginId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
robotId: action.source.robotId,
|
||||
baseActionId: action.source.baseActionId,
|
||||
},
|
||||
execution: action.execution,
|
||||
parameters: action.parameters,
|
||||
parameterSchemaRaw: action.parameterSchemaRaw,
|
||||
timeout: action.execution.timeoutMs,
|
||||
retryable: action.execution.retryable,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- Plugin Dependency Extraction ---------- */
|
||||
|
||||
export function collectPluginDependencies(design: ExperimentDesign): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const step of design.steps) {
|
||||
for (const action of step.actions) {
|
||||
if (action.source.kind === "plugin" && action.source.pluginId) {
|
||||
const versionPart = action.source.pluginVersion
|
||||
? `@${action.source.pluginVersion}`
|
||||
: "";
|
||||
set.add(`${action.source.pluginId}${versionPart}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
/* ---------- Integrity Hash Generation ---------- */
|
||||
|
||||
/**
|
||||
* Build a minimal, deterministic JSON-serializable representation capturing:
|
||||
* - Step ordering, ids, types, triggers
|
||||
* - Action ordering, ids, types, provenance, execution transport, parameters (keys only for hash)
|
||||
* - Plugin dependency list
|
||||
*
|
||||
* Parameter values are not fully included (only key presence) to avoid hash churn
|
||||
* on mutable text fields while preserving structural identity. If full parameter
|
||||
* value hashing is desired, adjust `summarizeParametersForHash`.
|
||||
*/
|
||||
function buildStructuralSignature(
|
||||
design: ExperimentDesign,
|
||||
steps: CompiledExecutionStep[],
|
||||
pluginDependencies: string[],
|
||||
): unknown {
|
||||
return {
|
||||
experimentId: design.id,
|
||||
version: design.version,
|
||||
steps: steps.map((s) => ({
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
// Include condition keys only for stability
|
||||
conditionKeys: Object.keys(s.trigger.conditions).sort(),
|
||||
},
|
||||
actions: s.actions.map((a) => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
category: a.category,
|
||||
provenance: a.provenance,
|
||||
transport: a.execution.transport,
|
||||
timeout: a.timeout,
|
||||
retryable: a.retryable ?? false,
|
||||
parameterKeys: summarizeParametersForHash(a.parameters),
|
||||
})),
|
||||
})),
|
||||
pluginDependencies,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeParametersForHash(params: Record<string, unknown>): string[] {
|
||||
return Object.keys(params).sort();
|
||||
}
|
||||
|
||||
/* ---------- Stable Hash Implementation ---------- */
|
||||
|
||||
/**
|
||||
* Simple stable hash using built-in Web Crypto if available; falls back
|
||||
* to a lightweight JS implementation (FNV-1a) for environments without
|
||||
* crypto.subtle (e.g. some test runners).
|
||||
*
|
||||
* This is synchronous; if crypto.subtle is present it still uses
|
||||
* a synchronous wrapper by blocking on the Promise with deasync style
|
||||
* simulation (not implemented) so we default to FNV-1a here for portability.
|
||||
*/
|
||||
function stableHash(value: unknown, algorithm: "sha256" | "sha1"): string {
|
||||
// Use a deterministic JSON stringify
|
||||
const json = JSON.stringify(value);
|
||||
// FNV-1a 64-bit (represented as hex)
|
||||
let hashHigh = 0xcbf29ce4;
|
||||
let hashLow = 0x84222325; // Split 64-bit for simple JS accumulation
|
||||
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
const c = json.charCodeAt(i);
|
||||
// XOR low part
|
||||
hashLow ^= c;
|
||||
// 64-bit FNV prime: 1099511628211 -> split multiply
|
||||
// (hash * prime) mod 2^64
|
||||
// Multiply low
|
||||
let low =
|
||||
(hashLow & 0xffff) * 0x1b3 +
|
||||
(((hashLow >>> 16) * 0x1b3) & 0xffff) * 0x10000;
|
||||
// Include high
|
||||
low +=
|
||||
((hashHigh & 0xffff) * 0x1b3 +
|
||||
(((hashHigh >>> 16) * 0x1b3) & 0xffff) * 0x10000) &
|
||||
0xffffffff;
|
||||
// Rotate values (approximate 64-bit handling)
|
||||
hashHigh ^= low >>> 13;
|
||||
hashHigh &= 0xffffffff;
|
||||
hashLow = low & 0xffffffff;
|
||||
}
|
||||
|
||||
// Combine into hex; algorithm param reserved for future (differing strategies)
|
||||
const highHex = (hashHigh >>> 0).toString(16).padStart(8, "0");
|
||||
const lowHex = (hashLow >>> 0).toString(16).padStart(8, "0");
|
||||
return `${algorithm}-${highHex}${lowHex}`;
|
||||
}
|
||||
|
||||
/* ---------- Validation Helpers (Optional Use) ---------- */
|
||||
|
||||
/**
|
||||
* Lightweight structural sanity checks prior to compilation.
|
||||
* Returns array of issues; empty array means pass.
|
||||
*/
|
||||
export function validateDesignStructure(design: ExperimentDesign): string[] {
|
||||
const issues: string[] = [];
|
||||
if (!design.steps.length) {
|
||||
issues.push("No steps defined");
|
||||
}
|
||||
const seenStepIds = new Set<string>();
|
||||
for (const step of design.steps) {
|
||||
if (seenStepIds.has(step.id)) {
|
||||
issues.push(`Duplicate step id: ${step.id}`);
|
||||
} else {
|
||||
seenStepIds.add(step.id);
|
||||
}
|
||||
if (!step.actions.length) {
|
||||
issues.push(`Step "${step.name}" has no actions`);
|
||||
}
|
||||
const seenActionIds = new Set<string>();
|
||||
for (const action of step.actions) {
|
||||
if (seenActionIds.has(action.id)) {
|
||||
issues.push(`Duplicate action id in step "${step.name}": ${action.id}`);
|
||||
} else {
|
||||
seenActionIds.add(action.id);
|
||||
}
|
||||
if (!action.type) {
|
||||
issues.push(`Action "${action.id}" missing type`);
|
||||
}
|
||||
if (!action.source?.kind) {
|
||||
issues.push(`Action "${action.id}" missing provenance`);
|
||||
}
|
||||
if (!action.execution?.transport) {
|
||||
issues.push(`Action "${action.id}" missing execution transport`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level convenience wrapper: validate + compile; throws on issues.
|
||||
*/
|
||||
export function validateAndCompile(
|
||||
design: ExperimentDesign,
|
||||
): CompiledExecutionGraph {
|
||||
const issues = validateDesignStructure(design);
|
||||
if (issues.length) {
|
||||
const error = new Error(
|
||||
`Design validation failed:\n- ${issues.join("\n- ")}`,
|
||||
);
|
||||
(error as { issues?: string[] }).issues = issues;
|
||||
throw error;
|
||||
}
|
||||
return compileExecutionDesign(design);
|
||||
}
|
||||
166
src/lib/experiment-designer/types.ts
Normal file
166
src/lib/experiment-designer/types.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// Core experiment designer types
|
||||
|
||||
export type StepType = "sequential" | "parallel" | "conditional" | "loop";
|
||||
|
||||
export type ActionCategory = "wizard" | "robot" | "observation" | "control";
|
||||
|
||||
export type ActionType =
|
||||
| "wizard_speak"
|
||||
| "wizard_gesture"
|
||||
| "robot_move"
|
||||
| "robot_speak"
|
||||
| "wait"
|
||||
| "observe"
|
||||
| "collect_data"
|
||||
// Namespaced plugin action types will use pattern: pluginId.actionId
|
||||
| (string & {});
|
||||
|
||||
export type TriggerType =
|
||||
| "trial_start"
|
||||
| "participant_action"
|
||||
| "timer"
|
||||
| "previous_step";
|
||||
|
||||
export interface ActionParameter {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "number" | "select" | "boolean";
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
value?: string | number | boolean;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
step?: number; // numeric increment if relevant
|
||||
}
|
||||
|
||||
export interface ActionDefinition {
|
||||
id: string;
|
||||
type: ActionType;
|
||||
name: string;
|
||||
description: string;
|
||||
category: ActionCategory;
|
||||
icon: string;
|
||||
color: string;
|
||||
parameters: ActionParameter[];
|
||||
source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string; // original internal action id inside plugin repo
|
||||
};
|
||||
execution?: ExecutionDescriptor;
|
||||
parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit
|
||||
}
|
||||
|
||||
export interface ExperimentAction {
|
||||
id: string;
|
||||
type: ActionType;
|
||||
name: string;
|
||||
parameters: Record<string, unknown>;
|
||||
duration?: number;
|
||||
category: ActionCategory;
|
||||
source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
};
|
||||
execution: ExecutionDescriptor;
|
||||
parameterSchemaRaw?: unknown;
|
||||
}
|
||||
|
||||
export interface StepTrigger {
|
||||
type: TriggerType;
|
||||
conditions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExperimentStep {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: StepType;
|
||||
order: number;
|
||||
trigger: StepTrigger;
|
||||
actions: ExperimentAction[];
|
||||
estimatedDuration?: number;
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export interface ExperimentDesign {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
steps: ExperimentStep[];
|
||||
version: number;
|
||||
lastSaved: Date;
|
||||
compiledAt?: Date; // when an execution plan was compiled
|
||||
integrityHash?: string; // hash of action definitions for reproducibility
|
||||
}
|
||||
|
||||
// Trigger options for UI
|
||||
export const TRIGGER_OPTIONS = [
|
||||
{ value: "trial_start" as const, label: "Trial starts" },
|
||||
{ value: "participant_action" as const, label: "Participant acts" },
|
||||
{ value: "timer" as const, label: "After timer" },
|
||||
{ value: "previous_step" as const, label: "Previous step completes" },
|
||||
];
|
||||
|
||||
// Step type options for UI
|
||||
export const STEP_TYPE_OPTIONS = [
|
||||
{
|
||||
value: "sequential" as const,
|
||||
label: "Sequential",
|
||||
description: "Actions run one after another",
|
||||
},
|
||||
{
|
||||
value: "parallel" as const,
|
||||
label: "Parallel",
|
||||
description: "Actions run at the same time",
|
||||
},
|
||||
{
|
||||
value: "conditional" as const,
|
||||
label: "Conditional",
|
||||
description: "Actions run if condition is met",
|
||||
},
|
||||
{
|
||||
value: "loop" as const,
|
||||
label: "Loop",
|
||||
description: "Actions repeat multiple times",
|
||||
},
|
||||
];
|
||||
|
||||
// Execution descriptors (appended)
|
||||
|
||||
export interface ExecutionDescriptor {
|
||||
transport: "ros2" | "rest" | "internal";
|
||||
timeoutMs?: number;
|
||||
retryable?: boolean;
|
||||
ros2?: Ros2Execution;
|
||||
rest?: RestExecution;
|
||||
}
|
||||
|
||||
export interface Ros2Execution {
|
||||
topic?: string;
|
||||
messageType?: string;
|
||||
service?: string;
|
||||
action?: string;
|
||||
qos?: {
|
||||
reliability?: string;
|
||||
durability?: string;
|
||||
history?: string;
|
||||
depth?: number;
|
||||
};
|
||||
payloadMapping?: unknown; // mapping definition retained for transform at runtime
|
||||
}
|
||||
|
||||
export interface RestExecution {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string | number | boolean>;
|
||||
bodyTemplate?: unknown;
|
||||
}
|
||||
326
src/lib/experiment-designer/visual-design-guard.ts
Normal file
326
src/lib/experiment-designer/visual-design-guard.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
StepType,
|
||||
TriggerType,
|
||||
ActionCategory,
|
||||
ExecutionDescriptor,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Visual Design Guard
|
||||
*
|
||||
* Provides a robust Zod-based parsing/normalization pipeline that:
|
||||
* - Accepts a loosely-typed visualDesign.steps payload coming from the client
|
||||
* - Normalizes and validates it into strongly typed arrays for internal processing
|
||||
* - Strips unknown fields (preserves only what we rely on)
|
||||
* - Ensures provenance + execution descriptors are structurally sound
|
||||
*
|
||||
* This replaces ad-hoc runtime filtering in the experiments update mutation.
|
||||
*
|
||||
* Usage:
|
||||
* const { steps, issues } = parseVisualDesignSteps(rawSteps);
|
||||
* if (issues.length) -> reject request
|
||||
* else -> steps (ExperimentStep[]) is now safe for conversion & compilation
|
||||
*/
|
||||
|
||||
// Enumerations (reuse domain model semantics without hard binding to future expansions)
|
||||
const stepTypeEnum = z.enum(["sequential", "parallel", "conditional", "loop"]);
|
||||
const triggerTypeEnum = z.enum([
|
||||
"trial_start",
|
||||
"participant_action",
|
||||
"timer",
|
||||
"previous_step",
|
||||
]);
|
||||
const actionCategoryEnum = z.enum([
|
||||
"wizard",
|
||||
"robot",
|
||||
"observation",
|
||||
"control",
|
||||
]);
|
||||
|
||||
// Provenance
|
||||
const actionSourceSchema = z
|
||||
.object({
|
||||
kind: z.enum(["core", "plugin"]),
|
||||
pluginId: z.string().min(1).optional(),
|
||||
pluginVersion: z.string().min(1).optional(),
|
||||
robotId: z.string().min(1).nullable().optional(),
|
||||
baseActionId: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Execution descriptor
|
||||
const executionDescriptorSchema = z
|
||||
.object({
|
||||
transport: z.enum(["ros2", "rest", "internal"]),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
ros2: z
|
||||
.object({
|
||||
topic: z.string().min(1).optional(),
|
||||
messageType: z.string().min(1).optional(),
|
||||
service: z.string().min(1).optional(),
|
||||
action: z.string().min(1).optional(),
|
||||
qos: z
|
||||
.object({
|
||||
reliability: z.string().optional(),
|
||||
durability: z.string().optional(),
|
||||
history: z.string().optional(),
|
||||
depth: z.number().int().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
payloadMapping: z.unknown().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
rest: z
|
||||
.object({
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
path: z.string().min(1),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Action parameter snapshot is a free-form structure retained for audit
|
||||
const parameterSchemaRawSchema = z.unknown().optional();
|
||||
|
||||
// Action schema (loose input → normalized internal)
|
||||
const visualActionInputSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
category: actionCategoryEnum.optional(),
|
||||
parameters: z.record(z.string(), z.unknown()).default({}),
|
||||
source: actionSourceSchema.optional(),
|
||||
execution: executionDescriptorSchema.optional(),
|
||||
parameterSchemaRaw: parameterSchemaRawSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Trigger schema
|
||||
const triggerSchema = z
|
||||
.object({
|
||||
type: triggerTypeEnum,
|
||||
conditions: z.record(z.string(), z.unknown()).default({}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Step schema
|
||||
const visualStepInputSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
type: stepTypeEnum,
|
||||
order: z.number().int().nonnegative().optional(),
|
||||
trigger: triggerSchema.optional(),
|
||||
actions: z.array(visualActionInputSchema),
|
||||
expanded: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Array schema root
|
||||
const visualDesignStepsSchema = z.array(visualStepInputSchema);
|
||||
|
||||
/**
|
||||
* Parse & normalize raw steps payload.
|
||||
*/
|
||||
export function parseVisualDesignSteps(raw: unknown): {
|
||||
steps: ExperimentStep[];
|
||||
issues: string[];
|
||||
} {
|
||||
const issues: string[] = [];
|
||||
|
||||
const parsed = visualDesignStepsSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
const zodErr = parsed.error;
|
||||
issues.push(
|
||||
...zodErr.issues.map(
|
||||
(issue) =>
|
||||
`steps${
|
||||
issue.path.length ? "." + issue.path.join(".") : ""
|
||||
}: ${issue.message} (code=${issue.code})`,
|
||||
),
|
||||
);
|
||||
return { steps: [], issues };
|
||||
}
|
||||
|
||||
// Normalize to internal ExperimentStep[] shape
|
||||
const inputSteps = parsed.data;
|
||||
|
||||
const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
|
||||
const actions: ExperimentAction[] = s.actions.map((a) => {
|
||||
// Default provenance
|
||||
const source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
} = a.source
|
||||
? {
|
||||
kind: a.source.kind,
|
||||
pluginId: a.source.pluginId,
|
||||
pluginVersion: a.source.pluginVersion,
|
||||
robotId: a.source.robotId ?? null,
|
||||
baseActionId: a.source.baseActionId,
|
||||
}
|
||||
: { kind: "core" };
|
||||
|
||||
// Default execution
|
||||
const execution: ExecutionDescriptor = a.execution
|
||||
? {
|
||||
transport: a.execution.transport,
|
||||
timeoutMs: a.execution.timeoutMs,
|
||||
retryable: a.execution.retryable,
|
||||
ros2: a.execution.ros2,
|
||||
rest: a.execution.rest
|
||||
? {
|
||||
method: a.execution.rest.method,
|
||||
path: a.execution.rest.path,
|
||||
headers: a.execution.rest.headers
|
||||
? Object.fromEntries(
|
||||
Object.entries(a.execution.rest.headers).filter(
|
||||
(kv): kv is [string, string] =>
|
||||
typeof kv[1] === "string",
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: { transport: "internal" };
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
type: a.type, // dynamic (pluginId.actionId)
|
||||
name: a.name,
|
||||
parameters: a.parameters ?? {},
|
||||
duration: undefined,
|
||||
category: (a.category ?? "wizard") as ActionCategory,
|
||||
source: {
|
||||
kind: source.kind,
|
||||
pluginId: source.kind === "plugin" ? source.pluginId : undefined,
|
||||
pluginVersion:
|
||||
source.kind === "plugin" ? source.pluginVersion : undefined,
|
||||
robotId: source.kind === "plugin" ? (source.robotId ?? null) : null,
|
||||
baseActionId:
|
||||
source.kind === "plugin" ? source.baseActionId : undefined,
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: a.parameterSchemaRaw,
|
||||
};
|
||||
});
|
||||
|
||||
// Construct step
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
type: s.type as StepType,
|
||||
order: typeof s.order === "number" ? s.order : idx,
|
||||
trigger: {
|
||||
type: (s.trigger?.type ?? "previous_step") as TriggerType,
|
||||
conditions: s.trigger?.conditions ?? {},
|
||||
},
|
||||
actions,
|
||||
estimatedDuration: undefined,
|
||||
expanded: s.expanded ?? true,
|
||||
};
|
||||
});
|
||||
|
||||
// Basic structural checks
|
||||
const seenStepIds = new Set<string>();
|
||||
for (const st of normalized) {
|
||||
if (seenStepIds.has(st.id)) {
|
||||
issues.push(`Duplicate step id: ${st.id}`);
|
||||
}
|
||||
seenStepIds.add(st.id);
|
||||
if (!st.actions.length) {
|
||||
issues.push(`Step "${st.name}" has no actions`);
|
||||
}
|
||||
const seenActionIds = new Set<string>();
|
||||
for (const act of st.actions) {
|
||||
if (seenActionIds.has(act.id)) {
|
||||
issues.push(`Duplicate action id in step "${st.name}": ${act.id}`);
|
||||
}
|
||||
seenActionIds.add(act.id);
|
||||
if (!act.source.kind) {
|
||||
issues.push(`Action "${act.id}" missing source.kind`);
|
||||
}
|
||||
if (!act.execution.transport) {
|
||||
issues.push(`Action "${act.id}" missing execution transport`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { steps: normalized, issues };
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate aggregate duration (in seconds) from normalized steps.
|
||||
* Uses simple additive heuristic: sum each step's summed action durations
|
||||
* if present; falls back to rough defaults for certain action patterns.
|
||||
*/
|
||||
export function estimateDesignDurationSeconds(steps: ExperimentStep[]): number {
|
||||
let total = 0;
|
||||
for (const step of steps) {
|
||||
let stepSum = 0;
|
||||
for (const action of step.actions) {
|
||||
const t = classifyDuration(action);
|
||||
stepSum += t;
|
||||
}
|
||||
total += stepSum;
|
||||
}
|
||||
return Math.max(1, Math.round(total));
|
||||
}
|
||||
|
||||
function classifyDuration(action: ExperimentAction): number {
|
||||
// Heuristic mapping (could be evolved to plugin-provided estimates)
|
||||
switch (true) {
|
||||
case action.type.startsWith("wizard_speak"):
|
||||
case action.type.startsWith("robot_speak"): {
|
||||
const text = action.parameters.text as string | undefined;
|
||||
if (text && text.length > 0) {
|
||||
return Math.max(2, Math.round(text.length / 10));
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
case action.type.startsWith("wait"): {
|
||||
const d = action.parameters.duration as number | undefined;
|
||||
return d && d > 0 ? d : 2;
|
||||
}
|
||||
case action.type.startsWith("robot_move"):
|
||||
return 5;
|
||||
case action.type.startsWith("wizard_gesture"):
|
||||
return 2;
|
||||
case action.type.startsWith("observe"): {
|
||||
const d = action.parameters.duration as number | undefined;
|
||||
return d && d > 0 ? d : 5;
|
||||
}
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: validates, returns steps or throws with issues attached.
|
||||
*/
|
||||
export function assertVisualDesignSteps(raw: unknown): ExperimentStep[] {
|
||||
const { steps, issues } = parseVisualDesignSteps(raw);
|
||||
if (issues.length) {
|
||||
const err = new Error(
|
||||
`Visual design validation failed:\n- ${issues.join("\n- ")}`,
|
||||
);
|
||||
(err as { issues?: string[] }).issues = issues;
|
||||
throw err;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { adminRouter } from "~/server/api/routers/admin";
|
||||
import { analyticsRouter } from "~/server/api/routers/analytics";
|
||||
import { authRouter } from "~/server/api/routers/auth";
|
||||
import { collaborationRouter } from "~/server/api/routers/collaboration";
|
||||
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
||||
import { experimentsRouter } from "~/server/api/routers/experiments";
|
||||
import { mediaRouter } from "~/server/api/routers/media";
|
||||
import { participantsRouter } from "~/server/api/routers/participants";
|
||||
@@ -28,6 +29,7 @@ export const appRouter = createTRPCRouter({
|
||||
analytics: analyticsRouter,
|
||||
collaboration: collaborationRouter,
|
||||
admin: adminRouter,
|
||||
dashboard: dashboardRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
312
src/server/api/routers/dashboard.ts
Normal file
312
src/server/api/routers/dashboard.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { and, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
activityLogs,
|
||||
experiments,
|
||||
participants,
|
||||
studies,
|
||||
studyMembers,
|
||||
trials,
|
||||
users,
|
||||
userSystemRoles,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
export const dashboardRouter = createTRPCRouter({
|
||||
getRecentActivity: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().min(1).max(20).default(10),
|
||||
studyId: z.string().uuid().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get studies the user has access to
|
||||
const accessibleStudies = await ctx.db
|
||||
.select({ studyId: studyMembers.studyId })
|
||||
.from(studyMembers)
|
||||
.where(eq(studyMembers.userId, userId));
|
||||
|
||||
const studyIds = accessibleStudies.map((s) => s.studyId);
|
||||
|
||||
// If no accessible studies, return empty
|
||||
if (studyIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build where conditions
|
||||
const whereConditions = input.studyId
|
||||
? eq(activityLogs.studyId, input.studyId)
|
||||
: inArray(activityLogs.studyId, studyIds);
|
||||
|
||||
// Get recent activity logs
|
||||
const activities = await ctx.db
|
||||
.select({
|
||||
id: activityLogs.id,
|
||||
action: activityLogs.action,
|
||||
description: activityLogs.description,
|
||||
createdAt: activityLogs.createdAt,
|
||||
user: {
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
},
|
||||
study: {
|
||||
name: studies.name,
|
||||
},
|
||||
})
|
||||
.from(activityLogs)
|
||||
.innerJoin(users, eq(activityLogs.userId, users.id))
|
||||
.innerJoin(studies, eq(activityLogs.studyId, studies.id))
|
||||
.where(whereConditions)
|
||||
.orderBy(desc(activityLogs.createdAt))
|
||||
.limit(input.limit);
|
||||
|
||||
return activities.map((activity) => ({
|
||||
id: activity.id,
|
||||
type: activity.action,
|
||||
title: activity.description,
|
||||
description: `${activity.study.name} - ${activity.user.name}`,
|
||||
time: activity.createdAt,
|
||||
status: "info" as const,
|
||||
}));
|
||||
}),
|
||||
|
||||
getStudyProgress: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().min(1).max(10).default(5),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get studies the user has access to with participant counts
|
||||
const studyProgress = await ctx.db
|
||||
.select({
|
||||
id: studies.id,
|
||||
name: studies.name,
|
||||
status: studies.status,
|
||||
createdAt: studies.createdAt,
|
||||
totalParticipants: count(participants.id),
|
||||
})
|
||||
.from(studies)
|
||||
.innerJoin(studyMembers, eq(studies.id, studyMembers.studyId))
|
||||
.leftJoin(participants, eq(studies.id, participants.studyId))
|
||||
.where(
|
||||
and(eq(studyMembers.userId, userId), eq(studies.status, "active")),
|
||||
)
|
||||
.groupBy(studies.id, studies.name, studies.status, studies.createdAt)
|
||||
.orderBy(desc(studies.createdAt))
|
||||
.limit(input.limit);
|
||||
|
||||
// Get trial completion counts for each study
|
||||
const studyIds = studyProgress.map((s) => s.id);
|
||||
|
||||
const trialCounts =
|
||||
studyIds.length > 0
|
||||
? await ctx.db
|
||||
.select({
|
||||
studyId: experiments.studyId,
|
||||
completedTrials: count(trials.id),
|
||||
})
|
||||
.from(experiments)
|
||||
.innerJoin(trials, eq(experiments.id, trials.experimentId))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "completed"),
|
||||
),
|
||||
)
|
||||
.groupBy(experiments.studyId)
|
||||
: [];
|
||||
|
||||
const trialCountMap = new Map(
|
||||
trialCounts.map((tc) => [tc.studyId, tc.completedTrials]),
|
||||
);
|
||||
|
||||
return studyProgress.map((study) => {
|
||||
const completedTrials = trialCountMap.get(study.id) ?? 0;
|
||||
const totalParticipants = study.totalParticipants;
|
||||
|
||||
// Calculate progress based on completed trials vs participants
|
||||
// If no participants, progress is 0; if trials >= participants, progress is 100%
|
||||
const progress =
|
||||
totalParticipants > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round((completedTrials / totalParticipants) * 100),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
id: study.id,
|
||||
name: study.name,
|
||||
progress,
|
||||
participants: completedTrials, // Using completed trials as active participants
|
||||
totalParticipants,
|
||||
status: study.status,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get studies the user has access to
|
||||
const accessibleStudies = await ctx.db
|
||||
.select({ studyId: studyMembers.studyId })
|
||||
.from(studyMembers)
|
||||
.where(eq(studyMembers.userId, userId));
|
||||
|
||||
const studyIds = accessibleStudies.map((s) => s.studyId);
|
||||
|
||||
if (studyIds.length === 0) {
|
||||
return {
|
||||
totalStudies: 0,
|
||||
totalExperiments: 0,
|
||||
totalParticipants: 0,
|
||||
totalTrials: 0,
|
||||
activeTrials: 0,
|
||||
scheduledTrials: 0,
|
||||
completedToday: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get total counts
|
||||
const [studyCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(studies)
|
||||
.where(inArray(studies.id, studyIds));
|
||||
|
||||
const [experimentCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(experiments)
|
||||
.where(inArray(experiments.studyId, studyIds));
|
||||
|
||||
const [participantCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(participants)
|
||||
.where(inArray(participants.studyId, studyIds));
|
||||
|
||||
const [trialCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(inArray(experiments.studyId, studyIds));
|
||||
|
||||
// Get active trials count
|
||||
const [activeTrialsCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "in_progress"),
|
||||
),
|
||||
);
|
||||
|
||||
// Get scheduled trials count
|
||||
const [scheduledTrialsCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "scheduled"),
|
||||
),
|
||||
);
|
||||
|
||||
// Get today's completed trials
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [completedTodayCount] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "completed"),
|
||||
gte(trials.completedAt, today),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
totalStudies: studyCount?.count ?? 0,
|
||||
totalExperiments: experimentCount?.count ?? 0,
|
||||
totalParticipants: participantCount?.count ?? 0,
|
||||
totalTrials: trialCount?.count ?? 0,
|
||||
activeTrials: activeTrialsCount?.count ?? 0,
|
||||
scheduledTrials: scheduledTrialsCount?.count ?? 0,
|
||||
completedToday: completedTodayCount?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
debug: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get user info
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
// Get user system roles
|
||||
const systemRoles = await ctx.db.query.userSystemRoles.findMany({
|
||||
where: eq(userSystemRoles.userId, userId),
|
||||
});
|
||||
|
||||
// Get user study memberships
|
||||
const studyMemberships = await ctx.db.query.studyMembers.findMany({
|
||||
where: eq(studyMembers.userId, userId),
|
||||
with: {
|
||||
study: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get all studies (admin view)
|
||||
const allStudies = await ctx.db.query.studies.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
createdBy: true,
|
||||
},
|
||||
where: sql`deleted_at IS NULL`,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
return {
|
||||
user: user
|
||||
? {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
}
|
||||
: null,
|
||||
systemRoles: systemRoles.map((r) => r.role),
|
||||
studyMemberships: studyMemberships.map((m) => ({
|
||||
studyId: m.studyId,
|
||||
role: m.role,
|
||||
study: m.study,
|
||||
})),
|
||||
allStudies,
|
||||
session: {
|
||||
userId: ctx.session.user.id,
|
||||
userEmail: ctx.session.user.email,
|
||||
userRole: ctx.session.user.roles?.[0]?.role ?? null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -468,6 +468,7 @@ export const robotsRouter = createTRPCRouter({
|
||||
repositoryUrl: plugins.repositoryUrl,
|
||||
trustLevel: plugins.trustLevel,
|
||||
status: plugins.status,
|
||||
actionDefinitions: plugins.actionDefinitions,
|
||||
createdAt: plugins.createdAt,
|
||||
updatedAt: plugins.updatedAt,
|
||||
},
|
||||
|
||||
@@ -4,8 +4,15 @@ import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
activityLogs, studies, studyMemberRoleEnum, studyMembers,
|
||||
studyStatusEnum, users, userSystemRoles
|
||||
activityLogs,
|
||||
plugins,
|
||||
studies,
|
||||
studyMemberRoleEnum,
|
||||
studyMembers,
|
||||
studyPlugins,
|
||||
studyStatusEnum,
|
||||
users,
|
||||
userSystemRoles,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
export const studiesRouter = createTRPCRouter({
|
||||
@@ -274,6 +281,20 @@ export const studiesRouter = createTRPCRouter({
|
||||
role: "owner",
|
||||
});
|
||||
|
||||
// Auto-install core plugin in new study
|
||||
const corePlugin = await ctx.db.query.plugins.findFirst({
|
||||
where: eq(plugins.name, "HRIStudio Core System"),
|
||||
});
|
||||
|
||||
if (corePlugin) {
|
||||
await ctx.db.insert(studyPlugins).values({
|
||||
studyId: newStudy.id,
|
||||
pluginId: corePlugin.id,
|
||||
configuration: {},
|
||||
installedBy: userId,
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId: newStudy.id,
|
||||
@@ -534,7 +555,7 @@ export const studiesRouter = createTRPCRouter({
|
||||
studyId,
|
||||
userId,
|
||||
action: "member_removed",
|
||||
description: `Removed ${memberToRemove.user?.name ?? memberToRemove.user?.email ?? 'Unknown user'}`,
|
||||
description: `Removed ${memberToRemove.user?.name ?? memberToRemove.user?.email ?? "Unknown user"}`,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -393,6 +393,7 @@ export const experiments = createTable(
|
||||
visualDesign: jsonb("visual_design"),
|
||||
executionGraph: jsonb("execution_graph"),
|
||||
pluginDependencies: text("plugin_dependencies").array(),
|
||||
integrityHash: varchar("integrity_hash", { length: 128 }),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
@@ -496,12 +497,24 @@ export const actions = createTable(
|
||||
.references(() => steps.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
type: varchar("type", { length: 100 }).notNull(), // e.g., 'speak', 'move', 'wait', 'collect_data'
|
||||
type: varchar("type", { length: 100 }).notNull(), // e.g., 'speak', 'move', 'wait', 'collect_data' or pluginId.actionId
|
||||
orderIndex: integer("order_index").notNull(),
|
||||
parameters: jsonb("parameters").default({}),
|
||||
validationSchema: jsonb("validation_schema"),
|
||||
timeout: integer("timeout"), // in seconds
|
||||
retryCount: integer("retry_count").default(0).notNull(),
|
||||
// Provenance & execution metadata
|
||||
sourceKind: varchar("source_kind", { length: 20 }), // 'core' | 'plugin'
|
||||
pluginId: varchar("plugin_id", { length: 255 }),
|
||||
pluginVersion: varchar("plugin_version", { length: 50 }),
|
||||
robotId: varchar("robot_id", { length: 255 }),
|
||||
baseActionId: varchar("base_action_id", { length: 255 }),
|
||||
category: varchar("category", { length: 50 }),
|
||||
transport: varchar("transport", { length: 20 }), // 'ros2' | 'rest' | 'internal'
|
||||
ros2: jsonb("ros2_config"),
|
||||
rest: jsonb("rest_config"),
|
||||
retryable: boolean("retryable"),
|
||||
parameterSchemaRaw: jsonb("parameter_schema_raw"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
|
||||
Reference in New Issue
Block a user