feat: create dialog for plan creation in triggers, make sure to bind the correct things in triggers. Change the norms to take one condition, rather than a list. yes, tests are probably still broken.

ref: N25B-412
This commit is contained in:
Björn Otgaar
2025-12-16 18:21:19 +01:00
parent 0b29cb5858
commit c1ef924be1
5 changed files with 377 additions and 37 deletions

View File

@@ -138,4 +138,68 @@
border-radius: 5pt;
outline: plum solid 2pt;
filter: drop-shadow(0 0 0.25rem plum);
}
.planDialog {
width: 80vw;
max-width: 900px;
padding: 1rem;
border: none;
border-radius: 8px;
}
.planDialog::backdrop {
background: rgba(0, 0, 0, 0.4);
}
.planEditor {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
min-width: 600px;
}
.planEditorLeft {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.planEditorRight {
display: flex;
flex-direction: column;
gap: 0.5rem;
border-left: 1px solid var(--border-color, #ccc);
padding-left: 1rem;
max-height: 300px;
overflow-y: auto;
}
.planStep {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: text-decoration 0.2s;
}
.planStep:hover {
text-decoration: line-through;
}
.stepType {
margin-left: auto;
opacity: 0.7;
font-size: 0.85em;
}
.stepIndex {
opacity: 0.6;
}
.emptySteps {
opacity: 0.5;
font-style: italic;
}

View File

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

View File

@@ -0,0 +1,25 @@
export type Plan = {
name: string,
id: string,
steps: PlanElement[],
}
export type PlanElement = Goal | Action
export type Goal = {
id: string,
name: string,
plan: Plan,
can_fail: boolean,
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 ActionTypes = "speech" | "gesture" | "llm";

View File

@@ -20,7 +20,7 @@ import { BasicBeliefReduce } from './BasicBeliefNode';
export type NormNodeData = {
label: string;
droppable: boolean;
conditions: string[]; // List of (basic) belief nodes' ids.
condition?: string; // id of this node's belief.
norm: string;
hasReduce: boolean;
critical: boolean;
@@ -70,13 +70,12 @@ export default function NormNode(props: NodeProps<NormNode>) {
/>
</div>
{data.conditions.length > 0 && (<div className={"flex-row gap-md align-center"} data-testid="norm-condition-information">
<label htmlFor={checkbox_id}>{data.conditions.length} condition{data.conditions.length > 1 ? "s" : ""}/ belief{data.conditions.length > 1 ? "s" : ""} attached.</label>
{data.condition && (<div className={"flex-row gap-md align-center"} data-testid="norm-condition-information">
<label htmlFor={checkbox_id}>Condition/ Belief attached.</label>
</div>)}
<Handle type="source" position={Position.Right} id="norms"/>
<Handle type="target" position={Position.Bottom} id="norms"/>
<Handle type="source" position={Position.Right} id="phase"/>
<Handle type="target" position={Position.Bottom} id="belief"/>
</div>
</>;
};
@@ -90,11 +89,6 @@ export default function NormNode(props: NodeProps<NormNode>) {
export function NormReduce(node: Node, nodes: Node[]) {
const data = node.data as NormNodeData;
// conditions nodes - make sure to check for empty arrays
let conditionNodes: Node[] = [];
if (data.conditions)
conditionNodes = nodes.filter((node) => data.conditions.includes(node.id));
// Build the result object
const result: Record<string, unknown> = {
id: node.id,
@@ -103,12 +97,14 @@ export function NormReduce(node: Node, nodes: Node[]) {
critical: data.critical,
};
// Go over our conditionNodes. They should either be Basic (OR TODO: Inferred)
const reducer = BasicBeliefReduce;
result["basic_beliefs"] = conditionNodes.map((condition) => reducer(condition, nodes))
if (data.condition) {
const reducer = BasicBeliefReduce; // TODO: also add inferred.
const conditionNode = nodes.find((node) => node.id === data.condition);
// In case something went wrong, and our condition doesn't actually exist;
if (conditionNode == undefined) return result;
result["belief"] = reducer(conditionNode, nodes)
}
// When the Inferred is being implemented, you should follow the same kind of structure that PhaseNode has,
// dividing the conditions into basic and inferred, then calling the correct reducer on them.
return result
}
@@ -119,9 +115,9 @@ export function NormReduce(node: Node, nodes: Node[]) {
*/
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as NormNodeData;
// If we got a belief connected, this is a condition for the norm.
// 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 */))) {
data.conditions.push(_sourceNodeId);
data.condition = _sourceNodeId;
}
}
@@ -141,10 +137,8 @@ export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) {
*/
export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as NormNodeData;
// If we got a belief connected, this is a 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 */))) {
data.conditions = data.conditions.filter(id => id != _sourceNodeId);
}
// remove if the target of disconnection was our condition
if (_sourceNodeId == data.condition) data.condition = undefined
}
/**

View File

@@ -9,9 +9,11 @@ import {
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import useFlowStore from '../VisProgStores';
import { useState } from 'react';
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';
/**
* The default data structure for a Trigger node
@@ -28,8 +30,8 @@ import duplicateIndices from '../../../../utils/duplicateIndices';
export type TriggerNodeData = {
label: string;
droppable: boolean;
triggerType: "keywords" | string;
triggers: Keyword[] | never;
condition?: string; // id of the belief
plan?: Plan;
hasReduce: boolean;
};
@@ -55,25 +57,265 @@ 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"
};
}
};
const setKeywords = (keywords: Keyword[]) => {
updateNodeData(props.id, {...data, triggers: keywords});
}
return <>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
{data.triggerType === "emotion" && (
<div className={"flex-row gap-md"}>Emotion?</div>
)}
{data.triggerType === "keywords" && (
<Keywords
keywords={data.triggers}
setKeywords={setKeywords}
/>
)}
<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>
<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>
)}
</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>
</>;
}
@@ -108,6 +350,11 @@ export function TriggerReduce(node: Node, _nodes: Node[]) {
*/
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 */))) {
data.condition = _sourceNodeId;
}
}
/**
@@ -126,6 +373,9 @@ export function TriggerConnectionSource(_thisNode: Node, _targetNodeId: string)
*/
export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
const data = _thisNode.data as TriggerNodeData;
// remove if the target of disconnection was our condition
if (_sourceNodeId == data.condition) data.condition = undefined
}
/**