Compare commits
46 Commits
dev
...
feat/step-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e7207c32 | ||
|
|
5e245a00da | ||
|
|
1e951968dd | ||
|
|
108fdeeedc | ||
|
|
79d889c10e | ||
|
|
bac94d5f8c | ||
|
|
f95b1148d9 | ||
|
|
46d900305a | ||
|
|
c4a4c52ecc | ||
|
|
96242fa6b0 | ||
|
|
c2486f5f43 | ||
|
|
f0c67c00dc | ||
|
|
a0a4687aeb | ||
|
|
8ffc919e7e | ||
|
|
71443c7fb6 | ||
|
|
39f013c47f | ||
|
|
a5a345b9a9 | ||
|
|
96afba2a1d | ||
|
|
6e1eb25bbc | ||
|
|
c7ed3c8ef2 | ||
|
|
e6f29a0f6b | ||
|
|
f2c01f67ac | ||
|
|
14cfc2bf15 | ||
|
|
a2b4847ca4 | ||
|
|
a1e242e391 | ||
|
|
a4428c0d67 | ||
|
|
4356f201ab | ||
|
|
e805c882fe | ||
|
|
ad8111d6c2 | ||
|
|
c9df87929b | ||
|
|
57ebe724db | ||
|
|
794e638081 | ||
|
|
12ef2ef86e | ||
|
|
0fefefe7f0 | ||
|
|
9601f56ea9 | ||
|
|
873b1cfb0b | ||
|
|
4bd67debf3 | ||
|
|
e53e1a3958 | ||
|
|
7a89b0aedd | ||
|
|
7b05c7344c | ||
|
|
d80ced547c | ||
|
|
cd1aa84f89 | ||
|
|
469a6c7a69 | ||
|
|
b0a5e4770c | ||
|
|
f0fe520ea0 | ||
|
|
b10dbae488 |
@@ -8,6 +8,7 @@ import VisProg from "./pages/VisProgPage/VisProg.tsx";
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import Logging from "./components/Logging/Logging.tsx";
|
import Logging from "./components/Logging/Logging.tsx";
|
||||||
|
|
||||||
|
|
||||||
function App(){
|
function App(){
|
||||||
const [showLogs, setShowLogs] = useState(false);
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
background-color: #242424;
|
background-color: #242424;
|
||||||
|
|
||||||
--accent-color: #008080;
|
--accent-color: #008080;
|
||||||
|
--panel-shadow:
|
||||||
|
0 1px 2px white,
|
||||||
|
0 8px 24px rgba(190, 186, 186, 0.253);
|
||||||
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@@ -15,6 +18,14 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--panel-shadow:
|
||||||
|
0 1px 2px rgba(221, 221, 221, 0.178),
|
||||||
|
0 8px 24px rgba(27, 27, 27, 0.507);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
224
src/pages/MonitoringPage/Components.tsx
Normal file
224
src/pages/MonitoringPage/Components.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import styles from './MonitoringPage.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HELPER: Unified sender function
|
||||||
|
*/
|
||||||
|
const sendUserInterrupt = async (type: string, context: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("http://localhost:8000/button_pressed", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type, context }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Backend response error");
|
||||||
|
console.log(`Interrupt Sent - Type: ${type}, Context: ${context}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to send interrupt:`, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- GESTURE COMPONENT ---
|
||||||
|
export const GestureControls: React.FC = () => {
|
||||||
|
const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1");
|
||||||
|
|
||||||
|
const gestures = [
|
||||||
|
{ label: "Body Talk 1", value: "animations/Stand/BodyTalk/Speaking/BodyTalk_1" },
|
||||||
|
{ label: "Thinking 8", value: "animations/Stand/Gestures/Thinking_8" },
|
||||||
|
{ label: "Thinking 1", value: "animations/Stand/Gestures/Thinking_1" },
|
||||||
|
{ label: "Happy", value: "animations/Stand/Emotions/Positive/Happy_1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.gestures}>
|
||||||
|
<h4>Gestures</h4>
|
||||||
|
<div className={styles.gestureInputGroup}>
|
||||||
|
<select
|
||||||
|
value={selectedGesture}
|
||||||
|
onChange={(e) => setSelectedGesture(e.target.value)}
|
||||||
|
>
|
||||||
|
{gestures.map(g => <option key={g.value} value={g.value}>{g.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<button onClick={() => sendUserInterrupt("gesture", selectedGesture)}>
|
||||||
|
Actuate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- PRESET SPEECH COMPONENT ---
|
||||||
|
export const SpeechPresets: React.FC = () => {
|
||||||
|
const phrases = [
|
||||||
|
{ label: "Hello, I'm Pepper", text: "Hello, I'm Pepper" },
|
||||||
|
{ label: "Repeat please", text: "Could you repeat that please" },
|
||||||
|
{ label: "About yourself", text: "Tell me something about yourself" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.speech}>
|
||||||
|
<h4>Speech Presets</h4>
|
||||||
|
<ul>
|
||||||
|
{phrases.map((phrase, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<button
|
||||||
|
className={styles.speechBtn}
|
||||||
|
onClick={() => sendUserInterrupt("speech", phrase.text)}
|
||||||
|
>
|
||||||
|
"{phrase.label}"
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DIRECT SPEECH (INPUT) COMPONENT ---
|
||||||
|
export const DirectSpeechInput: React.FC = () => {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
sendUserInterrupt("speech", text);
|
||||||
|
setText(""); // Clear after sending
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.directSpeech}>
|
||||||
|
<h4>Direct Pepper Speech</h4>
|
||||||
|
<div className={styles.speechInput}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="Type message..."
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||||
|
/>
|
||||||
|
<button onClick={handleSend}>Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- interface for goals/triggers/norms/conditional norms ---
|
||||||
|
type StatusItem = {
|
||||||
|
id?: string | number;
|
||||||
|
achieved?: boolean;
|
||||||
|
description?: string;
|
||||||
|
label?: string;
|
||||||
|
norm?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatusListProps {
|
||||||
|
title: string;
|
||||||
|
items: StatusItem[];
|
||||||
|
type: 'goal' | 'trigger' | 'norm'| 'cond_norm';
|
||||||
|
activeIds: Record<string, boolean>;
|
||||||
|
currentGoalIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- STATUS LIST COMPONENT ---
|
||||||
|
export const StatusList: React.FC<StatusListProps> = ({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
type,
|
||||||
|
activeIds,
|
||||||
|
currentGoalIndex // Destructure this prop
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<section className={styles.phaseBox}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<ul>
|
||||||
|
{items.map((item, idx) => {
|
||||||
|
if (item.id === undefined) return null;
|
||||||
|
const isActive = !!activeIds[item.id];
|
||||||
|
const showIndicator = type !== 'norm';
|
||||||
|
const canOverride = showIndicator && !isActive;
|
||||||
|
|
||||||
|
const isCurrentGoal = type === 'goal' && idx === currentGoalIndex;
|
||||||
|
|
||||||
|
const handleOverrideClick = () => {
|
||||||
|
if (!canOverride) return;
|
||||||
|
sendUserInterrupt("override", String(item.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.id ?? idx} className={styles.statusItem}>
|
||||||
|
{showIndicator && (
|
||||||
|
<span
|
||||||
|
className={`${styles.statusIndicator} ${isActive ? styles.active : styles.inactive} ${canOverride ? styles.clickable : ''}`}
|
||||||
|
onClick={handleOverrideClick}
|
||||||
|
>
|
||||||
|
{isActive ? "✔️" : "❌"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={styles.itemDescription}
|
||||||
|
style={{
|
||||||
|
// Visual Feedback
|
||||||
|
textDecoration: isCurrentGoal ? 'underline' : 'none',
|
||||||
|
fontWeight: isCurrentGoal ? 'bold' : 'normal',
|
||||||
|
color: isCurrentGoal ? '#007bff' : 'inherit',
|
||||||
|
backgroundColor: isCurrentGoal ? '#e7f3ff' : 'transparent', // Added subtle highlight
|
||||||
|
padding: isCurrentGoal ? '2px 4px' : '0',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.description || item.label || item.norm}
|
||||||
|
{isCurrentGoal && " (Current)"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --- Robot Connected ---
|
||||||
|
export const RobotConnected = () => {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current connection state:
|
||||||
|
* - `true`: Robot is connected.
|
||||||
|
* - `false`: Robot is not connected.
|
||||||
|
* - `null`: Connection status is unknown (initial check in progress).
|
||||||
|
*/
|
||||||
|
const [connected, setConnected] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Open a Server-Sent Events (SSE) connection to receive live ping updates.
|
||||||
|
// We're expecting a stream of data like that looks like this: `data = False` or `data = True`
|
||||||
|
const eventSource = new EventSource("http://localhost:8000/robot/ping_stream");
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
|
||||||
|
// Expecting messages in JSON format: `true` or `false`
|
||||||
|
console.log("received message:", event.data);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setConnected(data)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
console.log("couldnt extract connected from incoming ping data")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
console.log("Ping message not in correct format:", event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up the SSE connection when the component unmounts.
|
||||||
|
return () => eventSource.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3>Connection:</h3>
|
||||||
|
<p className={connected ? styles.connected : styles.disconnected }>{connected ? "● Robot is connected" : "● Robot is disconnected"}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
274
src/pages/MonitoringPage/MonitoringPage.module.css
Normal file
274
src/pages/MonitoringPage/MonitoringPage.module.css
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
.dashboardContainer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr; /* Left = content, Right = logs */
|
||||||
|
grid-template-rows: auto 1fr auto; /* Header, Main, Footer */
|
||||||
|
grid-template-areas:
|
||||||
|
"header logs"
|
||||||
|
"main logs"
|
||||||
|
"footer footer";
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HEADER */
|
||||||
|
.experimentOverview {
|
||||||
|
grid-area: header;
|
||||||
|
display: flex;
|
||||||
|
color: color;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-main);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
position: static; /* ensures it scrolls away */
|
||||||
|
}
|
||||||
|
|
||||||
|
.phaseProgress {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase {
|
||||||
|
display: inline-block;
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
margin: 0 3px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 25px;
|
||||||
|
background: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed {
|
||||||
|
background-color: green;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
background-color: rgb(255, 123, 0);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected {
|
||||||
|
color: green;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnected {
|
||||||
|
color: red;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pausePlayInactive{
|
||||||
|
background-color: gray;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pausePlayActive{
|
||||||
|
background-color: green;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restartPhase{
|
||||||
|
background-color: rgb(255, 123, 0);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restartExperiment{
|
||||||
|
background-color: red;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MAIN GRID */
|
||||||
|
.phaseOverview {
|
||||||
|
grid-area: main;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
grid-template-rows: repeat(2, auto);
|
||||||
|
gap: 1rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-main);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.phaseBox {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phaseBox ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.phaseBox ul::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phaseBox ul::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #ccc;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phaseOverviewText {
|
||||||
|
grid-column: 1 / -1; /* make the title span across both columns */
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0; /* remove default section margin */
|
||||||
|
padding: 0.25rem 0; /* smaller internal space */
|
||||||
|
}
|
||||||
|
|
||||||
|
.phaseOverviewText h3{
|
||||||
|
margin: 0; /* removes top/bottom whitespace */
|
||||||
|
padding: 0; /* keeps spacing tight */
|
||||||
|
}
|
||||||
|
|
||||||
|
.phaseBox h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checked::before {
|
||||||
|
content: '✔️ ';
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusIndicator {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 10px;
|
||||||
|
user-select: none;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemDescription {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LOGS */
|
||||||
|
.logs {
|
||||||
|
grid-area: logs;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-main);
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 83%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background-color: Canvas;
|
||||||
|
color: CanvasText;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs button {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FOOTER */
|
||||||
|
.controlsSection {
|
||||||
|
grid-area: footer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-main);
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsSection button {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestures,
|
||||||
|
.speech,
|
||||||
|
.directSpeech {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speechInput {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speechInput input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: Canvas;
|
||||||
|
color: CanvasText;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.speechInput button {
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: Canvas;
|
||||||
|
color: CanvasText;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RESPONSIVE */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.phaseOverview {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controlsSection {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
347
src/pages/MonitoringPage/MonitoringPage.tsx
Normal file
347
src/pages/MonitoringPage/MonitoringPage.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styles from './MonitoringPage.module.css';
|
||||||
|
import useProgramStore from "../../utils/programStore.ts";
|
||||||
|
import { GestureControls, SpeechPresets, DirectSpeechInput, StatusList, RobotConnected } from './Components';
|
||||||
|
import { nextPhase, useExperimentLogger, pauseExperiment, playExperiment, resetExperiment, resetPhase, type ExperimentStreamData, type GoalUpdate, type TriggerUpdate, type CondNormsStateUpdate, type PhaseUpdate } from ".//MonitoringPageAPI.ts"
|
||||||
|
|
||||||
|
import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode.tsx';
|
||||||
|
|
||||||
|
// Stream message types are defined in MonitoringPageAPI as `ExperimentStreamData`.
|
||||||
|
// Types for reduced program items (output from node reducers):
|
||||||
|
export type ReducedPlanStep = {
|
||||||
|
id: string;
|
||||||
|
text?: string;
|
||||||
|
gesture?: { type: string; name?: string };
|
||||||
|
goal?: string;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
export type ReducedPlan = { id: string; steps: ReducedPlanStep[] } | "";
|
||||||
|
|
||||||
|
export type ReducedGoal = { id: string; name: string; description?: string; can_fail?: boolean; plan?: ReducedPlan };
|
||||||
|
|
||||||
|
export type ReducedCondition = {
|
||||||
|
id: string;
|
||||||
|
keyword?: string;
|
||||||
|
emotion?: string;
|
||||||
|
object?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
export type ReducedTrigger = { id: string; name: string; condition?: ReducedCondition | ""; plan?: ReducedPlan };
|
||||||
|
|
||||||
|
export type ReducedNorm = { id: string; label?: string; norm?: string; condition?: ReducedCondition | "" };
|
||||||
|
|
||||||
|
|
||||||
|
const MonitoringPage: React.FC = () => {
|
||||||
|
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
||||||
|
const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase);
|
||||||
|
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
|
||||||
|
const getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase);
|
||||||
|
|
||||||
|
// Can be used to block actions until feedback from CB.
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [activeIds, setActiveIds] = React.useState<Record<string, boolean>>({});
|
||||||
|
const [goalIndex, setGoalIndex] = React.useState(0);
|
||||||
|
const [isPlaying, setIsPlaying] = React.useState(false);
|
||||||
|
|
||||||
|
const phaseIds = getPhaseIds();
|
||||||
|
|
||||||
|
const [phaseIndex, setPhaseIndex] = React.useState(0);
|
||||||
|
|
||||||
|
//see if we reached end node
|
||||||
|
const [isFinished, setIsFinished] = React.useState(false);
|
||||||
|
|
||||||
|
const handleStreamUpdate = React.useCallback((data: ExperimentStreamData) => {
|
||||||
|
// Check for phase updates
|
||||||
|
if (data.type === 'phase_update' && data.id) {
|
||||||
|
const payload = data as PhaseUpdate;
|
||||||
|
if (payload.id === "end") {
|
||||||
|
setIsFinished(true);
|
||||||
|
} else {
|
||||||
|
setIsFinished(false);
|
||||||
|
|
||||||
|
const allIds = getPhaseIds();
|
||||||
|
const newIndex = allIds.indexOf(payload.id);
|
||||||
|
if (newIndex !== -1) {
|
||||||
|
setPhaseIndex(newIndex);
|
||||||
|
setGoalIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (data.type === 'goal_update') {
|
||||||
|
const payload = data as GoalUpdate;
|
||||||
|
const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as ReducedGoal[];
|
||||||
|
const gIndex = currentPhaseGoals.findIndex((g: ReducedGoal) => g.id === payload.id);
|
||||||
|
|
||||||
|
if (gIndex !== -1) {
|
||||||
|
//set current goal to the goal that is just started
|
||||||
|
setGoalIndex(gIndex);
|
||||||
|
|
||||||
|
// All previous goals are set to "active" which means they are achieved
|
||||||
|
setActiveIds((prev) => {
|
||||||
|
const nextState = { ...prev };
|
||||||
|
|
||||||
|
// We loop until i is LESS than gIndex.
|
||||||
|
// This leaves currentPhaseGoals[gIndex] as isActive: false.
|
||||||
|
for (let i = 0; i < gIndex; i++) {
|
||||||
|
nextState[currentPhaseGoals[i].id ] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Now pursuing goal: ${payload.id}. Previous goals marked achieved.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (data.type === 'trigger_update') {
|
||||||
|
const payload = data as TriggerUpdate;
|
||||||
|
setActiveIds((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[payload.id]: payload.achieved
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
else if (data.type === 'cond_norms_state_update') {
|
||||||
|
const payload = data as CondNormsStateUpdate;
|
||||||
|
setActiveIds((prev) => {
|
||||||
|
const nextState = { ...prev };
|
||||||
|
// payload.norms is typed on the union, so safe to use directly
|
||||||
|
payload.norms.forEach((normUpdate) => {
|
||||||
|
nextState[normUpdate.id] = normUpdate.active;
|
||||||
|
console.log(`Conditional norm ${normUpdate.id} set to active: ${normUpdate.active}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
console.log("Updated conditional norms state:", payload.norms);
|
||||||
|
}
|
||||||
|
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex]);
|
||||||
|
|
||||||
|
useExperimentLogger(handleStreamUpdate);
|
||||||
|
|
||||||
|
if (phaseIds.length === 0) {
|
||||||
|
return <p className={styles.empty}>No program loaded.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseId = phaseIds[phaseIndex];
|
||||||
|
|
||||||
|
const goals = (getGoalsInPhase(phaseId) as ReducedGoal[]).map(g => ({
|
||||||
|
...g,
|
||||||
|
label: g.name,
|
||||||
|
achieved: activeIds[g.id] ?? false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const triggers = (getTriggersInPhase(phaseId) as ReducedTrigger[]).map(t => ({
|
||||||
|
...t,
|
||||||
|
label: (() => {
|
||||||
|
|
||||||
|
let prefix = "";
|
||||||
|
if (t.condition && typeof t.condition !== "string" && "keyword" in t.condition && typeof t.condition.keyword === "string") {
|
||||||
|
prefix = `if keywords said: "${t.condition.keyword}"`;
|
||||||
|
} else if (t.condition && typeof t.condition !== "string" && "name" in t.condition && typeof t.condition.name === "string") {
|
||||||
|
prefix = `if LLM belief: ${t.condition.name}`;
|
||||||
|
} else { //fallback
|
||||||
|
prefix = t.name || "Trigger"; // use typed `name` as a reliable fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const stepLabels = (t.plan && typeof t.plan !== "string" ? t.plan.steps : []).map((step: ReducedPlanStep) => {
|
||||||
|
if ("text" in step && typeof step.text === "string") {
|
||||||
|
return `say: "${step.text}"`;
|
||||||
|
}
|
||||||
|
if ("gesture" in step && step.gesture) {
|
||||||
|
const g = step.gesture;
|
||||||
|
return `perform gesture: ${g.name || g.type}`;
|
||||||
|
}
|
||||||
|
if ("goal" in step && typeof step.goal === "string") {
|
||||||
|
return `perform LLM: ${step.goal}`;
|
||||||
|
}
|
||||||
|
return "Action"; // Fallback
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
const planText = stepLabels.length > 0
|
||||||
|
? `➔ Do: ${stepLabels.join(", ")}`
|
||||||
|
: "➔ (No actions set)";
|
||||||
|
|
||||||
|
return `${prefix} ${planText}`;
|
||||||
|
})(),
|
||||||
|
isActive: activeIds[t.id] ?? false
|
||||||
|
}));
|
||||||
|
|
||||||
|
const norms = (getNormsInPhase(phaseId) as NormNodeData[])
|
||||||
|
.filter(n => !n.condition)
|
||||||
|
.map(n => ({
|
||||||
|
...n,
|
||||||
|
label: n.norm,
|
||||||
|
}));
|
||||||
|
const conditionalNorms = (getNormsInPhase(phaseId) as ReducedNorm[])
|
||||||
|
.filter(n => !!n.condition) // Only items with a condition
|
||||||
|
.map(n => ({
|
||||||
|
...n,
|
||||||
|
label: (() => {
|
||||||
|
let prefix = "";
|
||||||
|
if (n.condition && typeof n.condition !== "string" && "keyword" in n.condition && typeof n.condition.keyword === "string") {
|
||||||
|
prefix = `if keywords said: "${n.condition.keyword}"`;
|
||||||
|
} else if (n.condition && typeof n.condition !== "string" && "name" in n.condition && typeof n.condition.name === "string") {
|
||||||
|
prefix = `if LLM belief: ${n.condition.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return `${prefix} ➔ Norm: ${n.norm}`;
|
||||||
|
})(),
|
||||||
|
achieved: activeIds[n.id] ?? false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Handle logic of 'next' button.
|
||||||
|
|
||||||
|
const handleButton = async (button: string, _context?: string, _endpoint?: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
switch (button) {
|
||||||
|
case "pause":
|
||||||
|
await pauseExperiment();
|
||||||
|
break;
|
||||||
|
case "play":
|
||||||
|
await playExperiment();
|
||||||
|
break;
|
||||||
|
case "nextPhase":
|
||||||
|
await nextPhase();
|
||||||
|
break;
|
||||||
|
case "resetPhase":
|
||||||
|
await resetPhase();
|
||||||
|
break;
|
||||||
|
case "resetExperiment":
|
||||||
|
await resetExperiment();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.dashboardContainer}>
|
||||||
|
{/* HEADER */}
|
||||||
|
<header className={styles.experimentOverview}>
|
||||||
|
<div className={styles.phaseName}>
|
||||||
|
<h2>Experiment Overview</h2>
|
||||||
|
<p><strong>Phase</strong> {` ${phaseIndex + 1}`} </p>
|
||||||
|
<div className={styles.phaseProgress}>
|
||||||
|
{phaseIds.map((id, index) => (
|
||||||
|
<span
|
||||||
|
key={id}
|
||||||
|
className={`${styles.phase} ${
|
||||||
|
index < phaseIndex ? styles.completed :
|
||||||
|
index === phaseIndex ? styles.current : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.experimentControls}>
|
||||||
|
<h3>Experiment Controls</h3>
|
||||||
|
<div className={styles.controlsButtons}>
|
||||||
|
{/*Pause button*/}
|
||||||
|
<button
|
||||||
|
className={`${!isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
handleButton("pause");}
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
>❚❚</button>
|
||||||
|
|
||||||
|
{/*Play button*/}
|
||||||
|
<button
|
||||||
|
className={`${isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
handleButton("play");}
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
>▶</button>
|
||||||
|
|
||||||
|
{/*Next button*/}
|
||||||
|
<button
|
||||||
|
className={styles.next}
|
||||||
|
onClick={() => handleButton("nextPhase")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/*Restart Phase button*/}
|
||||||
|
<button
|
||||||
|
className={styles.restartPhase}
|
||||||
|
onClick={() => handleButton("resetPhase")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
↩
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/*Restart Experiment button*/}
|
||||||
|
<button
|
||||||
|
className={styles.restartExperiment}
|
||||||
|
onClick={() => handleButton("resetExperiment")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
⟲
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.connectionStatus}>
|
||||||
|
{RobotConnected()}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* MAIN GRID */}
|
||||||
|
|
||||||
|
<main className={styles.phaseOverview}>
|
||||||
|
<section className={styles.phaseOverviewText}>
|
||||||
|
<h3>Phase Overview</h3>
|
||||||
|
</section>
|
||||||
|
{isFinished ? (
|
||||||
|
<div className={styles.finishedMessage}>
|
||||||
|
<p> All phases have been successfully completed.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<StatusList title="Goals" items={goals} type="goal" activeIds={activeIds} currentGoalIndex={goalIndex} />
|
||||||
|
<StatusList title="Triggers" items={triggers} type="trigger" activeIds={activeIds} />
|
||||||
|
<StatusList title="Norms" items={norms} type="norm" activeIds={activeIds} />
|
||||||
|
<StatusList title="Conditional Norms" items={conditionalNorms} type="cond_norm" activeIds={activeIds} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* LOGS */}
|
||||||
|
<aside className={styles.logs}>
|
||||||
|
<h3>Logs</h3>
|
||||||
|
<div className={styles.logHeader}>
|
||||||
|
<span>Global:</span>
|
||||||
|
<button>ALL</button>
|
||||||
|
<button>Add</button>
|
||||||
|
<button className={styles.live}>Live</button>
|
||||||
|
</div>
|
||||||
|
<textarea defaultValue="Example Log: much log"></textarea>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* FOOTER */}
|
||||||
|
<footer className={styles.controlsSection}>
|
||||||
|
<GestureControls />
|
||||||
|
<SpeechPresets />
|
||||||
|
<DirectSpeechInput />
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MonitoringPage;
|
||||||
107
src/pages/MonitoringPage/MonitoringPageAPI.ts
Normal file
107
src/pages/MonitoringPage/MonitoringPageAPI.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
const API_BASE_BP = "http://localhost:8000/button_pressed"; // Change depending on Pims interup agent/ correct endpoint
|
||||||
|
const API_BASE = "http://localhost:8000";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HELPER: Unified sender function
|
||||||
|
* In a real app, you might move this to a /services or /hooks folder
|
||||||
|
*/
|
||||||
|
const sendAPICall = async (type: string, context: string, endpoint?: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_BP}${endpoint ?? ""}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type, context }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Backend response error");
|
||||||
|
console.log(`API Call send - Type: ${type}, Context: ${context} ${endpoint ? `, Endpoint: ${endpoint}` : ""}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to send api call:`, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an API call to the CB for going to the next phase.
|
||||||
|
* In case we can't go to the next phase, the function will throw an error.
|
||||||
|
*/
|
||||||
|
export async function nextPhase(): Promise<void> {
|
||||||
|
const type = "next_phase"
|
||||||
|
const context = ""
|
||||||
|
sendAPICall(type, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an API call to the CB for going to reset the currect phase
|
||||||
|
* In case we can't go to the next phase, the function will throw an error.
|
||||||
|
*/
|
||||||
|
export async function resetPhase(): Promise<void> {
|
||||||
|
const type = "reset_phase"
|
||||||
|
const context = ""
|
||||||
|
sendAPICall(type, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an API call to the CB for going to reset the experiment
|
||||||
|
* In case we can't go to the next phase, the function will throw an error.
|
||||||
|
*/
|
||||||
|
export async function resetExperiment(): Promise<void> {
|
||||||
|
const type = "reset_experiment"
|
||||||
|
const context = ""
|
||||||
|
sendAPICall(type, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pauseExperiment(): Promise<void> {
|
||||||
|
const type = "pause"
|
||||||
|
const context = "true"
|
||||||
|
sendAPICall(type, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playExperiment(): Promise<void> {
|
||||||
|
const type = "pause"
|
||||||
|
const context = "false"
|
||||||
|
sendAPICall(type, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types for the experiment stream messages
|
||||||
|
*/
|
||||||
|
export type PhaseUpdate = { type: 'phase_update'; id: string };
|
||||||
|
export type GoalUpdate = { type: 'goal_update'; id: string };
|
||||||
|
export type TriggerUpdate = { type: 'trigger_update'; id: string; achieved: boolean };
|
||||||
|
export type CondNormsStateUpdate = { type: 'cond_norms_state_update'; norms: { id: string; active: boolean }[] };
|
||||||
|
export type ExperimentStreamData = PhaseUpdate | GoalUpdate | TriggerUpdate | CondNormsStateUpdate | Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hook that listens to the experiment stream and logs data to the console.
|
||||||
|
* It does not render anything.
|
||||||
|
*/
|
||||||
|
export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
const eventSource = new EventSource(`${API_BASE}/experiment_stream`);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(event.data) as ExperimentStreamData;
|
||||||
|
if (onUpdate) {
|
||||||
|
console.log(event.data);
|
||||||
|
onUpdate(parsedData);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Stream parse error:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (err) => {
|
||||||
|
console.error("SSE Connection Error:", err);
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, [onUpdate]);
|
||||||
|
}
|
||||||
167
src/pages/SimpleProgram/SimpleProgram.module.css
Normal file
167
src/pages/SimpleProgram/SimpleProgram.module.css
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/* ---------- Layout ---------- */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: clamp(0.75rem, 2vw, 1.25rem);
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
font-size: clamp(1rem, 2.2vw, 1.4rem);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: #111;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Content ---------- */
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Grid ---------- */
|
||||||
|
|
||||||
|
.phaseGrid {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 2%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Box ---------- */
|
||||||
|
|
||||||
|
.box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1e1e1e;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxHeader {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
background: linear-gradient(135deg, #dcdcdc, #e9e9e9);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(0.9rem, 1.5vw, 1.05rem);
|
||||||
|
border-bottom: 1px solid #cfcfcf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boxContent {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Lists ---------- */
|
||||||
|
|
||||||
|
.iconList {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconList li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: clamp(0.85rem, 1.3vw, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletList {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletList li {
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Icons ---------- */
|
||||||
|
|
||||||
|
.successIcon,
|
||||||
|
.failIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.successIcon {
|
||||||
|
background: #3cb371;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failIcon {
|
||||||
|
background: #e5533d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Empty ---------- */
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
opacity: 0.55;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Responsive ---------- */
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.phaseGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #ddd;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton:hover {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
192
src/pages/SimpleProgram/SimpleProgram.tsx
Normal file
192
src/pages/SimpleProgram/SimpleProgram.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import React from "react";
|
||||||
|
import styles from "./SimpleProgram.module.css";
|
||||||
|
import useProgramStore from "../../utils/programStore.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic container box with a header and content area.
|
||||||
|
*/
|
||||||
|
type BoxProps = {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Box: React.FC<BoxProps> = ({ title, children }) => (
|
||||||
|
<div className={styles.box}>
|
||||||
|
<div className={styles.boxHeader}>{title}</div>
|
||||||
|
<div className={styles.boxContent}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a list of goals for a phase.
|
||||||
|
* Expects goal-like objects from the program store.
|
||||||
|
*/
|
||||||
|
const GoalList: React.FC<{ goals: unknown[] }> = ({ goals }) => {
|
||||||
|
if (!goals.length) {
|
||||||
|
return <p className={styles.empty}>No goals defined.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={styles.iconList}>
|
||||||
|
{goals.map((g, idx) => {
|
||||||
|
const goal = g as {
|
||||||
|
id?: string;
|
||||||
|
description?: string;
|
||||||
|
achieved?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={goal.id ?? idx}>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
goal.achieved ? styles.successIcon : styles.failIcon
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{goal.achieved ? "✔" : "✖"}
|
||||||
|
</span>
|
||||||
|
{goal.description ?? "Unnamed goal"}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a list of triggers for a phase.
|
||||||
|
*/
|
||||||
|
const TriggerList: React.FC<{ triggers: unknown[] }> = ({ triggers }) => {
|
||||||
|
if (!triggers.length) {
|
||||||
|
return <p className={styles.empty}>No triggers defined.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={styles.iconList}>
|
||||||
|
{triggers.map((t, idx) => {
|
||||||
|
const trigger = t as {
|
||||||
|
id?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={trigger.id ?? idx}>
|
||||||
|
<span className={styles.failIcon}>✖</span>
|
||||||
|
{trigger.label ?? "Unnamed trigger"}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a list of norms for a phase.
|
||||||
|
*/
|
||||||
|
const NormList: React.FC<{ norms: unknown[] }> = ({ norms }) => {
|
||||||
|
if (!norms.length) {
|
||||||
|
return <p className={styles.empty}>No norms defined.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={styles.bulletList}>
|
||||||
|
{norms.map((n, idx) => {
|
||||||
|
const norm = n as {
|
||||||
|
id?: string;
|
||||||
|
norm?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return <li key={norm.id ?? idx}>{norm.norm ?? "Unnamed norm"}</li>;
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays all phase-related information in a grid layout.
|
||||||
|
*/
|
||||||
|
type PhaseGridProps = {
|
||||||
|
norms: unknown[];
|
||||||
|
goals: unknown[];
|
||||||
|
triggers: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const PhaseGrid: React.FC<PhaseGridProps> = ({
|
||||||
|
norms,
|
||||||
|
goals,
|
||||||
|
triggers,
|
||||||
|
}) => (
|
||||||
|
<div className={styles.phaseGrid}>
|
||||||
|
<Box title="Norms">
|
||||||
|
<NormList norms={norms} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box title="Triggers">
|
||||||
|
<TriggerList triggers={triggers} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box title="Goals">
|
||||||
|
<GoalList goals={goals} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box title="Conditional Norms">
|
||||||
|
<p className={styles.empty}>No conditional norms defined.</p>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main program viewer.
|
||||||
|
* Reads all data from the program store and allows
|
||||||
|
* navigating between phases.
|
||||||
|
*/
|
||||||
|
const SimpleProgram: React.FC = () => {
|
||||||
|
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
||||||
|
const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase);
|
||||||
|
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
|
||||||
|
const getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase);
|
||||||
|
|
||||||
|
const phaseIds = getPhaseIds();
|
||||||
|
const [phaseIndex, setPhaseIndex] = React.useState(0);
|
||||||
|
|
||||||
|
if (phaseIds.length === 0) {
|
||||||
|
return <p className={styles.empty}>No program loaded.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseId = phaseIds[phaseIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h2>
|
||||||
|
Phase {phaseIndex + 1} / {phaseIds.length}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<button
|
||||||
|
disabled={phaseIndex === 0}
|
||||||
|
onClick={() => setPhaseIndex((i) => i - 1)}
|
||||||
|
>
|
||||||
|
◀ Prev
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={phaseIndex === phaseIds.length - 1}
|
||||||
|
onClick={() => setPhaseIndex((i) => i + 1)}
|
||||||
|
>
|
||||||
|
Next ▶
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<PhaseGrid
|
||||||
|
norms={getNormsInPhase(phaseId)}
|
||||||
|
goals={getGoalsInPhase(phaseId)}
|
||||||
|
triggers={getTriggersInPhase(phaseId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SimpleProgram;
|
||||||
@@ -164,6 +164,26 @@
|
|||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottomLeftHandle {
|
||||||
|
left: 40% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomRightHandle {
|
||||||
|
left: 60% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planNoIterate {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-style: italic;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.backButton {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
box-shadow: var(--panel-shadow);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.node-toolbar-tooltip {
|
.node-toolbar-tooltip {
|
||||||
background-color: darkgray;
|
background-color: darkgray;
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
|||||||
import styles from './VisProg.module.css'
|
import styles from './VisProg.module.css'
|
||||||
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
||||||
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
||||||
|
import MonitoringPage from '../MonitoringPage/MonitoringPage.tsx';
|
||||||
|
|
||||||
// --| config starting params for flow |--
|
// --| config starting params for flow |--
|
||||||
|
|
||||||
@@ -145,7 +146,7 @@ function VisualProgrammingUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// currently outputs the prepared program to the console
|
// currently outputs the prepared program to the console
|
||||||
function runProgram() {
|
function runProgramm() {
|
||||||
const phases = graphReducer();
|
const phases = graphReducer();
|
||||||
const program = {phases}
|
const program = {phases}
|
||||||
console.log(JSON.stringify(program, null, 2));
|
console.log(JSON.stringify(program, null, 2));
|
||||||
@@ -186,6 +187,27 @@ function graphReducer() {
|
|||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
function VisProgPage() {
|
function VisProgPage() {
|
||||||
|
const [showSimpleProgram, setShowSimpleProgram] = useState(false);
|
||||||
|
const setProgramState = useProgramStore((state) => state.setProgramState);
|
||||||
|
|
||||||
|
const runProgram = () => {
|
||||||
|
const phases = graphReducer(); // reduce graph
|
||||||
|
setProgramState({ phases }); // <-- save to store
|
||||||
|
setShowSimpleProgram(true); // show SimpleProgram
|
||||||
|
runProgramm(); // send to backend if needed
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showSimpleProgram) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button className={styles.backButton} onClick={() => setShowSimpleProgram(false)}>
|
||||||
|
Back to Editor ◀
|
||||||
|
</button>
|
||||||
|
<MonitoringPage/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<VisualProgrammingUI/>
|
<VisualProgrammingUI/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Plan } from "./Plan";
|
import type { Plan, PlanElement } from "./Plan";
|
||||||
|
|
||||||
export const defaultPlan: Plan = {
|
export const defaultPlan: Plan = {
|
||||||
name: "Default Plan",
|
name: "Default Plan",
|
||||||
id: "-1",
|
id: "-1",
|
||||||
steps: [],
|
steps: [] as PlanElement[],
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { type Node } from "@xyflow/react"
|
||||||
|
import { GoalReduce } from "../nodes/GoalNode"
|
||||||
|
|
||||||
|
|
||||||
export type Plan = {
|
export type Plan = {
|
||||||
name: string,
|
name: string,
|
||||||
id: string,
|
id: string,
|
||||||
@@ -7,10 +11,7 @@ export type Plan = {
|
|||||||
export type PlanElement = Goal | Action
|
export type PlanElement = Goal | Action
|
||||||
|
|
||||||
export type Goal = {
|
export type Goal = {
|
||||||
id: string,
|
id: string // we let the reducer figure out the rest dynamically
|
||||||
name: string,
|
|
||||||
plan: Plan,
|
|
||||||
can_fail: boolean,
|
|
||||||
type: "goal"
|
type: "goal"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,23 +20,24 @@ export type Action = SpeechAction | GestureAction | LLMAction
|
|||||||
export type SpeechAction = { id: string, text: string, type:"speech" }
|
export type SpeechAction = { id: string, text: string, type:"speech" }
|
||||||
export type GestureAction = { id: string, gesture: string, isTag: boolean, type:"gesture" }
|
export type GestureAction = { id: string, gesture: string, isTag: boolean, type:"gesture" }
|
||||||
export type LLMAction = { id: string, goal: string, type:"llm" }
|
export type LLMAction = { id: string, goal: string, type:"llm" }
|
||||||
|
|
||||||
export type ActionTypes = "speech" | "gesture" | "llm";
|
export type ActionTypes = "speech" | "gesture" | "llm";
|
||||||
|
|
||||||
|
|
||||||
// Extract the wanted information from a plan within the reducing of nodes
|
// 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 ""
|
if (!plan) return ""
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
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.
|
// 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
|
// 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) {
|
switch (planElement.type) {
|
||||||
case ("speech"):
|
case ("speech"):
|
||||||
return {
|
return {
|
||||||
@@ -56,12 +58,7 @@ function StepReduce(planElement: PlanElement) {
|
|||||||
goal: planElement.goal,
|
goal: planElement.goal,
|
||||||
}
|
}
|
||||||
case ("goal"):
|
case ("goal"):
|
||||||
return {
|
return thisNode ? GoalReduce(thisNode, nodes) : {}
|
||||||
id: planElement.id,
|
|
||||||
plan: planElement.plan,
|
|
||||||
can_fail: planElement.can_fail,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,10 +68,36 @@ function StepReduce(planElement: PlanElement) {
|
|||||||
* @param plan: the plan to check
|
* @param plan: the plan to check
|
||||||
* @returns: a boolean
|
* @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.
|
// TODO: should recursively check plans that have goals (and thus more plans) in them.
|
||||||
if (!plan) return false
|
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
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.planDialog::backdrop {
|
.planDialog::backdrop {
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
@@ -68,4 +67,17 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dragHandle {
|
||||||
|
margin-left: auto;
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0.5;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHandle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planStepDragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
@@ -6,16 +6,26 @@ import { defaultPlan } from "../components/Plan.default";
|
|||||||
import { TextField } from "../../../../components/TextField";
|
import { TextField } from "../../../../components/TextField";
|
||||||
import GestureValueEditor from "./GestureValueEditor";
|
import GestureValueEditor from "./GestureValueEditor";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The properties of a plan editor.
|
||||||
|
* @property plan: The optional plan loaded into this editor.
|
||||||
|
* @property onSave: The function that will be called upon save.
|
||||||
|
* @property description: Optional description which is already set.
|
||||||
|
* @property onlyEditPhasing: Optional boolean to toggle
|
||||||
|
* whether or not this editor is part of the phase editing.
|
||||||
|
*/
|
||||||
type PlanEditorDialogProps = {
|
type PlanEditorDialogProps = {
|
||||||
plan?: Plan;
|
plan?: Plan;
|
||||||
onSave: (plan: Plan | undefined) => void;
|
onSave: (plan: Plan | undefined) => void;
|
||||||
description? : string;
|
description? : string;
|
||||||
|
onlyEditPhasing? : boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlanEditorDialog({
|
export default function PlanEditorDialog({
|
||||||
plan,
|
plan,
|
||||||
onSave,
|
onSave,
|
||||||
description,
|
description,
|
||||||
|
onlyEditPhasing = false,
|
||||||
}: PlanEditorDialogProps) {
|
}: PlanEditorDialogProps) {
|
||||||
// UseStates and references
|
// UseStates and references
|
||||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||||
@@ -23,7 +33,10 @@ export default function PlanEditorDialog({
|
|||||||
const [newActionType, setNewActionType] = useState<ActionTypes>("speech");
|
const [newActionType, setNewActionType] = useState<ActionTypes>("speech");
|
||||||
const [newActionGestureType, setNewActionGestureType] = useState<boolean>(true);
|
const [newActionGestureType, setNewActionGestureType] = useState<boolean>(true);
|
||||||
const [newActionValue, setNewActionValue] = useState("");
|
const [newActionValue, setNewActionValue] = useState("");
|
||||||
|
const [hasInteractedWithPlan, setHasInteractedWithPlan] = useState<boolean>(false)
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
const { setScrollable } = useFlowStore();
|
const { setScrollable } = useFlowStore();
|
||||||
|
const nodes = useFlowStore().nodes;
|
||||||
|
|
||||||
// Button Actions
|
// Button Actions
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
@@ -55,6 +68,7 @@ export default function PlanEditorDialog({
|
|||||||
|
|
||||||
const buildAction = (): Action => {
|
const buildAction = (): Action => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
setHasInteractedWithPlan(true)
|
||||||
switch (newActionType) {
|
switch (newActionType) {
|
||||||
case "speech":
|
case "speech":
|
||||||
return { id, text: newActionValue, type: "speech" };
|
return { id, text: newActionValue, type: "speech" };
|
||||||
@@ -86,9 +100,9 @@ export default function PlanEditorDialog({
|
|||||||
data-testid={"PlanEditorDialogTestID"}
|
data-testid={"PlanEditorDialogTestID"}
|
||||||
>
|
>
|
||||||
<form method="dialog" className="flex-col gap-md">
|
<form method="dialog" className="flex-col gap-md">
|
||||||
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
|
<h3> {onlyEditPhasing ? "Editing Phase Ordering" : (draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan")} </h3>
|
||||||
{/* Plan name text field */}
|
{/* Plan name text field */}
|
||||||
{draftPlan && (
|
{(draftPlan && !onlyEditPhasing) && (
|
||||||
<TextField
|
<TextField
|
||||||
value={draftPlan.name}
|
value={draftPlan.name}
|
||||||
setValue={(name) =>
|
setValue={(name) =>
|
||||||
@@ -101,12 +115,14 @@ export default function PlanEditorDialog({
|
|||||||
{draftPlan && (<div className={styles.planEditor}>
|
{draftPlan && (<div className={styles.planEditor}>
|
||||||
<div className={styles.planEditorLeft}>
|
<div className={styles.planEditorLeft}>
|
||||||
{/* Left Side (Action Adder) */}
|
{/* Left Side (Action Adder) */}
|
||||||
<h4>Add Action</h4>
|
<h4>{onlyEditPhasing ? "You can't add any actions, only rearrange the steps." : "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> Filled in as a suggestion! </label>
|
||||||
<label> Feel free to change! </label>
|
<label> Feel free to change! </label>
|
||||||
</div>)}
|
</div>)}
|
||||||
<label>
|
|
||||||
|
|
||||||
|
{(!onlyEditPhasing) && (<label>
|
||||||
Action Type <wbr />
|
Action Type <wbr />
|
||||||
{/* Type selection */}
|
{/* Type selection */}
|
||||||
<select
|
<select
|
||||||
@@ -120,10 +136,10 @@ export default function PlanEditorDialog({
|
|||||||
<option value="gesture">Gesture Action</option>
|
<option value="gesture">Gesture Action</option>
|
||||||
<option value="llm">LLM Action</option>
|
<option value="llm">LLM Action</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>)}
|
||||||
|
|
||||||
{/* Action value editor*/}
|
{/* Action value editor*/}
|
||||||
{newActionType === "gesture" ? (
|
{!onlyEditPhasing && newActionType === "gesture" ? (
|
||||||
// Gesture get their own editor component
|
// Gesture get their own editor component
|
||||||
<GestureValueEditor
|
<GestureValueEditor
|
||||||
value={newActionValue}
|
value={newActionValue}
|
||||||
@@ -131,15 +147,18 @@ export default function PlanEditorDialog({
|
|||||||
setType={setNewActionGestureType}
|
setType={setNewActionGestureType}
|
||||||
placeholder="Gesture name"
|
placeholder="Gesture name"
|
||||||
/>
|
/>
|
||||||
) : (
|
)
|
||||||
<TextField
|
:
|
||||||
|
// Only show the text field if we're not just rearranging.
|
||||||
|
(!onlyEditPhasing &&
|
||||||
|
(<TextField
|
||||||
value={newActionValue}
|
value={newActionValue}
|
||||||
setValue={setNewActionValue}
|
setValue={setNewActionValue}
|
||||||
placeholder={
|
placeholder={
|
||||||
newActionType === "speech" ? "Speech text"
|
newActionType === "speech" ? "Speech text"
|
||||||
: "LLM goal"
|
: "LLM goal"
|
||||||
}
|
}
|
||||||
/>
|
/>)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Adding steps */}
|
{/* Adding steps */}
|
||||||
@@ -196,9 +215,13 @@ export default function PlanEditorDialog({
|
|||||||
|
|
||||||
<span className={styles.stepIndex}>{index + 1}.</span>
|
<span className={styles.stepIndex}>{index + 1}.</span>
|
||||||
<span className={styles.stepType}>{step.type}:</span>
|
<span className={styles.stepType}>{step.type}:</span>
|
||||||
<span className={styles.stepName}>{
|
<span className={styles.stepName}>
|
||||||
step.type == "goal" ? ""/* TODO: Add support for goals */
|
{
|
||||||
: GetActionValue(step)}
|
// 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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import { TextField } from '../../../../components/TextField';
|
import { TextField } from '../../../../components/TextField';
|
||||||
import { MultilineTextField } from '../../../../components/MultilineTextField';
|
import { MultilineTextField } from '../../../../components/MultilineTextField';
|
||||||
@@ -124,7 +124,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
|||||||
wrapping = '"'
|
wrapping = '"'
|
||||||
break;
|
break;
|
||||||
case ("semantic"):
|
case ("semantic"):
|
||||||
placeholder = "description..."
|
placeholder = "short description..."
|
||||||
wrapping = '"'
|
wrapping = '"'
|
||||||
break;
|
break;
|
||||||
case ("object"):
|
case ("object"):
|
||||||
@@ -184,12 +184,12 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
|||||||
<MultilineTextField
|
<MultilineTextField
|
||||||
value={data.belief.description}
|
value={data.belief.description}
|
||||||
setValue={setBeliefDescription}
|
setValue={setBeliefDescription}
|
||||||
placeholder={"Describe the desciption of this LLM belief..."}
|
placeholder={"Describe a detailed desciption of this LLM belief..."}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||||
allowOnlyConnectionsFromType(["norm", "trigger"]),
|
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
|
||||||
]}/>
|
]}/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import { Toolbar } from '../components/NodeComponents';
|
|||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
import { TextField } from '../../../../components/TextField';
|
import { TextField } from '../../../../components/TextField';
|
||||||
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||||
import useFlowStore from '../VisProgStores';
|
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 PlanEditorDialog from '../components/PlanEditor';
|
||||||
import { MultilineTextField } from '../../../../components/MultilineTextField';
|
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
|
* The default data dot a phase node
|
||||||
@@ -43,10 +45,12 @@ export type GoalNode = Node<GoalNodeData>
|
|||||||
*/
|
*/
|
||||||
export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
||||||
const {updateNodeData} = useFlowStore();
|
const {updateNodeData} = useFlowStore();
|
||||||
|
const _nodes = useFlowStore().nodes;
|
||||||
|
|
||||||
const text_input_id = `goal_${id}_text_input`;
|
const text_input_id = `goal_${id}_text_input`;
|
||||||
const checkbox_id = `goal_${id}_checkbox`;
|
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) => {
|
const setDescription = (value: string) => {
|
||||||
updateNodeData(id, {...data, description: value});
|
updateNodeData(id, {...data, description: value});
|
||||||
@@ -73,7 +77,7 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.can_fail && (<div>
|
{(data.can_fail || hasCheckSubGoal) && (<div>
|
||||||
<label htmlFor={text_input_id}>Description/ Condition of goal:</label>
|
<label htmlFor={text_input_id}>Description/ Condition of goal:</label>
|
||||||
<div className={"flex-wrap"}>
|
<div className={"flex-wrap"}>
|
||||||
<MultilineTextField
|
<MultilineTextField
|
||||||
@@ -93,8 +97,8 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
|||||||
<input
|
<input
|
||||||
id={checkbox_id}
|
id={checkbox_id}
|
||||||
type={"checkbox"}
|
type={"checkbox"}
|
||||||
disabled={!planIterate}
|
disabled={!planIterate || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
|
||||||
checked={!planIterate || data.can_fail}
|
checked={!planIterate || data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
|
||||||
onChange={(e) => planIterate ? setFailable(e.target.checked) : setFailable(false)}
|
onChange={(e) => planIterate ? setFailable(e.target.checked) : setFailable(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,6 +119,10 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
|||||||
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
|
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
|
||||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||||
]}/>
|
]}/>
|
||||||
|
|
||||||
|
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[allowOnlyConnectionsFromType(["goal"])]}/>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
@@ -131,8 +139,8 @@ export function GoalReduce(node: Node, _nodes: Node[]) {
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
can_fail: data.can_fail,
|
can_fail: data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes)),
|
||||||
plan: data.plan ? PlanReduce(data.plan) : "",
|
plan: data.plan ? PlanReduce(_nodes, data.plan) : "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +155,22 @@ export const GoalTooltip = `
|
|||||||
* @param _sourceNodeId the source of the received connection
|
* @param _sourceNodeId the source of the received connection
|
||||||
*/
|
*/
|
||||||
export function GoalConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
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
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
*/
|
*/
|
||||||
export function GoalDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
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={[
|
<MultiConnectionHandle type="source" position={Position.Right} id="norms" rules={[
|
||||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
|
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"])
|
allowOnlyConnectionsFromType(["basic_belief"])
|
||||||
]}/>
|
]}/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ export const PhaseNodeDefaults: PhaseNodeData = {
|
|||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
nextPhaseId: null,
|
nextPhaseId: null,
|
||||||
isFirstPhase: false,
|
isFirstPhase: false,
|
||||||
|
plan: undefined,
|
||||||
};
|
};
|
||||||
@@ -10,6 +10,11 @@ import {allowOnlyConnectionsFromType, noSelfConnections} from "../HandleRules.ts
|
|||||||
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import { TextField } from '../../../../components/TextField';
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
import PlanEditorDialog from '../components/PlanEditor.tsx';
|
||||||
|
import type { Plan } from '../components/Plan.tsx';
|
||||||
|
import { insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
|
||||||
|
import { defaultPlan } from '../components/Plan.default.ts';
|
||||||
|
import type { GoalNode } from './GoalNode.tsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default data dot a phase node
|
* The default data dot a phase node
|
||||||
@@ -26,6 +31,7 @@ export type PhaseNodeData = {
|
|||||||
hasReduce: boolean;
|
hasReduce: boolean;
|
||||||
nextPhaseId: string | "end" | null;
|
nextPhaseId: string | "end" | null;
|
||||||
isFirstPhase: boolean;
|
isFirstPhase: boolean;
|
||||||
|
plan?: Plan;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PhaseNode = Node<PhaseNodeData>
|
export type PhaseNode = Node<PhaseNodeData>
|
||||||
@@ -54,6 +60,24 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
|||||||
placeholder={"Phase ..."}
|
placeholder={"Phase ..."}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{(data.plan && data.plan.steps.length > 0) && (<div>
|
||||||
|
<PlanEditorDialog
|
||||||
|
plan={data.plan}
|
||||||
|
onSave={(plan) => {
|
||||||
|
updateNodeData(props.id, {
|
||||||
|
...data,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
description={props.data.label}
|
||||||
|
onlyEditPhasing={true}
|
||||||
|
/>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||||
noSelfConnections,
|
noSelfConnections,
|
||||||
allowOnlyConnectionsFromType(["phase", "start"]),
|
allowOnlyConnectionsFromType(["phase", "start"]),
|
||||||
@@ -108,7 +132,10 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
|
|||||||
console.warn(`No reducer found for node type ${type}`);
|
console.warn(`No reducer found for node type ${type}`);
|
||||||
result[type + "s"] = [];
|
result[type + "s"] = [];
|
||||||
} else {
|
} 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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,9 +153,9 @@ export const PhaseTooltip = `
|
|||||||
*/
|
*/
|
||||||
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
const data = _thisNode.data as PhaseNodeData
|
const data = _thisNode.data as PhaseNodeData
|
||||||
|
|
||||||
const nodes = useFlowStore.getState().nodes;
|
const nodes = useFlowStore.getState().nodes;
|
||||||
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
|
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
|
||||||
|
|
||||||
switch (sourceNode.type) {
|
switch (sourceNode.type) {
|
||||||
case "phase": break;
|
case "phase": break;
|
||||||
case "start": data.isFirstPhase = true; break;
|
case "start": data.isFirstPhase = true; break;
|
||||||
@@ -136,6 +163,18 @@ export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
|||||||
// endNodes cannot be the source of an outgoing connection
|
// endNodes cannot be the source of an outgoing connection
|
||||||
// so we don't need to cover them with a special case
|
// so we don't need to cover them with a special case
|
||||||
// before handling the default behavior
|
// before handling the default behavior
|
||||||
|
case "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, sourceNode as GoalNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else, lets just insert this goal into our current plan.
|
||||||
|
else {
|
||||||
|
data.plan = insertGoalInPlan(structuredClone(data.plan), sourceNode as GoalNode)
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: data.children.push(_sourceNodeId); break;
|
default: data.children.push(_sourceNodeId); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { TriggerNodeData } from "./TriggerNode";
|
|||||||
*/
|
*/
|
||||||
export const TriggerNodeDefaults: TriggerNodeData = {
|
export const TriggerNodeDefaults: TriggerNodeData = {
|
||||||
label: "Trigger Node",
|
label: "Trigger Node",
|
||||||
|
name: "",
|
||||||
droppable: true,
|
droppable: true,
|
||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
};
|
};
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Connection,
|
|
||||||
type Edge,
|
|
||||||
type Node,
|
type Node,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
@@ -13,6 +11,10 @@ import useFlowStore from '../VisProgStores';
|
|||||||
import {PlanReduce, type Plan } from '../components/Plan';
|
import {PlanReduce, type Plan } from '../components/Plan';
|
||||||
import PlanEditorDialog from '../components/PlanEditor';
|
import PlanEditorDialog from '../components/PlanEditor';
|
||||||
import { BasicBeliefReduce } from './BasicBeliefNode';
|
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
|
* The default data structure for a Trigger node
|
||||||
@@ -26,6 +28,7 @@ import { BasicBeliefReduce } from './BasicBeliefNode';
|
|||||||
*/
|
*/
|
||||||
export type TriggerNodeData = {
|
export type TriggerNodeData = {
|
||||||
label: string;
|
label: string;
|
||||||
|
name: string;
|
||||||
droppable: boolean;
|
droppable: boolean;
|
||||||
condition?: string; // id of the belief
|
condition?: string; // id of the belief
|
||||||
plan?: Plan;
|
plan?: Plan;
|
||||||
@@ -35,18 +38,6 @@ export type TriggerNodeData = {
|
|||||||
|
|
||||||
export type TriggerNode = Node<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
|
* Defines how a Trigger node should be rendered
|
||||||
* @param props - Node properties provided by React Flow, including `id` and `data`.
|
* @param props - Node properties provided by React Flow, including `id` and `data`.
|
||||||
@@ -56,19 +47,44 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
|||||||
const data = props.data;
|
const data = props.data;
|
||||||
const {updateNodeData} = useFlowStore();
|
const {updateNodeData} = useFlowStore();
|
||||||
|
|
||||||
|
const setName= (value: string) => {
|
||||||
|
updateNodeData(props.id, {...data, name: value})
|
||||||
|
}
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
|
|
||||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
<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"}>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"}>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>
|
<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={[
|
<MultiConnectionHandle type="source" position={Position.Right} id="TriggerSource" rules={[
|
||||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||||
]}/>
|
]}/>
|
||||||
<SingleConnectionHandle type="target" position={Position.Bottom} id="beliefs" rules={[
|
<SingleConnectionHandle
|
||||||
allowOnlyConnectionsFromType(["basic_belief"])
|
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
|
<PlanEditorDialog
|
||||||
plan={data.plan}
|
plan={data.plan}
|
||||||
@@ -95,8 +111,9 @@ export function TriggerReduce(node: Node, nodes: Node[]) {
|
|||||||
const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : ""
|
const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : ""
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
|
name: node.data.name,
|
||||||
condition: conditionData, // Make sure we have a condition before reducing, or default to ""
|
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
|
// no additional connection logic exists yet
|
||||||
const data = _thisNode.data as TriggerNodeData;
|
const data = _thisNode.data as TriggerNodeData;
|
||||||
// If we got a belief connected, this is the 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 */))) {
|
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;
|
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;
|
const data = _thisNode.data as TriggerNodeData;
|
||||||
// remove if the target of disconnection was our condition
|
// remove if the target of disconnection was our condition
|
||||||
if (_sourceNodeId == data.condition) data.condition = undefined
|
if (_sourceNodeId == data.condition) data.condition = undefined
|
||||||
|
|
||||||
|
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
83
test/pages/simpleProgram/SimpleProgram.tsx
Normal file
83
test/pages/simpleProgram/SimpleProgram.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import SimpleProgram from "../../../src/pages/SimpleProgram/SimpleProgram";
|
||||||
|
import useProgramStore from "../../../src/utils/programStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to preload the program store before rendering.
|
||||||
|
*/
|
||||||
|
function loadProgram(phases: Record<string, unknown>[]) {
|
||||||
|
useProgramStore.getState().setProgramState({ phases });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SimpleProgram", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
loadProgram([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows empty state when no program is loaded", () => {
|
||||||
|
render(<SimpleProgram />);
|
||||||
|
expect(screen.getByText("No program loaded.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders first phase content", () => {
|
||||||
|
loadProgram([
|
||||||
|
{
|
||||||
|
id: "phase-1",
|
||||||
|
norms: [{ id: "n1", norm: "Be polite" }],
|
||||||
|
goals: [{ id: "g1", description: "Finish task", achieved: true }],
|
||||||
|
triggers: [{ id: "t1", label: "Keyword trigger" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SimpleProgram />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Phase 1 / 1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Be polite")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Finish task")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Keyword trigger")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows navigating between phases", () => {
|
||||||
|
loadProgram([
|
||||||
|
{
|
||||||
|
id: "phase-1",
|
||||||
|
norms: [],
|
||||||
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "phase-2",
|
||||||
|
norms: [{ id: "n2", norm: "Be careful" }],
|
||||||
|
goals: [],
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SimpleProgram />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Next ▶"));
|
||||||
|
|
||||||
|
expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Be careful")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prev button is disabled on first phase", () => {
|
||||||
|
loadProgram([
|
||||||
|
{ id: "phase-1", norms: [], goals: [], triggers: [] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SimpleProgram />);
|
||||||
|
expect(screen.getByText("◀ Prev")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("next button is disabled on last phase", () => {
|
||||||
|
loadProgram([
|
||||||
|
{ id: "phase-1", norms: [], goals: [], triggers: [] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<SimpleProgram />);
|
||||||
|
expect(screen.getByText("Next ▶")).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
// PlanEditorDialog.test.tsx
|
|
||||||
import { describe, it, beforeEach, jest } from '@jest/globals';
|
import { describe, it, beforeEach, jest } from '@jest/globals';
|
||||||
import { screen, within } from '@testing-library/react';
|
import { screen, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { Node } from '@xyflow/react';
|
||||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||||
import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor';
|
import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor';
|
||||||
import { PlanReduce, type Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan';
|
import { PlanReduce, type Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan';
|
||||||
import '@testing-library/jest-dom';
|
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
|
// Mock structuredClone
|
||||||
(globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val)));
|
(globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val)));
|
||||||
@@ -468,7 +472,18 @@ describe('PlanEditorDialog', () => {
|
|||||||
|
|
||||||
describe('Plan reducing', () => {
|
describe('Plan reducing', () => {
|
||||||
it('should correctly reduce the plan given the elements of the plan', () => {
|
it('should correctly reduce the plan given the elements of the plan', () => {
|
||||||
|
// Create a plan for testing
|
||||||
const testplan = extendedPlan
|
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 = {
|
const expectedResult = {
|
||||||
id: "extended-plan-1",
|
id: "extended-plan-1",
|
||||||
steps: [
|
steps: [
|
||||||
@@ -493,12 +508,13 @@ describe('PlanEditorDialog', () => {
|
|||||||
{
|
{
|
||||||
id: "fourthstep",
|
id: "fourthstep",
|
||||||
text: "I'm a cyborg ninja :>"
|
text: "I'm a cyborg ninja :>"
|
||||||
}
|
},
|
||||||
|
goalReduced,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualResult = PlanReduce(testplan)
|
// 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)
|
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 { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||||
import TriggerNode, {
|
import TriggerNode, {
|
||||||
TriggerReduce,
|
TriggerReduce,
|
||||||
TriggerNodeCanConnect,
|
|
||||||
type TriggerNodeData,
|
type TriggerNodeData,
|
||||||
TriggerConnectionSource, TriggerConnectionTarget
|
TriggerConnectionSource, TriggerConnectionTarget
|
||||||
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode';
|
} 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 { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts';
|
||||||
import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.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 { 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', () => {
|
describe('TriggerNode', () => {
|
||||||
|
|
||||||
@@ -73,7 +74,8 @@ describe('TriggerNode', () => {
|
|||||||
data: {
|
data: {
|
||||||
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
|
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
|
||||||
condition: "belief-1",
|
condition: "belief-1",
|
||||||
plan: defaultPlan
|
plan: defaultPlan,
|
||||||
|
name: "trigger-1"
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,6 +95,7 @@ describe('TriggerNode', () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
id: 'trigger-1',
|
id: 'trigger-1',
|
||||||
|
name: "trigger-1",
|
||||||
condition: {
|
condition: {
|
||||||
id: "belief-1",
|
id: "belief-1",
|
||||||
keyword: "",
|
keyword: "",
|
||||||
@@ -132,10 +135,50 @@ describe('TriggerNode', () => {
|
|||||||
TriggerConnectionTarget(node1, node2.id);
|
TriggerConnectionTarget(node1, node2.id);
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should return true for TriggerNodeCanConnect if connection exists', () => {
|
|
||||||
const connection = { source: 'trigger-1', target: 'norm-1' };
|
describe('TriggerConnects Function', () => {
|
||||||
expect(TriggerNodeCanConnect(connection as any)).toBe(true);
|
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