Compare commits

14 Commits

Author SHA1 Message Date
JobvAlewijk
a4f7b48031 Merge branch 'feat/monitoringpage-pim' of ssh://git.science.uu.nl/ics/sp/2025/n25b/pepperplus-ui into feat/monitoringpage-pim 2026-01-29 16:24:19 +01:00
JobvAlewijk
b18cd5bfa5 chore: face 2026-01-29 16:24:03 +01:00
Pim Hutting
09e6287f9d chore: edited a test to pass 2026-01-26 14:35:46 +01:00
Pim Hutting
8b40001038 Merge branch 'dev' into feat/monitoringpage-pim 2026-01-26 14:22:13 +01:00
Twirre
f9e0eb95f8 Merge branch 'feat/add-inferred-belief-node' into 'dev'
feat: added an inferred belief node to the editor

See merge request ics/sp/2025/n25b/pepperplus-ui!42
2026-01-23 12:57:35 +00:00
Gerla, J. (Justin)
47c5e94b8f feat: added an inferred belief node to the editor 2026-01-23 12:57:34 +00:00
Pim Hutting
b17d1e7618 Merge branch 'fix/correct-capitalization' into 'dev'
chore: fix the capitalization of 3 characters to make sure they match. :)

See merge request ics/sp/2025/n25b/pepperplus-ui!46
2026-01-23 10:42:36 +00:00
Björn Otgaar
ec211ccbc3 chore: fix the capitalization of 3 characters to make sure they match. :) 2026-01-23 11:25:30 +01:00
Pim Hutting
9a555165e6 Merge branch 'dev' into feat/monitoringpage-pim 2026-01-22 10:19:46 +01:00
Pim Hutting
f73bbb9d02 chore: added tests and removed restart phase
this version also has recursive goals functional
2026-01-22 10:15:20 +01:00
Pim Hutting
883f0a95a6 chore: only check if play is undefined 2026-01-20 13:55:34 +01:00
Gerla, J. (Justin)
6f4471ce6f Merge branch 'demo' into 'dev'
feat: merged demo into dev

See merge request ics/sp/2025/n25b/pepperplus-ui!43
2026-01-20 11:10:58 +00:00
Pim Hutting
8c28dd6c1c Merge branch 'feat/recursive-goal-making' into 'demo'
Adding goal nodes automatically adds them to the plan, and correctly reduces them

See merge request ics/sp/2025/n25b/pepperplus-ui!40
2026-01-19 14:00:04 +00:00
Björn Otgaar
e1257bdf48 Merge branch 'demo' into feat/recursive-goal-making 2026-01-13 12:33:27 +01:00
14 changed files with 171 additions and 86 deletions

View File

