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:
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Plan } from "./Plan";
|
||||
|
||||
export const defaultPlan: Plan = {
|
||||
name: "Default Plan",
|
||||
id: "-1",
|
||||
steps: [],
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user