From fc656188cf199cb4b1eeb59492b86ca0a24a6c47 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Wed, 16 Sep 2026 21:42:05 +0200 Subject: [PATCH] feat(agent): add test frontend and Docker stack --- .agents/MEMORY.md | 34 +++- .dockerignore | 21 +++ .env.example | 18 ++ Dockerfile | 18 ++ agent/README.md | 29 +++- agent/api.py | 32 ++++ compose.yaml | 62 +++++++ docs/API.md | 23 +++ docs/DOCKER.md | 130 +++++++++++++++ planung.md | 16 ++ requirements-runtime.txt | 5 + tests/test_api.py | 26 +++ tests/test_deployment.py | 47 ++++++ web/app.js | 345 +++++++++++++++++++++++++++++++++++++++ web/index.html | 160 ++++++++++-------- web/styles.css | 170 +++++++++++++++++++ 16 files changed, 1065 insertions(+), 71 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 docs/DOCKER.md create mode 100644 requirements-runtime.txt create mode 100644 tests/test_deployment.py create mode 100644 web/app.js create mode 100644 web/styles.css diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index 51f504c..d5737e9 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -5,10 +5,10 @@ Rollender Übergabe-Log für agent-Threads. Workflow: `.agents/SKILL.md` ## Current focus -API-first-Festigung (D19): Der eigenständige Wissensdienst erhält einen -versionierten, authentisierbaren v1-Vertrag und eine explizite Privacy-Grenze. -Als Nächstes folgt der dünne Odoo-Client; Lohndaten bleiben bis zum separaten -tenant-autorisierten Datenvertrag außerhalb der Agenten-API. +Der Docker-Compose-Stack (D21) für UI + API auf `100.103.83.12` ist lokal +gebaut und validiert. Offen ist nur der Zielhost-Smoke-Test samt Verifikation +des tatsächlichen Ollama-DNS-Alias im externen Netz `ollama-default`; danach +kann der dünne Odoo-Client folgen. ## Completed (2026-09-14) @@ -436,6 +436,27 @@ tenant-autorisierten Datenvertrag außerhalb der Agenten-API. ist durch `check_same_thread=False` plus `RLock` worker-thread-sicher. Neue Nicht-lokaler CLI-Bind ohne `PV_API_KEY` wird fail-closed abgelehnt. Neue API-/Concurrency-Tests erhöhen die Offline-Suite von 75 auf **82 grüne Tests**. +- **D20 (Test-Frontend, 2026-09-16):** FastAPI liefert unter `/` ein + dependency-freies, responsives Frontend aus `web/` aus. Es nutzt same-origin + `/v1/ask` und `/v1/health`, zeigt Status, Quellen, Konflikte, Rückfragen, + Suchplan und Grounding-Metadaten und hält den Verlauf nur im DOM. Ein + optional gemerkter Service-Key liegt ausschließlich im `sessionStorage` des + Tabs; kein Secret wird in die Assets injiziert. Modelltext wird ohne + `innerHTML` gerendert. CSP gilt gezielt für UI/Assets, damit `/docs` nutzbar + bleibt; globale Header setzen `nosniff`, `DENY` und `no-referrer`. Betrieb + auf dem Tailscale-Interface: `python -m agent.cli serve --host + 100.103.83.12` mit gesetztem `PV_API_KEY`; UI auf Port 8080. Tests 82 → 84. +- **D21 (Docker-Compose-Deployment, 2026-09-16):** `compose.yaml` baut einen + gemeinsamen UI-/API-Container, bindet Port 8080 nur an `100.103.83.12` und + tritt dem externen Netz `ollama-default` bei. Ollama wird nicht dupliziert; + `OLLAMA_URL` ist konfigurierbar (Beispiel `http://ollama:11434`, Alias auf + Zielhost noch zu verifizieren). `data/` ist schreibbar, `wissensbasis/` + read-only; Root-FS read-only, Capabilities entfernt, no-new-privileges, + unprivilegierter `PUID:PGID`, verpflichtender `PV_API_KEY`. `.dockerignore` + hält `.env`, Index, Layer-1-Korpora und KB aus dem Image; Runtime-Image nutzt + `requirements-runtime.txt`. Compose `config --quiet`, Image-Build und + read-only Import-/Asset-Smoke-Test bestanden. Deployment: `docs/DOCKER.md`. + Statische Deploymenttests erhöhen die Suite von 84 auf **87 Tests**. - **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs- korrektheit > Latenz. @@ -446,7 +467,10 @@ tenant-autorisierten Datenvertrag außerhalb der Agenten-API. - `.agents/skills/pv-rag-agent/SKILL.md` — verbindliche Regeln. - `agent/README.md` — Betrieb, Konfiguration, Host-Schritte. - `docs/API.md` — v1-Vertrag, Authentisierung, Privacy-Grenze und Odoo-Aufruf. -- `agent/api.py` — versionierte Service-Oberfläche und Sicherheitsgrenzen. +- `agent/api.py` — versionierte Service-Oberfläche, UI-Auslieferung und Sicherheitsgrenzen. +- `web/` — dependency-freies Test-Frontend. +- `compose.yaml`, `Dockerfile`, `.env.example`, `.dockerignore` — Zielhost-Stack. +- `docs/DOCKER.md` — Deployment, Indexaufbau und Smoke-Tests. - `agent/eval/goldset.yaml` — Goldset (IDs gegen kb.json verifiziert). - `agent/generate.py` — Grounding-Kern (Prompt, Post-Validierung). - `wissensbasis/README.md` — Layer-2-Schema + Rechtsprechungs-Provenance. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3c66a51 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.gitignore +.env +.env.* +!.env.example +.agents +.firecrawl +.lexis360 +.rechtsprechung +.wiku +.pytest_cache +.venv +__pycache__ +*.pyc +*.pyo +data +docs +tests +tools +wissensbasis +planung.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6339d90 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Laufzeit-UID/GID des Besitzers von ./data auf dem Docker-Host +PUID=1000 +PGID=1000 + +# Starker, zufälliger Service-Key; niemals diesen Beispielwert verwenden. +PV_API_KEY=CHANGE_ME +# Optional getrennt; leer bedeutet: PV_API_KEY auch für /v1/reindex verwenden. +PV_ADMIN_API_KEY= + +# DNS-Name und interner Port des Ollama-Containers im Netz ollama-default. +# Vor dem Start auf dem Zielhost mit `docker network inspect ollama-default` +# gegen den tatsächlichen Container-/Aliasnamen prüfen. +OLLAMA_URL=http://ollama:11434 + +PV_PORT=8080 +PV_ANSWER_MODEL=qwen3.8:27b +PV_EMBED_MODEL=bge-m3 +PV_THINK=false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d30729a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +COPY requirements-runtime.txt ./ +RUN python -m pip install --no-cache-dir -r requirements-runtime.txt + +COPY agent/ ./agent/ +COPY web/ ./web/ + +EXPOSE 8080 + +CMD ["python", "-m", "agent.cli", "serve", "--host", "0.0.0.0"] diff --git a/agent/README.md b/agent/README.md index 58d781c..4a6d9c3 100644 --- a/agent/README.md +++ b/agent/README.md @@ -43,14 +43,39 @@ python -m agent.cli eval python -m agent.cli eval --answers --json-out data/eval-report.json # 4) HTTP-API + Test-Chat -python -m agent.cli serve # http://127.0.0.1:8080 (/v1/ask, /v1/health, /v1/reindex) +python -m agent.cli serve # http://127.0.0.1:8080/ (UI + v1-API) + +# Zielhost im Tailscale-Netz (PV_API_KEY muss gesetzt sein) +python -m agent.cli serve --host 100.103.83.12 +# UI: http://100.103.83.12:8080/ ``` +Das dependency-freie Frontend unter `/` zeigt Health, Antwortstatus, Quellen, +Konflikte und Rückfragen. Der Service-Key wird nur im Browser eingegeben und +optional ausschließlich für den aktuellen Tab gespeichert. + Der stabile v1-Vertrag, Bearer-Authentisierung, Fehlersemantik und die Datenschutzgrenze für die spätere Odoo-Anbindung sind in `docs/API.md` dokumentiert. v1 ist zustandslos und akzeptiert keine Mandanten-, Mitarbeiter- oder Abrechnungsobjekte. +## Docker Compose auf `100.103.83.12` + +Der Stack in `compose.yaml` verbindet den Agenten mit dem bereits vorhandenen +externen Docker-Netz `ollama-default`; Ollama wird nicht dupliziert. Setup und +Smoke-Tests: `docs/DOCKER.md`. + +```bash +cp .env.example .env # API-Key und Ollama-DNS-Alias setzen + +docker compose build +docker compose up -d +``` + +UI: `http://100.103.83.12:8080/`. `data/` wird schreibbar und +`wissensbasis/` read-only eingebunden; weder Index noch lokale Quellkorpora +landen im Image. + ## Konfiguration (Umgebungsvariablen) | Variable | Default | Bedeutung | @@ -216,7 +241,7 @@ agent/ api.py FastAPI v1 (/v1/ask, /v1/health, /v1/reindex), Auth + Request-IDs cli.py ingest | ask | eval | serve eval/ goldset.yaml + evaluate.py -web/index.html Minimaler Test-Chat +web/ Same-origin Test-Frontend (HTML, CSS, JavaScript) tools/ Intake + Registry (build_registry.py, ingest_sources.py) tests/ 64 Tests (offline, Fake-Ollama) data/ index.db (gitignored) diff --git a/agent/api.py b/agent/api.py index b1f1a4e..faf3c9d 100644 --- a/agent/api.py +++ b/agent/api.py @@ -12,9 +12,11 @@ import secrets import threading import uuid from contextlib import asynccontextmanager +from pathlib import Path from typing import Literal from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import FileResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, ConfigDict, Field @@ -27,6 +29,8 @@ from .retrieve import Retriever API_VERSION = "v1" DATA_SCOPE = "knowledge_base_only" _REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") +WEB_DIR = Path(__file__).resolve().parent.parent / "web" +_WEB_ASSETS = {"app.js", "styles.css"} logger = logging.getLogger(__name__) bearer = HTTPBearer(auto_error=False) bearer_credentials = Depends(bearer) @@ -170,9 +174,37 @@ async def add_request_id(request: Request, call_next): request.state.request_id = request_id response = await call_next(request) response.headers["X-Request-ID"] = request_id + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + if request.url.path == "/" or request.url.path.startswith("/assets/"): + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; base-uri 'none'; form-action 'self'; " + "frame-ancestors 'none'; img-src 'self' data:; " + "script-src 'self'; style-src 'self'; connect-src 'self'" + ) return response +@app.get("/", include_in_schema=False, response_class=FileResponse) +def frontend() -> FileResponse: + return FileResponse( + WEB_DIR / "index.html", + media_type="text/html", + headers={"Cache-Control": "no-store"}, + ) + + +@app.get("/assets/{asset_name}", include_in_schema=False, response_class=FileResponse) +def frontend_asset(asset_name: str) -> FileResponse: + if asset_name not in _WEB_ASSETS: + raise HTTPException(status_code=404, detail="Asset nicht gefunden.") + return FileResponse( + WEB_DIR / asset_name, + headers={"Cache-Control": "public, max-age=3600"}, + ) + + def _request_id(request: Request) -> str: return getattr(request.state, "request_id", uuid.uuid4().hex) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..76b0791 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,62 @@ +name: pv-agent + +services: + pv-agent: + build: + context: . + dockerfile: Dockerfile + image: pv-agent:local + restart: unless-stopped + init: true + user: "${PUID:-1000}:${PGID:-1000}" + ports: + - "100.103.83.12:${PV_PORT:-8080}:8080" + environment: + OLLAMA_URL: "${OLLAMA_URL:-http://ollama:11434}" + PV_ANSWER_MODEL: "${PV_ANSWER_MODEL:-qwen3.8:27b}" + PV_EMBED_MODEL: "${PV_EMBED_MODEL:-bge-m3}" + PV_DB_PATH: /app/data/index.db + PV_KB_DIR: /app/wissensbasis + PV_PORT: "8080" + PV_API_KEY: "${PV_API_KEY:?PV_API_KEY muss gesetzt sein}" + PV_ADMIN_API_KEY: "${PV_ADMIN_API_KEY:-}" + PV_THINK: "${PV_THINK:-false}" + PV_NUM_CTX: "${PV_NUM_CTX:-32768}" + PV_MAX_CONTEXT_CHARS: "${PV_MAX_CONTEXT_CHARS:-90000}" + PV_NUM_PREDICT: "${PV_NUM_PREDICT:-2048}" + PV_CANDIDATE_POOL: "${PV_CANDIDATE_POOL:-150}" + PV_RRF_K: "${PV_RRF_K:-20}" + PV_DENSE_WEIGHT: "${PV_DENSE_WEIGHT:-2.0}" + PV_CONTEXT_BLOCKS: "${PV_CONTEXT_BLOCKS:-8}" + PV_PER_QUERY_SLOTS: "${PV_PER_QUERY_SLOTS:-2}" + PV_SURVEY_BLOCKS: "${PV_SURVEY_BLOCKS:-16}" + volumes: + - ./data:/app/data + - ./wissensbasis:/app/wissensbasis:ro + networks: + - ollama-default + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + healthcheck: + test: + - CMD + - python + - -c + - >- + import json, urllib.request; + d=json.load(urllib.request.urlopen('http://127.0.0.1:8080/v1/health', timeout=5)); + raise SystemExit(0 if d.get('status') == 'ok' else 1) + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + +networks: + ollama-default: + external: true + name: ollama-default diff --git a/docs/API.md b/docs/API.md index 307c57e..9a8a2c5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -21,6 +21,7 @@ Aufbewahrung. Ein allgemeiner API-Key allein reicht dafür nicht aus. | Methode | Pfad | Auth | Zweck | |---|---|---|---| +| `GET` | `/` | Eingabe im UI | Test-Frontend | | `POST` | `/v1/ask` | Service-Key | belegte Wissensantwort | | `GET` | `/v1/health` | öffentlich | Readiness ohne interne Hostdetails | | `POST` | `/v1/reindex` | Admin-Key | Index nach KB-Änderung neu aufbauen | @@ -28,6 +29,28 @@ Aufbewahrung. Ein allgemeiner API-Key allein reicht dafür nicht aus. Die bisherigen Pfade `/ask`, `/health` und `/reindex` bleiben vorläufig als `deprecated` Kompatibilitätsrouten erhalten und liefern denselben Vertrag. +## Test-Frontend + +Das dependency-freie Frontend wird vom API-Prozess same-origin unter `/` +ausgeliefert. Dadurch sind keine CORS-Freigaben nötig. Es zeigt Dienststatus, +Antwort, zitierte Quellen, Quellenkonflikte, Rückfragen und technische +Grounding-Metadaten. Modellantworten werden nur als Text gerendert; HTML aus +einer Antwort wird nicht ausgeführt. + +Für den vorgesehenen Tailscale-Host: + +```bash +export PV_API_KEY='service-key-aus-secret-store' +python -m agent.cli serve --host 100.103.83.12 +``` + +Danach ist die Oberfläche unter `http://100.103.83.12:8080/` erreichbar. Der +Service-Key wird vom Benutzer im Frontend eingegeben und niemals serverseitig +in HTML oder JavaScript eingebettet. Optional speichert ihn die Oberfläche nur +im `sessionStorage` des aktuellen Browser-Tabs; Chatverlauf und Fragen werden +nicht im Browser gespeichert. Außerhalb eines verschlüsselten Tailnets ist vor +den Dienst ein TLS-Reverse-Proxy zu setzen. + ## Authentisierung ```bash diff --git a/docs/DOCKER.md b/docs/DOCKER.md new file mode 100644 index 0000000..f687fe1 --- /dev/null +++ b/docs/DOCKER.md @@ -0,0 +1,130 @@ +# Docker-Deployment auf `100.103.83.12` + +Der Compose-Stack betreibt Test-Frontend und FastAPI-Agent gemeinsam. Er startet +keinen zweiten Ollama-Container, sondern verbindet sich mit dem vorhandenen +externen Docker-Netz `ollama-default`. + +## Voraussetzungen + +Auf dem Zielhost müssen vorhanden sein: + +- Docker Engine mit Compose-Plugin; +- das externe Netz `ollama-default`; +- ein darin erreichbarer Ollama-Container; +- die Modelle `qwen3.8:27b` und `bge-m3` in dieser Ollama-Instanz; +- `wissensbasis/` und entweder ein vorhandenes `data/index.db` oder genügend + Zeit für den initialen Indexaufbau. + +Das Netz und seine Container/Aliase prüfen: + +```bash +docker network inspect ollama-default +``` + +Der Compose-Beispielwert nimmt den DNS-Namen `ollama` und den internen +Ollama-Port `11434` an. Das ist **nicht lokal verifiziert**, weil das Netz nur +auf dem Zielhost existiert. Falls der Container im Netz anders heißt, muss +`OLLAMA_URL` in `.env` entsprechend gesetzt werden, beispielsweise: + +```text +OLLAMA_URL=http://tatsaechlicher-containername:11434 +``` + +Die veröffentlichte Host-Portnummer `11435` ist innerhalb des gemeinsamen +Docker-Netzes normalerweise nicht relevant; Container sprechen den internen +Port des Ollama-Containers an. + +## Konfiguration + +```bash +cp .env.example .env +``` + +Dann `.env` anpassen: + +1. `PV_API_KEY` durch einen starken zufälligen Wert ersetzen; +2. optional einen getrennten `PV_ADMIN_API_KEY` setzen; +3. `OLLAMA_URL` anhand des Netzwerk-Alias prüfen; +4. `PUID`/`PGID` auf den Besitzer von `data/` setzen. + +`.env` ist gitignored und darf nicht committed werden. Compose verwendet die +Datei nur zur Interpolation der ausdrücklich in `compose.yaml` aufgelisteten +Variablen; sonstige lokale Secrets werden nicht pauschal in den Container +durchgereicht. + +## Start mit vorhandenem Index + +```bash +docker compose build +docker compose up -d +docker compose ps +docker compose logs --follow pv-agent +``` + +Aufruf im Tailscale-Netz: + +```text +http://100.103.83.12:8080/ +``` + +Im Frontend denselben Wert wie `PV_API_KEY` als Service-Key eingeben. + +## Initialen Index im Container bauen + +Falls `data/index.db` auf dem Zielhost noch fehlt: + +```bash +mkdir -p data +docker compose run --rm pv-agent python -m agent.cli ingest +``` + +Der Lauf verwendet `bge-m3` über `OLLAMA_URL` und schreibt den Index in das +Bind-Mount `./data`. Danach den Dienst starten: + +```bash +docker compose up -d +``` + +Der Prozess läuft als `PUID:PGID`. `data/` muss für diese IDs schreibbar sein, +insbesondere wenn `/v1/reindex` verwendet werden soll. + +## Smoke-Tests + +```bash +curl --fail http://100.103.83.12:8080/v1/health +curl --fail \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"question":"Wie hoch ist der steuerfreie Tagesgeldsatz?","mode":"knowledge"}' \ + http://100.103.83.12:8080/v1/ask +``` + +`/v1/health` muss `"status":"ok"` liefern. Ein Status `degraded` bedeutet in +der Regel, dass der Index fehlt oder Ollama unter dem konfigurierten +Container-DNS-Namen nicht erreichbar ist. + +## Sicherheitsprofil + +- Port `8080` wird nur an die Tailscale-Adresse `100.103.83.12` gebunden. +- `PV_API_KEY` ist für den Compose-Start verpflichtend. +- Root-Dateisystem ist read-only; nur `./data` ist schreibbar. +- Alle Linux-Capabilities werden entfernt; `no-new-privileges` ist aktiv. +- `wissensbasis/` wird read-only eingebunden. +- Frontend und API sind same-origin; es ist keine CORS-Freigabe nötig. +- Außerhalb des verschlüsselten Tailnets ist TLS vor dem Dienst erforderlich. + +## Aktualisierung + +```bash +git pull +docker compose build --pull +docker compose up -d +docker image prune +``` + +Nach Änderungen an der Wissensbasis: + +```bash +docker compose run --rm pv-agent python -m agent.cli ingest +docker compose up -d +``` diff --git a/planung.md b/planung.md index 3d10b62..6538389 100644 --- a/planung.md +++ b/planung.md @@ -357,4 +357,20 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests getrennten tenant-autorisierten Vertrag; sie wird nicht durch Anhängen von Rohdaten an `/v1/ask` umgesetzt. Vertrag und Odoo-Clientregeln: `docs/API.md`. +- **Test-Frontend (2026-09-16, D20):** FastAPI liefert ein responsives, + dependency-freies same-origin UI unter `/` aus. Es rendert Antworttext + XSS-sicher, zeigt Health, Quellen, Konflikte, Rückfragen sowie Grounding- + Metadaten und speichert weder Fragen noch Chatverlauf. Ein Service-Key wird + nur vom Benutzer eingegeben und optional im `sessionStorage` des Tabs + gehalten. Zielbetrieb ist `http://100.103.83.12:8080/` mit Bind an das + Tailscale-Interface und gesetztem `PV_API_KEY`; CSP und weitere Security- + Header schützen die UI, ohne FastAPI `/docs` zu blockieren. +- **Docker-Deployment (2026-09-16, D21):** Ein gehärteter Compose-Service + betreibt UI und API gemeinsam auf `100.103.83.12:8080` und nutzt das externe + Netz `ollama-default`. Ollama bleibt ein separater bestehender Container; + sein Netzwerk-DNS-Alias wird über `OLLAMA_URL` konfiguriert. Index und KB + bleiben Host-Bind-Mounts, Secrets und lokale Korpora außerhalb des Images. + Der Stack verlangt einen Service-Key, läuft als konfigurierbare unprivilegierte + UID/GID mit read-only Root-FS und besitzt einen Readiness-Healthcheck. + Deployment und initialer Indexaufbau: `docs/DOCKER.md`. - Betrieb: `agent/README.md`. diff --git a/requirements-runtime.txt b/requirements-runtime.txt new file mode 100644 index 0000000..04dd42e --- /dev/null +++ b/requirements-runtime.txt @@ -0,0 +1,5 @@ +fastapi>=0.115 +uvicorn>=0.30 +httpx>=0.27 +PyYAML>=6.0 +numpy>=2.0 diff --git a/tests/test_api.py b/tests/test_api.py index 9ca39ea..f8edf0d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -69,6 +69,32 @@ def configure_state(*, api_key: str = "", admin_key: str = "") -> None: app.state.rag.retriever = FakeRetriever() +def test_frontend_is_served_with_security_headers(): + with TestClient(app) as client: + configure_state(api_key="service-secret") + response = client.get("/") + assert response.status_code == 200 + assert "PV Wissen" in response.text + assert "service-secret" not in response.text + assert response.headers["cache-control"] == "no-store" + assert response.headers["x-frame-options"] == "DENY" + assert response.headers["x-content-type-options"] == "nosniff" + assert "script-src 'self'" in response.headers["content-security-policy"] + + +def test_frontend_assets_use_v1_api_and_unknown_assets_are_hidden(): + with TestClient(app) as client: + configure_state() + script = client.get("/assets/app.js") + styles = client.get("/assets/styles.css") + missing = client.get("/assets/index.html") + assert script.status_code == 200 + assert styles.status_code == 200 + assert 'fetch("/v1/ask"' in script.text + assert "innerHTML" not in script.text + assert missing.status_code == 404 + + def test_nonlocal_bind_without_api_key_is_rejected(capsys): class Args: host = "0.0.0.0" diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 0000000..85611c5 --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,47 @@ +"""Statische Schutztests für den Docker-Compose-Deploymentvertrag.""" +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def load_compose() -> dict: + return yaml.safe_load((ROOT / "compose.yaml").read_text(encoding="utf-8")) + + +def test_compose_uses_external_ollama_network_and_tailscale_bind(): + compose = load_compose() + service = compose["services"]["pv-agent"] + assert service["networks"] == ["ollama-default"] + assert compose["networks"]["ollama-default"] == { + "external": True, + "name": "ollama-default", + } + assert "100.103.83.12:${PV_PORT:-8080}:8080" in service["ports"] + assert service["environment"]["OLLAMA_URL"] == ( + "${OLLAMA_URL:-http://ollama:11434}" + ) + + +def test_compose_requires_auth_and_limits_container_privileges(): + service = load_compose()["services"]["pv-agent"] + assert ":?" in service["environment"]["PV_API_KEY"] + assert "env_file" not in service + assert service["read_only"] is True + assert service["security_opt"] == ["no-new-privileges:true"] + assert service["cap_drop"] == ["ALL"] + assert "./data:/app/data" in service["volumes"] + assert "./wissensbasis:/app/wissensbasis:ro" in service["volumes"] + + +def test_docker_context_excludes_secrets_and_runtime_data(): + ignored = set( + (ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines() + ) + assert {".env", "data", ".lexis360", ".rechtsprechung", "wissensbasis"} <= ignored + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + assert "requirements-runtime.txt" in dockerfile + assert "COPY agent/" in dockerfile + assert "COPY web/" in dockerfile + assert "COPY ." not in dockerfile diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..3e34dca --- /dev/null +++ b/web/app.js @@ -0,0 +1,345 @@ +"use strict"; + +const elements = { + form: document.getElementById("ask-form"), + question: document.getElementById("question"), + submit: document.getElementById("submit"), + cancel: document.getElementById("cancel"), + messages: document.getElementById("messages"), + empty: document.getElementById("empty-state"), + health: document.getElementById("health"), + key: document.getElementById("api-key"), + rememberKey: document.getElementById("remember-key"), + keyVisibility: document.getElementById("key-visibility"), + topK: document.getElementById("top-k"), + charCount: document.getElementById("char-count"), + settingsToggle: document.getElementById("settings-toggle"), + settingsBody: document.getElementById("settings-body"), +}; + +let activeController = null; +let loadingMessage = null; +let loadingTimer = null; +let loadingStartedAt = 0; + +function node(tag, className, text) { + const item = document.createElement(tag); + if (className) item.className = className; + if (text !== undefined && text !== null) item.textContent = text; + return item; +} + +function requestId() { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") { + return `web-${globalThis.crypto.randomUUID()}`; + } + return `web-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function apiHeaders() { + const headers = { + "Content-Type": "application/json", + "X-Request-ID": requestId(), + }; + const key = elements.key.value.trim(); + if (key) headers.Authorization = `Bearer ${key}`; + return headers; +} + +function updateCharacterCount() { + elements.charCount.textContent = `${elements.question.value.length} / 2000`; +} + +function scrollToLatest() { + elements.messages.lastElementChild?.scrollIntoView({ behavior: "smooth", block: "end" }); +} + +function addUserMessage(question) { + elements.empty.hidden = true; + const wrapper = node("article", "message message-user"); + wrapper.append(node("div", "message-label", "Deine Frage")); + wrapper.append(node("div", "message-body", question)); + elements.messages.append(wrapper); + scrollToLatest(); +} + +function addLoadingMessage() { + const wrapper = node("article", "message message-agent"); + const label = node("div", "message-label", "PV Agent"); + const body = node("div", "message-body"); + const row = node("div", "loading-row"); + row.append(node("span", "loader")); + const text = node("span", "loading-text", "Wissensbasis wird durchsucht … 0 s"); + row.append(text); + body.append(row); + wrapper.append(label, body); + elements.messages.append(wrapper); + loadingStartedAt = Date.now(); + loadingTimer = window.setInterval(() => { + const seconds = Math.round((Date.now() - loadingStartedAt) / 1000); + text.textContent = `Wissensbasis wird durchsucht … ${seconds} s`; + }, 1000); + loadingMessage = wrapper; + scrollToLatest(); +} + +function removeLoadingMessage() { + if (loadingTimer) window.clearInterval(loadingTimer); + loadingTimer = null; + loadingMessage?.remove(); + loadingMessage = null; +} + +function badge(text, variant = "") { + return node("span", `badge${variant ? ` badge-${variant}` : ""}`, text); +} + +function sourcePanel(sources) { + const details = node("details", "source-panel"); + const summary = node("summary", "", `${sources.length} zitierte ${sources.length === 1 ? "Quelle" : "Quellen"}`); + const list = node("div", "source-list"); + for (const source of sources) { + const card = node("div", "source-card"); + card.append(node("div", "source-id", source.id || "Unbekannte ID")); + card.append(node("div", "source-title", source.title || "Ohne Titel")); + const meta = [source.section, source.stand ? `Stand ${source.stand}` : null, source.work] + .filter(Boolean) + .join(" · "); + if (meta) card.append(node("div", "source-meta", meta)); + list.append(card); + } + details.append(summary, list); + return details; +} + +function conflictPanel(conflicts) { + const container = node("div"); + for (const conflict of conflicts) { + const box = node("div", "conflict-box"); + box.append(node("strong", "", `Quellenkonflikt · ${(conflict.source_ids || []).join(", ")}`)); + box.append(node("p", "", conflict.summary)); + container.append(box); + } + return container; +} + +function followUpPanel(question) { + const box = node("div", "follow-up"); + box.append(node("p", "", question)); + const button = node("button", "secondary", "Rückfrage übernehmen"); + button.type = "button"; + button.addEventListener("click", () => { + elements.question.value = question; + updateCharacterCount(); + elements.question.focus(); + }); + box.append(button); + return box; +} + +function technicalPanel(data) { + const details = node("details", "technical-panel"); + details.append(node("summary", "", "Technische Details")); + const list = node("ul", "query-list"); + const facts = [ + `Request-ID: ${data.request_id || "–"}`, + `Antworttyp: ${data.answer_type || "specific"}`, + `Kontextblöcke: ${data.grounding?.context_count ?? data.n_context ?? "–"}`, + `Regenerierungen: ${data.regenerations ?? 0}`, + `Datenumfang: ${data.grounding?.data_scope || "–"}`, + ]; + for (const fact of facts) list.append(node("li", "", fact)); + if (Array.isArray(data.planned_queries) && data.planned_queries.length) { + list.append(node("li", "", "Suchplan:")); + for (const query of data.planned_queries) { + const suffix = [query.scope, query.stand_year].filter(Boolean).join(", "); + list.append(node("li", "", `${query.text}${suffix ? ` (${suffix})` : ""}`)); + } + } + details.append(list); + return details; +} + +function addAgentMessage(data) { + const status = data.status || (data.verified ? (data.refused ? "refused" : "answered") : "uncertain"); + const wrapper = node("article", `message message-agent is-${status}`); + wrapper.append(node("div", "message-label", "PV Agent")); + const body = node("div", "message-body"); + const statusLine = node("div", "status-line"); + if (status === "answered") statusLine.append(badge("Zitiergeprüft")); + if (status === "refused") statusLine.append(badge("Nicht in der Wissensbasis", "warning")); + if (status === "uncertain") statusLine.append(badge("Nicht verlässlich belegt", "error")); + if (data.answer_type === "survey") statusLine.append(badge("Überblick")); + body.append(statusLine); + body.append(node("div", "answer-text", data.answer || "Keine Antwort erhalten.")); + + if (Array.isArray(data.conflicts) && data.conflicts.length) { + body.append(conflictPanel(data.conflicts)); + } + if (data.clarification_question) { + body.append(followUpPanel(data.clarification_question)); + } + if (Array.isArray(data.sources) && data.sources.length) { + body.append(sourcePanel(data.sources)); + } + + const seconds = typeof data.latency_ms === "number" ? `${(data.latency_ms / 1000).toFixed(1)} s` : "–"; + body.append(node("div", "answer-meta", `${data.model || "Modell"} · ${seconds} · ${data.citations?.length || 0} Zitate`)); + body.append(technicalPanel(data)); + wrapper.append(body); + elements.messages.append(wrapper); + scrollToLatest(); +} + +function addErrorMessage(message, requestIdValue = "") { + const wrapper = node("article", "message message-agent message-error"); + wrapper.append(node("div", "message-label", "Verbindungsfehler")); + const body = node("div", "message-body"); + body.append(badge("Anfrage fehlgeschlagen", "error")); + body.append(node("div", "answer-text", message)); + if (requestIdValue) body.append(node("div", "answer-meta", `Request-ID: ${requestIdValue}`)); + wrapper.append(body); + elements.messages.append(wrapper); + scrollToLatest(); +} + +async function errorDetail(response) { + try { + const payload = await response.json(); + if (typeof payload.detail === "string") return payload.detail; + if (Array.isArray(payload.detail)) return "Die Anfrage entspricht nicht dem API-Vertrag."; + } catch (_) { + // Absichtlich neutral: keine unstrukturierte Serverantwort in die UI übernehmen. + } + return `HTTP ${response.status}`; +} + +function setBusy(busy) { + elements.submit.disabled = busy; + elements.cancel.hidden = !busy; + elements.question.disabled = busy; +} + +async function ask(question) { + if (activeController) return; + addUserMessage(question); + addLoadingMessage(); + setBusy(true); + activeController = new AbortController(); + const topK = elements.topK.value ? Number(elements.topK.value) : undefined; + const payload = { question, mode: "knowledge" }; + if (topK) payload.top_k = topK; + + try { + const response = await fetch("/v1/ask", { + method: "POST", + headers: apiHeaders(), + body: JSON.stringify(payload), + signal: activeController.signal, + }); + const responseRequestId = response.headers.get("X-Request-ID") || ""; + removeLoadingMessage(); + if (!response.ok) { + const detail = await errorDetail(response); + if (response.status === 401) { + addErrorMessage("Service-Key fehlt oder ist ungültig. Bitte die Verbindungseinstellungen prüfen.", responseRequestId); + elements.key.focus(); + } else { + addErrorMessage(detail, responseRequestId); + } + return; + } + addAgentMessage(await response.json()); + } catch (error) { + removeLoadingMessage(); + if (error.name === "AbortError") { + addErrorMessage("Die Anzeige wurde abgebrochen. Die serverseitige Verarbeitung kann bereits begonnen haben."); + } else { + addErrorMessage("Der Wissensdienst ist nicht erreichbar. Netzwerk und Dienststatus prüfen."); + } + } finally { + activeController = null; + setBusy(false); + elements.question.focus(); + } +} + +async function checkHealth() { + elements.health.className = "health health-loading"; + try { + const response = await fetch("/v1/health", { headers: { "X-Request-ID": requestId() } }); + if (!response.ok) throw new Error("health request failed"); + const data = await response.json(); + const ok = data.status === "ok"; + elements.health.className = `health ${ok ? "health-ok" : "health-error"}`; + elements.health.lastElementChild.textContent = ok + ? `${data.index?.n_entries ?? "–"} Quellen · bereit` + : "Dienst eingeschränkt"; + if (data.authentication_enabled && !elements.key.value) { + elements.key.placeholder = "Service-Key erforderlich"; + } + } catch (_) { + elements.health.className = "health health-error"; + elements.health.lastElementChild.textContent = "Dienst nicht erreichbar"; + } +} + +elements.form.addEventListener("submit", (event) => { + event.preventDefault(); + const question = elements.question.value.trim(); + if (question.length < 3) return; + elements.question.value = ""; + updateCharacterCount(); + ask(question); +}); + +elements.question.addEventListener("input", updateCharacterCount); +elements.question.addEventListener("keydown", (event) => { + if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + elements.form.requestSubmit(); + } +}); + +elements.cancel.addEventListener("click", () => activeController?.abort()); + +document.querySelectorAll("[data-question]").forEach((button) => { + button.addEventListener("click", () => { + elements.question.value = button.dataset.question || ""; + updateCharacterCount(); + elements.question.focus(); + }); +}); + +elements.keyVisibility.addEventListener("click", () => { + const visible = elements.key.type === "text"; + elements.key.type = visible ? "password" : "text"; + elements.keyVisibility.textContent = visible ? "Anzeigen" : "Verbergen"; + elements.keyVisibility.setAttribute("aria-label", visible ? "Service-Key anzeigen" : "Service-Key verbergen"); +}); + +elements.rememberKey.addEventListener("change", () => { + if (elements.rememberKey.checked) { + sessionStorage.setItem("pv-api-key", elements.key.value); + } else { + sessionStorage.removeItem("pv-api-key"); + } +}); + +elements.key.addEventListener("input", () => { + if (elements.rememberKey.checked) sessionStorage.setItem("pv-api-key", elements.key.value); +}); + +elements.settingsToggle.addEventListener("click", () => { + const expanded = elements.settingsToggle.getAttribute("aria-expanded") === "true"; + elements.settingsToggle.setAttribute("aria-expanded", String(!expanded)); + elements.settingsBody.hidden = expanded; +}); + +const savedKey = sessionStorage.getItem("pv-api-key"); +if (savedKey) { + elements.key.value = savedKey; + elements.rememberKey.checked = true; +} +updateCharacterCount(); +checkHealth(); diff --git a/web/index.html b/web/index.html index ac556e1..537f95b 100644 --- a/web/index.html +++ b/web/index.html @@ -1,69 +1,101 @@ - + - - -PV RAG Agent — Test-Chat - + + name="viewport" content="width=device-width, initial-scale=1"> + + PV Wissen — Test-Chat + + -

