mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
feat: introduce conditional steps and branching logic to the experiment wizard and designer, along with new core and WoZ plugins.
This commit is contained in:
58
scripts/debug-experiment-structure.ts
Normal file
58
scripts/debug-experiment-structure.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { steps, experiments, actions } from "~/server/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
|
||||
async function debugExperimentStructure() {
|
||||
console.log("Debugging Experiment Structure for Interactive Storyteller...");
|
||||
|
||||
// Find the experiment
|
||||
const experiment = await db.query.experiments.findFirst({
|
||||
where: eq(experiments.name, "The Interactive Storyteller"),
|
||||
with: {
|
||||
steps: {
|
||||
orderBy: [asc(steps.orderIndex)],
|
||||
with: {
|
||||
actions: {
|
||||
orderBy: [asc(actions.orderIndex)],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
console.error("Experiment not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Experiment: ${experiment.name} (${experiment.id})`);
|
||||
console.log(`Plugin Dependencies:`, experiment.pluginDependencies);
|
||||
console.log("---------------------------------------------------");
|
||||
|
||||
experiment.steps.forEach((step, index) => {
|
||||
console.log(`Step ${index + 1}: ${step.name}`);
|
||||
console.log(` ID: ${step.id}`);
|
||||
console.log(` Type: ${step.type}`);
|
||||
console.log(` Order: ${step.orderIndex}`);
|
||||
console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2));
|
||||
|
||||
if (step.actions && step.actions.length > 0) {
|
||||
console.log(` Actions (${step.actions.length}):`);
|
||||
step.actions.forEach((action, actionIndex) => {
|
||||
console.log(` ${actionIndex + 1}. [${action.type}] ${action.name}`);
|
||||
if (action.type === 'wizard_wait_for_response') {
|
||||
console.log(` Options:`, JSON.stringify((action.parameters as any)?.options, null, 2));
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log("---------------------------------------------------");
|
||||
});
|
||||
}
|
||||
|
||||
debugExperimentStructure()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
42
scripts/inspect-all-steps.ts
Normal file
42
scripts/inspect-all-steps.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
import { db } from "../src/server/db";
|
||||
import { experiments, steps } from "../src/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
async function inspectAllSteps() {
|
||||
const result = await db.query.experiments.findMany({
|
||||
with: {
|
||||
steps: {
|
||||
orderBy: (steps, { asc }) => [asc(steps.orderIndex)],
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
orderIndex: true,
|
||||
conditions: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Found ${result.length} experiments.`);
|
||||
|
||||
for (const exp of result) {
|
||||
console.log(`Experiment: ${exp.name} (${exp.id})`);
|
||||
for (const step of exp.steps) {
|
||||
// Only print conditional steps or the first step
|
||||
if (step.type === 'conditional' || step.orderIndex === 0) {
|
||||
console.log(` [${step.orderIndex}] ${step.name} (${step.type})`);
|
||||
console.log(` Conditions: ${JSON.stringify(step.conditions)}`);
|
||||
}
|
||||
}
|
||||
console.log('---');
|
||||
}
|
||||
}
|
||||
|
||||
inspectAllSteps()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
47
scripts/inspect-branch-action.ts
Normal file
47
scripts/inspect-branch-action.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { actions, steps } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
async function inspectAction() {
|
||||
console.log("Inspecting Action 10851aef-e720-45fc-ba5e-05e1e3425dab...");
|
||||
|
||||
const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab";
|
||||
|
||||
const action = await db.query.actions.findFirst({
|
||||
where: eq(actions.id, actionId),
|
||||
with: {
|
||||
step: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
conditions: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!action) {
|
||||
console.error("Action not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Action Found:");
|
||||
console.log(" Name:", action.name);
|
||||
console.log(" Type:", action.type);
|
||||
console.log(" Parameters:", JSON.stringify(action.parameters, null, 2));
|
||||
|
||||
console.log("Parent Step:");
|
||||
console.log(" ID:", action.step.id);
|
||||
console.log(" Name:", action.step.name);
|
||||
console.log(" Type:", action.step.type);
|
||||
console.log(" Conditions:", JSON.stringify(action.step.conditions, null, 2));
|
||||
}
|
||||
|
||||
inspectAction()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
30
scripts/inspect-branch-steps.ts
Normal file
30
scripts/inspect-branch-steps.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { steps } from "~/server/db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
async function inspectBranchSteps() {
|
||||
console.log("Inspecting Steps 4 (Branch A) and 5 (Branch B)...");
|
||||
|
||||
const step4Id = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5";
|
||||
const step5Id = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30";
|
||||
|
||||
const branchSteps = await db.query.steps.findMany({
|
||||
where: inArray(steps.id, [step4Id, step5Id])
|
||||
});
|
||||
|
||||
branchSteps.forEach(step => {
|
||||
console.log(`Step: ${step.name} (${step.id})`);
|
||||
console.log(` Type: ${step.type}`);
|
||||
console.log(` Order: ${step.orderIndex}`);
|
||||
console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2));
|
||||
console.log("---------------------------------------------------");
|
||||
});
|
||||
}
|
||||
|
||||
inspectBranchSteps()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
24
scripts/inspect-db.ts
Normal file
24
scripts/inspect-db.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
|
||||
import { db } from "../src/server/db";
|
||||
import { steps } from "../src/server/db/schema";
|
||||
import { eq, like } from "drizzle-orm";
|
||||
|
||||
async function checkSteps() {
|
||||
const allSteps = await db.select().from(steps).where(like(steps.name, "%Comprehension Check%"));
|
||||
|
||||
console.log("Found steps:", allSteps.length);
|
||||
|
||||
for (const step of allSteps) {
|
||||
console.log("Step Name:", step.name);
|
||||
console.log("Type:", step.type);
|
||||
console.log("Conditions (typeof):", typeof step.conditions);
|
||||
console.log("Conditions (value):", JSON.stringify(step.conditions, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
checkSteps()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
61
scripts/inspect-step.ts
Normal file
61
scripts/inspect-step.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { steps, experiments } from "~/server/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
|
||||
async function inspectExperimentSteps() {
|
||||
// Find experiment by ID
|
||||
const experiment = await db.query.experiments.findFirst({
|
||||
where: eq(experiments.id, "961d0cb1-256d-4951-8387-6d855a0ae603")
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
console.log("Experiment not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Inspecting Experiment: ${experiment.name} (${experiment.id})`);
|
||||
|
||||
const experimentSteps = await db.query.steps.findMany({
|
||||
where: eq(steps.experimentId, experiment.id),
|
||||
orderBy: [asc(steps.orderIndex)],
|
||||
with: {
|
||||
actions: {
|
||||
orderBy: (actions, { asc }) => [asc(actions.orderIndex)]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Found ${experimentSteps.length} steps.`);
|
||||
|
||||
for (const step of experimentSteps) {
|
||||
console.log("--------------------------------------------------");
|
||||
console.log(`Step [${step.orderIndex}] ID: ${step.id}`);
|
||||
console.log(`Name: ${step.name}`);
|
||||
console.log(`Type: ${step.type}`);
|
||||
console.log(`NextStepId: ${step.nextStepId}`);
|
||||
|
||||
if (step.type === 'conditional') {
|
||||
console.log("Conditions:", JSON.stringify(step.conditions, null, 2));
|
||||
}
|
||||
|
||||
if (step.actions.length > 0) {
|
||||
console.log("Actions:");
|
||||
for (const action of step.actions) {
|
||||
console.log(` - [${action.orderIndex}] ${action.name} (${action.type})`);
|
||||
if (action.type === 'wizard_wait_for_response') {
|
||||
console.log(" Parameters:", JSON.stringify(action.parameters, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inspectExperimentSteps()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
33
scripts/inspect-visual-design.ts
Normal file
33
scripts/inspect-visual-design.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
import { db } from "../src/server/db";
|
||||
import { experiments } from "../src/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
async function inspectVisualDesign() {
|
||||
const exps = await db.select().from(experiments);
|
||||
|
||||
for (const exp of exps) {
|
||||
console.log(`Experiment: ${exp.name}`);
|
||||
if (exp.visualDesign) {
|
||||
const vd = exp.visualDesign as any;
|
||||
console.log("Visual Design Steps:");
|
||||
if (vd.steps && Array.isArray(vd.steps)) {
|
||||
vd.steps.forEach((s: any, i: number) => {
|
||||
console.log(` [${i}] ${s.name} (${s.type})`);
|
||||
console.log(` Trigger: ${JSON.stringify(s.trigger)}`);
|
||||
});
|
||||
} else {
|
||||
console.log(" No steps in visualDesign or invalid format.");
|
||||
}
|
||||
} else {
|
||||
console.log(" No visualDesign blob.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inspectVisualDesign()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
69
scripts/patch-branch-action-params.ts
Normal file
69
scripts/patch-branch-action-params.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { actions, steps } from "~/server/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
async function patchActionParams() {
|
||||
console.log("Patching Action Parameters for Interactive Storyteller...");
|
||||
|
||||
// Target Step IDs
|
||||
const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check
|
||||
const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab"; // Action: Wait for Choice
|
||||
|
||||
// 1. Get the authoritative conditions from the Step
|
||||
const step = await db.query.steps.findFirst({
|
||||
where: eq(steps.id, step3CondId)
|
||||
});
|
||||
|
||||
if (!step) {
|
||||
console.error("Step 3 not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
const conditions = step.conditions as any;
|
||||
const richOptions = conditions?.options;
|
||||
|
||||
if (!richOptions || !Array.isArray(richOptions)) {
|
||||
console.error("Step 3 conditions are missing valid options!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Found rich options in Step:", JSON.stringify(richOptions, null, 2));
|
||||
|
||||
// 2. Get the Action
|
||||
const action = await db.query.actions.findFirst({
|
||||
where: eq(actions.id, actionId)
|
||||
});
|
||||
|
||||
if (!action) {
|
||||
console.error("Action not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Current Action Parameters:", JSON.stringify(action.parameters, null, 2));
|
||||
|
||||
// 3. Patch the Action Parameters
|
||||
// We replace the simple string options with the rich object options
|
||||
const currentParams = action.parameters as any;
|
||||
const newParams = {
|
||||
...currentParams,
|
||||
options: richOptions // Overwrite with rich options from step
|
||||
};
|
||||
|
||||
console.log("New Action Parameters:", JSON.stringify(newParams, null, 2));
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE hs_action
|
||||
SET parameters = ${JSON.stringify(newParams)}::jsonb
|
||||
WHERE id = ${actionId}
|
||||
`);
|
||||
|
||||
console.log("Action parameters successfully patched.");
|
||||
}
|
||||
|
||||
patchActionParams()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
92
scripts/patch-branch-steps.ts
Normal file
92
scripts/patch-branch-steps.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { steps } from "~/server/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
async function patchBranchSteps() {
|
||||
console.log("Patching branch steps for Interactive Storyteller...");
|
||||
|
||||
// Target Step IDs (From debug output)
|
||||
const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check
|
||||
const stepBranchAId = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5"; // Step 4: Branch A (Correct)
|
||||
const stepBranchBId = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30"; // Step 5: Branch B (Incorrect)
|
||||
const stepConclusionId = "cc3fbc7f-29e5-45e0-8d46-e80813c54292"; // Step 6: Conclusion
|
||||
|
||||
// Update Step 3 (The Conditional Step)
|
||||
console.log("Updating Step 3 (Conditional Step)...");
|
||||
const step3Conditional = await db.query.steps.findFirst({
|
||||
where: eq(steps.id, step3CondId)
|
||||
});
|
||||
|
||||
if (step3Conditional) {
|
||||
const currentConditions = (step3Conditional.conditions as any) || {};
|
||||
const options = currentConditions.options || [];
|
||||
|
||||
// Patch options to point to real step IDs
|
||||
const newOptions = options.map((opt: any) => {
|
||||
if (opt.value === "Correct") return { ...opt, nextStepId: stepBranchAId };
|
||||
if (opt.value === "Incorrect") return { ...opt, nextStepId: stepBranchBId };
|
||||
return opt;
|
||||
});
|
||||
|
||||
const newConditions = { ...currentConditions, options: newOptions };
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE hs_step
|
||||
SET conditions = ${JSON.stringify(newConditions)}::jsonb
|
||||
WHERE id = ${step3CondId}
|
||||
`);
|
||||
console.log("Step 3 (Conditional) updated links.");
|
||||
} else {
|
||||
console.log("Step 3 (Conditional) not found.");
|
||||
}
|
||||
|
||||
// Update Step 4 (Branch A)
|
||||
console.log("Updating Step 4 (Branch A)...");
|
||||
/*
|
||||
Note: We already patched Step 4 in previous run but under wrong assumption?
|
||||
Let's re-patch to be safe.
|
||||
Debug output showed ID: 3a2dc0b7-a43e-4236-9b9e-f957abafc1e5
|
||||
It should jump to Conclusion (cc3fbc7f...)
|
||||
*/
|
||||
const stepBranchA = await db.query.steps.findFirst({
|
||||
where: eq(steps.id, stepBranchAId)
|
||||
});
|
||||
|
||||
if (stepBranchA) {
|
||||
const currentConditions = (stepBranchA.conditions as Record<string, unknown>) || {};
|
||||
const newConditions = { ...currentConditions, nextStepId: stepConclusionId };
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE hs_step
|
||||
SET conditions = ${JSON.stringify(newConditions)}::jsonb
|
||||
WHERE id = ${stepBranchAId}
|
||||
`);
|
||||
console.log("Step 4 (Branch A) updated jump target.");
|
||||
}
|
||||
|
||||
// Update Step 5 (Branch B)
|
||||
console.log("Updating Step 5 (Branch B)...");
|
||||
const stepBranchB = await db.query.steps.findFirst({
|
||||
where: eq(steps.id, stepBranchBId)
|
||||
});
|
||||
|
||||
if (stepBranchB) {
|
||||
const currentConditions = (stepBranchB.conditions as Record<string, unknown>) || {};
|
||||
const newConditions = { ...currentConditions, nextStepId: stepConclusionId };
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE hs_step
|
||||
SET conditions = ${JSON.stringify(newConditions)}::jsonb
|
||||
WHERE id = ${stepBranchBId}
|
||||
`);
|
||||
console.log("Step 5 (Branch B) updated jump target.");
|
||||
}
|
||||
}
|
||||
|
||||
patchBranchSteps()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -35,6 +35,19 @@ async function loadNaoPluginDef() {
|
||||
|
||||
// Global variable to hold the loaded definition
|
||||
let NAO_PLUGIN_DEF: any;
|
||||
let CORE_PLUGIN_DEF: any;
|
||||
let WOZ_PLUGIN_DEF: any;
|
||||
|
||||
function loadSystemPlugin(filename: string) {
|
||||
const LOCAL_PATH = path.join(__dirname, `../src/plugins/definitions/${filename}`);
|
||||
try {
|
||||
const raw = fs.readFileSync(LOCAL_PATH, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error(`❌ Failed to load system plugin ${filename}:`, err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Starting realistic seed script...");
|
||||
@@ -43,6 +56,8 @@ async function main() {
|
||||
|
||||
try {
|
||||
NAO_PLUGIN_DEF = await loadNaoPluginDef();
|
||||
CORE_PLUGIN_DEF = loadSystemPlugin("hristudio-core.json");
|
||||
WOZ_PLUGIN_DEF = loadSystemPlugin("hristudio-woz.json");
|
||||
|
||||
// Ensure legacy 'actions' property maps to 'actionDefinitions' if needed, though schema supports both if we map it
|
||||
if (NAO_PLUGIN_DEF.actions && !NAO_PLUGIN_DEF.actionDefinitions) {
|
||||
@@ -61,6 +76,7 @@ async function main() {
|
||||
await db.delete(schema.studyPlugins).where(sql`1=1`);
|
||||
await db.delete(schema.studyMembers).where(sql`1=1`);
|
||||
await db.delete(schema.studies).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`);
|
||||
@@ -144,12 +160,51 @@ async function main() {
|
||||
{ studyId: study!.id, userId: researcherUser!.id, role: "researcher" }
|
||||
]);
|
||||
|
||||
await db.insert(schema.studyPlugins).values({
|
||||
// Insert System Plugins
|
||||
const [corePlugin] = await db.insert(schema.plugins).values({
|
||||
name: CORE_PLUGIN_DEF.name,
|
||||
version: CORE_PLUGIN_DEF.version,
|
||||
description: CORE_PLUGIN_DEF.description,
|
||||
author: CORE_PLUGIN_DEF.author,
|
||||
trustLevel: "official",
|
||||
actionDefinitions: CORE_PLUGIN_DEF.actionDefinitions,
|
||||
robotId: null, // System Plugin
|
||||
metadata: { ...CORE_PLUGIN_DEF, id: CORE_PLUGIN_DEF.id },
|
||||
status: "active"
|
||||
}).returning();
|
||||
|
||||
const [wozPlugin] = await db.insert(schema.plugins).values({
|
||||
name: WOZ_PLUGIN_DEF.name,
|
||||
version: WOZ_PLUGIN_DEF.version,
|
||||
description: WOZ_PLUGIN_DEF.description,
|
||||
author: WOZ_PLUGIN_DEF.author,
|
||||
trustLevel: "official",
|
||||
actionDefinitions: WOZ_PLUGIN_DEF.actionDefinitions,
|
||||
robotId: null, // System Plugin
|
||||
metadata: { ...WOZ_PLUGIN_DEF, id: WOZ_PLUGIN_DEF.id },
|
||||
status: "active"
|
||||
}).returning();
|
||||
|
||||
await db.insert(schema.studyPlugins).values([
|
||||
{
|
||||
studyId: study!.id,
|
||||
pluginId: naoPlugin!.id,
|
||||
configuration: { robotIp: "10.0.0.42" },
|
||||
installedBy: adminUser.id
|
||||
});
|
||||
},
|
||||
{
|
||||
studyId: study!.id,
|
||||
pluginId: corePlugin!.id,
|
||||
configuration: {},
|
||||
installedBy: adminUser.id
|
||||
},
|
||||
{
|
||||
studyId: study!.id,
|
||||
pluginId: wozPlugin!.id,
|
||||
configuration: {},
|
||||
installedBy: adminUser.id
|
||||
}
|
||||
]);
|
||||
|
||||
const [experiment] = await db.insert(schema.experiments).values({
|
||||
studyId: study!.id,
|
||||
@@ -256,16 +311,49 @@ async function main() {
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
||||
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
||||
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
||||
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
||||
// --- Step 4a: Correct Response Branch ---
|
||||
const [step4a] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "Branch A: Correct Response",
|
||||
description: "Response when participant says 'Red'",
|
||||
type: "robot",
|
||||
orderIndex: 3,
|
||||
required: false,
|
||||
durationEstimate: 20
|
||||
}).returning();
|
||||
|
||||
// --- Step 4b: Incorrect Response Branch ---
|
||||
const [step4b] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "Branch B: Incorrect Response",
|
||||
description: "Response when participant gives wrong answer",
|
||||
type: "robot",
|
||||
orderIndex: 4,
|
||||
required: false,
|
||||
durationEstimate: 20
|
||||
}).returning();
|
||||
|
||||
// --- Step 3: Comprehension Check (Wizard Decision Point) ---
|
||||
// Note: Wizard will choose to proceed to Step 4a (Correct) or 4b (Incorrect)
|
||||
const [step3] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "Comprehension Check",
|
||||
description: "Ask participant about rock color and wait for wizard input",
|
||||
type: "wizard",
|
||||
type: "conditional",
|
||||
orderIndex: 2,
|
||||
required: true,
|
||||
durationEstimate: 30
|
||||
durationEstimate: 30,
|
||||
conditions: {
|
||||
variable: "last_wizard_response",
|
||||
options: [
|
||||
{ label: "Correct Response (Red)", value: "Correct", nextStepId: step4a!.id, variant: "default" },
|
||||
{ label: "Incorrect Response", value: "Incorrect", nextStepId: step4b!.id, variant: "destructive" }
|
||||
]
|
||||
}
|
||||
}).returning();
|
||||
|
||||
await db.insert(schema.actions).values([
|
||||
@@ -282,30 +370,30 @@ async function main() {
|
||||
},
|
||||
{
|
||||
stepId: step3!.id,
|
||||
name: "Wait for Wizard Input",
|
||||
name: "Wait for Choice",
|
||||
type: "wizard_wait_for_response",
|
||||
orderIndex: 1,
|
||||
// Define the options that will be presented to the Wizard
|
||||
parameters: {
|
||||
prompt_text: "Did participant answer 'Red' correctly?",
|
||||
response_type: "verbal",
|
||||
timeout: 60
|
||||
options: ["Correct", "Incorrect"]
|
||||
},
|
||||
sourceKind: "core",
|
||||
pluginId: "hristudio-woz", // Explicit link
|
||||
category: "wizard"
|
||||
},
|
||||
{
|
||||
stepId: step3!.id,
|
||||
name: "Branch Decision",
|
||||
type: "branch",
|
||||
orderIndex: 2,
|
||||
parameters: {},
|
||||
sourceKind: "core",
|
||||
pluginId: "hristudio-core", // Explicit link
|
||||
category: "control"
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Step 4a: Correct Response Branch ---
|
||||
const [step4a] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "Branch A: Correct Response",
|
||||
description: "Response when participant says 'Red'",
|
||||
type: "robot",
|
||||
orderIndex: 3,
|
||||
required: false,
|
||||
durationEstimate: 20
|
||||
}).returning();
|
||||
|
||||
await db.insert(schema.actions).values([
|
||||
{
|
||||
stepId: step4a!.id,
|
||||
@@ -342,16 +430,7 @@ async function main() {
|
||||
}
|
||||
]);
|
||||
|
||||
// --- Step 4b: Incorrect Response Branch ---
|
||||
const [step4b] = await db.insert(schema.steps).values({
|
||||
experimentId: experiment!.id,
|
||||
name: "Branch B: Incorrect Response",
|
||||
description: "Response when participant gives wrong answer",
|
||||
type: "robot",
|
||||
orderIndex: 4,
|
||||
required: false,
|
||||
durationEstimate: 20
|
||||
}).returning();
|
||||
|
||||
|
||||
await db.insert(schema.actions).values([
|
||||
{
|
||||
|
||||
78
scripts/simulate-branch-logic.ts
Normal file
78
scripts/simulate-branch-logic.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
|
||||
// Mock of the logic in WizardInterface.tsx handleNextStep
|
||||
const steps = [
|
||||
{
|
||||
id: "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85",
|
||||
name: "Step 3 (Conditional)",
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
id: "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5",
|
||||
name: "Step 4 (Branch A)",
|
||||
order: 3,
|
||||
conditions: {
|
||||
"nextStepId": "cc3fbc7f-29e5-45e0-8d46-e80813c54292"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30",
|
||||
name: "Step 5 (Branch B)",
|
||||
order: 4,
|
||||
conditions: {
|
||||
"nextStepId": "cc3fbc7f-29e5-45e0-8d46-e80813c54292"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
|
||||
name: "Step 6 (Conclusion)",
|
||||
order: 5
|
||||
}
|
||||
];
|
||||
|
||||
function simulateNextStep(currentStepIndex: number) {
|
||||
const currentStep = steps[currentStepIndex];
|
||||
console.log(`\n--- Simulating Next Step from: ${currentStep.name} ---`);
|
||||
console.log("Current Step Data:", JSON.stringify(currentStep, null, 2));
|
||||
|
||||
// Logic from WizardInterface.tsx
|
||||
console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions);
|
||||
|
||||
if (currentStep?.conditions?.nextStepId) {
|
||||
const nextId = String(currentStep.conditions.nextStepId);
|
||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
||||
|
||||
console.log(`Target ID: ${nextId}`);
|
||||
console.log(`Target Index Found: ${targetIndex}`);
|
||||
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
|
||||
return targetIndex;
|
||||
} else {
|
||||
console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`);
|
||||
}
|
||||
} else {
|
||||
console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly.");
|
||||
}
|
||||
|
||||
// Default: Linear progression
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
console.log(`Proceeding linearly to index ${nextIndex}`);
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
// Simulate Branch A (Index 1 in this array, but 3 in real experiment?)
|
||||
// In real exp, Step 3 is index 2. Step 4 (Branch A) is index 3.
|
||||
console.log("Real experiment indices:");
|
||||
// 0: Hook, 1: Narrative, 2: Conditional, 3: Branch A, 4: Branch B, 5: Conclusion
|
||||
const indexStep4 = 1; // logical index in my mock array
|
||||
const indexStep5 = 2; // logical index
|
||||
|
||||
console.log("Testing Branch A Logic:");
|
||||
const resultA = simulateNextStep(indexStep4);
|
||||
if (resultA === 3) console.log("SUCCESS: Branch A jumped to Conclusion");
|
||||
else console.log("FAILURE: Branch A fell through");
|
||||
|
||||
console.log("\nTesting Branch B Logic:");
|
||||
const resultB = simulateNextStep(indexStep5);
|
||||
if (resultB === 3) console.log("SUCCESS: Branch B jumped to Conclusion");
|
||||
else console.log("FAILURE: Branch B fell through");
|
||||
44
scripts/verify-conversion.ts
Normal file
44
scripts/verify-conversion.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
|
||||
import { db } from "../src/server/db";
|
||||
import { experiments } from "../src/server/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { convertDatabaseToSteps } from "../src/lib/experiment-designer/block-converter";
|
||||
|
||||
async function verifyConversion() {
|
||||
const experiment = await db.query.experiments.findFirst({
|
||||
with: {
|
||||
steps: {
|
||||
orderBy: (steps, { asc }) => [asc(steps.orderIndex)],
|
||||
with: {
|
||||
actions: {
|
||||
orderBy: (actions, { asc }) => [asc(actions.orderIndex)],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
console.log("No experiment found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Raw DB Steps Count:", experiment.steps.length);
|
||||
const converted = convertDatabaseToSteps(experiment.steps);
|
||||
|
||||
console.log("Converted Steps:");
|
||||
converted.forEach((s, idx) => {
|
||||
console.log(`[${idx}] ${s.name} (${s.type})`);
|
||||
console.log(` Trigger:`, JSON.stringify(s.trigger));
|
||||
if (s.type === 'conditional') {
|
||||
console.log(` Conditions populated?`, Object.keys(s.trigger.conditions).length > 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
verifyConversion()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
74
scripts/verify-trpc-logic.ts
Normal file
74
scripts/verify-trpc-logic.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
|
||||
import { db } from "~/server/db";
|
||||
import { experiments, steps, actions } from "~/server/db/schema";
|
||||
import { eq, asc, desc } from "drizzle-orm";
|
||||
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
|
||||
|
||||
async function verifyTrpcLogic() {
|
||||
console.log("Verifying TRPC Logic for Interactive Storyteller...");
|
||||
|
||||
// 1. Simulate the DB Query from experiments.ts
|
||||
const experiment = await db.query.experiments.findFirst({
|
||||
where: eq(experiments.name, "The Interactive Storyteller"),
|
||||
with: {
|
||||
study: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
robot: true,
|
||||
steps: {
|
||||
with: {
|
||||
actions: {
|
||||
orderBy: [asc(actions.orderIndex)],
|
||||
},
|
||||
},
|
||||
orderBy: [asc(steps.orderIndex)],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
console.error("Experiment not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Simulate the Transformation
|
||||
console.log("Transforming DB steps to Designer steps...");
|
||||
const transformedSteps = convertDatabaseToSteps(experiment.steps);
|
||||
|
||||
// 3. Inspect Step 4 (Branch A)
|
||||
// Step index 3 (0-based) is Branch A
|
||||
const branchAStep = transformedSteps[3];
|
||||
console.log("Step 4 (Branch A):", branchAStep.name);
|
||||
console.log(" Type:", branchAStep.type);
|
||||
console.log(" Trigger:", JSON.stringify(branchAStep.trigger, null, 2));
|
||||
|
||||
// Check conditions specifically
|
||||
const conditions = branchAStep.trigger?.conditions as any;
|
||||
if (conditions?.nextStepId) {
|
||||
console.log("SUCCESS: nextStepId found in conditions:", conditions.nextStepId);
|
||||
} else {
|
||||
console.error("FAILURE: nextStepId MISSING in conditions!");
|
||||
}
|
||||
|
||||
// Inspect Step 5 (Branch B) for completeness
|
||||
const branchBStep = transformedSteps[4];
|
||||
console.log("Step 5 (Branch B):", branchBStep.name);
|
||||
console.log(" Trigger:", JSON.stringify(branchBStep.trigger, null, 2));
|
||||
}
|
||||
|
||||
verifyTrpcLogic()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -222,7 +222,10 @@ export default async function ExperimentDesignerPage({
|
||||
: "sequential";
|
||||
})(),
|
||||
order: s.orderIndex ?? idx,
|
||||
trigger: { type: "trial_start", conditions: {} },
|
||||
trigger: {
|
||||
type: idx === 0 ? "trial_start" : "previous_step",
|
||||
conditions: (s.conditions as Record<string, unknown>) || {},
|
||||
},
|
||||
actions,
|
||||
expanded: true,
|
||||
};
|
||||
|
||||
@@ -171,10 +171,27 @@ function WizardPageContent() {
|
||||
|
||||
const renderView = () => {
|
||||
const trialData = {
|
||||
...trial,
|
||||
id: trial.id,
|
||||
status: trial.status,
|
||||
scheduledAt: trial.scheduledAt,
|
||||
startedAt: trial.startedAt,
|
||||
completedAt: trial.completedAt,
|
||||
duration: trial.duration,
|
||||
sessionNumber: trial.sessionNumber,
|
||||
notes: trial.notes,
|
||||
metadata: trial.metadata as Record<string, unknown> | null,
|
||||
experimentId: trial.experimentId,
|
||||
participantId: trial.participantId,
|
||||
wizardId: trial.wizardId,
|
||||
experiment: {
|
||||
id: trial.experiment.id,
|
||||
name: trial.experiment.name,
|
||||
description: trial.experiment.description,
|
||||
studyId: trial.experiment.studyId,
|
||||
},
|
||||
participant: {
|
||||
...trial.participant,
|
||||
id: trial.participant.id,
|
||||
participantCode: trial.participant.participantCode,
|
||||
demographics: trial.participant.demographics as Record<
|
||||
string,
|
||||
unknown
|
||||
@@ -184,7 +201,7 @@ function WizardPageContent() {
|
||||
|
||||
switch (currentRole) {
|
||||
case "wizard":
|
||||
return <WizardView trial={trialData} />;
|
||||
return <WizardView trial={trialData} userRole={currentRole} />;
|
||||
case "observer":
|
||||
return <ObserverView trial={trialData} />;
|
||||
case "participant":
|
||||
@@ -195,24 +212,8 @@ function WizardPageContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={getViewTitle(currentRole)}
|
||||
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||
icon={getViewIcon(currentRole)}
|
||||
actions={
|
||||
currentRole !== "participant" ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trial
|
||||
</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1">{renderView()}</div>
|
||||
<div>
|
||||
{renderView()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
import type { ActionDefinition, ExperimentAction } from "~/lib/experiment-designer/types";
|
||||
import corePluginDef from "~/plugins/definitions/hristudio-core.json";
|
||||
import wozPluginDef from "~/plugins/definitions/hristudio-woz.json";
|
||||
|
||||
/**
|
||||
* ActionRegistry
|
||||
*
|
||||
* Central singleton for loading and serving action definitions from:
|
||||
* - Core system action JSON manifests (served from /hristudio-core/plugins/*.json)
|
||||
* - Core system action JSON manifests (hristudio-core, hristudio-woz)
|
||||
* - Study-installed plugin action definitions (ROS2 / REST / internal transports)
|
||||
*
|
||||
* Responsibilities:
|
||||
@@ -15,12 +17,6 @@ import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
* - Provenance retention (core vs plugin, plugin id/version, robot id)
|
||||
* - Parameter schema → UI parameter mapping (primitive only for now)
|
||||
* - Fallback action population if core load fails (ensures minimal functionality)
|
||||
*
|
||||
* Notes:
|
||||
* - The registry is client-side only (designer runtime); server performs its own
|
||||
* validation & compilation using persisted action instances (never trusts client).
|
||||
* - Action IDs for plugins are namespaced: `${plugin.id}.${action.id}`.
|
||||
* - Core actions retain their base IDs (e.g., wait, wizard_speak) for clarity.
|
||||
*/
|
||||
export class ActionRegistry {
|
||||
private static instance: ActionRegistry;
|
||||
@@ -31,6 +27,8 @@ export class ActionRegistry {
|
||||
private loadedStudyId: string | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
private readonly SYSTEM_PLUGIN_IDS = ["hristudio-core", "hristudio-woz"];
|
||||
|
||||
static getInstance(): ActionRegistry {
|
||||
if (!ActionRegistry.instance) {
|
||||
ActionRegistry.instance = new ActionRegistry();
|
||||
@@ -49,235 +47,19 @@ export class ActionRegistry {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
/* ---------------- Core Actions ---------------- */
|
||||
/* ---------------- Core / System Actions ---------------- */
|
||||
|
||||
async loadCoreActions(): Promise<void> {
|
||||
if (this.coreActionsLoaded) return;
|
||||
|
||||
interface CoreBlockParam {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
value?: string | number | boolean;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
step?: number;
|
||||
}
|
||||
// Load System Plugins (Core & WoZ)
|
||||
this.registerPluginDefinition(corePluginDef);
|
||||
this.registerPluginDefinition(wozPluginDef);
|
||||
|
||||
interface CoreBlock {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
parameters?: CoreBlockParam[];
|
||||
timeoutMs?: number;
|
||||
retryable?: boolean;
|
||||
nestable?: boolean;
|
||||
}
|
||||
|
||||
try {
|
||||
const coreActionSets = [
|
||||
"wizard-actions",
|
||||
"control-flow",
|
||||
"observation",
|
||||
"events",
|
||||
];
|
||||
|
||||
for (const actionSetId of coreActionSets) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/hristudio-core/plugins/${actionSetId}.json`,
|
||||
);
|
||||
// Non-blocking skip if not found
|
||||
if (!response.ok) continue;
|
||||
|
||||
const rawActionSet = (await response.json()) as unknown;
|
||||
const actionSet = rawActionSet as { blocks?: CoreBlock[] };
|
||||
if (!actionSet.blocks || !Array.isArray(actionSet.blocks)) continue;
|
||||
|
||||
// Register each block as an ActionDefinition
|
||||
actionSet.blocks.forEach((block) => {
|
||||
if (!block.id || !block.name) return;
|
||||
|
||||
const actionDef: ActionDefinition = {
|
||||
id: block.id,
|
||||
type: block.id,
|
||||
name: block.name,
|
||||
description: block.description ?? "",
|
||||
category: this.mapBlockCategoryToActionCategory(block.category),
|
||||
icon: block.icon ?? "Zap",
|
||||
color: block.color ?? "#6b7280",
|
||||
parameters: (block.parameters ?? []).map((param) => ({
|
||||
id: param.id,
|
||||
name: param.name,
|
||||
type:
|
||||
(param.type as "text" | "number" | "select" | "boolean") ||
|
||||
"text",
|
||||
placeholder: param.placeholder,
|
||||
options: param.options,
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
value: param.value,
|
||||
required: param.required !== false,
|
||||
description: param.description,
|
||||
step: param.step,
|
||||
})),
|
||||
source: {
|
||||
kind: "core",
|
||||
baseActionId: block.id,
|
||||
},
|
||||
execution: {
|
||||
transport: "internal",
|
||||
timeoutMs: block.timeoutMs,
|
||||
retryable: block.retryable,
|
||||
},
|
||||
parameterSchemaRaw: {
|
||||
parameters: block.parameters ?? [],
|
||||
},
|
||||
nestable: block.nestable,
|
||||
};
|
||||
|
||||
this.actions.set(actionDef.id, actionDef);
|
||||
});
|
||||
} catch (error) {
|
||||
// Non-fatal: we will fallback later
|
||||
console.warn(`Failed to load core action set ${actionSetId}:`, error);
|
||||
}
|
||||
}
|
||||
console.log(`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`);
|
||||
|
||||
this.coreActionsLoaded = true;
|
||||
this.notifyListeners();
|
||||
} catch (error) {
|
||||
console.error("Failed to load core actions:", error);
|
||||
this.loadFallbackActions();
|
||||
}
|
||||
}
|
||||
|
||||
private mapBlockCategoryToActionCategory(
|
||||
category: string,
|
||||
): ActionDefinition["category"] {
|
||||
switch (category) {
|
||||
case "wizard":
|
||||
return "wizard";
|
||||
case "event":
|
||||
return "wizard"; // Events are wizard-initiated triggers
|
||||
case "robot":
|
||||
return "robot";
|
||||
case "control":
|
||||
return "control";
|
||||
case "sensor":
|
||||
case "observation":
|
||||
return "observation";
|
||||
default:
|
||||
return "wizard";
|
||||
}
|
||||
}
|
||||
|
||||
private loadFallbackActions(): void {
|
||||
const fallbackActions: ActionDefinition[] = [
|
||||
{
|
||||
id: "wizard_say",
|
||||
type: "wizard_say",
|
||||
name: "Wizard Says",
|
||||
description: "Wizard speaks to participant",
|
||||
category: "wizard",
|
||||
icon: "MessageSquare",
|
||||
color: "#a855f7",
|
||||
parameters: [
|
||||
{
|
||||
id: "message",
|
||||
name: "Message",
|
||||
type: "text",
|
||||
placeholder: "Hello, participant!",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "tone",
|
||||
name: "Tone",
|
||||
type: "select",
|
||||
options: ["neutral", "friendly", "encouraging"],
|
||||
value: "neutral",
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wizard_say" },
|
||||
execution: { transport: "internal", timeoutMs: 30000 },
|
||||
parameterSchemaRaw: {},
|
||||
nestable: false,
|
||||
},
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
name: "Wait",
|
||||
description: "Wait for specified time",
|
||||
category: "control",
|
||||
icon: "Clock",
|
||||
color: "#f59e0b",
|
||||
parameters: [
|
||||
{
|
||||
id: "duration",
|
||||
name: "Duration (seconds)",
|
||||
type: "number",
|
||||
min: 0.1,
|
||||
max: 300,
|
||||
value: 2,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wait" },
|
||||
execution: { transport: "internal", timeoutMs: 60000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
duration: {
|
||||
type: "number",
|
||||
minimum: 0.1,
|
||||
maximum: 300,
|
||||
default: 2,
|
||||
},
|
||||
},
|
||||
required: ["duration"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "observe",
|
||||
type: "observe",
|
||||
name: "Observe",
|
||||
description: "Record participant behavior",
|
||||
category: "observation",
|
||||
icon: "Eye",
|
||||
color: "#8b5cf6",
|
||||
parameters: [
|
||||
{
|
||||
id: "behavior",
|
||||
name: "Behavior to observe",
|
||||
type: "select",
|
||||
options: ["facial_expression", "body_language", "verbal_response"],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "observe" },
|
||||
execution: { transport: "internal", timeoutMs: 120000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
behavior: {
|
||||
type: "string",
|
||||
enum: ["facial_expression", "body_language", "verbal_response"],
|
||||
},
|
||||
},
|
||||
required: ["behavior"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
fallbackActions.forEach((action) => this.actions.set(action.id, action));
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------------- Plugin Actions ---------------- */
|
||||
@@ -295,6 +77,22 @@ export class ActionRegistry {
|
||||
let totalActionsLoaded = 0;
|
||||
|
||||
(studyPlugins ?? []).forEach((plugin) => {
|
||||
this.registerPluginDefinition(plugin);
|
||||
totalActionsLoaded += (plugin.actionDefinitions?.length || 0);
|
||||
});
|
||||
|
||||
console.log(
|
||||
`ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`,
|
||||
);
|
||||
|
||||
this.pluginActionsLoaded = true;
|
||||
this.loadedStudyId = studyId;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------------- Shared Registration Logic ---------------- */
|
||||
|
||||
private registerPluginDefinition(plugin: any) {
|
||||
const actionDefs = Array.isArray(plugin.actionDefinitions)
|
||||
? plugin.actionDefinitions
|
||||
: undefined;
|
||||
@@ -312,7 +110,14 @@ export class ActionRegistry {
|
||||
control: "control",
|
||||
observation: "observation",
|
||||
};
|
||||
const category = categoryMap[rawCategory] ?? "robot";
|
||||
|
||||
// Default category based on plugin type or explicit category
|
||||
let category = categoryMap[rawCategory];
|
||||
if (!category) {
|
||||
if (plugin.id === 'hristudio-woz') category = 'wizard';
|
||||
else if (plugin.id === 'hristudio-core') category = 'control';
|
||||
else category = 'robot';
|
||||
}
|
||||
|
||||
const execution = action.ros2
|
||||
? {
|
||||
@@ -345,36 +150,50 @@ export class ActionRegistry {
|
||||
retryable: action.retryable,
|
||||
};
|
||||
|
||||
// Extract semantic ID from metadata if available, otherwise fall back to database IDs (which typically causes mismatch if seed uses semantic)
|
||||
// Ideally, plugin.metadata.robotId should populate this.
|
||||
const semanticRobotId = plugin.metadata?.robotId || plugin.robotId || plugin.id;
|
||||
// Extract semantic ID from metadata if available, otherwise fall back to database IDs
|
||||
// Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id
|
||||
const semanticRobotId =
|
||||
plugin.metadata?.robotId ||
|
||||
plugin.metadata?.id ||
|
||||
plugin.robotId ||
|
||||
plugin.id;
|
||||
|
||||
// For system plugins, we want to keep the short IDs (wait, branch) to avoid breaking existing save data
|
||||
// For robot plugins, we namespace them (nao6-ros2.say_text)
|
||||
const isSystem = this.SYSTEM_PLUGIN_IDS.includes(semanticRobotId);
|
||||
const actionId = isSystem ? action.id : `${semanticRobotId}.${action.id}`;
|
||||
const actionType = actionId; // Type is usually same as ID
|
||||
|
||||
const actionDef: ActionDefinition = {
|
||||
id: `${semanticRobotId}.${action.id}`,
|
||||
type: `${semanticRobotId}.${action.id}`,
|
||||
id: actionId,
|
||||
type: actionType,
|
||||
name: action.name,
|
||||
description: action.description ?? "",
|
||||
category,
|
||||
icon: action.icon ?? "Bot",
|
||||
color: "#10b981",
|
||||
color: action.color || "#10b981",
|
||||
parameters: this.convertParameterSchemaToParameters(
|
||||
action.parameterSchema,
|
||||
),
|
||||
source: {
|
||||
kind: "plugin",
|
||||
pluginId: semanticRobotId, // Use semantic ID here too
|
||||
kind: isSystem ? "core" : "plugin", // Maintain 'core' distinction for UI grouping if needed
|
||||
pluginId: semanticRobotId,
|
||||
robotId: plugin.robotId,
|
||||
pluginVersion: plugin.version ?? undefined,
|
||||
baseActionId: action.id,
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: action.parameterSchema ?? undefined,
|
||||
nestable: action.nestable
|
||||
};
|
||||
this.actions.set(actionDef.id, actionDef);
|
||||
// Register aliases if provided by plugin metadata
|
||||
const aliases = Array.isArray(action.aliases)
|
||||
? action.aliases
|
||||
: undefined;
|
||||
|
||||
// Prevent overwriting if it already exists (first-come-first-served, usually core first)
|
||||
if (!this.actions.has(actionId)) {
|
||||
this.actions.set(actionId, actionDef);
|
||||
}
|
||||
|
||||
// Register aliases
|
||||
const aliases = Array.isArray(action.aliases) ? action.aliases : undefined;
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
if (typeof alias === "string" && alias.trim()) {
|
||||
@@ -382,19 +201,7 @@ export class ActionRegistry {
|
||||
}
|
||||
}
|
||||
}
|
||||
totalActionsLoaded++;
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
`ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`,
|
||||
);
|
||||
// console.log("Current action registry state:", { totalActions: this.actions.size });
|
||||
|
||||
|
||||
this.pluginActionsLoaded = true;
|
||||
this.loadedStudyId = studyId;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
private convertParameterSchemaToParameters(
|
||||
@@ -417,7 +224,7 @@ export class ActionRegistry {
|
||||
if (!schema?.properties) return [];
|
||||
|
||||
return Object.entries(schema.properties).map(([key, paramDef]) => {
|
||||
let type: "text" | "number" | "select" | "boolean" = "text";
|
||||
let type: "text" | "number" | "select" | "boolean" | "json" | "array" = "text";
|
||||
|
||||
if (paramDef.type === "number") {
|
||||
type = "number";
|
||||
@@ -425,6 +232,10 @@ export class ActionRegistry {
|
||||
type = "boolean";
|
||||
} else if (paramDef.enum && Array.isArray(paramDef.enum)) {
|
||||
type = "select";
|
||||
} else if (paramDef.type === "array") {
|
||||
type = "array";
|
||||
} else if (paramDef.type === "object") {
|
||||
type = "json";
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -444,29 +255,17 @@ export class ActionRegistry {
|
||||
private resetPluginActions(): void {
|
||||
this.pluginActionsLoaded = false;
|
||||
this.loadedStudyId = null;
|
||||
// Remove existing plugin actions (retain known core ids + fallback ids)
|
||||
const pluginActionIds = Array.from(this.actions.keys()).filter(
|
||||
(id) =>
|
||||
!id.startsWith("wizard_") &&
|
||||
!id.startsWith("when_") &&
|
||||
!id.startsWith("wait") &&
|
||||
!id.startsWith("observe") &&
|
||||
!id.startsWith("repeat") &&
|
||||
!id.startsWith("if_") &&
|
||||
!id.startsWith("parallel") &&
|
||||
!id.startsWith("sequence") &&
|
||||
!id.startsWith("random_") &&
|
||||
!id.startsWith("try_") &&
|
||||
!id.startsWith("break") &&
|
||||
!id.startsWith("measure_") &&
|
||||
!id.startsWith("count_") &&
|
||||
!id.startsWith("record_") &&
|
||||
!id.startsWith("capture_") &&
|
||||
!id.startsWith("log_") &&
|
||||
!id.startsWith("survey_") &&
|
||||
!id.startsWith("physiological_"),
|
||||
);
|
||||
pluginActionIds.forEach((id) => this.actions.delete(id));
|
||||
|
||||
// Robust Reset: Remove valid plugin actions, BUT protect system plugins.
|
||||
const idsToDelete: string[] = [];
|
||||
this.actions.forEach((action, id) => {
|
||||
if (action.source.kind === "plugin" && !this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")) {
|
||||
idsToDelete.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
idsToDelete.forEach((id) => this.actions.delete(id));
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------------- Query Helpers ---------------- */
|
||||
|
||||
@@ -116,10 +116,21 @@ interface RawExperiment {
|
||||
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
||||
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
|
||||
// plugin provenance data (which might be missing from stale visualDesign snapshots).
|
||||
// 1. Prefer database steps (Source of Truth) if valid.
|
||||
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
|
||||
try {
|
||||
// console.log('[DesignerRoot] Hydrating design from Database Steps (Source of Truth)');
|
||||
const dbSteps = convertDatabaseToSteps(exp.steps);
|
||||
// Check if steps are already converted (have trigger property) to avoid double-conversion data loss
|
||||
const firstStep = exp.steps[0] as any;
|
||||
let dbSteps: ExperimentStep[];
|
||||
|
||||
if (firstStep && typeof firstStep === 'object' && 'trigger' in firstStep) {
|
||||
// Already converted by server
|
||||
dbSteps = exp.steps as ExperimentStep[];
|
||||
} else {
|
||||
// Raw DB steps, need conversion
|
||||
dbSteps = convertDatabaseToSteps(exp.steps);
|
||||
}
|
||||
|
||||
return {
|
||||
id: exp.id,
|
||||
name: exp.name,
|
||||
@@ -129,7 +140,7 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[DesignerRoot] Failed to convert DB steps, falling back to visualDesign:', err);
|
||||
console.warn('[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +627,9 @@ export function DesignerRoot({
|
||||
setLastSavedAt(new Date());
|
||||
toast.success("Experiment saved");
|
||||
|
||||
// Auto-validate after save to clear "Modified" (drift) status
|
||||
void validateDesign();
|
||||
|
||||
console.log('[DesignerRoot] 💾 SAVE complete');
|
||||
|
||||
onPersist?.({
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type ExperimentDesign,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Settings,
|
||||
Zap,
|
||||
@@ -39,6 +40,9 @@ import {
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
Plus,
|
||||
GitBranch,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
@@ -275,8 +279,138 @@ export function PropertiesPanelBase({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
{def?.parameters.length ? (
|
||||
{/* Branching Configuration (Special Case) */}
|
||||
{selectedAction.type === "branch" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase flex justify-between items-center">
|
||||
<span>Branch Options</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={() => {
|
||||
const currentOptions = (containingStep.trigger.conditions as any)?.options || [];
|
||||
onStepUpdate(containingStep.id, {
|
||||
trigger: {
|
||||
...containingStep.trigger,
|
||||
conditions: {
|
||||
...containingStep.trigger.conditions,
|
||||
options: [
|
||||
...currentOptions,
|
||||
{ label: "New Option", nextStepIndex: containingStep.order + 1, variant: "default" }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
// Auto-upgrade step type if needed
|
||||
if (containingStep.type !== "conditional") {
|
||||
onStepUpdate(containingStep.id, { type: "conditional" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{((containingStep.trigger.conditions as any)?.options || []).map((opt: any, idx: number) => (
|
||||
<div key={idx} className="space-y-2 p-2 rounded border bg-muted/50">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-[10px]">Label</Label>
|
||||
<Input
|
||||
value={opt.label}
|
||||
onChange={(e) => {
|
||||
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
|
||||
newOpts[idx] = { ...newOpts[idx], label: e.target.value };
|
||||
onStepUpdate(containingStep.id, {
|
||||
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
|
||||
});
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[80px]">
|
||||
<Label className="text-[10px]">Target Step</Label>
|
||||
<Select
|
||||
value={opt.nextStepId ?? design.steps[opt.nextStepIndex]?.id ?? ""}
|
||||
onValueChange={(val) => {
|
||||
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
|
||||
// Find index for legacy support / display logic if needed
|
||||
const stepIdx = design.steps.findIndex(s => s.id === val);
|
||||
|
||||
newOpts[idx] = {
|
||||
...newOpts[idx],
|
||||
nextStepId: val,
|
||||
nextStepIndex: stepIdx !== -1 ? stepIdx : undefined
|
||||
};
|
||||
|
||||
onStepUpdate(containingStep.id, {
|
||||
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="Select step..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{design.steps.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id} disabled={s.id === containingStep.id}>
|
||||
{s.order + 1}. {s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Select
|
||||
value={opt.variant || "default"}
|
||||
onValueChange={(val) => {
|
||||
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
|
||||
newOpts[idx] = { ...newOpts[idx], variant: val };
|
||||
onStepUpdate(containingStep.id, {
|
||||
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[120px] text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default (Next)</SelectItem>
|
||||
<SelectItem value="destructive">Destructive (Red)</SelectItem>
|
||||
<SelectItem value="outline">Outline</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
|
||||
onClick={() => {
|
||||
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
|
||||
newOpts.splice(idx, 1);
|
||||
onStepUpdate(containingStep.id, {
|
||||
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!((containingStep.trigger.conditions as any)?.options?.length)) && (
|
||||
<div className="text-center py-4 border border-dashed rounded text-xs text-muted-foreground">
|
||||
No options defined.<br />Click + to add a branch.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Standard Parameters */
|
||||
def?.parameters.length ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
Parameters
|
||||
@@ -304,6 +438,7 @@ export function PropertiesPanelBase({
|
||||
<div className="text-muted-foreground text-xs">
|
||||
No parameters for this action.
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Trash2,
|
||||
GitBranch,
|
||||
Edit3,
|
||||
CornerDownRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
@@ -96,6 +97,7 @@ interface StepRowProps {
|
||||
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
|
||||
onReorderStep: (stepId: string, direction: 'up' | 'down') => void;
|
||||
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
|
||||
isChild?: boolean;
|
||||
}
|
||||
|
||||
function StepRow({
|
||||
@@ -115,8 +117,10 @@ function StepRow({
|
||||
registerMeasureRef,
|
||||
onReorderStep,
|
||||
onReorderAction,
|
||||
isChild,
|
||||
}: StepRowProps) {
|
||||
// const step = item.step; // Removed local derivation
|
||||
const allSteps = useDesignerStore((s) => s.steps);
|
||||
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||
|
||||
const displayActions = useMemo(() => {
|
||||
@@ -149,9 +153,17 @@ function StepRow({
|
||||
<div style={style} data-step-id={step.id}>
|
||||
<div
|
||||
ref={(el) => registerMeasureRef(step.id, el)}
|
||||
className="relative px-3 py-4"
|
||||
className={cn(
|
||||
"relative px-3 py-4 transition-all duration-300",
|
||||
isChild && "ml-8 pl-0"
|
||||
)}
|
||||
data-step-id={step.id}
|
||||
>
|
||||
{isChild && (
|
||||
<div className="absolute left-[-24px] top-8 text-muted-foreground/40">
|
||||
<CornerDownRight className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<StepDroppableArea stepId={step.id} />
|
||||
<div
|
||||
className={cn(
|
||||
@@ -281,6 +293,78 @@ function StepRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Conditional Branching Visualization */}
|
||||
{/* Conditional Branching Visualization */}
|
||||
{step.type === "conditional" && (
|
||||
<div className="mx-3 my-3 rounded-md border text-xs" style={{
|
||||
backgroundColor: 'var(--validation-warning-bg)', // Semantic background
|
||||
borderColor: 'var(--validation-warning-border)', // Semantic border
|
||||
}}>
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2 font-medium" style={{
|
||||
borderColor: 'var(--validation-warning-border)',
|
||||
color: 'var(--validation-warning-text)'
|
||||
}}>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
<span>Branching Logic</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2 space-y-2">
|
||||
{!(step.trigger.conditions as any)?.options?.length ? (
|
||||
<div className="text-muted-foreground/60 italic text-center py-2 text-[11px]">
|
||||
No branches configured. Add options in properties.
|
||||
</div>
|
||||
) : (
|
||||
(step.trigger.conditions as any).options.map((opt: any, idx: number) => {
|
||||
// Resolve ID to name for display
|
||||
let targetName = "Unlinked";
|
||||
let targetIndex = -1;
|
||||
|
||||
if (opt.nextStepId) {
|
||||
const target = allSteps.find(s => s.id === opt.nextStepId);
|
||||
if (target) {
|
||||
targetName = target.name;
|
||||
targetIndex = target.order;
|
||||
}
|
||||
} else if (typeof opt.nextStepIndex === 'number') {
|
||||
targetIndex = opt.nextStepIndex;
|
||||
targetName = `Step #${targetIndex + 1}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex items-center justify-between rounded bg-background/50 shadow-sm border p-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Badge variant="outline" className={cn(
|
||||
"text-[10px] uppercase font-bold tracking-wider px-1.5 py-0.5 min-w-[70px] justify-center bg-background",
|
||||
opt.variant === "destructive"
|
||||
? "border-red-500/30 text-red-600 dark:text-red-400"
|
||||
: "border-slate-500/30 text-foreground"
|
||||
)}>
|
||||
{opt.label}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-[10px]">then go to</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-right min-w-0 max-w-[50%]">
|
||||
<span className="font-medium truncate text-[11px] block text-foreground" title={targetName}>
|
||||
{targetName}
|
||||
</span>
|
||||
{targetIndex !== -1 && (
|
||||
<Badge variant="secondary" className="px-1 py-0 h-4 text-[9px] min-w-[20px] justify-center tabular-nums">
|
||||
#{targetIndex + 1}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50 flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action List (Collapsible/Virtual content) */}
|
||||
{step.expanded && (
|
||||
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
|
||||
@@ -315,7 +399,7 @@ function StepRow({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -787,6 +871,21 @@ export function FlowWorkspace({
|
||||
return map;
|
||||
}, [steps]);
|
||||
|
||||
/* Hierarchy detection for visual indentation */
|
||||
const childStepIds = useMemo(() => {
|
||||
const children = new Set<string>();
|
||||
for (const step of steps) {
|
||||
if (step.type === 'conditional' && (step.trigger.conditions as any)?.options) {
|
||||
for (const opt of (step.trigger.conditions as any).options) {
|
||||
if (opt.nextStepId) {
|
||||
children.add(opt.nextStepId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}, [steps]);
|
||||
|
||||
/* Resize observer for viewport and width changes */
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
@@ -1202,6 +1301,7 @@ export function FlowWorkspace({
|
||||
registerMeasureRef={registerMeasureRef}
|
||||
onReorderStep={handleReorderStep}
|
||||
onReorderAction={handleReorderAction}
|
||||
isChild={childStepIds.has(vi.step.id)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -203,8 +203,7 @@ function projectStepForDesign(
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
// Only the sorted keys of conditions (structural presence)
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
conditions: canonicalize(step.trigger.conditions),
|
||||
},
|
||||
actions: step.actions.map((a) => projectActionForDesign(a, options)),
|
||||
};
|
||||
@@ -267,11 +266,35 @@ export async function computeDesignHash(
|
||||
opts: DesignHashOptions = {},
|
||||
): Promise<string> {
|
||||
const options = { ...DEFAULT_OPTIONS, ...opts };
|
||||
const projected = steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => projectStepForDesign(s, options));
|
||||
return hashObject({ steps: projected });
|
||||
|
||||
// 1. Sort steps first to ensure order independence of input array
|
||||
const sortedSteps = steps.slice().sort((a, b) => a.order - b.order);
|
||||
|
||||
// 2. Map hierarchically (Merkle style)
|
||||
const stepHashes = await Promise.all(sortedSteps.map(async (s) => {
|
||||
// Action hashes
|
||||
const actionHashes = await Promise.all(s.actions.map(a => hashObject(projectActionForDesign(a, options))));
|
||||
|
||||
// Step hash
|
||||
const pStep = {
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
order: s.order,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
conditions: canonicalize(s.trigger.conditions),
|
||||
},
|
||||
actions: actionHashes,
|
||||
...(options.includeStepNames ? { name: s.name } : {}),
|
||||
};
|
||||
return hashObject(pStep);
|
||||
}));
|
||||
|
||||
// 3. Aggregate design hash
|
||||
return hashObject({
|
||||
steps: stepHashes,
|
||||
count: steps.length
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -338,7 +361,7 @@ export async function computeIncrementalDesignHash(
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
conditions: canonicalize(step.trigger.conditions),
|
||||
},
|
||||
actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""),
|
||||
...(options.includeStepNames ? { name: step.name } : {}),
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface ValidationResult {
|
||||
// Parallel/conditional/loop execution happens at the ACTION level, not step level
|
||||
const VALID_STEP_TYPES: StepType[] = [
|
||||
"sequential",
|
||||
"conditional",
|
||||
];
|
||||
const VALID_TRIGGER_TYPES: TriggerType[] = [
|
||||
"trial_start",
|
||||
@@ -391,6 +392,34 @@ export function validateParameters(
|
||||
}
|
||||
break;
|
||||
|
||||
case "array":
|
||||
if (!Array.isArray(value)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be a list/array`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Enter a list of values",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "json":
|
||||
if (typeof value !== "object" || value === null) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be a valid object`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Enter a valid JSON object",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown parameter type
|
||||
issues.push({
|
||||
|
||||
@@ -29,12 +29,13 @@ interface WizardViewProps {
|
||||
demographics: Record<string, unknown> | null;
|
||||
};
|
||||
};
|
||||
userRole: string;
|
||||
}
|
||||
|
||||
export function WizardView({ trial }: WizardViewProps) {
|
||||
export function WizardView({ trial, userRole }: WizardViewProps) {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<WizardInterface trial={trial} userRole="wizard" />
|
||||
<div className="h-full max-h-full w-full overflow-hidden">
|
||||
<WizardInterface trial={trial} userRole={userRole} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export function RobotActionsPanel({
|
||||
disconnect: disconnectRos,
|
||||
executeRobotAction: executeRosAction,
|
||||
} = useWizardRos({
|
||||
autoConnect: true,
|
||||
autoConnect: false, // Let WizardInterface handle connection
|
||||
onActionCompleted: (execution) => {
|
||||
toast.success(`Completed: ${execution.actionId}`, {
|
||||
description: `Action executed in ${execution.endTime ? execution.endTime.getTime() - execution.startTime.getTime() : 0}ms`,
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Play, CheckCircle, X, Clock, AlertCircle, HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Play,
|
||||
CheckCircle,
|
||||
X,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Pause,
|
||||
SkipForward
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import Link from "next/link";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { PanelsContainer } from "~/components/experiments/designer/layout/PanelsContainer";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { WizardControlPanel } from "./panels/WizardControlPanel";
|
||||
import { WizardExecutionPanel } from "./panels/WizardExecutionPanel";
|
||||
import { WizardMonitoringPanel } from "./panels/WizardMonitoringPanel";
|
||||
import { WizardObservationPane } from "./panels/WizardObservationPane";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/ui/resizable";
|
||||
import { TrialStatusBar } from "./panels/TrialStatusBar";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useWizardRos } from "~/hooks/useWizardRos";
|
||||
import { toast } from "sonner";
|
||||
@@ -68,8 +82,18 @@ interface StepData {
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "conditional";
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
nextStepId?: string;
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
actions: ActionData[];
|
||||
}
|
||||
@@ -87,24 +111,31 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
// Persistent tab states to prevent resets from parent re-renders
|
||||
const [controlPanelTab, setControlPanelTab] = useState<
|
||||
"control" | "step" | "actions" | "robot"
|
||||
>("control");
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<
|
||||
"current" | "timeline" | "events"
|
||||
>(trial.status === "in_progress" ? "current" : "timeline");
|
||||
// UI State
|
||||
const [executionPanelTab, setExecutionPanelTab] = useState<"current" | "timeline" | "events">("timeline");
|
||||
const [obsTab, setObsTab] = useState<"notes" | "timeline">("notes");
|
||||
const [isExecutingAction, setIsExecutingAction] = useState(false);
|
||||
const [monitoringPanelTab, setMonitoringPanelTab] = useState<
|
||||
"status" | "robot" | "events"
|
||||
>("status");
|
||||
const [completedActionsCount, setCompletedActionsCount] = useState(0);
|
||||
|
||||
// Collapse state for panels
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
const [obsCollapsed, setObsCollapsed] = useState(false);
|
||||
|
||||
// Center tabs (Timeline | Actions)
|
||||
const [centerTab, setCenterTab] = useState<"timeline" | "actions">("timeline");
|
||||
|
||||
// Reset completed actions when step changes
|
||||
useEffect(() => {
|
||||
setCompletedActionsCount(0);
|
||||
}, [currentStepIndex]);
|
||||
|
||||
// Track the last response value from wizard_wait_for_response for branching
|
||||
const [lastResponse, setLastResponse] = useState<string | null>(null);
|
||||
|
||||
// Get experiment steps from API
|
||||
const { data: experimentSteps } = api.experiments.getSteps.useQuery(
|
||||
{ experimentId: trial.experimentId },
|
||||
@@ -145,7 +176,7 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
case "parallel":
|
||||
return "parallel_steps" as const;
|
||||
case "conditional":
|
||||
return "conditional_branch" as const;
|
||||
return "conditional" as const;
|
||||
default:
|
||||
return "wizard_action" as const;
|
||||
}
|
||||
@@ -276,9 +307,11 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
name: step.name ?? `Step ${index + 1}`,
|
||||
description: step.description,
|
||||
type: mapStepType(step.type),
|
||||
// Fix: Conditions are at root level from API
|
||||
conditions: (step as any).conditions ?? (step as any).trigger?.conditions ?? undefined,
|
||||
parameters: step.parameters ?? {},
|
||||
order: step.order ?? index,
|
||||
actions: step.actions?.map((action) => ({
|
||||
actions: step.actions?.filter(a => a.type !== 'branch').map((action) => ({
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
@@ -414,11 +447,60 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
console.log("Pause trial");
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setCompletedActionsCount(0); // Reset immediately to prevent flickering/double-click issues
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
// Note: Step transitions can be enhanced later with database logging
|
||||
const handleNextStep = (targetIndex?: number) => {
|
||||
// If explicit target provided (from branching choice), use it
|
||||
if (typeof targetIndex === 'number') {
|
||||
// Find step by index to ensure safety
|
||||
if (targetIndex >= 0 && targetIndex < steps.length) {
|
||||
console.log(`[WizardInterface] Manual jump to step ${targetIndex}`);
|
||||
setCompletedActionsCount(0);
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setLastResponse(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic Branching Logic
|
||||
const currentStep = steps[currentStepIndex];
|
||||
|
||||
// Check if we have a stored response that dictates the next step
|
||||
if (currentStep?.type === 'conditional' && currentStep.conditions?.options && lastResponse) {
|
||||
const matchedOption = currentStep.conditions.options.find(opt => opt.value === lastResponse);
|
||||
if (matchedOption && matchedOption.nextStepId) {
|
||||
// Find index of the target step
|
||||
const targetIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Branching to step ${targetIndex} (${matchedOption.label})`);
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setLastResponse(null); // Reset after consuming
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for explicit nextStepId in conditions (e.g. for end of branch)
|
||||
console.log("[WizardInterface] Checking for nextStepId condition:", currentStep?.conditions);
|
||||
if (currentStep?.conditions?.nextStepId) {
|
||||
const nextId = String(currentStep.conditions.nextStepId);
|
||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`);
|
||||
setCurrentStepIndex(targetIndex);
|
||||
setCompletedActionsCount(0);
|
||||
return;
|
||||
} else {
|
||||
console.warn(`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`);
|
||||
}
|
||||
} else {
|
||||
console.log("[WizardInterface] No nextStepId found in conditions, proceeding linearly.");
|
||||
}
|
||||
|
||||
// Default: Linear progression
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
if (nextIndex < steps.length) {
|
||||
setCurrentStepIndex(nextIndex);
|
||||
} else {
|
||||
handleCompleteTrial();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -476,8 +558,25 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
parameters?: Record<string, unknown>,
|
||||
) => {
|
||||
try {
|
||||
// Log action execution
|
||||
console.log("Executing action:", actionId, parameters);
|
||||
|
||||
// Handle branching logic (wizard_wait_for_response)
|
||||
if (parameters?.value && parameters?.label) {
|
||||
setLastResponse(String(parameters.value));
|
||||
|
||||
// If nextStepId is provided, jump immediately
|
||||
if (parameters.nextStepId) {
|
||||
const nextId = String(parameters.nextStepId);
|
||||
const targetIndex = steps.findIndex(s => s.id === nextId);
|
||||
if (targetIndex !== -1) {
|
||||
console.log(`[WizardInterface] Choice-based jump to step ${targetIndex} (${nextId})`);
|
||||
handleNextStep(targetIndex);
|
||||
return; // Exit after jump
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (actionId === "acknowledge") {
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
@@ -614,65 +713,102 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
[logRobotActionMutation, trial.id],
|
||||
);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Compact Status Bar */}
|
||||
<div className="bg-background border-b px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge
|
||||
variant={statusConfig.variant}
|
||||
className="flex items-center gap-1"
|
||||
<div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
|
||||
<PageHeader
|
||||
title="Trial Execution"
|
||||
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button
|
||||
onClick={handleStartTrial}
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
<Play className="h-4 w-4" />
|
||||
Start Trial
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{trial.status === "in_progress" && (
|
||||
<div className="flex items-center gap-1 font-mono text-sm">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatElapsedTime(elapsedTime)}
|
||||
</div>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePauseTrial}
|
||||
className="gap-2"
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
Pause
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNextStep()}
|
||||
className="gap-2"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
Next Step
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleAbortTrial}
|
||||
className="gap-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Abort
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleCompleteTrial}
|
||||
className="gap-2 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Complete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{steps.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Step {currentStepIndex + 1} of {totalSteps}
|
||||
</span>
|
||||
<div className="w-16">
|
||||
<Progress value={progressPercentage} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
{_userRole !== "participant" && (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/studies/${trial.experiment.studyId}/trials`}>
|
||||
Exit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
className="flex-none px-2 pb-2"
|
||||
/>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<div>{trial.experiment.name}</div>
|
||||
<div>{trial.participant.participantCode}</div>
|
||||
<Badge
|
||||
variant={rosConnected ? "default" : "outline"}
|
||||
className="text-xs"
|
||||
{/* Main Grid - 2 rows */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2">
|
||||
{/* Top Row - 3 Column Layout */}
|
||||
<div className="flex-1 min-h-0 flex gap-2">
|
||||
{/* Left Sidebar - Control Panel (Collapsible) */}
|
||||
{!leftCollapsed && (
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
||||
<span className="text-sm font-medium">Control</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setLeftCollapsed(true)}
|
||||
>
|
||||
{rosConnected ? "ROS Connected" : "ROS Offline"}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => startTour("wizard")}
|
||||
className="hover:bg-muted p-1 rounded-full transition-colors"
|
||||
title="Start Tour"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content with Vertical Resizable Split */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
<ResizablePanel defaultSize={75} minSize={30}>
|
||||
<PanelsContainer
|
||||
left={
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-controls" className="h-full">
|
||||
<WizardControlPanel
|
||||
trial={trial}
|
||||
@@ -688,15 +824,59 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
onExecuteRobotAction={handleExecuteRobotAction}
|
||||
studyId={trial.experiment.studyId}
|
||||
_isConnected={rosConnected}
|
||||
activeTab={controlPanelTab}
|
||||
onTabChange={setControlPanelTab}
|
||||
isStarting={startTrialMutation.isPending}
|
||||
onSetAutonomousLife={setAutonomousLife}
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
center={
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center - Tabbed Workspace */}
|
||||
{/* Center - Execution Workspace */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||
<div className="flex items-center border-b px-3 py-2 bg-muted/30 min-h-[45px]">
|
||||
{leftCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 mr-2"
|
||||
onClick={() => setLeftCollapsed(false)}
|
||||
title="Open Tools Panel"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Trial Execution</span>
|
||||
{currentStep && (
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{currentStep.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="mr-2 text-xs text-muted-foreground font-medium">
|
||||
Step {currentStepIndex + 1} / {steps.length}
|
||||
</div>
|
||||
|
||||
{rightCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setRightCollapsed(false)}
|
||||
title="Open Robot Status"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-timeline" className="h-full">
|
||||
<WizardExecutionPanel
|
||||
trial={trial}
|
||||
@@ -718,8 +898,24 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Robot Status (Collapsible) */}
|
||||
{!rightCollapsed && (
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm w-80">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
||||
<span className="text-sm font-medium">Robot Status</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setRightCollapsed(true)}
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<div id="tour-wizard-robot-status" className="h-full">
|
||||
<WizardMonitoringPanel
|
||||
rosConnected={rosConnected}
|
||||
@@ -732,27 +928,56 @@ export const WizardInterface = React.memo(function WizardInterface({
|
||||
readOnly={trial.status === 'completed' || _userRole === 'observer'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
showDividers={true}
|
||||
className="h-full"
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
<ResizablePanel defaultSize={25} minSize={10}>
|
||||
{/* Bottom Row - Observations (Full Width, Collapsible) */}
|
||||
{!obsCollapsed && (
|
||||
<Tabs value={obsTab} onValueChange={(v) => setObsTab(v as "notes" | "timeline")} className="flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm h-48 flex-none">
|
||||
<div className="flex items-center border-b px-3 py-2 bg-muted/30 gap-3">
|
||||
<span className="text-sm font-medium">Observations</span>
|
||||
<TabsList className="h-7 bg-transparent border-0 p-0">
|
||||
<TabsTrigger value="notes" className="text-xs h-7 px-3">Notes</TabsTrigger>
|
||||
<TabsTrigger value="timeline" className="text-xs h-7 px-3">Timeline</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setObsCollapsed(true)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 bg-muted/10">
|
||||
<WizardObservationPane
|
||||
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'}
|
||||
activeTab={obsTab}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
{
|
||||
obsCollapsed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setObsCollapsed(false)}
|
||||
className="w-full flex-none"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 mr-2" />
|
||||
Show Observations
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
133
src/components/trials/wizard/panels/TrialStatusBar.tsx
Normal file
133
src/components/trials/wizard/panels/TrialStatusBar.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
GitBranch,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Play,
|
||||
StickyNote,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
|
||||
export interface TrialStatusBarProps {
|
||||
currentStepIndex: number;
|
||||
totalSteps: number;
|
||||
trialStatus: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
rosConnected: boolean;
|
||||
eventsCount: number;
|
||||
completedActionsCount: number;
|
||||
totalActionsCount: number;
|
||||
onAddNote?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TrialStatusBar({
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
trialStatus,
|
||||
rosConnected,
|
||||
eventsCount,
|
||||
completedActionsCount,
|
||||
totalActionsCount,
|
||||
onAddNote,
|
||||
className,
|
||||
}: TrialStatusBarProps) {
|
||||
const progressPercentage = useMemo(
|
||||
() => (totalSteps > 0 ? ((currentStepIndex + 1) / totalSteps) * 100 : 0),
|
||||
[currentStepIndex, totalSteps],
|
||||
);
|
||||
|
||||
const actionProgress = useMemo(
|
||||
() =>
|
||||
totalActionsCount > 0
|
||||
? (completedActionsCount / totalActionsCount) * 100
|
||||
: 0,
|
||||
[completedActionsCount, totalActionsCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
|
||||
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Step Progress */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<GitBranch className="h-3.5 w-3.5 opacity-70" />
|
||||
Step {currentStepIndex + 1}/{totalSteps}
|
||||
</span>
|
||||
<div className="w-20">
|
||||
<Progress value={progressPercentage} className="h-1.5" />
|
||||
</div>
|
||||
<span className="text-muted-foreground/70">{Math.round(progressPercentage)}%</span>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||
|
||||
{/* Action Progress */}
|
||||
{totalActionsCount > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5 opacity-70" />
|
||||
{completedActionsCount}/{totalActionsCount} actions
|
||||
</span>
|
||||
<div className="w-16">
|
||||
<Progress value={actionProgress} className="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Trial Stats */}
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 opacity-70" />
|
||||
{eventsCount} events
|
||||
</span>
|
||||
{trialStatus === "in_progress" && (
|
||||
<Badge variant="default" className="h-5 gap-1 bg-emerald-500 px-1.5 text-[10px] font-normal">
|
||||
<Play className="h-2.5 w-2.5" />
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
{trialStatus === "completed" && (
|
||||
<Badge variant="secondary" className="h-5 gap-1 px-1.5 text-[10px] font-normal">
|
||||
<CheckCircle2 className="h-2.5 w-2.5" />
|
||||
Completed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{onAddNote && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={onAddNote}
|
||||
title="Add Quick Note"
|
||||
>
|
||||
<StickyNote className="mr-1.5 h-3.5 w-3.5" />
|
||||
Note
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrialStatusBar;
|
||||
@@ -207,9 +207,9 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden bg-black p-4 flex items-center justify-center relative">
|
||||
<div className="flex-1 overflow-hidden bg-muted/50 p-4 flex items-center justify-center relative">
|
||||
{isCameraEnabled ? (
|
||||
<div className="w-full relative rounded-lg overflow-hidden border border-slate-800">
|
||||
<div className="w-full relative rounded-lg overflow-hidden border border-border shadow-sm bg-black">
|
||||
<AspectRatio ratio={16 / 9}>
|
||||
<Webcam
|
||||
ref={webcamRef}
|
||||
@@ -249,11 +249,13 @@ export function WebcamPanel({ readOnly = false }: { readOnly?: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-slate-500">
|
||||
<CameraOff className="mx-auto mb-2 h-12 w-12 opacity-20" />
|
||||
<p className="text-sm">Camera is disabled</p>
|
||||
<div className="text-center text-muted-foreground/50">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<CameraOff className="h-6 w-6 opacity-50" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">Camera is disabled</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={handleEnableCamera}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { Separator } from "~/components/ui/separator";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { RobotActionsPanel } from "../RobotActionsPanel";
|
||||
|
||||
@@ -34,8 +33,17 @@ interface StepData {
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "conditional"; // Updated to match DB enum
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
actions?: {
|
||||
id: string;
|
||||
@@ -80,7 +88,7 @@ interface WizardControlPanelProps {
|
||||
currentStepIndex: number;
|
||||
onStartTrial: () => void;
|
||||
onPauseTrial: () => void;
|
||||
onNextStep: () => void;
|
||||
onNextStep: (targetIndex?: number) => void;
|
||||
onCompleteTrial: () => void;
|
||||
onAbortTrial: () => void;
|
||||
onExecuteAction: (
|
||||
@@ -94,14 +102,13 @@ interface WizardControlPanelProps {
|
||||
) => Promise<void>;
|
||||
studyId?: string;
|
||||
_isConnected: boolean;
|
||||
activeTab: "control" | "step" | "actions" | "robot";
|
||||
onTabChange: (tab: "control" | "step" | "actions" | "robot") => void;
|
||||
|
||||
isStarting?: boolean;
|
||||
onSetAutonomousLife?: (enabled: boolean) => Promise<boolean | void>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function WizardControlPanel({
|
||||
export const WizardControlPanel = React.memo(function WizardControlPanel({
|
||||
trial,
|
||||
currentStep,
|
||||
steps,
|
||||
@@ -115,8 +122,6 @@ export function WizardControlPanel({
|
||||
onExecuteRobotAction,
|
||||
studyId,
|
||||
_isConnected,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
isStarting = false,
|
||||
onSetAutonomousLife,
|
||||
readOnly = false,
|
||||
@@ -141,237 +146,21 @@ export function WizardControlPanel({
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Tabbed Content */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value: string) => {
|
||||
if (
|
||||
value === "control" ||
|
||||
value === "step" ||
|
||||
value === "actions" ||
|
||||
value === "robot"
|
||||
) {
|
||||
onTabChange(value as "control" | "step" | "actions");
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-2 py-1">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="control" className="text-xs">
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
Control
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="step" className="text-xs">
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
Step
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="actions" className="text-xs">
|
||||
<Zap className="mr-1 h-3 w-3" />
|
||||
Actions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{/* Trial Control Tab */}
|
||||
<TabsContent
|
||||
value="control"
|
||||
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-3 p-3">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Start Trial clicked");
|
||||
onStartTrial();
|
||||
}}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
disabled={isStarting || readOnly}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{isStarting ? "Starting..." : "Start Trial"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{trial.status === "in_progress" && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={onPauseTrial}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={readOnly}
|
||||
>
|
||||
<Pause className="mr-1 h-3 w-3" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNextStep}
|
||||
disabled={(currentStepIndex >= steps.length - 1) || readOnly}
|
||||
size="sm"
|
||||
>
|
||||
<SkipForward className="mr-1 h-3 w-3" />
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Button
|
||||
onClick={onCompleteTrial}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
disabled={readOnly}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Complete Trial
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onAbortTrial}
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
disabled={readOnly}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Abort Trial
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(trial.status === "completed" ||
|
||||
trial.status === "aborted") && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Trial has ended. All controls are disabled.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-medium">Robot Status</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Connection
|
||||
</span>
|
||||
{_isConnected ? (
|
||||
<Badge variant="default" className="bg-green-600 text-xs">
|
||||
Connected
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-yellow-600 border-yellow-600 text-xs">
|
||||
Polling...
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="autonomous-life"
|
||||
checked={autonomousLife}
|
||||
onCheckedChange={handleAutonomousLifeChange}
|
||||
disabled={!_isConnected || readOnly}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Current Step Tab */}
|
||||
<TabsContent
|
||||
value="step"
|
||||
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
{currentStep && trial.status === "in_progress" ? (
|
||||
<div className="space-y-4 p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{currentStep.name}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{currentStep.type.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Decision Point UI removed as per user request (handled in Execution Panel) */}
|
||||
|
||||
{currentStep.description && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{currentStep.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">Step Progress</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Current</span>
|
||||
<span>Step {currentStepIndex + 1}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Remaining</span>
|
||||
<span>{steps.length - currentStepIndex - 1} steps</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStep.type === "robot_action" && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
Robot is executing this step. Monitor progress in the
|
||||
monitoring panel.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center text-center text-xs">
|
||||
{trial.status === "scheduled"
|
||||
? "Start trial to see current step"
|
||||
: trial.status === "in_progress"
|
||||
? "No current step"
|
||||
: "Trial has ended"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Quick Actions Tab */}
|
||||
<TabsContent
|
||||
value="actions"
|
||||
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2 p-3">
|
||||
{trial.status === "in_progress" ? (
|
||||
<>
|
||||
<div className="mb-2 text-xs font-medium">
|
||||
Quick Actions
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Acknowledge clicked");
|
||||
onExecuteAction("acknowledge");
|
||||
}}
|
||||
onClick={() => onExecuteAction("acknowledge")}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
@@ -381,36 +170,26 @@ export function WizardControlPanel({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Intervene clicked");
|
||||
onExecuteAction("intervene");
|
||||
}}
|
||||
className="w-full justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<AlertCircle className="mr-2 h-3 w-3" />
|
||||
Intervene
|
||||
Flag Intervention
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => {
|
||||
console.log("[WizardControlPanel] Add Note clicked");
|
||||
onExecuteAction("note", { content: "Wizard note" });
|
||||
}}
|
||||
onClick={() => onExecuteAction("note", { content: "Wizard note" })}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<User className="mr-2 h-3 w-3" />
|
||||
Add Note
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
{currentStep?.type === "wizard_action" && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">Step Actions</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -419,31 +198,44 @@ export function WizardControlPanel({
|
||||
disabled={readOnly}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Mark Complete
|
||||
Mark Step Complete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<div className="text-muted-foreground text-center text-xs">
|
||||
{trial.status === "scheduled"
|
||||
? "Start trial to access actions"
|
||||
: "Actions unavailable - trial not active"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground p-2 text-center border border-dashed rounded-md bg-muted/20">
|
||||
Controls available during trial
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Robot Actions Tab */}
|
||||
<TabsContent
|
||||
value="robot"
|
||||
className="m-0 h-full data-[state=active]:flex data-[state=active]:flex-col"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
<Separator />
|
||||
|
||||
{/* Robot Controls (Merged from System & Robot Tab) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">Connection</span>
|
||||
{_isConnected ? (
|
||||
<Badge variant="default" className="bg-green-600 text-xs">Connected</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground border-muted-foreground/30 text-xs">Offline</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="autonomous-life" className="text-xs font-normal text-muted-foreground">Autonomous Life</Label>
|
||||
<Switch
|
||||
id="autonomous-life"
|
||||
checked={!!autonomousLife}
|
||||
onCheckedChange={handleAutonomousLifeChange}
|
||||
disabled={!_isConnected || readOnly}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Robot Actions Panel Integration */}
|
||||
{studyId && onExecuteRobotAction ? (
|
||||
<div className={readOnly ? "pointer-events-none opacity-50" : ""}>
|
||||
<RobotActionsPanel
|
||||
@@ -453,19 +245,12 @@ export function WizardControlPanel({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Robot actions are not available. Study ID or action
|
||||
handler is missing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="text-xs text-muted-foreground text-center py-2">Robot actions unavailable</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div >
|
||||
</div >
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Play,
|
||||
Clock,
|
||||
SkipForward,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
User,
|
||||
Activity,
|
||||
Zap,
|
||||
ArrowRight,
|
||||
AlertTriangle,
|
||||
Zap,
|
||||
Loader2,
|
||||
Clock,
|
||||
RotateCcw,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -26,8 +25,17 @@ interface StepData {
|
||||
| "wizard_action"
|
||||
| "robot_action"
|
||||
| "parallel_steps"
|
||||
| "conditional_branch";
|
||||
| "conditional";
|
||||
parameters: Record<string, unknown>;
|
||||
conditions?: {
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
nextStepId?: string;
|
||||
nextStepIndex?: number;
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
|
||||
}[];
|
||||
};
|
||||
order: number;
|
||||
actions?: {
|
||||
id: string;
|
||||
@@ -129,30 +137,31 @@ export function WizardExecutionPanel({
|
||||
|
||||
const activeActionIndex = completedActionsCount;
|
||||
|
||||
// Auto-scroll to active action
|
||||
const activeActionRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeActionRef.current) {
|
||||
activeActionRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, [activeActionIndex, currentStepIndex]);
|
||||
|
||||
// Pre-trial state
|
||||
if (trial.status === "scheduled") {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b p-3">
|
||||
<h3 className="text-sm font-medium">Trial Ready</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{steps.length} steps prepared for execution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-1 items-center justify-center p-6">
|
||||
<div className="w-full max-w-md space-y-3 text-center">
|
||||
<Clock className="text-muted-foreground mx-auto h-8 w-8" />
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md space-y-4 text-center">
|
||||
<Clock className="text-muted-foreground mx-auto h-12 w-12 opacity-20" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Ready to Begin</h4>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Use the control panel to start this trial
|
||||
<h4 className="text-lg font-medium">Ready to Begin</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{steps.length} steps prepared. Use controls to start.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<div>Experiment: {trial.experiment.name}</div>
|
||||
<div>Participant: {trial.participant.participantCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,88 +206,111 @@ export function WizardExecutionPanel({
|
||||
|
||||
// Active trial state
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Trial Execution</h3>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{currentStepIndex + 1} / {steps.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{currentStep && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{currentStep.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Simplified Content - Sequential Focus */}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="pr-4">
|
||||
{currentStep ? (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
{/* Header Info (Simplified) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex flex-col gap-4 p-4 max-w-2xl mx-auto">
|
||||
{/* Header Info */}
|
||||
<div className="space-y-1 pb-4 border-b">
|
||||
<h2 className="text-xl font-bold tracking-tight">{currentStep.name}</h2>
|
||||
{currentStep.description && (
|
||||
<div className="text-muted-foreground text-sm mt-1">{currentStep.description}</div>
|
||||
<div className="text-muted-foreground">{currentStep.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Sequence */}
|
||||
{currentStep.actions && currentStep.actions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Execution Sequence
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="relative ml-3 space-y-0 pt-2">
|
||||
{currentStep.actions.map((action, idx) => {
|
||||
const isCompleted = idx < activeActionIndex;
|
||||
const isActive = idx === activeActionIndex;
|
||||
const isLast = idx === currentStep.actions!.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className={`group relative flex items-center gap-4 rounded-xl border p-5 transition-all ${isActive ? "bg-card border-primary ring-1 ring-primary shadow-md" :
|
||||
isCompleted ? "bg-muted/30 border-transparent opacity-70" :
|
||||
"bg-card border-border opacity-50"
|
||||
className="relative pl-8 pb-10 last:pb-0"
|
||||
ref={isActive ? activeActionRef : undefined}
|
||||
>
|
||||
{/* Connecting Line */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className={`absolute left-[11px] top-8 bottom-0 w-[2px] ${isCompleted ? "bg-primary/20" : "bg-border/40"}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Marker */}
|
||||
<div
|
||||
className={`absolute left-0 top-1 h-6 w-6 rounded-full border-2 flex items-center justify-center z-10 bg-background transition-all duration-300 ${isCompleted
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: isActive
|
||||
? "border-primary ring-4 ring-primary/10 scale-110"
|
||||
: "border-muted-foreground/30 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border text-sm font-medium ${isCompleted ? "bg-transparent text-green-600 border-green-600" :
|
||||
isActive ? "bg-transparent text-primary border-primary font-bold shadow-sm" :
|
||||
"bg-transparent text-muted-foreground border-transparent"
|
||||
}`}>
|
||||
{isCompleted ? <CheckCircle className="h-5 w-5" /> : idx + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font - medium truncate ${isCompleted ? "line-through text-muted-foreground" : ""} `}>{action.name}</div>
|
||||
{action.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{action.description}
|
||||
</div>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="text-[10px] font-bold">{idx + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action.pluginId && isActive && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Content Card */}
|
||||
<div
|
||||
className={`rounded-lg border transition-all duration-300 ${isActive
|
||||
? "bg-card border-primary/50 shadow-md p-5 translate-x-1"
|
||||
: "bg-muted/5 border-transparent p-3 opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div
|
||||
className={`text-base font-medium leading-none ${isCompleted ? "line-through text-muted-foreground" : ""
|
||||
}`}
|
||||
>
|
||||
{action.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{action.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Action Controls */}
|
||||
{isActive && (
|
||||
<div className="pt-3 flex items-center gap-3">
|
||||
{action.pluginId && !["hristudio-core", "hristudio-woz"].includes(action.pluginId) ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shadow-sm min-w-[100px]"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onExecuteRobotAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".")
|
||||
? action.type.split(".").pop()!
|
||||
: action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false }
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<Play className="mr-2 h-3.5 w-3.5" />
|
||||
Execute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-9 px-3 text-muted-foreground hover:text-foreground"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("Skip clicked");
|
||||
// Fire and forget
|
||||
onSkipAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".")
|
||||
@@ -293,34 +325,8 @@ export function WizardExecutionPanel({
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
size="default"
|
||||
className="h-10 px-4 shadow-sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("Execute clicked");
|
||||
onExecuteRobotAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".")
|
||||
? action.type.split(".").pop()!
|
||||
: action.type,
|
||||
action.parameters || {},
|
||||
{ autoAdvance: false },
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Execute
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback for actions with no plugin ID (e.g. manual steps) */}
|
||||
{!action.pluginId && isActive && (
|
||||
<div className="flex items-center gap-2">
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
@@ -331,26 +337,57 @@ export function WizardExecutionPanel({
|
||||
>
|
||||
Mark Done
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed State Indicator */}
|
||||
{isCompleted && (
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<div className="text-xs font-medium text-green-600">
|
||||
Done
|
||||
</div>
|
||||
{action.pluginId && (
|
||||
<>
|
||||
{/* Wizard Wait For Response / Branching UI */}
|
||||
{isActive && action.type === 'wizard_wait_for_response' && action.parameters?.options && Array.isArray(action.parameters.options) && (
|
||||
<div className="pt-3 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{(action.parameters.options as any[]).map((opt, optIdx) => {
|
||||
// Handle both string options and object options
|
||||
const label = typeof opt === 'string' ? opt : opt.label;
|
||||
const value = typeof opt === 'string' ? opt : opt.value;
|
||||
const nextStepId = typeof opt === 'object' ? opt.nextStepId : undefined;
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
title="Retry Action"
|
||||
key={optIdx}
|
||||
variant="outline"
|
||||
className="justify-start h-auto py-3 px-4 text-left border-primary/20 hover:border-primary hover:bg-primary/5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onExecuteAction(
|
||||
action.id,
|
||||
{
|
||||
value,
|
||||
label,
|
||||
nextStepId
|
||||
}
|
||||
);
|
||||
onActionCompleted();
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="font-medium">{String(label)}</span>
|
||||
{typeof opt !== 'string' && value && <span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded-sm">{String(value)}</span>}
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed State Actions */}
|
||||
{isCompleted && action.pluginId && (
|
||||
<div className="pt-1 flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Execute again without advancing count
|
||||
onExecuteRobotAction(
|
||||
action.pluginId!,
|
||||
action.type.includes(".") ? action.type.split(".").pop()! : action.type,
|
||||
@@ -360,41 +397,27 @@ export function WizardExecutionPanel({
|
||||
}}
|
||||
disabled={readOnly || isExecuting}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<RotateCcw className="mr-1.5 h-3 w-3" />
|
||||
Retry
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-100"
|
||||
title="Mark Issue"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onExecuteAction("note", {
|
||||
content: `Reported issue with action: ${action.name}`,
|
||||
category: "system_issue"
|
||||
});
|
||||
}}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Manual Advance Button */}
|
||||
{activeActionIndex >= (currentStep.actions?.length || 0) && (
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="mt-6 flex justify-center pb-8">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={currentStepIndex === steps.length - 1 ? onCompleteTrial : onNextStep}
|
||||
className={`w-full text-white shadow-md transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
|
||||
className={`w-full max-w-sm text-white shadow-lg transition-all hover:scale-[1.02] ${currentStepIndex === steps.length - 1
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-green-600 hover:bg-green-700"
|
||||
}`}
|
||||
@@ -406,35 +429,15 @@ export function WizardExecutionPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Wizard Controls (If applicable) */}
|
||||
{currentStep.type === "wizard_action" && (
|
||||
<div className="rounded-xl border border-dashed p-6 space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Manual Controls</h3>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-12 justify-start border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100 hover:text-yellow-800"
|
||||
onClick={() => onExecuteAction("intervene")}
|
||||
disabled={readOnly}
|
||||
>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Flag Issue / Intervention
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
No active step
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground space-y-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
|
||||
<div className="text-sm">Waiting for trial to start...</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
{/* Scroll Hint Fade */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-background to-transparent z-10" />
|
||||
</div>
|
||||
</div >
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-2">
|
||||
{/* Camera View - Always Visible */}
|
||||
<div className="shrink-0 bg-black rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
|
||||
<div className="shrink-0 bg-muted/30 rounded-lg overflow-hidden border shadow-sm h-48 sm:h-56 relative group">
|
||||
<WebcamPanel readOnly={readOnly} />
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ const WizardMonitoringPanel = function WizardMonitoringPanel({
|
||||
{rosConnected ? (
|
||||
<Power className="h-3 w-3 text-green-600" />
|
||||
) : (
|
||||
<PowerOff className="h-3 w-3 text-gray-400" />
|
||||
<Badge variant="outline" className="text-gray-500 border-gray-300 text-xs text-muted-foreground w-auto px-1.5 py-0">Offline</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ interface WizardObservationPaneProps {
|
||||
) => Promise<void>;
|
||||
isSubmitting?: boolean;
|
||||
readOnly?: boolean;
|
||||
activeTab?: "notes" | "timeline";
|
||||
}
|
||||
|
||||
export function WizardObservationPane({
|
||||
@@ -38,6 +39,7 @@ export function WizardObservationPane({
|
||||
isSubmitting = false,
|
||||
trialEvents = [],
|
||||
readOnly = false,
|
||||
activeTab = "notes",
|
||||
}: WizardObservationPaneProps & { trialEvents?: TrialEvent[] }) {
|
||||
const [note, setNote] = useState("");
|
||||
const [category, setCategory] = useState("observation");
|
||||
@@ -68,20 +70,8 @@ export function WizardObservationPane({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col border-t bg-background">
|
||||
<Tabs defaultValue="notes" className="flex h-full flex-col">
|
||||
<div className="border-b px-4 bg-muted/30">
|
||||
<TabsList className="h-9 -mb-px bg-transparent p-0">
|
||||
<TabsTrigger value="notes" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
|
||||
Notes & Observations
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="timeline" className="h-9 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 pb-2 pt-2 font-medium text-muted-foreground data-[state=active]:text-foreground shadow-none">
|
||||
Timeline
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="notes" className="flex-1 flex flex-col p-4 m-0 data-[state=inactive]:hidden">
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<div className={`flex-1 flex flex-col p-4 m-0 ${activeTab !== "notes" ? "hidden" : ""}`}>
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Textarea
|
||||
placeholder={readOnly ? "Session is read-only" : "Type your observation here..."}
|
||||
@@ -151,12 +141,11 @@ export function WizardObservationPane({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<TabsContent value="timeline" className="flex-1 m-0 min-h-0 p-4 data-[state=inactive]:hidden">
|
||||
<div className={`flex-1 m-0 min-h-0 p-4 ${activeTab !== "timeline" ? "hidden" : ""}`}>
|
||||
<HorizontalTimeline events={trialEvents} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,7 +168,6 @@ export function convertDatabaseToSteps(
|
||||
const sortedSteps = [...dbSteps].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
|
||||
|
||||
return sortedSteps.map((dbStep, idx) => {
|
||||
// console.log(`[block-converter] Step ${dbStep.name} OrderIndex:`, dbStep.orderIndex, dbStep.order_index);
|
||||
return {
|
||||
id: dbStep.id,
|
||||
name: dbStep.name,
|
||||
|
||||
@@ -24,7 +24,7 @@ export type TriggerType =
|
||||
export interface ActionParameter {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "number" | "select" | "boolean";
|
||||
type: "text" | "number" | "select" | "boolean" | "json" | "array";
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
|
||||
@@ -148,7 +148,11 @@ export class WizardRosService extends EventEmitter {
|
||||
console.error("[WizardROS] WebSocket error:", error);
|
||||
clearTimeout(connectionTimeout);
|
||||
this.isConnecting = false;
|
||||
|
||||
// Prevent unhandled error event if no listeners
|
||||
if (this.listenerCount("error") > 0) {
|
||||
this.emit("error", error);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
51
src/plugins/definitions/hristudio-core.json
Normal file
51
src/plugins/definitions/hristudio-core.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"id": "hristudio-core",
|
||||
"name": "HRIStudio Core",
|
||||
"version": "1.0.0",
|
||||
"description": "Essential platform control flow and logic actions.",
|
||||
"author": "HRIStudio",
|
||||
"trustLevel": "official",
|
||||
"actionDefinitions": [
|
||||
{
|
||||
"id": "wait",
|
||||
"name": "Wait",
|
||||
"description": "Wait for specified time",
|
||||
"category": "control",
|
||||
"icon": "Clock",
|
||||
"color": "#f59e0b",
|
||||
"parameters": {},
|
||||
"parameterSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"duration": {
|
||||
"title": "Duration (seconds)",
|
||||
"type": "number",
|
||||
"minimum": 0.1,
|
||||
"maximum": 300,
|
||||
"default": 2,
|
||||
"description": "Time to wait in seconds"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"duration"
|
||||
]
|
||||
},
|
||||
"timeout": 60000,
|
||||
"retryable": false,
|
||||
"nestable": false
|
||||
},
|
||||
{
|
||||
"id": "branch",
|
||||
"name": "Branch / Decision",
|
||||
"description": "Prompt the wizard to choose a path.",
|
||||
"category": "control",
|
||||
"icon": "GitBranch",
|
||||
"color": "#f97316",
|
||||
"parameters": {},
|
||||
"parameterSchema": {},
|
||||
"timeout": 0,
|
||||
"retryable": false,
|
||||
"nestable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
107
src/plugins/definitions/hristudio-woz.json
Normal file
107
src/plugins/definitions/hristudio-woz.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"id": "hristudio-woz",
|
||||
"name": "Wizard of Oz Features",
|
||||
"version": "1.0.0",
|
||||
"description": "Standard capabilities for Wizard of Oz studies.",
|
||||
"author": "HRIStudio",
|
||||
"trustLevel": "official",
|
||||
"actionDefinitions": [
|
||||
{
|
||||
"id": "wizard_say",
|
||||
"name": "Wizard Says",
|
||||
"description": "Wizard speaks to participant",
|
||||
"category": "wizard",
|
||||
"icon": "MessageSquare",
|
||||
"color": "#a855f7",
|
||||
"parameters": {},
|
||||
"parameterSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"title": "Message",
|
||||
"type": "string",
|
||||
"description": "Text to display/speak"
|
||||
},
|
||||
"tone": {
|
||||
"title": "Tone",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"neutral",
|
||||
"friendly",
|
||||
"encouraging"
|
||||
],
|
||||
"default": "neutral"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
]
|
||||
},
|
||||
"timeout": 30000,
|
||||
"retryable": true,
|
||||
"nestable": false
|
||||
},
|
||||
{
|
||||
"id": "wizard_wait_for_response",
|
||||
"name": "Wait for Wizard Input",
|
||||
"description": "Pause execution until wizard provides input",
|
||||
"category": "wizard",
|
||||
"icon": "HandMetal",
|
||||
"color": "#a855f7",
|
||||
"parameters": {},
|
||||
"parameterSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt_text": {
|
||||
"title": "Prompt Text",
|
||||
"type": "string",
|
||||
"description": "What did the participant say?"
|
||||
},
|
||||
"options": {
|
||||
"title": "Response Options",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Choices for the Wizard"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"prompt_text"
|
||||
]
|
||||
},
|
||||
"timeout": 0,
|
||||
"retryable": false,
|
||||
"nestable": false
|
||||
},
|
||||
{
|
||||
"id": "observe",
|
||||
"name": "Observe",
|
||||
"description": "Record participant behavior",
|
||||
"category": "observation",
|
||||
"icon": "Eye",
|
||||
"color": "#8b5cf6",
|
||||
"parameters": {},
|
||||
"parameterSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"behavior": {
|
||||
"title": "Behavior to observe",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"facial_expression",
|
||||
"body_language",
|
||||
"verbal_response"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"behavior"
|
||||
]
|
||||
},
|
||||
"timeout": 120000,
|
||||
"retryable": false,
|
||||
"nestable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1543,7 +1543,8 @@ export const experimentsRouter = createTRPCRouter({
|
||||
description: step.description,
|
||||
order: step.orderIndex,
|
||||
duration: step.durationEstimate,
|
||||
parameters: step.conditions as Record<string, unknown>,
|
||||
parameters: {} as Record<string, unknown>, // No standard parameters on Step, only Conditions
|
||||
conditions: step.conditions as Record<string, unknown>, // Correctly map conditions
|
||||
parentId: undefined, // Not supported in current schema
|
||||
children: [], // TODO: implement hierarchical steps if needed
|
||||
actions: step.actions.map((action) => ({
|
||||
|
||||
@@ -1046,6 +1046,19 @@ export const trialsRouter = createTRPCRouter({
|
||||
createdBy: ctx.session.user.id,
|
||||
});
|
||||
|
||||
// Update execution variables if data provided
|
||||
if (input.data) {
|
||||
const executionEngine = getExecutionEngine();
|
||||
Object.entries(input.data).forEach(([key, value]) => {
|
||||
executionEngine.setVariable(input.trialId, key, value);
|
||||
});
|
||||
|
||||
// Also set a generic "last_wizard_response" if response field exists
|
||||
if ('response' in input.data) {
|
||||
executionEngine.setVariable(input.trialId, "last_wizard_response", input.data.response);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface StepDefinition {
|
||||
type: string;
|
||||
orderIndex: number;
|
||||
condition?: string;
|
||||
conditions?: Record<string, any>;
|
||||
actions: ActionDefinition[];
|
||||
}
|
||||
|
||||
@@ -173,7 +174,8 @@ export class TrialExecutionEngine {
|
||||
description: step.description || undefined,
|
||||
type: step.type,
|
||||
orderIndex: step.orderIndex,
|
||||
condition: (step.conditions as string) || undefined,
|
||||
condition: typeof step.conditions === 'string' ? step.conditions : undefined,
|
||||
conditions: typeof step.conditions === 'object' ? (step.conditions as Record<string, any>) : undefined,
|
||||
actions: actionDefinitions,
|
||||
});
|
||||
}
|
||||
@@ -399,20 +401,37 @@ export class TrialExecutionEngine {
|
||||
|
||||
switch (action.type) {
|
||||
case "wait":
|
||||
case "hristudio-core.wait":
|
||||
return await this.executeWaitAction(action);
|
||||
|
||||
case "branch":
|
||||
case "hristudio-core.branch":
|
||||
// Branch actions are logical markers; execution is just a pass-through
|
||||
return {
|
||||
success: true,
|
||||
completed: true,
|
||||
duration: 0,
|
||||
data: { message: "Branch point reached" },
|
||||
};
|
||||
|
||||
case "wizard_say":
|
||||
case "hristudio-woz.wizard_say":
|
||||
return await this.executeWizardAction(trialId, action);
|
||||
|
||||
case "wizard_wait_for_response":
|
||||
case "hristudio-woz.wizard_wait_for_response":
|
||||
return await this.executeWizardAction(trialId, action);
|
||||
|
||||
case "wizard_gesture":
|
||||
return await this.executeWizardAction(trialId, action);
|
||||
|
||||
case "observe_behavior":
|
||||
case "hristudio-woz.observe":
|
||||
return await this.executeObservationAction(trialId, action);
|
||||
|
||||
default:
|
||||
// Check if it's a robot action (contains plugin prefix)
|
||||
if (action.type.includes(".")) {
|
||||
if (action.type.includes(".") && !action.type.startsWith("hristudio-")) {
|
||||
return await this.executeRobotAction(trialId, action);
|
||||
}
|
||||
|
||||
@@ -424,6 +443,7 @@ export class TrialExecutionEngine {
|
||||
data: {
|
||||
message: `Action type '${action.type}' not implemented yet`,
|
||||
parameters: action.parameters,
|
||||
localHandler: true // Indicate this fell through to default local handler
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -813,6 +833,16 @@ export class TrialExecutionEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a variable in the trial context
|
||||
*/
|
||||
setVariable(trialId: string, key: string, value: unknown): void {
|
||||
const context = this.activeTrials.get(trialId);
|
||||
if (context) {
|
||||
context.variables[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to the next step
|
||||
*/
|
||||
@@ -827,12 +857,54 @@ export class TrialExecutionEngine {
|
||||
return { success: false, error: "No steps loaded for trial" };
|
||||
}
|
||||
|
||||
const currentStep = steps[context.currentStepIndex];
|
||||
if (!currentStep) {
|
||||
return { success: false, error: "Invalid current step" };
|
||||
}
|
||||
|
||||
const previousStepIndex = context.currentStepIndex;
|
||||
context.currentStepIndex++;
|
||||
let nextStepIndex = context.currentStepIndex + 1;
|
||||
|
||||
// Check for branching conditions
|
||||
if (currentStep.conditions && currentStep.conditions.options) {
|
||||
const { variable, options } = currentStep.conditions as any;
|
||||
|
||||
// Default to "last_wizard_response" if variable not specified, for backward compatibility
|
||||
const variableName = variable || "last_wizard_response";
|
||||
const variableValue = context.variables[variableName];
|
||||
|
||||
console.log(`[TrialExecution] Checking branch condition for step ${currentStep.id}: variable=${variableName}, value=${variableValue}`);
|
||||
|
||||
if (variableValue !== undefined) {
|
||||
// Find matching option
|
||||
// option.value matches variableValue (e.g., label string)
|
||||
const matchedOption = options.find((opt: any) => opt.value === variableValue || opt.label === variableValue);
|
||||
|
||||
if (matchedOption) {
|
||||
if (matchedOption.nextStepId) {
|
||||
// Find step by ID
|
||||
const targetStepIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
|
||||
if (targetStepIndex !== -1) {
|
||||
nextStepIndex = targetStepIndex;
|
||||
console.log(`[TrialExecution] Taking branch to step ID ${matchedOption.nextStepId} (Index ${nextStepIndex})`);
|
||||
} else {
|
||||
console.warn(`[TrialExecution] Branch target step ID ${matchedOption.nextStepId} not found`);
|
||||
}
|
||||
} else if (matchedOption.nextStepIndex !== undefined) {
|
||||
// Fallback to relative/absolute index if ID not present (legacy)
|
||||
nextStepIndex = matchedOption.nextStepIndex;
|
||||
console.log(`[TrialExecution] Taking branch to index ${nextStepIndex}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.currentStepIndex = nextStepIndex;
|
||||
|
||||
await this.logTrialEvent(trialId, "step_transition", {
|
||||
fromStepIndex: previousStepIndex,
|
||||
toStepIndex: context.currentStepIndex,
|
||||
reason: nextStepIndex !== previousStepIndex + 1 ? "branch" : "sequence"
|
||||
});
|
||||
|
||||
// Check if we've completed all steps
|
||||
|
||||
Reference in New Issue
Block a user