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. + +![Desktop chat: a document hit and a cached calculation](docs/screenshots/chat-desktop.png) + +On a narrow screen the document form stacks above the conversation. + +![Mobile chat layout](docs/screenshots/chat-mobile.png) + +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 ( +
+
+
+

AgentOps

+

Ask the documents.

+
+
+ {offline && API unreachable} + {health && ( + <> + store {health.store} + queue {health.queue} + cache {health.cache} + + )} +
+
+ +
+
+

Add a document

+

+ Stored text is what search_docs can retrieve. Arithmetic skips the index and uses the calculator. +

+
+ +