Redesign experiment designer workspace and seed Bucknell data

- Overhauled designer UI: virtualized flow, slim action panel, improved
drag - Added Bucknell studies, users, and NAO plugin to seed-dev script
- Enhanced validation panel and inspector UX - Updated wizard-actions
plugin options formatting - Removed Minio from docker-compose for local
dev - Numerous UI and code quality improvements for experiment design
This commit is contained in:
2025-08-13 17:56:30 -04:00
parent 488674fca8
commit 550021a18e
17 changed files with 2430 additions and 766 deletions

View File

@@ -3,6 +3,8 @@ import { drizzle } from "drizzle-orm/postgres-js";
import { eq, sql } from "drizzle-orm";
import postgres from "postgres";
import * as schema from "../src/server/db/schema";
import { readFile } from "node:fs/promises";
import path from "node:path";
// Database connection
const connectionString = process.env.DATABASE_URL!;
@@ -17,34 +19,57 @@ async function syncRepository(
try {
console.log(`🔄 Syncing repository: ${repoUrl}`);
// Use localhost for development
const devUrl = repoUrl.includes("core.hristudio.com")
? "http://localhost:3000/hristudio-core"
: repoUrl;
// Resolve source: use local public repo for core, remote URL otherwise
const isCore = repoUrl.includes("core.hristudio.com");
const devUrl = repoUrl;
// Fetch repository metadata
const repoResponse = await fetch(`${devUrl}/repository.json`);
if (!repoResponse.ok) {
throw new Error(
`Failed to fetch repository metadata: ${repoResponse.status}`,
);
}
const repoMetadata = (await repoResponse.json()) as {
description?: string;
author?: { name?: string };
urls?: { git?: string };
trust?: string;
};
// Fetch repository metadata (local filesystem for core)
const repoMetadata = isCore
? (JSON.parse(
await readFile(
path.join(
process.cwd(),
"public",
"hristudio-core",
"repository.json",
),
"utf8",
),
) as {
description?: string;
author?: { name?: string };
urls?: { git?: string };
trust?: string;
})
: await (async () => {
const repoResponse = await fetch(`${devUrl}/repository.json`);
if (!repoResponse.ok) {
throw new Error(
`Failed to fetch repository metadata: ${repoResponse.status}`,
);
}
return (await repoResponse.json()) as {
description?: string;
author?: { name?: string };
urls?: { git?: string };
trust?: string;
};
})();
// For core repository, create a single plugin with all block groups
if (repoUrl.includes("core.hristudio.com")) {
const indexResponse = await fetch(`${devUrl}/plugins/index.json`);
if (!indexResponse.ok) {
throw new Error(
`Failed to fetch plugin index: ${indexResponse.status}`,
);
}
const indexData = (await indexResponse.json()) as {
if (isCore) {
const indexData = JSON.parse(
await readFile(
path.join(
process.cwd(),
"public",
"hristudio-core",
"plugins",
"index.json",
),
"utf8",
),
) as {
plugins?: Array<{ blockCount?: number }>;
};
@@ -203,7 +228,7 @@ async function main() {
.returning();
console.log(`✅ Created ${insertedRobots.length} robots`);
// Create users
// Create users (Bucknell University team)
console.log("👥 Creating users...");
const hashedPassword = await bcrypt.hash("password123", 12);
@@ -216,29 +241,29 @@ async function main() {
image: null,
},
{
name: "Dr. Alice Rodriguez",
email: "alice.rodriguez@university.edu",
name: "Prof. Dana Miller",
email: "dana.miller@bucknell.edu",
password: hashedPassword,
emailVerified: new Date(),
image: null,
},
{
name: "Dr. Bob Chen",
email: "bob.chen@research.org",
name: "Chris Lee",
email: "chris.lee@bucknell.edu",
password: hashedPassword,
emailVerified: new Date(),
image: null,
},
{
name: "Emily Watson",
email: "emily.watson@lab.edu",
name: "Priya Singh",
email: "priya.singh@bucknell.edu",
password: hashedPassword,
emailVerified: new Date(),
image: null,
},
{
name: "Dr. Maria Santos",
email: "maria.santos@tech.edu",
name: "Jordan White",
email: "jordan.white@bucknell.edu",
password: hashedPassword,
emailVerified: new Date(),
image: null,
@@ -321,32 +346,23 @@ async function main() {
console.log("📚 Creating studies...");
const studies = [
{
name: "Human-Robot Collaboration Study",
name: "NAO Classroom Interaction",
description:
"Investigating collaborative tasks between humans and robots in shared workspace environments",
institution: "MIT Computer Science",
irbProtocol: "IRB-2024-001",
"Evaluating student engagement with NAO-led prompts during lab sessions",
institution: "Bucknell University",
irbProtocol: "BU-IRB-2025-NAO-01",
status: "active" as const,
createdBy: seanUser.id,
},
{
name: "Robot Navigation Study",
name: "Wizard-of-Oz Dialogue Study",
description:
"A comprehensive study on robot navigation and obstacle avoidance in dynamic environments",
institution: "Stanford HCI Lab",
irbProtocol: "IRB-2024-002",
"WoZ-controlled NAO to assess timing and tone in instructional feedback",
institution: "Bucknell University",
irbProtocol: "BU-IRB-2025-WOZ-02",
status: "draft" as const,
createdBy: seanUser.id,
},
{
name: "Social Robot Interaction Study",
description:
"Examining social dynamics between humans and humanoid robots in educational settings",
institution: "Carnegie Mellon",
irbProtocol: "IRB-2024-003",
status: "active" as const,
createdBy: seanUser.id,
},
];
const insertedStudies = await db
@@ -411,6 +427,27 @@ async function main() {
);
}
// Install NAO plugin for first study if available
console.log("🤝 Installing NAO plugin (if available)...");
const naoPlugin = await db
.select()
.from(schema.plugins)
.where(eq(schema.plugins.name, "NAO Humanoid Robot"))
.limit(1);
if (naoPlugin.length > 0 && insertedStudies[0]) {
await db.insert(schema.studyPlugins).values({
studyId: insertedStudies[0].id,
pluginId: naoPlugin[0]!.id,
configuration: { voice: "nao-tts", locale: "en-US" },
installedBy: seanUser.id,
});
console.log("✅ Installed NAO plugin in first study");
} else {
console.log(
" NAO plugin not found in repository sync; continuing without it",
);
}
// Create some participants
console.log("👤 Creating participants...");
const participants = [];
@@ -447,24 +484,313 @@ async function main() {
.returning();
console.log(`✅ Created ${insertedParticipants.length} participants`);
// Create basic experiments
// Create experiments (include one NAO-based)
console.log("🧪 Creating experiments...");
const experiments = insertedStudies.map((study, i) => ({
studyId: study.id,
name: `Basic Interaction Protocol ${i + 1}`,
description: `A simple human-robot interaction experiment for ${study.name}`,
version: 1,
status: "ready" as const,
estimatedDuration: 30 + i * 10,
createdBy: seanUser.id,
}));
const experiments = [
{
studyId: insertedStudies[0]!.id,
name: "Basic Interaction Protocol 1",
description: "Wizard prompts + NAO speaks demo script",
version: 1,
status: "ready" as const,
estimatedDuration: 25,
createdBy: seanUser.id,
},
{
studyId: insertedStudies[1]!.id,
name: "Dialogue Timing Pilot",
description: "Compare response timing variants under WoZ control",
version: 1,
status: "draft" as const,
estimatedDuration: 35,
createdBy: seanUser.id,
},
];
const insertedExperiments = await db
.insert(schema.experiments)
.values(experiments)
.values(
experiments.map((e) => ({
...e,
visualDesign: {
// minimal starter design; steps optionally overwritten below for DB tables
steps: [],
version: 1,
lastSaved: new Date().toISOString(),
},
})),
)
.returning();
console.log(`✅ Created ${insertedExperiments.length} experiments`);
// Seed a richer, multi-step design for the first experiment (wizard + robot)
if (insertedExperiments[0]) {
const exp = insertedExperiments[0];
// Step 1: Wizard demo + robot speaks
const step1 = await db
.insert(schema.steps)
.values({
experimentId: exp.id,
name: "Step 1 • Introduction & Object Demo",
description: "Wizard greets participant and demonstrates an object",
type: "wizard",
orderIndex: 0,
required: true,
conditions: {},
})
.returning();
const step1Id = step1[0]!.id;
// Action 1.1: Wizard shows object
await db.insert(schema.actions).values({
stepId: step1Id,
name: "show object",
description: "Wizard presents or demonstrates an object",
type: "wizard_show_object",
orderIndex: 0,
parameters: { object: "Cube" },
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
// Resolve NAO plugin id/version for namespaced action type
const naoDbPlugin1 = await db
.select({ id: schema.plugins.id, version: schema.plugins.version })
.from(schema.plugins)
.where(eq(schema.plugins.name, "NAO Humanoid Robot"))
.limit(1);
const naoPluginRow1 = naoDbPlugin1[0];
// Action 1.2: Robot/NAO says text (or wizard says fallback)
await db.insert(schema.actions).values({
stepId: step1Id,
name: naoPluginRow1 ? "NAO Say Text" : "Wizard Say",
description: naoPluginRow1
? "Make the robot speak using text-to-speech"
: "Wizard speaks to participant",
type: naoPluginRow1 ? `${naoPluginRow1.id}.say_text` : "wizard_say",
orderIndex: 1,
parameters: naoPluginRow1
? { text: "Hello, I am NAO. Let's begin!", speed: 110, volume: 0.75 }
: { message: "Hello! Let's begin the session.", tone: "friendly" },
sourceKind: naoPluginRow1 ? "plugin" : "core",
pluginId: naoPluginRow1 ? naoPluginRow1.id : null,
pluginVersion: naoPluginRow1 ? naoPluginRow1.version : null,
category: naoPluginRow1 ? "robot" : "wizard",
transport: naoPluginRow1 ? "rest" : "internal",
retryable: false,
});
// Step 2: Wait for response (wizard)
const step2 = await db
.insert(schema.steps)
.values({
experimentId: exp.id,
name: "Step 2 • Participant Response",
description: "Wizard waits for the participant's response",
type: "wizard",
orderIndex: 1,
required: true,
conditions: {},
})
.returning();
const step2Id = step2[0]!.id;
await db.insert(schema.actions).values({
stepId: step2Id,
name: "wait for response",
description: "Wizard waits for participant to respond",
type: "wizard_wait_for_response",
orderIndex: 0,
parameters: {
response_type: "verbal",
timeout: 20,
prompt_text: "What did you notice about the object?",
},
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
// Step 3: Robot LED feedback (or record note fallback)
const step3 = await db
.insert(schema.steps)
.values({
experimentId: exp.id,
name: "Step 3 • Robot Feedback",
description: "Provide feedback using robot LED color or record note",
type: "robot",
orderIndex: 2,
required: false,
conditions: {},
})
.returning();
const step3Id = step3[0]!.id;
const naoDbPlugin2 = await db
.select({ id: schema.plugins.id, version: schema.plugins.version })
.from(schema.plugins)
.where(eq(schema.plugins.name, "NAO Humanoid Robot"))
.limit(1);
const naoPluginRow2 = naoDbPlugin2[0];
if (naoPluginRow2) {
await db.insert(schema.actions).values({
stepId: step3Id,
name: "Set LED Color",
description: "Change NAO's eye LEDs to reflect state",
type: `${naoPluginRow2.id}.set_led_color`,
orderIndex: 0,
parameters: { color: "blue", intensity: 0.6 },
sourceKind: "plugin",
pluginId: naoPluginRow2.id,
pluginVersion: naoPluginRow2.version,
category: "robot",
transport: "rest",
retryable: false,
});
} else {
await db.insert(schema.actions).values({
stepId: step3Id,
name: "record note",
description: "Wizard records an observation",
type: "wizard_record_note",
orderIndex: 0,
parameters: {
note_type: "observation",
prompt: "No robot available",
},
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
}
}
// Seed a richer design for the second experiment (timers + conditional/parallel)
if (insertedExperiments[1]) {
const exp2 = insertedExperiments[1];
// Step A: Baseline prompt
const stepA = await db
.insert(schema.steps)
.values({
experimentId: exp2.id,
name: "Step A • Baseline Prompt",
description: "Wizard provides a baseline instruction",
type: "wizard",
orderIndex: 0,
required: true,
conditions: {},
})
.returning();
const stepAId = stepA[0]!.id;
await db.insert(schema.actions).values({
stepId: stepAId,
name: "say",
description: "Wizard speaks to participant",
type: "wizard_say",
orderIndex: 0,
parameters: {
message: "We'll try a short timing task next.",
tone: "instructional",
},
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
// Step B: Parallel gestures/animation
const stepB = await db
.insert(schema.steps)
.values({
experimentId: exp2.id,
name: "Step B • Parallel Cues",
description: "Provide multiple cues at once (gesture + animation)",
type: "parallel",
orderIndex: 1,
required: false,
conditions: {},
})
.returning();
const stepBId = stepB[0]!.id;
await db.insert(schema.actions).values({
stepId: stepBId,
name: "gesture",
description: "Wizard performs a physical gesture",
type: "wizard_gesture",
orderIndex: 0,
parameters: { type: "point", direction: "participant" },
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
const naoDbPluginB = await db
.select({ id: schema.plugins.id, version: schema.plugins.version })
.from(schema.plugins)
.where(eq(schema.plugins.name, "NAO Humanoid Robot"))
.limit(1);
const naoPluginRowB = naoDbPluginB[0];
if (naoPluginRowB) {
await db.insert(schema.actions).values({
stepId: stepBId,
name: "Play Animation",
description: "NAO plays a greeting animation",
type: `${naoPluginRowB.id}.play_animation`,
orderIndex: 1,
parameters: { animation: "Hello" },
sourceKind: "plugin",
pluginId: naoPluginRowB.id,
pluginVersion: naoPluginRowB.version,
category: "robot",
transport: "rest",
retryable: false,
});
}
// Step C: Conditional follow-up after a brief wait
const stepC = await db
.insert(schema.steps)
.values({
experimentId: exp2.id,
name: "Step C • Conditional Follow-up",
description: "Proceed based on observed response after timer",
type: "conditional",
orderIndex: 2,
required: false,
conditions: { predicate: "response_received", timer_ms: 3000 },
})
.returning();
const stepCId = stepC[0]!.id;
await db.insert(schema.actions).values({
stepId: stepCId,
name: "record note",
description: "Wizard records a follow-up note",
type: "wizard_record_note",
orderIndex: 0,
parameters: {
note_type: "participant_response",
prompt: "Response after parallel cues",
},
sourceKind: "core",
category: "wizard",
transport: "internal",
retryable: false,
});
}
// Create some trials for dashboard demo
console.log("🧪 Creating sample trials...");
const trials = [];
@@ -526,6 +852,65 @@ async function main() {
.returning();
console.log(`✅ Created ${insertedTrials.length} trials`);
// Create trial events time series for richer dashboards
const trialEventRows = [];
for (const t of insertedTrials) {
const baseStart = t.startedAt ?? new Date(Date.now() - 60 * 60 * 1000);
const t1 = new Date(baseStart.getTime() - 2 * 60 * 1000); // 2 min before start
const t2 = new Date(baseStart.getTime()); // start
const t3 = new Date(baseStart.getTime() + 3 * 60 * 1000); // +3 min
const t4 = new Date(baseStart.getTime() + 8 * 60 * 1000); // +8 min
const t5 =
t.completedAt ?? new Date(baseStart.getTime() + 15 * 60 * 1000); // completion
trialEventRows.push(
{
trialId: t.id,
eventType: "wizard_prompt_shown",
actionId: null,
timestamp: t1,
data: { prompt: "Welcome and object demo" },
createdBy: seanUser.id,
},
{
trialId: t.id,
eventType: "action_started",
actionId: null,
timestamp: t2,
data: { label: "demo_start" },
createdBy: seanUser.id,
},
{
trialId: t.id,
eventType: "robot_action_executed",
actionId: null,
timestamp: t3,
data: { robot: "nao", action: "speak" },
createdBy: seanUser.id,
},
{
trialId: t.id,
eventType: "action_completed",
actionId: null,
timestamp: t4,
data: { label: "demo_complete" },
createdBy: seanUser.id,
},
{
trialId: t.id,
eventType: "trial_note",
actionId: null,
timestamp: t5,
data: { summary: "Session ended successfully" },
createdBy: seanUser.id,
},
);
}
if (trialEventRows.length) {
await db.insert(schema.trialEvents).values(trialEventRows);
console.log(`✅ Created ${trialEventRows.length} trial events`);
}
// Create some activity logs for dashboard demo
console.log("📝 Creating activity logs...");
const activityEntries = [];
@@ -612,7 +997,7 @@ async function main() {
console.log("\n✅ Seed script completed successfully!");
console.log("\n📊 Created:");
console.log(`${insertedRobots.length} robots`);
console.log(`${insertedUsers.length} users`);
console.log(`${insertedUsers.length} users (Bucknell)`);
console.log(`${insertedRepos.length} plugin repositories`);
console.log(`${totalPlugins} plugins (via repository sync)`);
console.log(`${insertedStudies.length} studies`);