12 Commits

Author SHA1 Message Date
JobvAlewijk
a4f7b48031 Merge branch 'feat/monitoringpage-pim' of ssh://git.science.uu.nl/ics/sp/2025/n25b/pepperplus-ui into feat/monitoringpage-pim 2026-01-29 16:24:19 +01:00
JobvAlewijk
b18cd5bfa5 chore: face 2026-01-29 16:24:03 +01:00
Pim Hutting
09e6287f9d chore: edited a test to pass 2026-01-26 14:35:46 +01:00
Pim Hutting
8b40001038 Merge branch 'dev' into feat/monitoringpage-pim 2026-01-26 14:22:13 +01:00
Twirre
f9e0eb95f8 Merge branch 'feat/add-inferred-belief-node' into 'dev'
feat: added an inferred belief node to the editor

See merge request ics/sp/2025/n25b/pepperplus-ui!42
2026-01-23 12:57:35 +00:00
Gerla, J. (Justin)
47c5e94b8f feat: added an inferred belief node to the editor 2026-01-23 12:57:34 +00:00
Pim Hutting
b17d1e7618 Merge branch 'fix/correct-capitalization' into 'dev'
chore: fix the capitalization of 3 characters to make sure they match. :)

See merge request ics/sp/2025/n25b/pepperplus-ui!46
2026-01-23 10:42:36 +00:00
Björn Otgaar
ec211ccbc3 chore: fix the capitalization of 3 characters to make sure they match. :) 2026-01-23 11:25:30 +01:00
Pim Hutting
9a555165e6 Merge branch 'dev' into feat/monitoringpage-pim 2026-01-22 10:19:46 +01:00
Pim Hutting
f73bbb9d02 chore: added tests and removed restart phase
this version also has recursive goals functional
2026-01-22 10:15:20 +01:00
Pim Hutting
883f0a95a6 chore: only check if play is undefined 2026-01-20 13:55:34 +01:00
Gerla, J. (Justin)
6f4471ce6f Merge branch 'demo' into 'dev'
feat: merged demo into dev

See merge request ics/sp/2025/n25b/pepperplus-ui!43
2026-01-20 11:10:58 +00:00
39 changed files with 251 additions and 1226 deletions

16
package-lock.json generated
View File

@@ -10,7 +10,6 @@
"dependencies": {
"@neodrag/react": "^2.3.1",
"@xyflow/react": "^12.8.6",
"clsx": "^2.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.3",
@@ -3972,15 +3971,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -6955,9 +6945,9 @@
}
},
"node_modules/react-router": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",

View File

