Merge remote-tracking branch 'origin/feat/recursive-goal-making' into feat/monitoringpage

This commit is contained in:
Pim Hutting
2026-01-12 17:16:52 +01:00
14 changed files with 550 additions and 95 deletions

View File

@@ -140,6 +140,20 @@
filter: drop-shadow(0 0 0.25rem plum);
}
.planNoIterate {
opacity: 0.5;
font-style: italic;
text-decoration: line-through;
}
.bottomLeftHandle {
left: 40% !important;
}
.bottomRightHandle {
left: 60% !important;
}
.planNoIterate {
opacity: 0.5;
font-style: italic;

View File

@@ -1,7 +1,7 @@
import type { Plan } from "./Plan";
import type { Plan, PlanElement } from "./Plan";
export const defaultPlan: Plan = {
name: "Default Plan",
id: "-1",
steps: [],
steps: [] as PlanElement[],
}

View File

@@ -1,3 +1,7 @@
import { type Node } from "@xyflow/react"
import { GoalReduce } from "../nodes/GoalNode"
export type Plan = {
name: string,
id: string,
@@ -7,10 +11,7 @@ export type Plan = {
export type PlanElement = Goal | Action
export type Goal = {
id: string,
name: string,
plan: Plan,
can_fail: boolean,
id: string // we let the reducer figure out the rest dynamically
type: "goal"
}
@@ -19,23 +20,24 @@ export type Action = SpeechAction | GestureAction | LLMAction
export type SpeechAction = { id: string, text: string, type:"speech" }
export type GestureAction = { id: string, gesture: string, isTag: boolean, type:"gesture" }
export type LLMAction = { id: string, goal: string, type:"llm" }
export type ActionTypes = "speech" | "gesture" | "llm";
// Extract the wanted information from a plan within the reducing of nodes
export function PlanReduce(plan?: Plan) {
export function PlanReduce(_nodes: Node[], plan?: Plan, ) {
if (!plan) return ""
return {
id: plan.id,
steps: plan.steps.map((x) => StepReduce(x))
steps: plan.steps.map((x) => StepReduce(x, _nodes))
}
}
// Extract the wanted information from a plan element.
function StepReduce(planElement: PlanElement) {
function StepReduce(planElement: PlanElement, _nodes: Node[]) : Record<string, unknown> {
// We have different types of plan elements, requiring differnt types of output
const nodes = _nodes
const thisNode = _nodes.find((x) => x.id === planElement.id)
switch (planElement.type) {
case ("speech"):
return {
@@ -56,12 +58,7 @@ function StepReduce(planElement: PlanElement) {
goal: planElement.goal,
}
case ("goal"):
return {
id: planElement.id,
plan: planElement.plan,
can_fail: planElement.can_fail,
};
default:
return thisNode ? GoalReduce(thisNode, nodes) : {}
}
}
@@ -71,10 +68,36 @@ function StepReduce(planElement: PlanElement) {
* @param plan: the plan to check
* @returns: a boolean
*/
export function DoesPlanIterate(plan?: Plan) : boolean {
export function DoesPlanIterate( _nodes: Node[], plan?: Plan,) : boolean {
// TODO: should recursively check plans that have goals (and thus more plans) in them.
if (!plan) return false
return plan.steps.filter((step) => step.type == "llm").length > 0;
return plan.steps.filter((step) => step.type == "llm").length > 0 ||
(
// Find the goal node of this step
plan.steps.filter((step) => step.type == "goal").map((goalStep) => {
const goalId = goalStep.id;
const goalNode = _nodes.find((x) => x.id === goalId);
// In case we don't find any valid plan, this node doesn't iterate
if (!goalNode || !goalNode.data.plan) return false;
// Otherwise, check if this node can fail - if so, we should have the option to iterate
return (goalNode && goalNode.data.plan && goalNode.data.can_fail)
})
).includes(true);
}
/**
* Checks if any of the plan's goal steps has its can_fail value set to true.
* @param plan: plan to check
* @param _nodes: nodes in flow store.
*/
export function HasCheckingSubGoal(plan: Plan, _nodes: Node[]) {
const goalSteps = plan.steps.filter((x) => x.type == "goal");
return goalSteps.map((goalStep) => {
// Find the goal node and check its can_fail data boolean.
const goalId = goalStep.id;
const goalNode = _nodes.find((x) => x.id === goalId);
return (goalNode && goalNode.data.can_fail)
}).includes(true);
}
/**

View File

@@ -0,0 +1,35 @@
// This file is to avoid sharing both functions and components which eslint dislikes. :)
import type { GoalNode } from "../nodes/GoalNode"
import type { Goal, Plan } from "./Plan"
/**
* Inserts a goal into a plan
* @param plan: plan to insert goal into
* @param goalNode: the goal node to insert into the plan.
* @returns: a new plan with the goal inside.
*/
export function insertGoalInPlan(plan: Plan, goalNode: GoalNode): Plan {
const planElement : Goal = {
id: goalNode.id,
type: "goal",
}
return {
...plan,
steps: [...plan.steps, planElement],
}
}
/**
* Deletes a goal from a plan
* @param plan: plan to delete goal from
* @param goalID: the goal node to delete.
* @returns: a new plan with the goal removed.
*/
export function deleteGoalInPlanByID(plan: Plan, goalID: string) {
const updatedPlan = {...plan,
steps: plan.steps.filter((x) => x.id !== goalID)
}
return updatedPlan.steps.length == 0 ? undefined : updatedPlan
}

View File

@@ -23,7 +23,9 @@ export default function PlanEditorDialog({
const [newActionType, setNewActionType] = useState<ActionTypes>("speech");
const [newActionGestureType, setNewActionGestureType] = useState<boolean>(true);
const [newActionValue, setNewActionValue] = useState("");
const [hasInteractedWithPlan, setHasInteractedWithPlan] = useState<boolean>(false)
const { setScrollable } = useFlowStore();
const nodes = useFlowStore().nodes;
//Button Actions
const openCreate = () => {
@@ -55,6 +57,7 @@ export default function PlanEditorDialog({
const buildAction = (): Action => {
const id = crypto.randomUUID();
setHasInteractedWithPlan(true)
switch (newActionType) {
case "speech":
return { id, text: newActionValue, type: "speech" };
@@ -102,7 +105,7 @@ export default function PlanEditorDialog({
<div className={styles.planEditorLeft}>
{/* Left Side (Action Adder) */}
<h4>Add Action</h4>
{(!plan && description && draftPlan.steps.length === 0) && (<div className={styles.stepSuggestion}>
{(!plan && description && draftPlan.steps.length === 0 && !hasInteractedWithPlan) && (<div className={styles.stepSuggestion}>
<label> Filled in as a suggestion! </label>
<label> Feel free to change! </label>
</div>)}
@@ -196,9 +199,13 @@ export default function PlanEditorDialog({
<span className={styles.stepIndex}>{index + 1}.</span>
<span className={styles.stepType}>{step.type}:</span>
<span className={styles.stepName}>{
step.type == "goal" ? ""/* TODO: Add support for goals */
: GetActionValue(step)}
<span className={styles.stepName}>
{
// This just tries to find the goals name, i know it looks ugly:(
step.type === "goal"
? ((nodes.find(x => x.id === step.id)?.data.name as string) == "" ?
"unnamed goal": (nodes.find(x => x.id === step.id)?.data.name as string))
: (GetActionValue(step) ?? "")}
</span>
</div>
))}

View File

@@ -6,7 +6,7 @@ import {
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import { TextField } from '../../../../components/TextField';
import { MultilineTextField } from '../../../../components/MultilineTextField';
@@ -120,7 +120,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
wrapping = '"'
break;
case ("semantic"):
placeholder = "description..."
placeholder = "short description..."
wrapping = '"'
break;
case ("object"):
@@ -180,12 +180,12 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
<MultilineTextField
value={data.belief.description}
setValue={setBeliefDescription}
placeholder={"Describe the desciption of this LLM belief..."}
placeholder={"Describe a detailed desciption of this LLM belief..."}
/>
</div>
)}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
allowOnlyConnectionsFromType(["norm", "trigger"]),
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
]}/>
</div>
</>

View File

@@ -7,11 +7,13 @@ import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import { TextField } from '../../../../components/TextField';
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import { DoesPlanIterate, PlanReduce, type Plan } from '../components/Plan';
import {DoesPlanIterate, HasCheckingSubGoal, PlanReduce, type Plan } from '../components/Plan';
import PlanEditorDialog from '../components/PlanEditor';
import { MultilineTextField } from '../../../../components/MultilineTextField';
import { defaultPlan } from '../components/Plan.default.ts';
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
/**
* The default data dot a phase node
@@ -43,10 +45,12 @@ export type GoalNode = Node<GoalNodeData>
*/
export default function GoalNode({id, data}: NodeProps<GoalNode>) {
const {updateNodeData} = useFlowStore();
const _nodes = useFlowStore().nodes;
const text_input_id = `goal_${id}_text_input`;
const checkbox_id = `goal_${id}_checkbox`;
const planIterate = DoesPlanIterate(data.plan);
const planIterate = DoesPlanIterate(_nodes, data.plan);
const hasCheckSubGoal = data.plan !== undefined && HasCheckingSubGoal(data.plan, _nodes)
const setDescription = (value: string) => {
updateNodeData(id, {...data, description: value});
@@ -73,7 +77,7 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
/>
</div>
{data.can_fail && (<div>
{(data.can_fail || hasCheckSubGoal) && (<div>
<label htmlFor={text_input_id}>Description/ Condition of goal:</label>
<div className={"flex-wrap"}>
<MultilineTextField
@@ -93,8 +97,8 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
<input
id={checkbox_id}
type={"checkbox"}
disabled={!planIterate}
checked={!planIterate || data.can_fail}
disabled={!planIterate || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
checked={!planIterate || data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
onChange={(e) => planIterate ? setFailable(e.target.checked) : setFailable(false)}
/>
</div>
@@ -115,6 +119,10 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
]}/>
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[allowOnlyConnectionsFromType(["goal"])]}/>
</div>
</>;
}
@@ -131,8 +139,8 @@ export function GoalReduce(node: Node, _nodes: Node[]) {
id: node.id,
name: data.name,
description: data.description,
can_fail: data.can_fail,
plan: data.plan ? PlanReduce(data.plan) : "",
can_fail: data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes)),
plan: data.plan ? PlanReduce(_nodes, data.plan) : "",
}
}
@@ -142,7 +150,22 @@ export function GoalReduce(node: Node, _nodes: Node[]) {
* @param _sourceNodeId the source of the received connection
*/
export function GoalConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
// Goals should only be targeted by other goals, for them to be part of our plan.
const nodes = useFlowStore.getState().nodes;
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode || otherNode.type !== "goal") return;
const data = _thisNode.data as GoalNodeData
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
if (!data.plan) {
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode)
}
// Else, lets just insert this goal into our current plan.
else {
data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode)
}
}
/**
@@ -160,7 +183,9 @@ export function GoalConnectionSource(_thisNode: Node, _targetNodeId: string) {
* @param _sourceNodeId the source of the disconnected connection
*/
export function GoalDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
// We should probably check if our disconnection was by a goal, since it would mean we have to remove it from our plan list.
const data = _thisNode.data as GoalNodeData
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
}
/**

View File

@@ -80,7 +80,7 @@ export default function NormNode(props: NodeProps<NormNode>) {
<MultiConnectionHandle type="source" position={Position.Right} id="norms" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
]}/>
<SingleConnectionHandle type="target" position={Position.Bottom} id="beliefs" rules={[
<SingleConnectionHandle type="target" position={Position.Bottom} id="NormBeliefs" rules={[
allowOnlyConnectionsFromType(["basic_belief"])
]}/>
</div>

View File

@@ -108,7 +108,10 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
console.warn(`No reducer found for node type ${type}`);
result[type + "s"] = [];
} else {
result[type + "s"] = typedChildren.map((child) => reducer(child, nodes));
result[type + "s"] = [];
for (const typedChild of typedChildren) {
(result[type + "s"] as object[]).push(reducer(typedChild, nodes))
}
}
});

View File

@@ -5,6 +5,7 @@ import type { TriggerNodeData } from "./TriggerNode";
*/
export const TriggerNodeDefaults: TriggerNodeData = {
label: "Trigger Node",
name: "",
droppable: true,
hasReduce: true,
};

View File

@@ -1,8 +1,6 @@
import {
type NodeProps,
Position,
type Connection,
type Edge,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
@@ -10,9 +8,13 @@ import styles from '../../VisProg.module.css';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import { PlanReduce, type Plan } from '../components/Plan';
import {PlanReduce, type Plan } from '../components/Plan';
import PlanEditorDialog from '../components/PlanEditor';
import { BasicBeliefReduce } from './BasicBeliefNode';
import type { GoalNode } from './GoalNode.tsx';
import { defaultPlan } from '../components/Plan.default.ts';
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
import { TextField } from '../../../../components/TextField.tsx';
/**
* The default data structure for a Trigger node
@@ -26,6 +28,7 @@ import { BasicBeliefReduce } from './BasicBeliefNode';
*/
export type TriggerNodeData = {
label: string;
name: string;
droppable: boolean;
condition?: string; // id of the belief
plan?: Plan;
@@ -35,18 +38,6 @@ export type TriggerNodeData = {
export type TriggerNode = Node<TriggerNodeData>
/**
* Determines whether a Trigger node can connect to another node or edge.
*
* @param connection - The connection or edge being attempted to connect towards.
* @returns `true` if the connection is defined; otherwise, `false`.
*
*/
export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
return (connection != undefined);
}
/**
* Defines how a Trigger node should be rendered
* @param props - Node properties provided by React Flow, including `id` and `data`.
@@ -55,20 +46,45 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
export default function TriggerNode(props: NodeProps<TriggerNode>) {
const data = props.data;
const {updateNodeData} = useFlowStore();
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
value={props.data.name}
setValue={(val) => setName(val)}
placeholder={"Name of this trigger..."}
/>
<div className={"flex-row gap-md"}>Triggers when the condition is met.</div>
<div className={"flex-row gap-md"}>Condition/ Belief is currently {data.condition ? "" : "not"} set. {data.condition ? "🟢" : "🔴"}</div>
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
<MultiConnectionHandle type="source" position={Position.Right} id="TriggerSource" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
]}/>
<SingleConnectionHandle type="target" position={Position.Bottom} id="beliefs" rules={[
allowOnlyConnectionsFromType(["basic_belief"])
]}/>
<SingleConnectionHandle
type="target"
position={Position.Bottom}
id="TriggerBeliefs"
style={{ left: '40%' }}
rules={[
allowOnlyConnectionsFromType(['basic_belief']),
]}
/>
<MultiConnectionHandle
type="target"
position={Position.Bottom}
id="GoalTarget"
style={{ left: '60%' }}
rules={[
allowOnlyConnectionsFromType(['goal']),
]}
/>
<PlanEditorDialog
plan={data.plan}
@@ -95,8 +111,9 @@ export function TriggerReduce(node: Node, nodes: Node[]) {
const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : ""
return {
id: node.id,
name: node.data.name,
condition: conditionData, // Make sure we have a condition before reducing, or default to ""
plan: !data.plan ? "" : PlanReduce(data.plan), // Make sure we have a plan when reducing, or default to ""
plan: !data.plan ? "" : PlanReduce(nodes, data.plan), // Make sure we have a plan when reducing, or default to ""
}
}
@@ -110,9 +127,25 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string)
// no additional connection logic exists yet
const data = _thisNode.data as TriggerNodeData;
// If we got a belief connected, this is the condition for the norm.
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) {
const nodes = useFlowStore.getState().nodes;
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode) return;
if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) {
data.condition = _sourceNodeId;
}
else if (otherNode.type === 'goal') {
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
if (!data.plan) {
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode)
}
// Else, lets just insert this goal into our current plan.
else {
data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode)
}
}
}
/**
@@ -134,6 +167,8 @@ 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)
}
/**