Spaces:
Running
Running
Merge pull request #1 from NeonGeckoCom/dev-cursor
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .cursor/rules/docker-build.mdc +87 -0
- .dockerignore +61 -0
- .env.example +11 -0
- .gitattributes +1 -0
- .gitignore +16 -0
- Dockerfile +48 -0
- README.md +274 -1
- backend/app/__init__.py +0 -0
- backend/app/api/__init__.py +0 -0
- backend/app/api/decidron.py +95 -0
- backend/app/config.py +28 -0
- backend/app/data/__init__.py +0 -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 +210 -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 +51 -0
- backend/app/utils/__init__.py +0 -0
- backend/dev.ps1 +28 -0
- backend/dev.sh +24 -0
- backend/requirements.txt +6 -0
- docker-compose.yml +13 -0
- frontend/.gitignore +23 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +34 -0
- frontend/public/favicon.ico +0 -0
- frontend/public/index.html +14 -0
- frontend/public/manifest.json +25 -0
- frontend/public/neon-logo.png +3 -0
- frontend/public/robots.txt +2 -0
- frontend/src/App.js +43 -0
- frontend/src/components/Header.js +34 -0
- frontend/src/components/SettingsMenu.js +88 -0
- frontend/src/components/decidron/CommandBuilder.js +160 -0
- frontend/src/components/decidron/DecidronSimulator.js +191 -0
- frontend/src/components/decidron/HistoryTimeline.js +29 -0
- frontend/src/components/decidron/NetworkDiagram.js +97 -0
- frontend/src/components/decidron/ResultsPanel.js +41 -0
- frontend/src/components/decidron/SensorInputForm.js +74 -0
- frontend/src/components/decidron/StatsTable.js +47 -0
- frontend/src/index.js +10 -0
- frontend/src/styles/ccai.css +1884 -0
- frontend/src/styles/components.css +1455 -0
- frontend/src/styles/decidron.css +236 -0
- frontend/src/styles/layout.css +176 -0
- frontend/src/styles/variables.css +108 -0
.cursor/rules/docker-build.mdc
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
description: Docker build cache conventions for CCAI-Vibe-Demo
|
| 3 |
+
globs: Dockerfile,docker-compose*.yml,.dockerignore,backend/requirements.txt,frontend/package*.json
|
| 4 |
+
alwaysApply: false
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
# Docker build cache conventions
|
| 8 |
+
|
| 9 |
+
This project uses **BuildKit cache mounts** so `npm ci` and `pip install`
|
| 10 |
+
skip the network when only source files change. Don't replace them
|
| 11 |
+
with plain `RUN` commands - that reintroduces full re-downloads on every
|
| 12 |
+
dependency change.
|
| 13 |
+
|
| 14 |
+
## Required Dockerfile pattern
|
| 15 |
+
|
| 16 |
+
The Dockerfile MUST start with the BuildKit syntax directive:
|
| 17 |
+
|
| 18 |
+
```dockerfile
|
| 19 |
+
# syntax=docker/dockerfile:1.7
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
### Frontend stage (root user, node:22-alpine)
|
| 23 |
+
|
| 24 |
+
```dockerfile
|
| 25 |
+
COPY frontend/package.json frontend/package-lock.json ./
|
| 26 |
+
RUN --mount=type=cache,target=/root/.npm \
|
| 27 |
+
npm ci
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
### Backend stage (USER user, uid 1000, python:3.12-slim)
|
| 31 |
+
|
| 32 |
+
```dockerfile
|
| 33 |
+
COPY --chown=user backend/requirements.txt ./
|
| 34 |
+
RUN --mount=type=cache,target=/home/user/.cache/pip,uid=1000,gid=1000 \
|
| 35 |
+
pip install --user -r requirements.txt
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
The `uid=1000,gid=1000` flags are mandatory because the cache target
|
| 39 |
+
sits inside the unprivileged user's home dir. Without them the cache
|
| 40 |
+
is created root-owned and pip cannot write to it.
|
| 41 |
+
|
| 42 |
+
## Don'ts
|
| 43 |
+
|
| 44 |
+
- **Don't** pass `--no-cache-dir` to `pip install`. It defeats the
|
| 45 |
+
cache mount. The mount lives outside the image, so wheels never
|
| 46 |
+
bloat the final layer either way.
|
| 47 |
+
- **Don't** `COPY .` blindly. Layers must be copy-deps-first,
|
| 48 |
+
copy-source-second so a source edit doesn't bust the deps layer.
|
| 49 |
+
- **Don't** add new files outside `backend/`, `frontend/` or the
|
| 50 |
+
lockfiles to a `COPY` line without first checking `.dockerignore`.
|
| 51 |
+
|
| 52 |
+
## Build context (`.dockerignore`)
|
| 53 |
+
|
| 54 |
+
The ignore list excludes `.git/`, `node_modules/`, `frontend/build/`,
|
| 55 |
+
`agent-transcripts/`, `__pycache__`, secrets (`.env*`),
|
| 56 |
+
`docker-compose.override.yml`, `backend/tests/`, and editor metadata.
|
| 57 |
+
If you need a previously-ignored path inside the image, add a narrow
|
| 58 |
+
`!path/to/keep` exception rather than removing the broad ignore.
|
| 59 |
+
|
| 60 |
+
## Verifying the cache works
|
| 61 |
+
|
| 62 |
+
After a Dockerfile or deps-file change, run two builds back to back:
|
| 63 |
+
|
| 64 |
+
```powershell
|
| 65 |
+
docker compose build
|
| 66 |
+
docker compose build # second time
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
The second build's `npm ci` / `pip install` step should print a
|
| 70 |
+
`CACHED` line (when nothing in the deps file changed) or a
|
| 71 |
+
near-instant `=> [internal] load build context` finish (when only
|
| 72 |
+
source files changed). If it re-downloads, the cache mount is
|
| 73 |
+
mis-configured.
|
| 74 |
+
|
| 75 |
+
If a build ever errors with `the --mount option requires BuildKit`,
|
| 76 |
+
opt in explicitly:
|
| 77 |
+
|
| 78 |
+
```powershell
|
| 79 |
+
$env:DOCKER_BUILDKIT=1; docker compose build
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
## HuggingFace Spaces compatibility
|
| 83 |
+
|
| 84 |
+
HuggingFace's Docker Space builder honors the `# syntax=` directive
|
| 85 |
+
and BuildKit cache mounts. Older builders silently treat the mounts
|
| 86 |
+
as no-ops, so this Dockerfile remains forward-compatible with any
|
| 87 |
+
plain-Docker environment.
|
.dockerignore
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build context exclusions for `docker build` / `docker compose build`.
|
| 2 |
+
# Trimming the context speeds the daemon transfer and keeps cache hashes
|
| 3 |
+
# stable: changes to ignored paths do NOT invalidate Dockerfile layers.
|
| 4 |
+
|
| 5 |
+
# ── Git / VCS metadata ───────────────────────────────────────────────
|
| 6 |
+
.git
|
| 7 |
+
.gitignore
|
| 8 |
+
.gitattributes
|
| 9 |
+
.github
|
| 10 |
+
|
| 11 |
+
# ── Cursor / agent transcripts (local IDE state, never needed in image)
|
| 12 |
+
.cursor
|
| 13 |
+
agent-transcripts
|
| 14 |
+
.commit-msg.txt
|
| 15 |
+
|
| 16 |
+
# ── Editor / OS junk ─────────────────────────────────────────────────
|
| 17 |
+
.vscode
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
Thumbs.db
|
| 21 |
+
*.swp
|
| 22 |
+
|
| 23 |
+
# ── Secrets / env (image gets env at runtime via docker-compose)
|
| 24 |
+
.env
|
| 25 |
+
.env.*
|
| 26 |
+
*.env
|
| 27 |
+
!**/.env.example
|
| 28 |
+
|
| 29 |
+
# ── Python local caches ──────────────────────────────────────────────
|
| 30 |
+
**/__pycache__
|
| 31 |
+
*.pyc
|
| 32 |
+
*.pyo
|
| 33 |
+
*.pyd
|
| 34 |
+
*.egg-info
|
| 35 |
+
.pytest_cache
|
| 36 |
+
.mypy_cache
|
| 37 |
+
.ruff_cache
|
| 38 |
+
.venv
|
| 39 |
+
venv
|
| 40 |
+
|
| 41 |
+
# ── Node local caches & build outputs ────────────────────────────────
|
| 42 |
+
# Lockfiles ARE included on purpose so `npm ci` is reproducible.
|
| 43 |
+
**/node_modules
|
| 44 |
+
frontend/build
|
| 45 |
+
frontend/.next
|
| 46 |
+
frontend/.cache
|
| 47 |
+
**/.cache
|
| 48 |
+
npm-debug.log*
|
| 49 |
+
yarn-debug.log*
|
| 50 |
+
yarn-error.log*
|
| 51 |
+
|
| 52 |
+
# ── Local-only docker compose overrides ──────────────────────────────
|
| 53 |
+
# The base docker-compose.yml is needed for `docker compose build` from
|
| 54 |
+
# the build dir, but the local override (which references absolute host
|
| 55 |
+
# paths to a secrets file outside this repo) shouldn't end up in the
|
| 56 |
+
# image build context.
|
| 57 |
+
docker-compose.override.yml
|
| 58 |
+
|
| 59 |
+
# ── Tests aren't shipped in the runtime image ────────────────────────
|
| 60 |
+
# (Re-enable if you want to run pytest inside the deployed container.)
|
| 61 |
+
backend/tests
|
.env.example
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.env
|
| 4 |
+
node_modules/
|
| 5 |
+
frontend/build/
|
| 6 |
+
backend/static/
|
| 7 |
+
frontend/.env.development
|
| 8 |
+
.DS_Store
|
| 9 |
+
*.egg-info/
|
| 10 |
+
dist/
|
| 11 |
+
.venv/
|
| 12 |
+
venv/
|
| 13 |
+
*.log
|
| 14 |
+
docker-compose.override.yml
|
| 15 |
+
docker-compose.dev-cursor.yml
|
| 16 |
+
.commit-msg.txt
|
Dockerfile
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.7
|
| 2 |
+
#
|
| 3 |
+
# BuildKit cache mounts speed up rebuilds by persisting npm and pip
|
| 4 |
+
# package caches across builds (outside the image - they don't bloat
|
| 5 |
+
# the final layer). The first `docker compose build` is unchanged;
|
| 6 |
+
# subsequent rebuilds skip the network for any unchanged dependency.
|
| 7 |
+
#
|
| 8 |
+
# The `# syntax=` directive auto-opts into BuildKit on Docker Desktop
|
| 9 |
+
# 20.10+; HuggingFace Spaces also supports this directive. Older
|
| 10 |
+
# builders silently treat the cache mounts as no-ops, so this file
|
| 11 |
+
# remains forward-compatible.
|
| 12 |
+
|
| 13 |
+
FROM node:22-alpine AS frontend-build
|
| 14 |
+
WORKDIR /app/frontend
|
| 15 |
+
|
| 16 |
+
# Copy lockfile + manifest first so the layer hash only changes when
|
| 17 |
+
# deps actually change, not on every source edit.
|
| 18 |
+
COPY frontend/package.json frontend/package-lock.json ./
|
| 19 |
+
RUN --mount=type=cache,target=/root/.npm \
|
| 20 |
+
npm ci
|
| 21 |
+
|
| 22 |
+
COPY frontend/ ./
|
| 23 |
+
ENV REACT_APP_API_URL=
|
| 24 |
+
RUN npm run build
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
FROM python:3.12-slim
|
| 28 |
+
|
| 29 |
+
RUN useradd -m -u 1000 user
|
| 30 |
+
USER user
|
| 31 |
+
ENV HOME=/home/user \
|
| 32 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 33 |
+
PYTHONUNBUFFERED=1
|
| 34 |
+
|
| 35 |
+
WORKDIR $HOME/app
|
| 36 |
+
|
| 37 |
+
# Lockfile-equivalent first; cache mount targets the unprivileged
|
| 38 |
+
# user's pip cache (uid/gid 1000 matches the `user` account).
|
| 39 |
+
COPY --chown=user backend/requirements.txt ./
|
| 40 |
+
RUN --mount=type=cache,target=/home/user/.cache/pip,uid=1000,gid=1000 \
|
| 41 |
+
pip install --user -r requirements.txt
|
| 42 |
+
|
| 43 |
+
COPY --chown=user backend/ ./
|
| 44 |
+
COPY --chown=user --from=frontend-build /app/frontend/build ./static
|
| 45 |
+
|
| 46 |
+
EXPOSE 7860
|
| 47 |
+
|
| 48 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1 +1,274 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Decidron Network Simulator
|
| 3 |
+
emoji: 🔗
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Decidron Network Simulator
|
| 12 |
+
|
| 13 |
+
A deterministic, browser-based simulator for **Decidron networks** — the
|
| 14 |
+
coupled machine-learning decision units described in US Patent
|
| 15 |
+
[US11989636B1](https://patents.google.com/patent/US11989636B1/en)
|
| 16 |
+
("System and method for persuadable collaborative conversational AI",
|
| 17 |
+
Gasper & Leeds).
|
| 18 |
+
|
| 19 |
+
You define simulated **sensor inputs** and **processing commands**
|
| 20 |
+
(IF a sensor matches THEN emit an output / drive an actuator / modify the
|
| 21 |
+
network), run the simulation, and watch the **network diagram**,
|
| 22 |
+
**performance statistics**, and **topology-change history** update live.
|
| 23 |
+
|
| 24 |
+
> This project started from Neon.ai's private `CCAI-Vibe-Demo` app and
|
| 25 |
+
> reuses its FastAPI + React scaffold, Docker setup, and visual branding.
|
| 26 |
+
> The CCAI multi-LLM orchestration was intentionally removed — v1 of the
|
| 27 |
+
> simulator is purely rule-based and needs no API keys or LLM. See
|
| 28 |
+
> [Relationship to CCAI](#relationship-to-ccai-and-the-patent).
|
| 29 |
+
|
| 30 |
+
## Patent context (brief)
|
| 31 |
+
|
| 32 |
+
A **Decidron** is a machine-learning unit (MLU) with collaborative
|
| 33 |
+
protocols that can be **coupled/networked** with other Decidrons. The
|
| 34 |
+
patent highlights that "a MLU network coupled with real-time sensors and
|
| 35 |
+
actuators can operate industrial controls ... and increased reliability
|
| 36 |
+
through parallel pathways." A Decidron records its **experience**, fires
|
| 37 |
+
**actions**, scores **results**, and can **modify its own topology**.
|
| 38 |
+
|
| 39 |
+
This simulator models those concepts deterministically:
|
| 40 |
+
|
| 41 |
+
| Patent concept | Simulator |
|
| 42 |
+
| --- | --- |
|
| 43 |
+
| Coupled MLU node / coupling | `decidron` node / directed edge |
|
| 44 |
+
| Sensors and actuators | `sensor` / `actuator` nodes |
|
| 45 |
+
| Experience Chains (ECDS) | per-node experience log (sensor received → rule fired → actuator acted) |
|
| 46 |
+
| Select & perform action, results scored | command output (+ optional actuator) + performance stats |
|
| 47 |
+
| Dynamic/extensible topology | `network_ops` applied when a command fires |
|
| 48 |
+
| Parallel pathways / reliability | redundant couplings in the seed network |
|
| 49 |
+
|
| 50 |
+
## Prerequisites
|
| 51 |
+
|
| 52 |
+
- **Docker** (recommended), or
|
| 53 |
+
- **Python 3.12+** and **Node 22+** for local dev.
|
| 54 |
+
|
| 55 |
+
## Start the application
|
| 56 |
+
|
| 57 |
+
### Docker (recommended)
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
cp .env.example .env # no secrets needed; defaults are fine
|
| 61 |
+
docker compose up --build
|
| 62 |
+
# open http://localhost:7860
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
The container builds the React frontend, serves it as static files from
|
| 66 |
+
FastAPI, and listens on port 7860. Override the host port with
|
| 67 |
+
`CCAI_HOST_PORT` in `.env` if 7860 is taken.
|
| 68 |
+
|
| 69 |
+
### Local development
|
| 70 |
+
|
| 71 |
+
Backend (FastAPI on :7860):
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
cd backend
|
| 75 |
+
python -m venv .venv
|
| 76 |
+
.venv/Scripts/activate # Windows; use source .venv/bin/activate on macOS/Linux
|
| 77 |
+
pip install -r requirements.txt
|
| 78 |
+
uvicorn app.main:app --port 7860
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
#### Hot reload (development)
|
| 82 |
+
|
| 83 |
+
Do **not** rely on `uvicorn --reload` on Windows: it detects file changes
|
| 84 |
+
but frequently fails to actually restart the worker, so it silently keeps
|
| 85 |
+
serving stale code. Instead use the bundled dev scripts, which wrap
|
| 86 |
+
uvicorn in `watchfiles` for a clean full restart on every change:
|
| 87 |
+
|
| 88 |
+
```powershell
|
| 89 |
+
cd backend
|
| 90 |
+
.\dev.ps1 # Windows
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
cd backend
|
| 95 |
+
./dev.sh # macOS/Linux/Git Bash
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
Both default to `app.main:app` on `127.0.0.1:7860`, watching `./app`.
|
| 99 |
+
`watchfiles` ships with `uvicorn[standard]`. (On non-Windows hosts plain
|
| 100 |
+
`uvicorn --reload` works too, but the scripts behave identically.)
|
| 101 |
+
|
| 102 |
+
Frontend (CRA dev server on :3000, proxying to the backend):
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
cd frontend
|
| 106 |
+
npm install
|
| 107 |
+
# point the dev server at the backend:
|
| 108 |
+
echo "REACT_APP_API_URL=http://localhost:7860" > .env.development
|
| 109 |
+
npm start
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
## Web UI workflow
|
| 113 |
+
|
| 114 |
+
1. **View the initial network diagram** — sensors (blue boxes) feed
|
| 115 |
+
coupled Decidrons (purple hexagons) that drive actuators (green
|
| 116 |
+
triangles).
|
| 117 |
+
2. **Enter a simulated sensor input** — pick a channel (e.g.
|
| 118 |
+
`Temperature Sensor`), enter a value (e.g. `97`), and click **Queue**.
|
| 119 |
+
3. **Create processing commands** — define IF (sensor + match) THEN
|
| 120 |
+
(output, optional actuator). The match is an `operator:value` rule.
|
| 121 |
+
4. **Run the simulation** — click **Run Simulation**; queued inputs are
|
| 122 |
+
evaluated against all commands.
|
| 123 |
+
5. **Read the results and statistics** — matched commands, emitted
|
| 124 |
+
outputs, and per-sensor/command/output metrics appear on the right.
|
| 125 |
+
6. **Review the network-change diagram** — if a command applied
|
| 126 |
+
`network_ops`, a new version is snapshotted; select any version in the
|
| 127 |
+
**Network Modification History** to view that topology in the diagram.
|
| 128 |
+
|
| 129 |
+
## Processing command format
|
| 130 |
+
|
| 131 |
+
A command is JSON:
|
| 132 |
+
|
| 133 |
+
```json
|
| 134 |
+
{
|
| 135 |
+
"name": "HighTempAlert",
|
| 136 |
+
"sensor": "temperature",
|
| 137 |
+
"match": "gte:85",
|
| 138 |
+
"output": "Trigger failsafe review",
|
| 139 |
+
"actuator": "actuator-1",
|
| 140 |
+
"network_ops": [
|
| 141 |
+
{ "op": "add_node", "node": { "id": "D3", "label": "Decidron D3", "type": "decidron" } },
|
| 142 |
+
{ "op": "add_edge", "from": "temperature", "to": "D3" },
|
| 143 |
+
{ "op": "remove_edge", "from": "D1", "to": "D2" }
|
| 144 |
+
]
|
| 145 |
+
}
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
- `sensor` — the sensor channel/node id this command listens to.
|
| 149 |
+
- `match` — `operator:value`, where operator is one of `contains`,
|
| 150 |
+
`equals`, `startswith`, `endswith`, `regex`, `gt`, `gte`, `lt`, `lte`.
|
| 151 |
+
A bare value (no operator) is treated as `contains`.
|
| 152 |
+
- `output` — text emitted when the rule fires.
|
| 153 |
+
- `actuator` *(optional)* — id of an actuator node to drive.
|
| 154 |
+
- `network_ops` *(optional)* — topology changes: `add_node`, `add_edge`,
|
| 155 |
+
`remove_edge` (edges use `from`/`to`).
|
| 156 |
+
|
| 157 |
+
## API reference
|
| 158 |
+
|
| 159 |
+
All endpoints are under `/api/decidron`.
|
| 160 |
+
|
| 161 |
+
| Method | Path | Purpose |
|
| 162 |
+
| --- | --- | --- |
|
| 163 |
+
| GET | `/api/decidron/network` | Current network + vis-network diagram JSON |
|
| 164 |
+
| POST | `/api/decidron/sensors/input` | Queue a simulated sensor input |
|
| 165 |
+
| GET | `/api/decidron/commands` | List processing commands |
|
| 166 |
+
| POST | `/api/decidron/commands` | Create a processing command |
|
| 167 |
+
| POST | `/api/decidron/run` | Process queued (or supplied) inputs |
|
| 168 |
+
| GET | `/api/decidron/stats` | Performance statistics |
|
| 169 |
+
| GET | `/api/decidron/history` | Topology-change snapshots |
|
| 170 |
+
| POST | `/api/decidron/reset` | Reset to the seed network |
|
| 171 |
+
|
| 172 |
+
### curl examples
|
| 173 |
+
|
| 174 |
+
```bash
|
| 175 |
+
# View the initial network
|
| 176 |
+
curl http://localhost:7860/api/decidron/network
|
| 177 |
+
|
| 178 |
+
# Queue a sensor reading and run
|
| 179 |
+
curl -X POST http://localhost:7860/api/decidron/sensors/input \
|
| 180 |
+
-H 'Content-Type: application/json' \
|
| 181 |
+
-d '{"channel":"temperature","value":"97"}'
|
| 182 |
+
|
| 183 |
+
curl -X POST http://localhost:7860/api/decidron/run \
|
| 184 |
+
-H 'Content-Type: application/json' -d '{}'
|
| 185 |
+
|
| 186 |
+
# Create a command
|
| 187 |
+
curl -X POST http://localhost:7860/api/decidron/commands \
|
| 188 |
+
-H 'Content-Type: application/json' \
|
| 189 |
+
-d '{"name":"LowTemp","sensor":"temperature","match":"lt:0","output":"Freeze warning"}'
|
| 190 |
+
|
| 191 |
+
# Run with inline inputs (bypasses the queue)
|
| 192 |
+
curl -X POST http://localhost:7860/api/decidron/run \
|
| 193 |
+
-H 'Content-Type: application/json' \
|
| 194 |
+
-d '{"inputs":[{"channel":"temperature","value":"-5"}]}'
|
| 195 |
+
|
| 196 |
+
# Stats and history
|
| 197 |
+
curl http://localhost:7860/api/decidron/stats
|
| 198 |
+
curl http://localhost:7860/api/decidron/history
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
## Config & seed files
|
| 202 |
+
|
| 203 |
+
- [`backend/app/config.py`](backend/app/config.py) — minimal settings
|
| 204 |
+
(`CORS_ORIGINS`).
|
| 205 |
+
- [`backend/app/data/decidron/default_network.json`](backend/app/data/decidron/default_network.json)
|
| 206 |
+
— the starter network (sensor → coupled Decidrons → actuator, with a
|
| 207 |
+
redundant coupling).
|
| 208 |
+
- [`backend/app/data/decidron/sample_commands.json`](backend/app/data/decidron/sample_commands.json)
|
| 209 |
+
— example commands (one drives an actuator, one reconfigures the
|
| 210 |
+
topology). Edit these to change the simulation's starting state.
|
| 211 |
+
|
| 212 |
+
## Adding files in Cursor
|
| 213 |
+
|
| 214 |
+
- **Open the repo as a workspace**: File → Open Folder → `decidron-simulator`.
|
| 215 |
+
- **New file**: right-click a folder in the Explorer → New File.
|
| 216 |
+
- **Drag & drop** files from your file manager into the Explorer.
|
| 217 |
+
- **Ask Cursor in chat**: e.g. "Add `backend/app/decidron/alerts.py`".
|
| 218 |
+
- **Terminal**: `New-Item backend/app/decidron/alerts.py` (PowerShell) or
|
| 219 |
+
`touch backend/app/decidron/alerts.py` (bash).
|
| 220 |
+
|
| 221 |
+
## Relationship to CCAI and the patent
|
| 222 |
+
|
| 223 |
+
v1 deliberately omits the patent's full pipeline: ECDS context/memory
|
| 224 |
+
augmentation, the embedded CCAI per Decidron, Theory-of-Mind,
|
| 225 |
+
persuadability, and action-reinforcement learning. A future version
|
| 226 |
+
could make Decidrons LLM-driven by porting the orchestration and LLM
|
| 227 |
+
clients from the `CCAI-Vibe-Demo` reference app. The simulator's logic is
|
| 228 |
+
isolated under `backend/app/decidron/` and
|
| 229 |
+
`frontend/src/components/decidron/` to keep that door open.
|
| 230 |
+
|
| 231 |
+
## Project layout
|
| 232 |
+
|
| 233 |
+
```
|
| 234 |
+
backend/
|
| 235 |
+
app/
|
| 236 |
+
main.py # FastAPI app: CORS, /api/health, SPA serving
|
| 237 |
+
config.py # minimal settings
|
| 238 |
+
api/decidron.py # REST router
|
| 239 |
+
decidron/ # simulation engine (models, network, engine, stats, diagrams)
|
| 240 |
+
data/decidron/ # seed network + sample commands
|
| 241 |
+
frontend/
|
| 242 |
+
src/
|
| 243 |
+
App.js # shell
|
| 244 |
+
components/Header.js # branded header
|
| 245 |
+
components/decidron/ # simulator UI (diagram, forms, tables, history)
|
| 246 |
+
utils/api.js # API client
|
| 247 |
+
styles/ # branding + simulator CSS
|
| 248 |
+
Dockerfile, docker-compose.yml
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
## Hugging Face Space (BrainForge)
|
| 252 |
+
|
| 253 |
+
Hosted as a Docker Space on [BrainForge/decidron-simulator](https://huggingface.co/spaces/BrainForge/decidron-simulator) (`app_port: 7860`, `cpu-basic`). No API keys or secrets are required.
|
| 254 |
+
|
| 255 |
+
Deploy from the **`master`** branch (merge `dev-cursor` into `master` on GitHub first):
|
| 256 |
+
|
| 257 |
+
```bash
|
| 258 |
+
git remote add hf https://huggingface.co/spaces/BrainForge/decidron-simulator
|
| 259 |
+
git checkout master && git pull origin master
|
| 260 |
+
git push hf master:main
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
The Space builds from the root `Dockerfile` (React frontend → `backend/static`, FastAPI on port 7860).
|
| 264 |
+
|
| 265 |
+
## Troubleshooting
|
| 266 |
+
|
| 267 |
+
- **Port 7860 in use** — set `CCAI_HOST_PORT` in `.env`, or run uvicorn
|
| 268 |
+
on another port.
|
| 269 |
+
- **Frontend can't reach the API in dev** — ensure
|
| 270 |
+
`REACT_APP_API_URL=http://localhost:7860` is set in
|
| 271 |
+
`frontend/.env.development` and the backend is running.
|
| 272 |
+
- **CORS errors** — add your origin to `CORS_ORIGINS` in `.env`.
|
| 273 |
+
- **Diagram doesn't render** — vis-network needs the bundled assets from
|
| 274 |
+
`npm install`; re-run it if you cloned without building.
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/api/__init__.py
ADDED
|
File without changes
|
backend/app/api/decidron.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""REST API for the Decidron network simulator."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, HTTPException
|
| 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 |
+
@router.delete("/commands/{name}")
|
| 50 |
+
async def delete_command(name: str) -> dict:
|
| 51 |
+
if not engine.remove_command(name):
|
| 52 |
+
raise HTTPException(status_code=404, detail=f"No command named '{name}'")
|
| 53 |
+
return {"deleted": name}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class RunRequest(BaseModel):
|
| 57 |
+
inputs: list[SensorInput] | None = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.post("/run")
|
| 61 |
+
async def run_simulation(req: RunRequest | None = None) -> dict:
|
| 62 |
+
inputs = req.inputs if req else None
|
| 63 |
+
results: list[RunResult] = engine.run(inputs)
|
| 64 |
+
return {
|
| 65 |
+
"results": [r.model_dump() for r in results],
|
| 66 |
+
"matches": sum(1 for r in results if r.matched),
|
| 67 |
+
"network": _network_payload(),
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.get("/stats")
|
| 72 |
+
async def get_stats() -> dict:
|
| 73 |
+
return engine.stats.as_dict()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.get("/history")
|
| 77 |
+
async def get_history() -> dict:
|
| 78 |
+
return {
|
| 79 |
+
"version": engine.network.version,
|
| 80 |
+
"snapshots": [
|
| 81 |
+
{
|
| 82 |
+
"version": s.version,
|
| 83 |
+
"reason": s.reason,
|
| 84 |
+
"timestamp": s.timestamp,
|
| 85 |
+
"diagram": to_vis_network(s.nodes, s.edges),
|
| 86 |
+
}
|
| 87 |
+
for s in engine.network.history
|
| 88 |
+
],
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.post("/reset")
|
| 93 |
+
async def reset_simulation() -> dict:
|
| 94 |
+
engine.reset()
|
| 95 |
+
return _network_payload()
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from pydantic_settings import BaseSettings
|
| 6 |
+
|
| 7 |
+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
| 8 |
+
_ENV_FILE = _PROJECT_ROOT / ".env"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Settings(BaseSettings):
|
| 12 |
+
"""Minimal app settings for the Decidron simulator.
|
| 13 |
+
|
| 14 |
+
The deterministic simulator needs no LLM provider credentials; the
|
| 15 |
+
only configurable surface today is CORS for local dev where the CRA
|
| 16 |
+
dev server (:3000) talks to the FastAPI backend (:7860).
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
cors_origins: str = "http://localhost:3000,http://localhost:7860"
|
| 20 |
+
|
| 21 |
+
model_config = {"env_file": str(_ENV_FILE), "env_file_encoding": "utf-8"}
|
| 22 |
+
|
| 23 |
+
@property
|
| 24 |
+
def cors_origin_list(self) -> list[str]:
|
| 25 |
+
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
settings = Settings()
|
backend/app/data/__init__.py
ADDED
|
File without changes
|
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,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
def remove_command(self, name: str) -> bool:
|
| 106 |
+
return self.commands.pop(name, None) is not None
|
| 107 |
+
|
| 108 |
+
# -- sensor inputs ---------------------------------------------------
|
| 109 |
+
|
| 110 |
+
def add_sensor_input(self, sensor_input: SensorInput) -> SensorInput:
|
| 111 |
+
self.pending_inputs.append(sensor_input)
|
| 112 |
+
return sensor_input
|
| 113 |
+
|
| 114 |
+
# -- experience (ECDS-like) -----------------------------------------
|
| 115 |
+
|
| 116 |
+
def _record(self, node_id: str, event: ExperienceEvent) -> None:
|
| 117 |
+
node = self.network.nodes.get(node_id)
|
| 118 |
+
if node is not None:
|
| 119 |
+
node.experience.append(event)
|
| 120 |
+
|
| 121 |
+
def _decidrons_for_sensor(self, sensor_node_id: str | None) -> list[str]:
|
| 122 |
+
"""Decidron nodes coupled downstream of a sensor (fallback: all)."""
|
| 123 |
+
if sensor_node_id is not None:
|
| 124 |
+
coupled = [
|
| 125 |
+
nid for nid in self.network.neighbors_out(sensor_node_id)
|
| 126 |
+
if self.network.nodes.get(nid) and self.network.nodes[nid].type == "decidron"
|
| 127 |
+
]
|
| 128 |
+
if coupled:
|
| 129 |
+
return coupled
|
| 130 |
+
return [n.id for n in self.network.nodes_list() if n.type == "decidron"]
|
| 131 |
+
|
| 132 |
+
# -- run -------------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
def run(self, inputs: list[SensorInput] | None = None) -> list[RunResult]:
|
| 135 |
+
batch = inputs if inputs is not None else list(self.pending_inputs)
|
| 136 |
+
results: list[RunResult] = []
|
| 137 |
+
|
| 138 |
+
for si in batch:
|
| 139 |
+
sensor_node = self.network.find_sensor_node(si.channel)
|
| 140 |
+
if sensor_node is not None:
|
| 141 |
+
self._record(sensor_node.id, ExperienceEvent(
|
| 142 |
+
kind="sensor_received",
|
| 143 |
+
detail=f"received '{si.value}' on channel '{si.channel}'",
|
| 144 |
+
sensor=si.channel,
|
| 145 |
+
value=si.value,
|
| 146 |
+
))
|
| 147 |
+
|
| 148 |
+
for cmd in self.commands.values():
|
| 149 |
+
if cmd.sensor.lower() != si.channel.lower():
|
| 150 |
+
continue
|
| 151 |
+
start = time.perf_counter()
|
| 152 |
+
matched = _matches(cmd.match, si.value)
|
| 153 |
+
ops_applied = 0
|
| 154 |
+
output = None
|
| 155 |
+
actuator = None
|
| 156 |
+
|
| 157 |
+
if matched:
|
| 158 |
+
output = cmd.output
|
| 159 |
+
for nid in self._decidrons_for_sensor(
|
| 160 |
+
sensor_node.id if sensor_node else None
|
| 161 |
+
):
|
| 162 |
+
self._record(nid, ExperienceEvent(
|
| 163 |
+
kind="rule_fired",
|
| 164 |
+
detail=f"command '{cmd.name}' fired -> {cmd.output}",
|
| 165 |
+
command=cmd.name,
|
| 166 |
+
sensor=si.channel,
|
| 167 |
+
value=si.value,
|
| 168 |
+
))
|
| 169 |
+
if cmd.actuator and cmd.actuator in self.network.nodes:
|
| 170 |
+
actuator = cmd.actuator
|
| 171 |
+
self._record(cmd.actuator, ExperienceEvent(
|
| 172 |
+
kind="actuator_acted",
|
| 173 |
+
detail=f"actuated by '{cmd.name}': {cmd.output}",
|
| 174 |
+
command=cmd.name,
|
| 175 |
+
))
|
| 176 |
+
if cmd.network_ops:
|
| 177 |
+
ops_applied = self.network.apply_ops(
|
| 178 |
+
cmd.network_ops, reason=f"command '{cmd.name}' fired"
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
latency_ms = (time.perf_counter() - start) * 1000.0
|
| 182 |
+
self.stats.record(si.channel, cmd.name, output, matched, latency_ms)
|
| 183 |
+
result = RunResult(
|
| 184 |
+
command=cmd.name,
|
| 185 |
+
sensor=si.channel,
|
| 186 |
+
value=si.value,
|
| 187 |
+
matched=matched,
|
| 188 |
+
output=output,
|
| 189 |
+
actuator=actuator,
|
| 190 |
+
network_ops_applied=ops_applied,
|
| 191 |
+
latency_ms=round(latency_ms, 4),
|
| 192 |
+
)
|
| 193 |
+
results.append(result)
|
| 194 |
+
|
| 195 |
+
self.input_log.append(si)
|
| 196 |
+
|
| 197 |
+
if inputs is None:
|
| 198 |
+
self.pending_inputs.clear()
|
| 199 |
+
self.run_results.extend(results)
|
| 200 |
+
return results
|
| 201 |
+
|
| 202 |
+
def reset(self) -> None:
|
| 203 |
+
self.network.reset()
|
| 204 |
+
self.stats.reset()
|
| 205 |
+
self.pending_inputs.clear()
|
| 206 |
+
self.input_log.clear()
|
| 207 |
+
self.run_results.clear()
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
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
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI, Request
|
| 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__)
|
| 14 |
+
|
| 15 |
+
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
| 16 |
+
|
| 17 |
+
app = FastAPI(
|
| 18 |
+
title="Decidron Network Simulator",
|
| 19 |
+
version="1.0.0",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
app.add_middleware(
|
| 23 |
+
CORSMiddleware,
|
| 24 |
+
allow_origins=settings.cors_origin_list or ["*"],
|
| 25 |
+
allow_credentials=True,
|
| 26 |
+
allow_methods=["*"],
|
| 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"}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
if STATIC_DIR.is_dir():
|
| 39 |
+
from starlette.staticfiles import StaticFiles # noqa: F401
|
| 40 |
+
from starlette.responses import FileResponse
|
| 41 |
+
|
| 42 |
+
@app.get("/{full_path:path}")
|
| 43 |
+
async def serve_spa(request: Request, full_path: str):
|
| 44 |
+
file_path = STATIC_DIR / full_path
|
| 45 |
+
if file_path.is_file():
|
| 46 |
+
return FileResponse(file_path)
|
| 47 |
+
return FileResponse(STATIC_DIR / "index.html")
|
| 48 |
+
|
| 49 |
+
LOG.info("Serving frontend from %s", STATIC_DIR)
|
| 50 |
+
else:
|
| 51 |
+
LOG.info("No static directory found at %s — frontend not served.", STATIC_DIR)
|
backend/app/utils/__init__.py
ADDED
|
File without changes
|
backend/dev.ps1
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backend dev server with WORKING hot reload on Windows.
|
| 2 |
+
#
|
| 3 |
+
# uvicorn's built-in `--reload` is unreliable on Windows: it detects file
|
| 4 |
+
# changes ("Reloading...") but often fails to actually restart the worker,
|
| 5 |
+
# so it silently keeps serving stale code. This wraps uvicorn in
|
| 6 |
+
# `watchfiles`, which does a clean full process restart on every change.
|
| 7 |
+
#
|
| 8 |
+
# Usage (from the backend/ folder):
|
| 9 |
+
# .\dev.ps1 # app.main:app on 127.0.0.1:7860, watching .\app
|
| 10 |
+
# .\dev.ps1 -Port 8000 # different port
|
| 11 |
+
# .\dev.ps1 -App pkg.main:app -Watch pkg
|
| 12 |
+
#
|
| 13 |
+
# Requires `watchfiles` (bundled with uvicorn[standard], or `pip install watchfiles`).
|
| 14 |
+
|
| 15 |
+
param(
|
| 16 |
+
[string]$App = "app.main:app",
|
| 17 |
+
[int]$Port = 7860,
|
| 18 |
+
[string]$Watch = "app",
|
| 19 |
+
[string]$BindHost = "127.0.0.1"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
$ErrorActionPreference = "Stop"
|
| 23 |
+
|
| 24 |
+
# Prefer the project venv's interpreter if present.
|
| 25 |
+
$py = if (Test-Path ".venv\Scripts\python.exe") { ".venv\Scripts\python.exe" } else { "python" }
|
| 26 |
+
|
| 27 |
+
Write-Host "watchfiles -> uvicorn $App on http://$BindHost`:$Port (watching .\$Watch)" -ForegroundColor Cyan
|
| 28 |
+
& $py -m watchfiles "$py -m uvicorn $App --host $BindHost --port $Port" $Watch
|
backend/dev.sh
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Backend dev server with hot reload via watchfiles (cross-platform).
|
| 3 |
+
#
|
| 4 |
+
# Equivalent to dev.ps1. uvicorn's built-in --reload is unreliable on
|
| 5 |
+
# Windows; watchfiles does a clean full process restart on every change
|
| 6 |
+
# and works the same everywhere.
|
| 7 |
+
#
|
| 8 |
+
# Usage (from the backend/ folder):
|
| 9 |
+
# ./dev.sh # app.main:app on 127.0.0.1:7860, watching ./app
|
| 10 |
+
# ./dev.sh app.main:app 8000 app
|
| 11 |
+
#
|
| 12 |
+
# Requires watchfiles (bundled with uvicorn[standard], or pip install watchfiles).
|
| 13 |
+
set -euo pipefail
|
| 14 |
+
|
| 15 |
+
APP="${1:-app.main:app}"
|
| 16 |
+
PORT="${2:-7860}"
|
| 17 |
+
WATCH="${3:-app}"
|
| 18 |
+
BIND_HOST="${4:-127.0.0.1}"
|
| 19 |
+
|
| 20 |
+
PY=".venv/bin/python"
|
| 21 |
+
[ -x "$PY" ] || PY="python"
|
| 22 |
+
|
| 23 |
+
echo "watchfiles -> uvicorn $APP on http://$BIND_HOST:$PORT (watching ./$WATCH)"
|
| 24 |
+
exec "$PY" -m watchfiles "$PY -m uvicorn $APP --host $BIND_HOST --port $PORT" "$WATCH"
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.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
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
app:
|
| 3 |
+
build: .
|
| 4 |
+
ports:
|
| 5 |
+
# Container always listens on 7860 (matches HuggingFace Space
|
| 6 |
+
# app_port). Host port is overridable via CCAI_HOST_PORT in .env
|
| 7 |
+
# or the environment so local devs can sidestep port conflicts.
|
| 8 |
+
- "${CCAI_HOST_PORT:-7860}:7860"
|
| 9 |
+
env_file:
|
| 10 |
+
- .env
|
| 11 |
+
environment:
|
| 12 |
+
CORS_ORIGINS: "http://localhost:7860,http://localhost:3000"
|
| 13 |
+
restart: unless-stopped
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.js
|
| 7 |
+
|
| 8 |
+
# testing
|
| 9 |
+
/coverage
|
| 10 |
+
|
| 11 |
+
# production
|
| 12 |
+
/build
|
| 13 |
+
|
| 14 |
+
# misc
|
| 15 |
+
.DS_Store
|
| 16 |
+
.env.local
|
| 17 |
+
.env.development.local
|
| 18 |
+
.env.test.local
|
| 19 |
+
.env.production.local
|
| 20 |
+
|
| 21 |
+
npm-debug.log*
|
| 22 |
+
yarn-debug.log*
|
| 23 |
+
yarn-error.log*
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"dependencies": {
|
| 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",
|
| 15 |
+
"build": "react-scripts build",
|
| 16 |
+
"test": "react-scripts test",
|
| 17 |
+
"eject": "react-scripts eject"
|
| 18 |
+
},
|
| 19 |
+
"eslintConfig": {
|
| 20 |
+
"extends": "react-app"
|
| 21 |
+
},
|
| 22 |
+
"browserslist": {
|
| 23 |
+
"production": [
|
| 24 |
+
">0.2%",
|
| 25 |
+
"not dead",
|
| 26 |
+
"not op_mini all"
|
| 27 |
+
],
|
| 28 |
+
"development": [
|
| 29 |
+
"last 1 chrome version",
|
| 30 |
+
"last 1 firefox version",
|
| 31 |
+
"last 1 safari version"
|
| 32 |
+
]
|
| 33 |
+
}
|
| 34 |
+
}
|
frontend/public/favicon.ico
ADDED
|
|
frontend/public/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" data-theme="light">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<link rel="icon" type="image/png" href="/neon-logo.png" />
|
| 7 |
+
<meta name="theme-color" content="#6366F1" />
|
| 8 |
+
<meta name="description" content="CCAI Demo" />
|
| 9 |
+
<title>CCAI Demo</title>
|
| 10 |
+
</head>
|
| 11 |
+
<body>
|
| 12 |
+
<div id="root"></div>
|
| 13 |
+
</body>
|
| 14 |
+
</html>
|
frontend/public/manifest.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"short_name": "React App",
|
| 3 |
+
"name": "Create React App Sample",
|
| 4 |
+
"icons": [
|
| 5 |
+
{
|
| 6 |
+
"src": "favicon.ico",
|
| 7 |
+
"sizes": "64x64 32x32 24x24 16x16",
|
| 8 |
+
"type": "image/x-icon"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"src": "logo192.png",
|
| 12 |
+
"type": "image/png",
|
| 13 |
+
"sizes": "192x192"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"src": "logo512.png",
|
| 17 |
+
"type": "image/png",
|
| 18 |
+
"sizes": "512x512"
|
| 19 |
+
}
|
| 20 |
+
],
|
| 21 |
+
"start_url": ".",
|
| 22 |
+
"display": "standalone",
|
| 23 |
+
"theme_color": "#000000",
|
| 24 |
+
"background_color": "#ffffff"
|
| 25 |
+
}
|
frontend/public/neon-logo.png
ADDED
|
Git LFS Details
|
frontend/public/robots.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# https://www.robotstxt.org/robotstxt.html
|
| 2 |
+
User-agent: *
|
frontend/src/App.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import Header from './components/Header';
|
| 3 |
+
import DecidronSimulator from './components/decidron/DecidronSimulator';
|
| 4 |
+
import { loadSettings, saveSettings } from './utils/settings';
|
| 5 |
+
import './styles/variables.css';
|
| 6 |
+
import './styles/layout.css';
|
| 7 |
+
import './styles/components.css';
|
| 8 |
+
import './styles/ccai.css';
|
| 9 |
+
|
| 10 |
+
const THEME_KEY = 'decidron-theme';
|
| 11 |
+
|
| 12 |
+
export default function App() {
|
| 13 |
+
const [theme, setTheme] = useState(
|
| 14 |
+
() => localStorage.getItem(THEME_KEY)
|
| 15 |
+
|| (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
| 16 |
+
);
|
| 17 |
+
const [settings, setSettings] = useState(() => loadSettings());
|
| 18 |
+
|
| 19 |
+
useEffect(() => {
|
| 20 |
+
document.documentElement.setAttribute('data-theme', theme);
|
| 21 |
+
localStorage.setItem(THEME_KEY, theme);
|
| 22 |
+
}, [theme]);
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
saveSettings(settings);
|
| 26 |
+
}, [settings]);
|
| 27 |
+
|
| 28 |
+
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="app">
|
| 32 |
+
<Header
|
| 33 |
+
theme={theme}
|
| 34 |
+
onToggleTheme={toggleTheme}
|
| 35 |
+
settings={settings}
|
| 36 |
+
onSettingsChange={setSettings}
|
| 37 |
+
/>
|
| 38 |
+
<main className="app-main">
|
| 39 |
+
<DecidronSimulator settings={settings} />
|
| 40 |
+
</main>
|
| 41 |
+
</div>
|
| 42 |
+
);
|
| 43 |
+
}
|
frontend/src/components/Header.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Sun, Moon } from 'lucide-react';
|
| 3 |
+
import SettingsMenu from './SettingsMenu';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Slim branded header for the Decidron simulator.
|
| 7 |
+
*/
|
| 8 |
+
export default function Header({ theme, onToggleTheme, settings, onSettingsChange }) {
|
| 9 |
+
return (
|
| 10 |
+
<header className="app-header">
|
| 11 |
+
<div className="header-left">
|
| 12 |
+
<a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="header-brand-link">
|
| 13 |
+
<img src="/neon-logo.png" alt="Neon.ai" className="app-logo" />
|
| 14 |
+
</a>
|
| 15 |
+
<h1 className="app-title">
|
| 16 |
+
<a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="app-title-link">
|
| 17 |
+
Neon.ai
|
| 18 |
+
</a> - Decidron Network Simulator
|
| 19 |
+
</h1>
|
| 20 |
+
</div>
|
| 21 |
+
<div className="header-right">
|
| 22 |
+
<SettingsMenu settings={settings} onChange={onSettingsChange} />
|
| 23 |
+
<button
|
| 24 |
+
type="button"
|
| 25 |
+
className="btn-sm btn-outline icon-btn"
|
| 26 |
+
onClick={onToggleTheme}
|
| 27 |
+
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
| 28 |
+
>
|
| 29 |
+
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
| 30 |
+
</button>
|
| 31 |
+
</div>
|
| 32 |
+
</header>
|
| 33 |
+
);
|
| 34 |
+
}
|
frontend/src/components/SettingsMenu.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import { Settings } from 'lucide-react';
|
| 3 |
+
import { LAYOUT_MODES, PANEL_KEYS, PANEL_LABELS } from '../utils/settings';
|
| 4 |
+
|
| 5 |
+
const LAYOUT_OPTIONS = [
|
| 6 |
+
{ id: LAYOUT_MODES.RANDOM, label: 'Random seed (default)', hint: 'Layout may shift on each run' },
|
| 7 |
+
{ id: LAYOUT_MODES.FIXED_SEED, label: 'Fixed seed', hint: 'Same layout every run unless topology changes' },
|
| 8 |
+
{ id: LAYOUT_MODES.EXPLICIT, label: 'Explicit coordinates', hint: 'Fixed positions; physics off' },
|
| 9 |
+
];
|
| 10 |
+
|
| 11 |
+
export default function SettingsMenu({ settings, onChange }) {
|
| 12 |
+
const [open, setOpen] = useState(false);
|
| 13 |
+
const wrapRef = useRef(null);
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
if (!open) return undefined;
|
| 17 |
+
const onDocClick = (e) => {
|
| 18 |
+
if (wrapRef.current && !wrapRef.current.contains(e.target)) {
|
| 19 |
+
setOpen(false);
|
| 20 |
+
}
|
| 21 |
+
};
|
| 22 |
+
document.addEventListener('mousedown', onDocClick);
|
| 23 |
+
return () => document.removeEventListener('mousedown', onDocClick);
|
| 24 |
+
}, [open]);
|
| 25 |
+
|
| 26 |
+
const setLayoutMode = (layoutMode) => {
|
| 27 |
+
onChange({ ...settings, layoutMode });
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
const togglePanel = (key) => {
|
| 31 |
+
onChange({
|
| 32 |
+
...settings,
|
| 33 |
+
panels: { ...settings.panels, [key]: !settings.panels[key] },
|
| 34 |
+
});
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<div className="ccai-dropdown-wrap decidron-settings-wrap" ref={wrapRef}>
|
| 39 |
+
<button
|
| 40 |
+
type="button"
|
| 41 |
+
className="btn-sm btn-outline icon-btn decidron-settings-trigger"
|
| 42 |
+
onClick={() => setOpen((v) => !v)}
|
| 43 |
+
title="Settings"
|
| 44 |
+
aria-expanded={open}
|
| 45 |
+
aria-haspopup="true"
|
| 46 |
+
>
|
| 47 |
+
<Settings size={16} />
|
| 48 |
+
</button>
|
| 49 |
+
{open && (
|
| 50 |
+
<div className="ccai-dropdown-panel decidron-settings-panel" role="menu">
|
| 51 |
+
<div className="ccai-dropdown-section">
|
| 52 |
+
<div className="ccai-dropdown-section-title">Diagram node placement</div>
|
| 53 |
+
{LAYOUT_OPTIONS.map((opt) => (
|
| 54 |
+
<label key={opt.id} className="decidron-settings-radio">
|
| 55 |
+
<input
|
| 56 |
+
type="radio"
|
| 57 |
+
name="layoutMode"
|
| 58 |
+
checked={settings.layoutMode === opt.id}
|
| 59 |
+
onChange={() => setLayoutMode(opt.id)}
|
| 60 |
+
/>
|
| 61 |
+
<span>
|
| 62 |
+
<strong>{opt.label}</strong>
|
| 63 |
+
<span className="decidron-settings-hint">{opt.hint}</span>
|
| 64 |
+
</span>
|
| 65 |
+
</label>
|
| 66 |
+
))}
|
| 67 |
+
</div>
|
| 68 |
+
<div className="ccai-dropdown-divider" />
|
| 69 |
+
<div className="ccai-dropdown-section">
|
| 70 |
+
<div className="ccai-dropdown-section-title">Right-side panels</div>
|
| 71 |
+
{PANEL_KEYS.map((key) => (
|
| 72 |
+
<label key={key} className="ccai-dropdown-item decidron-settings-check">
|
| 73 |
+
<input
|
| 74 |
+
type="checkbox"
|
| 75 |
+
checked={!!settings.panels[key]}
|
| 76 |
+
onChange={() => togglePanel(key)}
|
| 77 |
+
/>
|
| 78 |
+
<span className="ccai-dropdown-item-text">
|
| 79 |
+
<span className="ccai-dropdown-item-name">{PANEL_LABELS[key]}</span>
|
| 80 |
+
</span>
|
| 81 |
+
</label>
|
| 82 |
+
))}
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
+
</div>
|
| 87 |
+
);
|
| 88 |
+
}
|
frontend/src/components/decidron/CommandBuilder.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { Plus, Pencil, Trash2, X } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
const MATCH_OPS = ['contains', 'equals', 'gt', 'gte', 'lt', 'lte', 'startswith', 'endswith', 'regex'];
|
| 5 |
+
|
| 6 |
+
function parseMatch(match) {
|
| 7 |
+
if (typeof match === 'string' && match.includes(':')) {
|
| 8 |
+
const [op, ...rest] = match.split(':');
|
| 9 |
+
return { op: MATCH_OPS.includes(op) ? op : 'contains', value: rest.join(':') };
|
| 10 |
+
}
|
| 11 |
+
return { op: 'contains', value: match || '' };
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const BLANK = { name: '', sensor: '', op: 'gte', matchValue: '', output: '', actuator: '' };
|
| 15 |
+
|
| 16 |
+
export default function CommandBuilder({ commands, sensors, nodes, onCreate, onDelete }) {
|
| 17 |
+
const [form, setForm] = useState(BLANK);
|
| 18 |
+
const [editing, setEditing] = useState(null); // original name being edited
|
| 19 |
+
const [preservedOps, setPreservedOps] = useState([]); // keep network_ops we don't edit in the UI
|
| 20 |
+
const [error, setError] = useState(null);
|
| 21 |
+
|
| 22 |
+
const actuators = nodes.filter((n) => n.type === 'actuator');
|
| 23 |
+
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
| 24 |
+
|
| 25 |
+
const resetForm = () => {
|
| 26 |
+
setForm(BLANK);
|
| 27 |
+
setEditing(null);
|
| 28 |
+
setPreservedOps([]);
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const startEdit = (c) => {
|
| 32 |
+
const { op, value } = parseMatch(c.match);
|
| 33 |
+
setForm({
|
| 34 |
+
name: c.name,
|
| 35 |
+
sensor: c.sensor,
|
| 36 |
+
op,
|
| 37 |
+
matchValue: value,
|
| 38 |
+
output: c.output,
|
| 39 |
+
actuator: c.actuator || '',
|
| 40 |
+
});
|
| 41 |
+
setPreservedOps(c.network_ops || []);
|
| 42 |
+
setEditing(c.name);
|
| 43 |
+
setError(null);
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
const submit = async (e) => {
|
| 47 |
+
e.preventDefault();
|
| 48 |
+
setError(null);
|
| 49 |
+
if (!form.name || !form.sensor || form.matchValue === '' || !form.output) {
|
| 50 |
+
setError('Name, sensor, match value, and output are required.');
|
| 51 |
+
return;
|
| 52 |
+
}
|
| 53 |
+
try {
|
| 54 |
+
await onCreate({
|
| 55 |
+
name: form.name,
|
| 56 |
+
sensor: form.sensor,
|
| 57 |
+
match: `${form.op}:${form.matchValue}`,
|
| 58 |
+
output: form.output,
|
| 59 |
+
actuator: form.actuator || null,
|
| 60 |
+
network_ops: preservedOps,
|
| 61 |
+
}, editing);
|
| 62 |
+
resetForm();
|
| 63 |
+
} catch (err) {
|
| 64 |
+
setError(err.message);
|
| 65 |
+
}
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
+
return (
|
| 69 |
+
<div className="decidron-panel">
|
| 70 |
+
<h2>Processing Commands</h2>
|
| 71 |
+
<p className="panel-hint">
|
| 72 |
+
Sample commands are loaded below — edit or delete them, or add your
|
| 73 |
+
own. A command is IF (sensor matches) THEN (emit output, optionally
|
| 74 |
+
drive an actuator). Commands fire when you run the simulation.
|
| 75 |
+
</p>
|
| 76 |
+
|
| 77 |
+
{error && <div className="decidron-error">{error}</div>}
|
| 78 |
+
|
| 79 |
+
<form onSubmit={submit}>
|
| 80 |
+
<div className="decidron-row">
|
| 81 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 82 |
+
<label>Name</label>
|
| 83 |
+
<input value={form.name} onChange={set('name')} placeholder="HighTempAlert" />
|
| 84 |
+
</div>
|
| 85 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 86 |
+
<label>Sensor</label>
|
| 87 |
+
<select value={form.sensor} onChange={set('sensor')}>
|
| 88 |
+
<option value="">Select…</option>
|
| 89 |
+
{sensors.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
| 90 |
+
</select>
|
| 91 |
+
</div>
|
| 92 |
+
</div>
|
| 93 |
+
<div className="decidron-row">
|
| 94 |
+
<div className="decidron-field" style={{ width: 120 }}>
|
| 95 |
+
<label>Match</label>
|
| 96 |
+
<select value={form.op} onChange={set('op')}>
|
| 97 |
+
{MATCH_OPS.map((o) => <option key={o} value={o}>{o}</option>)}
|
| 98 |
+
</select>
|
| 99 |
+
</div>
|
| 100 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 101 |
+
<label>Value</label>
|
| 102 |
+
<input value={form.matchValue} onChange={set('matchValue')} placeholder="85" />
|
| 103 |
+
</div>
|
| 104 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 105 |
+
<label>Actuator (optional)</label>
|
| 106 |
+
<select value={form.actuator} onChange={set('actuator')}>
|
| 107 |
+
<option value="">None</option>
|
| 108 |
+
{actuators.map((a) => <option key={a.id} value={a.id}>{a.label}</option>)}
|
| 109 |
+
</select>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
<div className="decidron-field">
|
| 113 |
+
<label>Output</label>
|
| 114 |
+
<input value={form.output} onChange={set('output')} placeholder="Trigger failsafe review" />
|
| 115 |
+
</div>
|
| 116 |
+
{editing && preservedOps.length > 0 && (
|
| 117 |
+
<p className="panel-hint">
|
| 118 |
+
This command has {preservedOps.length} network op(s); they are
|
| 119 |
+
preserved when you save.
|
| 120 |
+
</p>
|
| 121 |
+
)}
|
| 122 |
+
<div className="decidron-row">
|
| 123 |
+
<button type="submit" className="decidron-btn">
|
| 124 |
+
{editing
|
| 125 |
+
? <><Pencil size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />Update Command</>
|
| 126 |
+
: <><Plus size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />Add Command</>}
|
| 127 |
+
</button>
|
| 128 |
+
{editing && (
|
| 129 |
+
<button type="button" className="decidron-btn secondary" onClick={resetForm}>
|
| 130 |
+
<X size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
|
| 131 |
+
Cancel
|
| 132 |
+
</button>
|
| 133 |
+
)}
|
| 134 |
+
</div>
|
| 135 |
+
</form>
|
| 136 |
+
|
| 137 |
+
<div style={{ marginTop: '0.75rem' }}>
|
| 138 |
+
{commands.map((c) => (
|
| 139 |
+
<div className="decidron-cmd" key={c.name}>
|
| 140 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem' }}>
|
| 141 |
+
<span>
|
| 142 |
+
<strong>{c.name}</strong>: IF <code>{c.sensor}</code> <code>{c.match}</code> THEN {c.output}
|
| 143 |
+
{c.actuator && <> → drives <code>{c.actuator}</code></>}
|
| 144 |
+
{c.network_ops && c.network_ops.length > 0 && <> <span className="decidron-chip">{c.network_ops.length} network op(s)</span></>}
|
| 145 |
+
</span>
|
| 146 |
+
<span style={{ whiteSpace: 'nowrap' }}>
|
| 147 |
+
<button type="button" className="decidron-btn secondary" title="Edit" onClick={() => startEdit(c)} style={{ padding: '0.2rem 0.4rem', marginLeft: 4 }}>
|
| 148 |
+
<Pencil size={13} />
|
| 149 |
+
</button>
|
| 150 |
+
<button type="button" className="decidron-btn secondary" title="Delete" onClick={() => onDelete(c.name)} style={{ padding: '0.2rem 0.4rem', marginLeft: 4 }}>
|
| 151 |
+
<Trash2 size={13} />
|
| 152 |
+
</button>
|
| 153 |
+
</span>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
))}
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
);
|
| 160 |
+
}
|
frontend/src/components/decidron/DecidronSimulator.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, deleteCommand, resetSimulation,
|
| 12 |
+
} from '../../utils/api';
|
| 13 |
+
import '../../styles/decidron.css';
|
| 14 |
+
|
| 15 |
+
const DEFAULT_VALUE = '97';
|
| 16 |
+
|
| 17 |
+
export default function DecidronSimulator({ settings }) {
|
| 18 |
+
const [network, setNetwork] = useState(null);
|
| 19 |
+
const [commands, setCommands] = useState([]);
|
| 20 |
+
const [stats, setStats] = useState(null);
|
| 21 |
+
const [history, setHistory] = useState([]);
|
| 22 |
+
const [results, setResults] = useState([]);
|
| 23 |
+
const [pending, setPending] = useState([]);
|
| 24 |
+
const [selected, setSelected] = useState(null); // null = live network
|
| 25 |
+
const [running, setRunning] = useState(false);
|
| 26 |
+
const [error, setError] = useState(null);
|
| 27 |
+
// Live sensor-input form values, pre-populated so the user can press
|
| 28 |
+
// "Run Simulation" immediately and see the network react.
|
| 29 |
+
const [channel, setChannel] = useState('');
|
| 30 |
+
const [value, setValue] = useState(DEFAULT_VALUE);
|
| 31 |
+
|
| 32 |
+
const refreshAll = useCallback(async () => {
|
| 33 |
+
try {
|
| 34 |
+
const [net, cmds, st, hist] = await Promise.all([
|
| 35 |
+
getNetwork(), getCommands(), getStats(), getHistory(),
|
| 36 |
+
]);
|
| 37 |
+
setNetwork(net);
|
| 38 |
+
setCommands(cmds);
|
| 39 |
+
setStats(st);
|
| 40 |
+
setHistory(hist.snapshots);
|
| 41 |
+
} catch (err) {
|
| 42 |
+
setError(err.message);
|
| 43 |
+
}
|
| 44 |
+
}, []);
|
| 45 |
+
|
| 46 |
+
useEffect(() => { refreshAll(); }, [refreshAll]);
|
| 47 |
+
|
| 48 |
+
// Default the channel to the first sensor once the network loads.
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
if (network && !channel) {
|
| 51 |
+
const firstSensor = network.nodes.find((n) => n.type === 'sensor');
|
| 52 |
+
if (firstSensor) setChannel(firstSensor.id);
|
| 53 |
+
}
|
| 54 |
+
}, [network, channel]);
|
| 55 |
+
|
| 56 |
+
const handleQueue = async () => {
|
| 57 |
+
if (!channel || value === '') return;
|
| 58 |
+
setError(null);
|
| 59 |
+
try {
|
| 60 |
+
await submitSensorInput(channel, value);
|
| 61 |
+
setPending((prev) => [...prev, { channel, value }]);
|
| 62 |
+
} catch (err) {
|
| 63 |
+
setError(err.message);
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const handleRun = async () => {
|
| 68 |
+
// Run queued inputs if any; otherwise run the current form value
|
| 69 |
+
// inline so a single click works on first load.
|
| 70 |
+
const inputs = pending.length > 0 ? null : [{ channel, value }];
|
| 71 |
+
if (pending.length === 0 && (!channel || value === '')) return;
|
| 72 |
+
setRunning(true);
|
| 73 |
+
setError(null);
|
| 74 |
+
try {
|
| 75 |
+
const resp = await runSimulation(inputs);
|
| 76 |
+
setResults(resp.results);
|
| 77 |
+
setNetwork(resp.network);
|
| 78 |
+
setSelected(null);
|
| 79 |
+
setPending([]);
|
| 80 |
+
const [st, hist] = await Promise.all([getStats(), getHistory()]);
|
| 81 |
+
setStats(st);
|
| 82 |
+
setHistory(hist.snapshots);
|
| 83 |
+
} catch (err) {
|
| 84 |
+
setError(err.message);
|
| 85 |
+
} finally {
|
| 86 |
+
setRunning(false);
|
| 87 |
+
}
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
const handleCreate = async (cmd, replaceName = null) => {
|
| 91 |
+
await createCommand(cmd);
|
| 92 |
+
if (replaceName && replaceName !== cmd.name) {
|
| 93 |
+
await deleteCommand(replaceName);
|
| 94 |
+
}
|
| 95 |
+
setCommands(await getCommands());
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const handleDelete = async (name) => {
|
| 99 |
+
setError(null);
|
| 100 |
+
try {
|
| 101 |
+
await deleteCommand(name);
|
| 102 |
+
setCommands(await getCommands());
|
| 103 |
+
} catch (err) {
|
| 104 |
+
setError(err.message);
|
| 105 |
+
}
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
const handleReset = async () => {
|
| 109 |
+
setError(null);
|
| 110 |
+
try {
|
| 111 |
+
await resetSimulation();
|
| 112 |
+
setResults([]);
|
| 113 |
+
setPending([]);
|
| 114 |
+
setSelected(null);
|
| 115 |
+
await refreshAll();
|
| 116 |
+
} catch (err) {
|
| 117 |
+
setError(err.message);
|
| 118 |
+
}
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
if (!network) {
|
| 122 |
+
return (
|
| 123 |
+
<div className="decidron-app">
|
| 124 |
+
{error ? <div className="decidron-error">{error}</div> : <p>Loading…</p>}
|
| 125 |
+
</div>
|
| 126 |
+
);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
const sensors = network.nodes.filter((n) => n.type === 'sensor');
|
| 130 |
+
const shownDiagram = selected ? selected.diagram : network.diagram;
|
| 131 |
+
const shownVersion = selected ? selected.version : network.version;
|
| 132 |
+
const { panels, layoutMode } = settings;
|
| 133 |
+
|
| 134 |
+
return (
|
| 135 |
+
<div className="decidron-app">
|
| 136 |
+
{error && <div className="decidron-error">{error}</div>}
|
| 137 |
+
<div className="decidron-grid">
|
| 138 |
+
<div>
|
| 139 |
+
<div className="decidron-panel">
|
| 140 |
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
| 141 |
+
<h2>Decidron Network (v{shownVersion}{selected ? ' — historical' : ' — live'})</h2>
|
| 142 |
+
<button type="button" className="decidron-btn secondary" onClick={handleReset}>
|
| 143 |
+
<RotateCcw size={13} style={{ marginRight: 4, verticalAlign: 'middle' }} />
|
| 144 |
+
Reset
|
| 145 |
+
</button>
|
| 146 |
+
</div>
|
| 147 |
+
<p className="panel-hint">
|
| 148 |
+
Sensors (blue) feed coupled Decidrons (purple) that drive
|
| 149 |
+
actuators (green). Commands can modify this topology.
|
| 150 |
+
</p>
|
| 151 |
+
<NetworkDiagram data={shownDiagram} layoutMode={layoutMode} />
|
| 152 |
+
</div>
|
| 153 |
+
<HistoryTimeline
|
| 154 |
+
snapshots={history}
|
| 155 |
+
selectedVersion={selected ? selected.version : null}
|
| 156 |
+
onSelect={(s) => setSelected(
|
| 157 |
+
s.version === network.version ? null : s
|
| 158 |
+
)}
|
| 159 |
+
/>
|
| 160 |
+
</div>
|
| 161 |
+
|
| 162 |
+
<div>
|
| 163 |
+
{panels.sensorInput && (
|
| 164 |
+
<SensorInputForm
|
| 165 |
+
sensors={sensors}
|
| 166 |
+
channel={channel}
|
| 167 |
+
value={value}
|
| 168 |
+
onChannelChange={setChannel}
|
| 169 |
+
onValueChange={setValue}
|
| 170 |
+
pending={pending}
|
| 171 |
+
onQueue={handleQueue}
|
| 172 |
+
onRun={handleRun}
|
| 173 |
+
running={running}
|
| 174 |
+
/>
|
| 175 |
+
)}
|
| 176 |
+
{panels.commands && (
|
| 177 |
+
<CommandBuilder
|
| 178 |
+
commands={commands}
|
| 179 |
+
sensors={sensors}
|
| 180 |
+
nodes={network.nodes}
|
| 181 |
+
onCreate={handleCreate}
|
| 182 |
+
onDelete={handleDelete}
|
| 183 |
+
/>
|
| 184 |
+
)}
|
| 185 |
+
{panels.results && <ResultsPanel results={results} />}
|
| 186 |
+
{panels.stats && <StatsTable stats={stats} />}
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
);
|
| 191 |
+
}
|
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,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useRef, useMemo } from 'react';
|
| 2 |
+
import { Network } from 'vis-network/standalone';
|
| 3 |
+
import { LAYOUT_MODES } from '../../utils/settings';
|
| 4 |
+
|
| 5 |
+
/** Known node positions for explicit-coordinate mode (vis-network canvas coords). */
|
| 6 |
+
const EXPLICIT_POSITIONS = {
|
| 7 |
+
temperature: { x: 0, y: -140 },
|
| 8 |
+
D1: { x: -120, y: 20 },
|
| 9 |
+
D2: { x: 120, y: 20 },
|
| 10 |
+
'actuator-1': { x: 0, y: 140 },
|
| 11 |
+
D3: { x: 220, y: 20 },
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
function fallbackPosition(nodeId, index) {
|
| 15 |
+
const angle = (index * 2 * Math.PI) / 8;
|
| 16 |
+
return { x: Math.cos(angle) * 180, y: Math.sin(angle) * 120 };
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function applyLayout(data, layoutMode) {
|
| 20 |
+
const base = data || { nodes: [], edges: [] };
|
| 21 |
+
const nodes = (base.nodes || []).map((node, index) => {
|
| 22 |
+
if (layoutMode !== LAYOUT_MODES.EXPLICIT) return { ...node };
|
| 23 |
+
const pos = EXPLICIT_POSITIONS[node.id] ?? fallbackPosition(node.id, index);
|
| 24 |
+
return { ...node, x: pos.x, y: pos.y, fixed: true };
|
| 25 |
+
});
|
| 26 |
+
return { nodes, edges: base.edges || [] };
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function buildOptions(layoutMode) {
|
| 30 |
+
const options = {
|
| 31 |
+
autoResize: true,
|
| 32 |
+
nodes: {
|
| 33 |
+
borderWidth: 2,
|
| 34 |
+
font: { color: '#ffffff', size: 14 },
|
| 35 |
+
},
|
| 36 |
+
edges: {
|
| 37 |
+
arrows: 'to',
|
| 38 |
+
color: { color: '#9aa3b2', highlight: '#6366F1' },
|
| 39 |
+
font: { size: 10, color: '#9aa3b2', strokeWidth: 0, align: 'middle' },
|
| 40 |
+
smooth: { type: 'cubicBezier', roundness: 0.4 },
|
| 41 |
+
},
|
| 42 |
+
groups: {
|
| 43 |
+
sensor: { shape: 'box', color: { background: '#3b82f6', border: '#1d4ed8' } },
|
| 44 |
+
decidron: { shape: 'hexagon', color: { background: '#8b5cf6', border: '#6d28d9' } },
|
| 45 |
+
actuator: { shape: 'triangle', color: { background: '#10b981', border: '#047857' } },
|
| 46 |
+
},
|
| 47 |
+
interaction: { hover: true, tooltipDelay: 120 },
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
if (layoutMode === LAYOUT_MODES.EXPLICIT) {
|
| 51 |
+
options.physics = { enabled: false };
|
| 52 |
+
} else {
|
| 53 |
+
options.physics = {
|
| 54 |
+
stabilization: { iterations: 150 },
|
| 55 |
+
barnesHut: { springLength: 150, gravitationalConstant: -6000 },
|
| 56 |
+
};
|
| 57 |
+
if (layoutMode === LAYOUT_MODES.FIXED_SEED) {
|
| 58 |
+
options.layout = { randomSeed: 42 };
|
| 59 |
+
}
|
| 60 |
+
// RANDOM: no randomSeed — vis-network picks a new seed each build (default).
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
return options;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export default function NetworkDiagram({ data, layoutMode = LAYOUT_MODES.RANDOM, height = 380 }) {
|
| 67 |
+
const containerRef = useRef(null);
|
| 68 |
+
const networkRef = useRef(null);
|
| 69 |
+
|
| 70 |
+
const graphData = useMemo(
|
| 71 |
+
() => applyLayout(data, layoutMode),
|
| 72 |
+
[data, layoutMode],
|
| 73 |
+
);
|
| 74 |
+
|
| 75 |
+
useEffect(() => {
|
| 76 |
+
if (!containerRef.current) return undefined;
|
| 77 |
+
networkRef.current = new Network(
|
| 78 |
+
containerRef.current,
|
| 79 |
+
graphData,
|
| 80 |
+
buildOptions(layoutMode),
|
| 81 |
+
);
|
| 82 |
+
return () => {
|
| 83 |
+
if (networkRef.current) {
|
| 84 |
+
networkRef.current.destroy();
|
| 85 |
+
networkRef.current = null;
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
}, [graphData, layoutMode]);
|
| 89 |
+
|
| 90 |
+
return (
|
| 91 |
+
<div
|
| 92 |
+
ref={containerRef}
|
| 93 |
+
className="decidron-diagram"
|
| 94 |
+
style={{ height, width: '100%' }}
|
| 95 |
+
/>
|
| 96 |
+
);
|
| 97 |
+
}
|
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,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { Activity, Play } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
export default function SensorInputForm({
|
| 5 |
+
sensors, channel, value, onChannelChange, onValueChange,
|
| 6 |
+
pending, onQueue, onRun, running,
|
| 7 |
+
}) {
|
| 8 |
+
const handleQueue = (e) => {
|
| 9 |
+
e.preventDefault();
|
| 10 |
+
if (!channel || value === '') return;
|
| 11 |
+
onQueue();
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
const canRun = !running && (pending.length > 0 || (channel && value !== ''));
|
| 15 |
+
const runLabel = pending.length > 0
|
| 16 |
+
? `Run Simulation (${pending.length} queued)`
|
| 17 |
+
: 'Run Simulation';
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<div className="decidron-panel">
|
| 21 |
+
<h2>Sensor Input</h2>
|
| 22 |
+
<p className="panel-hint">
|
| 23 |
+
A reading is pre-filled — just press <strong>Run Simulation</strong>
|
| 24 |
+
to see the network react. Use <strong>Queue</strong> to stack
|
| 25 |
+
several inputs before running.
|
| 26 |
+
</p>
|
| 27 |
+
<form onSubmit={handleQueue}>
|
| 28 |
+
<div className="decidron-row">
|
| 29 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 30 |
+
<label>Channel</label>
|
| 31 |
+
<select value={channel} onChange={(e) => onChannelChange(e.target.value)}>
|
| 32 |
+
{sensors.map((s) => (
|
| 33 |
+
<option key={s.id} value={s.id}>{s.label}</option>
|
| 34 |
+
))}
|
| 35 |
+
</select>
|
| 36 |
+
</div>
|
| 37 |
+
<div className="decidron-field" style={{ flex: 1 }}>
|
| 38 |
+
<label>Value</label>
|
| 39 |
+
<input
|
| 40 |
+
type="text"
|
| 41 |
+
value={value}
|
| 42 |
+
placeholder="e.g. 97"
|
| 43 |
+
onChange={(e) => onValueChange(e.target.value)}
|
| 44 |
+
/>
|
| 45 |
+
</div>
|
| 46 |
+
<button type="submit" className="decidron-btn secondary">
|
| 47 |
+
<Activity size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
|
| 48 |
+
Queue
|
| 49 |
+
</button>
|
| 50 |
+
</div>
|
| 51 |
+
</form>
|
| 52 |
+
|
| 53 |
+
{pending.length > 0 && (
|
| 54 |
+
<ul className="decidron-pending">
|
| 55 |
+
{pending.map((p, i) => (
|
| 56 |
+
<li key={i}>{p.channel}: {p.value}</li>
|
| 57 |
+
))}
|
| 58 |
+
</ul>
|
| 59 |
+
)}
|
| 60 |
+
|
| 61 |
+
<div className="decidron-row" style={{ marginTop: '0.5rem' }}>
|
| 62 |
+
<button
|
| 63 |
+
type="button"
|
| 64 |
+
className="decidron-btn"
|
| 65 |
+
onClick={onRun}
|
| 66 |
+
disabled={!canRun}
|
| 67 |
+
>
|
| 68 |
+
<Play size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
|
| 69 |
+
{running ? 'Running…' : runLabel}
|
| 70 |
+
</button>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
);
|
| 74 |
+
}
|
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/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactDOM from 'react-dom/client';
|
| 3 |
+
import App from './App';
|
| 4 |
+
|
| 5 |
+
const root = ReactDOM.createRoot(document.getElementById('root'));
|
| 6 |
+
root.render(
|
| 7 |
+
<React.StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</React.StrictMode>
|
| 10 |
+
);
|
frontend/src/styles/ccai.css
ADDED
|
@@ -0,0 +1,1884 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ════════════════════════════════════════════════════════════════
|
| 2 |
+
Collaborative Conversational AI (CCAI) Demo - additive styles on
|
| 3 |
+
top of LLMChats3 baseline.
|
| 4 |
+
Anything new (multi-participant sidebar, dropdown, expert modal,
|
| 5 |
+
table view, orchestrator banners, failsafe banner) lives here.
|
| 6 |
+
════════════════════════════════════════════════════════════════ */
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
/* ── Header dropdown (Participants) ────────────────────────────── */
|
| 10 |
+
|
| 11 |
+
.ccai-dropdown-wrap { position: relative; }
|
| 12 |
+
|
| 13 |
+
.ccai-dropdown-trigger {
|
| 14 |
+
display: inline-flex;
|
| 15 |
+
align-items: center;
|
| 16 |
+
gap: 6px;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.ccai-dropdown-panel {
|
| 20 |
+
position: absolute;
|
| 21 |
+
right: 0;
|
| 22 |
+
top: calc(100% + 4px);
|
| 23 |
+
width: 320px;
|
| 24 |
+
max-height: 70vh;
|
| 25 |
+
overflow-y: auto;
|
| 26 |
+
background: var(--card-bg);
|
| 27 |
+
border: 1px solid var(--border-primary);
|
| 28 |
+
border-radius: 10px;
|
| 29 |
+
box-shadow: var(--shadow-md);
|
| 30 |
+
padding: 10px;
|
| 31 |
+
z-index: 60;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.ccai-dropdown-section {
|
| 35 |
+
display: flex;
|
| 36 |
+
flex-direction: column;
|
| 37 |
+
gap: 4px;
|
| 38 |
+
padding: 4px 0;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.ccai-dropdown-section-title {
|
| 42 |
+
font-size: 10px;
|
| 43 |
+
font-weight: 600;
|
| 44 |
+
text-transform: uppercase;
|
| 45 |
+
letter-spacing: 0.05em;
|
| 46 |
+
color: var(--text-muted);
|
| 47 |
+
padding: 4px 6px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.ccai-dropdown-divider {
|
| 51 |
+
height: 1px;
|
| 52 |
+
background: var(--border-muted);
|
| 53 |
+
margin: 4px -10px;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.ccai-dropdown-empty {
|
| 57 |
+
font-size: 11px;
|
| 58 |
+
color: var(--text-muted);
|
| 59 |
+
font-style: italic;
|
| 60 |
+
padding: 4px 8px;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.ccai-dropdown-item {
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
gap: 10px;
|
| 67 |
+
padding: 8px 10px;
|
| 68 |
+
border-radius: 8px;
|
| 69 |
+
cursor: pointer;
|
| 70 |
+
transition: background 0.1s;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.ccai-dropdown-item:hover {
|
| 74 |
+
background: var(--bg-tertiary);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.ccai-dropdown-item input[type="checkbox"] {
|
| 78 |
+
margin: 0;
|
| 79 |
+
flex-shrink: 0;
|
| 80 |
+
cursor: pointer;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.ccai-dropdown-item-checked {
|
| 84 |
+
background: var(--accent-light);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.ccai-dropdown-item-disabled {
|
| 88 |
+
opacity: 0.5;
|
| 89 |
+
cursor: not-allowed;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.ccai-dropdown-item-disabled input[type="checkbox"] {
|
| 93 |
+
cursor: not-allowed;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.ccai-dropdown-item-text {
|
| 97 |
+
display: flex;
|
| 98 |
+
flex-direction: column;
|
| 99 |
+
gap: 2px;
|
| 100 |
+
min-width: 0;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.ccai-dropdown-item-name {
|
| 104 |
+
font-size: 13px;
|
| 105 |
+
font-weight: 500;
|
| 106 |
+
color: var(--text-primary);
|
| 107 |
+
text-transform: capitalize;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.ccai-dropdown-item-sub {
|
| 111 |
+
font-size: 10px;
|
| 112 |
+
color: var(--text-muted);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.ccai-dropdown-create-btn {
|
| 116 |
+
display: inline-flex;
|
| 117 |
+
align-items: center;
|
| 118 |
+
gap: 6px;
|
| 119 |
+
padding: 6px 10px;
|
| 120 |
+
margin-top: 4px;
|
| 121 |
+
border: 1px dashed var(--accent-primary);
|
| 122 |
+
border-radius: 8px;
|
| 123 |
+
background: transparent;
|
| 124 |
+
color: var(--accent-primary);
|
| 125 |
+
font-size: 12px;
|
| 126 |
+
font-weight: 500;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.ccai-dropdown-create-btn:hover {
|
| 130 |
+
background: var(--accent-light);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
/* ── Sidebar: participants ─────────────────────────────────────── */
|
| 135 |
+
|
| 136 |
+
.ccai-sidebar {
|
| 137 |
+
gap: 12px;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.ccai-sidebar-header { gap: 4px; display: flex; flex-direction: column; }
|
| 141 |
+
.ccai-sidebar-help {
|
| 142 |
+
font-size: 11px;
|
| 143 |
+
color: var(--text-muted);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.ccai-participant-card {
|
| 147 |
+
border: 1px solid var(--border-primary);
|
| 148 |
+
border-radius: 10px;
|
| 149 |
+
background: var(--card-bg);
|
| 150 |
+
padding: 8px 10px;
|
| 151 |
+
display: flex;
|
| 152 |
+
flex-direction: column;
|
| 153 |
+
gap: 6px;
|
| 154 |
+
transition: opacity 0.15s, background 0.15s;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.ccai-participant-card-off {
|
| 158 |
+
opacity: 0.55;
|
| 159 |
+
background: var(--bg-secondary);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.ccai-participant-row {
|
| 163 |
+
display: flex;
|
| 164 |
+
align-items: center;
|
| 165 |
+
gap: 6px;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.ccai-participant-row-secondary {
|
| 169 |
+
justify-content: flex-end;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.ccai-accordion-chevron {
|
| 173 |
+
display: inline-flex;
|
| 174 |
+
align-items: center;
|
| 175 |
+
justify-content: center;
|
| 176 |
+
width: 22px;
|
| 177 |
+
height: 22px;
|
| 178 |
+
border: none;
|
| 179 |
+
background: transparent;
|
| 180 |
+
color: var(--text-muted);
|
| 181 |
+
border-radius: 4px;
|
| 182 |
+
flex-shrink: 0;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
.ccai-accordion-chevron:hover {
|
| 186 |
+
color: var(--text-primary);
|
| 187 |
+
background: var(--bg-tertiary);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.ccai-participant-name {
|
| 191 |
+
flex: 1;
|
| 192 |
+
font-size: 13px;
|
| 193 |
+
font-weight: 600;
|
| 194 |
+
color: var(--text-primary);
|
| 195 |
+
text-transform: capitalize;
|
| 196 |
+
overflow: hidden;
|
| 197 |
+
text-overflow: ellipsis;
|
| 198 |
+
white-space: nowrap;
|
| 199 |
+
min-width: 0;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.ccai-participant-controls {
|
| 203 |
+
display: flex;
|
| 204 |
+
align-items: center;
|
| 205 |
+
gap: 6px;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.ccai-participant-body {
|
| 209 |
+
display: flex;
|
| 210 |
+
flex-direction: column;
|
| 211 |
+
gap: 6px;
|
| 212 |
+
padding: 6px 0 2px;
|
| 213 |
+
border-top: 1px solid var(--border-muted);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.ccai-participant-field {
|
| 217 |
+
display: flex;
|
| 218 |
+
flex-direction: column;
|
| 219 |
+
gap: 2px;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.ccai-participant-field-label {
|
| 223 |
+
font-size: 10px;
|
| 224 |
+
font-weight: 600;
|
| 225 |
+
text-transform: uppercase;
|
| 226 |
+
letter-spacing: 0.04em;
|
| 227 |
+
color: var(--text-tertiary);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.ccai-participant-field-value {
|
| 231 |
+
font-size: 12px;
|
| 232 |
+
color: var(--text-secondary);
|
| 233 |
+
word-break: break-word;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.ccai-participant-prompt {
|
| 237 |
+
margin: 0;
|
| 238 |
+
font-size: 11px;
|
| 239 |
+
line-height: 1.5;
|
| 240 |
+
color: var(--text-secondary);
|
| 241 |
+
background: var(--bg-secondary);
|
| 242 |
+
border: 1px solid var(--border-muted);
|
| 243 |
+
border-radius: 6px;
|
| 244 |
+
padding: 8px 10px;
|
| 245 |
+
white-space: pre-wrap;
|
| 246 |
+
word-break: break-word;
|
| 247 |
+
max-height: 160px;
|
| 248 |
+
overflow-y: auto;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/* On/off slider switch */
|
| 252 |
+
.ccai-toggle {
|
| 253 |
+
position: relative;
|
| 254 |
+
display: inline-block;
|
| 255 |
+
width: 36px;
|
| 256 |
+
height: 20px;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.ccai-toggle input {
|
| 260 |
+
opacity: 0;
|
| 261 |
+
width: 0;
|
| 262 |
+
height: 0;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.ccai-toggle-slider {
|
| 266 |
+
position: absolute;
|
| 267 |
+
cursor: pointer;
|
| 268 |
+
inset: 0;
|
| 269 |
+
background: var(--accent-primary);
|
| 270 |
+
border-radius: 10px;
|
| 271 |
+
transition: background 0.2s;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.ccai-toggle-slider::before {
|
| 275 |
+
content: '';
|
| 276 |
+
position: absolute;
|
| 277 |
+
height: 14px;
|
| 278 |
+
width: 14px;
|
| 279 |
+
left: 19px;
|
| 280 |
+
top: 3px;
|
| 281 |
+
background: #fff;
|
| 282 |
+
border-radius: 50%;
|
| 283 |
+
transition: left 0.2s;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.ccai-toggle input:not(:checked) + .ccai-toggle-slider {
|
| 287 |
+
background: var(--text-muted);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.ccai-toggle input:not(:checked) + .ccai-toggle-slider::before {
|
| 291 |
+
left: 3px;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.ccai-remove-btn {
|
| 295 |
+
display: inline-flex;
|
| 296 |
+
align-items: center;
|
| 297 |
+
gap: 4px;
|
| 298 |
+
padding: 4px 8px;
|
| 299 |
+
border: 1px solid #DC2626;
|
| 300 |
+
border-radius: 6px;
|
| 301 |
+
background: transparent;
|
| 302 |
+
color: #DC2626;
|
| 303 |
+
font-size: 11px;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.ccai-remove-btn:hover { background: #FEE2E2; }
|
| 307 |
+
:root[data-theme="dark"] .ccai-remove-btn:hover { background: rgba(220, 38, 38, 0.15); }
|
| 308 |
+
|
| 309 |
+
.ccai-reenable-btn { font-size: 11px; padding: 4px 8px; }
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
/* ── Expert Persona modal ──────────────────────────────────────── */
|
| 313 |
+
|
| 314 |
+
.ccai-expert-modal {
|
| 315 |
+
width: min(760px, 92vw);
|
| 316 |
+
max-height: 88vh;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.ccai-expert-row {
|
| 320 |
+
display: grid;
|
| 321 |
+
grid-template-columns: 1fr 1fr;
|
| 322 |
+
gap: 12px;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.ccai-expert-field {
|
| 326 |
+
display: flex;
|
| 327 |
+
flex-direction: column;
|
| 328 |
+
gap: 4px;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
.ccai-expert-field label {
|
| 332 |
+
font-size: 11px;
|
| 333 |
+
font-weight: 600;
|
| 334 |
+
color: var(--text-tertiary);
|
| 335 |
+
text-transform: uppercase;
|
| 336 |
+
letter-spacing: 0.04em;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.ccai-expert-field input,
|
| 340 |
+
.ccai-expert-field select,
|
| 341 |
+
.ccai-expert-field textarea {
|
| 342 |
+
width: 100%;
|
| 343 |
+
padding: 8px 10px;
|
| 344 |
+
border: 1px solid var(--border-primary);
|
| 345 |
+
border-radius: 8px;
|
| 346 |
+
background: var(--bg-primary);
|
| 347 |
+
color: var(--text-primary);
|
| 348 |
+
font-size: 13px;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.ccai-tab-row {
|
| 352 |
+
display: flex;
|
| 353 |
+
align-items: center;
|
| 354 |
+
gap: 6px;
|
| 355 |
+
border-bottom: 1px solid var(--border-primary);
|
| 356 |
+
padding-bottom: 6px;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.ccai-tab-btn {
|
| 360 |
+
padding: 6px 12px;
|
| 361 |
+
border: 1px solid transparent;
|
| 362 |
+
border-radius: 6px;
|
| 363 |
+
background: transparent;
|
| 364 |
+
color: var(--text-secondary);
|
| 365 |
+
font-size: 12px;
|
| 366 |
+
font-weight: 600;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.ccai-tab-btn:hover {
|
| 370 |
+
background: var(--bg-tertiary);
|
| 371 |
+
color: var(--text-primary);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.ccai-tab-btn-active {
|
| 375 |
+
background: var(--accent-light);
|
| 376 |
+
color: var(--accent-primary);
|
| 377 |
+
border-color: var(--accent-primary);
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.ccai-tab-spacer { flex: 1; }
|
| 381 |
+
|
| 382 |
+
.ccai-role-style {
|
| 383 |
+
display: inline-flex;
|
| 384 |
+
align-items: center;
|
| 385 |
+
gap: 4px;
|
| 386 |
+
font-size: 12px;
|
| 387 |
+
color: var(--text-secondary);
|
| 388 |
+
cursor: pointer;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.ccai-expert-freeform,
|
| 392 |
+
.ccai-expert-structured {
|
| 393 |
+
display: flex;
|
| 394 |
+
flex-direction: column;
|
| 395 |
+
gap: 8px;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.ccai-expert-freeform .freeform-textarea {
|
| 399 |
+
width: 100%;
|
| 400 |
+
padding: 10px 12px;
|
| 401 |
+
border: 1px solid var(--border-primary);
|
| 402 |
+
border-radius: 8px;
|
| 403 |
+
background: var(--bg-primary);
|
| 404 |
+
color: var(--text-primary);
|
| 405 |
+
font-size: 13px;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.ccai-expert-structured textarea {
|
| 409 |
+
resize: vertical;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.ccai-expert-actions {
|
| 413 |
+
display: flex;
|
| 414 |
+
justify-content: flex-end;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
.ccai-expert-prompt {
|
| 418 |
+
display: flex;
|
| 419 |
+
flex-direction: column;
|
| 420 |
+
gap: 4px;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.ccai-expert-prompt label {
|
| 424 |
+
font-size: 11px;
|
| 425 |
+
font-weight: 600;
|
| 426 |
+
color: var(--text-tertiary);
|
| 427 |
+
text-transform: uppercase;
|
| 428 |
+
letter-spacing: 0.04em;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.ccai-expert-prompt textarea {
|
| 432 |
+
width: 100%;
|
| 433 |
+
padding: 10px 12px;
|
| 434 |
+
border: 1px solid var(--border-primary);
|
| 435 |
+
border-radius: 8px;
|
| 436 |
+
background: var(--bg-secondary);
|
| 437 |
+
color: var(--text-primary);
|
| 438 |
+
font-size: 13px;
|
| 439 |
+
line-height: 1.5;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.ccai-expert-error {
|
| 443 |
+
background: var(--error-bg);
|
| 444 |
+
border: 1px solid var(--error-border);
|
| 445 |
+
color: var(--error-text);
|
| 446 |
+
padding: 8px 12px;
|
| 447 |
+
border-radius: 8px;
|
| 448 |
+
font-size: 12px;
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.ccai-expert-footer {
|
| 452 |
+
display: flex;
|
| 453 |
+
align-items: center;
|
| 454 |
+
gap: 8px;
|
| 455 |
+
border-top: 1px solid var(--border-primary);
|
| 456 |
+
padding-top: 12px;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
/* ── Table view ────────────────────────────────────────────────── */
|
| 461 |
+
|
| 462 |
+
.ccai-table-overlay {
|
| 463 |
+
position: fixed;
|
| 464 |
+
inset: 0;
|
| 465 |
+
background: rgba(0, 0, 0, 0.5);
|
| 466 |
+
display: flex;
|
| 467 |
+
align-items: center;
|
| 468 |
+
justify-content: center;
|
| 469 |
+
z-index: 1000;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
.ccai-table-card {
|
| 473 |
+
background: var(--bg-primary);
|
| 474 |
+
border: 1px solid var(--border-primary);
|
| 475 |
+
border-radius: 12px;
|
| 476 |
+
width: min(1100px, 95vw);
|
| 477 |
+
max-height: 90vh;
|
| 478 |
+
display: flex;
|
| 479 |
+
flex-direction: column;
|
| 480 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.ccai-table-header {
|
| 484 |
+
display: flex;
|
| 485 |
+
align-items: center;
|
| 486 |
+
gap: 8px;
|
| 487 |
+
padding: 16px 20px;
|
| 488 |
+
border-bottom: 1px solid var(--border-primary);
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.ccai-table-header h2 {
|
| 492 |
+
margin: 0;
|
| 493 |
+
font-size: 16px;
|
| 494 |
+
font-weight: 600;
|
| 495 |
+
color: var(--text-primary);
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.ccai-table-body {
|
| 499 |
+
padding: 16px 20px;
|
| 500 |
+
display: flex;
|
| 501 |
+
flex-direction: column;
|
| 502 |
+
gap: 12px;
|
| 503 |
+
overflow: hidden;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.ccai-table-question,
|
| 507 |
+
.ccai-table-final {
|
| 508 |
+
background: var(--bg-secondary);
|
| 509 |
+
border-radius: 8px;
|
| 510 |
+
padding: 10px 12px;
|
| 511 |
+
font-size: 13px;
|
| 512 |
+
color: var(--text-secondary);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
.ccai-table-question strong,
|
| 516 |
+
.ccai-table-final strong {
|
| 517 |
+
display: block;
|
| 518 |
+
font-size: 11px;
|
| 519 |
+
text-transform: uppercase;
|
| 520 |
+
color: var(--text-tertiary);
|
| 521 |
+
letter-spacing: 0.04em;
|
| 522 |
+
margin-bottom: 4px;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.ccai-table-scroll {
|
| 526 |
+
overflow: auto;
|
| 527 |
+
border: 1px solid var(--border-primary);
|
| 528 |
+
border-radius: 8px;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.ccai-table {
|
| 532 |
+
width: 100%;
|
| 533 |
+
border-collapse: collapse;
|
| 534 |
+
font-size: 12px;
|
| 535 |
+
color: var(--text-primary);
|
| 536 |
+
table-layout: fixed;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
.ccai-table th,
|
| 540 |
+
.ccai-table td {
|
| 541 |
+
border: 1px solid var(--border-primary);
|
| 542 |
+
padding: 8px 10px;
|
| 543 |
+
vertical-align: top;
|
| 544 |
+
text-align: left;
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.ccai-table th {
|
| 548 |
+
background: var(--bg-secondary);
|
| 549 |
+
font-weight: 600;
|
| 550 |
+
text-transform: uppercase;
|
| 551 |
+
font-size: 10px;
|
| 552 |
+
letter-spacing: 0.04em;
|
| 553 |
+
color: var(--text-tertiary);
|
| 554 |
+
position: sticky;
|
| 555 |
+
top: 0;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
.ccai-table th:first-child,
|
| 559 |
+
.ccai-table td:first-child {
|
| 560 |
+
width: 15%;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
.ccai-table th:nth-child(n + 2),
|
| 564 |
+
.ccai-table td:nth-child(n + 2) {
|
| 565 |
+
width: 21.25%;
|
| 566 |
+
overflow-wrap: break-word;
|
| 567 |
+
word-wrap: break-word;
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
.ccai-table-name {
|
| 571 |
+
white-space: nowrap;
|
| 572 |
+
font-weight: 600;
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
.ccai-table-name small {
|
| 576 |
+
display: block;
|
| 577 |
+
font-weight: 400;
|
| 578 |
+
font-size: 10px;
|
| 579 |
+
color: var(--text-muted);
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
/* ── Credential Summary modal ──────────────────────────────────────
|
| 584 |
+
Surfaces the orchestrator's per-participant assessment built after
|
| 585 |
+
Phase 1 (concurrent per participant). Reuses the table
|
| 586 |
+
modal's overlay/card chrome for visual consistency.
|
| 587 |
+
──────────────────────────────────────────────────────────────── */
|
| 588 |
+
|
| 589 |
+
.ccai-credentials-overlay {
|
| 590 |
+
position: fixed;
|
| 591 |
+
inset: 0;
|
| 592 |
+
background: rgba(0, 0, 0, 0.5);
|
| 593 |
+
display: flex;
|
| 594 |
+
align-items: center;
|
| 595 |
+
justify-content: center;
|
| 596 |
+
z-index: 1000;
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
.ccai-credentials-card {
|
| 600 |
+
background: var(--bg-primary);
|
| 601 |
+
border: 1px solid var(--border-primary);
|
| 602 |
+
border-radius: 12px;
|
| 603 |
+
width: min(880px, 95vw);
|
| 604 |
+
max-height: 90vh;
|
| 605 |
+
display: flex;
|
| 606 |
+
flex-direction: column;
|
| 607 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
.ccai-credentials-header {
|
| 611 |
+
display: flex;
|
| 612 |
+
align-items: flex-start;
|
| 613 |
+
gap: 12px;
|
| 614 |
+
padding: 16px 20px;
|
| 615 |
+
border-bottom: 1px solid var(--border-primary);
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.ccai-credentials-header h2 {
|
| 619 |
+
margin: 0;
|
| 620 |
+
font-size: 16px;
|
| 621 |
+
font-weight: 600;
|
| 622 |
+
color: var(--text-primary);
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
.ccai-credentials-subtitle {
|
| 626 |
+
margin-top: 4px;
|
| 627 |
+
font-size: 12px;
|
| 628 |
+
color: var(--text-tertiary);
|
| 629 |
+
line-height: 1.4;
|
| 630 |
+
max-width: 560px;
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
.ccai-rate-limit-card {
|
| 634 |
+
width: min(480px, 92vw);
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
.ccai-rate-limit-body {
|
| 638 |
+
padding: 16px 20px;
|
| 639 |
+
font-size: 14px;
|
| 640 |
+
line-height: 1.5;
|
| 641 |
+
color: var(--text-secondary);
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
.ccai-rate-limit-body a {
|
| 645 |
+
color: var(--accent-primary);
|
| 646 |
+
text-decoration: none;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.ccai-rate-limit-body a:hover {
|
| 650 |
+
text-decoration: underline;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.ccai-rate-limit-actions {
|
| 654 |
+
display: flex;
|
| 655 |
+
justify-content: flex-end;
|
| 656 |
+
padding: 12px 20px 16px;
|
| 657 |
+
border-top: 1px solid var(--border-primary);
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
.ccai-credentials-question {
|
| 661 |
+
margin: 0 20px;
|
| 662 |
+
padding: 10px 12px;
|
| 663 |
+
background: var(--bg-secondary);
|
| 664 |
+
border-radius: 8px;
|
| 665 |
+
font-size: 13px;
|
| 666 |
+
color: var(--text-secondary);
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
.ccai-credentials-question strong {
|
| 670 |
+
display: block;
|
| 671 |
+
font-size: 11px;
|
| 672 |
+
text-transform: uppercase;
|
| 673 |
+
color: var(--text-tertiary);
|
| 674 |
+
letter-spacing: 0.04em;
|
| 675 |
+
margin-bottom: 4px;
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
.ccai-credentials-body {
|
| 679 |
+
padding: 16px 20px;
|
| 680 |
+
display: flex;
|
| 681 |
+
flex-direction: column;
|
| 682 |
+
gap: 12px;
|
| 683 |
+
overflow-y: auto;
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
.ccai-credentials-empty {
|
| 687 |
+
padding: 24px;
|
| 688 |
+
text-align: center;
|
| 689 |
+
font-size: 13px;
|
| 690 |
+
color: var(--text-tertiary);
|
| 691 |
+
border: 1px dashed var(--border-primary);
|
| 692 |
+
border-radius: 8px;
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
.ccai-credential-card {
|
| 696 |
+
padding: 12px 14px;
|
| 697 |
+
border: 1px solid var(--border-primary);
|
| 698 |
+
border-radius: 10px;
|
| 699 |
+
background: var(--bg-secondary);
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
.ccai-credential-card-head {
|
| 703 |
+
display: flex;
|
| 704 |
+
align-items: center;
|
| 705 |
+
gap: 12px;
|
| 706 |
+
margin-bottom: 8px;
|
| 707 |
+
flex-wrap: wrap;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.ccai-credential-name {
|
| 711 |
+
font-size: 14px;
|
| 712 |
+
font-weight: 600;
|
| 713 |
+
color: var(--text-primary);
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
.ccai-credibility-wrap {
|
| 717 |
+
display: inline-flex;
|
| 718 |
+
align-items: center;
|
| 719 |
+
gap: 6px;
|
| 720 |
+
margin-left: auto;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
.ccai-credibility-label {
|
| 724 |
+
font-size: 10px;
|
| 725 |
+
text-transform: uppercase;
|
| 726 |
+
letter-spacing: 0.04em;
|
| 727 |
+
color: var(--text-tertiary);
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
.ccai-credibility-bar {
|
| 731 |
+
width: 120px;
|
| 732 |
+
height: 6px;
|
| 733 |
+
background: var(--border-primary);
|
| 734 |
+
border-radius: 3px;
|
| 735 |
+
overflow: hidden;
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
.ccai-credibility-fill {
|
| 739 |
+
height: 100%;
|
| 740 |
+
background: linear-gradient(
|
| 741 |
+
90deg,
|
| 742 |
+
var(--accent-primary, #6366F1),
|
| 743 |
+
var(--accent-primary, #6366F1)
|
| 744 |
+
);
|
| 745 |
+
transition: width 0.3s ease;
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
.ccai-credibility-num {
|
| 749 |
+
font-size: 12px;
|
| 750 |
+
font-weight: 600;
|
| 751 |
+
font-variant-numeric: tabular-nums;
|
| 752 |
+
color: var(--text-secondary);
|
| 753 |
+
min-width: 32px;
|
| 754 |
+
text-align: right;
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
.ccai-credential-row {
|
| 758 |
+
display: grid;
|
| 759 |
+
grid-template-columns: 110px 1fr;
|
| 760 |
+
gap: 8px;
|
| 761 |
+
padding: 4px 0;
|
| 762 |
+
font-size: 13px;
|
| 763 |
+
line-height: 1.45;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
.ccai-credential-row-label {
|
| 767 |
+
font-size: 10px;
|
| 768 |
+
text-transform: uppercase;
|
| 769 |
+
letter-spacing: 0.04em;
|
| 770 |
+
color: var(--text-tertiary);
|
| 771 |
+
font-weight: 600;
|
| 772 |
+
padding-top: 2px;
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
.ccai-credential-row-value {
|
| 776 |
+
color: var(--text-primary);
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
@media (max-width: 600px) {
|
| 780 |
+
.ccai-credentials-card {
|
| 781 |
+
max-height: 100vh;
|
| 782 |
+
border-radius: 0;
|
| 783 |
+
width: 100vw;
|
| 784 |
+
}
|
| 785 |
+
.ccai-credential-row {
|
| 786 |
+
grid-template-columns: 1fr;
|
| 787 |
+
gap: 2px;
|
| 788 |
+
}
|
| 789 |
+
.ccai-credibility-bar {
|
| 790 |
+
width: 80px;
|
| 791 |
+
}
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
|
| 795 |
+
/* ── Orchestrator messages ─────────────────────────────────────── */
|
| 796 |
+
|
| 797 |
+
.ccai-orchestrator-msg {
|
| 798 |
+
text-align: center;
|
| 799 |
+
font-style: italic;
|
| 800 |
+
color: var(--text-secondary);
|
| 801 |
+
background: var(--bg-secondary);
|
| 802 |
+
border: 1px dashed var(--border-primary);
|
| 803 |
+
border-radius: 12px;
|
| 804 |
+
padding: 10px 16px;
|
| 805 |
+
margin: 6px auto;
|
| 806 |
+
max-width: 720px;
|
| 807 |
+
font-size: 13px;
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
.ccai-orchestrator-msg-label {
|
| 811 |
+
font-size: 10px;
|
| 812 |
+
text-transform: uppercase;
|
| 813 |
+
letter-spacing: 0.06em;
|
| 814 |
+
font-weight: 700;
|
| 815 |
+
color: var(--text-tertiary);
|
| 816 |
+
margin-bottom: 4px;
|
| 817 |
+
font-style: normal;
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
.ccai-orchestrator-msg-body p:last-child { margin-bottom: 0; }
|
| 821 |
+
|
| 822 |
+
.ccai-orchestrator-msg-report {
|
| 823 |
+
background: var(--accent-light);
|
| 824 |
+
border-style: solid;
|
| 825 |
+
border-color: var(--accent-primary);
|
| 826 |
+
font-style: normal;
|
| 827 |
+
text-align: left;
|
| 828 |
+
color: var(--text-primary);
|
| 829 |
+
font-size: 14px;
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
.ccai-orchestrator-msg-report .ccai-orchestrator-msg-label {
|
| 833 |
+
color: var(--accent-primary);
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
/* ── Failsafe pause banner ─────────────────────────────────────── */
|
| 838 |
+
|
| 839 |
+
.ccai-failsafe-banner {
|
| 840 |
+
display: flex;
|
| 841 |
+
align-items: center;
|
| 842 |
+
gap: 12px;
|
| 843 |
+
background: #FEF3C7;
|
| 844 |
+
border: 1px solid #FCD34D;
|
| 845 |
+
border-radius: 12px;
|
| 846 |
+
padding: 12px 14px;
|
| 847 |
+
color: #92400E;
|
| 848 |
+
margin: 8px 0;
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
:root[data-theme="dark"] .ccai-failsafe-banner {
|
| 852 |
+
background: rgba(217, 119, 6, 0.15);
|
| 853 |
+
border-color: rgba(252, 211, 77, 0.5);
|
| 854 |
+
color: #FBBF24;
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.ccai-failsafe-banner > div:first-child {
|
| 858 |
+
flex: 1;
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
.ccai-failsafe-title {
|
| 862 |
+
font-weight: 700;
|
| 863 |
+
font-size: 13px;
|
| 864 |
+
margin-bottom: 2px;
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.ccai-failsafe-text {
|
| 868 |
+
font-size: 12px;
|
| 869 |
+
opacity: 0.85;
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
|
| 873 |
+
/* ── Active-question chip (next to Stop Chat) ─────────────────── */
|
| 874 |
+
|
| 875 |
+
/* The chip caps at roughly two lines tall and scrolls vertically for
|
| 876 |
+
longer prompts. The full prompt is also exposed via the title
|
| 877 |
+
attribute for hover tooltips. */
|
| 878 |
+
.ccai-active-question {
|
| 879 |
+
flex: 1;
|
| 880 |
+
min-width: 0;
|
| 881 |
+
max-height: 60px;
|
| 882 |
+
padding: 8px 12px;
|
| 883 |
+
border: 1px solid var(--border-primary);
|
| 884 |
+
border-radius: 8px;
|
| 885 |
+
background: var(--bg-secondary);
|
| 886 |
+
color: var(--text-secondary);
|
| 887 |
+
font-size: 13px;
|
| 888 |
+
line-height: 1.4;
|
| 889 |
+
overflow-y: auto;
|
| 890 |
+
overflow-x: hidden;
|
| 891 |
+
overscroll-behavior: contain;
|
| 892 |
+
}
|
| 893 |
+
|
| 894 |
+
.ccai-active-question-label {
|
| 895 |
+
font-size: 10px;
|
| 896 |
+
font-weight: 700;
|
| 897 |
+
text-transform: uppercase;
|
| 898 |
+
letter-spacing: 0.05em;
|
| 899 |
+
color: var(--text-tertiary);
|
| 900 |
+
margin-right: 6px;
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
.ccai-active-question-text {
|
| 904 |
+
color: var(--text-secondary);
|
| 905 |
+
font-style: italic;
|
| 906 |
+
}
|
| 907 |
+
|
| 908 |
+
|
| 909 |
+
/* ── Conversation threading & addressing cues ─────────────────────
|
| 910 |
+
Lightweight visual aids that help a human reader follow who is
|
| 911 |
+
speaking to whom. Backed by the message's `addressed_to` field
|
| 912 |
+
(one participant id) and `replying_to` field (list of asker ids
|
| 913 |
+
the speaker had open threads with at turn start).
|
| 914 |
+
────────────────────────────────────────────────────────────────── */
|
| 915 |
+
|
| 916 |
+
/* Indent + thread line when a participant message is a direct reply
|
| 917 |
+
to the immediately previous participant message. We don't fully
|
| 918 |
+
nest replies because N-participant chats turn into trees fast;
|
| 919 |
+
this is a single-level visual hint, not full threading. */
|
| 920 |
+
.ccai-message-row.ccai-message-row-reply {
|
| 921 |
+
margin-left: 28px;
|
| 922 |
+
padding-left: 12px;
|
| 923 |
+
position: relative;
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
.ccai-message-row.ccai-message-row-reply::before {
|
| 927 |
+
content: '';
|
| 928 |
+
position: absolute;
|
| 929 |
+
left: 0;
|
| 930 |
+
top: 6px;
|
| 931 |
+
bottom: 6px;
|
| 932 |
+
width: 2px;
|
| 933 |
+
border-radius: 1px;
|
| 934 |
+
background: var(--border-primary);
|
| 935 |
+
opacity: 0.6;
|
| 936 |
+
}
|
| 937 |
+
|
| 938 |
+
/* "Replying to: X, Y" pill above the bubble, colored with the
|
| 939 |
+
speaker's tone. Mirrors the pending-thread list the orchestrator
|
| 940 |
+
showed the speaker at turn-start. Stays small and unobtrusive. */
|
| 941 |
+
.ccai-replying-to-pill {
|
| 942 |
+
display: inline-block;
|
| 943 |
+
font-size: 11px;
|
| 944 |
+
font-weight: 600;
|
| 945 |
+
letter-spacing: 0.01em;
|
| 946 |
+
padding: 2px 8px;
|
| 947 |
+
border-radius: 999px;
|
| 948 |
+
border: 1px solid;
|
| 949 |
+
background: var(--card-bg);
|
| 950 |
+
margin-bottom: 6px;
|
| 951 |
+
white-space: nowrap;
|
| 952 |
+
max-width: 100%;
|
| 953 |
+
overflow: hidden;
|
| 954 |
+
text-overflow: ellipsis;
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
/* "→ Addressee" chip in the speaker line. The addressee name is a
|
| 958 |
+
clickable button that scrolls the chat to that participant's most
|
| 959 |
+
recent message before this one. */
|
| 960 |
+
.ccai-addressee-wrap {
|
| 961 |
+
display: inline-flex;
|
| 962 |
+
align-items: center;
|
| 963 |
+
gap: 4px;
|
| 964 |
+
margin-left: 8px;
|
| 965 |
+
font-weight: 500;
|
| 966 |
+
opacity: 0.9;
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
.ccai-addressee-arrow {
|
| 970 |
+
display: inline-block;
|
| 971 |
+
vertical-align: middle;
|
| 972 |
+
opacity: 0.7;
|
| 973 |
+
}
|
| 974 |
+
|
| 975 |
+
.ccai-addressee-link {
|
| 976 |
+
background: none;
|
| 977 |
+
border: none;
|
| 978 |
+
cursor: pointer;
|
| 979 |
+
padding: 0;
|
| 980 |
+
font: inherit;
|
| 981 |
+
font-weight: 600;
|
| 982 |
+
text-decoration: underline;
|
| 983 |
+
text-decoration-style: dotted;
|
| 984 |
+
text-decoration-color: currentColor;
|
| 985 |
+
text-underline-offset: 2px;
|
| 986 |
+
transition: text-decoration-style 0.1s ease, opacity 0.1s ease;
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
.ccai-addressee-link:hover {
|
| 990 |
+
text-decoration-style: solid;
|
| 991 |
+
opacity: 1;
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
.ccai-addressee-link:focus-visible {
|
| 995 |
+
outline: 2px solid currentColor;
|
| 996 |
+
outline-offset: 2px;
|
| 997 |
+
border-radius: 2px;
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
/* Flash highlight applied to the scroll-target when the user clicks an
|
| 1001 |
+
addressee chip. Makes the destination obvious for a beat. */
|
| 1002 |
+
.ccai-flash-highlight .message-bubble {
|
| 1003 |
+
animation: ccai-flash 1.4s ease-out;
|
| 1004 |
+
}
|
| 1005 |
+
|
| 1006 |
+
@keyframes ccai-flash {
|
| 1007 |
+
0% {
|
| 1008 |
+
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.55);
|
| 1009 |
+
}
|
| 1010 |
+
60% {
|
| 1011 |
+
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.25);
|
| 1012 |
+
}
|
| 1013 |
+
100% {
|
| 1014 |
+
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0);
|
| 1015 |
+
}
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
|
| 1019 |
+
/* ── Stepper ───────────────────────────────────────────────────── */
|
| 1020 |
+
|
| 1021 |
+
.ccai-stepper-row {
|
| 1022 |
+
display: flex;
|
| 1023 |
+
align-items: center;
|
| 1024 |
+
gap: 8px;
|
| 1025 |
+
/* 22px left padding matches .dev-panel-row indentation so the
|
| 1026 |
+
stepper hangs under the "Max participants" category label
|
| 1027 |
+
consistently with every other category's items. */
|
| 1028 |
+
padding: 4px 10px 8px 22px;
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
.ccai-stepper-btn {
|
| 1032 |
+
width: 24px;
|
| 1033 |
+
height: 24px;
|
| 1034 |
+
padding: 0;
|
| 1035 |
+
display: inline-flex;
|
| 1036 |
+
align-items: center;
|
| 1037 |
+
justify-content: center;
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
.ccai-stepper-val {
|
| 1041 |
+
font-weight: 600;
|
| 1042 |
+
min-width: 20px;
|
| 1043 |
+
text-align: center;
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
|
| 1047 |
+
/* ── Responsive tweaks ─────────────────────────────────────────── */
|
| 1048 |
+
|
| 1049 |
+
@media (max-width: 900px) {
|
| 1050 |
+
.ccai-expert-row {
|
| 1051 |
+
grid-template-columns: 1fr;
|
| 1052 |
+
}
|
| 1053 |
+
.ccai-dropdown-panel {
|
| 1054 |
+
width: min(320px, calc(100vw - 24px));
|
| 1055 |
+
}
|
| 1056 |
+
}
|
| 1057 |
+
|
| 1058 |
+
@media (max-width: 600px) {
|
| 1059 |
+
.ccai-table-card {
|
| 1060 |
+
max-height: 100vh;
|
| 1061 |
+
border-radius: 0;
|
| 1062 |
+
width: 100vw;
|
| 1063 |
+
}
|
| 1064 |
+
}
|
| 1065 |
+
|
| 1066 |
+
|
| 1067 |
+
/* ── Conversation limits modal ────────────────────────────────────
|
| 1068 |
+
Re-uses .ccai-credentials-overlay/.ccai-credentials-card/.ccai-
|
| 1069 |
+
credentials-header/.ccai-credentials-body for the shell so the
|
| 1070 |
+
look matches the credential summary modal. The classes below
|
| 1071 |
+
style the per-section list and per-field stepper row. */
|
| 1072 |
+
|
| 1073 |
+
.ccai-limits-group {
|
| 1074 |
+
display: flex;
|
| 1075 |
+
flex-direction: column;
|
| 1076 |
+
gap: 10px;
|
| 1077 |
+
padding: 12px 14px;
|
| 1078 |
+
background: var(--bg-secondary);
|
| 1079 |
+
border: 1px solid var(--border-primary);
|
| 1080 |
+
border-radius: 10px;
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
.ccai-limits-group + .ccai-limits-group {
|
| 1084 |
+
margin-top: 4px;
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
.ccai-limits-group-title {
|
| 1088 |
+
font-size: 13px;
|
| 1089 |
+
font-weight: 600;
|
| 1090 |
+
color: var(--text-primary);
|
| 1091 |
+
text-transform: uppercase;
|
| 1092 |
+
letter-spacing: 0.04em;
|
| 1093 |
+
border-bottom: 1px solid var(--border-primary);
|
| 1094 |
+
padding-bottom: 6px;
|
| 1095 |
+
margin-bottom: 2px;
|
| 1096 |
+
}
|
| 1097 |
+
|
| 1098 |
+
.ccai-limit-row {
|
| 1099 |
+
display: flex;
|
| 1100 |
+
flex-direction: column;
|
| 1101 |
+
gap: 4px;
|
| 1102 |
+
padding: 6px 0;
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
.ccai-limit-row + .ccai-limit-row {
|
| 1106 |
+
border-top: 1px dashed var(--border-primary);
|
| 1107 |
+
padding-top: 10px;
|
| 1108 |
+
}
|
| 1109 |
+
|
| 1110 |
+
.ccai-limit-row-head {
|
| 1111 |
+
display: flex;
|
| 1112 |
+
align-items: center;
|
| 1113 |
+
justify-content: space-between;
|
| 1114 |
+
gap: 12px;
|
| 1115 |
+
flex-wrap: wrap;
|
| 1116 |
+
}
|
| 1117 |
+
|
| 1118 |
+
.ccai-limit-label {
|
| 1119 |
+
font-size: 14px;
|
| 1120 |
+
font-weight: 600;
|
| 1121 |
+
color: var(--text-primary);
|
| 1122 |
+
}
|
| 1123 |
+
|
| 1124 |
+
.ccai-limit-stepper {
|
| 1125 |
+
display: inline-flex;
|
| 1126 |
+
align-items: center;
|
| 1127 |
+
gap: 8px;
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
.ccai-limit-input {
|
| 1131 |
+
width: 64px;
|
| 1132 |
+
padding: 4px 6px;
|
| 1133 |
+
background: var(--bg-primary);
|
| 1134 |
+
border: 1px solid var(--border-primary);
|
| 1135 |
+
border-radius: 6px;
|
| 1136 |
+
color: var(--text-primary);
|
| 1137 |
+
font-size: 14px;
|
| 1138 |
+
text-align: right;
|
| 1139 |
+
}
|
| 1140 |
+
|
| 1141 |
+
.ccai-limit-range {
|
| 1142 |
+
font-size: 12px;
|
| 1143 |
+
color: var(--text-secondary);
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
.ccai-limit-reset {
|
| 1147 |
+
font-size: 12px;
|
| 1148 |
+
background: transparent;
|
| 1149 |
+
border: 1px solid var(--border-primary);
|
| 1150 |
+
color: var(--text-secondary);
|
| 1151 |
+
padding: 2px 8px;
|
| 1152 |
+
border-radius: 6px;
|
| 1153 |
+
cursor: pointer;
|
| 1154 |
+
}
|
| 1155 |
+
|
| 1156 |
+
.ccai-limit-reset:hover {
|
| 1157 |
+
background: var(--card-bg);
|
| 1158 |
+
color: var(--text-primary);
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
.ccai-limit-help {
|
| 1162 |
+
font-size: 12.5px;
|
| 1163 |
+
color: var(--text-secondary);
|
| 1164 |
+
line-height: 1.45;
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
|
| 1168 |
+
/* ── Auto-select participants (dropdown + sidebar placeholder) ──── */
|
| 1169 |
+
|
| 1170 |
+
.ccai-dropdown-autoselect {
|
| 1171 |
+
display: flex;
|
| 1172 |
+
align-items: center;
|
| 1173 |
+
gap: 8px;
|
| 1174 |
+
width: 100%;
|
| 1175 |
+
padding: 8px 10px;
|
| 1176 |
+
background: var(--bg-secondary);
|
| 1177 |
+
border: 1px solid var(--border-primary);
|
| 1178 |
+
border-radius: 8px;
|
| 1179 |
+
color: var(--text-primary);
|
| 1180 |
+
font-size: 13px;
|
| 1181 |
+
font-weight: 600;
|
| 1182 |
+
cursor: pointer;
|
| 1183 |
+
text-align: left;
|
| 1184 |
+
}
|
| 1185 |
+
|
| 1186 |
+
.ccai-dropdown-autoselect:hover {
|
| 1187 |
+
border-color: var(--accent-primary, var(--text-primary));
|
| 1188 |
+
}
|
| 1189 |
+
|
| 1190 |
+
.ccai-dropdown-autoselect-on {
|
| 1191 |
+
background: var(--accent-primary, var(--bg-primary));
|
| 1192 |
+
color: var(--bg-primary, var(--text-primary));
|
| 1193 |
+
border-color: transparent;
|
| 1194 |
+
}
|
| 1195 |
+
|
| 1196 |
+
.ccai-dropdown-autoselect-help {
|
| 1197 |
+
font-size: 11.5px;
|
| 1198 |
+
color: var(--text-secondary);
|
| 1199 |
+
padding: 6px 4px 0;
|
| 1200 |
+
line-height: 1.4;
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
.ccai-dropdown-section-muted {
|
| 1204 |
+
opacity: 0.55;
|
| 1205 |
+
pointer-events: none;
|
| 1206 |
+
}
|
| 1207 |
+
|
| 1208 |
+
.ccai-sidebar-autoselect-empty {
|
| 1209 |
+
margin: 12px;
|
| 1210 |
+
padding: 12px;
|
| 1211 |
+
border: 1px dashed var(--border-primary);
|
| 1212 |
+
border-radius: 8px;
|
| 1213 |
+
color: var(--text-secondary);
|
| 1214 |
+
font-size: 12.5px;
|
| 1215 |
+
line-height: 1.45;
|
| 1216 |
+
}
|
| 1217 |
+
|
| 1218 |
+
.ccai-sidebar-autoselect-empty strong {
|
| 1219 |
+
color: var(--text-primary);
|
| 1220 |
+
font-size: 13px;
|
| 1221 |
+
}
|
| 1222 |
+
|
| 1223 |
+
|
| 1224 |
+
/* ── Prompt catalog modal ─────────────────────────────────────────
|
| 1225 |
+
Re-uses .ccai-credentials-overlay/.ccai-credentials-card shell.
|
| 1226 |
+
The classes below style the per-group sections and per-item
|
| 1227 |
+
purpose / variables / template blocks. */
|
| 1228 |
+
|
| 1229 |
+
.ccai-prompt-group {
|
| 1230 |
+
display: flex;
|
| 1231 |
+
flex-direction: column;
|
| 1232 |
+
gap: 10px;
|
| 1233 |
+
padding: 12px 14px;
|
| 1234 |
+
background: var(--bg-secondary);
|
| 1235 |
+
border: 1px solid var(--border-primary);
|
| 1236 |
+
border-radius: 10px;
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
.ccai-prompt-group + .ccai-prompt-group {
|
| 1240 |
+
margin-top: 4px;
|
| 1241 |
+
}
|
| 1242 |
+
|
| 1243 |
+
.ccai-prompt-group-title {
|
| 1244 |
+
font-size: 13px;
|
| 1245 |
+
font-weight: 600;
|
| 1246 |
+
color: var(--text-primary);
|
| 1247 |
+
text-transform: uppercase;
|
| 1248 |
+
letter-spacing: 0.04em;
|
| 1249 |
+
border-bottom: 1px solid var(--border-primary);
|
| 1250 |
+
padding-bottom: 6px;
|
| 1251 |
+
}
|
| 1252 |
+
|
| 1253 |
+
.ccai-prompt-item {
|
| 1254 |
+
display: flex;
|
| 1255 |
+
flex-direction: column;
|
| 1256 |
+
gap: 6px;
|
| 1257 |
+
padding: 8px 0;
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
.ccai-prompt-item + .ccai-prompt-item {
|
| 1261 |
+
border-top: 1px dashed var(--border-primary);
|
| 1262 |
+
padding-top: 12px;
|
| 1263 |
+
}
|
| 1264 |
+
|
| 1265 |
+
.ccai-prompt-item-head {
|
| 1266 |
+
display: flex;
|
| 1267 |
+
align-items: center;
|
| 1268 |
+
justify-content: space-between;
|
| 1269 |
+
gap: 12px;
|
| 1270 |
+
}
|
| 1271 |
+
|
| 1272 |
+
.ccai-prompt-item-title {
|
| 1273 |
+
font-size: 14px;
|
| 1274 |
+
font-weight: 600;
|
| 1275 |
+
color: var(--text-primary);
|
| 1276 |
+
}
|
| 1277 |
+
|
| 1278 |
+
.ccai-prompt-copy-btn {
|
| 1279 |
+
display: inline-flex;
|
| 1280 |
+
align-items: center;
|
| 1281 |
+
gap: 4px;
|
| 1282 |
+
font-size: 12px;
|
| 1283 |
+
padding: 3px 8px;
|
| 1284 |
+
background: transparent;
|
| 1285 |
+
border: 1px solid var(--border-primary);
|
| 1286 |
+
color: var(--text-secondary);
|
| 1287 |
+
border-radius: 6px;
|
| 1288 |
+
cursor: pointer;
|
| 1289 |
+
}
|
| 1290 |
+
|
| 1291 |
+
.ccai-prompt-copy-btn:hover {
|
| 1292 |
+
background: var(--card-bg);
|
| 1293 |
+
color: var(--text-primary);
|
| 1294 |
+
}
|
| 1295 |
+
|
| 1296 |
+
.ccai-prompt-purpose {
|
| 1297 |
+
font-size: 12.5px;
|
| 1298 |
+
color: var(--text-secondary);
|
| 1299 |
+
line-height: 1.45;
|
| 1300 |
+
}
|
| 1301 |
+
|
| 1302 |
+
.ccai-prompt-vars {
|
| 1303 |
+
font-size: 12px;
|
| 1304 |
+
color: var(--text-secondary);
|
| 1305 |
+
}
|
| 1306 |
+
|
| 1307 |
+
.ccai-prompt-vars code {
|
| 1308 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
| 1309 |
+
background: var(--bg-primary);
|
| 1310 |
+
border: 1px solid var(--border-primary);
|
| 1311 |
+
border-radius: 4px;
|
| 1312 |
+
padding: 1px 4px;
|
| 1313 |
+
font-size: 11.5px;
|
| 1314 |
+
}
|
| 1315 |
+
|
| 1316 |
+
.ccai-prompt-template {
|
| 1317 |
+
background: var(--bg-primary);
|
| 1318 |
+
border: 1px solid var(--border-primary);
|
| 1319 |
+
border-radius: 6px;
|
| 1320 |
+
padding: 10px 12px;
|
| 1321 |
+
margin: 0;
|
| 1322 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
| 1323 |
+
font-size: 12px;
|
| 1324 |
+
line-height: 1.45;
|
| 1325 |
+
color: var(--text-primary);
|
| 1326 |
+
white-space: pre-wrap;
|
| 1327 |
+
overflow-x: auto;
|
| 1328 |
+
max-height: 280px;
|
| 1329 |
+
overflow-y: auto;
|
| 1330 |
+
}
|
| 1331 |
+
|
| 1332 |
+
|
| 1333 |
+
/* ════════════════════════════════════════════════════════════════
|
| 1334 |
+
Human Participant
|
| 1335 |
+
Styles for the in-the-loop human: add/edit modal, sidebar entry,
|
| 1336 |
+
in-chat input slot, lower-screen attention cue, green-accent bubble,
|
| 1337 |
+
and the inline edit affordance on the human's credential row.
|
| 1338 |
+
════════════════════════════════════════════════════════════════ */
|
| 1339 |
+
|
| 1340 |
+
/* ── Add a Human Participant button (header) ───────────────────── */
|
| 1341 |
+
|
| 1342 |
+
.ccai-human-add-btn {
|
| 1343 |
+
display: inline-flex;
|
| 1344 |
+
align-items: center;
|
| 1345 |
+
gap: 4px;
|
| 1346 |
+
white-space: nowrap;
|
| 1347 |
+
}
|
| 1348 |
+
|
| 1349 |
+
/* Header "Table View" button — links to the same Summary Table modal
|
| 1350 |
+
the Settings menu opens, so users don't have to dig through a menu
|
| 1351 |
+
to view it during/after a chat. */
|
| 1352 |
+
.ccai-table-view-btn {
|
| 1353 |
+
display: inline-flex;
|
| 1354 |
+
align-items: center;
|
| 1355 |
+
gap: 4px;
|
| 1356 |
+
white-space: nowrap;
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
.ccai-human-add-btn-active {
|
| 1360 |
+
border-color: #16A34A;
|
| 1361 |
+
color: #16A34A;
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
[data-theme="dark"] .ccai-human-add-btn-active {
|
| 1365 |
+
border-color: #4ADE80;
|
| 1366 |
+
color: #4ADE80;
|
| 1367 |
+
}
|
| 1368 |
+
|
| 1369 |
+
/* ── Human Participant modal (Add / Edit) ──────────────────────── */
|
| 1370 |
+
|
| 1371 |
+
.ccai-human-modal-card {
|
| 1372 |
+
width: min(640px, 92vw);
|
| 1373 |
+
}
|
| 1374 |
+
|
| 1375 |
+
.ccai-human-modal-body {
|
| 1376 |
+
display: flex;
|
| 1377 |
+
flex-direction: column;
|
| 1378 |
+
gap: 16px;
|
| 1379 |
+
padding: 16px 20px;
|
| 1380 |
+
}
|
| 1381 |
+
|
| 1382 |
+
.ccai-human-field {
|
| 1383 |
+
display: flex;
|
| 1384 |
+
flex-direction: column;
|
| 1385 |
+
gap: 6px;
|
| 1386 |
+
}
|
| 1387 |
+
|
| 1388 |
+
.ccai-human-field-label {
|
| 1389 |
+
font-size: 12px;
|
| 1390 |
+
font-weight: 600;
|
| 1391 |
+
color: var(--text-secondary);
|
| 1392 |
+
text-transform: uppercase;
|
| 1393 |
+
letter-spacing: 0.04em;
|
| 1394 |
+
}
|
| 1395 |
+
|
| 1396 |
+
.ccai-human-input {
|
| 1397 |
+
padding: 8px 10px;
|
| 1398 |
+
border: 1px solid var(--border-primary);
|
| 1399 |
+
border-radius: 6px;
|
| 1400 |
+
background: var(--card-bg);
|
| 1401 |
+
color: var(--text-primary);
|
| 1402 |
+
font-size: 14px;
|
| 1403 |
+
}
|
| 1404 |
+
|
| 1405 |
+
.ccai-human-input:focus {
|
| 1406 |
+
outline: none;
|
| 1407 |
+
border-color: #16A34A;
|
| 1408 |
+
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
| 1409 |
+
}
|
| 1410 |
+
|
| 1411 |
+
.ccai-human-summary-header {
|
| 1412 |
+
display: flex;
|
| 1413 |
+
align-items: center;
|
| 1414 |
+
justify-content: space-between;
|
| 1415 |
+
gap: 8px;
|
| 1416 |
+
}
|
| 1417 |
+
|
| 1418 |
+
.ccai-human-summary {
|
| 1419 |
+
width: 100%;
|
| 1420 |
+
padding: 10px 12px;
|
| 1421 |
+
border: 1px solid var(--border-primary);
|
| 1422 |
+
border-radius: 6px;
|
| 1423 |
+
background: var(--card-bg);
|
| 1424 |
+
color: var(--text-primary);
|
| 1425 |
+
font-size: 13px;
|
| 1426 |
+
font-family: inherit;
|
| 1427 |
+
line-height: 1.5;
|
| 1428 |
+
resize: vertical;
|
| 1429 |
+
min-height: 160px;
|
| 1430 |
+
box-sizing: border-box;
|
| 1431 |
+
}
|
| 1432 |
+
|
| 1433 |
+
.ccai-human-summary:focus {
|
| 1434 |
+
outline: none;
|
| 1435 |
+
border-color: #16A34A;
|
| 1436 |
+
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
| 1437 |
+
}
|
| 1438 |
+
|
| 1439 |
+
.ccai-human-summary-help {
|
| 1440 |
+
font-size: 12px;
|
| 1441 |
+
color: var(--text-tertiary);
|
| 1442 |
+
font-style: italic;
|
| 1443 |
+
}
|
| 1444 |
+
|
| 1445 |
+
.ccai-human-ai-btn {
|
| 1446 |
+
display: inline-flex;
|
| 1447 |
+
align-items: center;
|
| 1448 |
+
gap: 4px;
|
| 1449 |
+
white-space: nowrap;
|
| 1450 |
+
}
|
| 1451 |
+
|
| 1452 |
+
.ccai-human-ai-panel {
|
| 1453 |
+
border: 1px solid #16A34A;
|
| 1454 |
+
background: rgba(22, 163, 74, 0.04);
|
| 1455 |
+
border-radius: 8px;
|
| 1456 |
+
padding: 12px 14px;
|
| 1457 |
+
display: flex;
|
| 1458 |
+
flex-direction: column;
|
| 1459 |
+
gap: 10px;
|
| 1460 |
+
}
|
| 1461 |
+
|
| 1462 |
+
[data-theme="dark"] .ccai-human-ai-panel {
|
| 1463 |
+
background: rgba(74, 222, 128, 0.06);
|
| 1464 |
+
border-color: #4ADE80;
|
| 1465 |
+
}
|
| 1466 |
+
|
| 1467 |
+
.ccai-human-ai-counter {
|
| 1468 |
+
display: flex;
|
| 1469 |
+
align-items: center;
|
| 1470 |
+
justify-content: space-between;
|
| 1471 |
+
font-size: 12px;
|
| 1472 |
+
font-weight: 600;
|
| 1473 |
+
color: #16A34A;
|
| 1474 |
+
text-transform: uppercase;
|
| 1475 |
+
letter-spacing: 0.04em;
|
| 1476 |
+
}
|
| 1477 |
+
|
| 1478 |
+
[data-theme="dark"] .ccai-human-ai-counter {
|
| 1479 |
+
color: #4ADE80;
|
| 1480 |
+
}
|
| 1481 |
+
|
| 1482 |
+
.ccai-human-ai-stop {
|
| 1483 |
+
display: inline-flex;
|
| 1484 |
+
align-items: center;
|
| 1485 |
+
gap: 4px;
|
| 1486 |
+
background: transparent;
|
| 1487 |
+
border: 1px solid var(--border-primary);
|
| 1488 |
+
border-radius: 4px;
|
| 1489 |
+
padding: 2px 6px;
|
| 1490 |
+
font-size: 11px;
|
| 1491 |
+
color: var(--text-secondary);
|
| 1492 |
+
cursor: pointer;
|
| 1493 |
+
}
|
| 1494 |
+
|
| 1495 |
+
.ccai-human-ai-stop:hover {
|
| 1496 |
+
border-color: var(--text-secondary);
|
| 1497 |
+
color: var(--text-primary);
|
| 1498 |
+
}
|
| 1499 |
+
|
| 1500 |
+
.ccai-human-ai-turn {
|
| 1501 |
+
display: flex;
|
| 1502 |
+
flex-direction: column;
|
| 1503 |
+
gap: 2px;
|
| 1504 |
+
font-size: 12.5px;
|
| 1505 |
+
padding-bottom: 6px;
|
| 1506 |
+
border-bottom: 1px dashed var(--border-primary);
|
| 1507 |
+
}
|
| 1508 |
+
|
| 1509 |
+
.ccai-human-ai-q {
|
| 1510 |
+
color: var(--text-secondary);
|
| 1511 |
+
font-style: italic;
|
| 1512 |
+
}
|
| 1513 |
+
|
| 1514 |
+
.ccai-human-ai-a {
|
| 1515 |
+
color: var(--text-primary);
|
| 1516 |
+
}
|
| 1517 |
+
|
| 1518 |
+
.ccai-human-ai-current-q {
|
| 1519 |
+
font-size: 13px;
|
| 1520 |
+
color: var(--text-primary);
|
| 1521 |
+
background: var(--card-bg);
|
| 1522 |
+
border-left: 3px solid #16A34A;
|
| 1523 |
+
padding: 6px 10px;
|
| 1524 |
+
border-radius: 4px;
|
| 1525 |
+
}
|
| 1526 |
+
|
| 1527 |
+
.ccai-human-ai-answer {
|
| 1528 |
+
width: 100%;
|
| 1529 |
+
border: 1px solid var(--border-primary);
|
| 1530 |
+
border-radius: 6px;
|
| 1531 |
+
background: var(--card-bg);
|
| 1532 |
+
color: var(--text-primary);
|
| 1533 |
+
font-family: inherit;
|
| 1534 |
+
font-size: 13px;
|
| 1535 |
+
padding: 8px 10px;
|
| 1536 |
+
box-sizing: border-box;
|
| 1537 |
+
resize: vertical;
|
| 1538 |
+
}
|
| 1539 |
+
|
| 1540 |
+
.ccai-human-ai-answer:focus {
|
| 1541 |
+
outline: none;
|
| 1542 |
+
border-color: #16A34A;
|
| 1543 |
+
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
| 1544 |
+
}
|
| 1545 |
+
|
| 1546 |
+
.ccai-human-ai-actions {
|
| 1547 |
+
display: flex;
|
| 1548 |
+
justify-content: flex-end;
|
| 1549 |
+
}
|
| 1550 |
+
|
| 1551 |
+
.ccai-human-error {
|
| 1552 |
+
font-size: 12.5px;
|
| 1553 |
+
color: #DC2626;
|
| 1554 |
+
background: rgba(220, 38, 38, 0.08);
|
| 1555 |
+
border: 1px solid rgba(220, 38, 38, 0.3);
|
| 1556 |
+
border-radius: 6px;
|
| 1557 |
+
padding: 6px 10px;
|
| 1558 |
+
}
|
| 1559 |
+
|
| 1560 |
+
.ccai-human-modal-footer {
|
| 1561 |
+
display: flex;
|
| 1562 |
+
align-items: center;
|
| 1563 |
+
justify-content: space-between;
|
| 1564 |
+
gap: 8px;
|
| 1565 |
+
padding: 12px 20px 16px 20px;
|
| 1566 |
+
border-top: 1px solid var(--border-primary);
|
| 1567 |
+
}
|
| 1568 |
+
|
| 1569 |
+
.ccai-human-modal-footer-right {
|
| 1570 |
+
display: flex;
|
| 1571 |
+
align-items: center;
|
| 1572 |
+
gap: 8px;
|
| 1573 |
+
}
|
| 1574 |
+
|
| 1575 |
+
.ccai-human-remove {
|
| 1576 |
+
color: #DC2626;
|
| 1577 |
+
border-color: rgba(220, 38, 38, 0.4);
|
| 1578 |
+
}
|
| 1579 |
+
|
| 1580 |
+
.ccai-human-remove:hover {
|
| 1581 |
+
background: rgba(220, 38, 38, 0.06);
|
| 1582 |
+
border-color: #DC2626;
|
| 1583 |
+
}
|
| 1584 |
+
|
| 1585 |
+
/* ── Sidebar: Human participant card ──────────────────────────── */
|
| 1586 |
+
|
| 1587 |
+
.ccai-participant-card-human {
|
| 1588 |
+
border-left: 4px solid #16A34A;
|
| 1589 |
+
}
|
| 1590 |
+
|
| 1591 |
+
[data-theme="dark"] .ccai-participant-card-human {
|
| 1592 |
+
border-left-color: #4ADE80;
|
| 1593 |
+
}
|
| 1594 |
+
|
| 1595 |
+
.ccai-participant-human-tag {
|
| 1596 |
+
display: inline-block;
|
| 1597 |
+
margin-left: 8px;
|
| 1598 |
+
padding: 1px 6px;
|
| 1599 |
+
font-size: 10px;
|
| 1600 |
+
font-weight: 600;
|
| 1601 |
+
text-transform: uppercase;
|
| 1602 |
+
letter-spacing: 0.04em;
|
| 1603 |
+
color: #16A34A;
|
| 1604 |
+
background: rgba(22, 163, 74, 0.1);
|
| 1605 |
+
border-radius: 4px;
|
| 1606 |
+
}
|
| 1607 |
+
|
| 1608 |
+
[data-theme="dark"] .ccai-participant-human-tag {
|
| 1609 |
+
color: #4ADE80;
|
| 1610 |
+
background: rgba(74, 222, 128, 0.12);
|
| 1611 |
+
}
|
| 1612 |
+
|
| 1613 |
+
/* ── In-chat human input slot ─────────────────────────────────── */
|
| 1614 |
+
|
| 1615 |
+
.ccai-human-slot {
|
| 1616 |
+
display: flex;
|
| 1617 |
+
align-items: stretch;
|
| 1618 |
+
margin: 16px 0;
|
| 1619 |
+
border-radius: 10px;
|
| 1620 |
+
background: var(--card-bg);
|
| 1621 |
+
border: 2px solid #16A34A;
|
| 1622 |
+
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.12);
|
| 1623 |
+
overflow: hidden;
|
| 1624 |
+
animation: ccai-human-slot-pop 0.25s ease-out;
|
| 1625 |
+
}
|
| 1626 |
+
|
| 1627 |
+
@keyframes ccai-human-slot-pop {
|
| 1628 |
+
from { transform: scale(0.985); opacity: 0; }
|
| 1629 |
+
to { transform: scale(1); opacity: 1; }
|
| 1630 |
+
}
|
| 1631 |
+
|
| 1632 |
+
.ccai-human-slot-accent {
|
| 1633 |
+
width: 6px;
|
| 1634 |
+
background: #16A34A;
|
| 1635 |
+
flex-shrink: 0;
|
| 1636 |
+
animation: ccai-human-pulse-accent 1.6s ease-in-out infinite;
|
| 1637 |
+
}
|
| 1638 |
+
|
| 1639 |
+
@keyframes ccai-human-pulse-accent {
|
| 1640 |
+
0% { background: #16A34A; }
|
| 1641 |
+
50% { background: #4ADE80; }
|
| 1642 |
+
100% { background: #16A34A; }
|
| 1643 |
+
}
|
| 1644 |
+
|
| 1645 |
+
.ccai-human-slot-body {
|
| 1646 |
+
flex: 1;
|
| 1647 |
+
padding: 12px 14px;
|
| 1648 |
+
display: flex;
|
| 1649 |
+
flex-direction: column;
|
| 1650 |
+
gap: 8px;
|
| 1651 |
+
}
|
| 1652 |
+
|
| 1653 |
+
.ccai-human-slot-header {
|
| 1654 |
+
display: flex;
|
| 1655 |
+
align-items: center;
|
| 1656 |
+
gap: 8px;
|
| 1657 |
+
font-size: 13px;
|
| 1658 |
+
}
|
| 1659 |
+
|
| 1660 |
+
.ccai-human-slot-name {
|
| 1661 |
+
font-weight: 700;
|
| 1662 |
+
color: #16A34A;
|
| 1663 |
+
}
|
| 1664 |
+
|
| 1665 |
+
[data-theme="dark"] .ccai-human-slot-name {
|
| 1666 |
+
color: #4ADE80;
|
| 1667 |
+
}
|
| 1668 |
+
|
| 1669 |
+
.ccai-human-slot-pulse {
|
| 1670 |
+
width: 8px;
|
| 1671 |
+
height: 8px;
|
| 1672 |
+
border-radius: 50%;
|
| 1673 |
+
background: #16A34A;
|
| 1674 |
+
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.6);
|
| 1675 |
+
animation: ccai-human-pulse-dot 1.4s ease-in-out infinite;
|
| 1676 |
+
}
|
| 1677 |
+
|
| 1678 |
+
@keyframes ccai-human-pulse-dot {
|
| 1679 |
+
0% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.6); }
|
| 1680 |
+
70% { box-shadow: 0 0 0 8px rgba(22, 163, 74, 0); }
|
| 1681 |
+
100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0); }
|
| 1682 |
+
}
|
| 1683 |
+
|
| 1684 |
+
.ccai-human-slot-prompt {
|
| 1685 |
+
color: var(--text-secondary);
|
| 1686 |
+
font-style: italic;
|
| 1687 |
+
}
|
| 1688 |
+
|
| 1689 |
+
.ccai-human-slot-context {
|
| 1690 |
+
font-size: 12.5px;
|
| 1691 |
+
color: var(--text-secondary);
|
| 1692 |
+
background: rgba(22, 163, 74, 0.06);
|
| 1693 |
+
border-left: 3px solid rgba(22, 163, 74, 0.6);
|
| 1694 |
+
padding: 6px 10px;
|
| 1695 |
+
border-radius: 4px;
|
| 1696 |
+
}
|
| 1697 |
+
|
| 1698 |
+
.ccai-human-slot-textarea {
|
| 1699 |
+
width: 100%;
|
| 1700 |
+
border: 1px solid var(--border-primary);
|
| 1701 |
+
border-radius: 6px;
|
| 1702 |
+
background: var(--bg-primary);
|
| 1703 |
+
color: var(--text-primary);
|
| 1704 |
+
font-family: inherit;
|
| 1705 |
+
font-size: 14px;
|
| 1706 |
+
line-height: 1.45;
|
| 1707 |
+
padding: 10px 12px;
|
| 1708 |
+
resize: vertical;
|
| 1709 |
+
min-height: 80px;
|
| 1710 |
+
box-sizing: border-box;
|
| 1711 |
+
}
|
| 1712 |
+
|
| 1713 |
+
.ccai-human-slot-textarea:focus {
|
| 1714 |
+
outline: none;
|
| 1715 |
+
border-color: #16A34A;
|
| 1716 |
+
box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
|
| 1717 |
+
}
|
| 1718 |
+
|
| 1719 |
+
.ccai-human-slot-actions {
|
| 1720 |
+
display: flex;
|
| 1721 |
+
align-items: center;
|
| 1722 |
+
justify-content: space-between;
|
| 1723 |
+
gap: 8px;
|
| 1724 |
+
}
|
| 1725 |
+
|
| 1726 |
+
.ccai-human-slot-hint {
|
| 1727 |
+
font-size: 11.5px;
|
| 1728 |
+
color: var(--text-tertiary);
|
| 1729 |
+
}
|
| 1730 |
+
|
| 1731 |
+
.ccai-human-slot-actions-right {
|
| 1732 |
+
display: flex;
|
| 1733 |
+
gap: 8px;
|
| 1734 |
+
}
|
| 1735 |
+
|
| 1736 |
+
/* ── Bubble accent for the human's posted messages ────────────── */
|
| 1737 |
+
|
| 1738 |
+
.ccai-message-row-human .ccai-bubble {
|
| 1739 |
+
border-left: 4px solid #16A34A !important;
|
| 1740 |
+
padding-left: 12px;
|
| 1741 |
+
}
|
| 1742 |
+
|
| 1743 |
+
[data-theme="dark"] .ccai-message-row-human .ccai-bubble {
|
| 1744 |
+
border-left-color: #4ADE80 !important;
|
| 1745 |
+
}
|
| 1746 |
+
|
| 1747 |
+
/* ── Fixed-position lower-screen "waiting for your input" cue ──── */
|
| 1748 |
+
|
| 1749 |
+
.ccai-human-indicator {
|
| 1750 |
+
position: fixed;
|
| 1751 |
+
bottom: 18px;
|
| 1752 |
+
left: 50%;
|
| 1753 |
+
transform: translateX(-50%);
|
| 1754 |
+
display: inline-flex;
|
| 1755 |
+
align-items: center;
|
| 1756 |
+
gap: 8px;
|
| 1757 |
+
padding: 10px 18px;
|
| 1758 |
+
background: #16A34A;
|
| 1759 |
+
color: #ffffff;
|
| 1760 |
+
border: none;
|
| 1761 |
+
border-radius: 999px;
|
| 1762 |
+
font-size: 13px;
|
| 1763 |
+
font-weight: 600;
|
| 1764 |
+
letter-spacing: 0.01em;
|
| 1765 |
+
box-shadow:
|
| 1766 |
+
0 8px 22px rgba(22, 163, 74, 0.35),
|
| 1767 |
+
0 0 0 0 rgba(22, 163, 74, 0.5);
|
| 1768 |
+
cursor: pointer;
|
| 1769 |
+
z-index: 80;
|
| 1770 |
+
animation: ccai-human-indicator-pulse 1.8s ease-in-out infinite;
|
| 1771 |
+
}
|
| 1772 |
+
|
| 1773 |
+
[data-theme="dark"] .ccai-human-indicator {
|
| 1774 |
+
background: #22C55E;
|
| 1775 |
+
}
|
| 1776 |
+
|
| 1777 |
+
@keyframes ccai-human-indicator-pulse {
|
| 1778 |
+
0% { box-shadow: 0 8px 22px rgba(22, 163, 74, 0.35), 0 0 0 0 rgba(22, 163, 74, 0.5); }
|
| 1779 |
+
60% { box-shadow: 0 8px 22px rgba(22, 163, 74, 0.35), 0 0 0 14px rgba(22, 163, 74, 0); }
|
| 1780 |
+
100% { box-shadow: 0 8px 22px rgba(22, 163, 74, 0.35), 0 0 0 0 rgba(22, 163, 74, 0); }
|
| 1781 |
+
}
|
| 1782 |
+
|
| 1783 |
+
.ccai-human-indicator:hover {
|
| 1784 |
+
background: #15803D;
|
| 1785 |
+
}
|
| 1786 |
+
|
| 1787 |
+
[data-theme="dark"] .ccai-human-indicator:hover {
|
| 1788 |
+
background: #16A34A;
|
| 1789 |
+
}
|
| 1790 |
+
|
| 1791 |
+
.ccai-human-indicator-arrow {
|
| 1792 |
+
animation: ccai-human-indicator-arrow-bob 1.4s ease-in-out infinite;
|
| 1793 |
+
}
|
| 1794 |
+
|
| 1795 |
+
@keyframes ccai-human-indicator-arrow-bob {
|
| 1796 |
+
0%, 100% { transform: translateY(0); }
|
| 1797 |
+
50% { transform: translateY(3px); }
|
| 1798 |
+
}
|
| 1799 |
+
|
| 1800 |
+
/* ── CredentialSummaryModal: human row + inline edit ──────────── */
|
| 1801 |
+
|
| 1802 |
+
.ccai-credential-card-human {
|
| 1803 |
+
border-left: 4px solid #16A34A;
|
| 1804 |
+
}
|
| 1805 |
+
|
| 1806 |
+
[data-theme="dark"] .ccai-credential-card-human {
|
| 1807 |
+
border-left-color: #4ADE80;
|
| 1808 |
+
}
|
| 1809 |
+
|
| 1810 |
+
.ccai-credential-human-tag {
|
| 1811 |
+
display: inline-block;
|
| 1812 |
+
margin-left: 8px;
|
| 1813 |
+
padding: 1px 6px;
|
| 1814 |
+
font-size: 10px;
|
| 1815 |
+
font-weight: 600;
|
| 1816 |
+
text-transform: uppercase;
|
| 1817 |
+
letter-spacing: 0.04em;
|
| 1818 |
+
color: #16A34A;
|
| 1819 |
+
background: rgba(22, 163, 74, 0.1);
|
| 1820 |
+
border-radius: 4px;
|
| 1821 |
+
vertical-align: middle;
|
| 1822 |
+
}
|
| 1823 |
+
|
| 1824 |
+
[data-theme="dark"] .ccai-credential-human-tag {
|
| 1825 |
+
color: #4ADE80;
|
| 1826 |
+
background: rgba(74, 222, 128, 0.12);
|
| 1827 |
+
}
|
| 1828 |
+
|
| 1829 |
+
.ccai-credential-edit-btn {
|
| 1830 |
+
display: inline-flex;
|
| 1831 |
+
align-items: center;
|
| 1832 |
+
gap: 4px;
|
| 1833 |
+
margin-left: 8px;
|
| 1834 |
+
}
|
| 1835 |
+
|
| 1836 |
+
.ccai-credential-card-editing {
|
| 1837 |
+
background: rgba(22, 163, 74, 0.04);
|
| 1838 |
+
}
|
| 1839 |
+
|
| 1840 |
+
[data-theme="dark"] .ccai-credential-card-editing {
|
| 1841 |
+
background: rgba(74, 222, 128, 0.06);
|
| 1842 |
+
}
|
| 1843 |
+
|
| 1844 |
+
.ccai-credential-edit-name {
|
| 1845 |
+
padding: 4px 8px;
|
| 1846 |
+
border: 1px solid var(--border-primary);
|
| 1847 |
+
border-radius: 4px;
|
| 1848 |
+
background: var(--card-bg);
|
| 1849 |
+
color: var(--text-primary);
|
| 1850 |
+
font-size: 14px;
|
| 1851 |
+
font-weight: 600;
|
| 1852 |
+
margin-left: 0;
|
| 1853 |
+
}
|
| 1854 |
+
|
| 1855 |
+
.ccai-credential-row-edit {
|
| 1856 |
+
display: flex;
|
| 1857 |
+
flex-direction: column;
|
| 1858 |
+
gap: 4px;
|
| 1859 |
+
}
|
| 1860 |
+
|
| 1861 |
+
.ccai-credential-row-input {
|
| 1862 |
+
width: 100%;
|
| 1863 |
+
padding: 6px 8px;
|
| 1864 |
+
border: 1px solid var(--border-primary);
|
| 1865 |
+
border-radius: 4px;
|
| 1866 |
+
background: var(--card-bg);
|
| 1867 |
+
color: var(--text-primary);
|
| 1868 |
+
font-family: inherit;
|
| 1869 |
+
font-size: 13px;
|
| 1870 |
+
resize: vertical;
|
| 1871 |
+
box-sizing: border-box;
|
| 1872 |
+
}
|
| 1873 |
+
|
| 1874 |
+
.ccai-credential-row-input-num {
|
| 1875 |
+
width: 90px;
|
| 1876 |
+
}
|
| 1877 |
+
|
| 1878 |
+
.ccai-credential-edit-actions {
|
| 1879 |
+
display: flex;
|
| 1880 |
+
justify-content: flex-end;
|
| 1881 |
+
gap: 8px;
|
| 1882 |
+
margin-top: 8px;
|
| 1883 |
+
}
|
| 1884 |
+
|
frontend/src/styles/components.css
ADDED
|
@@ -0,0 +1,1455 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── Sidebar: LLM Selector ─────────────────────────────────────── */
|
| 2 |
+
|
| 3 |
+
.sidebar-title {
|
| 4 |
+
font-size: 18px;
|
| 5 |
+
font-weight: 700;
|
| 6 |
+
color: var(--text-primary);
|
| 7 |
+
text-align: center;
|
| 8 |
+
padding-bottom: 8px;
|
| 9 |
+
border-bottom: 2px solid var(--accent-primary);
|
| 10 |
+
margin: 0;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.sidebar-section {
|
| 14 |
+
display: flex;
|
| 15 |
+
flex-direction: column;
|
| 16 |
+
gap: 6px;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.selector-title {
|
| 20 |
+
display: flex;
|
| 21 |
+
align-items: center;
|
| 22 |
+
gap: 6px;
|
| 23 |
+
font-size: 15px;
|
| 24 |
+
font-weight: 700;
|
| 25 |
+
color: var(--text-primary);
|
| 26 |
+
text-decoration: underline;
|
| 27 |
+
text-underline-offset: 3px;
|
| 28 |
+
text-decoration-color: var(--border-primary);
|
| 29 |
+
margin: 0;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.selector-title-icon {
|
| 33 |
+
height: 16px;
|
| 34 |
+
width: auto;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.provider-group {
|
| 38 |
+
margin-bottom: 0;
|
| 39 |
+
border: 1px solid var(--border-primary);
|
| 40 |
+
border-radius: 8px;
|
| 41 |
+
overflow: hidden;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.provider-group.comp-group {
|
| 45 |
+
background: var(--comp-bg);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/* ── Neon model cards (matching LLMComparisons) ──────────────────── */
|
| 49 |
+
|
| 50 |
+
.neon-model-list {
|
| 51 |
+
display: flex;
|
| 52 |
+
flex-direction: column;
|
| 53 |
+
gap: 6px;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.neon-model-card {
|
| 57 |
+
border: 1px solid var(--neon-border);
|
| 58 |
+
border-radius: 8px;
|
| 59 |
+
overflow: hidden;
|
| 60 |
+
background: var(--neon-bg);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.neon-model-header {
|
| 64 |
+
display: flex;
|
| 65 |
+
align-items: center;
|
| 66 |
+
justify-content: space-between;
|
| 67 |
+
width: 100%;
|
| 68 |
+
padding: 10px 12px;
|
| 69 |
+
background: none;
|
| 70 |
+
border: none;
|
| 71 |
+
color: var(--text-primary);
|
| 72 |
+
font-size: 13px;
|
| 73 |
+
text-align: left;
|
| 74 |
+
cursor: pointer;
|
| 75 |
+
transition: background 0.15s;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.neon-model-header:hover {
|
| 79 |
+
background: var(--card-hover);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.neon-model-info {
|
| 83 |
+
display: flex;
|
| 84 |
+
align-items: baseline;
|
| 85 |
+
gap: 6px;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.neon-model-name {
|
| 89 |
+
font-weight: 600;
|
| 90 |
+
text-transform: capitalize;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.neon-model-version {
|
| 94 |
+
font-size: 11px;
|
| 95 |
+
color: var(--text-muted);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.neon-model-meta {
|
| 99 |
+
display: flex;
|
| 100 |
+
align-items: center;
|
| 101 |
+
gap: 6px;
|
| 102 |
+
color: var(--text-tertiary);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.badge.badge-neon {
|
| 106 |
+
display: inline-flex;
|
| 107 |
+
align-items: center;
|
| 108 |
+
padding: 2px 8px;
|
| 109 |
+
border-radius: 9999px;
|
| 110 |
+
font-size: 11px;
|
| 111 |
+
font-weight: 700;
|
| 112 |
+
background: var(--neon-bg);
|
| 113 |
+
color: var(--text-primary);
|
| 114 |
+
border: 1px solid var(--neon-border);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.neon-persona-list {
|
| 118 |
+
border-top: 1px solid var(--border-muted);
|
| 119 |
+
padding: 4px;
|
| 120 |
+
display: flex;
|
| 121 |
+
flex-direction: column;
|
| 122 |
+
gap: 2px;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.neon-persona-item {
|
| 126 |
+
display: flex;
|
| 127 |
+
align-items: flex-start;
|
| 128 |
+
gap: 8px;
|
| 129 |
+
width: 100%;
|
| 130 |
+
padding: 8px;
|
| 131 |
+
border-radius: 6px;
|
| 132 |
+
border: none;
|
| 133 |
+
background: none;
|
| 134 |
+
color: var(--text-primary);
|
| 135 |
+
cursor: pointer;
|
| 136 |
+
text-align: left;
|
| 137 |
+
transition: background 0.15s;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.neon-persona-item:hover {
|
| 141 |
+
background: var(--card-hover);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.persona-details {
|
| 145 |
+
flex: 1;
|
| 146 |
+
min-width: 0;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.persona-name-row {
|
| 150 |
+
display: flex;
|
| 151 |
+
align-items: center;
|
| 152 |
+
gap: 4px;
|
| 153 |
+
font-size: 13px;
|
| 154 |
+
font-weight: 500;
|
| 155 |
+
color: var(--text-primary);
|
| 156 |
+
text-transform: capitalize;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.persona-prompt-preview {
|
| 160 |
+
font-size: 11px;
|
| 161 |
+
color: var(--text-muted);
|
| 162 |
+
line-height: 1.4;
|
| 163 |
+
margin-top: 2px;
|
| 164 |
+
overflow: hidden;
|
| 165 |
+
display: -webkit-box;
|
| 166 |
+
-webkit-line-clamp: 2;
|
| 167 |
+
-webkit-box-orient: vertical;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.provider-accordion-header {
|
| 171 |
+
display: flex;
|
| 172 |
+
align-items: center;
|
| 173 |
+
justify-content: space-between;
|
| 174 |
+
width: 100%;
|
| 175 |
+
min-width: 0;
|
| 176 |
+
padding: 10px 12px;
|
| 177 |
+
border: none;
|
| 178 |
+
background: none;
|
| 179 |
+
color: var(--text-primary);
|
| 180 |
+
font-size: 13px;
|
| 181 |
+
font-weight: 600;
|
| 182 |
+
text-transform: uppercase;
|
| 183 |
+
transition: background 0.15s;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.provider-accordion-header:hover {
|
| 187 |
+
background: var(--card-hover);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.provider-accordion-title {
|
| 191 |
+
flex: 1;
|
| 192 |
+
min-width: 0;
|
| 193 |
+
text-align: left;
|
| 194 |
+
overflow: hidden;
|
| 195 |
+
text-overflow: ellipsis;
|
| 196 |
+
white-space: nowrap;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.provider-accordion-meta {
|
| 200 |
+
display: flex;
|
| 201 |
+
align-items: center;
|
| 202 |
+
gap: 6px;
|
| 203 |
+
color: var(--text-tertiary);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.provider-model-count {
|
| 207 |
+
font-size: 11px;
|
| 208 |
+
font-weight: 600;
|
| 209 |
+
color: var(--accent-primary);
|
| 210 |
+
background: var(--accent-light);
|
| 211 |
+
padding: 1px 6px;
|
| 212 |
+
border-radius: 10px;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.provider-group .model-list {
|
| 216 |
+
padding: 4px;
|
| 217 |
+
border-top: 1px solid var(--border-muted);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.model-list {
|
| 221 |
+
display: flex;
|
| 222 |
+
flex-direction: column;
|
| 223 |
+
gap: 4px;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.model-btn {
|
| 227 |
+
display: flex;
|
| 228 |
+
align-items: center;
|
| 229 |
+
gap: 8px;
|
| 230 |
+
width: 100%;
|
| 231 |
+
min-width: 0;
|
| 232 |
+
padding: 6px 10px;
|
| 233 |
+
border: 1px solid transparent;
|
| 234 |
+
border-radius: 6px;
|
| 235 |
+
background: none;
|
| 236 |
+
color: var(--text-primary);
|
| 237 |
+
font-size: 13px;
|
| 238 |
+
transition: all 0.15s;
|
| 239 |
+
text-align: left;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.model-btn:hover {
|
| 243 |
+
background: var(--card-hover);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.model-btn .model-name {
|
| 247 |
+
flex: 1;
|
| 248 |
+
min-width: 0;
|
| 249 |
+
font-weight: 500;
|
| 250 |
+
overflow: hidden;
|
| 251 |
+
text-overflow: ellipsis;
|
| 252 |
+
white-space: nowrap;
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
.model-btn .model-params {
|
| 256 |
+
font-size: 10px;
|
| 257 |
+
color: var(--text-muted);
|
| 258 |
+
white-space: nowrap;
|
| 259 |
+
flex-shrink: 0;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
/* Selection indicator: circle */
|
| 263 |
+
.select-indicator {
|
| 264 |
+
width: 16px;
|
| 265 |
+
height: 16px;
|
| 266 |
+
border-radius: 50%;
|
| 267 |
+
border: 2px solid var(--text-secondary);
|
| 268 |
+
background: #fff;
|
| 269 |
+
display: flex;
|
| 270 |
+
align-items: center;
|
| 271 |
+
justify-content: center;
|
| 272 |
+
flex-shrink: 0;
|
| 273 |
+
transition: all 0.15s;
|
| 274 |
+
position: relative;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.select-indicator.selected-a {
|
| 278 |
+
border-color: var(--speaker-a-color);
|
| 279 |
+
background: var(--speaker-a-color);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
.select-indicator.selected-b {
|
| 283 |
+
border-color: var(--speaker-b-color);
|
| 284 |
+
background: var(--speaker-b-color);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/* Double-select ring for "same LLM both sides" */
|
| 288 |
+
.select-indicator.double-selected {
|
| 289 |
+
box-shadow: 0 0 0 3px var(--card-bg), 0 0 0 5px var(--speaker-a-color);
|
| 290 |
+
border-color: var(--speaker-b-color);
|
| 291 |
+
background: linear-gradient(135deg, var(--speaker-a-color), var(--speaker-b-color));
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
.selection-label {
|
| 295 |
+
font-size: 10px;
|
| 296 |
+
font-weight: 700;
|
| 297 |
+
color: #fff;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
/* ── Accordion ─────────────────────────────────────────────────── */
|
| 302 |
+
|
| 303 |
+
.accordion {
|
| 304 |
+
border: 1px solid var(--border-primary);
|
| 305 |
+
border-radius: 12px;
|
| 306 |
+
background: var(--card-bg);
|
| 307 |
+
box-shadow: var(--shadow-sm);
|
| 308 |
+
overflow: hidden;
|
| 309 |
+
margin-bottom: 16px;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
.accordion-header {
|
| 313 |
+
display: flex;
|
| 314 |
+
align-items: center;
|
| 315 |
+
justify-content: space-between;
|
| 316 |
+
padding: 14px 20px;
|
| 317 |
+
background: var(--bg-secondary);
|
| 318 |
+
border: none;
|
| 319 |
+
width: 100%;
|
| 320 |
+
font-size: 15px;
|
| 321 |
+
font-weight: 600;
|
| 322 |
+
color: var(--text-primary);
|
| 323 |
+
transition: background 0.15s;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
.accordion-header:hover {
|
| 327 |
+
background: var(--bg-tertiary);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.accordion-body {
|
| 331 |
+
max-height: 0;
|
| 332 |
+
overflow: hidden;
|
| 333 |
+
transition: max-height 0.35s ease, padding 0.35s ease;
|
| 334 |
+
padding: 0 20px;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.accordion-body.open {
|
| 338 |
+
max-height: calc(100vh - 260px);
|
| 339 |
+
overflow-y: auto;
|
| 340 |
+
padding: 16px 20px 20px;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.persona-panels {
|
| 344 |
+
display: grid;
|
| 345 |
+
grid-template-columns: 1fr 1fr;
|
| 346 |
+
gap: 20px;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.persona-panel {
|
| 350 |
+
display: flex;
|
| 351 |
+
flex-direction: column;
|
| 352 |
+
gap: 12px;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
.persona-panel-header {
|
| 356 |
+
display: flex;
|
| 357 |
+
align-items: center;
|
| 358 |
+
gap: 8px;
|
| 359 |
+
font-size: 14px;
|
| 360 |
+
font-weight: 600;
|
| 361 |
+
color: var(--text-primary);
|
| 362 |
+
padding-bottom: 8px;
|
| 363 |
+
border-bottom: 2px solid var(--border-primary);
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.persona-panel:first-child .persona-panel-header {
|
| 367 |
+
border-color: var(--speaker-a-color);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.persona-panel:last-child .persona-panel-header {
|
| 371 |
+
border-color: var(--speaker-b-color);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.persona-field label {
|
| 375 |
+
display: block;
|
| 376 |
+
font-size: 12px;
|
| 377 |
+
font-weight: 600;
|
| 378 |
+
color: var(--text-tertiary);
|
| 379 |
+
margin-bottom: 4px;
|
| 380 |
+
text-transform: uppercase;
|
| 381 |
+
letter-spacing: 0.04em;
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
.persona-field input,
|
| 385 |
+
.persona-field textarea {
|
| 386 |
+
width: 100%;
|
| 387 |
+
padding: 10px 12px;
|
| 388 |
+
border: 1px solid var(--border-primary);
|
| 389 |
+
border-radius: 8px;
|
| 390 |
+
background: var(--bg-primary);
|
| 391 |
+
color: var(--text-primary);
|
| 392 |
+
font-size: 13px;
|
| 393 |
+
transition: border-color 0.15s;
|
| 394 |
+
resize: vertical;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.persona-field input:focus,
|
| 398 |
+
.persona-field textarea:focus {
|
| 399 |
+
outline: none;
|
| 400 |
+
border-color: var(--accent-primary);
|
| 401 |
+
box-shadow: 0 0 0 3px var(--accent-light);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.persona-field textarea {
|
| 405 |
+
min-height: 80px;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.persona-field textarea.tall {
|
| 409 |
+
min-height: 120px;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.persona-field input::placeholder,
|
| 413 |
+
.persona-field textarea::placeholder {
|
| 414 |
+
color: var(--text-muted);
|
| 415 |
+
font-style: italic;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
.freeform-label-row {
|
| 419 |
+
display: flex;
|
| 420 |
+
align-items: center;
|
| 421 |
+
gap: 10px;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.freeform-label-row label {
|
| 425 |
+
flex: 1;
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
.upload-btn {
|
| 429 |
+
flex-shrink: 0;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.freeform-textarea {
|
| 433 |
+
min-height: 260px;
|
| 434 |
+
resize: vertical;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
/* responsive rules consolidated below */
|
| 438 |
+
|
| 439 |
+
/* ── Chat Area ─────────────────────────────────────────────────── */
|
| 440 |
+
|
| 441 |
+
.chat-area {
|
| 442 |
+
flex: 1;
|
| 443 |
+
display: flex;
|
| 444 |
+
flex-direction: column;
|
| 445 |
+
gap: 16px;
|
| 446 |
+
padding: 16px 0;
|
| 447 |
+
overflow-y: auto;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
.chat-empty {
|
| 451 |
+
flex: 1;
|
| 452 |
+
display: flex;
|
| 453 |
+
align-items: center;
|
| 454 |
+
justify-content: center;
|
| 455 |
+
color: var(--text-muted);
|
| 456 |
+
font-size: 15px;
|
| 457 |
+
font-style: italic;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
/* ── Message Bubbles ───────────────────────────────────────────── */
|
| 461 |
+
|
| 462 |
+
.message-row {
|
| 463 |
+
display: flex;
|
| 464 |
+
align-items: flex-start;
|
| 465 |
+
gap: 12px;
|
| 466 |
+
margin-bottom: 8px;
|
| 467 |
+
animation: fadeSlideIn 0.3s ease;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.message-row.speaker-a {
|
| 471 |
+
flex-direction: row;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
.message-row.speaker-b {
|
| 475 |
+
flex-direction: row-reverse;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
@keyframes fadeSlideIn {
|
| 479 |
+
from { opacity: 0; transform: translateY(8px); }
|
| 480 |
+
to { opacity: 1; transform: translateY(0); }
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
/* Geometric shape avatars */
|
| 484 |
+
.avatar {
|
| 485 |
+
width: 40px;
|
| 486 |
+
height: 40px;
|
| 487 |
+
flex-shrink: 0;
|
| 488 |
+
display: flex;
|
| 489 |
+
align-items: center;
|
| 490 |
+
justify-content: center;
|
| 491 |
+
font-weight: 700;
|
| 492 |
+
font-size: 14px;
|
| 493 |
+
color: #fff;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.avatar-a {
|
| 497 |
+
background: var(--speaker-a-color);
|
| 498 |
+
border-radius: 50%;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.avatar-b {
|
| 502 |
+
background: var(--speaker-b-color);
|
| 503 |
+
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.message-bubble {
|
| 507 |
+
max-width: min(520px, 85%);
|
| 508 |
+
padding: 12px 16px;
|
| 509 |
+
border-radius: 16px;
|
| 510 |
+
font-size: 14px;
|
| 511 |
+
line-height: 1.6;
|
| 512 |
+
box-shadow: var(--shadow-sm);
|
| 513 |
+
overflow-wrap: break-word;
|
| 514 |
+
word-break: break-word;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.message-bubble.bubble-a {
|
| 518 |
+
background: var(--speaker-a-bg);
|
| 519 |
+
color: var(--text-primary);
|
| 520 |
+
border: 1px solid color-mix(in srgb, var(--speaker-a-color) 20%, transparent);
|
| 521 |
+
border-bottom-left-radius: 4px;
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
.message-bubble.bubble-b {
|
| 525 |
+
background: var(--speaker-b-bg);
|
| 526 |
+
color: var(--text-primary);
|
| 527 |
+
border: 1px solid color-mix(in srgb, var(--speaker-b-color) 20%, transparent);
|
| 528 |
+
border-bottom-right-radius: 4px;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.message-speaker {
|
| 532 |
+
font-size: 11px;
|
| 533 |
+
font-weight: 600;
|
| 534 |
+
margin-bottom: 4px;
|
| 535 |
+
text-transform: uppercase;
|
| 536 |
+
letter-spacing: 0.04em;
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
.speaker-a .message-speaker {
|
| 540 |
+
color: var(--speaker-a-color);
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.speaker-b .message-speaker {
|
| 544 |
+
color: var(--speaker-b-color);
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.message-bubble p {
|
| 548 |
+
margin: 0 0 8px;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
.message-bubble p:last-child {
|
| 552 |
+
margin-bottom: 0;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
.message-elapsed {
|
| 556 |
+
margin-top: 6px;
|
| 557 |
+
font-size: 11px;
|
| 558 |
+
color: var(--text-muted);
|
| 559 |
+
text-align: right;
|
| 560 |
+
opacity: 0.7;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
/* System messages */
|
| 564 |
+
.system-message {
|
| 565 |
+
text-align: center;
|
| 566 |
+
padding: 12px;
|
| 567 |
+
color: var(--text-muted);
|
| 568 |
+
font-style: italic;
|
| 569 |
+
font-size: 14px;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.system-message.end-of-chat {
|
| 573 |
+
font-weight: 700;
|
| 574 |
+
font-style: normal;
|
| 575 |
+
color: var(--text-secondary);
|
| 576 |
+
font-size: 16px;
|
| 577 |
+
padding: 20px;
|
| 578 |
+
border-top: 1px solid var(--border-primary);
|
| 579 |
+
margin-top: 8px;
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
.chat-stats {
|
| 583 |
+
text-align: center;
|
| 584 |
+
padding: 8px 12px;
|
| 585 |
+
font-size: 13px;
|
| 586 |
+
color: var(--text-muted);
|
| 587 |
+
background: var(--bg-secondary);
|
| 588 |
+
border-radius: 8px;
|
| 589 |
+
margin: 4px auto 8px;
|
| 590 |
+
max-width: 400px;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
/* Download strip rendered below the "End of Chat" marker. Mirrors the
|
| 594 |
+
items in the header DownloadMenu so the user doesn't have to scroll
|
| 595 |
+
back up to the top of a finished conversation to export it. */
|
| 596 |
+
.chat-end-downloads {
|
| 597 |
+
display: flex;
|
| 598 |
+
flex-wrap: wrap;
|
| 599 |
+
justify-content: center;
|
| 600 |
+
gap: 8px;
|
| 601 |
+
padding: 12px 0 4px;
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
.chat-end-download-btn {
|
| 605 |
+
white-space: nowrap;
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
/* ── Chat Controls ─────────────────────────────────────────────── */
|
| 609 |
+
|
| 610 |
+
.chat-controls {
|
| 611 |
+
display: flex;
|
| 612 |
+
gap: 12px;
|
| 613 |
+
align-items: stretch;
|
| 614 |
+
padding: 12px 0;
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
.chat-controls input {
|
| 618 |
+
flex: 1;
|
| 619 |
+
padding: 10px 14px;
|
| 620 |
+
border: 1px solid var(--border-primary);
|
| 621 |
+
border-radius: 8px;
|
| 622 |
+
background: var(--bg-primary);
|
| 623 |
+
color: var(--text-primary);
|
| 624 |
+
font-size: 14px;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
.chat-controls input:focus {
|
| 628 |
+
outline: none;
|
| 629 |
+
border-color: var(--accent-primary);
|
| 630 |
+
box-shadow: 0 0 0 3px var(--accent-light);
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
.btn-primary {
|
| 634 |
+
padding: 10px 20px;
|
| 635 |
+
border: none;
|
| 636 |
+
border-radius: 8px;
|
| 637 |
+
background: var(--accent-gradient);
|
| 638 |
+
color: #fff;
|
| 639 |
+
font-size: 14px;
|
| 640 |
+
font-weight: 600;
|
| 641 |
+
white-space: nowrap;
|
| 642 |
+
transition: opacity 0.15s, transform 0.1s;
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
.btn-primary:hover {
|
| 646 |
+
opacity: 0.9;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.btn-primary:active {
|
| 650 |
+
transform: scale(0.97);
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
.btn-primary:disabled {
|
| 654 |
+
opacity: 0.5;
|
| 655 |
+
cursor: not-allowed;
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
.btn-secondary {
|
| 659 |
+
padding: 10px 20px;
|
| 660 |
+
border: 1px solid var(--border-primary);
|
| 661 |
+
border-radius: 8px;
|
| 662 |
+
background: var(--bg-primary);
|
| 663 |
+
color: var(--text-secondary);
|
| 664 |
+
font-size: 14px;
|
| 665 |
+
font-weight: 500;
|
| 666 |
+
white-space: nowrap;
|
| 667 |
+
transition: all 0.15s;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.btn-secondary:hover {
|
| 671 |
+
background: var(--bg-tertiary);
|
| 672 |
+
border-color: var(--accent-primary);
|
| 673 |
+
color: var(--text-primary);
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
.btn-secondary:disabled {
|
| 677 |
+
opacity: 0.5;
|
| 678 |
+
cursor: not-allowed;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
.btn-stop {
|
| 682 |
+
padding: 10px 20px;
|
| 683 |
+
border: none;
|
| 684 |
+
border-radius: 8px;
|
| 685 |
+
background: #DC2626;
|
| 686 |
+
color: #fff;
|
| 687 |
+
font-size: 14px;
|
| 688 |
+
font-weight: 600;
|
| 689 |
+
white-space: nowrap;
|
| 690 |
+
transition: opacity 0.15s, transform 0.1s;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
.btn-stop:hover {
|
| 694 |
+
opacity: 0.9;
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
.btn-stop:active {
|
| 698 |
+
transform: scale(0.97);
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
/* ── Header Dev Menu ───────────────────────────────────────────── */
|
| 702 |
+
|
| 703 |
+
.dev-wrap {
|
| 704 |
+
display: flex;
|
| 705 |
+
align-items: center;
|
| 706 |
+
gap: 10px;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
.dev-download-btns {
|
| 710 |
+
display: flex;
|
| 711 |
+
gap: 6px;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
.btn-sm {
|
| 715 |
+
display: inline-flex;
|
| 716 |
+
align-items: center;
|
| 717 |
+
gap: 5px;
|
| 718 |
+
padding: 6px 10px;
|
| 719 |
+
border-radius: 8px;
|
| 720 |
+
font-size: 12px;
|
| 721 |
+
font-weight: 500;
|
| 722 |
+
cursor: pointer;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
.btn-outline {
|
| 726 |
+
border: 1px solid var(--border-primary);
|
| 727 |
+
background: var(--card-bg);
|
| 728 |
+
color: var(--text-secondary);
|
| 729 |
+
transition: border-color 0.15s;
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
.btn-outline:hover:not(:disabled) {
|
| 733 |
+
border-color: var(--accent-primary);
|
| 734 |
+
color: var(--text-primary);
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
.btn-outline:disabled {
|
| 738 |
+
opacity: 0.4;
|
| 739 |
+
cursor: not-allowed;
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
.btn-ghost {
|
| 743 |
+
border: 1px solid transparent;
|
| 744 |
+
background: transparent;
|
| 745 |
+
color: var(--text-secondary);
|
| 746 |
+
transition: background 0.15s;
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
.btn-ghost:hover {
|
| 750 |
+
background: var(--bg-tertiary);
|
| 751 |
+
color: var(--text-primary);
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
|
| 755 |
+
.dev-dropdown-header {
|
| 756 |
+
position: relative;
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
.dev-panel {
|
| 760 |
+
position: absolute;
|
| 761 |
+
right: 0;
|
| 762 |
+
top: 100%;
|
| 763 |
+
margin-top: 4px;
|
| 764 |
+
min-width: 220px;
|
| 765 |
+
/* Cap the panel at a bit less than the viewport so it never extends
|
| 766 |
+
past the bottom of the window; long content scrolls within. The
|
| 767 |
+
16px subtraction leaves a comfortable gap for shadow + breathing
|
| 768 |
+
room beneath the header. */
|
| 769 |
+
max-height: calc(100vh - 80px);
|
| 770 |
+
overflow-y: auto;
|
| 771 |
+
overscroll-behavior: contain;
|
| 772 |
+
background: var(--card-bg);
|
| 773 |
+
border: 1px solid var(--border-primary);
|
| 774 |
+
border-radius: 10px;
|
| 775 |
+
padding: 6px;
|
| 776 |
+
z-index: 50;
|
| 777 |
+
box-shadow: var(--shadow-md);
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
.dev-panel button {
|
| 781 |
+
display: block;
|
| 782 |
+
width: 100%;
|
| 783 |
+
text-align: left;
|
| 784 |
+
padding: 8px 10px;
|
| 785 |
+
border: none;
|
| 786 |
+
background: transparent;
|
| 787 |
+
/* Higher-contrast text: was --text-secondary (#4B5563) which read as
|
| 788 |
+
low-contrast against the white panel. --text-primary (#111827) is
|
| 789 |
+
near-black and matches the contrast of the rest of the chat UI. */
|
| 790 |
+
color: var(--text-primary);
|
| 791 |
+
border-radius: 6px;
|
| 792 |
+
cursor: pointer;
|
| 793 |
+
font-size: 13px;
|
| 794 |
+
transition: background 0.1s;
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
.dev-panel button:hover:not(:disabled) {
|
| 798 |
+
background: var(--bg-tertiary);
|
| 799 |
+
color: var(--text-primary);
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
.dev-panel button:disabled {
|
| 803 |
+
/* Slightly less aggressive than 0.4 because the base text is now darker;
|
| 804 |
+
0.55 keeps disabled items legible-but-distinguishable. */
|
| 805 |
+
opacity: 0.55;
|
| 806 |
+
cursor: not-allowed;
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
.dev-panel-hint {
|
| 810 |
+
font-size: 10px;
|
| 811 |
+
/* Bumped from --text-muted (#9CA3AF) to --text-secondary (#4B5563)
|
| 812 |
+
so inline metadata is actually readable. */
|
| 813 |
+
color: var(--text-secondary);
|
| 814 |
+
margin-left: 6px;
|
| 815 |
+
font-style: italic;
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
.dev-panel-divider {
|
| 819 |
+
height: 1px;
|
| 820 |
+
background: var(--border-muted);
|
| 821 |
+
margin: 4px 0;
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
.dev-panel-label {
|
| 825 |
+
font-size: 10px;
|
| 826 |
+
font-weight: 600;
|
| 827 |
+
text-transform: uppercase;
|
| 828 |
+
letter-spacing: 0.05em;
|
| 829 |
+
/* Bumped from --text-muted to --text-secondary for the same reason. */
|
| 830 |
+
color: var(--text-secondary);
|
| 831 |
+
padding: 4px 10px 2px;
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
/* Clickable section header used for multi-item accordion categories
|
| 835 |
+
inside the settings panel. Matches the .dev-panel-label visual style
|
| 836 |
+
(same uppercase / size / color) so collapsed sections read as plain
|
| 837 |
+
labels at rest; the chevron makes the click affordance discoverable
|
| 838 |
+
and the hover background tells the user it's interactive. */
|
| 839 |
+
.dev-panel-section-header {
|
| 840 |
+
display: flex !important;
|
| 841 |
+
align-items: center;
|
| 842 |
+
justify-content: space-between;
|
| 843 |
+
width: 100%;
|
| 844 |
+
padding: 4px 10px 2px !important;
|
| 845 |
+
border: none;
|
| 846 |
+
background: transparent;
|
| 847 |
+
font-size: 10px !important;
|
| 848 |
+
font-weight: 600 !important;
|
| 849 |
+
text-transform: uppercase;
|
| 850 |
+
letter-spacing: 0.05em;
|
| 851 |
+
color: var(--text-secondary) !important;
|
| 852 |
+
text-align: left;
|
| 853 |
+
cursor: pointer;
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
.dev-panel-section-header:hover {
|
| 857 |
+
background: var(--bg-tertiary);
|
| 858 |
+
color: var(--text-primary) !important;
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
+
.dev-panel-section-chevron {
|
| 862 |
+
flex-shrink: 0;
|
| 863 |
+
opacity: 0.6;
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
/* Indented row used for every actionable item inside a category, so
|
| 867 |
+
labels (left-aligned at 10px) read as clear group headers and the
|
| 868 |
+
items below them sit at a 22px hang. Matches the look of the
|
| 869 |
+
original .dev-panel-choice rows. */
|
| 870 |
+
.dev-panel-row {
|
| 871 |
+
padding-left: 22px !important;
|
| 872 |
+
display: flex !important;
|
| 873 |
+
align-items: center;
|
| 874 |
+
gap: 8px;
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
/* Two-line variant: name on top, subtitle (model name) on bottom,
|
| 878 |
+
chevron right-aligned. The middle text column is flex:1, min-width:0
|
| 879 |
+
so the subtitle can ellipsis-truncate when it overflows. */
|
| 880 |
+
.dev-panel-row-stack {
|
| 881 |
+
align-items: stretch;
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
.dev-panel-row-text {
|
| 885 |
+
flex: 1;
|
| 886 |
+
min-width: 0;
|
| 887 |
+
display: flex;
|
| 888 |
+
flex-direction: column;
|
| 889 |
+
align-items: flex-start;
|
| 890 |
+
gap: 2px;
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
.dev-panel-row-name {
|
| 894 |
+
font-size: 13px;
|
| 895 |
+
color: var(--text-primary);
|
| 896 |
+
line-height: 1.25;
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
.dev-panel-row-sub {
|
| 900 |
+
font-size: 11px;
|
| 901 |
+
color: var(--text-tertiary);
|
| 902 |
+
font-style: italic;
|
| 903 |
+
white-space: nowrap;
|
| 904 |
+
overflow: hidden;
|
| 905 |
+
text-overflow: ellipsis;
|
| 906 |
+
max-width: 100%;
|
| 907 |
+
line-height: 1.2;
|
| 908 |
+
}
|
| 909 |
+
|
| 910 |
+
.dev-panel-choice {
|
| 911 |
+
padding-left: 22px !important;
|
| 912 |
+
display: flex !important;
|
| 913 |
+
align-items: center;
|
| 914 |
+
gap: 8px;
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
.dev-panel-choice-active {
|
| 918 |
+
color: var(--text-primary) !important;
|
| 919 |
+
opacity: 1 !important;
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
.dev-check-icon {
|
| 923 |
+
flex-shrink: 0;
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
.dev-panel-choice-active .dev-check-icon {
|
| 927 |
+
color: var(--neon-accent, #22c55e);
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
.dev-panel-download-item {
|
| 931 |
+
display: none !important;
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
|
| 935 |
+
/* ── Orchestrator sub-menu ─────────────────────────────────────── */
|
| 936 |
+
|
| 937 |
+
.dev-sub-panel {
|
| 938 |
+
position: absolute;
|
| 939 |
+
right: 100%;
|
| 940 |
+
top: 0;
|
| 941 |
+
margin-right: 4px;
|
| 942 |
+
width: 280px;
|
| 943 |
+
max-height: 70vh;
|
| 944 |
+
display: flex;
|
| 945 |
+
flex-direction: column;
|
| 946 |
+
background: var(--card-bg);
|
| 947 |
+
border: 1px solid var(--border-primary);
|
| 948 |
+
border-radius: 10px;
|
| 949 |
+
padding: 8px;
|
| 950 |
+
z-index: 51;
|
| 951 |
+
box-shadow: var(--shadow-md);
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
.dev-sub-header {
|
| 955 |
+
display: flex;
|
| 956 |
+
flex-direction: column;
|
| 957 |
+
gap: 2px;
|
| 958 |
+
padding: 4px 6px 8px;
|
| 959 |
+
border-bottom: 1px solid var(--border-primary);
|
| 960 |
+
margin-bottom: 6px;
|
| 961 |
+
}
|
| 962 |
+
|
| 963 |
+
.dev-sub-title {
|
| 964 |
+
font-size: 12px;
|
| 965 |
+
font-weight: 600;
|
| 966 |
+
color: var(--text-primary);
|
| 967 |
+
text-transform: uppercase;
|
| 968 |
+
letter-spacing: 0.5px;
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
.dev-sub-current {
|
| 972 |
+
font-size: 11px;
|
| 973 |
+
color: var(--text-muted);
|
| 974 |
+
font-style: italic;
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
.dev-sub-search {
|
| 978 |
+
position: relative;
|
| 979 |
+
margin-bottom: 6px;
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
.dev-sub-search-icon {
|
| 983 |
+
position: absolute;
|
| 984 |
+
left: 8px;
|
| 985 |
+
top: 50%;
|
| 986 |
+
transform: translateY(-50%);
|
| 987 |
+
color: var(--text-muted);
|
| 988 |
+
opacity: 0.5;
|
| 989 |
+
pointer-events: none;
|
| 990 |
+
}
|
| 991 |
+
|
| 992 |
+
.dev-sub-search input {
|
| 993 |
+
width: 100%;
|
| 994 |
+
box-sizing: border-box;
|
| 995 |
+
padding: 7px 8px 7px 30px;
|
| 996 |
+
border: 1px solid var(--border-primary);
|
| 997 |
+
border-radius: 8px;
|
| 998 |
+
background: var(--bg-primary);
|
| 999 |
+
color: var(--text-primary);
|
| 1000 |
+
font-size: 12px;
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
+
.dev-sub-search input:focus {
|
| 1004 |
+
outline: none;
|
| 1005 |
+
border-color: var(--accent-primary);
|
| 1006 |
+
}
|
| 1007 |
+
|
| 1008 |
+
.dev-sub-search input::placeholder {
|
| 1009 |
+
color: var(--text-muted);
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
.dev-sub-list {
|
| 1013 |
+
list-style: none;
|
| 1014 |
+
margin: 0;
|
| 1015 |
+
padding: 0;
|
| 1016 |
+
overflow-y: auto;
|
| 1017 |
+
flex: 1;
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
.dev-sub-list li {
|
| 1021 |
+
margin-bottom: 2px;
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
.dev-sub-item {
|
| 1025 |
+
width: 100%;
|
| 1026 |
+
text-align: left;
|
| 1027 |
+
padding: 6px 8px;
|
| 1028 |
+
border: 1px solid transparent;
|
| 1029 |
+
border-radius: 6px;
|
| 1030 |
+
background: transparent;
|
| 1031 |
+
color: var(--text-primary);
|
| 1032 |
+
cursor: pointer;
|
| 1033 |
+
display: flex;
|
| 1034 |
+
flex-direction: column;
|
| 1035 |
+
gap: 1px;
|
| 1036 |
+
font-size: 12px;
|
| 1037 |
+
transition: background 0.1s;
|
| 1038 |
+
}
|
| 1039 |
+
|
| 1040 |
+
.dev-sub-item:hover {
|
| 1041 |
+
background: var(--bg-tertiary);
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
+
.dev-sub-item-active {
|
| 1045 |
+
border-color: var(--accent-primary);
|
| 1046 |
+
background: var(--accent-light);
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
.dev-sub-item-active:hover {
|
| 1050 |
+
background: var(--accent-light);
|
| 1051 |
+
}
|
| 1052 |
+
|
| 1053 |
+
.dev-sub-item strong {
|
| 1054 |
+
font-weight: 500;
|
| 1055 |
+
font-size: 12px;
|
| 1056 |
+
}
|
| 1057 |
+
|
| 1058 |
+
.dev-sub-provider {
|
| 1059 |
+
font-size: 10px;
|
| 1060 |
+
color: var(--text-muted);
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
/* responsive rules consolidated below */
|
| 1064 |
+
|
| 1065 |
+
/* ── Status / Loading ──────────────────────────────────────────── */
|
| 1066 |
+
|
| 1067 |
+
.status-bar {
|
| 1068 |
+
display: flex;
|
| 1069 |
+
align-items: center;
|
| 1070 |
+
gap: 8px;
|
| 1071 |
+
padding: 8px 0;
|
| 1072 |
+
color: var(--text-muted);
|
| 1073 |
+
font-size: 13px;
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
+
.spinner {
|
| 1077 |
+
width: 16px;
|
| 1078 |
+
height: 16px;
|
| 1079 |
+
border: 2px solid var(--border-primary);
|
| 1080 |
+
border-top-color: var(--accent-primary);
|
| 1081 |
+
border-radius: 50%;
|
| 1082 |
+
animation: spin 0.8s linear infinite;
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
@keyframes spin {
|
| 1086 |
+
to { transform: rotate(360deg); }
|
| 1087 |
+
}
|
| 1088 |
+
|
| 1089 |
+
/* ── Auth Badge ───────────────────────────────────────────────── */
|
| 1090 |
+
|
| 1091 |
+
.auth-badge {
|
| 1092 |
+
display: flex;
|
| 1093 |
+
align-items: center;
|
| 1094 |
+
gap: 6px;
|
| 1095 |
+
font-size: 12px;
|
| 1096 |
+
color: var(--text-muted);
|
| 1097 |
+
padding: 4px 8px;
|
| 1098 |
+
border-radius: 8px;
|
| 1099 |
+
background: var(--bg-secondary);
|
| 1100 |
+
border: 1px solid var(--border-primary);
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
.auth-username {
|
| 1104 |
+
font-weight: 500;
|
| 1105 |
+
color: var(--text-secondary);
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
.auth-org-tag {
|
| 1109 |
+
font-size: 10px;
|
| 1110 |
+
font-weight: 600;
|
| 1111 |
+
text-transform: uppercase;
|
| 1112 |
+
padding: 1px 5px;
|
| 1113 |
+
border-radius: 4px;
|
| 1114 |
+
background: var(--accent-primary);
|
| 1115 |
+
color: #fff;
|
| 1116 |
+
letter-spacing: 0.5px;
|
| 1117 |
+
}
|
| 1118 |
+
|
| 1119 |
+
.auth-remaining {
|
| 1120 |
+
font-size: 11px;
|
| 1121 |
+
color: var(--text-muted);
|
| 1122 |
+
white-space: nowrap;
|
| 1123 |
+
}
|
| 1124 |
+
|
| 1125 |
+
.auth-link {
|
| 1126 |
+
display: inline-flex;
|
| 1127 |
+
align-items: center;
|
| 1128 |
+
gap: 4px;
|
| 1129 |
+
color: var(--text-muted);
|
| 1130 |
+
text-decoration: none;
|
| 1131 |
+
transition: color 0.15s;
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
.auth-link:hover {
|
| 1135 |
+
color: var(--text-primary);
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
.auth-login {
|
| 1139 |
+
color: var(--accent-primary);
|
| 1140 |
+
font-weight: 500;
|
| 1141 |
+
}
|
| 1142 |
+
|
| 1143 |
+
.auth-login:hover {
|
| 1144 |
+
color: var(--text-primary);
|
| 1145 |
+
}
|
| 1146 |
+
|
| 1147 |
+
.auth-badge-end {
|
| 1148 |
+
margin-left: 4px;
|
| 1149 |
+
}
|
| 1150 |
+
|
| 1151 |
+
/* ── Footer ─────────────────────────────────────────────────────── */
|
| 1152 |
+
|
| 1153 |
+
.app-footer {
|
| 1154 |
+
padding: 8px 24px;
|
| 1155 |
+
text-align: center;
|
| 1156 |
+
font-size: 10px;
|
| 1157 |
+
color: var(--text-muted);
|
| 1158 |
+
border-top: 1px solid var(--border-primary);
|
| 1159 |
+
background: var(--bg-secondary);
|
| 1160 |
+
flex-shrink: 0;
|
| 1161 |
+
}
|
| 1162 |
+
|
| 1163 |
+
.app-footer a {
|
| 1164 |
+
color: var(--text-muted);
|
| 1165 |
+
text-decoration: underline;
|
| 1166 |
+
transition: color 0.15s;
|
| 1167 |
+
}
|
| 1168 |
+
|
| 1169 |
+
.app-footer a:hover {
|
| 1170 |
+
color: var(--text-secondary);
|
| 1171 |
+
}
|
| 1172 |
+
|
| 1173 |
+
/* ── Scrollbar ──────────────────────────────────────────────────── */
|
| 1174 |
+
|
| 1175 |
+
::-webkit-scrollbar {
|
| 1176 |
+
width: 6px;
|
| 1177 |
+
height: 6px;
|
| 1178 |
+
}
|
| 1179 |
+
|
| 1180 |
+
::-webkit-scrollbar-track {
|
| 1181 |
+
background: transparent;
|
| 1182 |
+
}
|
| 1183 |
+
|
| 1184 |
+
::-webkit-scrollbar-thumb {
|
| 1185 |
+
background: var(--text-muted);
|
| 1186 |
+
border-radius: 3px;
|
| 1187 |
+
}
|
| 1188 |
+
|
| 1189 |
+
::-webkit-scrollbar-thumb:hover {
|
| 1190 |
+
background: var(--text-tertiary);
|
| 1191 |
+
}
|
| 1192 |
+
|
| 1193 |
+
/* ══════════════════════════════════════════════════════════════════
|
| 1194 |
+
RESPONSIVE BREAKPOINTS
|
| 1195 |
+
══════════════════════════════════════════════════════════════════ */
|
| 1196 |
+
|
| 1197 |
+
/* ── Tablet (<900px) ──────────────────────────────────────────── */
|
| 1198 |
+
@media (max-width: 900px) {
|
| 1199 |
+
.persona-panels {
|
| 1200 |
+
grid-template-columns: 1fr;
|
| 1201 |
+
}
|
| 1202 |
+
.persona-field textarea {
|
| 1203 |
+
min-height: 60px;
|
| 1204 |
+
}
|
| 1205 |
+
.persona-field textarea.tall {
|
| 1206 |
+
min-height: 80px;
|
| 1207 |
+
}
|
| 1208 |
+
.freeform-textarea {
|
| 1209 |
+
min-height: 160px;
|
| 1210 |
+
}
|
| 1211 |
+
.accordion-header {
|
| 1212 |
+
padding: 12px 16px;
|
| 1213 |
+
font-size: 14px;
|
| 1214 |
+
}
|
| 1215 |
+
.accordion-body.open {
|
| 1216 |
+
padding: 12px 16px 16px;
|
| 1217 |
+
}
|
| 1218 |
+
.chat-controls {
|
| 1219 |
+
flex-wrap: wrap;
|
| 1220 |
+
gap: 8px;
|
| 1221 |
+
}
|
| 1222 |
+
.chat-controls input {
|
| 1223 |
+
flex-basis: 100%;
|
| 1224 |
+
font-size: 13px;
|
| 1225 |
+
}
|
| 1226 |
+
.message-row {
|
| 1227 |
+
gap: 8px;
|
| 1228 |
+
}
|
| 1229 |
+
.avatar {
|
| 1230 |
+
width: 32px;
|
| 1231 |
+
height: 32px;
|
| 1232 |
+
font-size: 12px;
|
| 1233 |
+
}
|
| 1234 |
+
}
|
| 1235 |
+
|
| 1236 |
+
/* ── Small tablet / large phone (<600px) ──────────────────────── */
|
| 1237 |
+
@media (max-width: 600px) {
|
| 1238 |
+
/* The standalone .dev-download-btns header strip and the
|
| 1239 |
+
.dev-panel-download-item mobile fallback inside the settings menu
|
| 1240 |
+
have been replaced by a dedicated <DownloadMenu> dropdown that
|
| 1241 |
+
works on all viewports, so the responsive fallback logic that used
|
| 1242 |
+
to live here is no longer needed. */
|
| 1243 |
+
.dev-sub-panel {
|
| 1244 |
+
right: 0;
|
| 1245 |
+
top: 100%;
|
| 1246 |
+
margin-right: 0;
|
| 1247 |
+
margin-top: 4px;
|
| 1248 |
+
width: min(280px, calc(100vw - 24px));
|
| 1249 |
+
}
|
| 1250 |
+
.dev-panel {
|
| 1251 |
+
min-width: 200px;
|
| 1252 |
+
}
|
| 1253 |
+
.auth-badge {
|
| 1254 |
+
font-size: 11px;
|
| 1255 |
+
padding: 3px 6px;
|
| 1256 |
+
gap: 4px;
|
| 1257 |
+
}
|
| 1258 |
+
.auth-remaining {
|
| 1259 |
+
display: none;
|
| 1260 |
+
}
|
| 1261 |
+
}
|
| 1262 |
+
|
| 1263 |
+
/* ── Phone (<480px) ───────────────────────────────────────────── */
|
| 1264 |
+
@media (max-width: 480px) {
|
| 1265 |
+
.persona-panels {
|
| 1266 |
+
gap: 12px;
|
| 1267 |
+
}
|
| 1268 |
+
.persona-panel {
|
| 1269 |
+
gap: 8px;
|
| 1270 |
+
}
|
| 1271 |
+
.persona-panel-header {
|
| 1272 |
+
font-size: 13px;
|
| 1273 |
+
padding-bottom: 6px;
|
| 1274 |
+
}
|
| 1275 |
+
.persona-field label {
|
| 1276 |
+
font-size: 11px;
|
| 1277 |
+
}
|
| 1278 |
+
.persona-field input,
|
| 1279 |
+
.persona-field textarea {
|
| 1280 |
+
padding: 8px 10px;
|
| 1281 |
+
font-size: 12px;
|
| 1282 |
+
}
|
| 1283 |
+
.persona-field textarea {
|
| 1284 |
+
min-height: 50px;
|
| 1285 |
+
}
|
| 1286 |
+
.persona-field textarea.tall {
|
| 1287 |
+
min-height: 60px;
|
| 1288 |
+
}
|
| 1289 |
+
.freeform-textarea {
|
| 1290 |
+
min-height: 120px;
|
| 1291 |
+
}
|
| 1292 |
+
.accordion {
|
| 1293 |
+
border-radius: 8px;
|
| 1294 |
+
margin-bottom: 10px;
|
| 1295 |
+
}
|
| 1296 |
+
.accordion-header {
|
| 1297 |
+
padding: 10px 12px;
|
| 1298 |
+
font-size: 13px;
|
| 1299 |
+
}
|
| 1300 |
+
.accordion-body.open {
|
| 1301 |
+
padding: 10px 12px 14px;
|
| 1302 |
+
}
|
| 1303 |
+
.chat-controls {
|
| 1304 |
+
gap: 6px;
|
| 1305 |
+
padding: 8px 0;
|
| 1306 |
+
}
|
| 1307 |
+
.btn-primary,
|
| 1308 |
+
.btn-secondary,
|
| 1309 |
+
.btn-stop {
|
| 1310 |
+
padding: 8px 14px;
|
| 1311 |
+
font-size: 13px;
|
| 1312 |
+
}
|
| 1313 |
+
.message-bubble {
|
| 1314 |
+
padding: 10px 12px;
|
| 1315 |
+
font-size: 13px;
|
| 1316 |
+
border-radius: 12px;
|
| 1317 |
+
}
|
| 1318 |
+
.message-row {
|
| 1319 |
+
gap: 6px;
|
| 1320 |
+
margin-bottom: 6px;
|
| 1321 |
+
}
|
| 1322 |
+
.avatar {
|
| 1323 |
+
width: 28px;
|
| 1324 |
+
height: 28px;
|
| 1325 |
+
font-size: 11px;
|
| 1326 |
+
}
|
| 1327 |
+
.system-message {
|
| 1328 |
+
font-size: 13px;
|
| 1329 |
+
padding: 8px;
|
| 1330 |
+
}
|
| 1331 |
+
.system-message.end-of-chat {
|
| 1332 |
+
font-size: 14px;
|
| 1333 |
+
padding: 14px;
|
| 1334 |
+
}
|
| 1335 |
+
/* On phones the download strip stacks so each label is fully
|
| 1336 |
+
readable; on wider viewports it stays inline-wrap. */
|
| 1337 |
+
.chat-end-downloads {
|
| 1338 |
+
flex-direction: column;
|
| 1339 |
+
align-items: stretch;
|
| 1340 |
+
gap: 6px;
|
| 1341 |
+
}
|
| 1342 |
+
.chat-end-download-btn {
|
| 1343 |
+
justify-content: center;
|
| 1344 |
+
}
|
| 1345 |
+
.btn-sm {
|
| 1346 |
+
padding: 5px 8px;
|
| 1347 |
+
font-size: 11px;
|
| 1348 |
+
}
|
| 1349 |
+
.btn-ghost {
|
| 1350 |
+
font-size: 12px;
|
| 1351 |
+
}
|
| 1352 |
+
.auth-badge {
|
| 1353 |
+
display: none;
|
| 1354 |
+
}
|
| 1355 |
+
.app-footer {
|
| 1356 |
+
padding: 6px 12px;
|
| 1357 |
+
}
|
| 1358 |
+
}
|
| 1359 |
+
|
| 1360 |
+
/* ── Role Prompt Modal ─────────────────────────────────────────── */
|
| 1361 |
+
|
| 1362 |
+
.modal-overlay {
|
| 1363 |
+
position: fixed;
|
| 1364 |
+
inset: 0;
|
| 1365 |
+
background: rgba(0, 0, 0, 0.5);
|
| 1366 |
+
display: flex;
|
| 1367 |
+
align-items: center;
|
| 1368 |
+
justify-content: center;
|
| 1369 |
+
z-index: 1000;
|
| 1370 |
+
animation: fadeIn 0.15s ease;
|
| 1371 |
+
}
|
| 1372 |
+
|
| 1373 |
+
.modal-content {
|
| 1374 |
+
background: var(--bg-primary);
|
| 1375 |
+
border: 1px solid var(--border-primary);
|
| 1376 |
+
border-radius: 12px;
|
| 1377 |
+
width: min(680px, 90vw);
|
| 1378 |
+
max-height: 80vh;
|
| 1379 |
+
display: flex;
|
| 1380 |
+
flex-direction: column;
|
| 1381 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
| 1382 |
+
}
|
| 1383 |
+
|
| 1384 |
+
.modal-header {
|
| 1385 |
+
display: flex;
|
| 1386 |
+
align-items: center;
|
| 1387 |
+
justify-content: space-between;
|
| 1388 |
+
padding: 16px 20px;
|
| 1389 |
+
border-bottom: 1px solid var(--border-primary);
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
.modal-header h2 {
|
| 1393 |
+
margin: 0;
|
| 1394 |
+
font-size: 16px;
|
| 1395 |
+
font-weight: 600;
|
| 1396 |
+
color: var(--text-primary);
|
| 1397 |
+
}
|
| 1398 |
+
|
| 1399 |
+
.modal-close {
|
| 1400 |
+
background: none;
|
| 1401 |
+
border: none;
|
| 1402 |
+
font-size: 22px;
|
| 1403 |
+
cursor: pointer;
|
| 1404 |
+
color: var(--text-muted);
|
| 1405 |
+
padding: 0 4px;
|
| 1406 |
+
line-height: 1;
|
| 1407 |
+
border-radius: 4px;
|
| 1408 |
+
transition: color 0.15s, background 0.15s;
|
| 1409 |
+
}
|
| 1410 |
+
|
| 1411 |
+
.modal-close:hover {
|
| 1412 |
+
color: var(--text-primary);
|
| 1413 |
+
background: var(--bg-secondary);
|
| 1414 |
+
}
|
| 1415 |
+
|
| 1416 |
+
.modal-body {
|
| 1417 |
+
padding: 20px;
|
| 1418 |
+
overflow-y: auto;
|
| 1419 |
+
display: flex;
|
| 1420 |
+
flex-direction: column;
|
| 1421 |
+
gap: 20px;
|
| 1422 |
+
}
|
| 1423 |
+
|
| 1424 |
+
.role-prompt-section h3 {
|
| 1425 |
+
margin: 0 0 8px;
|
| 1426 |
+
font-size: 14px;
|
| 1427 |
+
font-weight: 600;
|
| 1428 |
+
color: var(--text-primary);
|
| 1429 |
+
}
|
| 1430 |
+
|
| 1431 |
+
.role-prompt-model {
|
| 1432 |
+
font-weight: 400;
|
| 1433 |
+
color: var(--text-muted);
|
| 1434 |
+
font-size: 12px;
|
| 1435 |
+
}
|
| 1436 |
+
|
| 1437 |
+
.role-prompt-text {
|
| 1438 |
+
background: var(--bg-secondary);
|
| 1439 |
+
border: 1px solid var(--border-primary);
|
| 1440 |
+
border-radius: 8px;
|
| 1441 |
+
padding: 12px 14px;
|
| 1442 |
+
font-size: 13px;
|
| 1443 |
+
line-height: 1.6;
|
| 1444 |
+
color: var(--text-secondary);
|
| 1445 |
+
white-space: pre-wrap;
|
| 1446 |
+
word-break: break-word;
|
| 1447 |
+
margin: 0;
|
| 1448 |
+
max-height: 260px;
|
| 1449 |
+
overflow-y: auto;
|
| 1450 |
+
}
|
| 1451 |
+
|
| 1452 |
+
@keyframes fadeIn {
|
| 1453 |
+
from { opacity: 0; }
|
| 1454 |
+
to { opacity: 1; }
|
| 1455 |
+
}
|
frontend/src/styles/decidron.css
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.decidron-app {
|
| 2 |
+
width: 100%;
|
| 3 |
+
max-width: 1280px;
|
| 4 |
+
margin: 0 auto;
|
| 5 |
+
padding: 1rem 1.25rem 3rem;
|
| 6 |
+
/* The app shell locks .app/.app-main to 100vh with overflow:hidden, so
|
| 7 |
+
this container must fill the main area and scroll internally - keeps
|
| 8 |
+
the whole simulator (Results, Commands, Stats) reachable at any zoom. */
|
| 9 |
+
height: 100%;
|
| 10 |
+
overflow-y: auto;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.decidron-grid {
|
| 14 |
+
display: grid;
|
| 15 |
+
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
| 16 |
+
gap: 1rem;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
@media (max-width: 980px) {
|
| 20 |
+
.decidron-grid { grid-template-columns: 1fr; }
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.decidron-panel {
|
| 24 |
+
background: var(--card-bg);
|
| 25 |
+
border: 1px solid var(--border-primary);
|
| 26 |
+
border-radius: 12px;
|
| 27 |
+
box-shadow: var(--shadow-sm);
|
| 28 |
+
padding: 1rem;
|
| 29 |
+
margin-bottom: 1rem;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.decidron-panel h2 {
|
| 33 |
+
font-size: 0.95rem;
|
| 34 |
+
font-weight: 700;
|
| 35 |
+
color: var(--text-primary);
|
| 36 |
+
margin-bottom: 0.25rem;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.decidron-panel .panel-hint {
|
| 40 |
+
font-size: 0.78rem;
|
| 41 |
+
color: var(--text-tertiary);
|
| 42 |
+
margin-bottom: 0.75rem;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.decidron-diagram {
|
| 46 |
+
background: var(--bg-secondary);
|
| 47 |
+
border: 1px solid var(--border-primary);
|
| 48 |
+
border-radius: 10px;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.decidron-field {
|
| 52 |
+
display: flex;
|
| 53 |
+
flex-direction: column;
|
| 54 |
+
gap: 0.25rem;
|
| 55 |
+
margin-bottom: 0.6rem;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.decidron-field label {
|
| 59 |
+
font-size: 0.75rem;
|
| 60 |
+
font-weight: 600;
|
| 61 |
+
color: var(--text-secondary);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.decidron-field input,
|
| 65 |
+
.decidron-field select {
|
| 66 |
+
padding: 0.45rem 0.55rem;
|
| 67 |
+
border: 1px solid var(--border-primary);
|
| 68 |
+
border-radius: 8px;
|
| 69 |
+
background: var(--bg-primary);
|
| 70 |
+
color: var(--text-primary);
|
| 71 |
+
font-size: 0.85rem;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.decidron-row {
|
| 75 |
+
display: flex;
|
| 76 |
+
gap: 0.5rem;
|
| 77 |
+
flex-wrap: wrap;
|
| 78 |
+
align-items: flex-end;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.decidron-btn {
|
| 82 |
+
padding: 0.5rem 0.9rem;
|
| 83 |
+
border: none;
|
| 84 |
+
border-radius: 8px;
|
| 85 |
+
background: var(--accent-gradient);
|
| 86 |
+
color: #fff;
|
| 87 |
+
font-size: 0.85rem;
|
| 88 |
+
font-weight: 600;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.decidron-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 92 |
+
|
| 93 |
+
.decidron-btn.secondary {
|
| 94 |
+
background: transparent;
|
| 95 |
+
color: var(--text-secondary);
|
| 96 |
+
border: 1px solid var(--border-primary);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.decidron-table {
|
| 100 |
+
width: 100%;
|
| 101 |
+
border-collapse: collapse;
|
| 102 |
+
font-size: 0.8rem;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.decidron-table th,
|
| 106 |
+
.decidron-table td {
|
| 107 |
+
text-align: left;
|
| 108 |
+
padding: 0.4rem 0.5rem;
|
| 109 |
+
border-bottom: 1px solid var(--border-muted);
|
| 110 |
+
color: var(--text-secondary);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.decidron-table th { color: var(--text-tertiary); font-weight: 600; }
|
| 114 |
+
|
| 115 |
+
.decidron-chip {
|
| 116 |
+
display: inline-block;
|
| 117 |
+
padding: 0.1rem 0.45rem;
|
| 118 |
+
border-radius: 999px;
|
| 119 |
+
font-size: 0.7rem;
|
| 120 |
+
font-weight: 600;
|
| 121 |
+
background: var(--accent-light);
|
| 122 |
+
color: var(--accent-primary);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.decidron-chip.match { background: var(--speaker-b-bg); color: var(--speaker-b-color); }
|
| 126 |
+
.decidron-chip.nomatch { background: var(--bg-tertiary); color: var(--text-tertiary); }
|
| 127 |
+
|
| 128 |
+
.decidron-pending {
|
| 129 |
+
list-style: none;
|
| 130 |
+
display: flex;
|
| 131 |
+
flex-wrap: wrap;
|
| 132 |
+
gap: 0.35rem;
|
| 133 |
+
margin: 0.4rem 0;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.decidron-pending li {
|
| 137 |
+
font-size: 0.75rem;
|
| 138 |
+
padding: 0.15rem 0.5rem;
|
| 139 |
+
border-radius: 6px;
|
| 140 |
+
background: var(--comp-bg);
|
| 141 |
+
border: 1px solid var(--comp-border);
|
| 142 |
+
color: var(--text-secondary);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.decidron-history-item {
|
| 146 |
+
padding: 0.5rem 0.6rem;
|
| 147 |
+
border: 1px solid var(--border-primary);
|
| 148 |
+
border-radius: 8px;
|
| 149 |
+
margin-bottom: 0.4rem;
|
| 150 |
+
cursor: pointer;
|
| 151 |
+
font-size: 0.8rem;
|
| 152 |
+
color: var(--text-secondary);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.decidron-history-item.active {
|
| 156 |
+
border-color: var(--accent-primary);
|
| 157 |
+
background: var(--accent-light);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.decidron-error {
|
| 161 |
+
background: var(--error-bg);
|
| 162 |
+
border: 1px solid var(--error-border);
|
| 163 |
+
color: var(--error-text);
|
| 164 |
+
padding: 0.5rem 0.75rem;
|
| 165 |
+
border-radius: 8px;
|
| 166 |
+
font-size: 0.82rem;
|
| 167 |
+
margin-bottom: 0.75rem;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.decidron-cmd {
|
| 171 |
+
border: 1px solid var(--border-primary);
|
| 172 |
+
border-radius: 8px;
|
| 173 |
+
padding: 0.5rem 0.6rem;
|
| 174 |
+
margin-bottom: 0.4rem;
|
| 175 |
+
font-size: 0.8rem;
|
| 176 |
+
color: var(--text-secondary);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.decidron-cmd code {
|
| 180 |
+
background: var(--bg-tertiary);
|
| 181 |
+
padding: 0.05rem 0.3rem;
|
| 182 |
+
border-radius: 4px;
|
| 183 |
+
font-size: 0.75rem;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/* Header settings menu */
|
| 187 |
+
.decidron-settings-wrap .decidron-settings-trigger {
|
| 188 |
+
width: 36px;
|
| 189 |
+
height: 36px;
|
| 190 |
+
padding: 0;
|
| 191 |
+
display: inline-flex;
|
| 192 |
+
align-items: center;
|
| 193 |
+
justify-content: center;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.decidron-settings-panel {
|
| 197 |
+
width: 300px;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.decidron-settings-radio {
|
| 201 |
+
display: flex;
|
| 202 |
+
align-items: flex-start;
|
| 203 |
+
gap: 0.5rem;
|
| 204 |
+
padding: 0.45rem 0.5rem;
|
| 205 |
+
border-radius: 8px;
|
| 206 |
+
cursor: pointer;
|
| 207 |
+
font-size: 0.82rem;
|
| 208 |
+
color: var(--text-secondary);
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.decidron-settings-radio:hover {
|
| 212 |
+
background: var(--bg-tertiary);
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.decidron-settings-radio input {
|
| 216 |
+
margin-top: 0.2rem;
|
| 217 |
+
flex-shrink: 0;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.decidron-settings-radio strong {
|
| 221 |
+
display: block;
|
| 222 |
+
color: var(--text-primary);
|
| 223 |
+
font-weight: 600;
|
| 224 |
+
font-size: 0.82rem;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.decidron-settings-hint {
|
| 228 |
+
display: block;
|
| 229 |
+
font-size: 0.72rem;
|
| 230 |
+
color: var(--text-tertiary);
|
| 231 |
+
margin-top: 0.1rem;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
.decidron-settings-check {
|
| 235 |
+
cursor: pointer;
|
| 236 |
+
}
|
frontend/src/styles/layout.css
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.app {
|
| 2 |
+
height: 100vh;
|
| 3 |
+
display: flex;
|
| 4 |
+
flex-direction: column;
|
| 5 |
+
overflow: hidden;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.app-header {
|
| 9 |
+
display: flex;
|
| 10 |
+
align-items: center;
|
| 11 |
+
justify-content: space-between;
|
| 12 |
+
padding: 12px 24px;
|
| 13 |
+
background: var(--card-bg);
|
| 14 |
+
border-bottom: 1px solid var(--border-primary);
|
| 15 |
+
box-shadow: var(--shadow-sm);
|
| 16 |
+
flex-shrink: 0;
|
| 17 |
+
z-index: 100;
|
| 18 |
+
backdrop-filter: blur(12px);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.header-left {
|
| 22 |
+
display: flex;
|
| 23 |
+
align-items: center;
|
| 24 |
+
gap: 10px;
|
| 25 |
+
min-width: 0;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.header-brand-link {
|
| 29 |
+
display: flex;
|
| 30 |
+
align-items: center;
|
| 31 |
+
text-decoration: none;
|
| 32 |
+
transition: opacity 0.15s;
|
| 33 |
+
flex-shrink: 0;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.header-brand-link:hover {
|
| 37 |
+
opacity: 0.8;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.app-logo {
|
| 41 |
+
height: 32px;
|
| 42 |
+
width: auto;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.app-title {
|
| 46 |
+
font-size: 20px;
|
| 47 |
+
font-weight: 700;
|
| 48 |
+
background: var(--accent-gradient);
|
| 49 |
+
-webkit-background-clip: text;
|
| 50 |
+
-webkit-text-fill-color: transparent;
|
| 51 |
+
background-clip: text;
|
| 52 |
+
white-space: nowrap;
|
| 53 |
+
overflow: hidden;
|
| 54 |
+
text-overflow: ellipsis;
|
| 55 |
+
min-width: 0;
|
| 56 |
+
margin: 0;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.app-title-link {
|
| 60 |
+
all: unset;
|
| 61 |
+
cursor: pointer;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.app-title-link:hover {
|
| 65 |
+
opacity: 0.8;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.header-right {
|
| 69 |
+
display: flex;
|
| 70 |
+
align-items: center;
|
| 71 |
+
gap: 8px;
|
| 72 |
+
flex-shrink: 0;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.icon-btn {
|
| 76 |
+
display: flex;
|
| 77 |
+
align-items: center;
|
| 78 |
+
justify-content: center;
|
| 79 |
+
width: 36px;
|
| 80 |
+
height: 36px;
|
| 81 |
+
border: 1px solid var(--border-primary);
|
| 82 |
+
border-radius: 8px;
|
| 83 |
+
background: var(--bg-primary);
|
| 84 |
+
color: var(--text-secondary);
|
| 85 |
+
transition: all 0.15s;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.icon-btn:hover {
|
| 89 |
+
background: var(--bg-tertiary);
|
| 90 |
+
color: var(--text-primary);
|
| 91 |
+
border-color: var(--accent-primary);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.app-main {
|
| 95 |
+
display: flex;
|
| 96 |
+
flex: 1;
|
| 97 |
+
min-height: 0;
|
| 98 |
+
overflow: hidden;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.sidebar {
|
| 102 |
+
width: 300px;
|
| 103 |
+
min-width: 300px;
|
| 104 |
+
flex-shrink: 0;
|
| 105 |
+
padding: 16px;
|
| 106 |
+
overflow-y: auto;
|
| 107 |
+
overflow-x: hidden;
|
| 108 |
+
border-right: 1px solid var(--border-primary);
|
| 109 |
+
background: var(--bg-secondary);
|
| 110 |
+
display: flex;
|
| 111 |
+
flex-direction: column;
|
| 112 |
+
gap: 16px;
|
| 113 |
+
max-height: calc(100vh - 57px);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.content {
|
| 117 |
+
flex: 1;
|
| 118 |
+
min-height: 0;
|
| 119 |
+
padding: 20px 24px;
|
| 120 |
+
overflow-y: auto;
|
| 121 |
+
display: flex;
|
| 122 |
+
flex-direction: column;
|
| 123 |
+
gap: 0;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/* ── Tablet: stack sidebar above content ──────────────────────── */
|
| 127 |
+
@media (max-width: 900px) {
|
| 128 |
+
.app-header {
|
| 129 |
+
padding: 10px 16px;
|
| 130 |
+
}
|
| 131 |
+
.app-main {
|
| 132 |
+
flex-direction: column;
|
| 133 |
+
}
|
| 134 |
+
.sidebar {
|
| 135 |
+
width: 100%;
|
| 136 |
+
min-width: auto;
|
| 137 |
+
max-height: 200px;
|
| 138 |
+
flex-shrink: 0;
|
| 139 |
+
border-right: none;
|
| 140 |
+
border-bottom: 1px solid var(--border-primary);
|
| 141 |
+
}
|
| 142 |
+
.content {
|
| 143 |
+
padding: 16px;
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/* ── Phone ────────────────────────────────────────────────────── */
|
| 148 |
+
@media (max-width: 480px) {
|
| 149 |
+
.app-header {
|
| 150 |
+
padding: 8px 12px;
|
| 151 |
+
gap: 4px;
|
| 152 |
+
}
|
| 153 |
+
.app-logo {
|
| 154 |
+
height: 24px;
|
| 155 |
+
}
|
| 156 |
+
.app-title {
|
| 157 |
+
font-size: 14px;
|
| 158 |
+
}
|
| 159 |
+
.header-left {
|
| 160 |
+
gap: 6px;
|
| 161 |
+
}
|
| 162 |
+
.header-right {
|
| 163 |
+
gap: 4px;
|
| 164 |
+
}
|
| 165 |
+
.icon-btn {
|
| 166 |
+
width: 32px;
|
| 167 |
+
height: 32px;
|
| 168 |
+
}
|
| 169 |
+
.sidebar {
|
| 170 |
+
max-height: 160px;
|
| 171 |
+
padding: 8px;
|
| 172 |
+
}
|
| 173 |
+
.content {
|
| 174 |
+
padding: 12px;
|
| 175 |
+
}
|
| 176 |
+
}
|
frontend/src/styles/variables.css
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root[data-theme="light"] {
|
| 2 |
+
--bg-primary: #FFFFFF;
|
| 3 |
+
--bg-secondary: #F9FAFB;
|
| 4 |
+
--bg-tertiary: #F3F4F6;
|
| 5 |
+
--bg-gradient: linear-gradient(135deg, #EFF6FF 0%, #FFFFFF 50%, #F3E8FF 100%);
|
| 6 |
+
|
| 7 |
+
--text-primary: #111827;
|
| 8 |
+
--text-secondary: #4B5563;
|
| 9 |
+
--text-tertiary: #6B7280;
|
| 10 |
+
--text-muted: #9CA3AF;
|
| 11 |
+
|
| 12 |
+
--accent-primary: #6366F1;
|
| 13 |
+
--accent-secondary: #8B5CF6;
|
| 14 |
+
--accent-gradient: linear-gradient(135deg, #5558E3, #7C4FE8);
|
| 15 |
+
--accent-light: #EEF2FF;
|
| 16 |
+
|
| 17 |
+
--border-primary: #E5E7EB;
|
| 18 |
+
--border-muted: #F3F4F6;
|
| 19 |
+
|
| 20 |
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
| 21 |
+
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
| 22 |
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
| 23 |
+
|
| 24 |
+
--card-bg: #FFFFFF;
|
| 25 |
+
--card-hover: #F9FAFB;
|
| 26 |
+
|
| 27 |
+
--neon-accent: #7C4FE8;
|
| 28 |
+
--neon-bg: #EAE0FD;
|
| 29 |
+
--neon-border: #C4B5F0;
|
| 30 |
+
|
| 31 |
+
--comp-bg: #E0EAFD;
|
| 32 |
+
--comp-border: #B8CFF0;
|
| 33 |
+
|
| 34 |
+
--speaker-a-color: #6366F1;
|
| 35 |
+
--speaker-a-bg: #EEF2FF;
|
| 36 |
+
--speaker-b-color: #059669;
|
| 37 |
+
--speaker-b-bg: #ECFDF5;
|
| 38 |
+
|
| 39 |
+
--error-bg: #FEE2E2;
|
| 40 |
+
--error-border: #FECACA;
|
| 41 |
+
--error-text: #991B1B;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
:root[data-theme="dark"] {
|
| 45 |
+
--bg-primary: #1F2937;
|
| 46 |
+
--bg-secondary: #111827;
|
| 47 |
+
--bg-tertiary: #374151;
|
| 48 |
+
--bg-gradient: linear-gradient(135deg, #1F2937 0%, #111827 50%, #374151 100%);
|
| 49 |
+
|
| 50 |
+
--text-primary: #F9FAFB;
|
| 51 |
+
--text-secondary: #D1D5DB;
|
| 52 |
+
--text-tertiary: #9CA3AF;
|
| 53 |
+
--text-muted: #6B7280;
|
| 54 |
+
|
| 55 |
+
--accent-primary: #818CF8;
|
| 56 |
+
--accent-secondary: #A78BFA;
|
| 57 |
+
--accent-gradient: linear-gradient(135deg, #818CF8, #A78BFA);
|
| 58 |
+
--accent-light: #312E81;
|
| 59 |
+
|
| 60 |
+
--border-primary: #374151;
|
| 61 |
+
--border-muted: #1F2937;
|
| 62 |
+
|
| 63 |
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
| 64 |
+
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
|
| 65 |
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
| 66 |
+
|
| 67 |
+
--card-bg: #1F2937;
|
| 68 |
+
--card-hover: #374151;
|
| 69 |
+
|
| 70 |
+
--neon-accent: #A78BFA;
|
| 71 |
+
--neon-bg: #2D2640;
|
| 72 |
+
--neon-border: #8B77C0;
|
| 73 |
+
|
| 74 |
+
--comp-bg: #1E2A40;
|
| 75 |
+
--comp-border: #3D5A80;
|
| 76 |
+
|
| 77 |
+
--speaker-a-color: #818CF8;
|
| 78 |
+
--speaker-a-bg: #312E81;
|
| 79 |
+
--speaker-b-color: #34D399;
|
| 80 |
+
--speaker-b-bg: #064E3B;
|
| 81 |
+
|
| 82 |
+
--error-bg: #450A0A;
|
| 83 |
+
--error-border: #7F1D1D;
|
| 84 |
+
--error-text: #FCA5A5;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
*, *::before, *::after {
|
| 88 |
+
box-sizing: border-box;
|
| 89 |
+
margin: 0;
|
| 90 |
+
padding: 0;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
body {
|
| 94 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
| 95 |
+
background: var(--bg-gradient);
|
| 96 |
+
color: var(--text-primary);
|
| 97 |
+
line-height: 1.5;
|
| 98 |
+
-webkit-font-smoothing: antialiased;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
button {
|
| 102 |
+
cursor: pointer;
|
| 103 |
+
font-family: inherit;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
textarea, input {
|
| 107 |
+
font-family: inherit;
|
| 108 |
+
}
|