diff --git a/.env.example b/.env.example index 7bf5a31..ae2e482 100755 --- a/.env.example +++ b/.env.example @@ -17,3 +17,10 @@ AUTH_SECRET="" # Drizzle DATABASE_URL="postgresql://postgres:password@localhost:5433/hristudio" + +# MinIO/S3 Configuration +MINIO_ENDPOINT="http://localhost:9000" +MINIO_REGION="us-east-1" +MINIO_ACCESS_KEY="minioadmin" +MINIO_SECRET_KEY="minioadmin" +MINIO_BUCKET_NAME="hristudio-data" diff --git a/bun.lock b/bun.lock index 1d75457..6753ba0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "hristudio", @@ -13,6 +14,7 @@ "@hookform/resolvers": "^5.1.1", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", @@ -51,6 +53,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.60.0", "react-resizable-panels": "^3.0.4", + "react-webcam": "^7.2.0", "server-only": "^0.0.1", "sonner": "^2.0.7", "superjson": "^2.2.1", @@ -382,6 +385,8 @@ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@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-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "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-5nZrJTF7gH+e0nZS7/QxFz6tJV4VimhQb1avEgtsJxvvIp5JilL+c58HICsKzPxghdwaDt48hEfPM1au4zGy+w=="], + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "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-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "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-presence": "1.1.4", "@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-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="], @@ -1270,6 +1275,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-webcam": ["react-webcam@7.2.0", "", { "peerDependencies": { "react": ">=16.2.0", "react-dom": ">=16.2.0" } }, "sha512-xkrzYPqa1ag2DP+2Q/kLKBmCIfEx49bVdgCCCcZf88oF+0NPEbkwYk3/s/C7Zy0mhM8k+hpdNkBLzxg8H0aWcg=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], @@ -1492,6 +1499,8 @@ "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="], + "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "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-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + "@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@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-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "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-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="], "@radix-ui/react-roving-focus/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -1606,6 +1615,8 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "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-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], "@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], diff --git a/package.json b/package.json index bc01768..607d970 100755 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@hookform/resolvers": "^5.1.1", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", @@ -70,6 +71,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.60.0", "react-resizable-panels": "^3.0.4", + "react-webcam": "^7.2.0", "server-only": "^0.0.1", "sonner": "^2.0.7", "superjson": "^2.2.1", diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts index 8e9d857..f7637dd 100755 --- a/scripts/seed-dev.ts +++ b/scripts/seed-dev.ts @@ -3,1029 +3,321 @@ 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!; const connection = postgres(connectionString); const db = drizzle(connection, { schema }); -// Repository sync helper -async function syncRepository( - repoId: string, - repoUrl: string, -): Promise { - try { - console.log(`๐Ÿ”„ Syncing repository: ${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 (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 (isCore) { - const indexData = JSON.parse( - await readFile( - path.join( - process.cwd(), - "public", - "hristudio-core", - "plugins", - "index.json", - ), - "utf8", - ), - ) as { - plugins?: Array<{ blockCount?: number }>; - }; - - // Create core system plugin - await db.insert(schema.plugins).values({ - robotId: null, - name: "HRIStudio Core System", - version: "1.0.0", - description: repoMetadata.description ?? "", - author: repoMetadata.author?.name ?? "Unknown", - repositoryUrl: repoMetadata.urls?.git ?? "", - trustLevel: - (repoMetadata.trust as "official" | "verified" | "community") ?? - "community", - status: "active", - actionDefinitions: [], - metadata: { - platform: "Core", - category: "system", - repositoryId: repoId, - blockGroups: indexData.plugins ?? [], - totalBlocks: - indexData.plugins?.reduce( - (sum: number, p: { blockCount?: number }) => - sum + (p.blockCount ?? 0), - 0, - ) ?? 0, +// --- NAO6 Plugin Definitions (Inlined for reliability) --- +const NAO_PLUGIN_DEF = { + name: "NAO6 Robot (Enhanced ROS2 Integration)", + version: "2.0.0", + description: "Comprehensive NAO6 robot integration for HRIStudio experiments via ROS2.", + actions: [ + { + id: "nao_speak", + name: "Speak Text", + category: "speech", + parametersSchema: { + type: "object", + properties: { + text: { type: "string" }, + volume: { type: "number", default: 0.7 } }, - }); - - console.log( - `โœ… Synced core system with ${indexData.plugins?.length ?? 0} block groups`, - ); - return 1; - } - - // For robot repositories, sync individual plugins - const pluginIndexResponse = await fetch(`${devUrl}/plugins/index.json`); - if (!pluginIndexResponse.ok) { - throw new Error( - `Failed to fetch plugin index: ${pluginIndexResponse.status}`, - ); - } - const pluginFiles = (await pluginIndexResponse.json()) as string[]; - - let syncedCount = 0; - for (const pluginFile of pluginFiles) { - try { - const pluginResponse = await fetch(`${devUrl}/plugins/${pluginFile}`); - if (!pluginResponse.ok) { - console.warn( - `Failed to fetch ${pluginFile}: ${pluginResponse.status}`, - ); - continue; + required: ["text"] + } + }, + { + id: "nao_gesture", + name: "Perform Gesture", + category: "interaction", + parametersSchema: { + type: "object", + properties: { + gesture: { type: "string", enum: ["wave", "bow", "point"] }, + speed: { type: "number", default: 0.8 } + } + } + }, + { + id: "nao_look_at", + name: "Look At", + category: "movement", + parametersSchema: { + type: "object", + properties: { + target: { type: "string", enum: ["participant", "screen", "away"] }, + duration: { type: "number", default: 2.0 } } - const pluginData = (await pluginResponse.json()) as { - name?: string; - version?: string; - description?: string; - manufacturer?: { name?: string }; - documentation?: { mainUrl?: string }; - trustLevel?: string; - actions?: unknown[]; - platform?: string; - category?: string; - specs?: unknown; - ros2Config?: unknown; - }; - - await db.insert(schema.plugins).values({ - robotId: null, // Will be matched later if needed - name: pluginData.name ?? pluginFile.replace(".json", ""), - version: pluginData.version ?? "1.0.0", - description: pluginData.description ?? "", - author: - pluginData.manufacturer?.name ?? - repoMetadata.author?.name ?? - "Unknown", - repositoryUrl: - pluginData.documentation?.mainUrl ?? repoMetadata.urls?.git ?? "", - trustLevel: - (pluginData.trustLevel as "official" | "verified" | "community") ?? - (repoMetadata.trust as "official" | "verified" | "community") ?? - "community", - status: "active", - actionDefinitions: pluginData.actions ?? [], - metadata: { - platform: pluginData.platform, - category: pluginData.category, - repositoryId: repoId, - specs: pluginData.specs, - ros2Config: pluginData.ros2Config, - }, - }); - - console.log(`โœ… Synced plugin: ${pluginData.name}`); - syncedCount++; - } catch (error) { - console.warn(`Failed to process ${pluginFile}:`, error); } } - - return syncedCount; - } catch (error) { - console.error(`Failed to sync repository ${repoUrl}:`, error); - return 0; - } -} + ] +}; async function main() { - console.log("๐ŸŒฑ Starting simplified seed script..."); + console.log("๐ŸŒฑ Starting realistic seed script..."); try { - // Clean existing data (in reverse order of dependencies) + // 1. Clean existing data console.log("๐Ÿงน Cleaning existing data..."); - await db.delete(schema.studyPlugins).where(sql`1=1`); - await db.delete(schema.plugins).where(sql`1=1`); - await db.delete(schema.pluginRepositories).where(sql`1=1`); + // Delete in reverse dependency order + await db.delete(schema.mediaCaptures).where(sql`1=1`); await db.delete(schema.trialEvents).where(sql`1=1`); await db.delete(schema.trials).where(sql`1=1`); + await db.delete(schema.actions).where(sql`1=1`); await db.delete(schema.steps).where(sql`1=1`); await db.delete(schema.experiments).where(sql`1=1`); await db.delete(schema.participants).where(sql`1=1`); + await db.delete(schema.studyPlugins).where(sql`1=1`); await db.delete(schema.studyMembers).where(sql`1=1`); - await db.delete(schema.userSystemRoles).where(sql`1=1`); await db.delete(schema.studies).where(sql`1=1`); + await db.delete(schema.plugins).where(sql`1=1`); + await db.delete(schema.pluginRepositories).where(sql`1=1`); + await db.delete(schema.userSystemRoles).where(sql`1=1`); await db.delete(schema.users).where(sql`1=1`); await db.delete(schema.robots).where(sql`1=1`); - // Create robots - console.log("๐Ÿค– Creating robots..."); - const robots = [ - { - 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"], - communicationProtocol: "ros2" as const, - }, - { - name: "NAO Humanoid Robot", - manufacturer: "SoftBank Robotics", - model: "NAO V6", - description: - "Humanoid robot designed for education, research, and social interaction with ROS2 integration", - capabilities: [ - "speech", - "vision", - "walking", - "gestures", - "joint_control", - "touch_sensors", - "sonar_sensors", - "camera_feed", - "imu", - "odometry", - ], - communicationProtocol: "ros2" as const, - }, - ]; - - const insertedRobots = await db - .insert(schema.robots) - .values(robots) - .returning(); - console.log(`โœ… Created ${insertedRobots.length} robots`); - - // Create users (Bucknell University team) + // 2. Create Users console.log("๐Ÿ‘ฅ Creating users..."); const hashedPassword = await bcrypt.hash("password123", 12); - const users = [ - { - name: "Sean O'Connor", - email: "sean@soconnor.dev", - password: hashedPassword, - emailVerified: new Date(), - image: null, - }, - { - name: "L. Felipe Perrone", - email: "felipe.perrone@bucknell.edu", - password: hashedPassword, - emailVerified: new Date(), - image: null, - }, - ]; + const [adminUser] = await db.insert(schema.users).values({ + name: "Sean O'Connor", + email: "sean@soconnor.dev", + password: hashedPassword, + emailVerified: new Date(), + image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Sean", // Consistent avatar + }).returning(); - const insertedUsers = await db - .insert(schema.users) - .values(users) - .returning(); - console.log(`โœ… Created ${insertedUsers.length} users`); + const [researcherUser] = await db.insert(schema.users).values({ + name: "Dr. Felipe Perrone", + email: "felipe.perrone@bucknell.edu", + password: hashedPassword, + emailVerified: new Date(), + image: "https://api.dicebear.com/7.x/avataaars/svg?seed=Felipe", + }).returning(); - // Assign system roles - console.log("๐ŸŽญ Assigning system roles..."); - const seanUser = insertedUsers.find((u) => u.email === "sean@soconnor.dev"); - - if (!seanUser) { - throw new Error("Sean user not found after creation"); - } + if (!adminUser) throw new Error("Failed to create admin user"); await db.insert(schema.userSystemRoles).values({ - userId: seanUser.id, + userId: adminUser.id, role: "administrator", }); - console.log(`โœ… Assigned administrator role to Sean`); + // 3. Create Robots & Plugins + console.log("๐Ÿค– Creating robots and plugins..."); + const [naoRobot] = await db.insert(schema.robots).values({ + name: "NAO6", + manufacturer: "SoftBank Robotics", + model: "NAO V6", + description: "Humanoid robot for social interaction studies.", + capabilities: ["speech", "vision", "bipedal_walking", "gestures"], + communicationProtocol: "ros2", + }).returning(); - // Create plugin repositories - console.log("๐Ÿ“ฆ Creating plugin repositories..."); - const repositories = [ - { - 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, - syncStatus: "pending" as const, - createdBy: seanUser.id, + const [naoRepo] = await db.insert(schema.pluginRepositories).values({ + name: "HRIStudio Official Plugins", + url: "https://github.com/hristudio/plugins", + description: "Official verified plugins", + trustLevel: "official", + status: "active", + createdBy: adminUser.id, + }).returning(); + + const [naoPlugin] = await db.insert(schema.plugins).values({ + robotId: naoRobot!.id, + repositoryId: naoRepo!.id, + name: NAO_PLUGIN_DEF.name, + version: NAO_PLUGIN_DEF.version, + description: NAO_PLUGIN_DEF.description, + author: "HRIStudio Team", + trustLevel: "official", + status: "active", + repositoryUrl: naoRepo!.url, + actionDefinitions: NAO_PLUGIN_DEF.actions, + configurationSchema: { + type: "object", + properties: { + robotIp: { type: "string", default: "192.168.1.100" } + } }, - { - 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, - syncStatus: "pending" as const, - createdBy: seanUser.id, - }, - ]; + metadata: { category: "robot_control" } + }).returning(); - const insertedRepos = await db - .insert(schema.pluginRepositories) - .values(repositories) - .returning(); - console.log(`โœ… Created ${insertedRepos.length} plugin repositories`); + // 4. Create Study & Experiment + console.log("๐Ÿ“š Creating study and experiment..."); + const [study] = await db.insert(schema.studies).values({ + name: "Social Robot Attention Study", + description: "Investigating the effect of robot gaze on participant attention retention.", + institution: "Bucknell University", + irbProtocol: "2024-HRI-055", + status: "active", + createdBy: adminUser.id, + }).returning(); - // Sync repositories to populate plugins - console.log("๐Ÿ”„ Syncing plugin repositories..."); - let totalPlugins = 0; + await db.insert(schema.studyMembers).values([ + { studyId: study!.id, userId: adminUser.id, role: "owner" }, + { studyId: study!.id, userId: researcherUser!.id, role: "researcher" } + ]); - for (const repo of insertedRepos) { - const syncedCount = await syncRepository(repo.id, repo.url); - totalPlugins += syncedCount; + await db.insert(schema.studyPlugins).values({ + studyId: study!.id, + pluginId: naoPlugin!.id, + configuration: { robotIp: "10.0.0.42" }, + installedBy: adminUser.id + }); - // Update sync status - await db - .update(schema.pluginRepositories) - .set({ - syncStatus: syncedCount > 0 ? "completed" : "failed", - lastSyncAt: new Date(), - }) - .where(eq(schema.pluginRepositories.id, repo.id)); - } + const [experiment] = await db.insert(schema.experiments).values({ + studyId: study!.id, + name: "Attention Gaze Protocol A", + description: "Condition A: Robot maintains eye contact.", + version: 1, + status: "ready", // Correct enum value + createdBy: adminUser.id, + }).returning(); - // Create studies - console.log("๐Ÿ“š Creating studies..."); - const studies = [ - { - name: "NAO Classroom Interaction", - description: - "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: "Wizard-of-Oz Dialogue Study", - description: - "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, - }, - ]; - - const insertedStudies = await db - .insert(schema.studies) - .values(studies) - .returning(); - console.log(`โœ… Created ${insertedStudies.length} studies`); - - // Create study memberships - console.log("๐Ÿ‘ฅ Creating study memberships..."); - const studyMemberships = []; - - // Sean as owner of all studies - for (const study of insertedStudies) { - studyMemberships.push({ - studyId: study.id, - userId: seanUser.id, - role: "owner" as const, - }); - } - - // Add other users as researchers/wizards - const otherUsers = insertedUsers.filter((u) => u.id !== seanUser.id); - if (otherUsers.length > 0 && insertedStudies[0]) { - studyMemberships.push({ - studyId: insertedStudies[0].id, - userId: otherUsers[0]!.id, - role: "researcher" as const, - }); - - if (otherUsers.length > 1 && insertedStudies[1]) { - studyMemberships.push({ - studyId: insertedStudies[1].id, - userId: otherUsers[1]!.id, - role: "wizard" as const, - }); - } - } - - await db.insert(schema.studyMembers).values(studyMemberships); - console.log(`โœ… Created ${studyMemberships.length} study memberships`); - - // Install core plugin in all studies - console.log("๐Ÿ”Œ Installing core plugin in all studies..."); - const corePlugin = await db - .select() - .from(schema.plugins) - .where(eq(schema.plugins.name, "HRIStudio Core System")) - .limit(1); - - if (corePlugin.length > 0) { - const coreInstallations = insertedStudies.map((study) => ({ - studyId: study.id, - pluginId: corePlugin[0]!.id, - configuration: {}, - installedBy: seanUser.id, - })); - - await db.insert(schema.studyPlugins).values(coreInstallations); - console.log( - `โœ… Installed core plugin in ${insertedStudies.length} studies`, - ); - } - - // Install NAO6 ROS2 plugin for first study if available - console.log("๐Ÿค Installing NAO6 ROS2 plugin (if available)..."); - const naoPlugin = await db - .select() - .from(schema.plugins) - .where(eq(schema.plugins.name, "NAO6 Robot (ROS2 Integration)")) - .limit(1); - if (naoPlugin.length > 0 && insertedStudies[0]) { - await db.insert(schema.studyPlugins).values({ - studyId: insertedStudies[0].id, - pluginId: naoPlugin[0]!.id, - configuration: { - robotIp: "nao.local", - websocketUrl: "ws://localhost:9090", - maxLinearVelocity: 0.3, - maxAngularVelocity: 1.0, - defaultSpeed: 0.5, - }, - installedBy: seanUser.id, - }); - console.log("โœ… Installed NAO6 ROS2 plugin in first study"); - } else { - console.log( - "โ„น๏ธ NAO6 ROS2 plugin not found in repository sync; continuing without it", - ); - } - - // Create some participants + // 5. Participants console.log("๐Ÿ‘ค Creating participants..."); const participants = []; - - for (let i = 0; i < insertedStudies.length; i++) { - const study = insertedStudies[i]; - if (study) { - participants.push( - { - studyId: study.id, - participantCode: `P${String(i * 2 + 1).padStart(3, "0")}`, - name: `Participant ${i * 2 + 1}`, - email: `participant${i * 2 + 1}@example.com`, - demographics: { age: 25 + i, gender: "prefer not to say" }, - consentGiven: true, - consentGivenAt: new Date(), - }, - { - studyId: study.id, - participantCode: `P${String(i * 2 + 2).padStart(3, "0")}`, - name: `Participant ${i * 2 + 2}`, - email: `participant${i * 2 + 2}@example.com`, - demographics: { age: 30 + i, gender: "prefer not to say" }, - consentGiven: true, - consentGivenAt: new Date(), - }, - ); - } - } - - const insertedParticipants = await db - .insert(schema.participants) - .values(participants) - .returning(); - console.log(`โœ… Created ${insertedParticipants.length} participants`); - - // Create experiments (include one NAO-based) - console.log("๐Ÿงช Creating experiments..."); - 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.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 NAO6 ROS2 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, "NAO6 Robot (ROS2 Integration)")) - .limit(1); - const naoPluginRow1 = naoDbPlugin1[0]; - - // Action 1.2: Robot/NAO speaks text - await db.insert(schema.actions).values({ - stepId: step1Id, - name: naoPluginRow1 ? "NAO Speak Text" : "Wizard Say", - description: naoPluginRow1 - ? "Make the robot speak using text-to-speech via ROS2" - : "Wizard speaks to participant", - type: naoPluginRow1 ? `${naoPluginRow1.id}.nao6_speak` : "wizard_say", - orderIndex: 1, - parameters: naoPluginRow1 - ? { text: "Hello, I am NAO. Let's begin!", volume: 0.8 } - : { 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 ? "ros2" : "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, "NAO6 Robot (ROS2 Integration)")) - .limit(1); - const naoPluginRow2 = naoDbPlugin2[0]; - - if (naoPluginRow2) { - await db.insert(schema.actions).values({ - stepId: step3Id, - name: "NAO Move Head", - description: "Move NAO's head to look at participant", - type: `${naoPluginRow2.id}.nao6_move_head`, - orderIndex: 0, - parameters: { yaw: 0.0, pitch: -0.2, speed: 0.3 }, - sourceKind: "plugin", - pluginId: naoPluginRow2.id, - pluginVersion: naoPluginRow2.version, - category: "robot", - transport: "ros2", - 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, + for (let i = 1; i <= 5; i++) { + participants.push({ + studyId: study!.id, + participantCode: `P${100 + i}`, + name: `Participant ${100 + i}`, + consentGiven: true, + consentGivenAt: new Date(), + notes: i % 2 === 0 ? "Condition A" : "Condition B" }); } + const insertedParticipants = await db.insert(schema.participants).values(participants).returning(); - // Create some trials for dashboard demo - console.log("๐Ÿงช Creating sample trials..."); - const trials = []; + // 6. Trials & Realistic Logs + console.log("๐Ÿงช Generating trials with dense logs..."); - for (const experiment of insertedExperiments) { - if (!experiment) continue; + for (const p of insertedParticipants) { + const isCompleted = Math.random() > 0.2; // 80% completed + const trialStart = new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000); + const durationSecs = 300 + Math.floor(Math.random() * 300); // 5-10 mins + const trialEnd = new Date(trialStart.getTime() + durationSecs * 1000); - const studyParticipants = insertedParticipants.filter( - (p) => p.studyId === experiment.studyId, - ); + const [trial] = await db.insert(schema.trials).values({ + studyId: study!.id, // Ensure referencing study if schema allows, otherwise via experiment + experimentId: experiment!.id, + participantId: p.id, + wizardId: adminUser.id, + sessionNumber: 1, + status: isCompleted ? "completed" : "in_progress", + startedAt: trialStart, + completedAt: isCompleted ? trialEnd : null, + duration: isCompleted ? durationSecs : null, + createdBy: adminUser.id, + }).returning(); - if (studyParticipants.length > 0) { - // Create 2-3 trials per experiment - const trialCount = Math.min(studyParticipants.length, 3); - for (let j = 0; j < trialCount; j++) { - const participant = studyParticipants[j]; - if (participant) { - const scheduledAt = new Date( - Date.now() - Math.random() * 2 * 24 * 60 * 60 * 1000, - ); - const startedAt = new Date(scheduledAt.getTime() + 30 * 60 * 1000); // 30 minutes after scheduled - const completedAt = new Date(startedAt.getTime() + 45 * 60 * 1000); // 45 minutes after started + // Generate dense events + let currentTime = trialStart.getTime(); + const events = []; - // Vary the status: some completed, some in progress, some scheduled - let status: "scheduled" | "in_progress" | "completed" | "aborted"; - let actualStartedAt = null; - let actualCompletedAt = null; + // Event: Trial Start + events.push({ + trialId: trial!.id, + eventType: "system", + timestamp: new Date(currentTime), + data: { message: "Trial started", system_check: "nominal" }, + createdBy: adminUser.id + }); + currentTime += 2000; - if (j === 0) { - status = "completed"; - actualStartedAt = startedAt; - actualCompletedAt = completedAt; - } else if (j === 1 && trialCount > 2) { - status = "in_progress"; - actualStartedAt = startedAt; - } else { - status = "scheduled"; - } + // Event: Wizard Introduction (Wizard Action) + events.push({ + trialId: trial!.id, + eventType: "wizard_action", + timestamp: new Date(currentTime), + data: { action: "read_script", section: "intro" }, + createdBy: adminUser.id + }); + currentTime += 5000; - trials.push({ - participantId: participant.id, - experimentId: experiment.id, - sessionNumber: j + 1, - status, - scheduledAt, - startedAt: actualStartedAt, - completedAt: actualCompletedAt, - notes: `Trial session ${j + 1} for ${experiment.name}`, - createdBy: seanUser.id, - }); - } + // Loop for interaction events + const interactionCount = 15; + for (let k = 0; k < interactionCount; k++) { + // Gap + currentTime += 2000 + Math.random() * 5000; + + // 1. Robot Action (Speak/Gesture) + const actionType = Math.random() > 0.6 ? "nao_gesture" : "nao_speak"; + events.push({ + trialId: trial!.id, + eventType: "robot_action", + timestamp: new Date(currentTime), + data: actionType === "nao_gesture" + ? { plugin: "nao6", action: "gesture", type: "wave", speed: 0.8 } + : { plugin: "nao6", action: "speak", text: "Please look at the screen now.", volume: 0.7 }, + createdBy: adminUser.id + }); + + // Execution log + currentTime += 100; + events.push({ + trialId: trial!.id, + eventType: "system", + timestamp: new Date(currentTime), + data: { source: "ros2_bridge", topic: "/nao/cmd", status: "sent" }, + createdBy: adminUser.id + }); + + // 2. Participant Reaction (Simulated Logs/Wizard Note) + if (Math.random() > 0.7) { + currentTime += 3000; + events.push({ + trialId: trial!.id, + eventType: "wizard_note", + timestamp: new Date(currentTime), + data: { note: "Participant looked away briefly.", tag: "distraction" }, + createdBy: adminUser.id + }); } } + + // End + if (isCompleted) { + events.push({ + trialId: trial!.id, + eventType: "system", + timestamp: trialEnd, + data: { message: "Trial completed successfully" }, + createdBy: adminUser.id + }); + + // Fake Media Capture + await db.insert(schema.mediaCaptures).values({ + trialId: trial!.id, + mediaType: "video", // Changed from "video/webm" to general "video" + storagePath: `trials/${trial!.id}/recording.webm`, + fileSize: 15480000 + Math.floor(Math.random() * 5000000), // ~15-20MB + duration: durationSecs, + startTimestamp: trialStart, + endTimestamp: trialEnd, + }); + } + + await db.insert(schema.trialEvents).values(events); } - const insertedTrials = await db - .insert(schema.trials) - .values(trials) - .returning(); - console.log(`โœ… Created ${insertedTrials.length} trials`); + console.log("\nโœ… Database seeded successfully!"); + console.log(`Summary:`); + console.log(`- 1 Admin User (sean@soconnor.dev)`); + console.log(`- 1 Study (Social Robot Attention)`); + console.log(`- ${insertedParticipants.length} Participants`); + console.log(`- ${insertedParticipants.length} Trials created (mixed status)`); + console.log(`- ~20 Events per trial`); - // 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 = []; - - // Study creation activities - for (const study of insertedStudies) { - activityEntries.push({ - studyId: study.id, - userId: seanUser.id, - action: "study_created", - description: `Created study "${study.name}"`, - createdAt: new Date( - Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000, - ), // Random time in last week - }); - } - - // Experiment creation activities - for (const experiment of insertedExperiments) { - activityEntries.push({ - studyId: experiment.studyId, - userId: seanUser.id, - action: "experiment_created", - description: `Created experiment protocol "${experiment.name}"`, - createdAt: new Date( - Date.now() - Math.random() * 5 * 24 * 60 * 60 * 1000, - ), // Random time in last 5 days - }); - } - - // Participant enrollment activities - for (const participant of insertedParticipants) { - activityEntries.push({ - studyId: participant.studyId, - userId: seanUser.id, - action: "participant_enrolled", - description: `Enrolled participant ${participant.participantCode}`, - createdAt: new Date( - Date.now() - Math.random() * 3 * 24 * 60 * 60 * 1000, - ), // Random time in last 3 days - }); - } - - // Plugin installation activities - for (const study of insertedStudies) { - activityEntries.push({ - studyId: study.id, - userId: seanUser.id, - action: "plugin_installed", - description: "Installed HRIStudio Core System plugin", - createdAt: new Date( - Date.now() - Math.random() * 2 * 24 * 60 * 60 * 1000, - ), // Random time in last 2 days - }); - } - - // Add some recent activities - const firstStudy = insertedStudies[0]; - if (firstStudy) { - activityEntries.push( - { - studyId: firstStudy.id, - userId: seanUser.id, - action: "trial_scheduled", - description: "Scheduled new trial session", - createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago - }, - { - studyId: firstStudy.id, - userId: seanUser.id, - action: "experiment_updated", - description: "Updated experiment parameters", - createdAt: new Date(Date.now() - 4 * 60 * 60 * 1000), // 4 hours ago - }, - ); - } - - const insertedActivity = await db - .insert(schema.activityLogs) - .values(activityEntries) - .returning(); - console.log(`โœ… Created ${insertedActivity.length} activity log entries`); - - console.log("\nโœ… Seed script completed successfully!"); - console.log("\n๐Ÿ“Š Created:"); - console.log(` โ€ข ${insertedRobots.length} robots`); - console.log(` โ€ข ${insertedUsers.length} users (Bucknell)`); - console.log(` โ€ข ${insertedRepos.length} plugin repositories`); - console.log(` โ€ข ${totalPlugins} plugins (via repository sync)`); - console.log(` โ€ข ${insertedStudies.length} studies`); - console.log(` โ€ข ${studyMemberships.length} study memberships`); - console.log(` โ€ข ${insertedParticipants.length} participants`); - console.log(` โ€ข ${insertedExperiments.length} experiments`); - console.log(` โ€ข ${insertedTrials.length} trials`); - - console.log("\n๐Ÿ‘ค Login credentials:"); - console.log(" Email: sean@soconnor.dev"); - console.log(" Password: password123"); - console.log(" Role: Administrator"); - - console.log("\n๐Ÿ”„ Plugin repositories synced:"); - for (const repo of insertedRepos) { - console.log(` โ€ข ${repo.name}: ${repo.url}`); - } - - console.log("\n๐ŸŽฏ Next steps:"); - console.log(" 1. Start the development server: bun dev"); - console.log(" 2. Access admin dashboard to manage repositories"); - console.log(" 3. Browse plugin store to see synced plugins"); } catch (error) { - console.error("โŒ Error running seed script:", error); - throw error; + console.error("โŒ Seeding failed:", error); + process.exit(1); } finally { await connection.end(); } } -if (import.meta.url === `file://${process.argv[1]}`) { - void main(); -} - -export default main; +main(); diff --git a/src/app/(dashboard)/studies/[id]/analytics/page.tsx b/src/app/(dashboard)/studies/[id]/analytics/page.tsx index 8a253d2..c9f9ed8 100755 --- a/src/app/(dashboard)/studies/[id]/analytics/page.tsx +++ b/src/app/(dashboard)/studies/[id]/analytics/page.tsx @@ -1,25 +1,25 @@ "use client"; import { useParams } from "next/navigation"; -import { Suspense, useEffect } from "react"; +import { Suspense, useEffect, useState } from "react"; import { - Activity, BarChart3, - Calendar, - Download, + Search, Filter, - TrendingDown, - TrendingUp, + PlayCircle, + Calendar, + Clock, + ChevronRight, + User, + LayoutGrid } from "lucide-react"; -import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; +import { PageHeader } from "~/components/ui/page-header"; +import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; +import { useStudyContext } from "~/lib/study-context"; +import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails"; +import { api } from "~/trpc/react"; +import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView"; import { Select, SelectContent, @@ -27,283 +27,180 @@ import { SelectTrigger, SelectValue, } from "~/components/ui/select"; -import { PageHeader } from "~/components/ui/page-header"; -import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; -import { useStudyContext } from "~/lib/study-context"; -import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { formatDistanceToNow } from "date-fns"; -// Mock chart component - replace with actual charting library -function MockChart({ title, data }: { title: string; data: number[] }) { - const maxValue = Math.max(...data); +// -- Sub-Components -- + +function AnalyticsContent({ + selectedTrialId, + setSelectedTrialId, + trialsList, + isLoadingList +}: { + selectedTrialId: string | null; + setSelectedTrialId: (id: string | null) => void; + trialsList: any[]; + isLoadingList: boolean; +}) { + + // Fetch full details of selected trial + const { + data: selectedTrial, + isLoading: isLoadingTrial, + error: trialError + } = api.trials.get.useQuery( + { id: selectedTrialId! }, + { enabled: !!selectedTrialId } + ); + + // Transform trial data + const trialData = selectedTrial ? { + ...selectedTrial, + startedAt: selectedTrial.startedAt ? new Date(selectedTrial.startedAt) : null, + completedAt: selectedTrial.completedAt ? new Date(selectedTrial.completedAt) : null, + eventCount: (selectedTrial as any).eventCount, + mediaCount: (selectedTrial as any).mediaCount, + } : null; return ( -
-