PV RAG Agent — Wissensbasis Personalverrechnung

-

Antworten ausschließlich aus der kuratierten Wissensbasis, mit ID- und Stand-Beleg.

-
-
- - -
- +
+
+ + + + PV Wissen + Agent für österreichische Personalverrechnung + + +
+ + Verbindung wird geprüft +
+
+ +
+
+

Belegt. Nachvollziehbar. Lokal.

+

Was möchtest du zur Personalverrechnung wissen?

+

Antworten stammen ausschließlich aus der kuratierten Wissensbasis und führen ihre Quellen direkt an.

+
+ +
+ + +
+
+ +

Mit einer Fachfrage starten

+

Zum Beispiel zu Reisekosten, Abgaben, Kollektivverträgen oder arbeitsrechtlichen Ansprüchen.

+
+ + + +
+
+ +
+ +
+ + + +
+

Der Agent kann Fehler machen. Fachliche Entscheidungen anhand der angeführten Quellen prüfen.

+
+
+
+
- \ No newline at end of file + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..c2b0343 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,170 @@ +:root { + color-scheme: light; + --ink: #17211b; + --muted: #607066; + --paper: #f4f3ec; + --surface: #fffef9; + --line: #d9ddd6; + --green: #176b4d; + --green-dark: #0f4e38; + --green-soft: #e4f2ea; + --amber: #9a5a08; + --amber-soft: #fff4d9; + --red: #9b2c2c; + --red-soft: #fdeaea; + --shadow: 0 20px 60px rgba(34, 51, 40, 0.09); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + color: var(--ink); + background: + radial-gradient(circle at 15% -10%, rgba(63, 137, 103, 0.16), transparent 34rem), + linear-gradient(180deg, #fbfaf5 0%, var(--paper) 100%); +} + +button, input, select, textarea { font: inherit; } +button { color: inherit; } + +.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; } + +.topbar { + min-height: 80px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid rgba(23, 33, 27, 0.1); +} + +.brand { display: inline-flex; align-items: center; gap: 12px; color: inherit; text-decoration: none; } +.brand-mark { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: 12px; + color: white; + background: var(--green-dark); + font-family: Georgia, serif; + font-weight: 700; + letter-spacing: -0.04em; +} +.brand strong, .brand small { display: block; } +.brand strong { font-size: 1rem; letter-spacing: 0.01em; } +.brand small { margin-top: 2px; color: var(--muted); font-size: 0.76rem; } + +.health { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.82rem; } +.health-dot { width: 9px; height: 9px; border-radius: 50%; background: #a0aaa3; box-shadow: 0 0 0 4px rgba(160, 170, 163, 0.14); } +.health-ok .health-dot { background: #239666; box-shadow: 0 0 0 4px rgba(35, 150, 102, 0.14); } +.health-error .health-dot { background: #c4553e; box-shadow: 0 0 0 4px rgba(196, 85, 62, 0.14); } + +.hero { max-width: 780px; padding: 64px 0 38px; } +.eyebrow { margin: 0 0 12px; color: var(--green); font-size: 0.77rem; font-weight: 750; letter-spacing: 0.14em; text-transform: uppercase; } +h1 { max-width: 720px; margin: 0; font-family: Georgia, "Times New Roman", serif; font-size: clamp(2.25rem, 5vw, 4.25rem); font-weight: 500; line-height: 1.02; letter-spacing: -0.035em; } +.hero > p:last-child { max-width: 660px; margin: 22px 0 0; color: var(--muted); font-size: 1.04rem; line-height: 1.65; } + +.workspace { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 22px; align-items: start; padding-bottom: 64px; } +.settings, .chat { border: 1px solid var(--line); border-radius: 18px; background: rgba(255, 254, 249, 0.94); box-shadow: var(--shadow); } +.settings { padding: 18px; position: sticky; top: 18px; } +.settings-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } +.settings h2 { margin: 0; font-size: 0.94rem; } +.settings label:not(.check-row) { display: block; margin: 15px 0 6px; color: var(--muted); font-size: 0.74rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; } +.settings input[type="password"], .settings input[type="text"], .settings select { + width: 100%; min-width: 0; padding: 10px 11px; border: 1px solid var(--line); border-radius: 9px; color: var(--ink); background: white; +} +.key-row { display: flex; gap: 6px; } +.key-row input { flex: 1; } +.check-row { display: flex; align-items: center; gap: 8px; margin-top: 9px; color: var(--muted); font-size: 0.78rem; cursor: pointer; } +.check-row input { accent-color: var(--green); } +.icon-button { display: none; border: 0; background: transparent; cursor: pointer; } +.privacy-note { display: grid; gap: 5px; margin-top: 20px; padding: 12px; border-radius: 10px; color: #6f541b; background: var(--amber-soft); font-size: 0.76rem; line-height: 1.45; } + +.chat { min-height: 580px; overflow: hidden; } +.empty-state { display: grid; justify-items: center; padding: 58px 32px 42px; text-align: center; } +.empty-icon { display: grid; width: 52px; height: 52px; place-items: center; margin-bottom: 18px; border: 1px solid #bed3c6; border-radius: 16px; color: var(--green-dark); background: var(--green-soft); font-family: Georgia, serif; font-size: 1.6rem; } +.empty-state h2 { margin: 0; font-family: Georgia, serif; font-size: 1.55rem; font-weight: 500; } +.empty-state p { max-width: 540px; margin: 10px 0 22px; color: var(--muted); line-height: 1.55; } +.suggestions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; } +.suggestions button { padding: 9px 12px; border: 1px solid var(--line); border-radius: 999px; background: white; cursor: pointer; transition: border-color 0.15s, transform 0.15s; } +.suggestions button:hover { border-color: var(--green); transform: translateY(-1px); } + +.messages { display: flex; flex-direction: column; gap: 20px; padding: 30px 28px 8px; } +.message { display: grid; gap: 8px; } +.message-label { color: var(--muted); font-size: 0.72rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; } +.message-user { align-self: end; width: min(82%, 680px); } +.message-user .message-body { padding: 13px 16px; border-radius: 16px 16px 4px 16px; color: white; background: var(--green-dark); white-space: pre-wrap; line-height: 1.55; } +.message-agent .message-body { padding: 18px; border: 1px solid var(--line); border-radius: 4px 16px 16px 16px; background: white; } +.message-agent.is-refused .message-body { border-color: #e2c377; background: #fffbef; } +.message-agent.is-uncertain .message-body, .message-error .message-body { border-color: #e4aaaa; background: var(--red-soft); } +.answer-text { white-space: pre-wrap; line-height: 1.68; overflow-wrap: anywhere; } +.status-line { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 14px; } +.badge { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border-radius: 999px; color: var(--green-dark); background: var(--green-soft); font-size: 0.72rem; font-weight: 750; } +.badge-warning { color: #7b4c0b; background: var(--amber-soft); } +.badge-error { color: var(--red); background: var(--red-soft); } +.answer-meta { margin-top: 14px; color: var(--muted); font-size: 0.72rem; } + +.source-panel, .technical-panel { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 13px; } +details summary { color: var(--green-dark); font-size: 0.83rem; font-weight: 700; cursor: pointer; } +.source-list { display: grid; gap: 8px; margin-top: 10px; } +.source-card { padding: 10px 11px; border-radius: 9px; background: #f5f7f3; } +.source-id { color: var(--green-dark); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.75rem; font-weight: 800; } +.source-title { margin-top: 3px; font-size: 0.83rem; font-weight: 650; } +.source-meta { margin-top: 3px; color: var(--muted); font-size: 0.72rem; } +.conflict-box { margin-top: 14px; padding: 12px; border-left: 3px solid #d58a18; border-radius: 7px; background: var(--amber-soft); } +.conflict-box strong { display: block; margin-bottom: 5px; color: #744606; font-size: 0.78rem; } +.conflict-box p { margin: 0; color: #664d23; font-size: 0.82rem; line-height: 1.5; } +.follow-up { margin-top: 14px; padding: 12px; border-radius: 9px; background: var(--green-soft); } +.follow-up p { margin: 0 0 8px; font-size: 0.84rem; } +.query-list { margin: 8px 0 0; padding-left: 20px; color: var(--muted); font-size: 0.76rem; line-height: 1.5; } + +.loading-row { display: flex; align-items: center; gap: 10px; color: var(--muted); } +.loader { width: 18px; height: 18px; border: 2px solid #cbd3cd; border-top-color: var(--green); border-radius: 50%; animation: spin 0.8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +.composer { margin: 22px 20px 0; padding: 12px; border: 1px solid #cbd2cc; border-radius: 14px; background: white; box-shadow: 0 8px 30px rgba(38, 52, 43, 0.07); } +.composer:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(23, 107, 77, 0.1); } +.composer textarea { width: 100%; resize: vertical; border: 0; outline: 0; color: var(--ink); background: transparent; line-height: 1.5; } +.composer textarea::placeholder { color: #909a93; } +.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; } +.character-count { color: var(--muted); font-size: 0.69rem; } +.composer-actions { display: flex; gap: 8px; } +.primary, .secondary { min-height: 38px; padding: 8px 13px; border-radius: 9px; font-weight: 700; cursor: pointer; } +.primary { display: inline-flex; align-items: center; gap: 12px; border: 1px solid var(--green-dark); color: white; background: var(--green-dark); } +.primary:hover { background: var(--green); } +.primary:disabled { cursor: not-allowed; opacity: 0.55; } +.secondary { border: 1px solid var(--line); background: white; } +.secondary:hover { border-color: #9bac9f; } +.compact { min-height: auto; padding: 7px 9px; font-size: 0.72rem; } +.send-icon { font-size: 1.15rem; } +.footnote { margin: 12px 22px 20px; color: var(--muted); font-size: 0.7rem; text-align: center; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +[hidden] { display: none !important; } + +@media (max-width: 800px) { + .shell { width: min(100% - 20px, 720px); } + .topbar { min-height: 68px; } + .brand small { display: none; } + .health span:last-child { max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .hero { padding: 42px 4px 26px; } + .workspace { grid-template-columns: 1fr; } + .settings { position: static; padding: 13px 16px; } + .settings-heading { margin: 0; } + .icon-button { display: block; } + #settings-body { margin-top: 14px; } + .chat { min-height: 520px; } + .empty-state { padding: 42px 18px 30px; } + .messages { padding: 22px 14px 6px; } + .message-user { width: 92%; } + .composer { margin: 18px 10px 0; } + .footnote { margin-inline: 14px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } +}