Spaces:
Sleeping
Sleeping
NeonClary Cursor commited on
Commit ·
808ab9a
1
Parent(s): 51239c8
Add Decidron network simulator
Browse filesBackend: deterministic decidron engine (models, network, engine, stats,
diagrams) under backend/app/decidron with a REST router at
/api/decidron; networkx dependency and patent-shaped seed network +
sample commands.
Frontend: DecidronSimulator view (vis-network diagram, sensor input,
command builder, results, stats, history) wired into App.js; api.js
rewritten for the Decidron endpoints; vis-network/vis-data added.
Docs/config: full README (patent context, start, UI workflow, command
schema, API curl reference, troubleshooting); trimmed .env.example and
docker-compose to the deterministic simulator.
Co-authored-by: Cursor <cursoragent@cursor.com>
- .env.example +9 -23
- .gitignore +1 -0
- README.md +214 -113
- backend/app/api/decidron.py +88 -0
- backend/app/data/decidron/default_network.json +16 -0
- backend/app/data/decidron/sample_commands.json +20 -0
- backend/app/decidron/__init__.py +7 -0
- backend/app/decidron/diagrams.py +48 -0
- backend/app/decidron/engine.py +207 -0
- backend/app/decidron/models.py +106 -0
- backend/app/decidron/network.py +101 -0
- backend/app/decidron/stats.py +62 -0
- backend/app/main.py +4 -0
- backend/requirements.txt +1 -0
- docker-compose.yml +0 -1
- frontend/package-lock.json +93 -1
- frontend/package.json +3 -1
- frontend/src/App.js +2 -3
- frontend/src/components/decidron/CommandBuilder.js +102 -0
- frontend/src/components/decidron/DecidronSimulator.js +149 -0
- frontend/src/components/decidron/HistoryTimeline.js +29 -0
- frontend/src/components/decidron/NetworkDiagram.js +54 -0
- frontend/src/components/decidron/ResultsPanel.js +41 -0
- frontend/src/components/decidron/SensorInputForm.js +73 -0
- frontend/src/components/decidron/StatsTable.js +47 -0
- frontend/src/styles/decidron.css +178 -0
- frontend/src/utils/api.js +28 -351
- frontend/src/utils/storage.js +0 -135
.env.example
CHANGED
|
@@ -1,25 +1,11 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
#
|
| 6 |
-
# NOT used for HANA /auth/login — see NeonGeckoCom/brainforge-webapp docker_overlay/config/config.yaml
|
| 7 |
-
HANA_KLATCHAT_PASSWORD=sk_klatchat2024
|
| 8 |
-
# Direct vLLM host (default matches demo 4090-x1-3)
|
| 9 |
-
# NEON_SECURITY_VLLM_BASE_URL=https://4090-x1-3.neonaiservices2.com/vllm0
|
| 10 |
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
|
| 14 |
-
#
|
| 15 |
-
|
| 16 |
-
TOGETHER_API_KEY=your-together-api-key-here
|
| 17 |
-
OPENAI_API_KEY=your-openai-api-key-here
|
| 18 |
-
GEMINI_API_KEY=your-gemini-api-key-here
|
| 19 |
-
|
| 20 |
-
# Orchestrator LLM (used to monitor conversation endings)
|
| 21 |
-
# Defaults to gpt-4o-mini via OpenAI if OPENAI_API_KEY is set
|
| 22 |
-
ORCHESTRATOR_MODEL=gpt-4o-mini
|
| 23 |
-
|
| 24 |
-
# App
|
| 25 |
-
CORS_ORIGINS=http://localhost:3000
|
|
|
|
| 1 |
+
# Decidron Network Simulator config.
|
| 2 |
+
#
|
| 3 |
+
# The deterministic simulator requires no API keys. The only settings
|
| 4 |
+
# are CORS (for CRA dev on :3000 talking to the backend on :7860) and an
|
| 5 |
+
# optional host port override for docker compose.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
# Comma-separated allowed CORS origins.
|
| 8 |
+
CORS_ORIGINS=http://localhost:3000,http://localhost:7860
|
| 9 |
|
| 10 |
+
# Host port to publish the container on (container always listens on 7860).
|
| 11 |
+
# CCAI_HOST_PORT=7860
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
CHANGED
|
@@ -3,6 +3,7 @@ __pycache__/
|
|
| 3 |
.env
|
| 4 |
node_modules/
|
| 5 |
frontend/build/
|
|
|
|
| 6 |
.DS_Store
|
| 7 |
*.egg-info/
|
| 8 |
dist/
|
|
|
|
| 3 |
.env
|
| 4 |
node_modules/
|
| 5 |
frontend/build/
|
| 6 |
+
backend/static/
|
| 7 |
.DS_Store
|
| 8 |
*.egg-info/
|
| 9 |
dist/
|
README.md
CHANGED
|
@@ -1,128 +1,229 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
1. **Initial Opinions.** Each participant offers an independent first
|
| 49 |
-
opinion. The orchestrator builds a per-participant **Credential
|
| 50 |
-
Summary** in the background.
|
| 51 |
-
2. **Critique x 2.** Each participant gets two turns to critique
|
| 52 |
-
others, ask follow-ups, and revise. After Phase 2 the Credential
|
| 53 |
-
Summary is refreshed.
|
| 54 |
-
3. **Status Assessment.** The orchestrator either proceeds or runs
|
| 55 |
-
targeted follow-ups (max 3 iterations).
|
| 56 |
-
4. **Finalization.** Each participant either revises their own opinion
|
| 57 |
-
or endorses another's.
|
| 58 |
-
5. **Consensus Gathering.** Allied participants advocate, solo
|
| 59 |
-
participants seek allies / switch / propose compromises. The
|
| 60 |
-
orchestrator routes addressed-to messages.
|
| 61 |
-
6. **Closure.** Majority-report (with weighted dissent), or
|
| 62 |
-
unaddressed-factor probe + retry, or no-consensus report.
|
| 63 |
-
|
| 64 |
-
Thinking traces (`<think>`, `<reasoning>`, etc.) are stripped from every
|
| 65 |
-
LLM response in `backend/app/utils/sanitize.py` before being stored,
|
| 66 |
-
displayed, or fed back to the orchestrator/summarizer/Credential builder.
|
| 67 |
-
|
| 68 |
-
## Quick Start (local development)
|
| 69 |
|
| 70 |
```bash
|
| 71 |
-
cp .env.example .env
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
cd backend
|
|
|
|
|
|
|
| 76 |
pip install -r requirements.txt
|
| 77 |
-
uvicorn app.main:app --reload --port
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
cd frontend
|
| 81 |
npm install
|
|
|
|
|
|
|
| 82 |
npm start
|
| 83 |
```
|
| 84 |
|
| 85 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
```bash
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
```
|
| 93 |
|
| 94 |
-
##
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
- `
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
- **
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Decidron Network Simulator
|
| 2 |
+
|
| 3 |
+
A deterministic, browser-based simulator for **Decidron networks** — the
|
| 4 |
+
coupled machine-learning decision units described in US Patent
|
| 5 |
+
[US11989636B1](https://patents.google.com/patent/US11989636B1/en)
|
| 6 |
+
("System and method for persuadable collaborative conversational AI",
|
| 7 |
+
Gasper & Leeds).
|
| 8 |
+
|
| 9 |
+
You define simulated **sensor inputs** and **processing commands**
|
| 10 |
+
(IF a sensor matches THEN emit an output / drive an actuator / modify the
|
| 11 |
+
network), run the simulation, and watch the **network diagram**,
|
| 12 |
+
**performance statistics**, and **topology-change history** update live.
|
| 13 |
+
|
| 14 |
+
> This project started from Neon.ai's private `CCAI-Vibe-Demo` app and
|
| 15 |
+
> reuses its FastAPI + React scaffold, Docker setup, and visual branding.
|
| 16 |
+
> The CCAI multi-LLM orchestration was intentionally removed — v1 of the
|
| 17 |
+
> simulator is purely rule-based and needs no API keys or LLM. See
|
| 18 |
+
> [Relationship to CCAI](#relationship-to-ccai-and-the-patent).
|
| 19 |
+
|
| 20 |
+
## Patent context (brief)
|
| 21 |
+
|
| 22 |
+
A **Decidron** is a machine-learning unit (MLU) with collaborative
|
| 23 |
+
protocols that can be **coupled/networked** with other Decidrons. The
|
| 24 |
+
patent highlights that "a MLU network coupled with real-time sensors and
|
| 25 |
+
actuators can operate industrial controls ... and increased reliability
|
| 26 |
+
through parallel pathways." A Decidron records its **experience**, fires
|
| 27 |
+
**actions**, scores **results**, and can **modify its own topology**.
|
| 28 |
+
|
| 29 |
+
This simulator models those concepts deterministically:
|
| 30 |
+
|
| 31 |
+
| Patent concept | Simulator |
|
| 32 |
+
| --- | --- |
|
| 33 |
+
| Coupled MLU node / coupling | `decidron` node / directed edge |
|
| 34 |
+
| Sensors and actuators | `sensor` / `actuator` nodes |
|
| 35 |
+
| Experience Chains (ECDS) | per-node experience log (sensor received → rule fired → actuator acted) |
|
| 36 |
+
| Select & perform action, results scored | command output (+ optional actuator) + performance stats |
|
| 37 |
+
| Dynamic/extensible topology | `network_ops` applied when a command fires |
|
| 38 |
+
| Parallel pathways / reliability | redundant couplings in the seed network |
|
| 39 |
+
|
| 40 |
+
## Prerequisites
|
| 41 |
+
|
| 42 |
+
- **Docker** (recommended), or
|
| 43 |
+
- **Python 3.12+** and **Node 22+** for local dev.
|
| 44 |
+
|
| 45 |
+
## Start the application
|
| 46 |
+
|
| 47 |
+
### Docker (recommended)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
```bash
|
| 50 |
+
cp .env.example .env # no secrets needed; defaults are fine
|
| 51 |
+
docker compose up --build
|
| 52 |
+
# open http://localhost:7860
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
The container builds the React frontend, serves it as static files from
|
| 56 |
+
FastAPI, and listens on port 7860. Override the host port with
|
| 57 |
+
`CCAI_HOST_PORT` in `.env` if 7860 is taken.
|
| 58 |
+
|
| 59 |
+
### Local development
|
| 60 |
|
| 61 |
+
Backend (FastAPI on :7860):
|
| 62 |
+
|
| 63 |
+
```bash
|
| 64 |
cd backend
|
| 65 |
+
python -m venv .venv
|
| 66 |
+
.venv/Scripts/activate # Windows; use source .venv/bin/activate on macOS/Linux
|
| 67 |
pip install -r requirements.txt
|
| 68 |
+
uvicorn app.main:app --reload --port 7860
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Frontend (CRA dev server on :3000, proxying to the backend):
|
| 72 |
|
| 73 |
+
```bash
|
| 74 |
cd frontend
|
| 75 |
npm install
|
| 76 |
+
# point the dev server at the backend:
|
| 77 |
+
echo "REACT_APP_API_URL=http://localhost:7860" > .env.development
|
| 78 |
npm start
|
| 79 |
```
|
| 80 |
|
| 81 |
+
## Web UI workflow
|
| 82 |
+
|
| 83 |
+
1. **View the initial network diagram** — sensors (blue boxes) feed
|
| 84 |
+
coupled Decidrons (purple hexagons) that drive actuators (green
|
| 85 |
+
triangles).
|
| 86 |
+
2. **Enter a simulated sensor input** — pick a channel (e.g.
|
| 87 |
+
`Temperature Sensor`), enter a value (e.g. `97`), and click **Queue**.
|
| 88 |
+
3. **Create processing commands** — define IF (sensor + match) THEN
|
| 89 |
+
(output, optional actuator). The match is an `operator:value` rule.
|
| 90 |
+
4. **Run the simulation** — click **Run Simulation**; queued inputs are
|
| 91 |
+
evaluated against all commands.
|
| 92 |
+
5. **Read the results and statistics** — matched commands, emitted
|
| 93 |
+
outputs, and per-sensor/command/output metrics appear on the right.
|
| 94 |
+
6. **Review the network-change diagram** — if a command applied
|
| 95 |
+
`network_ops`, a new version is snapshotted; select any version in the
|
| 96 |
+
**Network Modification History** to view that topology in the diagram.
|
| 97 |
+
|
| 98 |
+
## Processing command format
|
| 99 |
+
|
| 100 |
+
A command is JSON:
|
| 101 |
+
|
| 102 |
+
```json
|
| 103 |
+
{
|
| 104 |
+
"name": "HighTempAlert",
|
| 105 |
+
"sensor": "temperature",
|
| 106 |
+
"match": "gte:85",
|
| 107 |
+
"output": "Trigger failsafe review",
|
| 108 |
+
"actuator": "actuator-1",
|
| 109 |
+
"network_ops": [
|
| 110 |
+
{ "op": "add_node", "node": { "id": "D3", "label": "Decidron D3", "type": "decidron" } },
|
| 111 |
+
{ "op": "add_edge", "from": "temperature", "to": "D3" },
|
| 112 |
+
{ "op": "remove_edge", "from": "D1", "to": "D2" }
|
| 113 |
+
]
|
| 114 |
+
}
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
- `sensor` — the sensor channel/node id this command listens to.
|
| 118 |
+
- `match` — `operator:value`, where operator is one of `contains`,
|
| 119 |
+
`equals`, `startswith`, `endswith`, `regex`, `gt`, `gte`, `lt`, `lte`.
|
| 120 |
+
A bare value (no operator) is treated as `contains`.
|
| 121 |
+
- `output` — text emitted when the rule fires.
|
| 122 |
+
- `actuator` *(optional)* — id of an actuator node to drive.
|
| 123 |
+
- `network_ops` *(optional)* — topology changes: `add_node`, `add_edge`,
|
| 124 |
+
`remove_edge` (edges use `from`/`to`).
|
| 125 |
+
|
| 126 |
+
## API reference
|
| 127 |
+
|
| 128 |
+
All endpoints are under `/api/decidron`.
|
| 129 |
+
|
| 130 |
+
| Method | Path | Purpose |
|
| 131 |
+
| --- | --- | --- |
|
| 132 |
+
| GET | `/api/decidron/network` | Current network + vis-network diagram JSON |
|
| 133 |
+
| POST | `/api/decidron/sensors/input` | Queue a simulated sensor input |
|
| 134 |
+
| GET | `/api/decidron/commands` | List processing commands |
|
| 135 |
+
| POST | `/api/decidron/commands` | Create a processing command |
|
| 136 |
+
| POST | `/api/decidron/run` | Process queued (or supplied) inputs |
|
| 137 |
+
| GET | `/api/decidron/stats` | Performance statistics |
|
| 138 |
+
| GET | `/api/decidron/history` | Topology-change snapshots |
|
| 139 |
+
| POST | `/api/decidron/reset` | Reset to the seed network |
|
| 140 |
+
|
| 141 |
+
### curl examples
|
| 142 |
|
| 143 |
```bash
|
| 144 |
+
# View the initial network
|
| 145 |
+
curl http://localhost:7860/api/decidron/network
|
| 146 |
+
|
| 147 |
+
# Queue a sensor reading and run
|
| 148 |
+
curl -X POST http://localhost:7860/api/decidron/sensors/input \
|
| 149 |
+
-H 'Content-Type: application/json' \
|
| 150 |
+
-d '{"channel":"temperature","value":"97"}'
|
| 151 |
+
|
| 152 |
+
curl -X POST http://localhost:7860/api/decidron/run \
|
| 153 |
+
-H 'Content-Type: application/json' -d '{}'
|
| 154 |
+
|
| 155 |
+
# Create a command
|
| 156 |
+
curl -X POST http://localhost:7860/api/decidron/commands \
|
| 157 |
+
-H 'Content-Type: application/json' \
|
| 158 |
+
-d '{"name":"LowTemp","sensor":"temperature","match":"lt:0","output":"Freeze warning"}'
|
| 159 |
+
|
| 160 |
+
# Run with inline inputs (bypasses the queue)
|
| 161 |
+
curl -X POST http://localhost:7860/api/decidron/run \
|
| 162 |
+
-H 'Content-Type: application/json' \
|
| 163 |
+
-d '{"inputs":[{"channel":"temperature","value":"-5"}]}'
|
| 164 |
+
|
| 165 |
+
# Stats and history
|
| 166 |
+
curl http://localhost:7860/api/decidron/stats
|
| 167 |
+
curl http://localhost:7860/api/decidron/history
|
| 168 |
```
|
| 169 |
|
| 170 |
+
## Config & seed files
|
| 171 |
+
|
| 172 |
+
- [`backend/app/config.py`](backend/app/config.py) — minimal settings
|
| 173 |
+
(`CORS_ORIGINS`).
|
| 174 |
+
- [`backend/app/data/decidron/default_network.json`](backend/app/data/decidron/default_network.json)
|
| 175 |
+
— the starter network (sensor → coupled Decidrons → actuator, with a
|
| 176 |
+
redundant coupling).
|
| 177 |
+
- [`backend/app/data/decidron/sample_commands.json`](backend/app/data/decidron/sample_commands.json)
|
| 178 |
+
— example commands (one drives an actuator, one reconfigures the
|
| 179 |
+
topology). Edit these to change the simulation's starting state.
|
| 180 |
+
|
| 181 |
+
## Adding files in Cursor
|
| 182 |
+
|
| 183 |
+
- **Open the repo as a workspace**: File → Open Folder → `decidron-simulator`.
|
| 184 |
+
- **New file**: right-click a folder in the Explorer → New File.
|
| 185 |
+
- **Drag & drop** files from your file manager into the Explorer.
|
| 186 |
+
- **Ask Cursor in chat**: e.g. "Add `backend/app/decidron/alerts.py`".
|
| 187 |
+
- **Terminal**: `New-Item backend/app/decidron/alerts.py` (PowerShell) or
|
| 188 |
+
`touch backend/app/decidron/alerts.py` (bash).
|
| 189 |
+
|
| 190 |
+
## Relationship to CCAI and the patent
|
| 191 |
+
|
| 192 |
+
v1 deliberately omits the patent's full pipeline: ECDS context/memory
|
| 193 |
+
augmentation, the embedded CCAI per Decidron, Theory-of-Mind,
|
| 194 |
+
persuadability, and action-reinforcement learning. A future version
|
| 195 |
+
could make Decidrons LLM-driven by porting the orchestration and LLM
|
| 196 |
+
clients from the `CCAI-Vibe-Demo` reference app. The simulator's logic is
|
| 197 |
+
isolated under `backend/app/decidron/` and
|
| 198 |
+
`frontend/src/components/decidron/` to keep that door open.
|
| 199 |
+
|
| 200 |
+
## Project layout
|
| 201 |
+
|
| 202 |
+
```
|
| 203 |
+
backend/
|
| 204 |
+
app/
|
| 205 |
+
main.py # FastAPI app: CORS, /api/health, SPA serving
|
| 206 |
+
config.py # minimal settings
|
| 207 |
+
api/decidron.py # REST router
|
| 208 |
+
decidron/ # simulation engine (models, network, engine, stats, diagrams)
|
| 209 |
+
data/decidron/ # seed network + sample commands
|
| 210 |
+
frontend/
|
| 211 |
+
src/
|
| 212 |
+
App.js # shell
|
| 213 |
+
components/Header.js # branded header
|
| 214 |
+
components/decidron/ # simulator UI (diagram, forms, tables, history)
|
| 215 |
+
utils/api.js # API client
|
| 216 |
+
styles/ # branding + simulator CSS
|
| 217 |
+
Dockerfile, docker-compose.yml
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
## Troubleshooting
|
| 221 |
+
|
| 222 |
+
- **Port 7860 in use** — set `CCAI_HOST_PORT` in `.env`, or run uvicorn
|
| 223 |
+
on another port.
|
| 224 |
+
- **Frontend can't reach the API in dev** — ensure
|
| 225 |
+
`REACT_APP_API_URL=http://localhost:7860` is set in
|
| 226 |
+
`frontend/.env.development` and the backend is running.
|
| 227 |
+
- **CORS errors** — add your origin to `CORS_ORIGINS` in `.env`.
|
| 228 |
+
- **Diagram doesn't render** — vis-network needs the bundled assets from
|
| 229 |
+
`npm install`; re-run it if you cloned without building.
|
backend/app/api/decidron.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""REST API for the Decidron network simulator."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
|
| 8 |
+
from app.decidron.engine import engine
|
| 9 |
+
from app.decidron.diagrams import to_vis_network
|
| 10 |
+
from app.decidron.models import ProcessingCommand, RunResult, SensorInput
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/decidron", tags=["decidron"])
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _network_payload() -> dict:
|
| 16 |
+
nodes = engine.network.nodes_list()
|
| 17 |
+
return {
|
| 18 |
+
"version": engine.network.version,
|
| 19 |
+
"nodes": [n.model_dump() for n in nodes],
|
| 20 |
+
"edges": [e.model_dump(by_alias=True) for e in engine.network.edges],
|
| 21 |
+
"diagram": to_vis_network(nodes, engine.network.edges),
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.get("/network")
|
| 26 |
+
async def get_network() -> dict:
|
| 27 |
+
return _network_payload()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.post("/sensors/input")
|
| 31 |
+
async def submit_sensor_input(sensor_input: SensorInput) -> dict:
|
| 32 |
+
engine.add_sensor_input(sensor_input)
|
| 33 |
+
return {
|
| 34 |
+
"queued": sensor_input.model_dump(),
|
| 35 |
+
"pending_count": len(engine.pending_inputs),
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.get("/commands")
|
| 40 |
+
async def list_commands() -> list[ProcessingCommand]:
|
| 41 |
+
return engine.list_commands()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@router.post("/commands")
|
| 45 |
+
async def create_command(command: ProcessingCommand) -> ProcessingCommand:
|
| 46 |
+
return engine.add_command(command)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class RunRequest(BaseModel):
|
| 50 |
+
inputs: list[SensorInput] | None = None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.post("/run")
|
| 54 |
+
async def run_simulation(req: RunRequest | None = None) -> dict:
|
| 55 |
+
inputs = req.inputs if req else None
|
| 56 |
+
results: list[RunResult] = engine.run(inputs)
|
| 57 |
+
return {
|
| 58 |
+
"results": [r.model_dump() for r in results],
|
| 59 |
+
"matches": sum(1 for r in results if r.matched),
|
| 60 |
+
"network": _network_payload(),
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@router.get("/stats")
|
| 65 |
+
async def get_stats() -> dict:
|
| 66 |
+
return engine.stats.as_dict()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@router.get("/history")
|
| 70 |
+
async def get_history() -> dict:
|
| 71 |
+
return {
|
| 72 |
+
"version": engine.network.version,
|
| 73 |
+
"snapshots": [
|
| 74 |
+
{
|
| 75 |
+
"version": s.version,
|
| 76 |
+
"reason": s.reason,
|
| 77 |
+
"timestamp": s.timestamp,
|
| 78 |
+
"diagram": to_vis_network(s.nodes, s.edges),
|
| 79 |
+
}
|
| 80 |
+
for s in engine.network.history
|
| 81 |
+
],
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.post("/reset")
|
| 86 |
+
async def reset_simulation() -> dict:
|
| 87 |
+
engine.reset()
|
| 88 |
+
return _network_payload()
|
backend/app/data/decidron/default_network.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"nodes": [
|
| 3 |
+
{ "id": "temperature", "label": "Temperature Sensor", "type": "sensor" },
|
| 4 |
+
{ "id": "D1", "label": "Decidron D1", "type": "decidron" },
|
| 5 |
+
{ "id": "D2", "label": "Decidron D2", "type": "decidron" },
|
| 6 |
+
{ "id": "actuator-1", "label": "Failsafe Actuator", "type": "actuator" }
|
| 7 |
+
],
|
| 8 |
+
"edges": [
|
| 9 |
+
{ "from": "temperature", "to": "D1", "label": "senses" },
|
| 10 |
+
{ "from": "temperature", "to": "D2", "label": "senses (redundant)" },
|
| 11 |
+
{ "from": "D1", "to": "D2", "label": "coupling" },
|
| 12 |
+
{ "from": "D2", "to": "D1", "label": "coupling" },
|
| 13 |
+
{ "from": "D1", "to": "actuator-1", "label": "drives" },
|
| 14 |
+
{ "from": "D2", "to": "actuator-1", "label": "drives (redundant)" }
|
| 15 |
+
]
|
| 16 |
+
}
|
backend/app/data/decidron/sample_commands.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"name": "HighTempAlert",
|
| 4 |
+
"sensor": "temperature",
|
| 5 |
+
"match": "gte:85",
|
| 6 |
+
"output": "Trigger failsafe review",
|
| 7 |
+
"actuator": "actuator-1"
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"name": "CriticalTempReconfigure",
|
| 11 |
+
"sensor": "temperature",
|
| 12 |
+
"match": "gte:95",
|
| 13 |
+
"output": "Spin up redundant Decidron D3 and couple it to the failsafe actuator",
|
| 14 |
+
"network_ops": [
|
| 15 |
+
{ "op": "add_node", "node": { "id": "D3", "label": "Decidron D3", "type": "decidron" } },
|
| 16 |
+
{ "op": "add_edge", "from": "temperature", "to": "D3" },
|
| 17 |
+
{ "op": "add_edge", "from": "D3", "to": "actuator-1" }
|
| 18 |
+
]
|
| 19 |
+
}
|
| 20 |
+
]
|
backend/app/decidron/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Decidron network simulator package.
|
| 2 |
+
|
| 3 |
+
A deterministic, LLM-free simulation of the patent's (US11989636B1)
|
| 4 |
+
Decidron network: machine-learning decision units (Decidrons) coupled
|
| 5 |
+
with sensors and actuators, that record experience, fire rule-based
|
| 6 |
+
actions, and can modify their own topology.
|
| 7 |
+
"""
|
backend/app/decidron/diagrams.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Convert Decidron network state into vis-network diagram JSON.
|
| 2 |
+
|
| 3 |
+
Uses ``networkx`` as the server-side graph representation and emits
|
| 4 |
+
``{nodes, edges}`` shaped for the vis-network frontend, styled by node
|
| 5 |
+
type (sensor / decidron / actuator).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import networkx as nx
|
| 11 |
+
|
| 12 |
+
from .models import DecidronNode, Edge
|
| 13 |
+
|
| 14 |
+
_GROUP_STYLE = {
|
| 15 |
+
"sensor": {"shape": "box", "color": "#3b82f6"},
|
| 16 |
+
"decidron": {"shape": "hexagon", "color": "#8b5cf6"},
|
| 17 |
+
"actuator": {"shape": "triangle", "color": "#10b981"},
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def build_graph(nodes: list[DecidronNode], edges: list[Edge]) -> nx.DiGraph:
|
| 22 |
+
g = nx.DiGraph()
|
| 23 |
+
for n in nodes:
|
| 24 |
+
g.add_node(n.id, label=n.label, type=n.type, experience=len(n.experience))
|
| 25 |
+
for e in edges:
|
| 26 |
+
g.add_edge(e.source, e.target, label=e.label or "")
|
| 27 |
+
return g
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def to_vis_network(nodes: list[DecidronNode], edges: list[Edge]) -> dict:
|
| 31 |
+
"""Return a vis-network ``{nodes, edges}`` payload."""
|
| 32 |
+
g = build_graph(nodes, edges)
|
| 33 |
+
vis_nodes = []
|
| 34 |
+
for node_id, data in g.nodes(data=True):
|
| 35 |
+
ntype = data.get("type", "decidron")
|
| 36 |
+
style = _GROUP_STYLE.get(ntype, {})
|
| 37 |
+
vis_nodes.append({
|
| 38 |
+
"id": node_id,
|
| 39 |
+
"label": data.get("label", node_id),
|
| 40 |
+
"group": ntype,
|
| 41 |
+
"title": f"{ntype} - {data.get('experience', 0)} experience events",
|
| 42 |
+
**style,
|
| 43 |
+
})
|
| 44 |
+
vis_edges = [
|
| 45 |
+
{"from": s, "to": t, "label": d.get("label", ""), "arrows": "to"}
|
| 46 |
+
for s, t, d in g.edges(data=True)
|
| 47 |
+
]
|
| 48 |
+
return {"nodes": vis_nodes, "edges": vis_edges}
|
backend/app/decidron/engine.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic Decidron processing engine.
|
| 2 |
+
|
| 3 |
+
Ties the network topology, statistics, user commands, and queued sensor
|
| 4 |
+
inputs together. On each run it matches commands against sensor inputs
|
| 5 |
+
and, for every match:
|
| 6 |
+
|
| 7 |
+
1. records ECDS-like experience events on the involved nodes
|
| 8 |
+
(sensor received -> decidron fired -> actuator acted),
|
| 9 |
+
2. emits the command output and drives the target actuator (if any),
|
| 10 |
+
3. applies topology ``network_ops`` (snapshotting history),
|
| 11 |
+
4. updates performance statistics.
|
| 12 |
+
|
| 13 |
+
No LLM is involved -- v1 is purely rule-based.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import re
|
| 20 |
+
import time
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
from .models import (
|
| 24 |
+
ExperienceEvent,
|
| 25 |
+
ProcessingCommand,
|
| 26 |
+
RunResult,
|
| 27 |
+
SensorInput,
|
| 28 |
+
)
|
| 29 |
+
from .network import NetworkStore
|
| 30 |
+
from .stats import StatsCollector
|
| 31 |
+
|
| 32 |
+
_DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "decidron"
|
| 33 |
+
_SAMPLE_COMMANDS = _DATA_DIR / "sample_commands.json"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _matches(rule: str, value: str) -> bool:
|
| 37 |
+
"""Evaluate an ``operator:value`` match rule against a sensor value."""
|
| 38 |
+
if ":" in rule:
|
| 39 |
+
op, _, target = rule.partition(":")
|
| 40 |
+
op = op.strip().lower()
|
| 41 |
+
target = target.strip()
|
| 42 |
+
else:
|
| 43 |
+
op, target = "contains", rule.strip()
|
| 44 |
+
|
| 45 |
+
v = value.strip()
|
| 46 |
+
|
| 47 |
+
def _nums():
|
| 48 |
+
try:
|
| 49 |
+
return float(v), float(target)
|
| 50 |
+
except (TypeError, ValueError):
|
| 51 |
+
return None, None
|
| 52 |
+
|
| 53 |
+
if op == "contains":
|
| 54 |
+
return target.lower() in v.lower()
|
| 55 |
+
if op == "equals":
|
| 56 |
+
return v.lower() == target.lower()
|
| 57 |
+
if op == "startswith":
|
| 58 |
+
return v.lower().startswith(target.lower())
|
| 59 |
+
if op == "endswith":
|
| 60 |
+
return v.lower().endswith(target.lower())
|
| 61 |
+
if op == "regex":
|
| 62 |
+
try:
|
| 63 |
+
return re.search(target, v) is not None
|
| 64 |
+
except re.error:
|
| 65 |
+
return False
|
| 66 |
+
if op in {"gt", "lt", "gte", "lte"}:
|
| 67 |
+
a, b = _nums()
|
| 68 |
+
if a is None:
|
| 69 |
+
return False
|
| 70 |
+
return {
|
| 71 |
+
"gt": a > b,
|
| 72 |
+
"lt": a < b,
|
| 73 |
+
"gte": a >= b,
|
| 74 |
+
"lte": a <= b,
|
| 75 |
+
}[op]
|
| 76 |
+
# Unknown operator -> no match.
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class DecidronEngine:
|
| 81 |
+
def __init__(self) -> None:
|
| 82 |
+
self.network = NetworkStore()
|
| 83 |
+
self.stats = StatsCollector()
|
| 84 |
+
self.commands: dict[str, ProcessingCommand] = {}
|
| 85 |
+
self.pending_inputs: list[SensorInput] = []
|
| 86 |
+
self.input_log: list[SensorInput] = []
|
| 87 |
+
self.run_results: list[RunResult] = []
|
| 88 |
+
self._load_sample_commands()
|
| 89 |
+
|
| 90 |
+
def _load_sample_commands(self) -> None:
|
| 91 |
+
if _SAMPLE_COMMANDS.is_file():
|
| 92 |
+
for c in json.loads(_SAMPLE_COMMANDS.read_text(encoding="utf-8")):
|
| 93 |
+
cmd = ProcessingCommand(**c)
|
| 94 |
+
self.commands[cmd.name] = cmd
|
| 95 |
+
|
| 96 |
+
# -- commands --------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
def list_commands(self) -> list[ProcessingCommand]:
|
| 99 |
+
return list(self.commands.values())
|
| 100 |
+
|
| 101 |
+
def add_command(self, command: ProcessingCommand) -> ProcessingCommand:
|
| 102 |
+
self.commands[command.name] = command
|
| 103 |
+
return command
|
| 104 |
+
|
| 105 |
+
# -- sensor inputs ---------------------------------------------------
|
| 106 |
+
|
| 107 |
+
def add_sensor_input(self, sensor_input: SensorInput) -> SensorInput:
|
| 108 |
+
self.pending_inputs.append(sensor_input)
|
| 109 |
+
return sensor_input
|
| 110 |
+
|
| 111 |
+
# -- experience (ECDS-like) -----------------------------------------
|
| 112 |
+
|
| 113 |
+
def _record(self, node_id: str, event: ExperienceEvent) -> None:
|
| 114 |
+
node = self.network.nodes.get(node_id)
|
| 115 |
+
if node is not None:
|
| 116 |
+
node.experience.append(event)
|
| 117 |
+
|
| 118 |
+
def _decidrons_for_sensor(self, sensor_node_id: str | None) -> list[str]:
|
| 119 |
+
"""Decidron nodes coupled downstream of a sensor (fallback: all)."""
|
| 120 |
+
if sensor_node_id is not None:
|
| 121 |
+
coupled = [
|
| 122 |
+
nid for nid in self.network.neighbors_out(sensor_node_id)
|
| 123 |
+
if self.network.nodes.get(nid) and self.network.nodes[nid].type == "decidron"
|
| 124 |
+
]
|
| 125 |
+
if coupled:
|
| 126 |
+
return coupled
|
| 127 |
+
return [n.id for n in self.network.nodes_list() if n.type == "decidron"]
|
| 128 |
+
|
| 129 |
+
# -- run -------------------------------------------------------------
|
| 130 |
+
|
| 131 |
+
def run(self, inputs: list[SensorInput] | None = None) -> list[RunResult]:
|
| 132 |
+
batch = inputs if inputs is not None else list(self.pending_inputs)
|
| 133 |
+
results: list[RunResult] = []
|
| 134 |
+
|
| 135 |
+
for si in batch:
|
| 136 |
+
sensor_node = self.network.find_sensor_node(si.channel)
|
| 137 |
+
if sensor_node is not None:
|
| 138 |
+
self._record(sensor_node.id, ExperienceEvent(
|
| 139 |
+
kind="sensor_received",
|
| 140 |
+
detail=f"received '{si.value}' on channel '{si.channel}'",
|
| 141 |
+
sensor=si.channel,
|
| 142 |
+
value=si.value,
|
| 143 |
+
))
|
| 144 |
+
|
| 145 |
+
for cmd in self.commands.values():
|
| 146 |
+
if cmd.sensor.lower() != si.channel.lower():
|
| 147 |
+
continue
|
| 148 |
+
start = time.perf_counter()
|
| 149 |
+
matched = _matches(cmd.match, si.value)
|
| 150 |
+
ops_applied = 0
|
| 151 |
+
output = None
|
| 152 |
+
actuator = None
|
| 153 |
+
|
| 154 |
+
if matched:
|
| 155 |
+
output = cmd.output
|
| 156 |
+
for nid in self._decidrons_for_sensor(
|
| 157 |
+
sensor_node.id if sensor_node else None
|
| 158 |
+
):
|
| 159 |
+
self._record(nid, ExperienceEvent(
|
| 160 |
+
kind="rule_fired",
|
| 161 |
+
detail=f"command '{cmd.name}' fired -> {cmd.output}",
|
| 162 |
+
command=cmd.name,
|
| 163 |
+
sensor=si.channel,
|
| 164 |
+
value=si.value,
|
| 165 |
+
))
|
| 166 |
+
if cmd.actuator and cmd.actuator in self.network.nodes:
|
| 167 |
+
actuator = cmd.actuator
|
| 168 |
+
self._record(cmd.actuator, ExperienceEvent(
|
| 169 |
+
kind="actuator_acted",
|
| 170 |
+
detail=f"actuated by '{cmd.name}': {cmd.output}",
|
| 171 |
+
command=cmd.name,
|
| 172 |
+
))
|
| 173 |
+
if cmd.network_ops:
|
| 174 |
+
ops_applied = self.network.apply_ops(
|
| 175 |
+
cmd.network_ops, reason=f"command '{cmd.name}' fired"
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
latency_ms = (time.perf_counter() - start) * 1000.0
|
| 179 |
+
self.stats.record(si.channel, cmd.name, output, matched, latency_ms)
|
| 180 |
+
result = RunResult(
|
| 181 |
+
command=cmd.name,
|
| 182 |
+
sensor=si.channel,
|
| 183 |
+
value=si.value,
|
| 184 |
+
matched=matched,
|
| 185 |
+
output=output,
|
| 186 |
+
actuator=actuator,
|
| 187 |
+
network_ops_applied=ops_applied,
|
| 188 |
+
latency_ms=round(latency_ms, 4),
|
| 189 |
+
)
|
| 190 |
+
results.append(result)
|
| 191 |
+
|
| 192 |
+
self.input_log.append(si)
|
| 193 |
+
|
| 194 |
+
if inputs is None:
|
| 195 |
+
self.pending_inputs.clear()
|
| 196 |
+
self.run_results.extend(results)
|
| 197 |
+
return results
|
| 198 |
+
|
| 199 |
+
def reset(self) -> None:
|
| 200 |
+
self.network.reset()
|
| 201 |
+
self.stats.reset()
|
| 202 |
+
self.pending_inputs.clear()
|
| 203 |
+
self.input_log.clear()
|
| 204 |
+
self.run_results.clear()
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
engine = DecidronEngine()
|
backend/app/decidron/models.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic schemas for the Decidron simulator.
|
| 2 |
+
|
| 3 |
+
These model the patent's core objects in a deliberately simplified,
|
| 4 |
+
deterministic form:
|
| 5 |
+
|
| 6 |
+
- ``DecidronNode`` -> a coupled MLU node (decidron) or a sensor/actuator.
|
| 7 |
+
- ``Edge`` -> a coupling between nodes.
|
| 8 |
+
- ``ExperienceEvent`` -> a simplified ECDS entry recorded on a node.
|
| 9 |
+
- ``ProcessingCommand`` -> a user-defined IF (sensor matches) THEN
|
| 10 |
+
(emit output / drive actuator / modify network) rule.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import time
|
| 16 |
+
from typing import Literal, Optional
|
| 17 |
+
|
| 18 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 19 |
+
|
| 20 |
+
NodeType = Literal["decidron", "sensor", "actuator"]
|
| 21 |
+
NetworkOpType = Literal["add_edge", "remove_edge", "add_node"]
|
| 22 |
+
ExperienceKind = Literal["sensor_received", "rule_fired", "actuator_acted"]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ExperienceEvent(BaseModel):
|
| 26 |
+
"""A simplified ECDS (Experience Chain Data Structure) entry.
|
| 27 |
+
|
| 28 |
+
The patent's signature behavior is that each unit records its
|
| 29 |
+
experience; in v1 we capture the salient facts of each run.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
kind: ExperienceKind
|
| 33 |
+
detail: str
|
| 34 |
+
command: Optional[str] = None
|
| 35 |
+
sensor: Optional[str] = None
|
| 36 |
+
value: Optional[str] = None
|
| 37 |
+
timestamp: float = Field(default_factory=time.time)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class DecidronNode(BaseModel):
|
| 41 |
+
id: str
|
| 42 |
+
label: str
|
| 43 |
+
type: NodeType = "decidron"
|
| 44 |
+
experience: list[ExperienceEvent] = Field(default_factory=list)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class Edge(BaseModel):
|
| 48 |
+
"""A directed coupling between two nodes (``from`` -> ``to``)."""
|
| 49 |
+
|
| 50 |
+
model_config = ConfigDict(populate_by_name=True)
|
| 51 |
+
|
| 52 |
+
source: str = Field(alias="from")
|
| 53 |
+
target: str = Field(alias="to")
|
| 54 |
+
label: Optional[str] = None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class NetworkOp(BaseModel):
|
| 58 |
+
"""A topology modification applied when a command fires."""
|
| 59 |
+
|
| 60 |
+
model_config = ConfigDict(populate_by_name=True)
|
| 61 |
+
|
| 62 |
+
op: NetworkOpType
|
| 63 |
+
source: Optional[str] = Field(default=None, alias="from")
|
| 64 |
+
target: Optional[str] = Field(default=None, alias="to")
|
| 65 |
+
node: Optional[DecidronNode] = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class SensorInput(BaseModel):
|
| 69 |
+
channel: str
|
| 70 |
+
value: str
|
| 71 |
+
timestamp: float = Field(default_factory=time.time)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class ProcessingCommand(BaseModel):
|
| 75 |
+
"""A user-defined processing rule.
|
| 76 |
+
|
| 77 |
+
``match`` is an ``operator:value`` string, e.g. ``contains:85``,
|
| 78 |
+
``equals:on``, ``gt:80``. A bare value is treated as ``contains``.
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
name: str
|
| 82 |
+
sensor: str
|
| 83 |
+
match: str
|
| 84 |
+
output: str
|
| 85 |
+
actuator: Optional[str] = None
|
| 86 |
+
network_ops: list[NetworkOp] = Field(default_factory=list)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class NetworkSnapshot(BaseModel):
|
| 90 |
+
version: int
|
| 91 |
+
reason: str
|
| 92 |
+
nodes: list[DecidronNode]
|
| 93 |
+
edges: list[Edge]
|
| 94 |
+
timestamp: float = Field(default_factory=time.time)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class RunResult(BaseModel):
|
| 98 |
+
command: str
|
| 99 |
+
sensor: str
|
| 100 |
+
value: str
|
| 101 |
+
matched: bool
|
| 102 |
+
output: Optional[str] = None
|
| 103 |
+
actuator: Optional[str] = None
|
| 104 |
+
network_ops_applied: int = 0
|
| 105 |
+
latency_ms: float = 0.0
|
| 106 |
+
timestamp: float = Field(default_factory=time.time)
|
backend/app/decidron/network.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-memory Decidron network state with version history.
|
| 2 |
+
|
| 3 |
+
Holds the live topology (nodes + directed couplings), applies topology
|
| 4 |
+
modifications, and snapshots the network whenever it changes so the UI
|
| 5 |
+
can show before/after diagrams.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from .models import DecidronNode, Edge, NetworkOp, NetworkSnapshot
|
| 14 |
+
|
| 15 |
+
_DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "decidron"
|
| 16 |
+
_DEFAULT_NETWORK = _DATA_DIR / "default_network.json"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class NetworkStore:
|
| 20 |
+
def __init__(self) -> None:
|
| 21 |
+
self.nodes: dict[str, DecidronNode] = {}
|
| 22 |
+
self.edges: list[Edge] = []
|
| 23 |
+
self.version: int = 0
|
| 24 |
+
self.history: list[NetworkSnapshot] = []
|
| 25 |
+
self.load_default()
|
| 26 |
+
|
| 27 |
+
def load_default(self) -> None:
|
| 28 |
+
raw = json.loads(_DEFAULT_NETWORK.read_text(encoding="utf-8"))
|
| 29 |
+
self.nodes = {n["id"]: DecidronNode(**n) for n in raw.get("nodes", [])}
|
| 30 |
+
self.edges = [Edge(**e) for e in raw.get("edges", [])]
|
| 31 |
+
self.version = 0
|
| 32 |
+
self.history = []
|
| 33 |
+
self._snapshot("initial network")
|
| 34 |
+
|
| 35 |
+
def reset(self) -> None:
|
| 36 |
+
self.load_default()
|
| 37 |
+
|
| 38 |
+
# -- queries ---------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
def neighbors_out(self, node_id: str) -> list[str]:
|
| 41 |
+
"""Nodes reachable from ``node_id`` via an outgoing coupling."""
|
| 42 |
+
return [e.target for e in self.edges if e.source == node_id]
|
| 43 |
+
|
| 44 |
+
def nodes_list(self) -> list[DecidronNode]:
|
| 45 |
+
return list(self.nodes.values())
|
| 46 |
+
|
| 47 |
+
def find_sensor_node(self, channel: str) -> DecidronNode | None:
|
| 48 |
+
"""Match a sensor channel to a sensor node by id or label."""
|
| 49 |
+
for node in self.nodes.values():
|
| 50 |
+
if node.type != "sensor":
|
| 51 |
+
continue
|
| 52 |
+
if node.id == channel or node.label.lower() == channel.lower():
|
| 53 |
+
return node
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
# -- mutations -------------------------------------------------------
|
| 57 |
+
|
| 58 |
+
def _snapshot(self, reason: str) -> None:
|
| 59 |
+
self.history.append(
|
| 60 |
+
NetworkSnapshot(
|
| 61 |
+
version=self.version,
|
| 62 |
+
reason=reason,
|
| 63 |
+
nodes=[n.model_copy(deep=True) for n in self.nodes.values()],
|
| 64 |
+
edges=[e.model_copy(deep=True) for e in self.edges],
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
def _edge_exists(self, source: str, target: str) -> bool:
|
| 69 |
+
return any(e.source == source and e.target == target for e in self.edges)
|
| 70 |
+
|
| 71 |
+
def apply_op(self, op: NetworkOp) -> bool:
|
| 72 |
+
"""Apply a single topology op. Returns True if the graph changed."""
|
| 73 |
+
if op.op == "add_node" and op.node is not None:
|
| 74 |
+
if op.node.id not in self.nodes:
|
| 75 |
+
self.nodes[op.node.id] = op.node
|
| 76 |
+
return True
|
| 77 |
+
return False
|
| 78 |
+
if op.op == "add_edge" and op.source and op.target:
|
| 79 |
+
if not self._edge_exists(op.source, op.target):
|
| 80 |
+
self.edges.append(Edge(source=op.source, target=op.target))
|
| 81 |
+
return True
|
| 82 |
+
return False
|
| 83 |
+
if op.op == "remove_edge" and op.source and op.target:
|
| 84 |
+
before = len(self.edges)
|
| 85 |
+
self.edges = [
|
| 86 |
+
e for e in self.edges
|
| 87 |
+
if not (e.source == op.source and e.target == op.target)
|
| 88 |
+
]
|
| 89 |
+
return len(self.edges) != before
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
def apply_ops(self, ops: list[NetworkOp], reason: str) -> int:
|
| 93 |
+
"""Apply ops, snapshotting once if anything changed. Returns count."""
|
| 94 |
+
changed = 0
|
| 95 |
+
for op in ops:
|
| 96 |
+
if self.apply_op(op):
|
| 97 |
+
changed += 1
|
| 98 |
+
if changed:
|
| 99 |
+
self.version += 1
|
| 100 |
+
self._snapshot(reason)
|
| 101 |
+
return changed
|
backend/app/decidron/stats.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Performance statistics for the Decidron simulator.
|
| 2 |
+
|
| 3 |
+
Aggregates per-sensor, per-command, and per-output counts plus latency
|
| 4 |
+
and last-run timestamps -- a deterministic stand-in for the patent's
|
| 5 |
+
"results determined and scored" step.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
from collections import defaultdict
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class StatsCollector:
|
| 15 |
+
def __init__(self) -> None:
|
| 16 |
+
self.sensors: dict[str, dict] = defaultdict(self._blank)
|
| 17 |
+
self.commands: dict[str, dict] = defaultdict(self._blank)
|
| 18 |
+
self.outputs: dict[str, dict] = defaultdict(self._blank)
|
| 19 |
+
self.total_runs: int = 0
|
| 20 |
+
self.total_matches: int = 0
|
| 21 |
+
|
| 22 |
+
@staticmethod
|
| 23 |
+
def _blank() -> dict:
|
| 24 |
+
return {"count": 0, "matches": 0, "last_run": None, "avg_latency_ms": 0.0}
|
| 25 |
+
|
| 26 |
+
@staticmethod
|
| 27 |
+
def _update(bucket: dict, matched: bool, latency_ms: float) -> None:
|
| 28 |
+
n = bucket["count"]
|
| 29 |
+
bucket["count"] = n + 1
|
| 30 |
+
if matched:
|
| 31 |
+
bucket["matches"] += 1
|
| 32 |
+
# incremental average latency
|
| 33 |
+
bucket["avg_latency_ms"] = (bucket["avg_latency_ms"] * n + latency_ms) / (n + 1)
|
| 34 |
+
bucket["last_run"] = time.time()
|
| 35 |
+
|
| 36 |
+
def record(
|
| 37 |
+
self,
|
| 38 |
+
sensor: str,
|
| 39 |
+
command: str,
|
| 40 |
+
output: str | None,
|
| 41 |
+
matched: bool,
|
| 42 |
+
latency_ms: float,
|
| 43 |
+
) -> None:
|
| 44 |
+
self.total_runs += 1
|
| 45 |
+
if matched:
|
| 46 |
+
self.total_matches += 1
|
| 47 |
+
self._update(self.sensors[sensor], matched, latency_ms)
|
| 48 |
+
self._update(self.commands[command], matched, latency_ms)
|
| 49 |
+
if matched and output:
|
| 50 |
+
self._update(self.outputs[output], True, latency_ms)
|
| 51 |
+
|
| 52 |
+
def reset(self) -> None:
|
| 53 |
+
self.__init__()
|
| 54 |
+
|
| 55 |
+
def as_dict(self) -> dict:
|
| 56 |
+
return {
|
| 57 |
+
"total_runs": self.total_runs,
|
| 58 |
+
"total_matches": self.total_matches,
|
| 59 |
+
"per_sensor": dict(self.sensors),
|
| 60 |
+
"per_command": dict(self.commands),
|
| 61 |
+
"per_output": dict(self.outputs),
|
| 62 |
+
}
|
backend/app/main.py
CHANGED
|
@@ -7,6 +7,7 @@ from fastapi import FastAPI, Request
|
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
|
| 9 |
from app.config import settings
|
|
|
|
| 10 |
|
| 11 |
logging.basicConfig(level=logging.INFO)
|
| 12 |
LOG = logging.getLogger(__name__)
|
|
@@ -26,6 +27,9 @@ app.add_middleware(
|
|
| 26 |
allow_headers=["*"],
|
| 27 |
)
|
| 28 |
|
|
|
|
|
|
|
|
|
|
| 29 |
@app.get("/api/health")
|
| 30 |
async def health():
|
| 31 |
return {"status": "ok"}
|
|
|
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
|
| 9 |
from app.config import settings
|
| 10 |
+
from app.api import decidron
|
| 11 |
|
| 12 |
logging.basicConfig(level=logging.INFO)
|
| 13 |
LOG = logging.getLogger(__name__)
|
|
|
|
| 27 |
allow_headers=["*"],
|
| 28 |
)
|
| 29 |
|
| 30 |
+
app.include_router(decidron.router, prefix="/api")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
@app.get("/api/health")
|
| 34 |
async def health():
|
| 35 |
return {"status": "ok"}
|
backend/requirements.txt
CHANGED
|
@@ -2,4 +2,5 @@ fastapi>=0.115.0
|
|
| 2 |
uvicorn[standard]>=0.32.0
|
| 3 |
pydantic-settings>=2.6.0
|
| 4 |
python-dotenv>=1.0.0
|
|
|
|
| 5 |
pytest>=8.0.0
|
|
|
|
| 2 |
uvicorn[standard]>=0.32.0
|
| 3 |
pydantic-settings>=2.6.0
|
| 4 |
python-dotenv>=1.0.0
|
| 5 |
+
networkx>=3.3
|
| 6 |
pytest>=8.0.0
|
docker-compose.yml
CHANGED
|
@@ -10,5 +10,4 @@ services:
|
|
| 10 |
- .env
|
| 11 |
environment:
|
| 12 |
CORS_ORIGINS: "http://localhost:7860,http://localhost:3000"
|
| 13 |
-
HF_RATE_LIMIT_DAILY: "30"
|
| 14 |
restart: unless-stopped
|
|
|
|
| 10 |
- .env
|
| 11 |
environment:
|
| 12 |
CORS_ORIGINS: "http://localhost:7860,http://localhost:3000"
|
|
|
|
| 13 |
restart: unless-stopped
|
frontend/package-lock.json
CHANGED
|
@@ -11,7 +11,9 @@
|
|
| 11 |
"lucide-react": "^0.577.0",
|
| 12 |
"react": "^19.2.4",
|
| 13 |
"react-dom": "^19.2.4",
|
| 14 |
-
"react-scripts": "5.0.1"
|
|
|
|
|
|
|
| 15 |
}
|
| 16 |
},
|
| 17 |
"node_modules/@alloc/quick-lru": {
|
|
@@ -2386,6 +2388,19 @@
|
|
| 2386 |
"postcss-selector-parser": "^6.0.10"
|
| 2387 |
}
|
| 2388 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2389 |
"node_modules/@eslint-community/eslint-utils": {
|
| 2390 |
"version": "4.9.1",
|
| 2391 |
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
|
@@ -3499,6 +3514,13 @@
|
|
| 3499 |
"@types/node": "*"
|
| 3500 |
}
|
| 3501 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3502 |
"node_modules/@types/html-minifier-terser": {
|
| 3503 |
"version": "6.1.0",
|
| 3504 |
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
|
@@ -5626,6 +5648,19 @@
|
|
| 5626 |
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
|
| 5627 |
"license": "MIT"
|
| 5628 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5629 |
"node_modules/compressible": {
|
| 5630 |
"version": "2.0.18",
|
| 5631 |
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
|
@@ -10794,6 +10829,13 @@
|
|
| 10794 |
"node": ">=4.0"
|
| 10795 |
}
|
| 10796 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10797 |
"node_modules/keyv": {
|
| 10798 |
"version": "4.5.4",
|
| 10799 |
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
|
@@ -16204,6 +16246,56 @@
|
|
| 16204 |
"node": ">= 0.8"
|
| 16205 |
}
|
| 16206 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16207 |
"node_modules/w3c-hr-time": {
|
| 16208 |
"version": "1.0.2",
|
| 16209 |
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
|
|
|
| 11 |
"lucide-react": "^0.577.0",
|
| 12 |
"react": "^19.2.4",
|
| 13 |
"react-dom": "^19.2.4",
|
| 14 |
+
"react-scripts": "5.0.1",
|
| 15 |
+
"vis-data": "^7.1.9",
|
| 16 |
+
"vis-network": "^9.1.9"
|
| 17 |
}
|
| 18 |
},
|
| 19 |
"node_modules/@alloc/quick-lru": {
|
|
|
|
| 2388 |
"postcss-selector-parser": "^6.0.10"
|
| 2389 |
}
|
| 2390 |
},
|
| 2391 |
+
"node_modules/@egjs/hammerjs": {
|
| 2392 |
+
"version": "2.0.17",
|
| 2393 |
+
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
|
| 2394 |
+
"integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==",
|
| 2395 |
+
"license": "MIT",
|
| 2396 |
+
"peer": true,
|
| 2397 |
+
"dependencies": {
|
| 2398 |
+
"@types/hammerjs": "^2.0.36"
|
| 2399 |
+
},
|
| 2400 |
+
"engines": {
|
| 2401 |
+
"node": ">=0.8.0"
|
| 2402 |
+
}
|
| 2403 |
+
},
|
| 2404 |
"node_modules/@eslint-community/eslint-utils": {
|
| 2405 |
"version": "4.9.1",
|
| 2406 |
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
|
|
|
| 3514 |
"@types/node": "*"
|
| 3515 |
}
|
| 3516 |
},
|
| 3517 |
+
"node_modules/@types/hammerjs": {
|
| 3518 |
+
"version": "2.0.46",
|
| 3519 |
+
"resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz",
|
| 3520 |
+
"integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==",
|
| 3521 |
+
"license": "MIT",
|
| 3522 |
+
"peer": true
|
| 3523 |
+
},
|
| 3524 |
"node_modules/@types/html-minifier-terser": {
|
| 3525 |
"version": "6.1.0",
|
| 3526 |
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
|
|
|
| 5648 |
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
|
| 5649 |
"license": "MIT"
|
| 5650 |
},
|
| 5651 |
+
"node_modules/component-emitter": {
|
| 5652 |
+
"version": "2.0.0",
|
| 5653 |
+
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-2.0.0.tgz",
|
| 5654 |
+
"integrity": "sha512-4m5s3Me2xxlVKG9PkZpQqHQR7bgpnN7joDMJ4yvVkVXngjoITG76IaZmzmywSeRTeTpc6N6r3H3+KyUurV8OYw==",
|
| 5655 |
+
"license": "MIT",
|
| 5656 |
+
"peer": true,
|
| 5657 |
+
"engines": {
|
| 5658 |
+
"node": ">=18"
|
| 5659 |
+
},
|
| 5660 |
+
"funding": {
|
| 5661 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 5662 |
+
}
|
| 5663 |
+
},
|
| 5664 |
"node_modules/compressible": {
|
| 5665 |
"version": "2.0.18",
|
| 5666 |
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
|
|
|
| 10829 |
"node": ">=4.0"
|
| 10830 |
}
|
| 10831 |
},
|
| 10832 |
+
"node_modules/keycharm": {
|
| 10833 |
+
"version": "0.4.0",
|
| 10834 |
+
"resolved": "https://registry.npmjs.org/keycharm/-/keycharm-0.4.0.tgz",
|
| 10835 |
+
"integrity": "sha512-TyQTtsabOVv3MeOpR92sIKk/br9wxS+zGj4BG7CR8YbK4jM3tyIBaF0zhzeBUMx36/Q/iQLOKKOT+3jOQtemRQ==",
|
| 10836 |
+
"license": "(Apache-2.0 OR MIT)",
|
| 10837 |
+
"peer": true
|
| 10838 |
+
},
|
| 10839 |
"node_modules/keyv": {
|
| 10840 |
"version": "4.5.4",
|
| 10841 |
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
|
|
|
| 16246 |
"node": ">= 0.8"
|
| 16247 |
}
|
| 16248 |
},
|
| 16249 |
+
"node_modules/vis-data": {
|
| 16250 |
+
"version": "7.1.10",
|
| 16251 |
+
"resolved": "https://registry.npmjs.org/vis-data/-/vis-data-7.1.10.tgz",
|
| 16252 |
+
"integrity": "sha512-23juM9tdCaHTX5vyIQ7XBzsfZU0Hny+gSTwniLrfFcmw9DOm7pi3+h9iEBsoZMp5rX6KNqWwc1MF0fkAmWVuoQ==",
|
| 16253 |
+
"license": "(Apache-2.0 OR MIT)",
|
| 16254 |
+
"funding": {
|
| 16255 |
+
"type": "opencollective",
|
| 16256 |
+
"url": "https://opencollective.com/visjs"
|
| 16257 |
+
},
|
| 16258 |
+
"peerDependencies": {
|
| 16259 |
+
"uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
| 16260 |
+
"vis-util": "^5.0.1"
|
| 16261 |
+
}
|
| 16262 |
+
},
|
| 16263 |
+
"node_modules/vis-network": {
|
| 16264 |
+
"version": "9.1.13",
|
| 16265 |
+
"resolved": "https://registry.npmjs.org/vis-network/-/vis-network-9.1.13.tgz",
|
| 16266 |
+
"integrity": "sha512-HLeHd5KZS92qzO1kC59qMh1/FWAZxMUEwUWBwDMoj6RKj/Ajkrgy/heEYo0Zc8SZNQ2J+u6omvK2+a28GX1QuQ==",
|
| 16267 |
+
"license": "(Apache-2.0 OR MIT)",
|
| 16268 |
+
"funding": {
|
| 16269 |
+
"type": "opencollective",
|
| 16270 |
+
"url": "https://opencollective.com/visjs"
|
| 16271 |
+
},
|
| 16272 |
+
"peerDependencies": {
|
| 16273 |
+
"@egjs/hammerjs": "^2.0.0",
|
| 16274 |
+
"component-emitter": "^1.3.0 || ^2.0.0",
|
| 16275 |
+
"keycharm": "^0.2.0 || ^0.3.0 || ^0.4.0",
|
| 16276 |
+
"uuid": "^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
| 16277 |
+
"vis-data": "^6.3.0 || ^7.0.0",
|
| 16278 |
+
"vis-util": "^5.0.1"
|
| 16279 |
+
}
|
| 16280 |
+
},
|
| 16281 |
+
"node_modules/vis-util": {
|
| 16282 |
+
"version": "5.0.7",
|
| 16283 |
+
"resolved": "https://registry.npmjs.org/vis-util/-/vis-util-5.0.7.tgz",
|
| 16284 |
+
"integrity": "sha512-E3L03G3+trvc/X4LXvBfih3YIHcKS2WrP0XTdZefr6W6Qi/2nNCqZfe4JFfJU6DcQLm6Gxqj2Pfl+02859oL5A==",
|
| 16285 |
+
"license": "(Apache-2.0 OR MIT)",
|
| 16286 |
+
"peer": true,
|
| 16287 |
+
"engines": {
|
| 16288 |
+
"node": ">=8"
|
| 16289 |
+
},
|
| 16290 |
+
"funding": {
|
| 16291 |
+
"type": "opencollective",
|
| 16292 |
+
"url": "https://opencollective.com/visjs"
|
| 16293 |
+
},
|
| 16294 |
+
"peerDependencies": {
|
| 16295 |
+
"@egjs/hammerjs": "^2.0.0",
|
| 16296 |
+
"component-emitter": "^1.3.0 || ^2.0.0"
|
| 16297 |
+
}
|
| 16298 |
+
},
|
| 16299 |
"node_modules/w3c-hr-time": {
|
| 16300 |
"version": "1.0.2",
|
| 16301 |
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
frontend/package.json
CHANGED
|
@@ -6,7 +6,9 @@
|
|
| 6 |
"lucide-react": "^0.577.0",
|
| 7 |
"react": "^19.2.4",
|
| 8 |
"react-dom": "^19.2.4",
|
| 9 |
-
"react-scripts": "5.0.1"
|
|
|
|
|
|
|
| 10 |
},
|
| 11 |
"scripts": {
|
| 12 |
"start": "react-scripts start",
|
|
|
|
| 6 |
"lucide-react": "^0.577.0",
|
| 7 |
"react": "^19.2.4",
|
| 8 |
"react-dom": "^19.2.4",
|
| 9 |
+
"react-scripts": "5.0.1",
|
| 10 |
+
"vis-data": "^7.1.9",
|
| 11 |
+
"vis-network": "^9.1.9"
|
| 12 |
},
|
| 13 |
"scripts": {
|
| 14 |
"start": "react-scripts start",
|
frontend/src/App.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import Header from './components/Header';
|
|
|
|
| 3 |
import './styles/variables.css';
|
| 4 |
import './styles/layout.css';
|
| 5 |
import './styles/components.css';
|
|
@@ -24,9 +25,7 @@ export default function App() {
|
|
| 24 |
<div className="app">
|
| 25 |
<Header theme={theme} onToggleTheme={toggleTheme} />
|
| 26 |
<main className="app-main">
|
| 27 |
-
<
|
| 28 |
-
Decidron Network Simulator — UI loading…
|
| 29 |
-
</p>
|
| 30 |
</main>
|
| 31 |
</div>
|
| 32 |
);
|
|
|
|
| 1 |
import React, { useState, useEffect } from 'react';
|
| 2 |
import Header from './components/Header';
|
| 3 |
+
import DecidronSimulator from './components/decidron/DecidronSimulator';
|
| 4 |
import './styles/variables.css';
|
| 5 |
import './styles/layout.css';
|
| 6 |
import './styles/components.css';
|
|
|
|
| 25 |
<div className="app">
|
| 26 |
<Header theme={theme} onToggleTheme={toggleTheme} />
|
| 27 |
<main className="app-main">
|
| 28 |
+
<DecidronSimulator />
|
|
|
|
|
|
|
| 29 |
</main>
|
| 30 |
</div>
|
| 31 |
);
|
frontend/src/components/decidron/CommandBuilder.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Plus } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
const MATCH_OPS = ['contains', 'equals', 'gt', 'gte', 'lt', 'lte', 'startswith', 'endswith', 'regex'];
|
| 5 |
+
|
| 6 |
+
export default function CommandBuilder({ commands, sensors, nodes, onCreate }) {
|
| 7 |
+
const [name, setName] = useState('');
|
| 8 |
+
const [sensor, setSensor] = useState('');
|
| 9 |
+
const [op, setOp] = useState('gte');
|
| 10 |
+
const [matchValue, setMatchValue] = useState('');
|
| 11 |
+
const [output, setOutput] = useState('');
|
| 12 |
+
const [actuator, setActuator] = useState('');
|
| 13 |
+
const [error, setError] = useState(null);
|
| 14 |
+
|
| 15 |
+
const actuators = nodes.filter((n) => n.type === 'actuator');
|
| 16 |
+
|
| 17 |
+
const submit = async (e) => {
|
| 18 |
+
e.preventDefault();
|
| 19 |
+
setError(null);
|
| 20 |
+
if (!name || !sensor || matchValue === '' || !output) {
|
| 21 |
+
setError('Name, sensor, match value, and output are required.');
|
| 22 |
+
return;
|
| 23 |
+
}
|
| 24 |
+
try {
|
| 25 |
+
await onCreate({
|
| 26 |
+
name,
|
| 27 |
+
sensor,
|
| 28 |
+
match: `${op}:${matchValue}`,
|
| 29 |
+
output,
|
| 30 |
+
actuator: actuator || null,
|
| 31 |
+
});
|
| 32 |
+
setName(''); setMatchValue(''); setOutput(''); setActuator('');
|
| 33 |
+
} catch (err) {
|
| 34 |
+
setError(err.message);
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="decidron-panel">
|
| 40 |
+
<h2>Processing Commands</h2>
|
| 41 |
+
<p className="panel-hint">
|
| 42 |
+
Define IF (sensor matches) THEN (emit output, optionally drive an
|
| 43 |
+
actuator). Commands fire when you run the simulation.
|
| 44 |
+
</p>
|
| 45 |
+
|
| 46 |
+
{error && <div className="decidron-error">{error}</div>}
|
| 47 |
+
|
| 48 |
+
<form onSubmit={submit}>
|
| 49 |
+
<div className="decidron-row">
|
| 50 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 51 |
+
<label>Name</label>
|
| 52 |
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="HighTempAlert" />
|
| 53 |
+
</div>
|
| 54 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 55 |
+
<label>Sensor</label>
|
| 56 |
+
<select value={sensor} onChange={(e) => setSensor(e.target.value)}>
|
| 57 |
+
<option value="">Select…</option>
|
| 58 |
+
{sensors.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
| 59 |
+
</select>
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
<div className="decidron-row">
|
| 63 |
+
<div className="decidron-field" style={{ width: 120 }}>
|
| 64 |
+
<label>Match</label>
|
| 65 |
+
<select value={op} onChange={(e) => setOp(e.target.value)}>
|
| 66 |
+
{MATCH_OPS.map((o) => <option key={o} value={o}>{o}</option>)}
|
| 67 |
+
</select>
|
| 68 |
+
</div>
|
| 69 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 70 |
+
<label>Value</label>
|
| 71 |
+
<input value={matchValue} onChange={(e) => setMatchValue(e.target.value)} placeholder="85" />
|
| 72 |
+
</div>
|
| 73 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 74 |
+
<label>Actuator (optional)</label>
|
| 75 |
+
<select value={actuator} onChange={(e) => setActuator(e.target.value)}>
|
| 76 |
+
<option value="">None</option>
|
| 77 |
+
{actuators.map((a) => <option key={a.id} value={a.id}>{a.label}</option>)}
|
| 78 |
+
</select>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
<div className="decidron-field">
|
| 82 |
+
<label>Output</label>
|
| 83 |
+
<input value={output} onChange={(e) => setOutput(e.target.value)} placeholder="Trigger failsafe review" />
|
| 84 |
+
</div>
|
| 85 |
+
<button type="submit" className="decidron-btn">
|
| 86 |
+
<Plus size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
|
| 87 |
+
Add Command
|
| 88 |
+
</button>
|
| 89 |
+
</form>
|
| 90 |
+
|
| 91 |
+
<div style={{ marginTop: '0.75rem' }}>
|
| 92 |
+
{commands.map((c) => (
|
| 93 |
+
<div className="decidron-cmd" key={c.name}>
|
| 94 |
+
<strong>{c.name}</strong>: IF <code>{c.sensor}</code> <code>{c.match}</code> THEN {c.output}
|
| 95 |
+
{c.actuator && <> → drives <code>{c.actuator}</code></>}
|
| 96 |
+
{c.network_ops && c.network_ops.length > 0 && <> <span className="decidron-chip">{c.network_ops.length} network op(s)</span></>}
|
| 97 |
+
</div>
|
| 98 |
+
))}
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
);
|
| 102 |
+
}
|
frontend/src/components/decidron/DecidronSimulator.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { RotateCcw } from 'lucide-react';
|
| 3 |
+
import NetworkDiagram from './NetworkDiagram';
|
| 4 |
+
import SensorInputForm from './SensorInputForm';
|
| 5 |
+
import CommandBuilder from './CommandBuilder';
|
| 6 |
+
import ResultsPanel from './ResultsPanel';
|
| 7 |
+
import StatsTable from './StatsTable';
|
| 8 |
+
import HistoryTimeline from './HistoryTimeline';
|
| 9 |
+
import {
|
| 10 |
+
getNetwork, getCommands, getStats, getHistory,
|
| 11 |
+
submitSensorInput, runSimulation, createCommand, resetSimulation,
|
| 12 |
+
} from '../../utils/api';
|
| 13 |
+
import '../../styles/decidron.css';
|
| 14 |
+
|
| 15 |
+
export default function DecidronSimulator() {
|
| 16 |
+
const [network, setNetwork] = useState(null);
|
| 17 |
+
const [commands, setCommands] = useState([]);
|
| 18 |
+
const [stats, setStats] = useState(null);
|
| 19 |
+
const [history, setHistory] = useState([]);
|
| 20 |
+
const [results, setResults] = useState([]);
|
| 21 |
+
const [pending, setPending] = useState([]);
|
| 22 |
+
const [selected, setSelected] = useState(null); // null = live network
|
| 23 |
+
const [running, setRunning] = useState(false);
|
| 24 |
+
const [error, setError] = useState(null);
|
| 25 |
+
|
| 26 |
+
const refreshAll = useCallback(async () => {
|
| 27 |
+
try {
|
| 28 |
+
const [net, cmds, st, hist] = await Promise.all([
|
| 29 |
+
getNetwork(), getCommands(), getStats(), getHistory(),
|
| 30 |
+
]);
|
| 31 |
+
setNetwork(net);
|
| 32 |
+
setCommands(cmds);
|
| 33 |
+
setStats(st);
|
| 34 |
+
setHistory(hist.snapshots);
|
| 35 |
+
} catch (err) {
|
| 36 |
+
setError(err.message);
|
| 37 |
+
}
|
| 38 |
+
}, []);
|
| 39 |
+
|
| 40 |
+
useEffect(() => { refreshAll(); }, [refreshAll]);
|
| 41 |
+
|
| 42 |
+
const handleQueue = async (channel, value) => {
|
| 43 |
+
setError(null);
|
| 44 |
+
try {
|
| 45 |
+
await submitSensorInput(channel, value);
|
| 46 |
+
setPending((prev) => [...prev, { channel, value }]);
|
| 47 |
+
} catch (err) {
|
| 48 |
+
setError(err.message);
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
const handleRun = async () => {
|
| 53 |
+
setRunning(true);
|
| 54 |
+
setError(null);
|
| 55 |
+
try {
|
| 56 |
+
const resp = await runSimulation();
|
| 57 |
+
setResults(resp.results);
|
| 58 |
+
setNetwork(resp.network);
|
| 59 |
+
setSelected(null);
|
| 60 |
+
setPending([]);
|
| 61 |
+
const [st, hist] = await Promise.all([getStats(), getHistory()]);
|
| 62 |
+
setStats(st);
|
| 63 |
+
setHistory(hist.snapshots);
|
| 64 |
+
} catch (err) {
|
| 65 |
+
setError(err.message);
|
| 66 |
+
} finally {
|
| 67 |
+
setRunning(false);
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const handleCreate = async (cmd) => {
|
| 72 |
+
await createCommand(cmd);
|
| 73 |
+
setCommands(await getCommands());
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
const handleReset = async () => {
|
| 77 |
+
setError(null);
|
| 78 |
+
try {
|
| 79 |
+
await resetSimulation();
|
| 80 |
+
setResults([]);
|
| 81 |
+
setPending([]);
|
| 82 |
+
setSelected(null);
|
| 83 |
+
await refreshAll();
|
| 84 |
+
} catch (err) {
|
| 85 |
+
setError(err.message);
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
if (!network) {
|
| 90 |
+
return (
|
| 91 |
+
<div className="decidron-app">
|
| 92 |
+
{error ? <div className="decidron-error">{error}</div> : <p>Loading…</p>}
|
| 93 |
+
</div>
|
| 94 |
+
);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const sensors = network.nodes.filter((n) => n.type === 'sensor');
|
| 98 |
+
const shownDiagram = selected ? selected.diagram : network.diagram;
|
| 99 |
+
const shownVersion = selected ? selected.version : network.version;
|
| 100 |
+
|
| 101 |
+
return (
|
| 102 |
+
<div className="decidron-app">
|
| 103 |
+
{error && <div className="decidron-error">{error}</div>}
|
| 104 |
+
<div className="decidron-grid">
|
| 105 |
+
<div>
|
| 106 |
+
<div className="decidron-panel">
|
| 107 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 108 |
+
<h2>Decidron Network (v{shownVersion}{selected ? ' — historical' : ' — live'})</h2>
|
| 109 |
+
<button type="button" className="decidron-btn secondary" onClick={handleReset}>
|
| 110 |
+
<RotateCcw size={13} style={{ marginRight: 4, verticalAlign: 'middle' }} />
|
| 111 |
+
Reset
|
| 112 |
+
</button>
|
| 113 |
+
</div>
|
| 114 |
+
<p className="panel-hint">
|
| 115 |
+
Sensors (blue) feed coupled Decidrons (purple) that drive
|
| 116 |
+
actuators (green). Commands can modify this topology.
|
| 117 |
+
</p>
|
| 118 |
+
<NetworkDiagram data={shownDiagram} />
|
| 119 |
+
</div>
|
| 120 |
+
<HistoryTimeline
|
| 121 |
+
snapshots={history}
|
| 122 |
+
selectedVersion={selected ? selected.version : null}
|
| 123 |
+
onSelect={(s) => setSelected(
|
| 124 |
+
s.version === network.version ? null : s
|
| 125 |
+
)}
|
| 126 |
+
/>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div>
|
| 130 |
+
<SensorInputForm
|
| 131 |
+
sensors={sensors}
|
| 132 |
+
pending={pending}
|
| 133 |
+
onQueue={handleQueue}
|
| 134 |
+
onRun={handleRun}
|
| 135 |
+
running={running}
|
| 136 |
+
/>
|
| 137 |
+
<CommandBuilder
|
| 138 |
+
commands={commands}
|
| 139 |
+
sensors={sensors}
|
| 140 |
+
nodes={network.nodes}
|
| 141 |
+
onCreate={handleCreate}
|
| 142 |
+
/>
|
| 143 |
+
<ResultsPanel results={results} />
|
| 144 |
+
<StatsTable stats={stats} />
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
);
|
| 149 |
+
}
|
frontend/src/components/decidron/HistoryTimeline.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
export default function HistoryTimeline({ snapshots, selectedVersion, onSelect }) {
|
| 4 |
+
return (
|
| 5 |
+
<div className="decidron-panel">
|
| 6 |
+
<h2>Network Modification History</h2>
|
| 7 |
+
<p className="panel-hint">
|
| 8 |
+
Each topology change is snapshotted. Select a version to view that
|
| 9 |
+
network state in the diagram above.
|
| 10 |
+
</p>
|
| 11 |
+
{(!snapshots || snapshots.length === 0) ? (
|
| 12 |
+
<p className="panel-hint">No snapshots yet.</p>
|
| 13 |
+
) : (
|
| 14 |
+
snapshots.map((s) => (
|
| 15 |
+
<div
|
| 16 |
+
key={s.version}
|
| 17 |
+
className={`decidron-history-item ${s.version === selectedVersion ? 'active' : ''}`}
|
| 18 |
+
onClick={() => onSelect(s)}
|
| 19 |
+
>
|
| 20 |
+
<strong>v{s.version}</strong> — {s.reason}
|
| 21 |
+
<span style={{ float: 'right', opacity: 0.6 }}>
|
| 22 |
+
{new Date(s.timestamp * 1000).toLocaleTimeString()}
|
| 23 |
+
</span>
|
| 24 |
+
</div>
|
| 25 |
+
))
|
| 26 |
+
)}
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
frontend/src/components/decidron/NetworkDiagram.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useRef } from 'react';
|
| 2 |
+
import { Network } from 'vis-network/standalone';
|
| 3 |
+
|
| 4 |
+
const OPTIONS = {
|
| 5 |
+
autoResize: true,
|
| 6 |
+
nodes: {
|
| 7 |
+
borderWidth: 2,
|
| 8 |
+
font: { color: '#ffffff', size: 14 },
|
| 9 |
+
},
|
| 10 |
+
edges: {
|
| 11 |
+
arrows: 'to',
|
| 12 |
+
color: { color: '#9aa3b2', highlight: '#6366F1' },
|
| 13 |
+
font: { size: 10, color: '#9aa3b2', strokeWidth: 0, align: 'middle' },
|
| 14 |
+
smooth: { type: 'cubicBezier', roundness: 0.4 },
|
| 15 |
+
},
|
| 16 |
+
groups: {
|
| 17 |
+
sensor: { shape: 'box', color: { background: '#3b82f6', border: '#1d4ed8' } },
|
| 18 |
+
decidron: { shape: 'hexagon', color: { background: '#8b5cf6', border: '#6d28d9' } },
|
| 19 |
+
actuator: { shape: 'triangle', color: { background: '#10b981', border: '#047857' } },
|
| 20 |
+
},
|
| 21 |
+
physics: {
|
| 22 |
+
stabilization: { iterations: 150 },
|
| 23 |
+
barnesHut: { springLength: 150, gravitationalConstant: -6000 },
|
| 24 |
+
},
|
| 25 |
+
interaction: { hover: true, tooltipDelay: 120 },
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
export default function NetworkDiagram({ data, height = 380 }) {
|
| 29 |
+
const containerRef = useRef(null);
|
| 30 |
+
const networkRef = useRef(null);
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
if (!containerRef.current) return undefined;
|
| 34 |
+
networkRef.current = new Network(
|
| 35 |
+
containerRef.current,
|
| 36 |
+
data || { nodes: [], edges: [] },
|
| 37 |
+
OPTIONS,
|
| 38 |
+
);
|
| 39 |
+
return () => {
|
| 40 |
+
if (networkRef.current) {
|
| 41 |
+
networkRef.current.destroy();
|
| 42 |
+
networkRef.current = null;
|
| 43 |
+
}
|
| 44 |
+
};
|
| 45 |
+
}, [data]);
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div
|
| 49 |
+
ref={containerRef}
|
| 50 |
+
className="decidron-diagram"
|
| 51 |
+
style={{ height, width: '100%' }}
|
| 52 |
+
/>
|
| 53 |
+
);
|
| 54 |
+
}
|
frontend/src/components/decidron/ResultsPanel.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
export default function ResultsPanel({ results }) {
|
| 4 |
+
return (
|
| 5 |
+
<div className="decidron-panel">
|
| 6 |
+
<h2>Latest Run Results</h2>
|
| 7 |
+
{(!results || results.length === 0) ? (
|
| 8 |
+
<p className="panel-hint">No runs yet. Queue a sensor input and run the simulation.</p>
|
| 9 |
+
) : (
|
| 10 |
+
<table className="decidron-table">
|
| 11 |
+
<thead>
|
| 12 |
+
<tr>
|
| 13 |
+
<th>Command</th>
|
| 14 |
+
<th>Sensor</th>
|
| 15 |
+
<th>Value</th>
|
| 16 |
+
<th>Result</th>
|
| 17 |
+
<th>Output</th>
|
| 18 |
+
<th>Net ops</th>
|
| 19 |
+
</tr>
|
| 20 |
+
</thead>
|
| 21 |
+
<tbody>
|
| 22 |
+
{results.map((r, i) => (
|
| 23 |
+
<tr key={i}>
|
| 24 |
+
<td>{r.command}</td>
|
| 25 |
+
<td>{r.sensor}</td>
|
| 26 |
+
<td>{r.value}</td>
|
| 27 |
+
<td>
|
| 28 |
+
<span className={`decidron-chip ${r.matched ? 'match' : 'nomatch'}`}>
|
| 29 |
+
{r.matched ? 'matched' : 'no match'}
|
| 30 |
+
</span>
|
| 31 |
+
</td>
|
| 32 |
+
<td>{r.output || '—'}{r.actuator ? ` → ${r.actuator}` : ''}</td>
|
| 33 |
+
<td>{r.network_ops_applied}</td>
|
| 34 |
+
</tr>
|
| 35 |
+
))}
|
| 36 |
+
</tbody>
|
| 37 |
+
</table>
|
| 38 |
+
)}
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
}
|
frontend/src/components/decidron/SensorInputForm.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Activity, Play } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
export default function SensorInputForm({ sensors, pending, onQueue, onRun, running }) {
|
| 5 |
+
const [channel, setChannel] = useState('');
|
| 6 |
+
const [value, setValue] = useState('');
|
| 7 |
+
|
| 8 |
+
useEffect(() => {
|
| 9 |
+
if (!channel && sensors.length) setChannel(sensors[0].id);
|
| 10 |
+
}, [sensors, channel]);
|
| 11 |
+
|
| 12 |
+
const handleQueue = (e) => {
|
| 13 |
+
e.preventDefault();
|
| 14 |
+
if (!channel || value === '') return;
|
| 15 |
+
onQueue(channel, value);
|
| 16 |
+
setValue('');
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<div className="decidron-panel">
|
| 21 |
+
<h2>Sensor Input</h2>
|
| 22 |
+
<p className="panel-hint">
|
| 23 |
+
Simulate a sensor reading, then run the network to process queued
|
| 24 |
+
inputs against your processing commands.
|
| 25 |
+
</p>
|
| 26 |
+
<form onSubmit={handleQueue}>
|
| 27 |
+
<div className="decidron-row">
|
| 28 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 29 |
+
<label>Channel</label>
|
| 30 |
+
<select value={channel} onChange={(e) => setChannel(e.target.value)}>
|
| 31 |
+
{sensors.map((s) => (
|
| 32 |
+
<option key={s.id} value={s.id}>{s.label}</option>
|
| 33 |
+
))}
|
| 34 |
+
</select>
|
| 35 |
+
</div>
|
| 36 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 37 |
+
<label>Value</label>
|
| 38 |
+
<input
|
| 39 |
+
type="text"
|
| 40 |
+
value={value}
|
| 41 |
+
placeholder="e.g. 97"
|
| 42 |
+
onChange={(e) => setValue(e.target.value)}
|
| 43 |
+
/>
|
| 44 |
+
</div>
|
| 45 |
+
<button type="submit" className="decidron-btn secondary">
|
| 46 |
+
<Activity size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
|
| 47 |
+
Queue
|
| 48 |
+
</button>
|
| 49 |
+
</div>
|
| 50 |
+
</form>
|
| 51 |
+
|
| 52 |
+
{pending.length > 0 && (
|
| 53 |
+
<ul className="decidron-pending">
|
| 54 |
+
{pending.map((p, i) => (
|
| 55 |
+
<li key={i}>{p.channel}: {p.value}</li>
|
| 56 |
+
))}
|
| 57 |
+
</ul>
|
| 58 |
+
)}
|
| 59 |
+
|
| 60 |
+
<div className="decidron-row" style={{ marginTop: '0.5rem' }}>
|
| 61 |
+
<button
|
| 62 |
+
type="button"
|
| 63 |
+
className="decidron-btn"
|
| 64 |
+
onClick={onRun}
|
| 65 |
+
disabled={running || pending.length === 0}
|
| 66 |
+
>
|
| 67 |
+
<Play size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
|
| 68 |
+
{running ? 'Running…' : `Run Simulation (${pending.length})`}
|
| 69 |
+
</button>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
}
|
frontend/src/components/decidron/StatsTable.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
function Section({ title, data }) {
|
| 4 |
+
const rows = Object.entries(data || {});
|
| 5 |
+
if (rows.length === 0) return null;
|
| 6 |
+
return (
|
| 7 |
+
<>
|
| 8 |
+
<tr><th colSpan={4} style={{ paddingTop: '0.6rem' }}>{title}</th></tr>
|
| 9 |
+
{rows.map(([key, s]) => (
|
| 10 |
+
<tr key={title + key}>
|
| 11 |
+
<td>{key}</td>
|
| 12 |
+
<td>{s.count}</td>
|
| 13 |
+
<td>{s.matches}</td>
|
| 14 |
+
<td>{s.avg_latency_ms != null ? s.avg_latency_ms.toFixed(3) : '—'}</td>
|
| 15 |
+
</tr>
|
| 16 |
+
))}
|
| 17 |
+
</>
|
| 18 |
+
);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export default function StatsTable({ stats }) {
|
| 22 |
+
const empty = !stats || stats.total_runs === 0;
|
| 23 |
+
return (
|
| 24 |
+
<div className="decidron-panel">
|
| 25 |
+
<h2>Performance Statistics</h2>
|
| 26 |
+
{empty ? (
|
| 27 |
+
<p className="panel-hint">Run the simulation to collect statistics.</p>
|
| 28 |
+
) : (
|
| 29 |
+
<>
|
| 30 |
+
<p className="panel-hint">
|
| 31 |
+
{stats.total_runs} command evaluation(s), {stats.total_matches} match(es).
|
| 32 |
+
</p>
|
| 33 |
+
<table className="decidron-table">
|
| 34 |
+
<thead>
|
| 35 |
+
<tr><th>Key</th><th>Runs</th><th>Matches</th><th>Avg ms</th></tr>
|
| 36 |
+
</thead>
|
| 37 |
+
<tbody>
|
| 38 |
+
<Section title="Per Sensor" data={stats.per_sensor} />
|
| 39 |
+
<Section title="Per Command" data={stats.per_command} />
|
| 40 |
+
<Section title="Per Output" data={stats.per_output} />
|
| 41 |
+
</tbody>
|
| 42 |
+
</table>
|
| 43 |
+
</>
|
| 44 |
+
)}
|
| 45 |
+
</div>
|
| 46 |
+
);
|
| 47 |
+
}
|
frontend/src/styles/decidron.css
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.decidron-app {
|
| 2 |
+
max-width: 1280px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
padding: 1rem 1.25rem 3rem;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
.decidron-grid {
|
| 8 |
+
display: grid;
|
| 9 |
+
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
| 10 |
+
gap: 1rem;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
@media (max-width: 980px) {
|
| 14 |
+
.decidron-grid { grid-template-columns: 1fr; }
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.decidron-panel {
|
| 18 |
+
background: var(--card-bg);
|
| 19 |
+
border: 1px solid var(--border-primary);
|
| 20 |
+
border-radius: 12px;
|
| 21 |
+
box-shadow: var(--shadow-sm);
|
| 22 |
+
padding: 1rem;
|
| 23 |
+
margin-bottom: 1rem;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.decidron-panel h2 {
|
| 27 |
+
font-size: 0.95rem;
|
| 28 |
+
font-weight: 700;
|
| 29 |
+
color: var(--text-primary);
|
| 30 |
+
margin-bottom: 0.25rem;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.decidron-panel .panel-hint {
|
| 34 |
+
font-size: 0.78rem;
|
| 35 |
+
color: var(--text-tertiary);
|
| 36 |
+
margin-bottom: 0.75rem;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.decidron-diagram {
|
| 40 |
+
background: var(--bg-secondary);
|
| 41 |
+
border: 1px solid var(--border-primary);
|
| 42 |
+
border-radius: 10px;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.decidron-field {
|
| 46 |
+
display: flex;
|
| 47 |
+
flex-direction: column;
|
| 48 |
+
gap: 0.25rem;
|
| 49 |
+
margin-bottom: 0.6rem;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.decidron-field label {
|
| 53 |
+
font-size: 0.75rem;
|
| 54 |
+
font-weight: 600;
|
| 55 |
+
color: var(--text-secondary);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.decidron-field input,
|
| 59 |
+
.decidron-field select {
|
| 60 |
+
padding: 0.45rem 0.55rem;
|
| 61 |
+
border: 1px solid var(--border-primary);
|
| 62 |
+
border-radius: 8px;
|
| 63 |
+
background: var(--bg-primary);
|
| 64 |
+
color: var(--text-primary);
|
| 65 |
+
font-size: 0.85rem;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.decidron-row {
|
| 69 |
+
display: flex;
|
| 70 |
+
gap: 0.5rem;
|
| 71 |
+
flex-wrap: wrap;
|
| 72 |
+
align-items: flex-end;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.decidron-btn {
|
| 76 |
+
padding: 0.5rem 0.9rem;
|
| 77 |
+
border: none;
|
| 78 |
+
border-radius: 8px;
|
| 79 |
+
background: var(--accent-gradient);
|
| 80 |
+
color: #fff;
|
| 81 |
+
font-size: 0.85rem;
|
| 82 |
+
font-weight: 600;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.decidron-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 86 |
+
|
| 87 |
+
.decidron-btn.secondary {
|
| 88 |
+
background: transparent;
|
| 89 |
+
color: var(--text-secondary);
|
| 90 |
+
border: 1px solid var(--border-primary);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.decidron-table {
|
| 94 |
+
width: 100%;
|
| 95 |
+
border-collapse: collapse;
|
| 96 |
+
font-size: 0.8rem;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.decidron-table th,
|
| 100 |
+
.decidron-table td {
|
| 101 |
+
text-align: left;
|
| 102 |
+
padding: 0.4rem 0.5rem;
|
| 103 |
+
border-bottom: 1px solid var(--border-muted);
|
| 104 |
+
color: var(--text-secondary);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.decidron-table th { color: var(--text-tertiary); font-weight: 600; }
|
| 108 |
+
|
| 109 |
+
.decidron-chip {
|
| 110 |
+
display: inline-block;
|
| 111 |
+
padding: 0.1rem 0.45rem;
|
| 112 |
+
border-radius: 999px;
|
| 113 |
+
font-size: 0.7rem;
|
| 114 |
+
font-weight: 600;
|
| 115 |
+
background: var(--accent-light);
|
| 116 |
+
color: var(--accent-primary);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.decidron-chip.match { background: var(--speaker-b-bg); color: var(--speaker-b-color); }
|
| 120 |
+
.decidron-chip.nomatch { background: var(--bg-tertiary); color: var(--text-tertiary); }
|
| 121 |
+
|
| 122 |
+
.decidron-pending {
|
| 123 |
+
list-style: none;
|
| 124 |
+
display: flex;
|
| 125 |
+
flex-wrap: wrap;
|
| 126 |
+
gap: 0.35rem;
|
| 127 |
+
margin: 0.4rem 0;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.decidron-pending li {
|
| 131 |
+
font-size: 0.75rem;
|
| 132 |
+
padding: 0.15rem 0.5rem;
|
| 133 |
+
border-radius: 6px;
|
| 134 |
+
background: var(--comp-bg);
|
| 135 |
+
border: 1px solid var(--comp-border);
|
| 136 |
+
color: var(--text-secondary);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.decidron-history-item {
|
| 140 |
+
padding: 0.5rem 0.6rem;
|
| 141 |
+
border: 1px solid var(--border-primary);
|
| 142 |
+
border-radius: 8px;
|
| 143 |
+
margin-bottom: 0.4rem;
|
| 144 |
+
cursor: pointer;
|
| 145 |
+
font-size: 0.8rem;
|
| 146 |
+
color: var(--text-secondary);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.decidron-history-item.active {
|
| 150 |
+
border-color: var(--accent-primary);
|
| 151 |
+
background: var(--accent-light);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.decidron-error {
|
| 155 |
+
background: var(--error-bg);
|
| 156 |
+
border: 1px solid var(--error-border);
|
| 157 |
+
color: var(--error-text);
|
| 158 |
+
padding: 0.5rem 0.75rem;
|
| 159 |
+
border-radius: 8px;
|
| 160 |
+
font-size: 0.82rem;
|
| 161 |
+
margin-bottom: 0.75rem;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.decidron-cmd {
|
| 165 |
+
border: 1px solid var(--border-primary);
|
| 166 |
+
border-radius: 8px;
|
| 167 |
+
padding: 0.5rem 0.6rem;
|
| 168 |
+
margin-bottom: 0.4rem;
|
| 169 |
+
font-size: 0.8rem;
|
| 170 |
+
color: var(--text-secondary);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.decidron-cmd code {
|
| 174 |
+
background: var(--bg-tertiary);
|
| 175 |
+
padding: 0.05rem 0.3rem;
|
| 176 |
+
border-radius: 4px;
|
| 177 |
+
font-size: 0.75rem;
|
| 178 |
+
}
|
frontend/src/utils/api.js
CHANGED
|
@@ -1,385 +1,62 @@
|
|
| 1 |
-
//
|
| 2 |
-
// done inside Docker (Dockerfile sets REACT_APP_API_URL=) or on the host
|
| 3 |
-
// without that env var - hits the same origin that served the page. This
|
| 4 |
-
// avoids the cross-origin trap where a `npm run build` on the host
|
| 5 |
-
// without REACT_APP_API_URL would silently bake `http://localhost:8000`
|
| 6 |
-
// into the bundle and break every API call from a Docker deployment.
|
| 7 |
//
|
| 8 |
-
//
|
| 9 |
-
//
|
| 10 |
-
// in
|
| 11 |
const API_BASE = process.env.REACT_APP_API_URL !== undefined
|
| 12 |
? process.env.REACT_APP_API_URL
|
| 13 |
: '';
|
| 14 |
|
| 15 |
-
|
| 16 |
-
const resp = await fetch(`${API_BASE}
|
| 17 |
-
if (!resp.ok) throw new Error(`Failed to fetch models: ${resp.status}`);
|
| 18 |
-
return resp.json();
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
export async function fetchPersonas() {
|
| 22 |
-
const resp = await fetch(`${API_BASE}/api/personas`, { cache: 'no-store' });
|
| 23 |
-
if (!resp.ok) throw new Error(`Failed to fetch personas: ${resp.status}`);
|
| 24 |
-
return resp.json();
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
export async function fetchDemoQuestions() {
|
| 28 |
-
const resp = await fetch(`${API_BASE}/api/demo-questions`, { cache: 'no-store' });
|
| 29 |
-
if (!resp.ok) throw new Error(`Failed to fetch demo questions: ${resp.status}`);
|
| 30 |
-
return resp.json();
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
export async function generateRole({ model_id, name, profile, identity, samples, role_style }) {
|
| 34 |
-
const resp = await fetch(`${API_BASE}/api/chat/generate-role`, {
|
| 35 |
-
method: 'POST',
|
| 36 |
headers: { 'Content-Type': 'application/json' },
|
| 37 |
-
|
|
|
|
| 38 |
});
|
| 39 |
if (!resp.ok) {
|
| 40 |
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 41 |
-
throw new Error(err.detail ||
|
| 42 |
-
}
|
| 43 |
-
return resp.json();
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
export async function generateRoleFreeform({ model_id, name, text, role_style }) {
|
| 47 |
-
const resp = await fetch(`${API_BASE}/api/chat/generate-role-freeform`, {
|
| 48 |
-
method: 'POST',
|
| 49 |
-
headers: { 'Content-Type': 'application/json' },
|
| 50 |
-
body: JSON.stringify({ model_id, name, text, role_style }),
|
| 51 |
-
});
|
| 52 |
-
if (!resp.ok) {
|
| 53 |
-
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 54 |
-
throw new Error(err.detail || 'Role generation failed');
|
| 55 |
-
}
|
| 56 |
-
return resp.json();
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
/**
|
| 60 |
-
* Start a CCAI conversation and consume the SSE stream.
|
| 61 |
-
*
|
| 62 |
-
* Body: { question, participants[], expert_personas[], model_assignments,
|
| 63 |
-
* orchestrator_model_id, summarizer_model_id, max_participants }
|
| 64 |
-
*/
|
| 65 |
-
export async function startChat(body, handlers, abortSignal) {
|
| 66 |
-
const resp = await fetch(`${API_BASE}/api/chat/start`, {
|
| 67 |
-
method: 'POST',
|
| 68 |
-
headers: { 'Content-Type': 'application/json' },
|
| 69 |
-
body: JSON.stringify(body),
|
| 70 |
-
signal: abortSignal,
|
| 71 |
-
});
|
| 72 |
-
|
| 73 |
-
if (!resp.ok) {
|
| 74 |
-
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 75 |
-
throw new Error(err.detail || 'Chat start failed');
|
| 76 |
}
|
| 77 |
-
|
| 78 |
-
const reader = resp.body.getReader();
|
| 79 |
-
const decoder = new TextDecoder();
|
| 80 |
-
let buffer = '';
|
| 81 |
-
|
| 82 |
-
try {
|
| 83 |
-
while (true) {
|
| 84 |
-
if (abortSignal?.aborted) break;
|
| 85 |
-
const { value, done } = await reader.read();
|
| 86 |
-
if (done) break;
|
| 87 |
-
buffer += decoder.decode(value, { stream: true });
|
| 88 |
-
|
| 89 |
-
const parts = buffer.split('\n\n');
|
| 90 |
-
buffer = parts.pop();
|
| 91 |
-
|
| 92 |
-
for (const part of parts) {
|
| 93 |
-
const lines = part.trim().split('\n');
|
| 94 |
-
let eventType = 'message';
|
| 95 |
-
let data = '';
|
| 96 |
-
for (const line of lines) {
|
| 97 |
-
if (line.startsWith('event: ')) eventType = line.slice(7).trim();
|
| 98 |
-
else if (line.startsWith('data: ')) data = line.slice(6);
|
| 99 |
-
}
|
| 100 |
-
if (!data) continue;
|
| 101 |
-
try {
|
| 102 |
-
const parsed = JSON.parse(data);
|
| 103 |
-
const handler = handlers[eventHandlerKey(eventType)];
|
| 104 |
-
if (handler) handler(parsed);
|
| 105 |
-
} catch (e) {
|
| 106 |
-
console.warn('SSE parse error', e, data);
|
| 107 |
-
}
|
| 108 |
-
}
|
| 109 |
-
}
|
| 110 |
-
} finally {
|
| 111 |
-
reader.releaseLock();
|
| 112 |
-
}
|
| 113 |
-
handlers.onDone?.();
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
function eventHandlerKey(eventType) {
|
| 117 |
-
switch (eventType) {
|
| 118 |
-
case 'session': return 'onSession';
|
| 119 |
-
case 'message': return 'onMessage';
|
| 120 |
-
case 'message_stream_start': return 'onMessageStreamStart';
|
| 121 |
-
case 'message_delta': return 'onMessageDelta';
|
| 122 |
-
case 'orchestrator': return 'onOrchestrator';
|
| 123 |
-
case 'system': return 'onSystem';
|
| 124 |
-
case 'status': return 'onStatus';
|
| 125 |
-
case 'error': return 'onError';
|
| 126 |
-
case 'done': return 'onDone';
|
| 127 |
-
case 'failsafe_pause': return 'onFailsafePause';
|
| 128 |
-
case 'orchestrator_cap_pause': return 'onOrchestratorCapPause';
|
| 129 |
-
case 'participant_error': return 'onParticipantError';
|
| 130 |
-
case 'participant_substituted': return 'onParticipantSubstituted';
|
| 131 |
-
case 'participant_replaced': return 'onParticipantReplaced';
|
| 132 |
-
case 'vote_cast': return 'onVoteCast';
|
| 133 |
-
case 'vote_tally': return 'onVoteTally';
|
| 134 |
-
case 'credentials_updated': return 'onCredentialsUpdated';
|
| 135 |
-
case 'human_turn_needed': return 'onHumanTurnNeeded';
|
| 136 |
-
case 'human_turn_cleared': return 'onHumanTurnCleared';
|
| 137 |
-
default: return null;
|
| 138 |
-
}
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
export async function continueChat(sessionId, reason) {
|
| 142 |
-
const resp = await fetch(
|
| 143 |
-
`${API_BASE}/api/chat/${sessionId}/continue?reason=${encodeURIComponent(reason)}`,
|
| 144 |
-
{ method: 'POST' },
|
| 145 |
-
);
|
| 146 |
-
if (!resp.ok) throw new Error('Continue failed');
|
| 147 |
return resp.json();
|
| 148 |
}
|
| 149 |
|
| 150 |
-
export
|
| 151 |
-
|
| 152 |
-
if (!resp.ok) throw new Error('Failed to get orchestrator');
|
| 153 |
-
return resp.json();
|
| 154 |
}
|
| 155 |
|
| 156 |
-
export
|
| 157 |
-
|
| 158 |
-
method: 'PUT',
|
| 159 |
-
headers: { 'Content-Type': 'application/json' },
|
| 160 |
-
body: JSON.stringify({ model_id: modelId }),
|
| 161 |
-
});
|
| 162 |
-
if (!resp.ok) throw new Error('Failed to set orchestrator');
|
| 163 |
-
return resp.json();
|
| 164 |
}
|
| 165 |
|
| 166 |
-
export
|
| 167 |
-
|
| 168 |
-
if (!resp.ok) throw new Error('Failed to get speed priority');
|
| 169 |
-
return resp.json();
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
export async function setSpeedPriority(enabled) {
|
| 173 |
-
const resp = await fetch(`${API_BASE}/api/chat/speed-priority`, {
|
| 174 |
-
method: 'PUT',
|
| 175 |
-
headers: { 'Content-Type': 'application/json' },
|
| 176 |
-
body: JSON.stringify({ enabled }),
|
| 177 |
-
});
|
| 178 |
-
if (!resp.ok) throw new Error('Failed to set speed priority');
|
| 179 |
-
return resp.json();
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
/**
|
| 183 |
-
* Fetch the catalog of available conversation structures and
|
| 184 |
-
* decision-making methods. The Settings menu's "Conversation format"
|
| 185 |
-
* accordion populates from this so adding a new plugin server-side
|
| 186 |
-
* doesn't require frontend code changes.
|
| 187 |
-
*
|
| 188 |
-
* Returns: { structures: [{id, name, description}, ...],
|
| 189 |
-
* decisions: [{id, name, description}, ...],
|
| 190 |
-
* default_structure_id, default_decision_id }
|
| 191 |
-
*/
|
| 192 |
-
export async function fetchConversationFormats() {
|
| 193 |
-
const resp = await fetch(`${API_BASE}/api/chat/conversation-formats`, { cache: 'no-store' });
|
| 194 |
-
if (!resp.ok) throw new Error('Failed to fetch conversation formats');
|
| 195 |
-
return resp.json();
|
| 196 |
-
}
|
| 197 |
-
|
| 198 |
-
export async function exportChat(sessionId, fmt = 'txt') {
|
| 199 |
-
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/export?fmt=${fmt}`);
|
| 200 |
-
if (!resp.ok) throw new Error('Export failed');
|
| 201 |
-
return resp.json();
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
export async function exportApiLog(sessionId) {
|
| 205 |
-
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/api-log`);
|
| 206 |
-
if (!resp.ok) throw new Error('API log export failed');
|
| 207 |
-
return resp.json();
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
export async function fetchTableView(sessionId) {
|
| 211 |
-
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/table`);
|
| 212 |
-
if (!resp.ok) throw new Error('Table view fetch failed');
|
| 213 |
-
return resp.json();
|
| 214 |
-
}
|
| 215 |
-
|
| 216 |
-
/**
|
| 217 |
-
* Fetch the catalog of every prompt template the orchestrator and
|
| 218 |
-
* participants use, grouped by phase and annotated with purpose and
|
| 219 |
-
* runtime variables. Backs the "View current chat prompts" modal.
|
| 220 |
-
*/
|
| 221 |
-
export async function fetchPromptCatalog() {
|
| 222 |
-
const resp = await fetch(`${API_BASE}/api/chat/prompts/catalog`, { cache: 'no-store' });
|
| 223 |
-
if (!resp.ok) throw new Error('Failed to fetch prompt catalog');
|
| 224 |
-
return resp.json();
|
| 225 |
-
}
|
| 226 |
-
|
| 227 |
-
/**
|
| 228 |
-
* Ask the backend to pick the top `count` participants from the
|
| 229 |
-
* candidate pool by relevance to the question. Used by the
|
| 230 |
-
* "Select N Automatically" toggle in the participants dropdown.
|
| 231 |
-
*
|
| 232 |
-
* Returns: { selected: [participant_id, ...], rationale: "..." }
|
| 233 |
-
*/
|
| 234 |
-
export async function autoSelectParticipants({ question, count, candidates, orchestrator_model_id }) {
|
| 235 |
-
const resp = await fetch(`${API_BASE}/api/chat/auto-select-participants`, {
|
| 236 |
method: 'POST',
|
| 237 |
-
|
| 238 |
-
body: JSON.stringify({ question, count, candidates, orchestrator_model_id }),
|
| 239 |
});
|
| 240 |
-
if (!resp.ok) {
|
| 241 |
-
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 242 |
-
throw new Error(err.detail || 'Auto-select failed');
|
| 243 |
-
}
|
| 244 |
-
return resp.json();
|
| 245 |
}
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
* descriptions. The frontend uses this to render the "Conversation
|
| 250 |
-
* limits" settings modal entirely from the server schema, so adding
|
| 251 |
-
* a new knob in the backend doesn't require a frontend change.
|
| 252 |
-
*
|
| 253 |
-
* Shape: { defaults: {field: int}, bounds: {field: {min, max}},
|
| 254 |
-
* descriptions: {field: {group, label, help}} }
|
| 255 |
-
*/
|
| 256 |
-
export async function fetchConversationLimitsDefaults() {
|
| 257 |
-
const resp = await fetch(`${API_BASE}/api/chat/limits/defaults`, { cache: 'no-store' });
|
| 258 |
-
if (!resp.ok) throw new Error('Failed to fetch conversation-limit defaults');
|
| 259 |
-
return resp.json();
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
export async function fetchCredentials(sessionId) {
|
| 263 |
-
const resp = await fetch(
|
| 264 |
-
`${API_BASE}/api/chat/${sessionId}/credentials`,
|
| 265 |
-
{ cache: 'no-store' },
|
| 266 |
-
);
|
| 267 |
-
if (!resp.ok) throw new Error('Credentials fetch failed');
|
| 268 |
-
return resp.json();
|
| 269 |
-
}
|
| 270 |
-
|
| 271 |
-
/**
|
| 272 |
-
* Submit the human participant's response to the orchestrator for the
|
| 273 |
-
* currently pending turn. `skip=true` flips the turn into a "declined
|
| 274 |
-
* to comment" note rather than a message.
|
| 275 |
-
*/
|
| 276 |
-
export async function submitHumanResponse(sessionId, { text, skip = false } = {}) {
|
| 277 |
-
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/human-response`, {
|
| 278 |
method: 'POST',
|
| 279 |
-
|
| 280 |
-
body: JSON.stringify({ text: text || '', skip: !!skip }),
|
| 281 |
});
|
| 282 |
-
if (!resp.ok) {
|
| 283 |
-
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 284 |
-
throw new Error(err.detail || 'Submit failed');
|
| 285 |
-
}
|
| 286 |
-
return resp.json();
|
| 287 |
}
|
| 288 |
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
* CredentialSummaryModal's edit affordance on the human's row. The
|
| 292 |
-
* backend rejects fields it doesn't know about; we send only the
|
| 293 |
-
* fields the user actually changed (sparse patch).
|
| 294 |
-
*/
|
| 295 |
-
export async function patchHumanCredential(sessionId, patch) {
|
| 296 |
-
const resp = await fetch(`${API_BASE}/api/chat/${sessionId}/credentials/human`, {
|
| 297 |
-
method: 'PATCH',
|
| 298 |
-
headers: { 'Content-Type': 'application/json' },
|
| 299 |
-
body: JSON.stringify(patch),
|
| 300 |
-
});
|
| 301 |
-
if (!resp.ok) {
|
| 302 |
-
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 303 |
-
throw new Error(err.detail || 'Edit failed');
|
| 304 |
-
}
|
| 305 |
-
return resp.json();
|
| 306 |
-
}
|
| 307 |
-
|
| 308 |
-
/**
|
| 309 |
-
* Start the AI-assisted credential intake Q&A flow. Returns either a
|
| 310 |
-
* first question or (rarely) a final summary if the LLM bails. The
|
| 311 |
-
* draft_id is needed for subsequent /answer calls.
|
| 312 |
-
*/
|
| 313 |
-
/**
|
| 314 |
-
* Generate a structured credential summary from a human's freeform
|
| 315 |
-
* profile text (experience, personality, etc.). Uses the orchestrator
|
| 316 |
-
* the same way it assesses an LLM participant's role prompt.
|
| 317 |
-
*/
|
| 318 |
-
export async function generateHumanCredentialFromProfile({
|
| 319 |
-
name, question, profile_text, participant_id, orchestrator_model_id,
|
| 320 |
-
}) {
|
| 321 |
-
const resp = await fetch(`${API_BASE}/api/chat/credentials/from-profile`, {
|
| 322 |
-
method: 'POST',
|
| 323 |
-
headers: { 'Content-Type': 'application/json' },
|
| 324 |
-
body: JSON.stringify({
|
| 325 |
-
name,
|
| 326 |
-
question,
|
| 327 |
-
profile_text,
|
| 328 |
-
participant_id: participant_id || '',
|
| 329 |
-
orchestrator_model_id: orchestrator_model_id || null,
|
| 330 |
-
}),
|
| 331 |
-
});
|
| 332 |
-
if (!resp.ok) {
|
| 333 |
-
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 334 |
-
throw new Error(err.detail || 'Credential generation failed');
|
| 335 |
-
}
|
| 336 |
-
return resp.json();
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
export async function startCredentialDraft({
|
| 340 |
-
name, question, max_questions = 6, orchestrator_model_id = null,
|
| 341 |
-
}) {
|
| 342 |
-
const resp = await fetch(`${API_BASE}/api/chat/credentials/draft`, {
|
| 343 |
-
method: 'POST',
|
| 344 |
-
headers: { 'Content-Type': 'application/json' },
|
| 345 |
-
body: JSON.stringify({ name, question, max_questions, orchestrator_model_id }),
|
| 346 |
-
});
|
| 347 |
-
if (!resp.ok) {
|
| 348 |
-
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 349 |
-
throw new Error(err.detail || 'Credential draft start failed');
|
| 350 |
-
}
|
| 351 |
-
return resp.json();
|
| 352 |
-
}
|
| 353 |
-
|
| 354 |
-
export async function answerCredentialDraft(draftId, answer) {
|
| 355 |
-
const resp = await fetch(`${API_BASE}/api/chat/credentials/draft/${draftId}/answer`, {
|
| 356 |
method: 'POST',
|
| 357 |
-
|
| 358 |
-
body: JSON.stringify({ answer: answer || '' }),
|
| 359 |
});
|
| 360 |
-
if (!resp.ok) {
|
| 361 |
-
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 362 |
-
throw new Error(err.detail || 'Credential draft answer failed');
|
| 363 |
-
}
|
| 364 |
-
return resp.json();
|
| 365 |
}
|
| 366 |
|
| 367 |
-
export
|
| 368 |
-
|
| 369 |
-
await fetch(`${API_BASE}/api/chat/credentials/draft/${draftId}`, {
|
| 370 |
-
method: 'DELETE',
|
| 371 |
-
});
|
| 372 |
-
} catch (_) { /* fire-and-forget cleanup; ignore */ }
|
| 373 |
}
|
| 374 |
|
| 375 |
-
export
|
| 376 |
-
|
| 377 |
-
if (!resp.ok) return { logged_in: false, remaining_conversations: -1 };
|
| 378 |
-
return resp.json();
|
| 379 |
}
|
| 380 |
|
| 381 |
-
export
|
| 382 |
-
|
| 383 |
-
if (!resp.ok) return { remaining: -1, daily_limit: 30 };
|
| 384 |
-
return resp.json();
|
| 385 |
}
|
|
|
|
| 1 |
+
// API client for the Decidron network simulator.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
//
|
| 3 |
+
// Defaults to relative URLs so a production build served by the FastAPI
|
| 4 |
+
// container hits the same origin. For CRA dev (`npm start` on :3000),
|
| 5 |
+
// set REACT_APP_API_URL=http://localhost:7860 in frontend/.env.development.
|
| 6 |
const API_BASE = process.env.REACT_APP_API_URL !== undefined
|
| 7 |
? process.env.REACT_APP_API_URL
|
| 8 |
: '';
|
| 9 |
|
| 10 |
+
async function jsonFetch(path, options = {}) {
|
| 11 |
+
const resp = await fetch(`${API_BASE}${path}`, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
headers: { 'Content-Type': 'application/json' },
|
| 13 |
+
cache: 'no-store',
|
| 14 |
+
...options,
|
| 15 |
});
|
| 16 |
if (!resp.ok) {
|
| 17 |
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
|
| 18 |
+
throw new Error(err.detail || `Request failed: ${resp.status}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
return resp.json();
|
| 21 |
}
|
| 22 |
|
| 23 |
+
export function getNetwork() {
|
| 24 |
+
return jsonFetch('/api/decidron/network');
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
+
export function getCommands() {
|
| 28 |
+
return jsonFetch('/api/decidron/commands');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
+
export function createCommand(command) {
|
| 32 |
+
return jsonFetch('/api/decidron/commands', {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
method: 'POST',
|
| 34 |
+
body: JSON.stringify(command),
|
|
|
|
| 35 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
+
export function submitSensorInput(channel, value) {
|
| 39 |
+
return jsonFetch('/api/decidron/sensors/input', {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
method: 'POST',
|
| 41 |
+
body: JSON.stringify({ channel, value }),
|
|
|
|
| 42 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
|
| 45 |
+
export function runSimulation(inputs = null) {
|
| 46 |
+
return jsonFetch('/api/decidron/run', {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
method: 'POST',
|
| 48 |
+
body: JSON.stringify(inputs ? { inputs } : {}),
|
|
|
|
| 49 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
+
export function getStats() {
|
| 53 |
+
return jsonFetch('/api/decidron/stats');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
+
export function getHistory() {
|
| 57 |
+
return jsonFetch('/api/decidron/history');
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
|
| 60 |
+
export function resetSimulation() {
|
| 61 |
+
return jsonFetch('/api/decidron/reset', { method: 'POST' });
|
|
|
|
|
|
|
| 62 |
}
|
frontend/src/utils/storage.js
DELETED
|
@@ -1,135 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Tiny localStorage shim for the CCAI demo.
|
| 3 |
-
*
|
| 4 |
-
* Single namespace `ccai-vibe-demo` so we never collide with anything else
|
| 5 |
-
* the host page is doing. Schema-versioned so we can migrate if/when the
|
| 6 |
-
* shape changes.
|
| 7 |
-
*/
|
| 8 |
-
|
| 9 |
-
const NS = 'ccai-vibe-demo';
|
| 10 |
-
const SCHEMA_VERSION = 1;
|
| 11 |
-
|
| 12 |
-
const DEFAULTS = {
|
| 13 |
-
schema_version: SCHEMA_VERSION,
|
| 14 |
-
expert_personas: [],
|
| 15 |
-
participants_selected: [],
|
| 16 |
-
participants_enabled: {},
|
| 17 |
-
model_assignments: {},
|
| 18 |
-
orchestrator_model_id: null,
|
| 19 |
-
summarizer_model_id: null,
|
| 20 |
-
max_participants: 5,
|
| 21 |
-
theme: null,
|
| 22 |
-
// Sparse map of user overrides to ConversationLimits fields. Empty
|
| 23 |
-
// map = "use server defaults". The server clamps anything we send,
|
| 24 |
-
// so storing whatever the user typed (including stale values from
|
| 25 |
-
// an earlier session) is safe.
|
| 26 |
-
conversation_limits: {},
|
| 27 |
-
// When true, the participants dropdown shows "Select N Automatically"
|
| 28 |
-
// as the active mode and the per-persona checkboxes are hidden.
|
| 29 |
-
// The auto-select happens just before /chat/start.
|
| 30 |
-
auto_select_mode: false,
|
| 31 |
-
// In-the-loop human participant. null when no human is configured.
|
| 32 |
-
// Shape when set:
|
| 33 |
-
// { participant_id, name, profile_text,
|
| 34 |
-
// credential_summary: {
|
| 35 |
-
// name, expertise, personality,
|
| 36 |
-
// credibility_for_question, bias_to_watch
|
| 37 |
-
// } }
|
| 38 |
-
// Persisted across page reloads so the user doesn't have to re-author
|
| 39 |
-
// their summary every session. Cleared from storage when the user
|
| 40 |
-
// removes the human via the sidebar.
|
| 41 |
-
human_participant: null,
|
| 42 |
-
// Conversation-format plugin choices (see backend
|
| 43 |
-
// /api/chat/conversation-formats). null means "use server default"
|
| 44 |
-
// — the backend returns `default_structure_id` / `default_decision_id`
|
| 45 |
-
// alongside the catalog so the UI can highlight them.
|
| 46 |
-
conversation_structure_id: null,
|
| 47 |
-
decision_method_id: null,
|
| 48 |
-
};
|
| 49 |
-
|
| 50 |
-
function readAll() {
|
| 51 |
-
try {
|
| 52 |
-
const raw = window.localStorage.getItem(NS);
|
| 53 |
-
if (!raw) return { ...DEFAULTS };
|
| 54 |
-
const parsed = JSON.parse(raw);
|
| 55 |
-
if (parsed.schema_version !== SCHEMA_VERSION) {
|
| 56 |
-
// Future-proofing: migrate or wipe. For v1 we just merge with defaults.
|
| 57 |
-
return { ...DEFAULTS, ...parsed, schema_version: SCHEMA_VERSION };
|
| 58 |
-
}
|
| 59 |
-
return { ...DEFAULTS, ...parsed };
|
| 60 |
-
} catch (err) {
|
| 61 |
-
console.warn('localStorage read failed:', err);
|
| 62 |
-
return { ...DEFAULTS };
|
| 63 |
-
}
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
function writeAll(state) {
|
| 67 |
-
try {
|
| 68 |
-
window.localStorage.setItem(NS, JSON.stringify(state));
|
| 69 |
-
} catch (err) {
|
| 70 |
-
console.warn('localStorage write failed:', err);
|
| 71 |
-
}
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
export function loadState() {
|
| 75 |
-
return readAll();
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
export function patchState(patch) {
|
| 79 |
-
const current = readAll();
|
| 80 |
-
const next = { ...current, ...patch };
|
| 81 |
-
writeAll(next);
|
| 82 |
-
return next;
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
export function setExpertPersonas(list) {
|
| 86 |
-
return patchState({ expert_personas: list });
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
export function setParticipantsSelected(ids) {
|
| 90 |
-
return patchState({ participants_selected: ids });
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
export function setParticipantsEnabled(map) {
|
| 94 |
-
return patchState({ participants_enabled: map });
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
export function setModelAssignments(map) {
|
| 98 |
-
return patchState({ model_assignments: map });
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
export function setOrchestratorModelId(modelId) {
|
| 102 |
-
return patchState({ orchestrator_model_id: modelId });
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
export function setSummarizerModelId(modelId) {
|
| 106 |
-
return patchState({ summarizer_model_id: modelId });
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
export function setMaxParticipants(n) {
|
| 110 |
-
return patchState({ max_participants: n });
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
export function setTheme(theme) {
|
| 114 |
-
return patchState({ theme });
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
export function setConversationLimits(limitsMap) {
|
| 118 |
-
return patchState({ conversation_limits: limitsMap || {} });
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
export function setAutoSelectMode(on) {
|
| 122 |
-
return patchState({ auto_select_mode: !!on });
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
export function setHumanParticipant(humanOrNull) {
|
| 126 |
-
return patchState({ human_participant: humanOrNull || null });
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
export function setConversationStructureId(id) {
|
| 130 |
-
return patchState({ conversation_structure_id: id || null });
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
export function setDecisionMethodId(id) {
|
| 134 |
-
return patchState({ decision_method_id: id || null });
|
| 135 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|