feat: fix a lot of small changes to match cb, add functionality for all plans, add tests for the new plan editor. even more i dont really know anymore.

ref: N25B-412
This commit is contained in:
Björn Otgaar
2025-12-17 15:51:50 +01:00
parent c1ef924be1
commit 444e8b0289
16 changed files with 884 additions and 561 deletions

View File

@@ -16,10 +16,55 @@ export type Goal = {
// Actions
export type Action = SpeechAction | GestureAction | LLMAction
export type SpeechAction = { name: string, id: string, text: string, type:"speech" }
export type GestureAction = { name: string, id: string, gesture: string, type:"gesture" }
export type LLMAction = { name: string, id: string, goal: string, type:"llm" }
export type SpeechAction = { id: string, text: string, type:"speech" }
export type GestureAction = { id: string, gesture: string, 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) {
if (!plan) return ""
return {
name: plan.name,
id: plan.id,
steps: plan.steps,
}
}
/**
* Finds out whether the plan can iterate multiple times, or always stops after one action.
* This comes down to checking if the plan only has speech/ gesture actions, or others as well.
* @param plan: the plan to check
* @returns: a boolean
*/
export function DoesPlanIterate(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;
}
/**
* Returns the value of the action.
* Since typescript can't polymorphicly access the value field,
* we need to switch over the types and return the correct field.
* @param action: action to retrieve the value from
* @returns string | undefined
*/
export function GetActionValue(action: Action) {
let returnAction;
switch (action.type) {
case "gesture":
returnAction = action as GestureAction
return returnAction.gesture;
case "speech":
returnAction = action as SpeechAction
return returnAction.text;
case "llm":
returnAction = action as LLMAction
return returnAction.goal;
default:
break;
}
}

View File

@@ -0,0 +1,237 @@
import { useRef, useState } from "react";
import styles from '../../VisProg.module.css';
import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan";
import { defaultPlan } from "../components/Plan.default";
import { TextField } from "../../../../components/TextField";
type PlanEditorDialogProps = {
plan?: Plan;
onSave: (plan: Plan | undefined) => void;
description? : string;
};
/**
* Adds an element to a React.JSX.Element that allows for the creation and editing of plans.
* Renders a dialog in the current screen with buttons and text fields for names, actions and other configurability.
* @param param0: Takes in a current plan, which can be undefined and a function which is called on saving with the potential plan.
* @returns: JSX.Element
* @example
* ```
* // Within a Node's default JSX Element function
* <PlanEditorDialog
* plan={data.plan}
* onSave={(plan) => {
* updateNodeData(props.id, {
* ...data,
* plan,
* });
* }}
* />
* ```
*/
export default function PlanEditorDialog({
plan,
onSave,
description,
}: PlanEditorDialogProps) {
// UseStates and references
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [draftPlan, setDraftPlan] = useState<Plan | null>(null);
const [newActionType, setNewActionType] = useState<ActionTypes>("speech");
const [newActionValue, setNewActionValue] = useState("");
//Button Actions
const openCreate = () => {
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()});
dialogRef.current?.showModal();
};
const openCreateWithDescription = () => {
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID(), name: description!});
setNewActionType("llm")
setNewActionValue(description!)
dialogRef.current?.showModal();
}
const openEdit = () => {
if (!plan) return;
setDraftPlan(structuredClone(plan));
dialogRef.current?.showModal();
};
const close = () => {
dialogRef.current?.close();
setDraftPlan(null);
};
const buildAction = (): Action => {
const id = crypto.randomUUID();
switch (newActionType) {
case "speech":
return { id, text: newActionValue, type: "speech" };
case "gesture":
return { id, gesture: newActionValue, type: "gesture" };
case "llm":
return { id, goal: newActionValue, type: "llm" };
}
};
return (<>
{/* Create and edit buttons */}
{!plan && (
<button className={styles.nodeButton} onClick={description ? openCreateWithDescription : openCreate}>
Create Plan
</button>
)}
{plan && (
<button className={styles.nodeButton} onClick={openEdit}>
Edit Plan
</button>
)}
{/* Start of dialog (plan editor) */}
<dialog
ref={dialogRef}
className={styles.planDialog}
onCancel={(e) => e.preventDefault()}
>
<form method="dialog" className="flex-col gap-md">
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
{/* Plan name text field */}
{draftPlan && (
<TextField
value={draftPlan.name}
setValue={(name) =>
setDraftPlan({ ...draftPlan, name })}
placeholder="Plan name"
data-testid="name_text_field"/>
)}
{/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */}
{draftPlan && (<div className={styles.planEditor}>
<div className={styles.planEditorLeft}>
{/* Left Side (Action Adder) */}
<h4>Add Action</h4>
{(!plan && description && draftPlan.steps.length === 0) && (<div className={styles.stepSuggestion}>
<label> Filled in as a suggestion! </label>
<label> Feel free to change! </label>
</div>)}
<label>
Action Type <wbr />
{/* Type selection */}
<select
value={newActionType}
onChange={(e) =>
setNewActionType(e.target.value as ActionTypes)}>
<option value="speech">Speech Action</option>
<option value="gesture">Gesture Action</option>
<option value="llm">LLM Action</option>
</select>
</label>
{/* Action value editor */}
<TextField
value={newActionValue}
setValue={setNewActionValue}
// TODO: Can be done with a switch in case there's more types to come.
placeholder={
newActionType === "speech" ? "Speech text"
: newActionType === "gesture" ? "Gesture name"
: "LLM goal"
}
/>
{/* Adding steps */}
<button
type="button"
disabled={!newActionValue}
onClick={() => {
if (!draftPlan) return;
// Add action to steps
const action = buildAction();
setDraftPlan({
...draftPlan,
steps: [...draftPlan.steps, action],});
// Reset current action building
setNewActionValue("");
setNewActionType("speech");
}}>
Add Step
</button>
</div>
{/* Right Side (Steps shown) */}
<div className={styles.planEditorRight}>
<h4>Steps</h4>
{/* Show if there are no steps yet */}
{draftPlan.steps.length === 0 && (
<div className={styles.emptySteps}>
No steps yet
</div>
)}
{/* Map over all steps, create a div for them that deletes them
if clicked on and add the index, name and type. as spans */}
{draftPlan.steps.map((step, index) => (
<div
key={step.id}
className={styles.planStep}
onClick={() => {
setDraftPlan({
...draftPlan,
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
}}>
<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>
</div>
))}
</div>
</div>
)} {/* End Action Editor and steps shower */}
{/* Buttons */}
<div className="flex-row gap-md">
{/* Close button */}
<button type="button" onClick={close}>
Cancel
</button>
{/* Confirm/ Create button */}
<button
type="button"
disabled={!draftPlan}
onClick={() => {
if (!draftPlan) return;
onSave(draftPlan);
close();
}}>
{draftPlan?.id === plan?.id ? "Confirm" : "Create"}
</button>
{/* Reset button */}
<button
type="button"
disabled={!draftPlan}
onClick={() => {
onSave(undefined);
close();
}}>
Reset
</button>
</div>
</form>
</dialog>
</>
);
}

View File

@@ -181,11 +181,27 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
*/
export function BasicBeliefReduce(node: Node, _nodes: Node[]) {
const data = node.data as BasicBeliefNodeData;
return {
id: node.id,
type: data.belief.type,
value: data.belief.value
const result: Record<string, unknown> = {
id: node.id,
};
switch (data.belief.type) {
case "emotion":
result["emotion"] = data.belief.value;
break;
case "keyword":
result["keyword"] = data.belief.value;
break;
case "object":
result["object"] = data.belief.value;
break;
case "semantic":
result["description"] = data.belief.value;
break;
default:
break;
}
return result
}
/**

View File

@@ -6,7 +6,8 @@ import type { GoalNodeData } from "./GoalNode";
export const GoalNodeDefaults: GoalNodeData = {
label: "Goal Node",
droppable: true,
description: "The robot will strive towards this goal",
description: "",
achieved: false,
hasReduce: true,
can_fail: true,
};

View File

@@ -8,6 +8,8 @@ import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import { TextField } from '../../../../components/TextField';
import useFlowStore from '../VisProgStores';
import { DoesPlanIterate, PlanReduce, type Plan } from '../components/Plan';
import PlanEditorDialog from '../components/PlanEditor';
/**
* The default data dot a phase node
@@ -22,6 +24,8 @@ export type GoalNodeData = {
droppable: boolean;
achieved: boolean;
hasReduce: boolean;
can_fail: boolean;
plan?: Plan;
};
export type GoalNode = Node<GoalNodeData>
@@ -37,13 +41,14 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
const text_input_id = `goal_${id}_text_input`;
const checkbox_id = `goal_${id}_checkbox`;
const planIterate = DoesPlanIterate(data.plan);
const setDescription = (value: string) => {
updateNodeData(id, {...data, description: value});
}
const setAchieved = (value: boolean) => {
updateNodeData(id, {...data, achieved: value});
const setFailable = (value: boolean) => {
updateNodeData(id, {...data, can_fail: value});
}
return <>
@@ -57,14 +62,34 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
setValue={(val) => setDescription(val)}
placeholder={"To ..."}
/>
</div>
<div className={"flex-row gap-md align-center"}>
<label htmlFor={checkbox_id}>Achieved:</label>
<div>
<label> {!data.plan ? "No plan set to execute while goal is not reached. 🔴" : "Will follow plan '" + data.plan.name + "' until goal is met. 🟢"} </label>
</div>
{data.plan && (<div className={"flex-row gap-md align-center " + (planIterate ? "" : styles.planNoIterate)}>
{planIterate ? "" : <s></s>}
<label htmlFor={checkbox_id}>Try this plan only once (allow critical abortion):</label>
<input
id={checkbox_id}
type={"checkbox"}
checked={data.achieved || false}
onChange={(e) => setAchieved(e.target.checked)}
disabled={!planIterate}
checked={!planIterate || data.can_fail}
onChange={(e) => planIterate ? setFailable(e.target.checked) : undefined}
/>
</div>
)}
<div>
<PlanEditorDialog
plan={data.plan}
onSave={(plan) => {
updateNodeData(id, {
...data,
plan,
});
}}
description={data.description}
/>
</div>
<Handle type="source" position={Position.Right} id="GoalSource"/>
@@ -80,11 +105,12 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
*/
export function GoalReduce(node: Node, _nodes: Node[]) {
const data = node.data as GoalNodeData;
return {
return {
id: node.id,
label: data.label,
name: data.label,
description: data.description,
achieved: data.achieved,
can_fail: data.can_fail,
plan: data.plan ? PlanReduce(data.plan) : "",
}
}

View File

@@ -6,7 +6,6 @@ import type { NormNodeData } from "./NormNode";
export const NormNodeDefaults: NormNodeData = {
label: "Norm Node",
droppable: true,
conditions: [],
norm: "",
hasReduce: true,
critical: false,

View File

@@ -86,7 +86,7 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
// Build the result object
const result: Record<string, unknown> = {
id: thisnode.id,
label: data.label,
name: data.label,
};
nodesInPhase.forEach((type) => {

View File

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

View File

@@ -9,11 +9,9 @@ import {
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import useFlowStore from '../VisProgStores';
import { useState, useRef } from 'react';
import { RealtimeTextField, TextField } from '../../../../components/TextField';
import duplicateIndices from '../../../../utils/duplicateIndices';
import type { Action, ActionTypes, Plan } from '../components/Plan';
import { defaultPlan } from '../components/Plan.default';
import { PlanReduce, type Plan } from '../components/Plan';
import PlanEditorDialog from '../components/PlanEditor';
import { BasicBeliefReduce } from './BasicBeliefNode';
/**
* The default data structure for a Trigger node
@@ -23,8 +21,6 @@ import { defaultPlan } from '../components/Plan.default';
*
* @property label: the display label of this Trigger node.
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
* @property triggerType - The type of trigger ("keywords" or a custom string).
* @property triggers - The list of keyword triggers (if applicable).
* @property hasReduce - Whether this node supports reduction logic.
*/
export type TriggerNodeData = {
@@ -44,6 +40,7 @@ export type TriggerNode = Node<TriggerNodeData>
*
* @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);
@@ -57,64 +54,7 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
export default function TriggerNode(props: NodeProps<TriggerNode>) {
const data = props.data;
const {updateNodeData} = useFlowStore();
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [draftPlan, setDraftPlan] = useState<Plan | null>(null);
// Helpers for inside plan creation
const [newActionType, setNewActionType] = useState<ActionTypes>("speech");
const [newActionName, setNewActionName] = useState("");
const [newActionValue, setNewActionValue] = useState("");
// Create a new Plan
const openCreateDialog = () => {
setDraftPlan(JSON.parse(JSON.stringify(defaultPlan)));
dialogRef.current?.showModal();
};
// Edit our current plan
const openEditDialog = () => {
if (!data.plan) return;
setDraftPlan(JSON.parse(JSON.stringify(data.plan)));
dialogRef.current?.showModal();
};
// Close the creating/editing of our plan
const closeDialog = () => {
dialogRef.current?.close();
};
// Define the function for creating actions
const buildAction = (): Action => {
const id = crypto.randomUUID();
switch (newActionType) {
case "speech":
return {
id,
name: newActionName,
text: newActionValue,
type: "speech"
};
case "gesture":
return {
id,
name: newActionName,
gesture: newActionValue,
type: "gesture"
};
case "llm":
return {
id,
name: newActionName,
goal: newActionValue,
type: "llm"
};
}
};
return <>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
@@ -123,199 +63,16 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
<Handle type="source" position={Position.Right} id="TriggerSource"/>
<Handle type="target" position={Position.Bottom} id="ConditionTarget"/>
{/* We don't have a plan yet, show our create plan button */}
{!data.plan && (
<button
className={styles.nodeButton}
onClick={openCreateDialog}
>
Create Plan
</button>
)}
{/* We have a plan, show our edit plan button */}
{data.plan && (
<button
className={styles.nodeButton}
onClick={openEditDialog}
>
Edit Plan
</button>
)}
<PlanEditorDialog
plan={data.plan}
onSave={(plan) => {
updateNodeData(props.id, {
...data,
plan,
});
}}
/>
</div>
{/* Define how our dialog should work */}
<dialog ref={dialogRef}
className={styles.planDialog}
onCancel={(e) => e.preventDefault()}
>
<form method="dialog" className="flex-col gap-md">
<h3>{draftPlan?.id === data.plan?.id ? "Edit Plan" : "Create Plan"}</h3>
{/*Text field to edit the name of our draft plan*/}
<div className={styles.planEditor}>
{/* LEFT: plan info + add action */}
<div className={styles.planEditorLeft}>
{/* Plan name */}
{draftPlan && (
<div className="flex-col gap-sm">
<label htmlFor="plan-name">Plan Name</label>
<TextField
id="plan-name"
value={draftPlan.name}
setValue={(name) =>
setDraftPlan({
...draftPlan,
name,
})
}
/>
</div>
)}
{/* Add action UI */}
{draftPlan && (
<div className="flex-col gap-sm">
<h4>Add Action</h4>
<label>
Action Type <wbr></wbr> {/* Just moves the selector over a bit :) */}
<select
value={newActionType}
onChange={(e) =>
setNewActionType(e.target.value as ActionTypes)
}
>
<option value="speech">Speech Action</option>
<option value="gesture">Gesture Action</option>
<option value="llm">LLM Action</option>
</select>
</label>
<TextField
value={newActionName}
setValue={setNewActionName}
placeholder="Action name"
/>
<TextField
value={newActionValue}
setValue={setNewActionValue}
placeholder={
newActionType === "speech"
? "Speech text"
: newActionType === "gesture"
? "Gesture name"
: "LLM goal"
}
/>
<button
type="button"
disabled={!newActionName || !newActionValue}
onClick={() => {
if (!draftPlan) return;
const action = buildAction();
setDraftPlan({
...draftPlan,
steps: [...draftPlan.steps, action],
});
setNewActionName("");
setNewActionValue("");
setNewActionType("speech");
}}
>
Add Step
</button>
</div>
)}
</div>
{/* RIGHT: steps list */}
<div className={styles.planEditorRight}>
<h4>Steps</h4>
{draftPlan && draftPlan.steps.length === 0 && (
<div className={styles.emptySteps}>
No steps yet
</div>
)}
{draftPlan?.steps.map((step, index) => (
<div
key={step.id}
className={styles.planStep}
onClick={() => {
if (!draftPlan) return;
setDraftPlan({
...draftPlan,
steps: draftPlan.steps.filter((s) => s.id !== step.id),
});
}}
>
<span className={styles.stepIndex}>{index + 1}.</span>
<span className={styles.stepName}>{step.name}</span>
<span className={styles.stepType}>{step.type}</span>
</div>
))}
</div>
</div>
<div className="flex-row gap-md">
{/*Button to close the plan editor.*/}
<button
type="button"
onClick={() => {
setDraftPlan(null);
closeDialog();
}}
>
Cancel
</button>
{/*Button to save the draftPlan to the plan in the Node.*/}
<button
type="button"
disabled={!draftPlan}
onClick={() => {
if (!draftPlan) return;
updateNodeData(props.id, {
...data,
plan: draftPlan,
});
setDraftPlan(null);
closeDialog();
}}
>
{draftPlan?.id === data.plan?.id ? "Confirm" : "Create"}
</button>
{/*Button to reset the plan*/}
<button
type="button"
disabled={!draftPlan}
onClick={() => {
if (!draftPlan) return;
updateNodeData(props.id, {
...data,
plan: undefined,
});
setNewActionName("")
setNewActionType("speech")
setNewActionValue("")
setDraftPlan(defaultPlan)
}}
>
Reset
</button>
</div>
</form>
</dialog>
</>;
}
@@ -325,22 +82,16 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
* @param _nodes - The list of all nodes in the current flow graph.
* @returns A simplified object containing the node label and its list of triggers.
*/
export function TriggerReduce(node: Node, _nodes: Node[]) {
const data = node.data;
switch (data.triggerType) {
case "keywords":
return {
id: node.id,
type: "keywords",
label: data.label,
keywords: data.triggers,
};
default:
return {
...data,
id: node.id,
};
export function TriggerReduce(node: Node, nodes: Node[]) {
const data = node.data as TriggerNodeData;
const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : ""
return {
id: node.id,
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 ""
}
}
/**
@@ -405,92 +156,4 @@ export type KeywordTriggerNodeProps = {
}
/** Union type for all possible Trigger node configurations. */
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
/**
* Renders an input element that allows users to add new keyword triggers.
*
* When the input is committed, the `addKeyword` callback is called with the new keyword.
*
* @param param0 - An object containing the `addKeyword` function.
* @returns A React element(React.JSX.Element) providing an input for adding keywords.
*/
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
const [input, setInput] = useState("");
const text_input_id = "keyword_adder_input";
return <div className={"flex-row gap-md"}>
<label htmlFor={text_input_id}>New Keyword:</label>
<RealtimeTextField
id={text_input_id}
value={input}
setValue={setInput}
onCommit={() => {
if (!input) return;
addKeyword(input);
setInput("");
}}
placeholder={"..."}
className={"flex-1"}
/>
</div>;
}
/**
* Displays and manages a list of keyword triggers for a Trigger node.
* Handles adding, editing, and removing keywords, as well as detecting duplicate entries.
*
* @param keywords - The current list of keyword triggers.
* @param setKeywords - A callback to update the keyword list in the parent node.
* @returns A React element(React.JSX.Element) for editing keyword triggers.
*/
function Keywords({
keywords,
setKeywords,
}: {
keywords: Keyword[];
setKeywords: (keywords: Keyword[]) => void;
}) {
type Interpolatable = string | number | boolean | bigint | null | undefined;
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
/** Indices of duplicates in the keyword array. */
const [duplicates, setDuplicates] = useState<number[]>([]);
function replace(id: string, value: string) {
value = value.trim();
const newKeywords = value === ""
? keywords.filter((kw) => kw.id != id)
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
setKeywords(newKeywords);
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
}
function add(value: string) {
value = value.trim();
if (value === "") return;
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
setKeywords(newKeywords);
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
}
return <>
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
{[...keywords].map(({id, keyword}, index) => {
return <div key={id} className={"flex-row gap-md"}>
<label htmlFor={inputElementId(id)}>Keyword:</label>
<TextField
id={inputElementId(id)}
value={keyword}
setValue={(val) => replace(id, val)}
placeholder={"..."}
className={"flex-1"}
invalid={duplicates.includes(index)}
/>
</div>;
})}
<KeywordAdder addKeyword={add} />
</>;
}
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;