@@ -77,11 +77,6 @@
color: white;
}
.restartPhase{
background-color: rgb(255, 123, 0);
color: white;
}
.restartExperiment{
background-color: red;
color: white;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useState } from 'react';
import styles from './MonitoringPage.module.css';
// Store & API
@@ -52,16 +52,12 @@ function useExperimentLogic() {
const [phaseIndex, setPhaseIndex] = useState(0);
const [isFinished, setIsFinished] = useState(false);
// Ref to suppress stream updates during the "Reset Phase" fast-forward sequence
const suppressUpdates = useRef(false);
const phaseIds = getPhaseIds();
const phaseNames = getPhaseNames();
// --- Stream Handlers ---
const handleStreamUpdate = useCallback((data: ExperimentStreamData) => {
if (suppressUpdates.current) return;
if (data.type === 'phase_update' && data.id) {
const payload = data as PhaseUpdate;
console.log(`${data.type} received, id : ${data.id}`);
@@ -105,7 +101,6 @@ function useExperimentLogic() {
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]);
const handleStatusUpdate = useCallback((data: unknown) => {
if (suppressUpdates.current) return;
const payload = data as CondNormsStateUpdate;
if (payload.type !== 'cond_norms_state_update') return;
@@ -145,7 +140,7 @@ function useExperimentLogic() {
}
}, [setProgramState]);
const handleControlAction = async (action: "pause" | "play" | "nextPhase" | "resetPhase") => {
const handleControlAction = async (action: "pause" | "play" | "nextPhase") => {
try {
setLoading(true);
switch (action) {
@@ -160,30 +155,6 @@ function useExperimentLogic() {
case "nextPhase":
await nextPhase();
break;
case "resetPhase":
//make sure you don't see the phases pass to arrive back at current phase
suppressUpdates.current = true;
const targetIndex = phaseIndex;
console.log(`Resetting phase: Restarting and skipping to index ${targetIndex}`);
const phases = graphReducer();
setProgramState({ phases });
setActiveIds({});
setPhaseIndex(0); // Visually reset to start
setGoalIndex(0);
setIsFinished(false);
// Restart backend
await runProgramm();
for (let i = 0; i < targetIndex; i++) {
console.log(`Skipping phase ${i}...`);
await nextPhase();
}
suppressUpdates.current = false;
setPhaseIndex(targetIndex);
setIsPlaying(true); //Maybe you pause and then reset
break;
}
} catch (err) {
console.error(err);
@@ -251,7 +222,7 @@ function ControlPanel({
}: {
loading: boolean,
isPlaying: boolean,
onAction: (a: "pause" | "play" | "nextPhase" | "resetPhase") => void,
onAction: (a: "pause" | "play" | "nextPhase") => void,
onReset: () => void
}) {
return (
@@ -276,12 +247,6 @@ function ControlPanel({
disabled={loading}
></button>
<button
className={styles.restartPhase}
onClick={() => onAction("resetPhase")}
disabled={loading}
></button>
<button
className={styles.restartExperiment}
onClick={onReset}

View File

@@ -32,16 +32,6 @@ export async function nextPhase(): Promise<void> {
}
/**
* Sends an API call to the CB for going to reset the currect phase
* In case we can't go to the next phase, the function will throw an error.
*/
export async function resetPhase(): Promise<void> {
const type = "reset_phase"
const context = ""
sendAPICall(type, context)
}
/**
* Sends an API call to the CB for going to pause experiment
*/

View File

@@ -119,7 +119,7 @@ const VisProgUI = () => {
<SaveLoadPanel></SaveLoadPanel>
</Panel>
<Panel position="bottom-center">
<button onClick={() => undo()}>undo</button>
<button onClick={() => undo()}>Undo</button>
<button onClick={() => redo()}>Redo</button>
</Panel>
<Controls/>
@@ -175,7 +175,7 @@ function VisProgPage() {
return (
<>
<VisualProgrammingUI/>
<button onClick={runProgram}>run program</button>
<button onClick={runProgram}>Run Program</button>
</>
)
}

View File

@@ -107,4 +107,16 @@ export function useHandleRules(
// finally we return a function that evaluates all rules using the created context
return evaluateRules(targetRules, connection, context);
};
}
export function validateConnectionWithRules(
connection: Connection,
context: ConnectionContext
): RuleResult {
const rules = useFlowStore.getState().getTargetRules(
connection.target!,
connection.targetHandle!
);
return evaluateRules(rules,connection, context);
}

View File

@@ -9,6 +9,7 @@ import {
type XYPosition,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
import type { FlowState } from './VisProgTypes';
import {
NodeDefaults,
@@ -129,7 +130,41 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
* Handles reconnecting an edge between nodes.
*/
onReconnect: (oldEdge, newConnection) => {
get().edgeReconnectSuccessful = true;
function createContext(
source: {id: string, handleId: string},
target: {id: string, handleId: string}
) : ConnectionContext {
const edges = get().edges;
const targetConnections = edges.filter(edge => edge.target === target.id && edge.targetHandle === target.handleId).length
return {
connectionCount: targetConnections,
source: source,
target: target
}
}
// connection validation
const context: ConnectionContext = oldEdge.source === newConnection.source
? createContext({id: newConnection.source, handleId: newConnection.sourceHandle!}, {id: newConnection.target, handleId: newConnection.targetHandle!})
: createContext({id: newConnection.target, handleId: newConnection.targetHandle!}, {id: newConnection.source, handleId: newConnection.sourceHandle!});
const result = validateConnectionWithRules(
newConnection,
context
);
if (!result.isSatisfied) {
set({
edges: get().edges.map(e =>
e.id === oldEdge.id ? oldEdge : e
),
});
return;
}
// further reconnect logic
set({ edgeReconnectSuccessful: true });
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
// We make sure to perform any required data updates on the newly reconnected nodes
@@ -188,7 +223,7 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
// Let's find our node to check if they have a special deletion function
const ourNode = get().nodes.find((n)=>n.id==nodeId);
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
// If there's no function, OR, our function tells us we can delete it, let's do so...
if (ourFunction == undefined || ourFunction()) {
set({

View File

@@ -10,6 +10,7 @@ import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores.tsx';
import { TextField } from '../../../../components/TextField.tsx';
import { MultilineTextField } from '../../../../components/MultilineTextField.tsx';
import {noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
/**
* The default data structure for a BasicBelief node
@@ -31,11 +32,12 @@ export type BasicBeliefNodeData = {
};
// These are all the types a basic belief could be.
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion | Face
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"};
type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"};
type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
type Emotion = { type: "emotion", id: string, value: string, label: "Emotion recognised:"};
type Face = { type: "face", id: string, value: string, label: "Face detected"};
export type BasicBeliefNode = Node<BasicBeliefNodeData>
@@ -112,8 +114,8 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
updateNodeData(props.id, {...data, belief: {...data.belief, description: value}});
}
// These are the labels outputted by our emotion detection model
const emotionOptions = ["sad", "angry", "surprise", "fear", "happy", "disgust", "neutral"];
// Use this
const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"]
let placeholder = ""
@@ -155,6 +157,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
<option value="semantic">Detected with LLM:</option>
<option value="object">Object found:</option>
<option value="emotion">Emotion recognised:</option>
<option value="face">Face detected</option>
</select>
{wrapping}
{data.belief.type === "emotion" && (
@@ -189,7 +192,8 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
</div>
)}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]),
noMatchingLeftRightBelief,
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]),
]}/>
</div>
</>
@@ -222,6 +226,10 @@ export function BasicBeliefReduce(node: Node, _nodes: Node[]) {
result["name"] = data.belief.value;
result["description"] = data.belief.description;
break;
case "face":
result["face_present"] = true;
break;
default:
break;
}

View File

@@ -5,7 +5,7 @@ import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx";
* Default data for this node
*/
export const InferredBeliefNodeDefaults: InferredBeliefNodeData = {
label: "Inferred Belief",
label: "AND/OR",
droppable: true,
inferredBelief: {
left: undefined,

View File

@@ -50,9 +50,9 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
const setName= (value: string) => {
updateNodeData(props.id, {...data, name: value})
}
return <>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
<TextField
@@ -70,9 +70,9 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
type="target"
position={Position.Bottom}
id="TriggerBeliefs"
style={{ left: '40%' }}
style={{ left: '40%' }}
rules={[
allowOnlyConnectionsFromType(['basic_belief', "inferred_belief"]),
allowOnlyConnectionsFromType(['basic_belief']),
]}
/>
@@ -136,7 +136,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string)
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode) return;
if (otherNode.type === 'basic_belief'|| otherNode.type ==='inferred_belief') {
if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) {
data.condition = _sourceNodeId;
}
@@ -172,7 +172,7 @@ export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: strin
const data = _thisNode.data as TriggerNodeData;
// remove if the target of disconnection was our condition
if (_sourceNodeId == data.condition) data.condition = undefined
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
}

View File

@@ -85,9 +85,8 @@ const useProgramStore = create<ProgramState>((set, get) => ({
const rootGoals = phase["goals"] as Record<string, unknown>[];
const flatList: GoalWithDepth[] = [];
// Helper: Define this ONCE, outside the loop
const isGoal = (item: Record<string, unknown>) => {
return item["plan"] !== undefined && item["plan"] !== null;
return item["plan"] !== undefined;
};
// Recursive helper function

View File

@@ -51,6 +51,7 @@ describe('MonitoringPage', () => {
const mockGetPhaseNames = jest.fn();
const mockGetNorms = jest.fn();
const mockGetGoals = jest.fn();
const mockGetGoalsWithDepth = jest.fn();
const mockGetTriggers = jest.fn();
const mockSetProgramState = jest.fn();
@@ -65,6 +66,7 @@ describe('MonitoringPage', () => {
getNormsInPhase: mockGetNorms,
getGoalsInPhase: mockGetGoals,
getTriggersInPhase: mockGetTriggers,
getGoalsWithDepth: mockGetGoalsWithDepth,
setProgramState: mockSetProgramState,
};
return selector(state);
@@ -81,7 +83,11 @@ describe('MonitoringPage', () => {
// Default mock return values
mockGetPhaseIds.mockReturnValue(['phase-1', 'phase-2']);
mockGetPhaseNames.mockReturnValue(['Intro', 'Main']);
mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1' }, { id: 'g2', name: 'Goal 2' }]);
mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1'}, { id: 'g2', name: 'Goal 2'}]);
mockGetGoalsWithDepth.mockReturnValue([
{ id: 'g1', name: 'Goal 1', level: 0 },
{ id: 'g2', name: 'Goal 2', level: 0 }
]);
mockGetTriggers.mockReturnValue([{ id: 't1', name: 'Trigger 1' }]);
mockGetNorms.mockReturnValue([
{ id: 'n1', norm: 'Norm 1', condition: null },

View File

@@ -1,8 +1,7 @@
import { renderHook, act, cleanup } from '@testing-library/react';
import {
sendAPICall,
nextPhase,
resetPhase,
nextPhase,
pauseExperiment,
playExperiment,
useExperimentLogger,
@@ -116,14 +115,6 @@ describe('MonitoringPageAPI', () => {
);
});
test('resetPhase sends correct params', async () => {
await resetPhase();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'reset_phase', context: '' }) })
);
});
test('pauseExperiment sends correct params', async () => {
await pauseExperiment();
expect(globalThis.fetch).toHaveBeenCalledWith(

View File

@@ -105,6 +105,8 @@ describe("SaveLoadPanel - combined tests", () => {
});
test("onLoad with invalid JSON does not update store", async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const file = new File(["not json"], "bad.json", { type: "application/json" });
file.text = jest.fn(() => Promise.resolve(`{"bad json`));
@@ -112,20 +114,19 @@ describe("SaveLoadPanel - combined tests", () => {
render(<SaveLoadPanel />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeTruthy();
// Give some input
act(() => {
fireEvent.change(input, { target: { files: [file] } });
});
await waitFor(() => {
expect(window.alert).toHaveBeenCalledTimes(1);
const nodesAfter = useFlowStore.getState().nodes;
expect(nodesAfter).toHaveLength(0);
expect(input.value).toBe("");
});
// Clean up the spy
consoleSpy.mockRestore();
});
test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => {

View File

@@ -115,6 +115,89 @@ describe('useProgramStore', () => {
});
});
describe('getGoalsWithDepth', () => {
const complexProgram: ReducedProgram = {
phases: [
{
id: 'phase-nested',
goals: [
// Level 0: Root Goal 1
{
id: 'root-1',
name: 'Root Goal 1',
plan: {
steps: [
// This is an ACTION (no plan), should be ignored
{ id: 'action-1', type: 'speech' },
// Level 1: Child Goal
{
id: 'child-1',
name: 'Child Goal',
plan: {
steps: [
// Level 2: Grandchild Goal
{
id: 'grandchild-1',
name: 'Grandchild',
plan: { steps: [] } // Empty plan is still a plan
}
]
}
}
]
}
},
// Level 0: Root Goal 2 (Sibling)
{
id: 'root-2',
name: 'Root Goal 2',
plan: { steps: [] }
}
]
}
]
};
it('should flatten nested goals and assign correct depth levels', () => {
useProgramStore.getState().setProgramState(complexProgram);
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
// logic: Root 1 -> Child 1 -> Grandchild 1 -> Root 2
expect(goals).toHaveLength(4);
// Check Root 1
expect(goals[0]).toEqual(expect.objectContaining({ id: 'root-1', level: 0 }));
// Check Child 1
expect(goals[1]).toEqual(expect.objectContaining({ id: 'child-1', level: 1 }));
// Check Grandchild 1
expect(goals[2]).toEqual(expect.objectContaining({ id: 'grandchild-1', level: 2 }));
// Check Root 2
expect(goals[3]).toEqual(expect.objectContaining({ id: 'root-2', level: 0 }));
});
it('should ignore steps that are not goals (missing "plan" property)', () => {
useProgramStore.getState().setProgramState(complexProgram);
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
// The 'action-1' object should NOT be in the list
const action = goals.find(g => g.id === 'action-1');
expect(action).toBeUndefined();
});
it('throws if phase does not exist', () => {
useProgramStore.getState().setProgramState(complexProgram);
expect(() =>
useProgramStore.getState().getGoalsWithDepth('missing-phase')
).toThrow('phase with id:"missing-phase" not found');
});
});
it('should return the names of all phases in the program', () => {
// Define a program specifically with names for this test
const programWithNames: ReducedProgram = {