diff --git a/docs/dashboards.md b/docs/dashboards.md
index baa271f7..b003941e 100644
--- a/docs/dashboards.md
+++ b/docs/dashboards.md
@@ -6,7 +6,7 @@ We can create custom dashboards for the system.
Dash is a package for writing and serving web applications directly in Python. In Dash, there are 2 primary items, 1) page components such as headers, divs, spans, etc. and 2) callbacks.
-### Getting started
+### Getting Started with Dash
Page components can be set up similar to other `html` layouts, like so
@@ -180,3 +180,38 @@ DASH_PAGES = [
}
]
```
+
+## NextJS
+
+NextJS is web framework for building React-based web applications along with additional server-side functionality.
+
+### Getting Started with NextJS
+
+Follow the [Getting Started Guide](https://nextjs.org/docs/app/getting-started) in the official NextJS documentation.
+
+### NextJS in the Learning Observer
+
+Before add a NextJS application can be built and added to the system, a few configurations changes need to be made. The built application will not access the server-side code. Any server-side API endpoints need to be implemented in Python. The code that calls these endpoints will need to be updated to point to the correct path.
+
+Additionally, we need to add a `basePath` to our `next.config.js` file. When building the application, this replaces prefixes all paths with the defined base path. This allows links to function appropriately while being served from Learning Observer.
+
+```js
+const nextConfig = {
+ // ... the rest of your config
+ basePath: '/_next/moduleName/pathOfBuiltApplication'
+```
+
+To add a NextJS project to a module, first the code needs to be built with `npm run build`. A directory named `out` will be created and the built application will be placed there. Copy this directoy to a module within Learning Observer.
+
+Next, add the path to the built application to the module's `module.py` file
+
+```python
+# module.py
+# ...other definitions
+'''
+Next js dashboards
+'''
+NEXTJS_PAGES = [
+ {'path': 'pathOfBuiltApplication/'}
+]
+```
diff --git a/learning_observer/learning_observer/routes.py b/learning_observer/learning_observer/routes.py
index 2a49cd77..5034b81b 100644
--- a/learning_observer/learning_observer/routes.py
+++ b/learning_observer/learning_observer/routes.py
@@ -451,7 +451,11 @@ def register_extra_views(app):
def create_nextjs_handler(path):
async def _nextjs_handler(request):
- return aiohttp.web.FileResponse(os.path.join(path, 'index.html'))
+ sub_url = request.match_info.get('tail')
+ if sub_url is None:
+ return aiohttp.web.FileResponse(os.path.join(path, 'index.html'))
+ # TODO will this handle multi-layered sub-urls? /foo/bar
+ return aiohttp.web.FileResponse(os.path.join(path, f'{sub_url}.html'))
return _nextjs_handler
@@ -467,6 +471,9 @@ def register_nextjs_routes(app):
static_path = f'/_next{page_path}_next/static/'
app.router.add_static(static_path, os.path.join(full_path, '_next', 'static'))
app.router.add_get(page_path, create_nextjs_handler(full_path))
+ # The tail path handles suburls
+ tail_path = page_path + '{tail:.*}'
+ app.router.add_get(tail_path, create_nextjs_handler(full_path))
def register_wsgi_routes(app):
diff --git a/modules/lo_event/lo_event/lo_assess/components/buildConfig.js b/modules/lo_event/lo_event/lo_assess/components/buildConfig.js
index 2cd7678e..7b56dc57 100644
--- a/modules/lo_event/lo_event/lo_assess/components/buildConfig.js
+++ b/modules/lo_event/lo_event/lo_assess/components/buildConfig.js
@@ -9,7 +9,7 @@ export function getTemplatePath(templateFileName) {
const _baseConfig = {
loa: {
- jsPattern: './lo_event/lo_assess/components/**/[A-Z]*.jsx',
+ jsPattern: './lo_event/lo_assess/components/**/{[A-Z]*,use[A-Z]*}.jsx',
xmlPattern: './lo_event/lo_assess/components/**/[A-Z]*.xml',
componentsFile: "./lo_event/lo_assess/components/components.jsx",
},
diff --git a/modules/lo_event/lo_event/lo_assess/components/buildlib.js b/modules/lo_event/lo_event/lo_assess/components/buildlib.js
index 8a84c092..29985771 100644
--- a/modules/lo_event/lo_event/lo_assess/components/buildlib.js
+++ b/modules/lo_event/lo_event/lo_assess/components/buildlib.js
@@ -28,7 +28,7 @@ function parsePath(path) {
const lastSlashIndex = path.lastIndexOf('/');
const fileName = path.substring(lastSlashIndex + 1).split('.')[0];
- const componentName = fileName.charAt(0).toUpperCase() + fileName.slice(1);
+ const componentName = fileName;
const fullPathWithoutExtension = path.split('.').slice(0, -1).join('.');
const jsxFullPath = fullPathWithoutExtension + ".jsx";
const xmlFullPath = fullPathWithoutExtension + ".xml";
diff --git a/modules/lo_event/lo_event/lo_assess/components/constants/LO_CONNECTION_STATUS.jsx b/modules/lo_event/lo_event/lo_assess/components/constants/LO_CONNECTION_STATUS.jsx
new file mode 100644
index 00000000..92179bcd
--- /dev/null
+++ b/modules/lo_event/lo_event/lo_assess/components/constants/LO_CONNECTION_STATUS.jsx
@@ -0,0 +1,7 @@
+export const LO_CONNECTION_STATUS = {
+ UNINSTANTIATED: -1,
+ CONNECTING: 0,
+ OPEN: 1,
+ CLOSING: 2,
+ CLOSED: 3
+}
diff --git a/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx b/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx
new file mode 100644
index 00000000..1c9bbb00
--- /dev/null
+++ b/modules/lo_event/lo_event/lo_assess/components/utilities/LOConnectionLastUpdated.jsx
@@ -0,0 +1,69 @@
+/**
+ * LOConnectionLastUpdated is a helper function for displaying
+ * connection information and last received message information
+ * about a websocket.
+ *
+ * Usage:
+ * ```js
+ * const { connectionStatus, message, } = LOConnection({ url, dataScope }); // or some other websocket
+ * return ( );
+ * ```
+ */
+import React from 'react';
+import { useState, useEffect } from 'react';
+import { LO_CONNECTION_STATUS } from '../constants/LO_CONNECTION_STATUS';
+import { renderTime } from '../../../util';
+
+function renderReadableTimeSinceUpdate (timeDifference) {
+ if (timeDifference < 5) {
+ return 'Just now';
+ }
+ return `${renderTime(timeDifference)} ago`;
+}
+
+export const LOConnectionLastUpdated = ({ message, connectionStatus, showText=false }) => {
+ const [lastUpdated, setLastUpdated] = useState(null);
+ const [lastUpdatedMessage, setLastUpdatedMessage] = useState('');
+
+ const icons = {
+ [LO_CONNECTION_STATUS.UNINSTANTIATED]: 'fas fa-circle',
+ [LO_CONNECTION_STATUS.CONNECTING]: 'fas fa-sync-alt',
+ [LO_CONNECTION_STATUS.OPEN]: 'fas fa-check text-success',
+ [LO_CONNECTION_STATUS.CLOSING]: 'fas fa-sync-alt',
+ [LO_CONNECTION_STATUS.CLOSED]: 'fas fa-times text-danger'
+ };
+ const titles = {
+ [LO_CONNECTION_STATUS.UNINSTANTIATED]: 'Uninstantiated',
+ [LO_CONNECTION_STATUS.CONNECTING]: 'Connecting to server',
+ [LO_CONNECTION_STATUS.OPEN]: 'Connected to server',
+ [LO_CONNECTION_STATUS.CLOSING]: 'Closing connection',
+ [LO_CONNECTION_STATUS.CLOSED]: 'Disconnected from server'
+ };
+
+ // Set last updated time when new message arrives
+ useEffect(() => {
+ if (message) {
+ setLastUpdated(new Date());
+ }
+ }, [message]);
+
+ // Every second update last updated message
+ useEffect(() => {
+ const interval = setInterval(() => {
+ if (lastUpdated) {
+ const now = new Date();
+ const timeDifference = Math.floor((now - lastUpdated) / 1000); // Time difference in seconds
+ setLastUpdatedMessage(renderReadableTimeSinceUpdate(timeDifference));
+ } else { setLastUpdatedMessage('Never'); }
+ }, 1000);
+ return () => clearInterval(interval); // Cleanup interval on unmount
+ }, [lastUpdated]);
+
+ return (
+
+ );
+};
diff --git a/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnection.jsx b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnection.jsx
new file mode 100644
index 00000000..c6f65caf
--- /dev/null
+++ b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnection.jsx
@@ -0,0 +1,116 @@
+/**
+ * useLOConnection is a websocket hook used for connecting to
+ * the communication protocl on Learning Observer.
+ *
+ * The server expects some data before it will start sending messages.
+ * When the connection opens, useLOConnection will send the `dataScope`,
+ * if available, to initiate receiving messages from the server.
+ * Otherwise, users should use the `sendMessage` function to provide
+ * data to LO.
+ *
+ * useLOConnection exposes the following items:
+ * - `sendMessage`: function to send messages to the server
+ * - `message`: the most recent message received
+ * - `error`: any errors that occured
+ * - `connectionStatus`: the current status of the websocket connection
+ * - `openConnection`: function that opens the connection when called
+ * - `closeConnection`: function that closes the connection when called
+ */
+import React from 'react';
+import { useEffect, useRef, useState } from 'react';
+import { LO_CONNECTION_STATUS } from '../constants/LO_CONNECTION_STATUS';
+
+export const useLOConnection = ({
+ url, dataScope
+}) => {
+ const [connectionStatus, setConnectionStatus] = useState(LO_CONNECTION_STATUS.CLOSED);
+ const [message, setMessage] = useState(null);
+ const [error, setError] = useState(null);
+ const clientRef = useRef(null);
+
+ // Function to open the WebSocket connection
+ const openConnection = () => {
+ // Prevent opening a new connection if one is already open or connecting
+ if (clientRef.current && (clientRef.current.readyState === LO_CONNECTION_STATUS.OPEN || clientRef.current.readyState === LO_CONNECTION_STATUS.CONNECTING)) {
+ console.warn("WebSocket connection is already open or in progress.");
+ return;
+ }
+
+ const protocol = { 'http:': 'ws:', 'https:': 'wss:' }[window.location.protocol];
+ const newUrl = url || `${protocol}//${window.location.hostname}:${window.location.port}/wsapi/communication_protocol`;
+ const client = new WebSocket(newUrl);
+ clientRef.current = client;
+
+ client.onopen = () => {
+ setConnectionStatus(LO_CONNECTION_STATUS.OPEN);
+ setError(null); // Clear any previous errors upon a successful connection
+ if (typeof dataScope !== 'undefined') {
+ client.send(JSON.stringify(dataScope));
+ }
+ };
+
+ client.onmessage = (event) => {
+ setMessage(event.data);
+ };
+
+ client.onerror = (event) => {
+ setError(event.message);
+ };
+
+ client.onclose = () => {
+ setConnectionStatus(LO_CONNECTION_STATUS.CLOSED);
+ };
+ };
+
+ // Function to close the WebSocket connection manually
+ const closeConnection = () => {
+ if (clientRef.current && connectionStatus === LO_CONNECTION_STATUS.OPEN) {
+ clientRef.current.close();
+ clientRef.current = null;
+ } else {
+ console.warn("WebSocket is not open; no connection to close.");
+ }
+ };
+
+ // Automatically attempt to open connection on mount
+ useEffect(() => {
+ openConnection();
+
+ // Cleanup on unmount
+ return () => {
+ closeConnection();
+ };
+ }, [url]); // Include `url` as a dependency in case it changes and requires a reconnection
+
+ const messageQueue = [];
+
+ // Send any messages on the queue
+ const processQueue = () => {
+ while (messageQueue.length > 0) {
+ if (clientRef.current && connectionStatus === LO_CONNECTION_STATUS.OPEN) {
+ const message = messageQueue.shift();
+ clientRef.current.send(message);
+ } else {
+ break;
+ }
+ }
+ };
+
+ // Start processing the queue when the connection opens
+ useEffect(() => {
+ if (connectionStatus === LO_CONNECTION_STATUS.OPEN) {
+ processQueue();
+ }
+ }, [connectionStatus]);
+
+ // Function to send a message via WebSocket
+ const sendMessage = (message) => {
+ if (clientRef.current && connectionStatus === LO_CONNECTION_STATUS.OPEN) {
+ clientRef.current.send(message);
+ } else {
+ messageQueue.push(message);
+ }
+ };
+
+ return { sendMessage, message, error, connectionStatus, openConnection, closeConnection };
+};
diff --git a/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnectionDataManager.jsx b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnectionDataManager.jsx
new file mode 100644
index 00000000..1bcb1a2b
--- /dev/null
+++ b/modules/lo_event/lo_event/lo_assess/components/utilities/useLOConnectionDataManager.jsx
@@ -0,0 +1,134 @@
+/**
+ * useLOConnectionDataManager handles storing and processing incoming
+ * messages from the communication protocol websocket connection.
+ * This hook wraps the useLOConnection hook to fetch updates.
+ * The communication protocol sends batches of updates to apply
+ * to a clientside data object.
+ *
+ * When the internal data is updated, we call the `onDataUpdate`
+ * parameter so parents can update accordingly.
+ *
+ * useLOConnectionDataManager exposes the following items:
+ * - `data`: current overall data received from websocket messages
+ * - `errors`: information about any errors received
+ * - `connection`: all returned items from useLOConnection
+ *
+ * Usage:
+ * ```js
+ const { data, errors, sendMessage } = useLOConnectionDataManager({ url, dataScope });
+
+ return (
+
+
+
User Data
+ {Object.keys(data).length > 0 ? (
+
{JSON.stringify(data, null, 2)}
+ ) : (
+
No user data available.
+ )}
+
+ {Object.keys(errors).length > 0 && (
+
+
Errors
+
{JSON.stringify(errors, null, 2)}
+
+ )}
+
+ );
+ * ```
+ */
+import { useReducer, useEffect } from 'react';
+import { useLOConnection } from './useLOConnection'; // Assuming LOConnection is renamed to useLOConnection
+
+// Reducer function for managing state updates
+const dataReducer = (state, action) => {
+ switch (action.type) {
+ case 'update': {
+ const { path, value } = action.payload;
+ const pathKeys = path.split('.');
+
+ // Create a new `data` object with the updated value at the correct path
+ const newData = { ...state.data };
+ let current = newData; // Start at the top-level copy
+ for (let i = 0; i < pathKeys.length - 1; i++) {
+ const key = pathKeys[i];
+ if (!(key in current)) {
+ current[key] = {}; // Create path if it doesn't exist
+ } else {
+ current[key] = { ...current[key] }; // Copy the existing nested object
+ }
+ current = current[key];
+ }
+
+ const finalKey = pathKeys[pathKeys.length - 1];
+ // TODO this doesn't handle a deep merge
+ current[finalKey] = {
+ ...current[finalKey], // Existing data
+ ...value, // New data (overwrites where necessary)
+ };
+
+ return {
+ ...state,
+ data: newData,
+ };
+ }
+ case 'error': {
+ const { path, value } = action.payload;
+ return {
+ ...state,
+ errors: {
+ ...state.errors,
+ [path]: value,
+ },
+ };
+ }
+ case 'clearError': {
+ const { path } = action.payload;
+ const newErrors = { ...state.errors };
+ delete newErrors[path];
+ return {
+ ...state,
+ errors: newErrors,
+ };
+ }
+ default:
+ console.warn(`Unhandled action type: ${action.type}`);
+ return state;
+ }
+};
+
+// Initial state for the reducer
+const initialState = {
+ data: {},
+ errors: {},
+};
+
+export const useLOConnectionDataManager = ({ url, dataScope }) => {
+ const { message, ...connection } = useLOConnection({ url, dataScope });
+ const [state, dispatch] = useReducer(dataReducer, initialState);
+
+ useEffect(() => {
+ if (message) {
+ try {
+ const messages = JSON.parse(message);
+
+ messages.forEach((msg) => {
+ if ('error' in msg.value) {
+ dispatch({ type: 'error', payload: { path: msg.path, value: msg.value } });
+ } else {
+ dispatch({ type: 'clearError', payload: { path: msg.path } });
+ dispatch({ type: msg.op, payload: { path: msg.path, value: msg.value } });
+ }
+ });
+ } catch (e) {
+ console.error('Failed to parse incoming message:', e);
+ }
+ }
+ }, [message]);
+
+ return {
+ connection,
+ data: state.data,
+ errors: state.errors,
+ };
+};
diff --git a/modules/lo_event/lo_event/util.js b/modules/lo_event/lo_event/util.js
index ee53636f..b74e7b86 100644
--- a/modules/lo_event/lo_event/util.js
+++ b/modules/lo_event/lo_event/util.js
@@ -426,3 +426,34 @@ export function dispatchCustomEvent(eventName, detail) {
console.warn("Event dispatching is not supported in this environment.");
}
}
+
+/**
+ * Convert seconds to a time string.
+ *
+ * Compact representation.
+ * 10 ==> 10s
+ * 125 ==> 2m
+ * 3600 ==> 1h
+ * 7601 ==> 2h
+ * 764450 ==> 8d
+ */
+export function renderTime (t) {
+ const seconds = Math.floor(t) % 60;
+ const minutes = Math.floor(t / 60) % 60;
+ const hours = Math.floor(t / 3600) % 60;
+ const days = Math.floor(t / 3600 / 24);
+
+ if (days > 0) {
+ return String(days) + 'd';
+ }
+ if (hours > 0) {
+ return String(hours) + 'h';
+ }
+ if (minutes > 0) {
+ return String(minutes) + 'm';
+ }
+ if (seconds > 0) {
+ return String(seconds) + 's';
+ }
+ return '-';
+}
diff --git a/modules/toy-assess/lo_event.sh b/modules/toy-assess/lo_event.sh
index 47a29fdf..4efe395b 100755
--- a/modules/toy-assess/lo_event.sh
+++ b/modules/toy-assess/lo_event.sh
@@ -11,4 +11,4 @@ pushd ../lo_event/
npm run prebuild
npm pack
popd
-npm install ../lo_event/lo_event-0.0.2.tgz --no-save
+npm install ../lo_event/lo_event-0.0.3.tgz --no-save
diff --git a/modules/toy-assess/next.config.js b/modules/toy-assess/next.config.js
index 5d65a05a..f7e13b93 100644
--- a/modules/toy-assess/next.config.js
+++ b/modules/toy-assess/next.config.js
@@ -11,7 +11,7 @@ const nextConfig = {
crypto: 'crypto',
ws: 'ws',
'indexeddb-js': 'indexeddb-js'
- })
+ });
config.module.rules.push({
test: /\.jsx?$/, // This regex matches both .js and .jsx files
include: [/node_modules\/lo_event/], // Include only the lo_event module
@@ -19,12 +19,18 @@ const nextConfig = {
loader: 'babel-loader',
options: {
// Add your babel presets here (e.g., @babel/preset-react)
- presets: ['@babel/preset-react'], // Assuming you use React
+ presets: ['@babel/preset-react'] // Assuming you use React
},
},
- })
- return config
+ });
+ return config;
},
-}
+ eslint: {
+ ignoreDuringBuilds: true,
+ },
+ output: 'export',
+ // TODO this is only needed when building for use within LO
+ // basePath: '/_next/learning_observer_template/toy_assess'
+};
-module.exports = nextConfig
+module.exports = nextConfig;
diff --git a/modules/toy-assess/package.json b/modules/toy-assess/package.json
index 8834e660..3d749bb6 100644
--- a/modules/toy-assess/package.json
+++ b/modules/toy-assess/package.json
@@ -16,7 +16,7 @@
"bufferutil": "^4.0.8",
"indexeddb-js": "^0.0.14",
"jasmine": "^5.1.0",
- "lo_event": "file:../lo_event/lo_event-0.0.2.tgz",
+ "lo_event": "file:../lo_event/lo_event-0.0.3.tgz",
"mapbox": "^1.0.0-beta10",
"mock-aws-s3": "^4.0.2",
"next": "13.5.5",
diff --git a/modules/toy-assess/src/app/changer/page.js b/modules/toy-assess/src/app/changer/page.js
index dd529bf4..e5c6e7a0 100644
--- a/modules/toy-assess/src/app/changer/page.js
+++ b/modules/toy-assess/src/app/changer/page.js
@@ -1,4 +1,4 @@
-// This is a little demo which changes the style of text
+// This is a little demo that retypes text as different characters.
'use client';
// @refresh reset
diff --git a/modules/toy-assess/src/app/lib/azureInterface.js b/modules/toy-assess/src/app/lib/azureInterface.js
index ce09c45d..33e317cd 100644
--- a/modules/toy-assess/src/app/lib/azureInterface.js
+++ b/modules/toy-assess/src/app/lib/azureInterface.js
@@ -4,7 +4,7 @@ const { OpenAIClient, AzureKeyCredential } = require("@azure/openai");
const resource=await process.env.OPENAI_URL;
const deploymentID=process.env.OPENAI_DEPLOYMENT_ID;
-const key = await process.env.OPENAI_API_KEY;
+const key = await process.env.OPENAI_API_KEY || 'EMPTY';
console.log("In azureInterface");
console.log("key: " + key);
diff --git a/modules/toy-assess/src/app/websocket/page.js b/modules/toy-assess/src/app/websocket/page.js
new file mode 100644
index 00000000..0453bee7
--- /dev/null
+++ b/modules/toy-assess/src/app/websocket/page.js
@@ -0,0 +1,56 @@
+// Demo to show the Websocket
+
+'use client';
+// @refresh reset
+
+// We need to import this to call init() for now
+import { } from '../components.js';
+
+import React, { useState, useEffect } from 'react';
+import { LOConnectionLastUpdated, useLOConnectionDataManager, LO_CONNECTION_STATUS, Button } from 'lo_event/lo_event/lo_assess/components/components.jsx';
+
+export default function Home ({ children }) {
+ const decoded = {};
+ decoded.course_id = '123456';
+ const dataScope = {
+ wo: {
+ execution_dag: 'writing_observer',
+ target_exports: ['docs_with_nlp_annotations'],
+ kwargs: decoded
+ }
+ };
+
+ const { data, errors, connection } = useLOConnectionDataManager({ url: 'ws://localhost:8888/wsapi/communication_protocol', dataScope });
+ return (
+