@@ -14,7 +14,6 @@
"dependencies": {
"@neodrag/react": "^2.3.1",
"@xyflow/react": "^12.8.6",
"clsx": "^2.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.3",

View File

@@ -161,13 +161,7 @@ input[type="checkbox"] {
.margin-0 {
margin: 0;
}
.margin-lg {
margin: 1rem;
}
.padding-0 {
padding: 0;
}
.padding-sm {
padding: .25rem;
}
@@ -177,9 +171,11 @@ input[type="checkbox"] {
.padding-lg {
padding: 1rem;
}
.padding-h-lg {
padding-left: 1rem;
padding-right: 1rem;
.padding-b-sm {
padding-bottom: .25rem;
}
.padding-b-md {
padding-bottom: .5rem;
}
.padding-b-lg {
padding-bottom: 1rem;
@@ -208,27 +204,6 @@ input[type="checkbox"] {
border: 3px solid canvastext;
}
.shadow-sm {
box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.25);
}
.shadow-md {
box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.25);
}
.shadow-lg {
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.25);
}
@media (prefers-color-scheme: dark) {
.shadow-sm {
box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.5);
}
.shadow-md {
box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.5);
}
.shadow-lg {
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
}
}
.font-small {
font-size: .75rem;
}
@@ -245,9 +220,6 @@ input[type="checkbox"] {
font-weight: bold;
}
.relative {
position: relative;
}
.clickable {
cursor: pointer;

View File

@@ -16,7 +16,7 @@ function App(){
<>
<header>
<Link to={"/"}>Home</Link>
<button onClick={() => setShowLogs(!showLogs)}>Developer Logs</button>
<button onClick={() => setShowLogs(!showLogs)}>Toggle Logging</button>
</header>
<div className={"flex-row justify-center flex-1 min-height-0"}>
<main className={"flex-col align-center flex-1 scroll-y"}>

View File

@@ -1,48 +0,0 @@
import {type ReactNode, type RefObject, useEffect, useRef} from "react";
export default function Dialog({
open,
close,
classname,
children,
}: {
open: boolean;
close: () => void;
classname?: string;
children: ReactNode;
}) {
const ref: RefObject<HTMLDialogElement | null> = useRef(null);
useEffect(() => {
if (open) {
ref.current?.showModal();
} else {
ref.current?.close();
}
}, [open]);
function handleClickOutside(event: React.MouseEvent) {
if (!ref.current) return;
const dialogDimensions = ref.current.getBoundingClientRect()
if (
event.clientX < dialogDimensions.left ||
event.clientX > dialogDimensions.right ||
event.clientY < dialogDimensions.top ||
event.clientY > dialogDimensions.bottom
) {
close();
}
}
return (
<dialog
ref={ref}
onCancel={close}
onPointerDown={handleClickOutside}
className={classname}
>
{children}
</dialog>
);
}

View File

@@ -1,31 +0,0 @@
import type {Cell} from "../../utils/cellStore.ts";
import type {LogRecord} from "./useLogs.ts";
/**
* Zustand store definition for managing user preferences related to logging.
*
* Includes flags for toggling relative timestamps and automatic scroll behavior.
*/
export type LoggingSettings = {
/** Whether to display log timestamps as relative (e.g., "2m 15s ago") instead of absolute. */
showRelativeTime: boolean;
/** Updates the `showRelativeTime` setting. */
setShowRelativeTime: (showRelativeTime: boolean) => void;
};
/**
* Props for any component that renders a single log message entry.
*
* @param recordCell - A reactive `Cell` containing a single `LogRecord`.
* @param onUpdate - Optional callback triggered when the log entry updates.
*/
export type MessageComponentProps = {
recordCell: Cell<LogRecord>,
onUpdate?: () => void,
};
/**
* Key used for the experiment filter predicate in the filter map, to exclude experiment logs from the developer logs.
*/
export const EXPERIMENT_FILTER_KEY = "experiment_filter";
export const EXPERIMENT_LOGGER_NAME = "experiment";

View File

@@ -13,8 +13,9 @@ type Setter<T> = (value: T | ((prev: T) => T)) => void;
* Mapping of log level names to their corresponding numeric severity.
* Used for comparison in log filtering predicates.
*/
const optionMapping: Map<string, number> = new Map([
const optionMapping = new Map([
["ALL", 0],
["LLM", 9],
["DEBUG", 10],
["INFO", 20],
["WARNING", 30],
@@ -92,7 +93,7 @@ function GlobalLevelFilter({
filterPredicates: Map<string, LogFilterPredicate>;
setFilterPredicates: Setter<Map<string, LogFilterPredicate>>;
}) {
const selected = filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value ?? "ALL";
const selected = filterPredicates.get(GLOBAL_LOG_LEVEL_PREDICATE_KEY)?.value || "ALL";
const setSelected = (selected: string | null) => {
if (!selected || !optionMapping.has(selected)) return;

View File

@@ -5,6 +5,7 @@
flex-shrink: 0;
box-shadow: 0 0 1rem black;
padding: 1rem 1rem 0 1rem;
}
.no-numbers {
@@ -14,6 +15,8 @@
}
.log-container {
margin-bottom: .5rem;
.accented-0, .accented-10 {
background-color: color-mix(in oklab, canvas, rgb(159, 159, 159) 35%)
}
@@ -29,7 +32,7 @@
}
.floating-button {
position: absolute;
position: fixed;
bottom: 1rem;
right: 1rem;
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);

View File

@@ -1,23 +1,38 @@
import {type ComponentType, useEffect, useRef, useState} from "react";
import {useEffect, useRef, useState} from "react";
import {create} from "zustand";
import formatDuration from "../../utils/formatDuration.ts";
import {type LogFilterPredicate, type LogRecord, useLogs} from "./useLogs.ts";
import Filters from "./Filters.tsx";
import {type Cell, useCell} from "../../utils/cellStore.ts";
import styles from "./Logging.module.css";
import {
EXPERIMENT_FILTER_KEY,
EXPERIMENT_LOGGER_NAME,
type LoggingSettings,
type MessageComponentProps
} from "./Definitions.ts";
import {create} from "zustand";
/**
* Local Zustand store for logging UI preferences.
* Zustand store definition for managing user preferences related to logging.
*
* Includes flags for toggling relative timestamps and automatic scroll behavior.
*/
type LoggingSettings = {
/** Whether to display log timestamps as relative (e.g., "2m 15s ago") instead of absolute. */
showRelativeTime: boolean;
/** Updates the `showRelativeTime` setting. */
setShowRelativeTime: (showRelativeTime: boolean) => void;
/** Whether the log view should automatically scroll to the newest entry. */
scrollToBottom: boolean;
/** Updates the `scrollToBottom` setting. */
setScrollToBottom: (scrollToBottom: boolean) => void;
};
/**
* Global Zustand store for logging UI preferences.
*/
const useLoggingSettings = create<LoggingSettings>((set) => ({
showRelativeTime: false,
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
scrollToBottom: true,
setScrollToBottom: (scrollToBottom: boolean) => set({ scrollToBottom }),
}));
/**
@@ -30,7 +45,13 @@ const useLoggingSettings = create<LoggingSettings>((set) => ({
* @param onUpdate - Optional callback triggered when the log entry updates.
* @returns A JSX element displaying a formatted log message.
*/
function LogMessage({ recordCell, onUpdate }: MessageComponentProps) {
function LogMessage({
recordCell,
onUpdate,
}: {
recordCell: Cell<LogRecord>,
onUpdate?: () => void,
}) {
const { showRelativeTime, setShowRelativeTime } = useLoggingSettings();
const record = useCell(recordCell);
@@ -48,7 +69,7 @@ function LogMessage({ recordCell, onUpdate }: MessageComponentProps) {
/** Simplifies the logger name by showing only the last path segment. */
const normalizedName = record.name.split(".").pop() || record.name;
// Notify the parent component (e.g., for scroll updates) when this record changes.
// Notify parent component (e.g. for scroll updates) when this record changes.
useEffect(() => {
if (onUpdate) onUpdate();
}, [record, onUpdate]);
@@ -56,10 +77,11 @@ function LogMessage({ recordCell, onUpdate }: MessageComponentProps) {
return <div className={`${styles.logContainer} round-md border-lg flex-row gap-md`}>
<div className={`${styles[`accented${normalizedLevelNo}`]} flex-col padding-sm justify-between`}>
<span className={"mono bold"}>{record.levelname}</span>
<span className={"mono clickable font-small"} onClick={() => setShowRelativeTime(!showRelativeTime)}>{
showRelativeTime
? formatDuration(record.relativeCreated)
: new Date(record.created * 1000).toLocaleTimeString()
<span className={"mono clickable font-small"}
onClick={() => setShowRelativeTime(!showRelativeTime)}
>{showRelativeTime
? formatDuration(record.relativeCreated)
: new Date(record.created * 1000).toLocaleTimeString()
}</span>
</div>
<div className={"flex-col flex-1 padding-sm"}>
@@ -78,18 +100,12 @@ function LogMessage({ recordCell, onUpdate }: MessageComponentProps) {
* - A floating "Scroll to bottom" button when not at the bottom.
*
* @param recordCells - Array of reactive log records to display.
* @param MessageComponent - A component to use to render each log message entry.
* @returns A scrollable log list component.
*/
export function LogMessages({
recordCells,
MessageComponent,
}: {
recordCells: Cell<LogRecord>[],
MessageComponent: ComponentType<MessageComponentProps>,
}) {
function LogMessages({ recordCells }: { recordCells: Cell<LogRecord>[] }) {
const scrollableRef = useRef<HTMLDivElement>(null);
const [scrollToBottom, setScrollToBottom] = useState(true);
const lastElementRef = useRef<HTMLLIElement>(null)
const { scrollToBottom, setScrollToBottom } = useLoggingSettings();
// Disable auto-scroll if the user manually scrolls.
useEffect(() => {
@@ -108,28 +124,30 @@ export function LogMessages({
}, [scrollableRef, setScrollToBottom]);
/**
* Scrolls the log messages to the bottom, making the latest messages visible.
* Scrolls the last log message into view if auto-scroll is enabled,
* or if forced (e.g., user clicks "Scroll to bottom").
*
* @param force - If true, forces scrolling even if `scrollToBottom` is false.
*/
function showBottom(force = false) {
if ((!scrollToBottom && !force) || !scrollableRef.current) return;
scrollableRef.current.scrollTo({top: scrollableRef.current.scrollHeight, left: 0, behavior: "smooth"});
function scrollLastElementIntoView(force = false) {
if ((!scrollToBottom && !force) || !lastElementRef.current) return;
lastElementRef.current.scrollIntoView({ behavior: "smooth" });
}
return <div ref={scrollableRef} className={"min-height-0 scroll-y padding-h-lg padding-b-lg flex-1"}>
return <div ref={scrollableRef} className={"min-height-0 scroll-y padding-b-md"}>
<ol className={`${styles.noNumbers} margin-0 flex-col gap-md`}>
{recordCells.map((recordCell, i) => (
<li key={`${i}_${recordCell.get().firstRelativeCreated}`}>
<MessageComponent recordCell={recordCell} onUpdate={showBottom} />
<LogMessage recordCell={recordCell} onUpdate={scrollLastElementIntoView} />
</li>
))}
<li ref={lastElementRef}></li>
</ol>
{!scrollToBottom && <button
className={styles.floatingButton}
onClick={() => {
setScrollToBottom(true);
showBottom(true);
scrollLastElementIntoView(true);
}}
>
Scroll to bottom
@@ -146,27 +164,16 @@ export function LogMessages({
* - Zustand-managed UI settings (auto-scroll, timestamp display).
*
* This component uses the `useLogs` hook to fetch and filter logs based on
* active predicates and re-renders automatically as new logs arrive.
* active predicates, and re-renders automatically as new logs arrive.
*
* @returns The complete logging UI as a React element.
*/
export default function Logging() {
// By default, filter experiment logs from this debug logger
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>([
[
EXPERIMENT_FILTER_KEY,
{
predicate: (r) => r.name == EXPERIMENT_LOGGER_NAME ? false : null,
priority: 999,
value: null,
} as LogFilterPredicate,
],
]));
const [filterPredicates, setFilterPredicates] = useState(new Map<string, LogFilterPredicate>());
const { filteredLogs, distinctNames } = useLogs(filterPredicates)
distinctNames.delete(EXPERIMENT_LOGGER_NAME);
return <div className={`flex-col min-height-0 relative ${styles.loggingContainer}`}>
<div className={"flex-row gap-lg justify-between align-center padding-lg"}>
return <div className={`flex-col gap-lg min-height-0 ${styles.loggingContainer}`}>
<div className={"flex-row gap-lg justify-between align-center"}>
<h2 className={"margin-0"}>Logs</h2>
<Filters
filterPredicates={filterPredicates}
@@ -174,6 +181,6 @@ export default function Logging() {
agentNames={distinctNames}
/>
</div>
<LogMessages recordCells={filteredLogs} MessageComponent={LogMessage} />
<LogMessages recordCells={filteredLogs} />
</div>;
}

View File

@@ -3,21 +3,6 @@ import {useCallback, useEffect, useRef, useState} from "react";
import {applyPriorityPredicates, type PriorityFilterPredicate} from "../../utils/priorityFiltering.ts";
import {cell, type Cell} from "../../utils/cellStore.ts";
type ExtraLevelName = 'LLM' | 'OBSERVATION' | 'ACTION' | 'CHAT';
export type LevelName = ExtraLevelName | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
/**
* Extra fields that are added to log records in the backend but are not part of the standard `LogRecord` type.
*
* @property reference - (Optional) A reference identifier linking related log messages.
* @property role - (Optional) For chat log messages, the role of the agent that generated the message.
*/
type ExtraLogRecordFields = {
reference?: string;
role?: string;
}
/**
* Represents a single log record emitted by the backend logging system.
*
@@ -27,19 +12,21 @@ type ExtraLogRecordFields = {
* @property levelno - The numeric severity value corresponding to `levelname`.
* @property created - The UNIX timestamp (in seconds) when this record was created.
* @property relativeCreated - The time (in milliseconds) since the logging system started.
* @property reference - (Optional) A reference identifier linking related log messages.
* @property firstCreated - Timestamp of the first log in this reference group.
* @property firstRelativeCreated - Relative timestamp of the first log in this reference group.
*/
export type LogRecord = {
name: string;
message: string;
levelname: LevelName;
levelname: 'LLM' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
levelno: number;
created: number;
relativeCreated: number;
reference?: string;
firstCreated: number;
firstRelativeCreated: number;
} & ExtraLogRecordFields;
};
/**
* A log filter predicate with priority support, used to determine whether
@@ -50,7 +37,7 @@ export type LogRecord = {
*
* @template T - The type of record being filtered (here, `LogRecord`).
*/
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & {
export type LogFilterPredicate = PriorityFilterPredicate<LogRecord> & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any };

View File

@@ -52,7 +52,7 @@ button {
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: canvas;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
@@ -75,6 +75,9 @@ button:focus-visible {
--dropdown-menu-background-color: rgb(247, 247, 247);
--dropdown-menu-border: rgba(207, 207, 207, 0.986);
}
button {
background-color: #f9f9f9;
}
}
@media (prefers-color-scheme: dark) {

View File

@@ -77,11 +77,6 @@
color: white;
}
.restartPhase{
background-color: rgb(255, 123, 0);
color: white;
}
.restartExperiment{
background-color: red;
color: white;
@@ -194,6 +189,32 @@
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;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useState } from 'react';
import styles from './MonitoringPage.module.css';
// Store & API
@@ -30,7 +30,6 @@ import {
StatusList,
RobotConnected
} from './MonitoringPageComponents';
import ExperimentLogs from "./components/ExperimentLogs.tsx";
// ----------------------------------------------------------------------
// 1. State management
@@ -53,16 +52,12 @@ function useExperimentLogic() {
const [phaseIndex, setPhaseIndex] = useState(0);
const [isFinished, setIsFinished] = useState(false);
// Ref to suppress stream updates during the "Reset Phase" fast-forward sequence
const suppressUpdates = useRef(false);
const phaseIds = getPhaseIds();
const phaseNames = getPhaseNames();
// --- Stream Handlers ---
const handleStreamUpdate = useCallback((data: ExperimentStreamData) => {
if (suppressUpdates.current) return;
if (data.type === 'phase_update' && data.id) {
const payload = data as PhaseUpdate;
console.log(`${data.type} received, id : ${data.id}`);
@@ -106,7 +101,6 @@ function useExperimentLogic() {
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]);
const handleStatusUpdate = useCallback((data: unknown) => {
if (suppressUpdates.current) return;
const payload = data as CondNormsStateUpdate;
if (payload.type !== 'cond_norms_state_update') return;
@@ -146,7 +140,7 @@ function useExperimentLogic() {
}
}, [setProgramState]);
const handleControlAction = async (action: "pause" | "play" | "nextPhase" | "resetPhase") => {
const handleControlAction = async (action: "pause" | "play" | "nextPhase") => {
try {
setLoading(true);
switch (action) {
@@ -161,30 +155,6 @@ function useExperimentLogic() {
case "nextPhase":
await nextPhase();
break;
case "resetPhase":
//make sure you don't see the phases pass to arrive back at current phase
suppressUpdates.current = true;
const targetIndex = phaseIndex;
console.log(`Resetting phase: Restarting and skipping to index ${targetIndex}`);
const phases = graphReducer();
setProgramState({ phases });
setActiveIds({});
setPhaseIndex(0); // Visually reset to start
setGoalIndex(0);
setIsFinished(false);
// Restart backend
await runProgramm();
for (let i = 0; i < targetIndex; i++) {
console.log(`Skipping phase ${i}...`);
await nextPhase();
}
suppressUpdates.current = false;
setPhaseIndex(targetIndex);
setIsPlaying(true); //Maybe you pause and then reset
break;
}
} catch (err) {
console.error(err);
@@ -252,7 +222,7 @@ function ControlPanel({
}: {
loading: boolean,
isPlaying: boolean,
onAction: (a: "pause" | "play" | "nextPhase" | "resetPhase") => void,
onAction: (a: "pause" | "play" | "nextPhase") => void,
onReset: () => void
}) {
return (
@@ -277,12 +247,6 @@ function ControlPanel({
disabled={loading}
></button>
<button
className={styles.restartPhase}
onClick={() => onAction("resetPhase")}
disabled={loading}
></button>
<button
className={styles.restartExperiment}
onClick={onReset}
@@ -417,8 +381,17 @@ const MonitoringPage: React.FC = () => {
)}
</main>
{/* LOGS */}
<ExperimentLogs />
{/* LOGS TODO: add actual 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}>

View File

@@ -32,16 +32,6 @@ export async function nextPhase(): Promise<void> {
}
/**
* 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 pause experiment
*/

View File

@@ -1,34 +0,0 @@
.logs {
/* grid-area used in MonitoringPage.module.css */
grid-area: logs;
box-shadow: var(--panel-shadow);
height: 900px;
width: 450px;
.live {
width: .5rem;
height: .5rem;
left: .5rem;
background: red;
border-radius: 50%;
}
}
.chat-message.alternate {
align-items: end;
text-align: end;
background-color: color-mix(in oklab, canvas, 75% #86c4fa);
.message-head {
flex-direction: row-reverse;
}
}
.download-list {
box-sizing: border-box;
list-style: none;
height: 50dvh;
min-width: 300px;
}

View File

@@ -1,186 +0,0 @@
import styles from "./ExperimentLogs.module.css";
import {LogMessages} from "../../../components/Logging/Logging.tsx";
import {useEffect, useMemo, useState} from "react";
import {type LogFilterPredicate, type LogRecord, useLogs} from "../../../components/Logging/useLogs.ts";
import capitalize from "../../../utils/capitalize.ts";
import {useCell} from "../../../utils/cellStore.ts";
import {
EXPERIMENT_FILTER_KEY,
EXPERIMENT_LOGGER_NAME,
type LoggingSettings,
type MessageComponentProps,
} from "../../../components/Logging/Definitions.ts";
import formatDuration from "../../../utils/formatDuration.ts";
import {create} from "zustand";
import Dialog from "../../../components/Dialog.tsx";
import delayedResolve from "../../../utils/delayedResolve.ts";
/**
* Local Zustand store for logging UI preferences.
*/
const useLoggingSettings = create<LoggingSettings>((set) => ({
showRelativeTime: false,
setShowRelativeTime: (showRelativeTime: boolean) => set({ showRelativeTime }),
}));
/**
* A dedicated component for rendering chat messages.
*
* @param record The chat record to render.
*/
function ChatMessage({ record }: { record: LogRecord }) {
const { showRelativeTime, setShowRelativeTime } = useLoggingSettings();
const reverse = record.role === "user" ? styles.alternate : "";
return <div className={`${styles.chatMessage} ${reverse} flex-col padding-md padding-h-lg shadow-md round-md`}>
<div className={`${styles.messageHead} flex-row gap-md align-center`}>
<span className={"bold"}>{capitalize(record.role ?? "unknown")}</span>
<span className={"font-small"}></span>
<span className={"mono clickable font-small"} onClick={() => setShowRelativeTime(!showRelativeTime)}>{
showRelativeTime
? formatDuration(record.relativeCreated)
: new Date(record.created * 1000).toLocaleTimeString()
}</span>
</div>
<span>{record.message}</span>
</div>
}
/**
* A generic log message component showing the log level, time, and message text.
*
* @param record The log record to render.
*/
function DefaultMessage({ record }: { record: LogRecord }) {
const { showRelativeTime, setShowRelativeTime } = useLoggingSettings();
return <div>
<div className={"flex-row gap-md align-center"}>
<span className={"font-small"}>{record.levelname}</span>
<span className={"font-small"}></span>
<span className={"mono clickable font-small"} onClick={() => setShowRelativeTime(!showRelativeTime)}>{
showRelativeTime
? formatDuration(record.relativeCreated)
: new Date(record.created * 1000).toLocaleTimeString()
}</span>
</div>
<span>{record.message}</span>
</div>;
}
/**
* A custom component for rendering experiment messages, which might include chat messages.
*
* @param recordCell The cell containing the log record to render.
* @param onUpdate A callback to notify the parent component when the record changes.
*/
function ExperimentMessage({recordCell, onUpdate}: MessageComponentProps) {
const record = useCell(recordCell);
// Notify the parent component (e.g., for scroll updates) when this record changes.
useEffect(() => {
if (onUpdate) onUpdate();
}, [record, onUpdate]);
if (record.levelname == "CHAT") {
return <ChatMessage record={record} />
} else {
return <DefaultMessage record={record} />
}
}
/**
* A download dialog listing experiment logs to download.
*
* @param filenames The list of available experiment logs to download.
* @param refresh A callback to refresh the list of available experiment logs.
*/
function DownloadScreen({filenames, refresh}: {filenames: string[] | null, refresh: () => void}) {
const list = (() => {
if (filenames == null) return <div className={`${styles.downloadList} flex-col align-center justify-center`}>
<p>Loading...</p>
</div>;
if (filenames.length === 0) return <div className={`${styles.downloadList} flex-col align-center justify-center`}>
<p>No files available.</p>
</div>
return <ol className={`${styles.downloadList} margin-0 padding-h-lg scroll-y`}>
{filenames!.map((filename) => (
<li><a key={filename} href={`http://localhost:8000/api/logs/files/${filename}`} download={filename}>{filename}</a></li>
))}
</ol>;
})();
return <div className={"flex-col"}>
<p className={"margin-lg"}>Select a file to download:</p>
{list}
<button onClick={refresh} className={"margin-lg shadow-sm"}>Refresh</button>
</div>;
}
/**
* A button that opens a download dialog for experiment logs when pressed.
*/
function DownloadButton() {
const [showModal, setShowModal] = useState(false);
const [filenames, setFilenames] = useState<string[] | null>(null);
async function getFiles(): Promise<string[]> {
const response = await fetch("http://localhost:8000/api/logs/files");
const files = await response.json();
files.sort();
return files;
}
useEffect(() => {
getFiles().then(setFilenames);
}, [showModal]);
return <>
<button className={"shadow-sm"} onClick={() => setShowModal((curr) => !curr)}>Download...</button>
<Dialog open={showModal} close={() => setShowModal(false)} classname={"padding-0 round-lg"}>
<DownloadScreen filenames={filenames} refresh={async () => {
setFilenames(null);
const files = await delayedResolve(getFiles(), 250);
setFilenames(files);
}} />
</Dialog>
</>;
}
/**
* A component for rendering experiment logs. This component uses the `useLogs` hook with a filter to show only
* experiment logs.
*/
export default function ExperimentLogs() {
// Show only experiment logs in this logger
const filters = useMemo(() => new Map<string, LogFilterPredicate>([
[
EXPERIMENT_FILTER_KEY,
{
predicate: (r) => r.name == EXPERIMENT_LOGGER_NAME,
priority: 999,
value: null,
} as LogFilterPredicate,
],
]), []);
const { filteredLogs } = useLogs(filters);
return <aside className={`${styles.logs} flex-col relative`}>
<div className={`${styles.head} padding-lg`}>
<div className={"flex-row align-center justify-between"}>
<h3>Logs</h3>
<div className={"flex-row gap-md align-center"}>
<div className={`flex-row align-center gap-md relative padding-md shadow-sm round-md`}>
<div className={styles.live}></div>
<span>Live</span>
</div>
<DownloadButton />
</div>
</div>
</div>
<LogMessages recordCells={filteredLogs} MessageComponent={ExperimentMessage} />
</aside>;
}

View File

@@ -4,16 +4,13 @@ import {
Panel,
ReactFlow,
ReactFlowProvider,
MarkerType, getOutgoers
MarkerType,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type CSSProperties, useEffect, useState} from "react";
import {useShallow} from 'zustand/react/shallow';
import useProgramStore from "../../utils/programStore.ts";
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
import {type EditorWarning, globalWarning} from "./visualProgrammingUI/components/EditorWarnings.tsx";
import {WarningsSidebar} from "./visualProgrammingUI/components/WarningSidebar.tsx";
import type {PhaseNode} from "./visualProgrammingUI/nodes/PhaseNode.tsx";
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
import styles from './VisProg.module.css'
@@ -92,31 +89,9 @@ const VisProgUI = () => {
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
const {unregisterWarning, registerWarning} = useFlowStore();
useEffect(() => {
if (checkPhaseChain()) {
unregisterWarning(globalWarning,'INCOMPLETE_PROGRAM');
} else {
// create global warning for incomplete program chain
const incompleteProgramWarning : EditorWarning = {
scope: {
id: globalWarning,
handleId: undefined
},
type: 'INCOMPLETE_PROGRAM',
severity: "ERROR",
description: "there is no complete phase chain from the startNode to the EndNode"
}
registerWarning(incompleteProgramWarning);
}
},[edges, registerWarning, unregisterWarning])
return (
<div className={`${styles.innerEditorContainer} round-lg border-lg flex-row`} style={({'--flow-zoom': zoom} as CSSProperties)}>
<div className={`${styles.innerEditorContainer} round-lg border-lg`} style={({'--flow-zoom': zoom} as CSSProperties)}>
<ReactFlow
nodes={nodes}
edges={edges}
@@ -136,7 +111,6 @@ const VisProgUI = () => {
snapToGrid
fitView
proOptions={{hideAttribution: true}}
style={{flexGrow: 3}}
>
<Panel position="top-center" className={styles.dndPanel}>
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
@@ -145,14 +119,12 @@ const VisProgUI = () => {
<SaveLoadPanel></SaveLoadPanel>
</Panel>
<Panel position="bottom-center">
<button onClick={() => undo()}>undo</button>
<button onClick={() => undo()}>Undo</button>
<button onClick={() => redo()}>Redo</button>
</Panel>
<Controls/>
<Background/>
</ReactFlow>
<WarningsSidebar/>
</div>
);
};
@@ -171,26 +143,7 @@ function VisualProgrammingUI() {
</ReactFlowProvider>
);
}
const checkPhaseChain = (): boolean => {
const {nodes, edges} = useFlowStore.getState();
function checkForCompleteChain(currentNodeId: string): boolean {
const outgoingPhases = getOutgoers({id: currentNodeId}, nodes, edges)
.filter(node => ["end", "phase"].includes(node.type!));
if (outgoingPhases.length === 0) return false;
if (outgoingPhases.some(node => node.type === "end" )) return true;
const next = outgoingPhases.map(node => checkForCompleteChain(node.id))
.find(result => result);
console.log(next);
return !!next;
}
return checkForCompleteChain('start');
};
/**
* houses the entire page, so also UI elements
@@ -219,19 +172,10 @@ function VisProgPage() {
);
}
const [programValidity, setProgramValidity] = useState<boolean>(true);
const {isProgramValid, severityIndex} = useFlowStore();
useEffect(() => {
setProgramValidity(isProgramValid);
// the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement,
// however this would cause unneeded updates
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [severityIndex]);
return (
<>
<VisualProgrammingUI/>
<button onClick={runProgram} disabled={!programValidity}>run program</button>
<button onClick={runProgram}>Run Program</button>
</>
)
}

View File

@@ -10,7 +10,6 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
import {editorWarningRegistry} from "./components/EditorWarnings.tsx";
import type { FlowState } from './VisProgTypes';
import {
NodeDefaults,
@@ -51,7 +50,7 @@ function createNode(id: string, type: string, position: XYPosition, data: Record
const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode];
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];
// Initial edges, leave empty as setting initial edges...
// ...breaks logic that is dependent on connection events
@@ -342,12 +341,8 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
})
return { ruleRegistry: registry };
})
},
...editorWarningRegistry(get, set),
}
}))
);
export default useFlowStore;

View File

@@ -9,7 +9,6 @@ import type {
OnEdgesDelete,
OnNodesDelete
} from '@xyflow/react';
import type {EditorWarningRegistry} from "./components/EditorWarnings.tsx";
import type {HandleRule} from "./HandleRuleLogic.ts";
import type { NodeTypes } from './NodeRegistry';
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
@@ -95,7 +94,7 @@ export type FlowState = {
* @param node - the Node object to add
*/
addNode: (node: Node) => void;
} & UndoRedoState & HandleRuleRegistry & EditorWarningRegistry;
} & UndoRedoState & HandleRuleRegistry;
export type UndoRedoState = {
// UndoRedo Types
@@ -130,7 +129,4 @@ export type HandleRuleRegistry = {
// cleans up all registered rules of all handles of the provided node
unregisterNodeRules: (nodeId: string) => void
}
}

View File

@@ -1,217 +0,0 @@
/* contains all logic for the VisProgEditor warning system
*
* Missing but desirable features:
* - Warning filtering:
* - if there is no completely connected chain of startNode-[PhaseNodes]-EndNode
* then hide any startNode, phaseNode, or endNode specific warnings
*/
import useFlowStore from "../VisProgStores.tsx";
import type {FlowState} from "../VisProgTypes.tsx";
// --| Type definitions |--
export type WarningId = NodeId | "GLOBAL_WARNINGS";
export type NodeId = string;
export type WarningType =
| 'MISSING_INPUT'
| 'MISSING_OUTPUT'
| 'PLAN_IS_UNDEFINED'
| 'INCOMPLETE_PROGRAM'
| string
export type WarningSeverity =
| 'INFO' // Acceptable, but important to be aware of
| 'WARNING' // Acceptable, but probably undesirable behavior
| 'ERROR' // Prevents running program, should be fixed before running program is allowed
export type WarningScope = {
id: string;
handleId?: string;
}
export type EditorWarning = {
scope: WarningScope;
type: WarningType;
severity: WarningSeverity;
description: string;
};
/**
* a scoped WarningKey,
* the handleId scoping is only needed for handle specific errors
*
* "`WarningType`:`handleId`"
*/
export type WarningKey = string; // for warnings that can occur on a per-handle basis
/**
* a composite key used in the severityIndex
*
* "`WarningId`|`WarningKey`"
*/
export type CompositeWarningKey = string;
export type WarningRegistry = Map<WarningId , Map<WarningKey, EditorWarning>>;
export type SeverityIndex = Map<WarningSeverity, Set<CompositeWarningKey>>;
type ZustandSet = (partial: Partial<FlowState> | ((state: FlowState) => Partial<FlowState>)) => void;
type ZustandGet = () => FlowState;
export type EditorWarningRegistry = {
editorWarningRegistry: WarningRegistry;
severityIndex: SeverityIndex;
getWarnings: () => EditorWarning[];
getWarningsBySeverity: (warningSeverity: WarningSeverity) => EditorWarning[];
/**
* checks if there are no warnings of breaking severity
* @returns {boolean}
*/
isProgramValid: () => boolean;
/**
* registers a warning to the warningRegistry and the SeverityIndex
* @param {EditorWarning} warning
*/
registerWarning: (warning: EditorWarning) => void;
/**
* unregisters a warning from the warningRegistry and the SeverityIndex
* @param {EditorWarning} warning
*/
unregisterWarning: (id: WarningId, warningKey: WarningKey) => void
/**
* unregisters warnings from the warningRegistry and the SeverityIndex
* @param {EditorWarning} warning
*/
unregisterWarningsForId: (id: WarningId) => void;
}
// --| implemented logic |--
export const globalWarning = "GLOBAL_WARNINGS";
export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry { return {
editorWarningRegistry: new Map<NodeId, Map<WarningKey, EditorWarning>>(),
severityIndex: new Map([
['INFO', new Set<CompositeWarningKey>()],
['WARNING', new Set<CompositeWarningKey>()],
['ERROR', new Set<CompositeWarningKey>()],
]),
getWarningsBySeverity: (warningSeverity) => {
const wRegistry = get().editorWarningRegistry;
const sIndex = get().severityIndex;
const warningKeys = sIndex.get(warningSeverity);
const warnings: EditorWarning[] = [];
warningKeys?.forEach(
(compositeKey) => {
const [id, warningKey] = compositeKey.split('|');
const warning = wRegistry.get(id)?.get(warningKey);
if (warning) {
warnings.push(warning);
}
}
)
return warnings;
},
isProgramValid: () => {
const sIndex = get().severityIndex;
return (sIndex.get("ERROR")!.size === 0);
},
getWarnings: () => Array.from(get().editorWarningRegistry.values())
.flatMap(innerMap => Array.from(innerMap.values())),
registerWarning: (warning) => {
const { scope: {id, handleId}, type, severity } = warning;
const warningKey = handleId ? `${type}:${handleId}` : type;
const compositeKey = `${id}|${warningKey}`;
const wRegistry = structuredClone(get().editorWarningRegistry);
const sIndex = structuredClone(get().severityIndex);
console.log("register")
// add to warning registry
if (!wRegistry.has(id)) {
wRegistry.set(id, new Map());
}
wRegistry.get(id)!.set(warningKey, warning);
// add to severityIndex
if (!sIndex.get(severity)!.has(compositeKey)) {
sIndex.get(severity)!.add(compositeKey);
}
set({
editorWarningRegistry: wRegistry,
severityIndex: sIndex
})
},
unregisterWarning: (id, warningKey) => {
const wRegistry = structuredClone(get().editorWarningRegistry);
const sIndex = structuredClone(get().severityIndex);
console.log("unregister")
// verify if the warning was created already
const warning = wRegistry.get(id)?.get(warningKey);
if (!warning) return;
// remove from warning registry
wRegistry.get(id)!.delete(warningKey);
// remove from severityIndex
sIndex.get(warning.severity)!.delete(`${id}|${warningKey}`);
set({
editorWarningRegistry: wRegistry,
severityIndex: sIndex
})
},
unregisterWarningsForId: (id) => {
const wRegistry = structuredClone(get().editorWarningRegistry);
const sIndex = structuredClone(get().severityIndex);
const nodeWarnings = wRegistry.get(id);
// remove from severity index
if (nodeWarnings) {
nodeWarnings.forEach((warning, warningKey) => {
sIndex.get(warning.severity)?.delete(`${id}|${warningKey}`);
});
}
// remove from warning registry
wRegistry.delete(id);
set({
editorWarningRegistry: wRegistry,
severityIndex: sIndex
})
},
}}
// returns a summary of the warningRegistry
export function warningSummary() {
const {severityIndex, isProgramValid} = useFlowStore.getState();
return {
info: severityIndex.get('INFO')!.size,
warning: severityIndex.get('WARNING')!.size,
error: severityIndex.get('ERROR')!.size,
isValid: isProgramValid(),
};
}

View File

@@ -1,82 +0,0 @@
.warnings-sidebar {
width: 320px;
height: 100%;
background: canvas;
border-left: 2px solid black;
display: flex;
flex-direction: column;
}
.warnings-header {
padding: 12px;
border-bottom: 1px solid #2a2a2e;
}
.severity-tabs {
display: flex;
gap: 4px;
}
.severity-tab {
flex: 1;
padding: 4px;
background: ButtonFace;
color: GrayText;
border: none;
cursor: pointer;
}
.count {
padding: 4px;
color: GrayText;
border: none;
cursor: pointer;
}
.severity-tab.active {
color: ButtonText;
border: 2px solid currentColor;
}
.warning-group-header {
background: ButtonFace;
}
.warnings-list {
flex: 1;
overflow-y: auto;
}
.warnings-empty {
margin: auto;
}
.warning-item {
display: flex;
margin: 5px;
gap: 8px;
padding: 8px 12px;
border-radius: 5px;
cursor: pointer;
}
.warning-item:hover {
background: ButtonFace;
}
.warning-item--error {
border: 2px solid red;
}
.warning-item--warning {
border: 3px solid orange;
}
.warning-item--info {
border: 3px solid steelblue;
}
.warning-item .meta {
font-size: 11px;
opacity: 0.6;
}

View File

@@ -1,154 +0,0 @@
import {useReactFlow, useStoreApi} from "@xyflow/react";
import clsx from "clsx";
import {useEffect, useState} from "react";
import useFlowStore from "../VisProgStores.tsx";
import {
warningSummary,
type WarningSeverity,
type EditorWarning, globalWarning
} from "./EditorWarnings.tsx";
import styles from "./WarningSidebar.module.css";
export function WarningsSidebar() {
const warnings = useFlowStore.getState().getWarnings();
const [severityFilter, setSeverityFilter] = useState<WarningSeverity | 'ALL'>('ALL');
useEffect(() => {}, [warnings]);
const filtered = severityFilter === 'ALL'
? warnings
: warnings.filter(w => w.severity === severityFilter);
return (
<aside className={styles.warningsSidebar}>
<WarningsHeader
severityFilter={severityFilter}
onChange={setSeverityFilter}
/>
<WarningsList warnings={filtered} />
</aside>
);
}
function WarningsHeader({
severityFilter,
onChange,
}: {
severityFilter: WarningSeverity | 'ALL';
onChange: (severity: WarningSeverity | 'ALL') => void;
}) {
const summary = warningSummary();
return (
<div className={styles.warningsHeader}>
<h3>Warnings</h3>
<div className={styles.severityTabs}>
{(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => (
<button
key={severity}
className={clsx(styles.severityTab, severityFilter === severity && styles.active)}
onClick={() => onChange(severity)}
>
{severity}
{severity !== 'ALL' && (
<span className={styles.count}>
{summary[severity.toLowerCase() as keyof typeof summary]}
</span>
)}
</button>
))}
</div>
</div>
);
}
function WarningsList(props: { warnings: EditorWarning[] }) {
const splitWarnings = {
global: props.warnings.filter(w => w.scope.id === globalWarning),
other: props.warnings.filter(w => w.scope.id !== globalWarning),
}
if (props.warnings.length === 0) {
return (
<div className={styles.warningsEmpty}>
No warnings!
</div>
)
}
return (
<div>
<div className={"warningGroup"}>
<div className={styles.warningGroupHeader}>global:</div>
<div className={styles.warningsList}>
{splitWarnings.global.map((warning) => (
<WarningListItem warning={warning} />
))}
{splitWarnings.global.length === 0 && "No global warnings!"}
</div>
</div>
<div className={"warningGroup"}>
<div className={styles.warningGroupHeader}>other:</div>
<div className={styles.warningsList}>
{splitWarnings.other.map((warning) => (
<WarningListItem warning={warning} />
))}
{splitWarnings.other.length === 0 && "No other warnings!"}
</div>
</div>
</div>
);
}
function WarningListItem(props: { warning: EditorWarning }) {
const jumpToNode = useJumpToNode();
return (
<div
className={clsx(styles.warningItem, styles[`warning-item--${props.warning.severity.toLowerCase()}`],)}
onClick={() => jumpToNode(props.warning.scope.id)}
>
<div className={styles.description}>
{props.warning.description}
</div>
<div className={styles.meta}>
{props.warning.scope.id}
{props.warning.scope.handleId && (
<span className={styles.handle}>@{props.warning.scope.handleId}</span>
)}
</div>
</div>
);
}
function useJumpToNode() {
const { getNode, setCenter } = useReactFlow();
const { addSelectedNodes } = useStoreApi().getState();
return (nodeId: string) => {
// user can't jump to global warning, so prevent further logic from running
if (nodeId === globalWarning) return;
const node = getNode(nodeId);
if (!node) return;
const { position, width = 0, height = 0} = node;
// move to node
setCenter(
position!.x + width / 2,
position!.y + height / 2,
{ zoom: 2, duration: 300 }
).then(() => {
// select the node
addSelectedNodes([nodeId]);
});
};
}

View File

@@ -32,11 +32,12 @@ export type BasicBeliefNodeData = {
};
// These are all the types a basic belief could be.
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion | Face
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"};
type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"};
type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
type Emotion = { type: "emotion", id: string, value: string, label: "Emotion recognised:"};
type Face = { type: "face", id: string, value: string, label: "Face detected"};
export type BasicBeliefNode = Node<BasicBeliefNodeData>
@@ -113,8 +114,8 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
updateNodeData(props.id, {...data, belief: {...data.belief, description: value}});
}
// These are the labels outputted by our emotion detection model
const emotionOptions = ["sad", "angry", "surprise", "fear", "happy", "disgust", "neutral"];
// Use this
const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"]
let placeholder = ""
@@ -156,6 +157,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
<option value="semantic">Detected with LLM:</option>
<option value="object">Object found:</option>
<option value="emotion">Emotion recognised:</option>
<option value="face">Face detected</option>
</select>
{wrapping}
{data.belief.type === "emotion" && (
@@ -191,7 +193,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
)}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
noMatchingLeftRightBelief,
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]),
]}/>
</div>
</>
@@ -224,6 +226,10 @@ export function BasicBeliefReduce(node: Node, _nodes: Node[]) {
result["name"] = data.belief.value;
result["description"] = data.belief.description;
break;
case "face":
result["face_present"] = true;
break;
default:
break;
}

View File

@@ -1,15 +1,12 @@
import {
type NodeProps,
Position,
type Node, useNodeConnections
type Node,
} from '@xyflow/react';
import {useEffect} from "react";
import type {EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from "../VisProgStores.tsx";
@@ -30,27 +27,6 @@ export type EndNode = Node<EndNodeData>
* @returns React.JSX.Element
*/
export default function EndNode(props: NodeProps<EndNode>) {
const {registerWarning, unregisterWarning} = useFlowStore.getState();
const connections = useNodeConnections({
id: props.id,
handleId: 'target'
})
useEffect(() => {
const noConnectionWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'target'
},
type: 'MISSING_INPUT',
severity: "ERROR",
description: "the endNode does not have an incoming connection from a phaseNode"
}
if (connections.length === 0) { registerWarning(noConnectionWarning); }
else { unregisterWarning(props.id, `${noConnectionWarning.type}:target`); }
}, [connections.length, props.id, registerWarning, unregisterWarning]);
return (
<>
<Toolbar nodeId={props.id} allowDelete={false}/>

View File

@@ -5,7 +5,7 @@ import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx";
* Default data for this node
*/
export const InferredBeliefNodeDefaults: InferredBeliefNodeData = {
label: "Inferred Belief",
label: "AND/OR",
droppable: true,
inferredBelief: {
left: undefined,

View File

@@ -1,10 +1,8 @@
import {
type NodeProps,
Position,
type Node, useNodeConnections
type Node
} from '@xyflow/react';
import {useEffect} from "react";
import type {EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
@@ -43,28 +41,6 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
const label_input_id = `phase_${props.id}_label_input`;
const {registerWarning, unregisterWarning} = useFlowStore.getState();
const connections = useNodeConnections({
id: props.id,
handleType: "target",
handleId: 'data'
})
useEffect(() => {
const noConnectionWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'data'
},
type: 'MISSING_INPUT',
severity: "WARNING",
description: "the phaseNode has no incoming goals, norms, and/or triggers"
}
if (connections.length === 0) { registerWarning(noConnectionWarning); }
else { unregisterWarning(props.id, `${noConnectionWarning.type}:data`); }
}, [connections.length, props.id, registerWarning, unregisterWarning]);
return (
<>
<Toolbar nodeId={props.id} allowDelete={true}/>

View File

@@ -1,15 +1,12 @@
import {
type NodeProps,
Position,
type Node, useNodeConnections
type Node,
} from '@xyflow/react';
import {useEffect} from "react";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {type EditorWarning} from "../components/EditorWarnings.tsx";
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
import useFlowStore from "../VisProgStores.tsx";
export type StartNodeData = {
@@ -28,27 +25,6 @@ export type StartNode = Node<StartNodeData>
* @returns React.JSX.Element
*/
export default function StartNode(props: NodeProps<StartNode>) {
const {registerWarning, unregisterWarning} = useFlowStore.getState();
const connections = useNodeConnections({
id: props.id,
handleId: 'source'
})
useEffect(() => {
const noConnectionWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'source'
},
type: 'MISSING_OUTPUT',
severity: "ERROR",
description: "the startNode does not have an outgoing connection to a phaseNode"
}
if (connections.length === 0) { registerWarning(noConnectionWarning); }
else { unregisterWarning(props.id, `${noConnectionWarning.type}:source`); }
}, [connections.length, props.id, registerWarning, unregisterWarning]);
return (
<>
<Toolbar nodeId={props.id} allowDelete={false}/>

View File

@@ -72,7 +72,7 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
id="TriggerBeliefs"
style={{ left: '40%' }}
rules={[
allowOnlyConnectionsFromType(["basic_belief","inferred_belief"]),
allowOnlyConnectionsFromType(['basic_belief']),
]}
/>
@@ -136,7 +136,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string)
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode) return;
if (['basic_belief', 'inferred_belief'].includes(otherNode.type!)) {
if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) {
data.condition = _sourceNodeId;
}

View File

@@ -1,3 +0,0 @@
export default function (s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}

View File

@@ -1,7 +0,0 @@
export default async function <T>(promise: Promise<T>, minDelayMs: number): Promise<T> {
const [result] = await Promise.all([
promise,
new Promise(resolve => setTimeout(resolve, minDelayMs))
]);
return result;
}

View File

@@ -4,7 +4,7 @@ export type PriorityFilterPredicate<T> = {
}
/**
* Applies a list of priority predicates to an element. For all predicates that don't return null, if the ones with the highest level return true, then this function returns true. Or conversely, if the one with the highest level returns false, then this function returns false.
* Applies a list of priority predicates to an element. For all predicates that don't return null, if the ones with the highest level return true, then this function returns true.
* @param element The element to apply the predicates to.
* @param predicates The list of predicates to apply.
*/

View File

@@ -85,9 +85,8 @@ const useProgramStore = create<ProgramState>((set, get) => ({
const rootGoals = phase["goals"] as Record<string, unknown>[];
const flatList: GoalWithDepth[] = [];
// Helper: Define this ONCE, outside the loop
const isGoal = (item: Record<string, unknown>) => {
return item["plan"] !== undefined && item["plan"] !== null;
return item["plan"] !== undefined;
};
// Recursive helper function

View File

@@ -11,6 +11,8 @@ const loggingStoreRef: { current: null | { setState: (state: Partial<LoggingSett
type LoggingSettingsState = {
showRelativeTime: boolean;
setShowRelativeTime: (show: boolean) => void;
scrollToBottom: boolean;
setScrollToBottom: (scroll: boolean) => void;
};
jest.mock("zustand", () => {
@@ -57,8 +59,8 @@ type LoggingComponent = typeof import("../../../src/components/Logging/Logging.t
let Logging: LoggingComponent;
beforeAll(async () => {
if (!Element.prototype.scrollTo) {
Object.defineProperty(Element.prototype, "scrollTo", {
if (!Element.prototype.scrollIntoView) {
Object.defineProperty(Element.prototype, "scrollIntoView", {
configurable: true,
writable: true,
value: function () {},
@@ -82,6 +84,7 @@ afterEach(() => {
function resetLoggingStore() {
loggingStoreRef.current?.setState({
showRelativeTime: false,
scrollToBottom: true,
});
}
@@ -148,7 +151,7 @@ describe("Logging component", () => {
];
mockUseLogs.mockReturnValue({filteredLogs: logs, distinctNames: new Set()});
const scrollSpy = jest.spyOn(Element.prototype, "scrollTo").mockImplementation(() => {});
const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {});
const user = userEvent.setup();
const view = render(<Logging/>);
@@ -172,7 +175,7 @@ describe("Logging component", () => {
const logCell = makeCell({message: "Initial", firstRelativeCreated: 42});
mockUseLogs.mockReturnValue({filteredLogs: [logCell], distinctNames: new Set()});
const scrollSpy = jest.spyOn(Element.prototype, "scrollTo").mockImplementation(() => {});
const scrollSpy = jest.spyOn(Element.prototype, "scrollIntoView").mockImplementation(() => {});
render(<Logging/>);
await waitFor(() => {
@@ -206,7 +209,7 @@ describe("Logging component", () => {
const initialMap = firstProps.filterPredicates;
expect(initialMap).toBeInstanceOf(Map);
expect(initialMap.size).toBe(1); // Initially, only filter out experiment logs
expect(initialMap.size).toBe(0);
expect(mockUseLogs).toHaveBeenCalledWith(initialMap);
const updatedPredicate: LogFilterPredicate = {

View File

@@ -51,6 +51,7 @@ describe('MonitoringPage', () => {
const mockGetPhaseNames = jest.fn();
const mockGetNorms = jest.fn();
const mockGetGoals = jest.fn();
const mockGetGoalsWithDepth = jest.fn();
const mockGetTriggers = jest.fn();
const mockSetProgramState = jest.fn();
@@ -65,6 +66,7 @@ describe('MonitoringPage', () => {
getNormsInPhase: mockGetNorms,
getGoalsInPhase: mockGetGoals,
getTriggersInPhase: mockGetTriggers,
getGoalsWithDepth: mockGetGoalsWithDepth,
setProgramState: mockSetProgramState,
};
return selector(state);
@@ -81,7 +83,11 @@ describe('MonitoringPage', () => {
// Default mock return values
mockGetPhaseIds.mockReturnValue(['phase-1', 'phase-2']);
mockGetPhaseNames.mockReturnValue(['Intro', 'Main']);
mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1' }, { id: 'g2', name: 'Goal 2' }]);
mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1'}, { id: 'g2', name: 'Goal 2'}]);
mockGetGoalsWithDepth.mockReturnValue([
{ id: 'g1', name: 'Goal 1', level: 0 },
{ id: 'g2', name: 'Goal 2', level: 0 }
]);
mockGetTriggers.mockReturnValue([{ id: 't1', name: 'Trigger 1' }]);
mockGetNorms.mockReturnValue([
{ id: 'n1', norm: 'Norm 1', condition: null },

View File

@@ -1,8 +1,7 @@
import { renderHook, act, cleanup } from '@testing-library/react';
import {
sendAPICall,
nextPhase,
resetPhase,
nextPhase,
pauseExperiment,
playExperiment,
useExperimentLogger,
@@ -116,14 +115,6 @@ describe('MonitoringPageAPI', () => {
);
});
test('resetPhase sends correct params', async () => {
await resetPhase();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'reset_phase', context: '' }) })
);
});
test('pauseExperiment sends correct params', async () => {
await pauseExperiment();
expect(globalThis.fetch).toHaveBeenCalledWith(

View File

@@ -105,6 +105,8 @@ describe("SaveLoadPanel - combined tests", () => {
});
test("onLoad with invalid JSON does not update store", async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const file = new File(["not json"], "bad.json", { type: "application/json" });
file.text = jest.fn(() => Promise.resolve(`{"bad json`));
@@ -112,20 +114,19 @@ describe("SaveLoadPanel - combined tests", () => {
render(<SaveLoadPanel />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeTruthy();
// Give some input
act(() => {
fireEvent.change(input, { target: { files: [file] } });
});
await waitFor(() => {
expect(window.alert).toHaveBeenCalledTimes(1);
const nodesAfter = useFlowStore.getState().nodes;
expect(nodesAfter).toHaveLength(0);
expect(input.value).toBe("");
});
// Clean up the spy
consoleSpy.mockRestore();
});
test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => {

View File

@@ -1,34 +0,0 @@
import capitalize from "../../src/utils/capitalize.ts";
describe('capitalize', () => {
it('capitalizes the first letter of a lowercase word', () => {
expect(capitalize('hello')).toBe('Hello');
});
it('keeps the first letter capitalized if already uppercase', () => {
expect(capitalize('Hello')).toBe('Hello');
});
it('handles single character strings', () => {
expect(capitalize('a')).toBe('A');
expect(capitalize('A')).toBe('A');
});
it('returns empty string for empty input', () => {
expect(capitalize('')).toBe('');
});
it('only capitalizes the first letter, leaving the rest unchanged', () => {
expect(capitalize('hELLO')).toBe('HELLO');
expect(capitalize('hello world')).toBe('Hello world');
});
it('handles strings starting with numbers', () => {
expect(capitalize('123abc')).toBe('123abc');
});
it('handles strings starting with special characters', () => {
expect(capitalize('!hello')).toBe('!hello');
expect(capitalize(' hello')).toBe(' hello');
});
});

View File

@@ -1,77 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import delayedResolve from "../../src/utils/delayedResolve.ts";
describe('delayedResolve', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('returns the resolved value of the promise', async () => {
const resultPromise = delayedResolve(Promise.resolve('hello'), 100);
await jest.advanceTimersByTimeAsync(100);
expect(await resultPromise).toBe('hello');
});
it('waits at least minDelayMs before resolving', async () => {
let resolved = false;
const resultPromise = delayedResolve(Promise.resolve('fast'), 100);
resultPromise.then(() => { resolved = true; });
await jest.advanceTimersByTimeAsync(50);
expect(resolved).toBe(false);
await jest.advanceTimersByTimeAsync(50);
expect(resolved).toBe(true);
});
it('resolves immediately after slow promise if it exceeds minDelayMs', async () => {
let resolved = false;
const slowPromise = new Promise<string>(resolve =>
setTimeout(() => resolve('slow'), 150)
);
const resultPromise = delayedResolve(slowPromise, 50);
resultPromise.then(() => { resolved = true; });
await jest.advanceTimersByTimeAsync(50);
expect(resolved).toBe(false);
await jest.advanceTimersByTimeAsync(100);
expect(resolved).toBe(true);
expect(await resultPromise).toBe('slow');
});
it('propagates rejections from the promise', async () => {
const error = new Error('test error');
const rejectedPromise = Promise.reject(error);
const resultPromise = delayedResolve(rejectedPromise, 100);
const assertion = expect(resultPromise).rejects.toThrow('test error');
await jest.advanceTimersByTimeAsync(100);
await assertion;
});
it('works with different value types', async () => {
const test = async <T>(value: T) => {
const resultPromise = delayedResolve(Promise.resolve(value), 10);
await jest.advanceTimersByTimeAsync(10);
return resultPromise;
};
expect(await test(42)).toBe(42);
expect(await test({ foo: 'bar' })).toEqual({ foo: 'bar' });
expect(await test([1, 2, 3])).toEqual([1, 2, 3]);
expect(await test(null)).toBeNull();
});
it('handles zero delay', async () => {
const resultPromise = delayedResolve(Promise.resolve('instant'), 0);
await jest.advanceTimersByTimeAsync(0);
expect(await resultPromise).toBe('instant');
});
});

View File

@@ -115,6 +115,89 @@ describe('useProgramStore', () => {
});
});
describe('getGoalsWithDepth', () => {
const complexProgram: ReducedProgram = {
phases: [
{
id: 'phase-nested',
goals: [
// Level 0: Root Goal 1
{
id: 'root-1',
name: 'Root Goal 1',
plan: {
steps: [
// This is an ACTION (no plan), should be ignored
{ id: 'action-1', type: 'speech' },
// Level 1: Child Goal
{
id: 'child-1',
name: 'Child Goal',
plan: {
steps: [
// Level 2: Grandchild Goal
{
id: 'grandchild-1',
name: 'Grandchild',
plan: { steps: [] } // Empty plan is still a plan
}
]
}
}
]
}
},
// Level 0: Root Goal 2 (Sibling)
{
id: 'root-2',
name: 'Root Goal 2',
plan: { steps: [] }
}
]
}
]
};
it('should flatten nested goals and assign correct depth levels', () => {
useProgramStore.getState().setProgramState(complexProgram);
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
// logic: Root 1 -> Child 1 -> Grandchild 1 -> Root 2
expect(goals).toHaveLength(4);
// Check Root 1
expect(goals[0]).toEqual(expect.objectContaining({ id: 'root-1', level: 0 }));
// Check Child 1
expect(goals[1]).toEqual(expect.objectContaining({ id: 'child-1', level: 1 }));
// Check Grandchild 1
expect(goals[2]).toEqual(expect.objectContaining({ id: 'grandchild-1', level: 2 }));
// Check Root 2
expect(goals[3]).toEqual(expect.objectContaining({ id: 'root-2', level: 0 }));
});
it('should ignore steps that are not goals (missing "plan" property)', () => {
useProgramStore.getState().setProgramState(complexProgram);
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
// The 'action-1' object should NOT be in the list
const action = goals.find(g => g.id === 'action-1');
expect(action).toBeUndefined();
});
it('throws if phase does not exist', () => {
useProgramStore.getState().setProgramState(complexProgram);
expect(() =>
useProgramStore.getState().getGoalsWithDepth('missing-phase')
).toThrow('phase with id:"missing-phase" not found');
});
});
it('should return the names of all phases in the program', () => {
// Define a program specifically with names for this test
const programWithNames: ReducedProgram = {