diff --git a/.dockerignore b/.dockerignore
index 7e6a3a7..042d35d 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -5,3 +5,5 @@
__pycache__
tests
*.md
+web/node_modules
+web/.next
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a486e2a..ba18bce 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,3 +40,27 @@ jobs:
done
docker logs agentops
exit 1
+ web:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: web
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: web/package-lock.json
+ - run: npm ci
+ - run: npm run build
+ manifests:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install kubectl
+ run: |
+ curl -fsSL -o kubectl "https://dl.k8s.io/release/v1.32.2/bin/linux/amd64/kubectl"
+ chmod +x kubectl
+ sudo mv kubectl /usr/local/bin/kubectl
+ - run: kubectl kustomize k8s >/dev/null
diff --git a/.gitignore b/.gitignore
index 5cd3024..3bfb730 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
__pycache__/
.venv/
.pytest_cache/
+node_modules/
+.next/
diff --git a/README.md b/README.md
index d681d10..6af8572 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ flowchart LR
ING[/docs ingest/] --> Q[[RabbitMQ]] --> W[Worker: chunk+embed, retries, DLQ] --> DB[(MongoDB)]
T --> DB
```
-Built: API, LangGraph agent, MCP server, Redis cache, MongoDB document and chat store, RabbitMQ worker, eval harness, Docker, Render and Fly config, CI. Not built yet: the Next.js UI and Kubernetes manifests.
+Built: API, LangGraph agent, MCP server, Redis cache, MongoDB document and chat store, RabbitMQ worker, eval harness, Next.js chat UI, Docker, Render, Fly, and kind manifests, CI.
## Quickstart
```bash
@@ -28,6 +28,8 @@ docker compose up --build # API, worker, Redis, MongoDB, RabbitMQ
pytest -q # offline tests
python -m app.evals # tool accuracy, golden Q&A, guardrails
python -m app.mcp_server # MCP (stdio) for Cursor/Claude
+cd web && npm install
+API_URL=http://127.0.0.1:8000 npm run dev # http://localhost:3000
```
Set `OPENAI_API_KEY` (and optionally `MODEL`, default `gpt-4o-mini`) to use the LangGraph ReAct agent. Without a key, a deterministic router picks the tool, so tests and demos run offline.
@@ -53,6 +55,7 @@ Point Cursor or Claude Desktop at the MCP server:
| `MONGO_DB` | Database name. Default `agentops`. |
| `MONGO_VECTOR_INDEX` | Atlas `$vectorSearch` index on `chunks.embedding`. |
| `RABBITMQ_URL` | Publishes ingest to the worker. Unset ingests inline. |
+| `API_URL` | Used by the Next.js server to reach the API. Default `http://127.0.0.1:8000`. |
## Ingest and search
`POST /docs` chunks the text and stores a hashed embedding with each chunk. Without `RABBITMQ_URL` that happens in the request. With it, the API publishes to the `ingest` queue and `python -m app.worker` does the write. A message is attempted three times. The next failure is published to `ingest.dlq`. The broker dead-letters rejected deliveries to that same queue, so a crash does not drop the message.
@@ -61,8 +64,24 @@ Search keeps chunks that share a query token and breaks ties with cosine similar
Guardrails run before any tool. Empty input, questions over 2,000 characters, and prompt-injection phrasing are refused.
+## Chat UI
+The Next.js app in [`web/`](web/) stores a document, asks the agent, and shows which tool answered. A repeat question is marked cached.
+
+
+
+On a narrow screen the document form stacks above the conversation.
+
+
+
+The browser calls same-origin routes under `/api`. Those routes proxy to `API_URL`, so a Vercel deployment does not need browser CORS on the API.
+
## Deploy
-`docker compose up --build` starts the API, the worker, Redis, MongoDB, and RabbitMQ.
+`docker compose up --build` starts the API, the worker, the web UI, Redis, MongoDB, and RabbitMQ. The UI is on port 3000.
+
+### Vercel
+Import the repo and set the project root to `web`. Set `API_URL` to the public API, such as the Render or Fly URL. [`web/vercel.json`](web/vercel.json) selects the Next.js framework. The API stays on Render or Fly; Vercel only hosts the UI.
+
+### Render and Fly
[render.yaml](render.yaml) is a Render blueprint: a web service on the image, plus a worker running `python -m app.worker`. Supply `OPENAI_API_KEY`, `REDIS_URL` (Upstash), `MONGO_URL` (Atlas), and `RABBITMQ_URL` (CloudAMQP). Set `MONGO_VECTOR_INDEX` when the Atlas index exists.
@@ -73,13 +92,22 @@ fly secrets set OPENAI_API_KEY=... REDIS_URL=... MONGO_URL=... RABBITMQ_URL=...
fly deploy
```
-CI builds the image and checks `/health`, a document ingest, and `/ask`.
+CI builds the API image and checks `/health`, a document ingest, and `/ask`. It also builds the Next.js app and renders the Kubernetes manifests with `kubectl kustomize`.
+
+### Kubernetes
+Manifests in [`k8s/`](k8s/) run Redis, MongoDB, RabbitMQ, the API, the ingest worker, and the web UI in the `agentops` namespace. Services are headless, so in-cluster DNS returns pod addresses. The API and web images are `agentops-mcp:local` and `agentops-web:local` with `imagePullPolicy: IfNotPresent`, which matches a kind cluster after `kind load`.
+
+[`scripts/kind-smoke.sh`](scripts/kind-smoke.sh) creates a cluster, loads those images plus Redis, MongoDB, and RabbitMQ, waits for rollouts, and checks that a stored document is searchable and that `2*(3+4)` returns 14 through the web proxy.
+
+```bash
+./scripts/kind-smoke.sh
+```
## Roadmap
- [x] MVP: FastAPI, LangGraph agent, MCP server, Redis cache, Docker, CI
- [x] MongoDB document and chat store, with vector search
- [x] RabbitMQ ingest worker with retries and a dead-letter queue
- [x] Eval harness (tool-call accuracy, golden Q&A) and guardrails
-- [ ] Next.js (TypeScript) chat UI
-- [x] Deploy config: Render and Fly
-- [ ] Kubernetes manifests (kind)
+- [x] Next.js (TypeScript) chat UI, deployable to Vercel
+- [x] Deploy config: Render, Fly, and Vercel
+- [x] Kubernetes manifests, tested on kind
diff --git a/app/queue.py b/app/queue.py
index 4717b2c..e424c9c 100644
--- a/app/queue.py
+++ b/app/queue.py
@@ -24,6 +24,14 @@ def uses_broker() -> bool:
return bool(os.getenv("RABBITMQ_URL"))
+def broker_parameters(url: str, *, attempts: int = 8) -> pika.URLParameters:
+ """Retry long enough for RabbitMQ to finish starting in Compose or kind."""
+ params = pika.URLParameters(url)
+ params.connection_attempts = attempts
+ params.retry_delay = 2
+ return params
+
+
def declare(channel) -> None:
channel.exchange_declare(exchange=DEAD_LETTER_EXCHANGE, exchange_type="direct", durable=True)
channel.queue_declare(queue=DEAD_LETTER_QUEUE, durable=True)
@@ -86,7 +94,7 @@ def enqueue(doc_id: str, text: str) -> dict:
invalidate_prefix("ask:")
return {"id": doc_id, "status": "stored"}
body = json.dumps({"id": doc_id, "text": text}).encode()
- connection = pika.BlockingConnection(pika.URLParameters(url))
+ connection = pika.BlockingConnection(broker_parameters(url))
try:
channel = connection.channel()
declare(channel)
diff --git a/app/worker.py b/app/worker.py
index 850b018..6159b39 100644
--- a/app/worker.py
+++ b/app/worker.py
@@ -3,7 +3,7 @@
import pika
-from .queue import DEAD_LETTER_QUEUE, INGEST_QUEUE, declare, handle_delivery
+from .queue import DEAD_LETTER_QUEUE, INGEST_QUEUE, broker_parameters, declare, handle_delivery
def _publish(channel, queue: str, body: bytes, headers: dict) -> None:
@@ -31,7 +31,7 @@ def on_message(channel, method, properties, body) -> None:
def run_worker(url: str | None = None) -> None:
url = url or os.environ["RABBITMQ_URL"]
- connection = pika.BlockingConnection(pika.URLParameters(url))
+ connection = pika.BlockingConnection(broker_parameters(url, attempts=30))
channel = connection.channel()
declare(channel)
channel.basic_qos(prefetch_count=1)
diff --git a/docker-compose.yml b/docker-compose.yml
index 257c65b..0e0b56e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -30,6 +30,14 @@ services:
condition: service_healthy
redis:
condition: service_started
+ web:
+ build: ./web
+ ports:
+ - "3000:3000"
+ environment:
+ API_URL: http://api:8000
+ depends_on:
+ - api
redis:
image: redis:7-alpine
mongo:
diff --git a/docs/screenshots/chat-desktop.png b/docs/screenshots/chat-desktop.png
new file mode 100644
index 0000000..9fce6d3
Binary files /dev/null and b/docs/screenshots/chat-desktop.png differ
diff --git a/docs/screenshots/chat-mobile.png b/docs/screenshots/chat-mobile.png
new file mode 100644
index 0000000..0a9abf9
Binary files /dev/null and b/docs/screenshots/chat-mobile.png differ
diff --git a/k8s/api.yaml b/k8s/api.yaml
new file mode 100644
index 0000000..47c378e
--- /dev/null
+++ b/k8s/api.yaml
@@ -0,0 +1,61 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: api
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: api
+ template:
+ metadata:
+ labels:
+ app: api
+ spec:
+ containers:
+ - name: api
+ image: agentops-mcp:local
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 8000
+ envFrom:
+ - configMapRef:
+ name: agentops-config
+ startupProbe:
+ httpGet:
+ path: /health
+ port: 8000
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 30
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 8000
+ periodSeconds: 5
+ timeoutSeconds: 5
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: 8000
+ periodSeconds: 20
+ timeoutSeconds: 5
+ failureThreshold: 3
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ memory: 512Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: api
+spec:
+ clusterIP: None
+ selector:
+ app: api
+ ports:
+ - port: 8000
+ targetPort: 8000
diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml
new file mode 100644
index 0000000..509de19
--- /dev/null
+++ b/k8s/configmap.yaml
@@ -0,0 +1,10 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: agentops-config
+data:
+ REDIS_URL: redis://redis:6379/0
+ MONGO_URL: mongodb://mongo:27017
+ RABBITMQ_URL: amqp://guest:guest@rabbitmq:5672/
+ MODEL: gpt-4o-mini
+ API_URL: http://api:8000
diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml
new file mode 100644
index 0000000..adb452d
--- /dev/null
+++ b/k8s/kustomization.yaml
@@ -0,0 +1,12 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: agentops
+resources:
+ - namespace.yaml
+ - configmap.yaml
+ - redis.yaml
+ - mongo.yaml
+ - rabbitmq.yaml
+ - api.yaml
+ - worker.yaml
+ - web.yaml
diff --git a/k8s/mongo.yaml b/k8s/mongo.yaml
new file mode 100644
index 0000000..a4eec77
--- /dev/null
+++ b/k8s/mongo.yaml
@@ -0,0 +1,42 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: mongo
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: mongo
+ template:
+ metadata:
+ labels:
+ app: mongo
+ spec:
+ containers:
+ - name: mongo
+ image: mongo:7
+ ports:
+ - containerPort: 27017
+ readinessProbe:
+ exec:
+ command: ["mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
+ initialDelaySeconds: 5
+ periodSeconds: 5
+ resources:
+ requests:
+ cpu: 50m
+ memory: 256Mi
+ limits:
+ memory: 512Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: mongo
+spec:
+ clusterIP: None
+ selector:
+ app: mongo
+ ports:
+ - port: 27017
+ targetPort: 27017
diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml
new file mode 100644
index 0000000..47f6f33
--- /dev/null
+++ b/k8s/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: agentops
diff --git a/k8s/rabbitmq.yaml b/k8s/rabbitmq.yaml
new file mode 100644
index 0000000..3f9c2a6
--- /dev/null
+++ b/k8s/rabbitmq.yaml
@@ -0,0 +1,42 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: rabbitmq
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: rabbitmq
+ template:
+ metadata:
+ labels:
+ app: rabbitmq
+ spec:
+ containers:
+ - name: rabbitmq
+ image: rabbitmq:3-alpine
+ ports:
+ - containerPort: 5672
+ readinessProbe:
+ exec:
+ command: ["rabbitmq-diagnostics", "-q", "ping"]
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ memory: 256Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: rabbitmq
+spec:
+ clusterIP: None
+ selector:
+ app: rabbitmq
+ ports:
+ - port: 5672
+ targetPort: 5672
diff --git a/k8s/redis.yaml b/k8s/redis.yaml
new file mode 100644
index 0000000..23872be
--- /dev/null
+++ b/k8s/redis.yaml
@@ -0,0 +1,42 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: redis
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: redis
+ template:
+ metadata:
+ labels:
+ app: redis
+ spec:
+ containers:
+ - name: redis
+ image: redis:7-alpine
+ ports:
+ - containerPort: 6379
+ readinessProbe:
+ exec:
+ command: ["redis-cli", "ping"]
+ initialDelaySeconds: 3
+ periodSeconds: 5
+ resources:
+ requests:
+ cpu: 20m
+ memory: 32Mi
+ limits:
+ memory: 128Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: redis
+spec:
+ clusterIP: None
+ selector:
+ app: redis
+ ports:
+ - port: 6379
+ targetPort: 6379
diff --git a/k8s/web.yaml b/k8s/web.yaml
new file mode 100644
index 0000000..a515738
--- /dev/null
+++ b/k8s/web.yaml
@@ -0,0 +1,57 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: web
+ template:
+ metadata:
+ labels:
+ app: web
+ spec:
+ containers:
+ - name: web
+ image: agentops-web:local
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 3000
+ env:
+ - name: API_URL
+ valueFrom:
+ configMapKeyRef:
+ name: agentops-config
+ key: API_URL
+ startupProbe:
+ httpGet:
+ path: /
+ port: 3000
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 30
+ readinessProbe:
+ httpGet:
+ path: /
+ port: 3000
+ periodSeconds: 5
+ timeoutSeconds: 5
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ memory: 512Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: web
+spec:
+ clusterIP: None
+ selector:
+ app: web
+ ports:
+ - port: 3000
+ targetPort: 3000
diff --git a/k8s/worker.yaml b/k8s/worker.yaml
new file mode 100644
index 0000000..c5d887c
--- /dev/null
+++ b/k8s/worker.yaml
@@ -0,0 +1,28 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: worker
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: worker
+ template:
+ metadata:
+ labels:
+ app: worker
+ spec:
+ containers:
+ - name: worker
+ image: agentops-mcp:local
+ imagePullPolicy: IfNotPresent
+ command: ["python", "-m", "app.worker"]
+ envFrom:
+ - configMapRef:
+ name: agentops-config
+ resources:
+ requests:
+ cpu: 50m
+ memory: 128Mi
+ limits:
+ memory: 512Mi
diff --git a/scripts/kind-smoke.sh b/scripts/kind-smoke.sh
new file mode 100755
index 0000000..17bbbdd
--- /dev/null
+++ b/scripts/kind-smoke.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# Build the API and web images, load them into kind, and exercise ingest + ask.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+CLUSTER="${KIND_CLUSTER:-agentops}"
+API_PORT="${API_PORT:-18000}"
+WEB_PORT="${WEB_PORT:-13000}"
+
+cleanup() {
+ if [[ -n "${API_PF:-}" ]]; then kill "$API_PF" 2>/dev/null || true; fi
+ if [[ -n "${WEB_PF:-}" ]]; then kill "$WEB_PF" 2>/dev/null || true; fi
+ kind delete cluster --name "$CLUSTER" >/dev/null 2>&1 || true
+}
+trap cleanup EXIT
+
+kind delete cluster --name "$CLUSTER" >/dev/null 2>&1 || true
+kind create cluster --name "$CLUSTER"
+
+docker build -t agentops-mcp:local "$ROOT"
+docker build -t agentops-web:local "$ROOT/web"
+for image in redis:7-alpine mongo:7 rabbitmq:3-alpine agentops-mcp:local agentops-web:local; do
+ if [[ "$image" != *":local" ]]; then
+ docker pull "$image"
+ fi
+ kind load docker-image "$image" --name "$CLUSTER"
+done
+
+kubectl apply -k "$ROOT/k8s"
+
+dump_cluster() {
+ kubectl -n agentops get pods -o wide >&2 || true
+ kubectl -n agentops logs deploy/api --tail=80 >&2 || true
+ kubectl -n agentops describe pods >&2 || true
+ kubectl -n agentops get events --sort-by=.lastTimestamp >&2 || true
+}
+
+rollout() {
+ local name="$1"
+ local timeout="$2"
+ if ! kubectl -n agentops rollout status "deploy/$name" --timeout="$timeout"; then
+ echo "rollout failed: $name" >&2
+ dump_cluster
+ return 1
+ fi
+}
+
+rollout redis 300s
+rollout mongo 300s
+rollout rabbitmq 300s
+rollout api 300s
+rollout worker 300s
+rollout web 300s
+
+kubectl -n agentops port-forward svc/api "$API_PORT:8000" >/tmp/kind-api-pf.log 2>&1 &
+API_PF=$!
+kubectl -n agentops port-forward svc/web "$WEB_PORT:3000" >/tmp/kind-web-pf.log 2>&1 &
+WEB_PF=$!
+
+for _ in $(seq 1 40); do
+ if curl -fsS "http://127.0.0.1:$API_PORT/health" >/dev/null; then
+ break
+ fi
+ sleep 1
+done
+
+curl -fsS -X POST "http://127.0.0.1:$API_PORT/docs" \
+ -H 'content-type: application/json' \
+ -d '{"id":"d1","text":"refund policy is 30 days"}'
+echo
+
+found=0
+for _ in $(seq 1 40); do
+ body="$(curl -fsS -X POST "http://127.0.0.1:$API_PORT/ask" \
+ -H 'content-type: application/json' \
+ -d '{"question":"refund policy"}')"
+ if python3 -c 'import json,sys; body=json.loads(sys.argv[1]); raise SystemExit(0 if body.get("answer") and body["answer"][0]["id"]=="d1" else 1)' "$body"; then
+ found=1
+ echo "$body"
+ break
+ fi
+ sleep 2
+done
+if [[ "$found" != 1 ]]; then
+ echo "document was not searchable" >&2
+ kubectl -n agentops logs deploy/worker >&2 || true
+ kubectl -n agentops logs deploy/api >&2 || true
+ exit 1
+fi
+
+calc="$(curl -fsS -X POST "http://127.0.0.1:$WEB_PORT/api/ask" \
+ -H 'content-type: application/json' \
+ -d '{"question":"2*(3+4)"}')"
+echo "$calc"
+python3 -c 'import json,sys; body=json.loads(sys.argv[1]); assert body["answer"]==14 and body["tool"]=="calculator"' "$calc"
+echo "kind smoke ok"
diff --git a/tests/test_worker.py b/tests/test_worker.py
index 5544c18..a61a38c 100644
--- a/tests/test_worker.py
+++ b/tests/test_worker.py
@@ -5,7 +5,7 @@
from fastapi.testclient import TestClient
from app.main import app
-from app.queue import enqueue, failure_action, handle_delivery
+from app.queue import broker_parameters, enqueue, failure_action, handle_delivery
from app.tools import search_docs
from app.worker import on_message
@@ -99,7 +99,6 @@ def close(self):
monkeypatch.setenv("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/")
monkeypatch.setattr("app.queue.pika.BlockingConnection", lambda params: Connection())
- monkeypatch.setattr("app.queue.pika.URLParameters", lambda url: url)
assert enqueue("d1", "hello") == {"id": "d1", "status": "queued"}
assert published["routing_key"] == "ingest"
assert json.loads(published["body"]) == {"id": "d1", "text": "hello"}
@@ -120,6 +119,12 @@ def test_health_reports_inline_queue_without_a_broker():
assert body["cache"] == "memory"
+def test_broker_parameters_retry_while_rabbit_starts():
+ params = broker_parameters("amqp://guest:guest@rabbitmq:5672/", attempts=30)
+ assert params.connection_attempts == 30
+ assert params.retry_delay == 2
+
+
def test_worker_refuses_to_start_without_a_broker(monkeypatch):
monkeypatch.delenv("RABBITMQ_URL", raising=False)
from app.worker import main
diff --git a/web/.dockerignore b/web/.dockerignore
new file mode 100644
index 0000000..8b88447
--- /dev/null
+++ b/web/.dockerignore
@@ -0,0 +1,3 @@
+node_modules
+.next
+.git
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..b90a368
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+.next
diff --git a/web/Dockerfile b/web/Dockerfile
new file mode 100644
index 0000000..c160a3a
--- /dev/null
+++ b/web/Dockerfile
@@ -0,0 +1,22 @@
+FROM node:22-alpine AS deps
+WORKDIR /app
+COPY package.json package-lock.json ./
+RUN npm ci
+
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+ENV DOCKER_BUILD=1
+RUN npm run build
+
+FROM node:22-alpine AS run
+WORKDIR /app
+ENV NODE_ENV=production
+ENV PORT=3000
+ENV HOSTNAME=0.0.0.0
+COPY --from=build /app/.next/standalone ./
+COPY --from=build /app/.next/static ./.next/static
+COPY --from=build /app/public ./public
+EXPOSE 3000
+CMD ["node", "server.js"]
diff --git a/web/app/api/ask/route.ts b/web/app/api/ask/route.ts
new file mode 100644
index 0000000..b8753aa
--- /dev/null
+++ b/web/app/api/ask/route.ts
@@ -0,0 +1,8 @@
+import { proxy } from "@/lib/upstream";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(request: Request) {
+ const body = await request.text();
+ return proxy("/ask", { method: "POST", body });
+}
diff --git a/web/app/api/chats/route.ts b/web/app/api/chats/route.ts
new file mode 100644
index 0000000..755b2d7
--- /dev/null
+++ b/web/app/api/chats/route.ts
@@ -0,0 +1,7 @@
+import { proxy } from "@/lib/upstream";
+
+export const dynamic = "force-dynamic";
+
+export function GET() {
+ return proxy("/chats");
+}
diff --git a/web/app/api/docs/route.ts b/web/app/api/docs/route.ts
new file mode 100644
index 0000000..a2642b1
--- /dev/null
+++ b/web/app/api/docs/route.ts
@@ -0,0 +1,8 @@
+import { proxy } from "@/lib/upstream";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(request: Request) {
+ const body = await request.text();
+ return proxy("/docs", { method: "POST", body });
+}
diff --git a/web/app/api/health/route.ts b/web/app/api/health/route.ts
new file mode 100644
index 0000000..567c693
--- /dev/null
+++ b/web/app/api/health/route.ts
@@ -0,0 +1,7 @@
+import { proxy } from "@/lib/upstream";
+
+export const dynamic = "force-dynamic";
+
+export function GET() {
+ return proxy("/health");
+}
diff --git a/web/app/globals.css b/web/app/globals.css
new file mode 100644
index 0000000..a78faf4
--- /dev/null
+++ b/web/app/globals.css
@@ -0,0 +1,207 @@
+:root {
+ color-scheme: light;
+ --ink: #1c1916;
+ --muted: #6d645b;
+ --line: #e4dcd2;
+ --paper: #fffdf9;
+ --accent: #c24b2a;
+ --accent-ink: #fff8f4;
+ --chip: #f3ebe3;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ color: var(--ink);
+ font-family: var(--font-sans), "Outfit", sans-serif;
+ background:
+ radial-gradient(880px 420px at 0% -10%, #f6d4c6 0%, transparent 60%),
+ radial-gradient(720px 380px at 100% 0%, #d7e6d4 0%, transparent 55%),
+ #f3efe8;
+}
+
+button, input, textarea { font: inherit; color: inherit; }
+
+.shell {
+ width: min(1120px, calc(100% - 32px));
+ margin: 0 auto;
+ padding: 36px 0 48px;
+}
+
+.top {
+ display: flex;
+ justify-content: space-between;
+ gap: 24px;
+ align-items: flex-end;
+ margin-bottom: 24px;
+}
+
+.eyebrow {
+ margin: 0 0 4px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ font-size: 12px;
+ color: var(--accent);
+}
+
+h1 {
+ margin: 0;
+ font-family: var(--font-display), Georgia, serif;
+ font-weight: 560;
+ font-size: clamp(40px, 5vw, 64px);
+ letter-spacing: -0.03em;
+ line-height: 0.95;
+}
+
+h2 { margin: 0 0 8px; font-size: 20px; }
+
+.pills { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
+
+.pill, .chip {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ padding: 6px 10px;
+ background: var(--paper);
+ border: 1px solid var(--line);
+ font-size: 13px;
+}
+
+.pill.bad { background: #fde8e2; border-color: #e7b2a4; color: #8a2e16; }
+
+.layout {
+ display: grid;
+ grid-template-columns: 320px 1fr;
+ gap: 18px;
+ align-items: start;
+}
+
+.panel, .chat {
+ background: var(--paper);
+ border: 1px solid var(--line);
+ border-radius: 22px;
+ box-shadow: 0 18px 50px rgba(60, 40, 20, 0.06);
+}
+
+.panel { padding: 22px; }
+
+.lede, .muted { color: var(--muted); }
+
+.lede { margin: 0 0 16px; line-height: 1.45; }
+
+.stack, .composer { display: grid; gap: 12px; }
+
+label { display: grid; gap: 6px; font-size: 14px; }
+
+input, textarea {
+ width: 100%;
+ border: 1px solid var(--line);
+ background: #fff;
+ border-radius: 12px;
+ padding: 12px 14px;
+}
+
+textarea { resize: vertical; }
+
+button {
+ border: 0;
+ border-radius: 12px;
+ padding: 12px 16px;
+ background: var(--accent);
+ color: var(--accent-ink);
+ cursor: pointer;
+}
+
+button:disabled { opacity: 0.55; cursor: not-allowed; }
+
+button.ghost {
+ background: transparent;
+ color: var(--ink);
+ border: 1px solid var(--line);
+}
+
+.note { margin: 0; color: #2f6f4e; }
+
+.chat { display: grid; grid-template-rows: 1fr auto auto; min-height: 640px; padding: 18px; }
+
+.transcript { display: grid; gap: 18px; align-content: start; }
+
+.empty {
+ margin: auto 0;
+ padding: 28px 8px;
+ color: var(--muted);
+}
+
+.empty p { margin: 0 0 8px; }
+
+.exchange { display: grid; gap: 6px; }
+
+.who {
+ margin: 0;
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.question { margin: 0; font-size: 18px; }
+
+.answer {
+ background: #faf7f3;
+ border-radius: 16px;
+ padding: 12px 14px;
+}
+
+.meta { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
+
+.chip { background: var(--chip); font-size: 12px; padding: 3px 8px; }
+
+.chip.quiet { background: transparent; }
+
+.plain { margin: 0; line-height: 1.45; white-space: pre-wrap; }
+
+.hits { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+
+.hits li { margin: 0; }
+
+.hit-head { display: flex; justify-content: space-between; gap: 12px; font-variant-numeric: tabular-nums; }
+
+.doc { font-weight: 650; }
+
+.score { color: var(--muted); font-size: 13px; }
+
+.hits p { margin: 4px 0 0; }
+
+.suggestions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
+
+.composer { grid-template-columns: 1fr auto; margin-top: 12px; }
+
+.error {
+ margin: 12px 0 0;
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: #fde8e2;
+ color: #8a2e16;
+}
+
+.sr {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+
+code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; }
+
+@media (max-width: 800px) {
+ .layout { grid-template-columns: 1fr; }
+ .top { flex-direction: column; align-items: flex-start; }
+ .pills { justify-content: flex-start; }
+ .chat { min-height: 520px; }
+}
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
new file mode 100644
index 0000000..b187319
--- /dev/null
+++ b/web/app/layout.tsx
@@ -0,0 +1,26 @@
+import type { Metadata } from "next";
+import { Fraunces, Outfit } from "next/font/google";
+import "./globals.css";
+
+const display = Fraunces({
+ subsets: ["latin"],
+ variable: "--font-display",
+});
+
+const sans = Outfit({
+ subsets: ["latin"],
+ variable: "--font-sans",
+});
+
+export const metadata: Metadata = {
+ title: "AgentOps",
+ description: "Chat with the document agent and watch which tool it calls.",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
{children}
+
+ );
+}
diff --git a/web/app/page.tsx b/web/app/page.tsx
new file mode 100644
index 0000000..1ddf50e
--- /dev/null
+++ b/web/app/page.tsx
@@ -0,0 +1,5 @@
+import { Console } from "@/components/console";
+
+export default function HomePage() {
+ return ;
+}
diff --git a/web/components/console.tsx b/web/components/console.tsx
new file mode 100644
index 0000000..4125e0a
--- /dev/null
+++ b/web/components/console.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import { FormEvent, useEffect, useState } from "react";
+
+type Hit = { id: string; text: string; score: number };
+type Answer = string | number | Hit[];
+
+type Turn = {
+ question: string;
+ answer: Answer;
+ mode?: string;
+ tool?: string;
+ cached?: boolean;
+};
+
+type Health = {
+ ok: boolean;
+ store: string;
+ queue: string;
+ cache: string;
+};
+
+const SUGGESTIONS = ["What is the refund policy?", "2*(3+4)"];
+
+export function Console() {
+ const [health, setHealth] = useState(null);
+ const [offline, setOffline] = useState(false);
+ const [turns, setTurns] = useState([]);
+ const [question, setQuestion] = useState("");
+ const [docId, setDocId] = useState("");
+ const [docText, setDocText] = useState("");
+ const [docNote, setDocNote] = useState("");
+ const [asking, setAsking] = useState(false);
+ const [storing, setStoring] = useState(false);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ let cancelled = false;
+ async function load() {
+ try {
+ const healthResponse = await fetch("/api/health");
+ if (!healthResponse.ok) throw new Error("health");
+ const healthBody = (await healthResponse.json()) as Health;
+ if (!cancelled) setHealth(healthBody);
+ } catch {
+ if (!cancelled) setOffline(true);
+ }
+ try {
+ const chatResponse = await fetch("/api/chats");
+ if (!chatResponse.ok) return;
+ const body = (await chatResponse.json()) as { chats?: Turn[] };
+ if (!cancelled) setTurns([...(body.chats ?? [])].reverse());
+ } catch {
+ /* history is optional; the ask box still works */
+ }
+ }
+ void load();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ async function ask(text: string) {
+ const trimmed = text.trim();
+ if (!trimmed || asking) return;
+ setAsking(true);
+ setError("");
+ try {
+ const response = await fetch("/api/ask", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ question: trimmed }),
+ });
+ if (!response.ok) throw new Error("ask failed");
+ const turn = (await response.json()) as Omit;
+ setTurns((current) => [...current, { question: trimmed, ...turn }]);
+ setQuestion("");
+ } catch {
+ setError("The agent could not answer. Check that the API is running.");
+ } finally {
+ setAsking(false);
+ }
+ }
+
+ async function onAsk(event: FormEvent) {
+ event.preventDefault();
+ await ask(question);
+ }
+
+ async function onStore(event: FormEvent) {
+ event.preventDefault();
+ const id = docId.trim();
+ const text = docText.trim();
+ if (!id || !text || storing) return;
+ setStoring(true);
+ setDocNote("");
+ setError("");
+ try {
+ const response = await fetch("/api/docs", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ id, text }),
+ });
+ if (!response.ok) throw new Error("store failed");
+ const body = (await response.json()) as { status?: string };
+ setDocNote(body.status === "queued" ? "Queued for the worker." : "Stored.");
+ setDocText("");
+ } catch {
+ setError("The document was not stored. Check that the API is running.");
+ } finally {
+ setStoring(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+ Add a document
+
+ Stored text is what search_docs can retrieve. Arithmetic skips the index and uses the calculator.
+
+
+
+
+
+
+ {turns.length === 0 && (
+
+
No questions yet.
+
Save a document, then ask about it, or send an expression such as 2*(3+4).
+
+ )}
+ {turns.map((turn, index) => (
+
+ You
+ {turn.question}
+
+
+ {turn.tool && {turn.tool}}
+ {turn.mode && {turn.mode}}
+ {turn.cached && cached}
+
+
+
+
+ ))}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {SUGGESTIONS.map((suggestion) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
+
+function AnswerView({ answer }: { answer: Answer }) {
+ if (typeof answer === "string" || typeof answer === "number") {
+ return {String(answer)}
;
+ }
+ if (!Array.isArray(answer) || answer.length === 0) {
+ return No matching documents.
;
+ }
+ return (
+
+ );
+}
diff --git a/web/lib/upstream.ts b/web/lib/upstream.ts
new file mode 100644
index 0000000..8a57f40
--- /dev/null
+++ b/web/lib/upstream.ts
@@ -0,0 +1,21 @@
+export function apiBase(): string {
+ return (process.env.API_URL || "http://127.0.0.1:8000").replace(/\/$/, "");
+}
+
+export async function proxy(path: string, init?: RequestInit): Promise {
+ const response = await fetch(`${apiBase()}${path}`, {
+ ...init,
+ headers: {
+ "content-type": "application/json",
+ ...(init?.headers ?? {}),
+ },
+ cache: "no-store",
+ });
+ const body = await response.text();
+ return new Response(body, {
+ status: response.status,
+ headers: {
+ "content-type": response.headers.get("content-type") || "application/json",
+ },
+ });
+}
diff --git a/web/next-env.d.ts b/web/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/web/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/web/next.config.ts b/web/next.config.ts
new file mode 100644
index 0000000..3ba0e42
--- /dev/null
+++ b/web/next.config.ts
@@ -0,0 +1,9 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {};
+
+if (process.env.DOCKER_BUILD === "1") {
+ nextConfig.output = "standalone";
+}
+
+export default nextConfig;
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..3463a73
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1001 @@
+{
+ "name": "agentops-web",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "agentops-web",
+ "dependencies": {
+ "next": "^15.5.4",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.18.6",
+ "@types/react": "^19.1.13",
+ "@types/react-dom": "^19.1.9",
+ "typescript": "^5.9.2"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
+ "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
+ "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
+ "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
+ "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
+ "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
+ "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
+ "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
+ "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
+ "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
+ "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
+ "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
+ "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
+ "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
+ "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
+ "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
+ "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
+ "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
+ "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
+ "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
+ "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
+ "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
+ "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
+ "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
+ "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "15.5.27",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz",
+ "integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "15.5.27",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz",
+ "integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "15.5.27",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz",
+ "integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "15.5.27",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz",
+ "integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "15.5.27",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz",
+ "integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "15.5.27",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz",
+ "integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "15.5.27",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz",
+ "integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "15.5.27",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz",
+ "integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "15.5.27",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz",
+ "integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
+ "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001816",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz",
+ "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "15.5.27",
+ "resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz",
+ "integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "15.5.27",
+ "@swc/helpers": "0.5.15",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "15.5.27",
+ "@next/swc-darwin-x64": "15.5.27",
+ "@next/swc-linux-arm64-gnu": "15.5.27",
+ "@next/swc-linux-arm64-musl": "15.5.27",
+ "@next/swc-linux-x64-gnu": "15.5.27",
+ "@next/swc-linux-x64-musl": "15.5.27",
+ "@next/swc-win32-arm64-msvc": "15.5.27",
+ "@next/swc-win32-x64-msvc": "15.5.27",
+ "sharp": "^0.34.3 || ^0.35.4"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
+ "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.5",
+ "@img/sharp-darwin-x64": "0.35.5",
+ "@img/sharp-freebsd-wasm32": "0.35.5",
+ "@img/sharp-libvips-darwin-arm64": "1.3.4",
+ "@img/sharp-libvips-darwin-x64": "1.3.4",
+ "@img/sharp-libvips-linux-arm": "1.3.4",
+ "@img/sharp-libvips-linux-arm64": "1.3.4",
+ "@img/sharp-libvips-linux-ppc64": "1.3.4",
+ "@img/sharp-libvips-linux-riscv64": "1.3.4",
+ "@img/sharp-libvips-linux-s390x": "1.3.4",
+ "@img/sharp-libvips-linux-x64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4",
+ "@img/sharp-linux-arm": "0.35.5",
+ "@img/sharp-linux-arm64": "0.35.5",
+ "@img/sharp-linux-ppc64": "0.35.5",
+ "@img/sharp-linux-riscv64": "0.35.5",
+ "@img/sharp-linux-s390x": "0.35.5",
+ "@img/sharp-linux-x64": "0.35.5",
+ "@img/sharp-linuxmusl-arm64": "0.35.5",
+ "@img/sharp-linuxmusl-x64": "0.35.5",
+ "@img/sharp-webcontainers-wasm32": "0.35.5",
+ "@img/sharp-win32-arm64": "0.35.5",
+ "@img/sharp-win32-ia32": "0.35.5",
+ "@img/sharp-win32-x64": "0.35.5"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..f852757
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "agentops-web",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start"
+ },
+ "dependencies": {
+ "next": "^15.5.4",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.18.6",
+ "@types/react": "^19.1.13",
+ "@types/react-dom": "^19.1.9",
+ "typescript": "^5.9.2"
+ }
+}
diff --git a/web/public/.gitkeep b/web/public/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..8b1f373
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/web/vercel.json b/web/vercel.json
new file mode 100644
index 0000000..f92a3f8
--- /dev/null
+++ b/web/vercel.json
@@ -0,0 +1,3 @@
+{
+ "framework": "nextjs"
+}