Adding goal nodes automatically adds them to the plan, and correctly reduces them #40
@@ -164,6 +164,14 @@
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.bottomLeftHandle {
|
||||
left: 40% !important;
|
||||
}
|
||||
|
||||
.bottomRightHandle {
|
||||
left: 60% !important;
|
||||
}
|
||||
|
||||
.node-toolbar-tooltip {
|
||||
background-color: darkgray;
|
||||
color: white;
|
||||
|
||||
@@ -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[],
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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';
|
||||
@@ -124,7 +124,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
||||
wrapping = '"'
|
||||
break;
|
||||
case ("semantic"):
|
||||
placeholder = "description..."
|
||||
placeholder = "short description..."
|
||||
wrapping = '"'
|
||||
break;
|
||||
case ("object"):
|
||||
@@ -184,12 +184,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>
|
||||
</>
|
||||
|
||||
@@ -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) : "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +155,22 @@ export const GoalTooltip = `
|
||||
* @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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,7 +188,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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TriggerNodeData } from "./TriggerNode";
|
||||
*/
|
||||
export const TriggerNodeDefaults: TriggerNodeData = {
|
||||
label: "Trigger Node",
|
||||
name: "",
|
||||
droppable: true,
|
||||
hasReduce: true,
|
||||
};
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
}
|
||||
@@ -115,9 +132,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,6 +172,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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// PlanEditorDialog.test.tsx
|
||||
import { describe, it, beforeEach, jest } from '@jest/globals';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { Node } from '@xyflow/react';
|
||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||
import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor';
|
||||
import { PlanReduce, type Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan';
|
||||
import '@testing-library/jest-dom';
|
||||
import { GoalReduce, type GoalNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx';
|
||||
import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts';
|
||||
import { insertGoalInPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx';
|
||||
|
||||
|
||||
// Mock structuredClone
|
||||
(globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val)));
|
||||
@@ -468,38 +472,50 @@ describe('PlanEditorDialog', () => {
|
||||
|
||||
describe('Plan reducing', () => {
|
||||
it('should correctly reduce the plan given the elements of the plan', () => {
|
||||
const testplan = extendedPlan
|
||||
const expectedResult = {
|
||||
id: "extended-plan-1",
|
||||
steps: [
|
||||
{
|
||||
id: "firststep",
|
||||
gesture: {
|
||||
type: "tag",
|
||||
name: "hello"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "secondstep",
|
||||
gesture: {
|
||||
type: "single",
|
||||
name: "somefolder/somegesture"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "thirdstep",
|
||||
goal: "ask the user something or whatever"
|
||||
},
|
||||
{
|
||||
id: "fourthstep",
|
||||
text: "I'm a cyborg ninja :>"
|
||||
// Create a plan for testing
|
||||
const testplan = extendedPlan
|
||||
const mockGoalNode: Node<GoalNodeData> = {
|
||||
id: 'goal-1',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'mock goal', plan: defaultPlan },
|
||||
};
|
||||
|
||||
// Insert the goal and retrieve its expected data
|
||||
const newTestPlan = insertGoalInPlan(testplan, mockGoalNode)
|
||||
const goalReduced = GoalReduce(mockGoalNode, [mockGoalNode])
|
||||
const expectedResult = {
|
||||
id: "extended-plan-1",
|
||||
steps: [
|
||||
{
|
||||
id: "firststep",
|
||||
gesture: {
|
||||
type: "tag",
|
||||
name: "hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const actualResult = PlanReduce(testplan)
|
||||
|
||||
expect(actualResult).toEqual(expectedResult)
|
||||
},
|
||||
{
|
||||
id: "secondstep",
|
||||
gesture: {
|
||||
type: "single",
|
||||
name: "somefolder/somegesture"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "thirdstep",
|
||||
goal: "ask the user something or whatever"
|
||||
},
|
||||
{
|
||||
id: "fourthstep",
|
||||
text: "I'm a cyborg ninja :>"
|
||||
},
|
||||
goalReduced,
|
||||
]
|
||||
}
|
||||
|
||||
// Check to see it the goal got added, and its reduced data was added to the goals'
|
||||
const actualResult = PlanReduce([mockGoalNode], newTestPlan)
|
||||
expect(actualResult).toEqual(expectedResult)
|
||||
});
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, it, beforeEach } from '@jest/globals';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||
import GoalNode, { GoalReduce, type GoalNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode';
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
import type { Node } from '@xyflow/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts';
|
||||
import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts';
|
||||
|
||||
describe('GoalNode', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the Goal node with default data', () => {
|
||||
const mockNode: Node = {
|
||||
id: 'goal-1',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)) },
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<GoalNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText('To ...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates goal name when user types and commits', async () => {
|
||||
const mockNode: Node<GoalNodeData> = {
|
||||
id: 'goal-2',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: '' },
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<GoalNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('To ...');
|
||||
|
||||
await user.type(input, 'Save the world{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updated = state.nodes.find(n => n.id === 'goal-2');
|
||||
expect(updated?.data.name).toBe('Save the world');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows plan message and disabled checked checkbox when plan does not iterate', () => {
|
||||
const mockNode: Node<GoalNodeData> = {
|
||||
id: 'goal-3',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: defaultPlan, name: 'G' },
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<GoalNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Will follow plan 'Default Plan' until all steps complete./i)).toBeInTheDocument();
|
||||
|
||||
const checkbox = screen.getByLabelText(/This plan always succeeds!/i) as HTMLInputElement;
|
||||
expect(checkbox).toBeDisabled();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('allows toggling can_fail when plan iterates', async () => {
|
||||
// plan with an llm-step will make DoesPlanIterate return true
|
||||
const iterPlan = { ...defaultPlan, id: 'p-iter', steps: [{ id: 'a-1', type: 'llm', goal: 'do' }] } as any;
|
||||
const mockNode: Node<GoalNodeData> = {
|
||||
id: 'goal-4',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: iterPlan, name: 'Iterating' },
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<GoalNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText(/Check if this plan fails/i) as HTMLInputElement;
|
||||
expect(checkbox).not.toBeDisabled();
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
await user.click(checkbox);
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updated = state.nodes.find(n => n.id === 'goal-4');
|
||||
expect(updated?.data.can_fail).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the checkbox and shows description when plan includes a checking sub-goal', () => {
|
||||
const childGoal: Node = {
|
||||
id: 'child-1',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), can_fail: true },
|
||||
};
|
||||
|
||||
const p = { ...defaultPlan, id: 'p-2', steps: [{ id: 'child-1', type: 'goal' } as any] } as any;
|
||||
|
||||
const mockNode: Node<GoalNodeData> = {
|
||||
id: 'goal-5',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: p, name: 'HasCheck' },
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode, childGoal], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<GoalNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
|
||||
expect(checkbox).toBeDisabled();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
// description box should be visible because there's a checking subgoal
|
||||
expect(screen.getByPlaceholderText('Describe the condition of this goal...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reduces its data correctly (GoalReduce)', () => {
|
||||
const childGoal: Node = {
|
||||
id: 'child-2',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), can_fail: true },
|
||||
};
|
||||
|
||||
const p = { ...defaultPlan, id: 'p-3', steps: [{ id: 'child-2', type: 'goal' } as any] } as any;
|
||||
|
||||
const mockNode: Node = {
|
||||
id: 'goal-6',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: p, name: 'ReduceMe', description: 'desc', can_fail: false },
|
||||
};
|
||||
|
||||
const reduced = GoalReduce(mockNode, [mockNode, childGoal]);
|
||||
expect(reduced).toEqual({
|
||||
id: 'goal-6',
|
||||
name: 'ReduceMe',
|
||||
description: 'desc',
|
||||
can_fail: true,
|
||||
plan: {
|
||||
id: expect.anything(),
|
||||
steps: expect.any(Array),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a goal into a plan when a goal is connected to another', () => {
|
||||
const source: Node = { id: 'g-src', type: 'goal', position: { x: 0, y: 0 }, data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Source' } };
|
||||
const target: Node = { id: 'g-target', type: 'goal', position: { x: 0, y: 0 }, data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Target' } };
|
||||
|
||||
useFlowStore.setState({ nodes: [source, target], edges: [] });
|
||||
|
||||
// Simulate react-flow connect
|
||||
useFlowStore.getState().onConnect({ source: 'g-src', target: 'g-target', sourceHandle: null, targetHandle: null });
|
||||
|
||||
const state = useFlowStore.getState();
|
||||
const updatedTarget = state.nodes.find(n => n.id === 'g-target');
|
||||
|
||||
expect(updatedTarget?.data.plan).toBeDefined();
|
||||
const plan = updatedTarget?.data.plan as any;
|
||||
expect(plan.steps.length).toBe(1);
|
||||
expect(plan.steps[0].id).toBe('g-src');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||
import TriggerNode, {
|
||||
TriggerReduce,
|
||||
TriggerNodeCanConnect,
|
||||
type TriggerNodeData,
|
||||
TriggerConnectionSource, TriggerConnectionTarget
|
||||
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode';
|
||||
@@ -14,6 +13,8 @@ import { TriggerNodeDefaults } from '../../../../../src/pages/VisProgPage/visual
|
||||
import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts';
|
||||
import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts';
|
||||
import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts';
|
||||
import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
describe('TriggerNode', () => {
|
||||
|
||||
@@ -73,7 +74,8 @@ describe('TriggerNode', () => {
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
|
||||
condition: "belief-1",
|
||||
plan: defaultPlan
|
||||
plan: defaultPlan,
|
||||
name: "trigger-1"
|
||||
},
|
||||
};
|
||||
|
||||
@@ -93,6 +95,7 @@ describe('TriggerNode', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'trigger-1',
|
||||
name: "trigger-1",
|
||||
condition: {
|
||||
id: "belief-1",
|
||||
keyword: "",
|
||||
@@ -132,10 +135,50 @@ describe('TriggerNode', () => {
|
||||
TriggerConnectionTarget(node1, node2.id);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true for TriggerNodeCanConnect if connection exists', () => {
|
||||
const connection = { source: 'trigger-1', target: 'norm-1' };
|
||||
expect(TriggerNodeCanConnect(connection as any)).toBe(true);
|
||||
|
||||
describe('TriggerConnects Function', () => {
|
||||
it('should correctly remove a goal from the triggers plan after it has been disconnected', () => {
|
||||
// first, define the goal node and trigger node.
|
||||
const goal: Node = {
|
||||
id: 'g-1',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Goal 1' },
|
||||
};
|
||||
|
||||
const trigger: Node<TriggerNodeData> = {
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(TriggerNodeDefaults)) },
|
||||
};
|
||||
|
||||
// set initial store
|
||||
useFlowStore.setState({ nodes: [goal, trigger], edges: [] });
|
||||
|
||||
// then, connect the goal to the trigger.
|
||||
act(() => {
|
||||
useFlowStore.getState().onConnect({ source: 'g-1', target: 'trigger-1', sourceHandle: null, targetHandle: null });
|
||||
});
|
||||
|
||||
// expect the goal id to be part of a goal step of the plan.
|
||||
let updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
|
||||
expect(updatedTrigger?.data.plan).toBeDefined();
|
||||
const plan = updatedTrigger?.data.plan as any;
|
||||
expect(plan.steps.find((s: any) => s.id === 'g-1')).toBeDefined();
|
||||
|
||||
// then, disconnect the goal from the trigger.
|
||||
act(() => {
|
||||
useFlowStore.getState().onEdgesDelete([{ id: 'g-1-trigger-1', source: 'g-1', target: 'trigger-1' } as any]);
|
||||
});
|
||||
|
||||
// finally, expect the goal id to NOT be part of the goal step of the plan.
|
||||
updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
|
||||
const planAfter = updatedTrigger?.data.plan as any;
|
||||
const stillHas = planAfter?.steps?.find((s: any) => s.id === 'g-1');
|
||||
expect(stillHas).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user