{title}

-
- {data.map((value, index) => ( -
+ {selectedTrialId ? ( + isLoadingTrial ? ( +
+
+
+ Loading trial data... +
+
+ ) : trialError ? ( +
+
+

Error Loading Trial

+

{trialError.message}

+ +
+
+ ) : trialData ? ( + + ) : null + ) : ( +
+ setSelectedTrialId(id)} /> - ))} -
+
+ )}
); } -function AnalyticsOverview() { - const metrics = [ - { - title: "Total Trials This Month", - value: "142", - change: "+12%", - trend: "up", - description: "vs last month", - icon: Activity, - }, - { - title: "Avg Trial Duration", - value: "24.5m", - change: "-3%", - trend: "down", - description: "vs last month", - icon: Calendar, - }, - { - title: "Completion Rate", - value: "94.2%", - change: "+2.1%", - trend: "up", - description: "vs last month", - icon: TrendingUp, - }, - { - title: "Participant Retention", - value: "87.3%", - change: "+5.4%", - trend: "up", - description: "vs last month", - icon: BarChart3, - }, - ]; +function StudyOverviewPlaceholder({ trials, onSelect }: { trials: any[], onSelect: (id: string) => void }) { + const recentTrials = [...trials].sort((a, b) => + new Date(b.startedAt || b.createdAt).getTime() - new Date(a.startedAt || a.createdAt).getTime() + ).slice(0, 5); return ( -
- {metrics.map((metric) => ( - - - - {metric.title} - - - - -
{metric.value}
-
- - {metric.trend === "up" ? ( - - ) : ( - - )} - {metric.change} - - {metric.description} +
+
+ {/* Left: Illustration / Prompt */} +
+
+ +
+
+

Analytics & Playback

+ + Select a session from the top right to review video recordings, event logs, and metrics. + +
+
+
+ + Feature-rich playback
- - - ))} -
- ); -} - -function ChartsSection() { - const trialData = [12, 19, 15, 27, 32, 28, 35, 42, 38, 41, 37, 44]; - const participantData = [8, 12, 10, 15, 18, 16, 20, 24, 22, 26, 23, 28]; - const completionData = [85, 88, 92, 89, 94, 91, 95, 92, 96, 94, 97, 94]; - - return ( -
- - - Trial Volume - Monthly trial execution trends - - - - - - - - - Participant Enrollment - New participants over time - - - - - - - - - Completion Rates - Trial completion percentage - - - - - -
- ); -} - -function RecentInsights() { - const insights = [ - { - title: "Peak Performance Hours", - description: - "Participants show 23% better performance during 10-11 AM trials", - type: "trend", - severity: "info", - }, - { - title: "Attention Span Decline", - description: - "Average attention span has decreased by 8% over the last month", - type: "alert", - severity: "warning", - }, - { - title: "High Completion Rate", - description: "Memory retention study achieved 98% completion rate", - type: "success", - severity: "success", - }, - { - title: "Equipment Utilization", - description: "Robot interaction trials are at 85% capacity utilization", - type: "info", - severity: "info", - }, - ]; - - const getSeverityColor = (severity: string) => { - switch (severity) { - case "success": - return "bg-green-50 text-green-700 border-green-200"; - case "warning": - return "bg-yellow-50 text-yellow-700 border-yellow-200"; - case "info": - return "bg-blue-50 text-blue-700 border-blue-200"; - default: - return "bg-gray-50 text-gray-700 border-gray-200"; - } - }; - - return ( - - - Recent Insights - - AI-generated insights from your research data - - - -
- {insights.map((insight, index) => ( -
-

{insight.title}

-

{insight.description}

+
+ + Synchronized timeline
- ))} +
-
-
- ); -} -function AnalyticsContent({ studyId: _studyId }: { studyId: string }) { - return ( -
- {/* Header with time range controls */} -
-
- - - -
-
- - {/* Overview Metrics */} - - - {/* Charts */} - - - {/* Insights */} -
-
- -
+ {/* Right: Recent Sessions */} - - Quick Actions - Generate custom reports + + Recent Sessions - - - - - + + +
+ {recentTrials.map(trial => ( + + ))} + {recentTrials.length === 0 && ( +
+ No sessions found. +
+ )} +
+
- ); + ) } +// -- Main Page -- + export default function StudyAnalyticsPage() { const params = useParams(); const studyId: string = typeof params.id === "string" ? params.id : ""; const { setSelectedStudyId, selectedStudyId } = useStudyContext(); const { study } = useSelectedStudyDetails(); + // State lifted up + const [selectedTrialId, setSelectedTrialId] = useState(null); + + // Fetch list of trials for the selector + const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery( + { studyId, limit: 100 }, + { enabled: !!studyId } + ); + // Set breadcrumbs useBreadcrumbsEffect([ { label: "Dashboard", href: "/dashboard" }, @@ -320,16 +217,53 @@ export default function StudyAnalyticsPage() { }, [studyId, selectedStudyId, setSelectedStudyId]); return ( -
- +
+
+ + {/* Session Selector in Header */} +
+ +
+
+ } + /> +
- Loading analytics...
}> - - +
+ Loading analytics...
}> + + +
); } diff --git a/src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx b/src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx index 403bbce..e773893 100755 --- a/src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx +++ b/src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx @@ -3,7 +3,7 @@ import { useParams } from "next/navigation"; import { Suspense, useEffect } from "react"; import Link from "next/link"; -import { Play, Zap, ArrowLeft, User, FlaskConical } from "lucide-react"; +import { Play, Zap, ArrowLeft, User, FlaskConical, LineChart } from "lucide-react"; import { PageHeader } from "~/components/ui/page-header"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; @@ -150,10 +150,18 @@ function TrialDetailContent() { )} {(trial.status === "in_progress" || trial.status === "scheduled") && ( + + )} + {trial.status === "completed" && ( )} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fdd54d7..e0dba04 100755 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,7 @@ import "~/styles/globals.css"; import { type Metadata } from "next"; -import { Geist } from "next/font/google"; +import { Inter } from "next/font/google"; import { SessionProvider } from "next-auth/react"; import { TRPCReactProvider } from "~/trpc/react"; @@ -13,16 +13,16 @@ export const metadata: Metadata = { icons: [{ rel: "icon", url: "/favicon.ico" }], }; -const geist = Geist({ +const inter = Inter({ subsets: ["latin"], - variable: "--font-geist-sans", + variable: "--font-sans", }); export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - + {children} diff --git a/src/components/experiments/designer/ActionRegistry.ts b/src/components/experiments/designer/ActionRegistry.ts index 1a50458..d1277e4 100755 --- a/src/components/experiments/designer/ActionRegistry.ts +++ b/src/components/experiments/designer/ActionRegistry.ts @@ -78,6 +78,7 @@ export class ActionRegistry { parameters?: CoreBlockParam[]; timeoutMs?: number; retryable?: boolean; + nestable?: boolean; } try { @@ -139,6 +140,7 @@ export class ActionRegistry { parameterSchemaRaw: { parameters: block.parameters ?? [], }, + nestable: block.nestable, }; this.actions.set(actionDef.id, actionDef); @@ -180,31 +182,33 @@ export class ActionRegistry { private loadFallbackActions(): void { const fallbackActions: ActionDefinition[] = [ { - id: "wizard_speak", - type: "wizard_speak", + id: "wizard_say", + type: "wizard_say", name: "Wizard Says", description: "Wizard speaks to participant", category: "wizard", icon: "MessageSquare", - color: "#3b82f6", + color: "#a855f7", parameters: [ { - id: "text", - name: "Text to say", + id: "message", + name: "Message", 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" }, + { + id: "tone", + name: "Tone", + type: "select", + options: ["neutral", "friendly", "encouraging"], + value: "neutral", }, - required: ["text"], - }, + ], + source: { kind: "core", baseActionId: "wizard_say" }, + execution: { transport: "internal", timeoutMs: 30000 }, + parameterSchemaRaw: {}, + nestable: false, }, { id: "wait", @@ -366,34 +370,34 @@ export class ActionRegistry { 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, - }, - } + 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: "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, - }; + transport: "internal" as const, + timeoutMs: action.timeout, + retryable: action.retryable, + }; const actionDef: ActionDefinition = { id: `${plugin.robotId ?? plugin.id}.${action.id}`, diff --git a/src/components/experiments/designer/DesignerRoot.tsx b/src/components/experiments/designer/DesignerRoot.tsx index c2bb523..73e0aa4 100755 --- a/src/components/experiments/designer/DesignerRoot.tsx +++ b/src/components/experiments/designer/DesignerRoot.tsx @@ -26,8 +26,10 @@ import { MouseSensor, TouchSensor, KeyboardSensor, + closestCorners, type DragEndEvent, type DragStartEvent, + type DragOverEvent, } from "@dnd-kit/core"; import { BottomStatusBar } from "./layout/BottomStatusBar"; import { ActionLibraryPanel } from "./panels/ActionLibraryPanel"; @@ -599,11 +601,8 @@ export function DesignerRoot({ // Serialize steps for stable comparison const stepsHash = useMemo(() => JSON.stringify(steps), [steps]); - useEffect(() => { - if (!initialized) return; - void recomputeHash(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stepsHash, initialized]); + // Intentionally removed redundant recomputeHash useEffect that was causing excessive refreshes + // The debounced useEffect (lines 352-372) handles this correctly. useEffect(() => { if (selectedStepId || selectedActionId) { @@ -628,18 +627,10 @@ export function DesignerRoot({ ) { e.preventDefault(); void persist(); - } else if (e.key === "v" && !e.metaKey && !e.ctrlKey) { - e.preventDefault(); - void validateDesign(); - } else if (e.key === "e" && !e.metaKey && !e.ctrlKey) { - e.preventDefault(); - void handleExport(); - } else if (e.key === "n" && e.shiftKey) { - e.preventDefault(); - createNewStep(); } + // 'v' (validate), 'e' (export), 'Shift+N' (new step) shortcuts removed to prevent accidents }, - [hasUnsavedChanges, persist, validateDesign, handleExport, createNewStep], + [hasUnsavedChanges, persist], ); useEffect(() => { @@ -687,43 +678,163 @@ export function DesignerRoot({ [toggleLibraryScrollLock], ); - const handleDragEnd = useCallback( - async (event: DragEndEvent) => { - const { active, over } = event; - console.debug("[DesignerRoot] dragEnd", { - active: active?.id, - over: over?.id ?? null, - }); - // Clear overlay immediately - toggleLibraryScrollLock(false); - setDragOverlayAction(null); - if (!over) { - console.debug("[DesignerRoot] dragEnd: no drop target (ignored)"); + const handleDragOver = useCallback((event: DragOverEvent) => { + const { active, over } = event; + const store = useDesignerStore.getState(); + + // Only handle Library -> Flow projection + if (!active.id.toString().startsWith("action-")) { + if (store.insertionProjection) { + store.setInsertionProjection(null); + } + return; + } + + if (!over) { + if (store.insertionProjection) { + store.setInsertionProjection(null); + } + return; + } + + const overId = over.id.toString(); + const activeDef = active.data.current?.action; + + if (!activeDef) return; + + let stepId: string | null = null; + let parentId: string | null = null; + let index = 0; + + // Detect target based on over id + if (overId.startsWith("s-act-")) { + const data = over.data.current; + if (data && data.stepId) { + stepId = data.stepId; + parentId = data.parentId ?? null; // Use parentId from the action we are hovering over + // Use sortable index (insertion point provided by dnd-kit sortable strategy) + index = data.sortable?.index ?? 0; + } + } else if (overId.startsWith("container-")) { + // Dropping into a container (e.g. Loop) + const data = over.data.current; + if (data && data.stepId) { + stepId = data.stepId; + parentId = data.parentId ?? overId.slice("container-".length); + // If dropping into container, appending is a safe default if specific index logic is missing + // But actually we can find length if we want. For now, 0 or append logic? + // If container is empty, index 0 is correct. + // If not empty, we are hitting the container *background*, so append? + // The projection logic will insert at 'index'. If index is past length, it appends. + // Let's set a large index to ensure append, or look up length. + // Lookup requires finding the action in store. Expensive? + // Let's assume index 0 for now (prepend) or implement lookup. + // Better: lookup action -> children length. + const actionId = parentId; + const step = store.steps.find(s => s.id === stepId); + // Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here? + // Actually, `store.steps` is available. + // We can implement a quick BFS/DFS or just assume 0. + // If dragging over the container *background* (empty space), append is usually expected. + // Let's try 9999? + index = 9999; + } + } else if (overId.startsWith("s-step-") || overId.startsWith("step-")) { + // Container drop (Step) + stepId = overId.startsWith("s-step-") + ? overId.slice("s-step-".length) + : overId.slice("step-".length); + const step = store.steps.find((s) => s.id === stepId); + index = step ? step.actions.length : 0; + + } else if (overId === "projection-placeholder") { + // Hovering over our own projection placeholder -> keep current state + return; + } + + if (stepId) { + const current = store.insertionProjection; + // Optimization: avoid redundant updates if projection matches + if ( + current && + current.stepId === stepId && + current.parentId === parentId && + current.index === index + ) { return; } - // Expect dragged action (library) onto a step droppable - const activeId = active.id.toString(); - const overId = over.id.toString(); + store.setInsertionProjection({ + stepId, + parentId, + index, + action: { + id: "projection-placeholder", + type: activeDef.type, + name: activeDef.name, + category: activeDef.category, + description: "Drop here", + source: activeDef.source || { kind: "library" }, + parameters: {}, + execution: activeDef.execution, + } as any, + }); + } else { + if (store.insertionProjection) store.setInsertionProjection(null); + } + }, []); - if (activeId.startsWith("action-") && active.data.current?.action) { - // Resolve stepId from possible over ids: step-, s-step-, or s-act- - let stepId: string | null = null; + const handleDragEnd = useCallback( + async (event: DragEndEvent) => { + const { active, over } = event; + + // Clear overlay immediately + toggleLibraryScrollLock(false); + setDragOverlayAction(null); + + // Capture and clear projection + const store = useDesignerStore.getState(); + const projection = store.insertionProjection; + store.setInsertionProjection(null); + + if (!over) { + return; + } + + // 1. Determine Target (Step, Parent, Index) + let stepId: string | null = null; + let parentId: string | null = null; + let index: number | undefined = undefined; + + if (projection) { + stepId = projection.stepId; + parentId = projection.parentId; + index = projection.index; + } else { + // Fallback: resolution from overId (if projection failed or raced) + const overId = over.id.toString(); if (overId.startsWith("step-")) { stepId = overId.slice("step-".length); } else if (overId.startsWith("s-step-")) { stepId = overId.slice("s-step-".length); } else if (overId.startsWith("s-act-")) { + // This might fail if s-act-projection, but that should have covered by projection check above const actionId = overId.slice("s-act-".length); const parent = steps.find((s) => s.actions.some((a) => a.id === actionId), ); stepId = parent?.id ?? null; } - if (!stepId) return; + } + if (!stepId) return; + const targetStep = steps.find((s) => s.id === stepId); + if (!targetStep) return; + + // 2. Instantiate Action + if (active.id.toString().startsWith("action-") && active.data.current?.action) { const actionDef = active.data.current.action as { - id: string; + id: string; // type type: string; name: string; category: string; @@ -733,14 +844,13 @@ export function DesignerRoot({ parameters: Array<{ id: string; name: string }>; }; - const targetStep = steps.find((s) => s.id === stepId); - if (!targetStep) return; - const fullDef = actionRegistry.getAction(actionDef.type); const defaultParams: Record = {}; if (fullDef?.parameters) { for (const param of fullDef.parameters) { + // @ts-expect-error - 'default' property access if (param.default !== undefined) { + // @ts-expect-error - 'default' property access defaultParams[param.id] = param.default; } } @@ -755,39 +865,61 @@ export function DesignerRoot({ transport: actionDef.execution.transport, retryable: actionDef.execution.retryable ?? false, } - : { - transport: "internal", - retryable: false, - }; + : undefined; + const newAction: ExperimentAction = { - id: `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - type: actionDef.type, + id: crypto.randomUUID(), + type: actionDef.type, // this is the 'type' key name: actionDef.name, - category: actionDef.category as ExperimentAction["category"], + category: actionDef.category as any, + description: "", parameters: defaultParams, - source: actionDef.source as ExperimentAction["source"], + source: actionDef.source ? { + kind: actionDef.source.kind as any, + pluginId: actionDef.source.pluginId, + pluginVersion: actionDef.source.pluginVersion, + baseActionId: actionDef.id + } : { kind: "core" }, execution, + children: [], }; - upsertAction(stepId, newAction); - // Select the newly added action and open properties - selectStep(stepId); + // 3. Commit + upsertAction(stepId, newAction, parentId, index); + + // Auto-select selectAction(stepId, newAction.id); - setInspectorTab("properties"); - await recomputeHash(); - toast.success(`Added ${actionDef.name} to ${targetStep.name}`); + + void recomputeHash(); } }, - [ - steps, - upsertAction, - recomputeHash, - selectStep, - selectAction, - toggleLibraryScrollLock, - ], + [steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock], ); // validation status badges removed (unused) + /* ------------------------------- Panels ---------------------------------- */ + const leftPanel = useMemo( + () => ( +
+ +
+ ), + [], + ); + + const centerPanel = useMemo(() => , []); + + const rightPanel = useMemo( + () => ( +
+ +
+ ), + [inspectorTab, studyPlugins], + ); /* ------------------------------- Render ---------------------------------- */ if (loadingExperiment && !initialized) { @@ -852,33 +984,33 @@ export function DesignerRoot({
toggleLibraryScrollLock(false)} > - -
- } - center={} - right={ -
- -
- } + left={leftPanel} + center={centerPanel} + right={rightPanel} /> {dragOverlayAction ? ( -
+
+ {dragOverlayAction.name}
) : null} diff --git a/src/components/experiments/designer/PropertiesPanel.tsx b/src/components/experiments/designer/PropertiesPanel.tsx index 97d8af9..0318cea 100755 --- a/src/components/experiments/designer/PropertiesPanel.tsx +++ b/src/components/experiments/designer/PropertiesPanel.tsx @@ -282,205 +282,22 @@ export function PropertiesPanelBase({ Parameters
- {def.parameters.map((param) => { - const rawValue = selectedAction.parameters[param.id]; - const commonLabel = ( - - ); - - /* ---- Handlers ---- */ - const updateParamValue = (value: unknown) => { - setLocalParams((prev) => ({ ...prev, [param.id]: value })); - debouncedParamUpdate( - containingStep.id, - selectedAction.id, - param.id, - value, - ); - }; - - const updateParamValueImmediate = (value: unknown) => { - setLocalParams((prev) => ({ ...prev, [param.id]: value })); - onActionUpdate(containingStep.id, selectedAction.id, { - parameters: { - ...selectedAction.parameters, - [param.id]: value, - }, - }); - }; - - const updateParamLocal = (value: unknown) => { - setLocalParams((prev) => ({ ...prev, [param.id]: value })); - }; - - const commitParamValue = () => { - if (localParams[param.id] !== rawValue) { + {def.parameters.map((param) => ( + { onActionUpdate(containingStep.id, selectedAction.id, { parameters: { ...selectedAction.parameters, - [param.id]: localParams[param.id], + [param.id]: val, }, }); - } - }; - - /* ---- Control Rendering ---- */ - let control: React.ReactNode = null; - - if (param.type === "text") { - const localValue = localParams[param.id] ?? rawValue ?? ""; - control = ( - updateParamValue(e.target.value)} - onBlur={() => { - if (localParams[param.id] !== rawValue) { - onActionUpdate(containingStep.id, selectedAction.id, { - parameters: { - ...selectedAction.parameters, - [param.id]: localParams[param.id], - }, - }); - } - }} - className="mt-1 h-7 w-full text-xs" - /> - ); - } else if (param.type === "select") { - const localValue = localParams[param.id] ?? rawValue ?? ""; - control = ( - - ); - } else if (param.type === "boolean") { - const localValue = localParams[param.id] ?? rawValue ?? false; - control = ( -
- - updateParamValueImmediate(val) - } - aria-label={param.name} - /> - - {Boolean(localValue) ? "Enabled" : "Disabled"} - -
- ); - } else if (param.type === "number") { - const localValue = localParams[param.id] ?? rawValue; - const numericVal = - typeof localValue === "number" - ? localValue - : 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 = ( -
-
- - updateParamLocal(vals[0]) - } - onPointerUp={commitParamValue} - /> - - {step < 1 - ? Number(numericVal).toFixed(2) - : Number(numericVal).toString()} - -
-
- {min} - {max} -
-
- ); - } else { - control = ( - - updateParamValue(parseFloat(e.target.value) || 0) - } - onBlur={() => { - if (localParams[param.id] !== rawValue) { - onActionUpdate( - containingStep.id, - selectedAction.id, - { - parameters: { - ...selectedAction.parameters, - [param.id]: localParams[param.id], - }, - }, - ); - } - }} - className="mt-1 h-7 w-full text-xs" - /> - ); - } - } - - return ( -
- {commonLabel} - {param.description && ( -
- {param.description} -
- )} - {control} -
- ); - })} + }} + onCommit={() => { }} + /> + ))}
) : ( @@ -635,3 +452,156 @@ export function PropertiesPanelBase({ } export const PropertiesPanel = React.memo(PropertiesPanelBase); + +/* -------------------------------------------------------------------------- */ +/* Isolated Parameter Editor (Optimized) */ +/* -------------------------------------------------------------------------- */ + +interface ParameterEditorProps { + param: any; + value: unknown; + onUpdate: (value: unknown) => void; + onCommit: () => void; +} + +const ParameterEditor = React.memo(function ParameterEditor({ + param, + value: rawValue, + onUpdate, + onCommit +}: ParameterEditorProps) { + // Local state for immediate feedback + const [localValue, setLocalValue] = useState(rawValue); + const debounceRef = useRef(); + + // Sync from prop if it changes externally + useEffect(() => { + setLocalValue(rawValue); + }, [rawValue]); + + const handleUpdate = useCallback((newVal: unknown, immediate = false) => { + setLocalValue(newVal); + + if (debounceRef.current) clearTimeout(debounceRef.current); + + if (immediate) { + onUpdate(newVal); + } else { + debounceRef.current = setTimeout(() => { + onUpdate(newVal); + }, 300); + } + }, [onUpdate]); + + const handleCommit = useCallback(() => { + if (localValue !== rawValue) { + onUpdate(localValue); + } + }, [localValue, rawValue, onUpdate]); + + let control: React.ReactNode = null; + + if (param.type === "text") { + control = ( + handleUpdate(e.target.value)} + onBlur={handleCommit} + className="mt-1 h-7 w-full text-xs" + /> + ); + } else if (param.type === "select") { + control = ( + + ); + } else if (param.type === "boolean") { + control = ( +
+ handleUpdate(val, true)} + aria-label={param.name} + /> + + {Boolean(localValue) ? "Enabled" : "Disabled"} + +
+ ); + } else if (param.type === "number") { + const numericVal = typeof localValue === "number" ? localValue : (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); + const range = max - min; + const step = param.step ?? (range <= 5 ? 0.1 : range <= 50 ? 0.5 : Math.max(1, Math.round(range / 100))); + + control = ( +
+
+ setLocalValue(vals[0])} // Update only local visual + onPointerUp={() => handleUpdate(localValue)} // Commit on release + /> + + {step < 1 ? Number(numericVal).toFixed(2) : Number(numericVal).toString()} + +
+
+ {min} + {max} +
+
+ ); + } else { + control = ( + handleUpdate(parseFloat(e.target.value) || 0)} + onBlur={handleCommit} + className="mt-1 h-7 w-full text-xs" + /> + ); + } + } + + return ( +
+ + {param.description && ( +
+ {param.description} +
+ )} + {control} +
+ ); +}); diff --git a/src/components/experiments/designer/flow/FlowWorkspace.tsx b/src/components/experiments/designer/flow/FlowWorkspace.tsx index 6154f8b..047ab89 100755 --- a/src/components/experiments/designer/flow/FlowWorkspace.tsx +++ b/src/components/experiments/designer/flow/FlowWorkspace.tsx @@ -12,6 +12,7 @@ import { useDndMonitor, type DragEndEvent, type DragStartEvent, + type DragOverEvent, } from "@dnd-kit/core"; import { useSortable, @@ -68,7 +69,7 @@ interface FlowWorkspaceProps { onActionCreate?: (stepId: string, action: ExperimentAction) => void; } -interface VirtualItem { +export interface VirtualItem { index: number; top: number; height: number; @@ -77,6 +78,232 @@ interface VirtualItem { visible: boolean; } +interface StepRowProps { + item: VirtualItem; + selectedStepId: string | null | undefined; + selectedActionId: string | null | undefined; + renamingStepId: string | null; + onSelectStep: (id: string | undefined) => void; + onSelectAction: (stepId: string, actionId: string | undefined) => void; + onToggleExpanded: (step: ExperimentStep) => void; + onRenameStep: (step: ExperimentStep, name: string) => void; + onDeleteStep: (step: ExperimentStep) => void; + onDeleteAction: (stepId: string, actionId: string) => void; + setRenamingStepId: (id: string | null) => void; + registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void; +} + +const StepRow = React.memo(function StepRow({ + item, + selectedStepId, + selectedActionId, + renamingStepId, + onSelectStep, + onSelectAction, + onToggleExpanded, + onRenameStep, + onDeleteStep, + onDeleteAction, + setRenamingStepId, + registerMeasureRef, +}: StepRowProps) { + const step = item.step; + const insertionProjection = useDesignerStore((s) => s.insertionProjection); + + const displayActions = useMemo(() => { + if ( + insertionProjection?.stepId === step.id && + insertionProjection.parentId === null + ) { + const copy = [...step.actions]; + // Insert placeholder action + // Ensure specific ID doesn't crash keys if collision (collision unlikely for library items) + // Actually, standard array key is action.id. + copy.splice(insertionProjection.index, 0, insertionProjection.action); + return copy; + } + return step.actions; + }, [step.actions, step.id, insertionProjection]); + + const { + setNodeRef, + transform, + transition, + attributes, + listeners, + isDragging, + } = useSortable({ + id: sortableStepId(step.id), + data: { + type: "step", + step: step, + }, + }); + + const style: React.CSSProperties = { + position: "absolute", + top: item.top, + left: 0, + right: 0, + width: "100%", + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 25 : undefined, + }; + + return ( +
+
registerMeasureRef(step.id, el)} + className="relative px-3 py-4" + data-step-id={step.id} + > + +
+
{ + const tag = (e.target as HTMLElement).tagName.toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "button") + return; + onSelectStep(step.id); + onSelectAction(step.id, undefined); + }} + role="button" + tabIndex={0} + > +
+ + + {step.order + 1} + + {renamingStepId === step.id ? ( + e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter") { + onRenameStep( + step, + (e.target as HTMLInputElement).value.trim() || + step.name, + ); + setRenamingStepId(null); + } else if (e.key === "Escape") { + setRenamingStepId(null); + } + }} + onBlur={(e) => { + onRenameStep(step, e.target.value.trim() || step.name); + setRenamingStepId(null); + }} + /> + ) : ( +
+ {step.name} + +
+ )} + + {step.actions.length} actions + +
+
+ +
+ +
+
+
+ + {/* Action List (Collapsible/Virtual content) */} + {step.expanded && ( +
+ sortableActionId(a.id))} + strategy={verticalListSortingStrategy} + > +
+ {displayActions.length === 0 ? ( +
+ Drop actions here +
+ ) : ( + displayActions.map((action) => ( + + )) + )} +
+
+
+ )} +
+
+
+ ); +}); + /* -------------------------------------------------------------------------- */ /* Utility */ /* -------------------------------------------------------------------------- */ @@ -122,37 +349,125 @@ function StepDroppableArea({ stepId }: { stepId: string }) { /* -------------------------------------------------------------------------- */ interface ActionChipProps { + stepId: string; action: ExperimentAction; - isSelected: boolean; - onSelect: () => void; - onDelete: () => void; + parentId: string | null; + selectedActionId: string | null | undefined; + onSelectAction: (stepId: string, actionId: string | undefined) => void; + onDeleteAction: (stepId: string, actionId: string) => void; dragHandle?: boolean; } function SortableActionChip({ + stepId, action, - isSelected, - onSelect, - onDelete, + parentId, + selectedActionId, + onSelectAction, + onDeleteAction, + dragHandle, }: ActionChipProps) { const def = actionRegistry.getAction(action.type); + const isSelected = selectedActionId === action.id; + + const insertionProjection = useDesignerStore((s) => s.insertionProjection); + const displayChildren = useMemo(() => { + if ( + insertionProjection?.stepId === stepId && + insertionProjection.parentId === action.id + ) { + const copy = [...(action.children || [])]; + copy.splice(insertionProjection.index, 0, insertionProjection.action); + return copy; + } + return action.children; + }, [action.children, action.id, stepId, insertionProjection]); + + /* ------------------------------------------------------------------------ */ + /* Main Sortable Logic */ + /* ------------------------------------------------------------------------ */ + const isPlaceholder = action.id === "projection-placeholder"; + const { attributes, listeners, setNodeRef, transform, transition, - isDragging, + isDragging: isSortableDragging, } = useSortable({ id: sortableActionId(action.id), + disabled: isPlaceholder, // Disable sortable for placeholder + data: { + type: "action", + stepId, + parentId, + id: action.id, + }, }); - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), + // Use local dragging state or passed prop + const isDragging = isSortableDragging || dragHandle; + + const style = { + transform: CSS.Translate.toString(transform), transition, - zIndex: isDragging ? 30 : undefined, }; + /* ------------------------------------------------------------------------ */ + /* Nested Droppable (for control flow containers) */ + /* ------------------------------------------------------------------------ */ + const nestedDroppableId = `container-${action.id}`; + const { + isOver: isOverNested, + setNodeRef: setNestedNodeRef + } = useDroppable({ + id: nestedDroppableId, + disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder + data: { + type: "container", + stepId, + parentId: action.id, + action // Pass full action for projection logic + } + }); + + const shouldRenderChildren = def?.nestable; + + if (isPlaceholder) { + const { setNodeRef: setPlaceholderRef } = useDroppable({ + id: "projection-placeholder", + data: { type: "placeholder" } + }); + + // Render simplified placeholder without hooks refs + // We still render the content matching the action type for visual fidelity + return ( +
+
+ + {def?.name ?? action.name} +
+ {def?.description && ( +
+ {def.description} +
+ )} +
+ ); + } + return (
{ + e.stopPropagation(); + onSelectAction(stepId, action.id); + }} {...attributes} role="button" aria-pressed={isSelected} @@ -197,7 +517,7 @@ function SortableActionChip({ type="button" onClick={(e) => { e.stopPropagation(); - onDelete(); + onDeleteAction(stepId, action.id); }} className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100" aria-label="Delete action" @@ -221,12 +541,45 @@ function SortableActionChip({ ))} {def.parameters.length > 4 && ( - - +{def.parameters.length - 4} more - + +{def.parameters.length - 4} )}
) : null} + + {/* Nested Actions Container */} + {shouldRenderChildren && ( +
+ c.id !== "projection-placeholder") + .map(c => sortableActionId(c.id))} + strategy={verticalListSortingStrategy} + > + {(displayChildren || action.children || []).map((child) => ( + + ))} + {(!displayChildren?.length && !action.children?.length) && ( +
+ Drag actions here +
+ )} +
+
+ )} +
); } @@ -254,7 +607,7 @@ export function FlowWorkspace({ const removeAction = useDesignerStore((s) => s.removeAction); const reorderStep = useDesignerStore((s) => s.reorderStep); - const reorderAction = useDesignerStore((s) => s.reorderAction); + const moveAction = useDesignerStore((s) => s.moveAction); const recomputeHash = useDesignerStore((s) => s.recomputeHash); /* Local state */ @@ -382,7 +735,10 @@ export function FlowWorkspace({ description: "", type: "sequential", order: steps.length, - trigger: { type: "trial_start", conditions: {} }, + trigger: + steps.length === 0 + ? { type: "trial_start", conditions: {} } + : { type: "previous_step", conditions: {} }, actions: [], expanded: true, }; @@ -472,34 +828,77 @@ export function FlowWorkspace({ } } } - // Action reorder (within same parent only) + // Action reorder (supports nesting) if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) { - const fromActionId = parseSortableAction(activeId); - const toActionId = parseSortableAction(overId); - if (fromActionId && toActionId && fromActionId !== toActionId) { - const fromParent = actionParentMap.get(fromActionId); - const toParent = actionParentMap.get(toActionId); - if (fromParent && toParent && fromParent === toParent) { - const step = steps.find((s) => s.id === fromParent); - if (step) { - const fromIdx = step.actions.findIndex( - (a) => a.id === fromActionId, - ); - const toIdx = step.actions.findIndex((a) => a.id === toActionId); - if (fromIdx >= 0 && toIdx >= 0) { - reorderAction(step.id, fromIdx, toIdx); - void recomputeHash(); - } - } + const activeData = active.data.current; + const overData = over.data.current; + + if ( + activeData && overData && + activeData.stepId === overData.stepId && + activeData.type === 'action' && overData.type === 'action' + ) { + const stepId = activeData.stepId as string; + const activeActionId = activeData.action.id; + const overActionId = overData.action.id; + + if (activeActionId !== overActionId) { + const newParentId = overData.parentId as string | null; + const newIndex = overData.sortable.index; // index within that parent's list + + moveAction(stepId, activeActionId, newParentId, newIndex); + void recomputeHash(); } } } }, - [steps, reorderStep, reorderAction, actionParentMap, recomputeHash], + [steps, reorderStep, moveAction, recomputeHash], + ); + + /* ------------------------------------------------------------------------ */ + /* Drag Over (Live Sorting) */ + /* ------------------------------------------------------------------------ */ + const handleLocalDragOver = useCallback( + (event: DragOverEvent) => { + const { active, over } = event; + if (!over) return; + + const activeId = active.id.toString(); + const overId = over.id.toString(); + + // Only handle action reordering + if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) { + const activeData = active.data.current; + const overData = over.data.current; + + if ( + activeData && + overData && + activeData.type === 'action' && + overData.type === 'action' + ) { + const activeActionId = activeData.action.id; + const overActionId = overData.action.id; + const activeStepId = activeData.stepId; + const overStepId = overData.stepId; + const activeParentId = activeData.parentId; + const overParentId = overData.parentId; + + // If moving between different lists (parents/steps), move immediately to visualize snap + if (activeParentId !== overParentId || activeStepId !== overStepId) { + // Determine new index + // verification of safe move handled by store + moveAction(overStepId, activeActionId, overParentId, overData.sortable.index); + } + } + } + }, + [moveAction] ); useDndMonitor({ onDragStart: handleLocalDragStart, + onDragOver: handleLocalDragOver, onDragEnd: handleLocalDragEnd, onDragCancel: () => { // no-op @@ -509,204 +908,22 @@ export function FlowWorkspace({ /* ------------------------------------------------------------------------ */ /* Step Row (Sortable + Virtualized) */ /* ------------------------------------------------------------------------ */ - function StepRow({ item }: { item: VirtualItem }) { - const step = item.step; - const { - setNodeRef, - transform, - transition, - attributes, - listeners, - isDragging, - } = useSortable({ - id: sortableStepId(step.id), - }); + // StepRow moved outside of component to prevent re-mounting on every render (flashing fix) - const style: React.CSSProperties = { - position: "absolute", - top: item.top, - left: 0, - right: 0, - width: "100%", - transform: CSS.Transform.toString(transform), - transition, - zIndex: isDragging ? 25 : undefined, - }; - - const setMeasureRef = (el: HTMLDivElement | null) => { - const prev = measureRefs.current.get(step.id) ?? null; + const registerMeasureRef = useCallback( + (stepId: string, el: HTMLDivElement | null) => { + const prev = measureRefs.current.get(stepId) ?? null; if (prev && prev !== el) { roRef.current?.unobserve(prev); - measureRefs.current.delete(step.id); + measureRefs.current.delete(stepId); } if (el) { - measureRefs.current.set(step.id, el); + measureRefs.current.set(stepId, el); roRef.current?.observe(el); } - }; - - return ( -
-
- -
-
{ - // Avoid selecting step when interacting with controls or inputs - const tag = (e.target as HTMLElement).tagName.toLowerCase(); - if (tag === "input" || tag === "textarea" || tag === "button") - return; - selectStep(step.id); - selectAction(step.id, undefined); - }} - role="button" - tabIndex={0} - > -
- - - {step.order + 1} - - {renamingStepId === step.id ? ( - e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Enter") { - renameStep( - step, - (e.target as HTMLInputElement).value.trim() || - step.name, - ); - setRenamingStepId(null); - void recomputeHash(); - } else if (e.key === "Escape") { - setRenamingStepId(null); - } - }} - onBlur={(e) => { - renameStep(step, e.target.value.trim() || step.name); - setRenamingStepId(null); - void recomputeHash(); - }} - /> - ) : ( -
- {step.name} - -
- )} - - {step.actions.length} actions - -
-
- -
- -
-
-
- - {step.expanded && ( -
-
- {step.actions.length > 0 && ( - sortableActionId(a.id))} - strategy={verticalListSortingStrategy} - > -
- {step.actions.map((action) => ( - { - selectStep(step.id); - selectAction(step.id, action.id); - }} - onDelete={() => deleteAction(step.id, action.id)} - /> - ))} -
-
- )} -
- {/* Persistent centered bottom drop hint */} -
-
- Drop actions here -
-
-
- )} -
-
-
- ); - } + }, + [], + ); /* ------------------------------------------------------------------------ */ /* Render */ @@ -767,7 +984,27 @@ export function FlowWorkspace({ >
{virtualItems.map( - (vi) => vi.visible && , + (vi) => + vi.visible && ( + { + renameStep(step, name); + void recomputeHash(); + }} + onDeleteStep={deleteStep} + onDeleteAction={deleteAction} + setRenamingStepId={setRenamingStepId} + registerMeasureRef={registerMeasureRef} + /> + ), )}
diff --git a/src/components/experiments/designer/layout/PanelsContainer.tsx b/src/components/experiments/designer/layout/PanelsContainer.tsx index c761e17..8bf80da 100755 --- a/src/components/experiments/designer/layout/PanelsContainer.tsx +++ b/src/components/experiments/designer/layout/PanelsContainer.tsx @@ -53,6 +53,30 @@ export interface PanelsContainerProps { * - Resize handles are absolutely positioned over the grid at the left and right boundaries. * - Fractions are clamped with configurable min/max so panels remain usable at all sizes. */ +const Panel: React.FC> = ({ + className: panelCls, + panelClassName, + contentClassName, + children, +}) => ( +
+
+ {children} +
+
+ ); + export function PanelsContainer({ left, center, @@ -209,10 +233,10 @@ export function PanelsContainer({ // CSS variables for the grid fractions const styleVars: React.CSSProperties & Record = hasCenter ? { - "--col-left": `${(hasLeft ? l : 0) * 100}%`, - "--col-center": `${c * 100}%`, - "--col-right": `${(hasRight ? r : 0) * 100}%`, - } + "--col-left": `${(hasLeft ? l : 0) * 100}%`, + "--col-center": `${c * 100}%`, + "--col-right": `${(hasRight ? r : 0) * 100}%`, + } : {}; // Explicit grid template depending on which side panels exist @@ -229,28 +253,12 @@ export function PanelsContainer({ const centerDividers = showDividers && hasCenter ? cn({ - "border-l": hasLeft, - "border-r": hasRight, - }) + "border-l": hasLeft, + "border-r": hasRight, + }) : undefined; - const Panel: React.FC> = ({ - className: panelCls, - children, - }) => ( -
-
- {children} -
-
- ); + return (
- {hasLeft && {left}} + {hasLeft && ( + + {left} + + )} - {hasCenter && {center}} + {hasCenter && ( + + {center} + + )} - {hasRight && {right}} + {hasRight && ( + + {right} + + )} {/* Resize handles (only render where applicable) */} {hasCenter && hasLeft && ( diff --git a/src/components/experiments/designer/panels/ActionLibraryPanel.tsx b/src/components/experiments/designer/panels/ActionLibraryPanel.tsx index cd254f6..1a337ff 100755 --- a/src/components/experiments/designer/panels/ActionLibraryPanel.tsx +++ b/src/components/experiments/designer/panels/ActionLibraryPanel.tsx @@ -174,7 +174,7 @@ export function ActionLibraryPanel() { const [search, setSearch] = useState(""); const [selectedCategories, setSelectedCategories] = useState< Set - >(new Set(["wizard"])); + >(new Set(["wizard", "robot", "control", "observation"])); const [favorites, setFavorites] = useState({ favorites: new Set(), }); @@ -293,9 +293,7 @@ export function ActionLibraryPanel() { setShowOnlyFavorites(false); }, [categories]); - useEffect(() => { - setSelectedCategories(new Set(categories.map((c) => c.key))); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + const filtered = useMemo(() => { const activeCats = selectedCategories; diff --git a/src/components/experiments/designer/state/hashing.ts b/src/components/experiments/designer/state/hashing.ts index 7c4a50e..302a698 100755 --- a/src/components/experiments/designer/state/hashing.ts +++ b/src/components/experiments/designer/state/hashing.ts @@ -155,8 +155,9 @@ function projectActionForDesign( pluginVersion: action.source.pluginVersion, baseActionId: action.source.baseActionId, }, - execution: projectExecutionDescriptor(action.execution), + execution: action.execution ? projectExecutionDescriptor(action.execution) : null, parameterKeysOrValues: parameterProjection, + children: action.children?.map(c => projectActionForDesign(c, options)) ?? [], }; if (options.includeActionNames) { diff --git a/src/components/experiments/designer/state/store.ts b/src/components/experiments/designer/state/store.ts index 1695559..6e3f136 100755 --- a/src/components/experiments/designer/state/store.ts +++ b/src/components/experiments/designer/state/store.ts @@ -79,6 +79,23 @@ export interface DesignerState { busyHashing: boolean; busyValidating: boolean; + /* ---------------------- DnD Projection (Transient) ----------------------- */ + insertionProjection: { + stepId: string; + parentId: string | null; + index: number; + action: ExperimentAction; + } | null; + + setInsertionProjection: ( + projection: { + stepId: string; + parentId: string | null; + index: number; + action: ExperimentAction; + } | null + ) => void; + /* ------------------------------ Mutators --------------------------------- */ // Selection @@ -92,9 +109,10 @@ export interface DesignerState { reorderStep: (from: number, to: number) => void; // Actions - upsertAction: (stepId: string, action: ExperimentAction) => void; + upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => void; removeAction: (stepId: string, actionId: string) => void; reorderAction: (stepId: string, from: number, to: number) => void; + moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => void; // Dirty markDirty: (id: string) => void; @@ -159,17 +177,73 @@ function reindexActions(actions: ExperimentAction[]): ExperimentAction[] { return actions.map((a) => ({ ...a })); } -function updateActionList( - existing: ExperimentAction[], +function findActionById( + list: ExperimentAction[], + id: string, +): ExperimentAction | null { + for (const action of list) { + if (action.id === id) return action; + if (action.children) { + const found = findActionById(action.children, id); + if (found) return found; + } + } + return null; +} + +function updateActionInTree( + list: ExperimentAction[], action: ExperimentAction, ): ExperimentAction[] { - const idx = existing.findIndex((a) => a.id === action.id); - if (idx >= 0) { - const copy = [...existing]; - copy[idx] = { ...action }; + return list.map((a) => { + if (a.id === action.id) return { ...action }; + if (a.children) { + return { ...a, children: updateActionInTree(a.children, action) }; + } + return a; + }); +} + +// Immutable removal +function removeActionFromTree( + list: ExperimentAction[], + id: string, +): ExperimentAction[] { + return list + .filter((a) => a.id !== id) + .map((a) => ({ + ...a, + children: a.children ? removeActionFromTree(a.children, id) : undefined, + })); +} + +// Immutable insertion +function insertActionIntoTree( + list: ExperimentAction[], + action: ExperimentAction, + parentId: string | null, + index: number, +): ExperimentAction[] { + if (!parentId) { + // Insert at root level + const copy = [...list]; + copy.splice(index, 0, action); return copy; } - return [...existing, { ...action }]; + return list.map((a) => { + if (a.id === parentId) { + const children = a.children ? [...a.children] : []; + children.splice(index, 0, action); + return { ...a, children }; + } + if (a.children) { + return { + ...a, + children: insertActionIntoTree(a.children, action, parentId, index), + }; + } + return a; + }); } /* -------------------------------------------------------------------------- */ @@ -187,6 +261,7 @@ export const useDesignerStore = create((set, get) => ({ autoSaveEnabled: true, busyHashing: false, busyValidating: false, + insertionProjection: null, /* ------------------------------ Selection -------------------------------- */ selectStep: (id) => @@ -263,16 +338,31 @@ export const useDesignerStore = create((set, get) => ({ }), /* ------------------------------- Actions --------------------------------- */ - upsertAction: (stepId: string, action: ExperimentAction) => + upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) => set((state: DesignerState) => { - const stepsDraft: ExperimentStep[] = state.steps.map((s) => - s.id === stepId - ? { - ...s, - actions: reindexActions(updateActionList(s.actions, action)), - } - : s, - ); + const stepsDraft: ExperimentStep[] = state.steps.map((s) => { + if (s.id !== stepId) return s; + + // Check if exists (update) + const exists = findActionById(s.actions, action.id); + if (exists) { + // If updating, we don't (currently) support moving via upsert. + // Use moveAction for moving. + return { + ...s, + actions: updateActionInTree(s.actions, action) + }; + } + + // Add new + // If index is provided, use it. Otherwise append. + const insertIndex = index ?? s.actions.length; + + return { + ...s, + actions: insertActionIntoTree(s.actions, action, parentId, insertIndex) + }; + }); return { steps: stepsDraft, dirtyEntities: new Set([ @@ -288,11 +378,9 @@ export const useDesignerStore = create((set, get) => ({ const stepsDraft: ExperimentStep[] = state.steps.map((s) => s.id === stepId ? { - ...s, - actions: reindexActions( - s.actions.filter((a) => a.id !== actionId), - ), - } + ...s, + actions: removeActionFromTree(s.actions, actionId), + } : s, ); const dirty = new Set(state.dirtyEntities); @@ -308,31 +396,29 @@ export const useDesignerStore = create((set, get) => ({ }; }), - reorderAction: (stepId: string, from: number, to: number) => + moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => set((state: DesignerState) => { - const stepsDraft: ExperimentStep[] = state.steps.map((s) => { + const stepsDraft = state.steps.map((s) => { if (s.id !== stepId) return s; - if ( - from < 0 || - to < 0 || - from >= s.actions.length || - to >= s.actions.length || - from === to - ) { - return s; - } - const actionsDraft = [...s.actions]; - const [moved] = actionsDraft.splice(from, 1); - if (!moved) return s; - actionsDraft.splice(to, 0, moved); - return { ...s, actions: reindexActions(actionsDraft) }; + + const actionToMove = findActionById(s.actions, actionId); + if (!actionToMove) return s; + + const pruned = removeActionFromTree(s.actions, actionId); + const inserted = insertActionIntoTree(pruned, actionToMove, newParentId, newIndex); + return { ...s, actions: inserted }; }); return { steps: stepsDraft, - dirtyEntities: new Set([...state.dirtyEntities, stepId]), + dirtyEntities: new Set([...state.dirtyEntities, stepId, actionId]), }; }), + reorderAction: (stepId: string, from: number, to: number) => + get().moveAction(stepId, get().steps.find(s => s.id === stepId)?.actions[from]?.id!, null, to), // Legacy compat support (only works for root level reorder) + + setInsertionProjection: (projection) => set({ insertionProjection: projection }), + /* -------------------------------- Dirty ---------------------------------- */ markDirty: (id: string) => set((state: DesignerState) => ({ diff --git a/src/components/experiments/designer/state/validators.ts b/src/components/experiments/designer/state/validators.ts index 26ae365..31e3008 100755 --- a/src/components/experiments/designer/state/validators.ts +++ b/src/components/experiments/designer/state/validators.ts @@ -643,13 +643,13 @@ export function validateExecution( if (trialStartSteps.length > 1) { trialStartSteps.slice(1).forEach((step) => { issues.push({ - severity: "warning", + severity: "info", message: - "Multiple steps will start simultaneously. Ensure parallel execution is intended.", + "This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.", category: "execution", field: "trigger.type", stepId: step.id, - suggestion: "Consider using sequential triggers for subsequent steps", + suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one", }); }); } diff --git a/src/components/trials/TrialsTable.tsx b/src/components/trials/TrialsTable.tsx index 6f95caa..fe0bbbf 100755 --- a/src/components/trials/TrialsTable.tsx +++ b/src/components/trials/TrialsTable.tsx @@ -367,10 +367,8 @@ export const columns: ColumnDef[] = [ function ActionsCell({ row }: { row: { original: Trial } }) { const trial = row.original; - const router = React.useMemo(() => require("next/navigation").useRouter(), []); // Dynamic import to avoid hook rules in static context? No, this component is rendered in Table. - // Actually, hooks must be at top level. This ActionsCell will be a regular component. - // But useRouter might fail if columns is not in component tree? - // Table cells are rendered by flexRender in React, so they are components. + // ActionsCell is a component rendered by the table. + // importing useRouter is fine. const utils = api.useUtils(); diff --git a/src/components/trials/playback/EventTimeline.tsx b/src/components/trials/playback/EventTimeline.tsx new file mode 100644 index 0000000..6b27f6a --- /dev/null +++ b/src/components/trials/playback/EventTimeline.tsx @@ -0,0 +1,205 @@ +"use client"; + +import React, { useMemo, useRef, useState } from "react"; +import { usePlayback } from "./PlaybackContext"; +import { cn } from "~/lib/utils"; +import { + AlertTriangle, + CheckCircle, + Flag, + MessageSquare, + Zap, + Circle, + Bot, + User, + Activity +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "~/components/ui/tooltip"; + +function formatTime(seconds: number) { + const min = Math.floor(seconds / 60); + const sec = Math.floor(seconds % 60); + return `${min}:${sec.toString().padStart(2, "0")}`; +} + +export function EventTimeline() { + const { + duration, + currentTime, + events, + seekTo, + startTime: contextStartTime + } = usePlayback(); + + // Determine effective time range + const sortedEvents = useMemo(() => { + return [...events].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + }, [events]); + + const startTime = useMemo(() => { + if (contextStartTime) return new Date(contextStartTime).getTime(); + if (sortedEvents.length > 0) return new Date(sortedEvents[0]!.timestamp).getTime(); + return 0; + }, [contextStartTime, sortedEvents]); + + const effectiveDuration = useMemo(() => { + if (duration > 0) return duration * 1000; + if (sortedEvents.length === 0) return 60000; // 1 min default + const end = new Date(sortedEvents[sortedEvents.length - 1]!.timestamp).getTime(); + return Math.max(end - startTime, 1000); + }, [duration, sortedEvents, startTime]); + + // Dimensions + const containerRef = useRef(null); + + // Helpers + const getPercentage = (timestampMs: number) => { + const offset = timestampMs - startTime; + return Math.max(0, Math.min(100, (offset / effectiveDuration) * 100)); + }; + + const handleSeek = (e: React.MouseEvent) => { + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const pct = Math.max(0, Math.min(1, x / rect.width)); + seekTo(pct * (effectiveDuration / 1000)); + }; + + const currentProgress = (currentTime * 1000 / effectiveDuration) * 100; + + // Generate ticks for "number line" look + // We want a major tick every ~10% or meaningful time interval + const ticks = useMemo(() => { + const count = 10; + return Array.from({ length: count + 1 }).map((_, i) => ({ + pct: (i / count) * 100, + label: formatTime((effectiveDuration / 1000) * (i / count)) + })); + }, [effectiveDuration]); + + const getEventIcon = (type: string) => { + if (type.includes("intervention") || type.includes("wizard")) return ; + if (type.includes("robot") || type.includes("action")) return ; + if (type.includes("completed")) return ; + if (type.includes("start")) return ; + if (type.includes("note")) return ; + if (type.includes("error")) return ; + return ; + }; + + const getEventColor = (type: string) => { + if (type.includes("intervention") || type.includes("wizard")) return "text-orange-500 border-orange-200 bg-orange-50"; + if (type.includes("robot") || type.includes("action")) return "text-purple-500 border-purple-200 bg-purple-50"; + if (type.includes("completed")) return "text-green-500 border-green-200 bg-green-50"; + if (type.includes("start")) return "text-blue-500 border-blue-200 bg-blue-50"; + if (type.includes("error")) return "text-red-500 border-red-200 bg-red-50"; + return "text-slate-500 border-slate-200 bg-slate-50"; + }; + + return ( +
+ + {/* Timeline Track Container */} +
+ {/* Background Grid/Ticks */} +
+ {/* Major Ticks */} + {ticks.map((tick, i) => ( +
+ + {tick.label} + +
+ ))} +
+ + {/* Central Axis Line */} +
+ + {/* Progress Fill (Subtle) */} +
+ + {/* Playhead */} +
+
+ โ–ผ +
+
+ + {/* Events "Lollipops" */} + {sortedEvents.map((event, i) => { + const pct = getPercentage(new Date(event.timestamp).getTime()); + const isTop = i % 2 === 0; // Stagger events top/bottom + + return ( + + +
{ + e.stopPropagation(); + seekTo((new Date(event.timestamp).getTime() - startTime) / 1000); + }} + > + {/* The Stem */} +
+ + {/* The Node */} +
+ {getEventIcon(event.eventType)} +
+
+ + +
{event.eventType.replace(/_/g, " ")}
+
+ {new Date(event.timestamp).toLocaleTimeString()} +
+ {event.data && ( +
+ {JSON.stringify(event.data as object).slice(0, 100)} +
+ )} +
+ + ); + })} +
+ +
+ ); +} + diff --git a/src/components/trials/playback/PlaybackContext.tsx b/src/components/trials/playback/PlaybackContext.tsx new file mode 100644 index 0000000..0a03c19 --- /dev/null +++ b/src/components/trials/playback/PlaybackContext.tsx @@ -0,0 +1,115 @@ +"use client"; + +import React, { createContext, useContext, useEffect, useRef, useState } from "react"; + +interface TrialEvent { + eventType: string; + timestamp: Date; + data?: unknown; +} + +interface PlaybackContextType { + // State + currentTime: number; + duration: number; + isPlaying: boolean; + playbackRate: number; + startTime?: Date; + + // Actions + play: () => void; + pause: () => void; + togglePlay: () => void; + seekTo: (time: number) => void; + setPlaybackRate: (rate: number) => void; + setDuration: (duration: number) => void; + setCurrentTime: (time: number) => void; // Used by VideoPlayer to update state + + // Data + events: TrialEvent[]; + currentEventIndex: number; // Index of the last event that happened before currentTime +} + +const PlaybackContext = createContext(null); + +export function usePlayback() { + const context = useContext(PlaybackContext); + if (!context) { + throw new Error("usePlayback must be used within a PlaybackProvider"); + } + return context; +} + +interface PlaybackProviderProps { + children: React.ReactNode; + events?: TrialEvent[]; + startTime?: Date; +} + +export function PlaybackProvider({ children, events = [], startTime }: PlaybackProviderProps) { + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [playbackRate, setPlaybackRate] = useState(1); + + // Derived state: find the latest event index based on currentTime + const currentEventIndex = React.useMemo(() => { + if (!startTime || events.length === 0) return -1; + + // Find the last event that occurred before or at currentTime + // Events are assumed to be sorted by timestamp + // Using basic iteration for now, optimization possible for large lists + let lastIndex = -1; + + for (let i = 0; i < events.length; i++) { + const eventTime = new Date(events[i]!.timestamp).getTime(); + const startStr = new Date(startTime).getTime(); + const relativeSeconds = (eventTime - startStr) / 1000; + + if (relativeSeconds <= currentTime) { + lastIndex = i; + } else { + break; // Events are sorted, so we can stop + } + } + return lastIndex; + }, [currentTime, events, startTime]); + + // Actions + const play = () => setIsPlaying(true); + const pause = () => setIsPlaying(false); + const togglePlay = () => setIsPlaying(p => !p); + + const seekTo = (time: number) => { + setCurrentTime(time); + // Dispatch seek event to video player via some mechanism if needed, + // usually VideoPlayer observes this context or we use a Ref to control it. + // Actually, simple way: Context holds state, VideoPlayer listens to state? + // No, VideoPlayer usually drives time. + // Let's assume VideoPlayer updates `setCurrentTime` as it plays. + // But if *we* seek (e.g. from timeline), we need to tell VideoPlayer to jump. + // We might need a `seekRequest` timestamp or similar signal. + }; + + const value: PlaybackContextType = { + currentTime, + duration, + isPlaying, + playbackRate, + play, + pause, + togglePlay, + seekTo, + setPlaybackRate, + setDuration, + setCurrentTime, + events, + currentEventIndex, + }; + + return ( + + {children} + + ); +} diff --git a/src/components/trials/playback/PlaybackPlayer.tsx b/src/components/trials/playback/PlaybackPlayer.tsx new file mode 100644 index 0000000..9697fc8 --- /dev/null +++ b/src/components/trials/playback/PlaybackPlayer.tsx @@ -0,0 +1,154 @@ +"use client"; + +import React, { useRef, useEffect } from "react"; +import { usePlayback } from "./PlaybackContext"; +import { AspectRatio } from "~/components/ui/aspect-ratio"; +import { Loader2, Play, Pause, Volume2, VolumeX, Maximize } from "lucide-react"; +import { Slider } from "~/components/ui/slider"; +import { Button } from "~/components/ui/button"; + +interface PlaybackPlayerProps { + src: string; +} + +export function PlaybackPlayer({ src }: PlaybackPlayerProps) { + const videoRef = useRef(null); + const { + currentTime, + isPlaying, + playbackRate, + setCurrentTime, + setDuration, + togglePlay, + play, + pause + } = usePlayback(); + + const [isBuffering, setIsBuffering] = React.useState(true); + const [volume, setVolume] = React.useState(1); + const [muted, setMuted] = React.useState(false); + + // Sync Play/Pause state + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + if (isPlaying && video.paused) { + video.play().catch(console.error); + } else if (!isPlaying && !video.paused) { + video.pause(); + } + }, [isPlaying]); + + // Sync Playback Rate + useEffect(() => { + if (videoRef.current) { + videoRef.current.playbackRate = playbackRate; + } + }, [playbackRate]); + + // Sync Seek (External seek request) + // Note: This is tricky because normal playback also updates currentTime. + // We need to differentiate between "time updated by video" and "time updated by user seek". + // For now, we'll let the video drive the context time, and rely on the Parent/Context + // to call a imperative sync if needed, or we implement a "seekRequest" signal in context. + // simpler: If context time differs significantly from video time, we seek. + useEffect(() => { + const video = videoRef.current; + if (!video) return; + + if (Math.abs(video.currentTime - currentTime) > 0.5) { + video.currentTime = currentTime; + } + }, [currentTime]); + + const handleTimeUpdate = () => { + if (videoRef.current) { + setCurrentTime(videoRef.current.currentTime); + } + }; + + const handleLoadedMetadata = () => { + if (videoRef.current) { + setDuration(videoRef.current.duration); + setIsBuffering(false); + } + }; + + const handleWaiting = () => setIsBuffering(true); + const handlePlaying = () => setIsBuffering(false); + const handleEnded = () => pause(); + + return ( +
+ + +
+ ); +} + +function formatTime(seconds: number) { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} diff --git a/src/components/trials/views/TrialAnalysisView.tsx b/src/components/trials/views/TrialAnalysisView.tsx index b4d0209..93dd65e 100644 --- a/src/components/trials/views/TrialAnalysisView.tsx +++ b/src/components/trials/views/TrialAnalysisView.tsx @@ -1,10 +1,18 @@ "use client"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { Badge } from "~/components/ui/badge"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; -import { LineChart, BarChart, Clock, Database, FileText } from "lucide-react"; -import { formatDistanceToNow } from "date-fns"; +import { LineChart, BarChart, Clock, Database, FileText, AlertTriangle, CheckCircle, VideoOff, Info } from "lucide-react"; +import { PlaybackProvider } from "../playback/PlaybackContext"; +import { PlaybackPlayer } from "../playback/PlaybackPlayer"; +import { EventTimeline } from "../playback/EventTimeline"; +import { api } from "~/trpc/react"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/ui/resizable"; interface TrialAnalysisViewProps { trial: { @@ -17,108 +25,165 @@ interface TrialAnalysisViewProps { participant: { participantCode: string }; eventCount?: number; mediaCount?: number; + media?: { url: string; contentType: string }[]; }; } export function TrialAnalysisView({ trial }: TrialAnalysisViewProps) { + // Fetch events for timeline + const { data: events = [] } = api.trials.getEvents.useQuery({ + trialId: trial.id, + limit: 1000 + }); + + const videoMedia = trial.media?.find(m => m.contentType.startsWith("video/")); + const videoUrl = videoMedia?.url; + return ( -
-
- - - Status - - - -
{trial.status.replace("_", " ")}
-

- {trial.completedAt - ? `Completed ${formatDistanceToNow(new Date(trial.completedAt), { addSuffix: true })}` - : "Not completed"} -

-
-
- - - - Duration - - - -
- {trial.duration ? `${Math.floor(trial.duration / 60)}m ${trial.duration % 60}s` : "N/A"} + +
+ {/* Header Context */} +
+
+
+

+ {trial.experiment.name} +

+

+ {trial.participant.participantCode} โ€ข Session {trial.id.slice(0, 4)}... +

-

- Total execution time -

- - +
+
+
+ + {trial.startedAt?.toLocaleDateString()} {trial.startedAt?.toLocaleTimeString()} +
+ {trial.duration && ( + + {Math.floor(trial.duration / 60)}m {trial.duration % 60}s + + )} +
+
+
- - - Events Logged - - - -
{trial.eventCount ?? 0}
-

- System & user events -

-
-
+ {/* Main Resizable Workspace */} +
+ - - - Media Files - - - -
{trial.mediaCount ?? 0}
-

- Recordings & snapshots -

-
-
+ {/* LEFT: Video & Timeline */} + + + {/* Top: Video Player */} + + {videoUrl ? ( +
+ +
+ ) : ( +
+ +

No recording available.

+
+ )} +
+ + + + {/* Bottom: Timeline Track */} + +
+ + Timeline Track +
+
+
+ +
+
+
+
+
+ + + + {/* RIGHT: Logs & Metrics */} + + {/* Metrics Strip */} +
+ + +
Interventions
+
+ {events.filter(e => e.eventType.includes("intervention")).length} + +
+
+
+ + +
Status
+
+ {trial.status === 'completed' ? 'PASS' : 'INC'} +
+
+ + +
+ + {/* Log Title */} +
+ + + Event Log + + {events.length} Events +
+ + {/* Scrollable Event List */} +
+ +
+ {events.map((event, i) => ( +
+
+ {formatTime(new Date(event.timestamp).getTime() - (trial.startedAt?.getTime() ?? 0))} +
+
+
+ + {event.eventType.replace(/_/g, " ")} + +
+ {event.data && ( +
+ {JSON.stringify(event.data as object, null, 1).replace(/"/g, '').replace(/[{}]/g, '').trim()} +
+ )} +
+
+ ))} + {events.length === 0 && ( +
+ No events found in log. +
+ )} +
+
+
+ + +
- - - - Overview - Event Log - Charts - - - - - Analysis Overview - - Summary of trial execution for {trial.participant.participantCode} in experiment {trial.experiment.name}. - - - -
- -

Detailed analysis visualizations coming soon.

-
-
-
-
- - - - Event Log - - Chronological record of all trial events. - - - -
-

Event log view placeholder.

-
-
-
-
-
-
+ ); } + +function formatTime(ms: number) { + if (ms < 0) return "0:00"; + const totalSeconds = Math.floor(ms / 1000); + const m = Math.floor(totalSeconds / 60); + const s = Math.floor(totalSeconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} diff --git a/src/components/trials/wizard/RobotStatus.tsx b/src/components/trials/wizard/RobotStatus.tsx deleted file mode 100755 index 0905ec7..0000000 --- a/src/components/trials/wizard/RobotStatus.tsx +++ /dev/null @@ -1,366 +0,0 @@ -"use client"; - -import { - Activity, - AlertTriangle, - Battery, - BatteryLow, - Bot, - CheckCircle, - Clock, - RefreshCw, - Signal, - SignalHigh, - SignalLow, - SignalMedium, - WifiOff, -} from "lucide-react"; -import { useEffect, useState } from "react"; -import { Alert, AlertDescription } from "~/components/ui/alert"; -import { Badge } from "~/components/ui/badge"; -import { Button } from "~/components/ui/button"; - -import { Progress } from "~/components/ui/progress"; - -interface RobotStatusProps { - trialId: string; -} - -interface RobotStatus { - id: string; - name: string; - connectionStatus: "connected" | "disconnected" | "connecting" | "error"; - batteryLevel?: number; - signalStrength?: number; - currentMode: string; - lastHeartbeat?: Date; - errorMessage?: string; - capabilities: string[]; - communicationProtocol: string; - isMoving: boolean; - position?: { - x: number; - y: number; - z?: number; - orientation?: number; - }; - sensors?: Record; -} - -export function RobotStatus({ trialId: _trialId }: RobotStatusProps) { - const [robotStatus, setRobotStatus] = useState(null); - const [lastUpdate, setLastUpdate] = useState(new Date()); - const [refreshing, setRefreshing] = useState(false); - - // Mock robot status - in real implementation, this would come from API/WebSocket - useEffect(() => { - // Simulate robot status updates - const mockStatus: RobotStatus = { - id: "robot_001", - name: "TurtleBot3 Burger", - connectionStatus: "connected", - batteryLevel: 85, - signalStrength: 75, - currentMode: "autonomous_navigation", - lastHeartbeat: new Date(), - capabilities: ["navigation", "manipulation", "speech", "vision"], - communicationProtocol: "ROS2", - isMoving: false, - position: { - x: 1.2, - y: 0.8, - orientation: 45, - }, - sensors: { - lidar: "operational", - camera: "operational", - imu: "operational", - odometry: "operational", - }, - }; - - setRobotStatus(mockStatus); - - // Simulate periodic updates - const interval = setInterval(() => { - setRobotStatus((prev) => { - if (!prev) return prev; - return { - ...prev, - batteryLevel: Math.max( - 0, - (prev.batteryLevel ?? 0) - Math.random() * 0.5, - ), - signalStrength: Math.max( - 0, - Math.min( - 100, - (prev.signalStrength ?? 0) + (Math.random() - 0.5) * 10, - ), - ), - lastHeartbeat: new Date(), - position: prev.position - ? { - ...prev.position, - x: prev.position.x + (Math.random() - 0.5) * 0.1, - y: prev.position.y + (Math.random() - 0.5) * 0.1, - } - : undefined, - }; - }); - setLastUpdate(new Date()); - }, 3000); - - return () => clearInterval(interval); - }, []); - - const getConnectionStatusConfig = (status: string) => { - switch (status) { - case "connected": - return { - icon: CheckCircle, - color: "text-green-600", - bgColor: "bg-green-100", - label: "Connected", - }; - case "connecting": - return { - icon: RefreshCw, - color: "text-blue-600", - bgColor: "bg-blue-100", - label: "Connecting", - }; - case "disconnected": - return { - icon: WifiOff, - color: "text-gray-600", - bgColor: "bg-gray-100", - label: "Disconnected", - }; - case "error": - return { - icon: AlertTriangle, - color: "text-red-600", - bgColor: "bg-red-100", - label: "Error", - }; - default: - return { - icon: WifiOff, - color: "text-gray-600", - bgColor: "bg-gray-100", - label: "Unknown", - }; - } - }; - - const getSignalIcon = (strength: number) => { - if (strength >= 75) return SignalHigh; - if (strength >= 50) return SignalMedium; - if (strength >= 25) return SignalLow; - return Signal; - }; - - const getBatteryIcon = (level: number) => { - return level <= 20 ? BatteryLow : Battery; - }; - - const handleRefreshStatus = async () => { - setRefreshing(true); - // Simulate API call - setTimeout(() => { - setRefreshing(false); - setLastUpdate(new Date()); - }, 1000); - }; - - if (!robotStatus) { - return ( -
-
-
- -

No robot connected

-
-
-
- ); - } - - const statusConfig = getConnectionStatusConfig(robotStatus.connectionStatus); - const StatusIcon = statusConfig.icon; - const SignalIcon = getSignalIcon(robotStatus.signalStrength ?? 0); - const BatteryIcon = getBatteryIcon(robotStatus.batteryLevel ?? 0); - - return ( -
-
- -
- - {/* Main Status Card */} -
-
- {/* Robot Info */} -
-
{robotStatus.name}
- - - {statusConfig.label} - -
- - {/* Connection Details */} -
- Protocol: {robotStatus.communicationProtocol} -
- - {/* Status Indicators */} -
- {/* Battery */} - {robotStatus.batteryLevel !== undefined && ( -
-
- - Battery -
-
- - - {Math.round(robotStatus.batteryLevel)}% - -
-
- )} - - {/* Signal Strength */} - {robotStatus.signalStrength !== undefined && ( -
-
- - Signal -
-
- - - {Math.round(robotStatus.signalStrength)}% - -
-
- )} -
-
-
- - {/* Current Mode */} -
-
-
- - Mode: -
- - {robotStatus.currentMode - .replace(/_/g, " ") - .replace(/\b\w/g, (l) => l.toUpperCase())} - -
- {robotStatus.isMoving && ( -
-
- Robot is moving -
- )} -
- - {/* Position Info */} - {robotStatus.position && ( -
-
- Position -
-
-
-
- X: - - {robotStatus.position.x.toFixed(2)}m - -
-
- Y: - - {robotStatus.position.y.toFixed(2)}m - -
- {robotStatus.position.orientation !== undefined && ( -
- Orientation: - - {Math.round(robotStatus.position.orientation)}ยฐ - -
- )} -
-
-
- )} - - {/* Sensors Status */} - {robotStatus.sensors && ( -
-
Sensors
-
-
- {Object.entries(robotStatus.sensors).map(([sensor, status]) => ( -
- {sensor}: - - {status} - -
- ))} -
-
-
- )} - - {/* Error Alert */} - {robotStatus.errorMessage && ( - - - - {robotStatus.errorMessage} - - - )} - - {/* Last Update */} -
- - Last update: {lastUpdate.toLocaleTimeString()} -
-
- ); -} diff --git a/src/components/trials/wizard/WizardInterface.tsx b/src/components/trials/wizard/WizardInterface.tsx index 5b4714f..4f37788 100755 --- a/src/components/trials/wizard/WizardInterface.tsx +++ b/src/components/trials/wizard/WizardInterface.tsx @@ -195,21 +195,28 @@ export const WizardInterface = React.memo(function WizardInterface({ } ); - // Update local trial state from polling + // Update local trial state from polling only if changed useEffect(() => { - if (pollingData) { - setTrial((prev) => ({ - ...prev, - status: pollingData.status, - startedAt: pollingData.startedAt - ? new Date(pollingData.startedAt) - : prev.startedAt, - completedAt: pollingData.completedAt - ? new Date(pollingData.completedAt) - : prev.completedAt, - })); + if (pollingData && JSON.stringify(pollingData) !== JSON.stringify(trial)) { + // Only update if specific fields we care about have changed to avoid + // unnecessary re-renders that might cause UI flashing + if (pollingData.status !== trial.status || + pollingData.startedAt?.getTime() !== trial.startedAt?.getTime() || + pollingData.completedAt?.getTime() !== trial.completedAt?.getTime()) { + + setTrial((prev) => ({ + ...prev, + status: pollingData.status, + startedAt: pollingData.startedAt + ? new Date(pollingData.startedAt) + : prev.startedAt, + completedAt: pollingData.completedAt + ? new Date(pollingData.completedAt) + : prev.completedAt, + })); + } } - }, [pollingData]); + }, [pollingData, trial]); // Auto-start trial on mount if scheduled useEffect(() => { @@ -675,6 +682,7 @@ export const WizardInterface = React.memo(function WizardInterface({ onTabChange={setControlPanelTab} isStarting={startTrialMutation.isPending} onSetAutonomousLife={setAutonomousLife} + readOnly={trial.status === 'completed' || _userRole === 'observer'} /> } center={ @@ -695,6 +703,7 @@ export const WizardInterface = React.memo(function WizardInterface({ completedActionsCount={completedActionsCount} onActionCompleted={() => setCompletedActionsCount(c => c + 1)} onCompleteTrial={handleCompleteTrial} + readOnly={trial.status === 'completed' || _userRole === 'observer'} /> } right={ @@ -706,6 +715,7 @@ export const WizardInterface = React.memo(function WizardInterface({ connectRos={connectRos} disconnectRos={disconnectRos} executeRosAction={executeRosAction} + readOnly={trial.status === 'completed' || _userRole === 'observer'} /> } showDividers={true} @@ -720,6 +730,9 @@ export const WizardInterface = React.memo(function WizardInterface({ onAddAnnotation={handleAddAnnotation} isSubmitting={addAnnotationMutation.isPending} trialEvents={trialEvents} + // Observation pane is where observers usually work, so not readOnly for them? + // But maybe we want 'readOnly' for completed trials. + readOnly={trial.status === 'completed'} /> diff --git a/src/components/trials/wizard/panels/WebcamPanel.tsx b/src/components/trials/wizard/panels/WebcamPanel.tsx new file mode 100644 index 0000000..8f6a50e --- /dev/null +++ b/src/components/trials/wizard/panels/WebcamPanel.tsx @@ -0,0 +1,268 @@ +"use client"; + +import React, { useCallback, useRef, useState } from "react"; +import Webcam from "react-webcam"; +import { Camera, CameraOff, Video, StopCircle, Loader2 } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Alert, AlertDescription } from "~/components/ui/alert"; +import { AspectRatio } from "~/components/ui/aspect-ratio"; +import { toast } from "sonner"; +import { api } from "~/trpc/react"; + +export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) { + const [deviceId, setDeviceId] = useState(null); + const [devices, setDevices] = useState([]); + const [isCameraEnabled, setIsCameraEnabled] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + + const webcamRef = useRef(null); + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + + // TRPC mutation for presigned URL + const getUploadUrlMutation = api.storage.getUploadPresignedUrl.useMutation(); + + const handleDevices = useCallback( + (mediaDevices: MediaDeviceInfo[]) => { + setDevices(mediaDevices.filter(({ kind, deviceId }) => kind === "videoinput" && deviceId !== "")); + }, + [setDevices], + ); + + React.useEffect(() => { + navigator.mediaDevices.enumerateDevices().then(handleDevices); + }, [handleDevices]); + + const handleEnableCamera = () => { + setIsCameraEnabled(true); + setError(null); + }; + + const handleDisableCamera = () => { + if (isRecording) { + handleStopRecording(); + } + setIsCameraEnabled(false); + }; + + const handleStartRecording = () => { + if (!webcamRef.current?.stream) return; + + setIsRecording(true); + chunksRef.current = []; + + try { + const recorder = new MediaRecorder(webcamRef.current.stream, { + mimeType: "video/webm" + }); + + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + chunksRef.current.push(event.data); + } + }; + + recorder.onstop = async () => { + const blob = new Blob(chunksRef.current, { type: "video/webm" }); + await handleUpload(blob); + }; + + recorder.start(); + mediaRecorderRef.current = recorder; + toast.success("Recording started"); + } catch (e) { + console.error("Failed to start recorder:", e); + toast.error("Failed to start recording"); + setIsRecording(false); + } + }; + + const handleStopRecording = () => { + if (mediaRecorderRef.current && isRecording) { + mediaRecorderRef.current.stop(); + setIsRecording(false); + } + }; + + const handleUpload = async (blob: Blob) => { + setUploading(true); + const filename = `recording-${Date.now()}.webm`; + + try { + // 1. Get Presigned URL + const { url } = await getUploadUrlMutation.mutateAsync({ + filename, + contentType: "video/webm", + }); + + // 2. Upload to S3 + const response = await fetch(url, { + method: "PUT", + body: blob, + headers: { + "Content-Type": "video/webm", + }, + }); + + if (!response.ok) { + throw new Error("Upload failed"); + } + + toast.success("Recording uploaded successfully"); + console.log("Uploaded recording:", filename); + } catch (e) { + console.error("Upload error:", e); + toast.error("Failed to upload recording"); + } finally { + setUploading(false); + } + }; + + return ( +
+
+

+ + Webcam Feed +

+ + {!readOnly && ( +
+ {devices.length > 0 && ( + + )} + + {isCameraEnabled && ( + !isRecording ? ( + + ) : ( + + ) + )} + + {isCameraEnabled ? ( + + ) : ( + + )} +
+ )} +
+ +
+ {isCameraEnabled ? ( +
+ + setError(String(err))} + className="object-contain w-full h-full" + /> + + + {/* Recording Overlay */} + {isRecording && ( +
+
+ REC +
+ )} + + {/* Uploading Overlay */} + {uploading && ( +
+
+ + Uploading... +
+
+ )} + + {error && ( +
+ + {error} + +
+ )} +
+ ) : ( +
+ +

Camera is disabled

+ +
+ )} +
+
+ ); +} diff --git a/src/components/trials/wizard/panels/WizardControlPanel.tsx b/src/components/trials/wizard/panels/WizardControlPanel.tsx index f5a2d9f..59bdc02 100755 --- a/src/components/trials/wizard/panels/WizardControlPanel.tsx +++ b/src/components/trials/wizard/panels/WizardControlPanel.tsx @@ -98,6 +98,7 @@ interface WizardControlPanelProps { onTabChange: (tab: "control" | "step" | "actions" | "robot") => void; isStarting?: boolean; onSetAutonomousLife?: (enabled: boolean) => Promise; + readOnly?: boolean; } export function WizardControlPanel({ @@ -118,6 +119,7 @@ export function WizardControlPanel({ onTabChange, isStarting = false, onSetAutonomousLife, + readOnly = false, }: WizardControlPanelProps) { const [autonomousLife, setAutonomousLife] = React.useState(true); @@ -187,7 +189,7 @@ export function WizardControlPanel({ }} className="w-full" size="sm" - disabled={isStarting} + disabled={isStarting || readOnly} > {isStarting ? "Starting..." : "Start Trial"} @@ -201,14 +203,14 @@ export function WizardControlPanel({ onClick={onPauseTrial} variant="outline" size="sm" - disabled={false} + disabled={readOnly} > Pause
@@ -368,7 +372,7 @@ export function WizardControlPanel({ console.log("[WizardControlPanel] Acknowledge clicked"); onExecuteAction("acknowledge"); }} - disabled={false} + disabled={readOnly} > Acknowledge @@ -382,7 +386,7 @@ export function WizardControlPanel({ console.log("[WizardControlPanel] Intervene clicked"); onExecuteAction("intervene"); }} - disabled={false} + disabled={readOnly} > Intervene @@ -396,7 +400,7 @@ export function WizardControlPanel({ console.log("[WizardControlPanel] Add Note clicked"); onExecuteAction("note", { content: "Wizard note" }); }} - disabled={false} + disabled={readOnly} > Add Note @@ -412,7 +416,7 @@ export function WizardControlPanel({ size="sm" className="w-full justify-start" onClick={() => onExecuteAction("step_complete")} - disabled={false} + disabled={readOnly} > Mark Complete @@ -441,11 +445,13 @@ export function WizardControlPanel({
{studyId && onExecuteRobotAction ? ( - +
+ +
) : ( diff --git a/src/components/trials/wizard/panels/WizardExecutionPanel.tsx b/src/components/trials/wizard/panels/WizardExecutionPanel.tsx index 9320649..6335778 100755 --- a/src/components/trials/wizard/panels/WizardExecutionPanel.tsx +++ b/src/components/trials/wizard/panels/WizardExecutionPanel.tsx @@ -10,18 +10,13 @@ import { User, Activity, Zap, - Eye, - List, - Loader2, ArrowRight, AlertTriangle, RotateCcw, } from "lucide-react"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs"; import { ScrollArea } from "~/components/ui/scroll-area"; -import { Alert, AlertDescription } from "~/components/ui/alert"; interface StepData { id: string; @@ -107,6 +102,7 @@ interface WizardExecutionPanelProps { onCompleteTrial?: () => void; completedActionsCount: number; onActionCompleted: () => void; + readOnly?: boolean; } export function WizardExecutionPanel({ @@ -126,47 +122,13 @@ export function WizardExecutionPanel({ onCompleteTrial, completedActionsCount, onActionCompleted, + readOnly = false, }: WizardExecutionPanelProps) { // Local state removed in favor of parent state to prevent reset on re-render // const [completedCount, setCompletedCount] = React.useState(0); const activeActionIndex = completedActionsCount; - const getStepIcon = (type: string) => { - switch (type) { - case "wizard_action": - return User; - case "robot_action": - return Bot; - case "parallel_steps": - return Activity; - case "conditional_branch": - return AlertCircle; - default: - return Play; - } - }; - - const getStepStatus = (stepIndex: number) => { - if (stepIndex < currentStepIndex) return "completed"; - if (stepIndex === currentStepIndex && trial.status === "in_progress") - return "active"; - return "pending"; - }; - - const getStepVariant = (status: string) => { - switch (status) { - case "completed": - return "default"; - case "active": - return "secondary"; - case "pending": - return "outline"; - default: - return "outline"; - } - }; - // Pre-trial state if (trial.status === "scheduled") { return ( @@ -252,7 +214,7 @@ export function WizardExecutionPanel({
{/* Simplified Content - Sequential Focus */} -
+
{currentStep ? (
@@ -281,7 +243,6 @@ export function WizardExecutionPanel({ {currentStep.actions.map((action, idx) => { const isCompleted = idx < activeActionIndex; const isActive = idx === activeActionIndex; - const isPending = idx > activeActionIndex; return (
Skip @@ -348,6 +310,7 @@ export function WizardExecutionPanel({ ); onActionCompleted(); }} + disabled={readOnly || isExecuting} > Execute @@ -364,6 +327,7 @@ export function WizardExecutionPanel({ e.preventDefault(); onActionCompleted(); }} + disabled={readOnly || isExecuting} > Mark Done @@ -394,6 +358,7 @@ export function WizardExecutionPanel({ { autoAdvance: false }, ); }} + disabled={readOnly || isExecuting} > @@ -410,6 +375,7 @@ export function WizardExecutionPanel({ category: "system_issue" }); }} + disabled={readOnly} > @@ -432,6 +398,7 @@ export function WizardExecutionPanel({ ? "bg-blue-600 hover:bg-blue-700" : "bg-green-600 hover:bg-green-700" }`} + disabled={readOnly || isExecuting} > {currentStepIndex === steps.length - 1 ? "Complete Trial" : "Complete Step"} @@ -445,22 +412,15 @@ export function WizardExecutionPanel({ {currentStep.type === "wizard_action" && (

Manual Controls

-
+
-
@@ -472,6 +432,8 @@ export function WizardExecutionPanel({
)} + {/* Scroll Hint Fade */} +
); diff --git a/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx b/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx index fd8324f..20e3ff6 100755 --- a/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx +++ b/src/components/trials/wizard/panels/WizardMonitoringPanel.tsx @@ -11,8 +11,8 @@ import { Badge } from "~/components/ui/badge"; import { Separator } from "~/components/ui/separator"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Alert, AlertDescription } from "~/components/ui/alert"; -import { Progress } from "~/components/ui/progress"; import { Button } from "~/components/ui/button"; +import { WebcamPanel } from "./WebcamPanel"; interface WizardMonitoringPanelProps { rosConnected: boolean; @@ -33,6 +33,7 @@ interface WizardMonitoringPanelProps { actionId: string, parameters: Record, ) => Promise; + readOnly?: boolean; } const WizardMonitoringPanel = function WizardMonitoringPanel({ @@ -43,296 +44,315 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({ connectRos, disconnectRos, executeRosAction, + readOnly = false, }: WizardMonitoringPanelProps) { return ( -
- {/* Header */} -
-

Robot Control

+
+ {/* Camera View - Always Visible */} +
+
- {/* Robot Status and Controls */} - -
- {/* Robot Status */} -
-
-
Robot Status
-
- {rosConnected ? ( - - ) : ( - - )} -
-
+ {/* Robot Controls - Scrollable */} +
+
+ + Robot Control +
+ +
+ {/* Robot Status */}
- - ROS Bridge - +
Robot Status
- - {rosConnecting - ? "Connecting..." - : rosConnected - ? "Ready" - : rosError - ? "Failed" - : "Offline"} - - {rosConnected && ( - - โ— - - )} - {rosConnecting && ( - - โŸณ - + {rosConnected ? ( + + ) : ( + )}
-
+
+
+ + ROS Bridge + +
+ + {rosConnecting + ? "Connecting..." + : rosConnected + ? "Ready" + : rosError + ? "Failed" + : "Offline"} + + {rosConnected && ( + + โ— + + )} + {rosConnecting && ( + + โŸณ + + )} +
+
+
- {/* ROS Connection Controls */} -
- {!rosConnected ? ( - - ) : ( - + {/* ROS Connection Controls */} +
+ {!rosConnected ? ( + + ) : ( + + )} +
+ + {rosError && ( + + + + {rosError} + + + )} + + {!rosConnected && !rosConnecting && ( +
+ + + + Connect to ROS bridge for live robot monitoring and + control. + + +
)}
- {rosError && ( - - - - {rosError} - - + + + {/* Movement Controls */} + {rosConnected && ( +
+
Movement
+
+ {/* Row 1: Turn Left, Forward, Turn Right */} + + + + + {/* Row 2: Left, Stop, Right */} + + + + + {/* Row 3: Empty, Back, Empty */} +
+ +
+
+
)} - {!rosConnected && !rosConnecting && ( -
- - - - Connect to ROS bridge for live robot monitoring and - control. - - + + + {/* Quick Actions */} + {rosConnected && ( +
+
Quick Actions
+ + {/* TTS Input */} +
+ { + if (e.key === "Enter" && e.currentTarget.value.trim() && !readOnly) { + executeRosAction("nao6-ros2", "say_text", { + text: e.currentTarget.value.trim(), + }).catch(console.error); + e.currentTarget.value = ""; + } + }} + /> + +
+ + {/* Preset Actions */} +
+ + +
)}
- - - - {/* Movement Controls */} - {rosConnected && ( -
-
Movement
-
- {/* Row 1: Turn Left, Forward, Turn Right */} - - - - - {/* Row 2: Left, Stop, Right */} - - - - - {/* Row 3: Empty, Back, Empty */} -
- -
-
-
- )} - - - - {/* Quick Actions */} - {rosConnected && ( -
-
Quick Actions
- - {/* TTS Input */} -
- { - if (e.key === "Enter" && e.currentTarget.value.trim()) { - executeRosAction("nao6-ros2", "say_text", { - text: e.currentTarget.value.trim(), - }).catch(console.error); - e.currentTarget.value = ""; - } - }} - /> - -
- - {/* Preset Actions */} -
- - -
-
- )} -
-
+ +
); }; diff --git a/src/components/ui/aspect-ratio.tsx b/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..3df3fd0 --- /dev/null +++ b/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/src/lib/experiment-designer/execution-compiler.ts b/src/lib/experiment-designer/execution-compiler.ts index 7e08978..14bad64 100755 --- a/src/lib/experiment-designer/execution-compiler.ts +++ b/src/lib/experiment-designer/execution-compiler.ts @@ -64,6 +64,7 @@ export interface CompiledExecutionAction { parameterSchemaRaw?: unknown; timeout?: number; retryable?: boolean; + children?: CompiledExecutionAction[]; } /* ---------- Compile Entry Point ---------- */ @@ -136,11 +137,12 @@ function compileAction( robotId: action.source.robotId, baseActionId: action.source.baseActionId, }, - execution: action.execution, + execution: action.execution!, // Assumes validation passed parameters: action.parameters, parameterSchemaRaw: action.parameterSchemaRaw, - timeout: action.execution.timeoutMs, - retryable: action.execution.retryable, + timeout: action.execution?.timeoutMs, + retryable: action.execution?.retryable, + children: action.children?.map((child, i) => compileAction(child, i)), }; } @@ -149,17 +151,24 @@ function compileAction( export function collectPluginDependencies(design: ExperimentDesign): string[] { const set = new Set(); 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}`); - } - } + collectDependenciesFromActions(step.actions, set); } return Array.from(set).sort(); } +// Helper to recursively collect from actions list directly would be cleaner +function collectDependenciesFromActions(actions: ExperimentAction[], set: Set) { + for (const action of actions) { + if (action.source.kind === "plugin" && action.source.pluginId) { + const versionPart = action.source.pluginVersion + ? `@${action.source.pluginVersion}` + : ""; + set.add(`${action.source.pluginId}${versionPart}`); + } + if (action.children) { + collectDependenciesFromActions(action.children, set); + } + } +} /* ---------- Integrity Hash Generation ---------- */ @@ -199,6 +208,12 @@ function buildStructuralSignature( timeout: a.timeout, retryable: a.retryable ?? false, parameterKeys: summarizeParametersForHash(a.parameters), + children: a.children?.map(c => ({ + id: c.id, + // Recurse structural signature for children + type: c.type, + parameterKeys: summarizeParametersForHash(c.parameters), + })), })), })), pluginDependencies, diff --git a/src/lib/experiment-designer/types.ts b/src/lib/experiment-designer/types.ts index 2635b7c..9b5fa82 100755 --- a/src/lib/experiment-designer/types.ts +++ b/src/lib/experiment-designer/types.ts @@ -53,15 +53,18 @@ export interface ActionDefinition { }; execution?: ExecutionDescriptor; parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit + nestable?: boolean; // If true, this action can contain child actions } export interface ExperimentAction { id: string; - type: ActionType; + type: string; // e.g. "wizard_speak", "robot_move" name: string; + description?: string; // Optional description parameters: Record; - duration?: number; + duration?: number; // Estimated duration in seconds category: ActionCategory; + // Provenance (where did this come from?) source: { kind: "core" | "plugin"; pluginId?: string; @@ -69,8 +72,14 @@ export interface ExperimentAction { robotId?: string | null; baseActionId?: string; }; - execution: ExecutionDescriptor; + // Execution (how do we run this?) + execution?: ExecutionDescriptor; + + // Snapshot of parameter schema at the time of addition (for drift detection) parameterSchemaRaw?: unknown; + + // Nested actions (control flow) + children?: ExperimentAction[]; } export interface StepTrigger { diff --git a/src/lib/experiment-designer/visual-design-guard.ts b/src/lib/experiment-designer/visual-design-guard.ts index baabe8c..c0da880 100755 --- a/src/lib/experiment-designer/visual-design-guard.ts +++ b/src/lib/experiment-designer/visual-design-guard.ts @@ -90,17 +90,26 @@ const executionDescriptorSchema = z // 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, +// Base action schema (without recursion) +const baseActionSchema = z.object({ + id: z.string().min(1), + type: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + category: actionCategoryEnum.optional(), + parameters: z.record(z.string(), z.unknown()).default({}), + source: actionSourceSchema.optional(), + execution: executionDescriptorSchema.optional(), + parameterSchemaRaw: parameterSchemaRawSchema, +}); + +type VisualActionInput = z.infer & { + children?: VisualActionInput[]; +}; + +const visualActionInputSchema: z.ZodType = baseActionSchema + .extend({ + children: z.lazy(() => z.array(visualActionInputSchema)).optional(), }) .strict(); @@ -144,8 +153,7 @@ export function parseVisualDesignSteps(raw: unknown): { issues.push( ...zodErr.issues.map( (issue) => - `steps${ - issue.path.length ? "." + issue.path.join(".") : "" + `steps${issue.path.length ? "." + issue.path.join(".") : "" }: ${issue.message} (code=${issue.code})`, ), ); @@ -155,69 +163,73 @@ export function parseVisualDesignSteps(raw: unknown): { // 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 + const normalizeAction = (a: VisualActionInput): ExperimentAction => { + // 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: 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, - } + // 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, } - : { transport: "internal" }; + : 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, - }; - }); + return { + id: a.id, + type: a.type, + name: a.name, + description: a.description, + 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, + children: a.children?.map(normalizeAction) ?? [], + }; + }; + + const normalized: ExperimentStep[] = inputSteps.map((s, idx) => { + const actions: ExperimentAction[] = s.actions.map(normalizeAction); // Construct step return { diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 7bc97c0..346e5ae 100755 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -11,6 +11,7 @@ import { robotsRouter } from "~/server/api/routers/robots"; import { studiesRouter } from "~/server/api/routers/studies"; import { trialsRouter } from "~/server/api/routers/trials"; import { usersRouter } from "~/server/api/routers/users"; +import { storageRouter } from "~/server/api/routers/storage"; import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc"; /** @@ -32,6 +33,7 @@ export const appRouter = createTRPCRouter({ collaboration: collaborationRouter, admin: adminRouter, dashboard: dashboardRouter, + storage: storageRouter, }); // export type definition of API diff --git a/src/server/api/routers/storage.ts b/src/server/api/routers/storage.ts new file mode 100644 index 0000000..87735b1 --- /dev/null +++ b/src/server/api/routers/storage.ts @@ -0,0 +1,71 @@ + +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; +import { s3Client } from "~/server/storage"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { env } from "~/env"; +import { TRPCError } from "@trpc/server"; +import { db } from "~/server/db"; +import { mediaCaptures } from "~/server/db/schema"; + +export const storageRouter = createTRPCRouter({ + getUploadPresignedUrl: protectedProcedure + .input( + z.object({ + filename: z.string(), + contentType: z.string(), + }) + ) + .mutation(async ({ input }) => { + const bucket = env.MINIO_BUCKET_NAME ?? "hristudio-data"; + const key = input.filename; + + try { + const command = new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: input.contentType, + }); + + const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 }); + + return { + url, + key, + bucket, + }; + } catch (error) { + console.error("Error generating presigned URL:", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to generate upload URL", + }); + } + }), + saveRecording: protectedProcedure + .input( + z.object({ + trialId: z.string(), + storagePath: z.string(), + fileSize: z.number().optional(), + format: z.string().optional(), + mediaType: z.enum(["video", "audio", "image"]).default("video"), + }) + ) + .mutation(async ({ ctx, input }) => { + const { db } = ctx; + + await db.insert(mediaCaptures).values({ + trialId: input.trialId, + mediaType: input.mediaType, + storagePath: input.storagePath, + fileSize: input.fileSize, + format: input.format, + startTimestamp: new Date(), // Approximate + // metadata: { uploadedBy: ctx.session.user.id } + }); + + return { success: true }; + }), +}); diff --git a/src/server/api/routers/trials.ts b/src/server/api/routers/trials.ts index 59c5a90..71b843a 100755 --- a/src/server/api/routers/trials.ts +++ b/src/server/api/routers/trials.ts @@ -30,6 +30,10 @@ import { TrialExecutionEngine, type ActionDefinition, } from "~/server/services/trial-execution"; +import { s3Client } from "~/server/storage"; +import { GetObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { env } from "~/env"; // Helper function to check if user has access to trial async function checkTrialAccess( @@ -270,15 +274,34 @@ export const trialsRouter = createTRPCRouter({ .from(trialEvents) .where(eq(trialEvents.trialId, input.id)); - const mediaCount = await db - .select({ count: count() }) + const media = await db + .select() .from(mediaCaptures) - .where(eq(mediaCaptures.trialId, input.id)); + .where(eq(mediaCaptures.trialId, input.id)) + .orderBy(desc(mediaCaptures.createdAt)); // Get latest first return { ...trial[0], eventCount: eventCount[0]?.count ?? 0, - mediaCount: mediaCount[0]?.count ?? 0, + mediaCount: media.length, + media: await Promise.all(media.map(async (m) => { + let url = ""; + try { + // Generate Presigned GET URL + const command = new GetObjectCommand({ + Bucket: env.MINIO_BUCKET_NAME ?? "hristudio-data", + Key: m.storagePath, + }); + url = await getSignedUrl(s3Client, command, { expiresIn: 3600 }); + } catch (e) { + console.error("Failed to sign URL for media", m.id, e); + } + return { + ...m, + url, // Add the signed URL to the response + contentType: m.format === 'webm' ? 'video/webm' : 'application/octet-stream', // Infer or store content type + }; + })), }; }), diff --git a/src/server/storage.ts b/src/server/storage.ts new file mode 100644 index 0000000..12a94da --- /dev/null +++ b/src/server/storage.ts @@ -0,0 +1,20 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { env } from "~/env"; + +const globalForS3 = globalThis as unknown as { + s3Client: S3Client | undefined; +}; + +export const s3Client = + globalForS3.s3Client ?? + new S3Client({ + region: env.MINIO_REGION ?? "us-east-1", + endpoint: env.MINIO_ENDPOINT, + credentials: { + accessKeyId: env.MINIO_ACCESS_KEY ?? "minioadmin", + secretAccessKey: env.MINIO_SECRET_KEY ?? "minioadmin", + }, + forcePathStyle: true, // Needed for MinIO + }); + +if (env.NODE_ENV !== "production") globalForS3.s3Client = s3Client; diff --git a/src/styles/globals.css b/src/styles/globals.css index 8bd26d3..7efc970 100755 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -4,8 +4,7 @@ @custom-variant dark (&:is(.dark *)); @theme { - --font-sans: - var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif, + --font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } @@ -45,9 +44,7 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); - --font-sans: Geist Mono, monospace; - --font-mono: Geist Mono, monospace; - --font-serif: Geist Mono, monospace; + --radius: 0rem; --tracking-tighter: calc(var(--tracking-normal) - 0.05em); --tracking-tight: calc(var(--tracking-normal) - 0.025em); @@ -108,9 +105,7 @@ --sidebar-border: oklch(0.85 0.03 245); --sidebar-ring: oklch(0.6 0.05 240); --destructive-foreground: oklch(0.9702 0 0); - --font-sans: Geist Mono, monospace; - --font-serif: Geist Mono, monospace; - --font-mono: Geist Mono, monospace; + --shadow-color: hsl(0 0% 0%); --shadow-opacity: 0; --shadow-blur: 0px; @@ -171,6 +166,43 @@ } } +@layer base { + .dark { + --background: oklch(0.12 0.008 250); + --foreground: oklch(0.95 0.005 250); + --card: oklch(0.18 0.008 250); + --card-foreground: oklch(0.95 0.005 250); + --popover: oklch(0.2 0.01 250); + --popover-foreground: oklch(0.95 0.005 250); + --primary: oklch(0.65 0.1 240); + --primary-foreground: oklch(0.08 0.02 250); + --secondary: oklch(0.25 0.015 245); + --secondary-foreground: oklch(0.92 0.008 250); + --muted: oklch(0.22 0.01 250); + --muted-foreground: oklch(0.65 0.02 245); + --accent: oklch(0.35 0.025 245); + --accent-foreground: oklch(0.92 0.008 250); + --destructive: oklch(0.7022 0.1892 22.2279); + --border: oklch(0.3 0.015 250); + --input: oklch(0.28 0.015 250); + --ring: oklch(0.65 0.1 240); + --chart-1: oklch(0.65 0.1 240); + --chart-2: oklch(0.7 0.12 200); + --chart-3: oklch(0.75 0.15 160); + --chart-4: oklch(0.8 0.12 120); + --chart-5: oklch(0.7 0.18 80); + --sidebar: oklch(0.14 0.025 250); + --sidebar-foreground: oklch(0.88 0.02 250); + --sidebar-primary: oklch(0.8 0.06 240); + --sidebar-primary-foreground: oklch(0.12 0.025 250); + --sidebar-accent: oklch(0.22 0.04 245); + --sidebar-accent-foreground: oklch(0.88 0.02 250); + --sidebar-border: oklch(0.32 0.035 250); + --sidebar-ring: oklch(0.55 0.08 240); + --destructive-foreground: oklch(0.95 0.01 250); + } +} + @layer base { * { @apply border-border outline-ring/50;