mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 16:56:42 +00:00
feat(agent): add test frontend and Docker stack
This commit is contained in:
@@ -5,10 +5,10 @@ Rollender Übergabe-Log für agent-Threads. Workflow: `.agents/SKILL.md`
|
|||||||
|
|
||||||
## Current focus
|
## Current focus
|
||||||
|
|
||||||
API-first-Festigung (D19): Der eigenständige Wissensdienst erhält einen
|
Der Docker-Compose-Stack (D21) für UI + API auf `100.103.83.12` ist lokal
|
||||||
versionierten, authentisierbaren v1-Vertrag und eine explizite Privacy-Grenze.
|
gebaut und validiert. Offen ist nur der Zielhost-Smoke-Test samt Verifikation
|
||||||
Als Nächstes folgt der dünne Odoo-Client; Lohndaten bleiben bis zum separaten
|
des tatsächlichen Ollama-DNS-Alias im externen Netz `ollama-default`; danach
|
||||||
tenant-autorisierten Datenvertrag außerhalb der Agenten-API.
|
kann der dünne Odoo-Client folgen.
|
||||||
|
|
||||||
## Completed (2026-09-14)
|
## 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
|
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
|
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**.
|
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
|
- **Bake-off-Protokoll** (Skill): Modellwechsel nur über dokumentierten
|
||||||
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
|
Goldset-Vergleich; Kriterium: Zitier-Präzision > Verweigerungs-
|
||||||
korrektheit > Latenz.
|
korrektheit > Latenz.
|
||||||
@@ -446,7 +467,10 @@ tenant-autorisierten Datenvertrag außerhalb der Agenten-API.
|
|||||||
- `.agents/skills/pv-rag-agent/SKILL.md` — verbindliche Regeln.
|
- `.agents/skills/pv-rag-agent/SKILL.md` — verbindliche Regeln.
|
||||||
- `agent/README.md` — Betrieb, Konfiguration, Host-Schritte.
|
- `agent/README.md` — Betrieb, Konfiguration, Host-Schritte.
|
||||||
- `docs/API.md` — v1-Vertrag, Authentisierung, Privacy-Grenze und Odoo-Aufruf.
|
- `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/eval/goldset.yaml` — Goldset (IDs gegen kb.json verifiziert).
|
||||||
- `agent/generate.py` — Grounding-Kern (Prompt, Post-Validierung).
|
- `agent/generate.py` — Grounding-Kern (Prompt, Post-Validierung).
|
||||||
- `wissensbasis/README.md` — Layer-2-Schema + Rechtsprechungs-Provenance.
|
- `wissensbasis/README.md` — Layer-2-Schema + Rechtsprechungs-Provenance.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -43,14 +43,39 @@ python -m agent.cli eval
|
|||||||
python -m agent.cli eval --answers --json-out data/eval-report.json
|
python -m agent.cli eval --answers --json-out data/eval-report.json
|
||||||
|
|
||||||
# 4) HTTP-API + Test-Chat
|
# 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
|
Der stabile v1-Vertrag, Bearer-Authentisierung, Fehlersemantik und die
|
||||||
Datenschutzgrenze für die spätere Odoo-Anbindung sind in `docs/API.md`
|
Datenschutzgrenze für die spätere Odoo-Anbindung sind in `docs/API.md`
|
||||||
dokumentiert. v1 ist zustandslos und akzeptiert keine Mandanten-, Mitarbeiter-
|
dokumentiert. v1 ist zustandslos und akzeptiert keine Mandanten-, Mitarbeiter-
|
||||||
oder Abrechnungsobjekte.
|
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)
|
## Konfiguration (Umgebungsvariablen)
|
||||||
|
|
||||||
| Variable | Default | Bedeutung |
|
| Variable | Default | Bedeutung |
|
||||||
@@ -216,7 +241,7 @@ agent/
|
|||||||
api.py FastAPI v1 (/v1/ask, /v1/health, /v1/reindex), Auth + Request-IDs
|
api.py FastAPI v1 (/v1/ask, /v1/health, /v1/reindex), Auth + Request-IDs
|
||||||
cli.py ingest | ask | eval | serve
|
cli.py ingest | ask | eval | serve
|
||||||
eval/ goldset.yaml + evaluate.py
|
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)
|
tools/ Intake + Registry (build_registry.py, ingest_sources.py)
|
||||||
tests/ 64 Tests (offline, Fake-Ollama)
|
tests/ 64 Tests (offline, Fake-Ollama)
|
||||||
data/ index.db (gitignored)
|
data/ index.db (gitignored)
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import secrets
|
|||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@@ -27,6 +29,8 @@ from .retrieve import Retriever
|
|||||||
API_VERSION = "v1"
|
API_VERSION = "v1"
|
||||||
DATA_SCOPE = "knowledge_base_only"
|
DATA_SCOPE = "knowledge_base_only"
|
||||||
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
_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__)
|
logger = logging.getLogger(__name__)
|
||||||
bearer = HTTPBearer(auto_error=False)
|
bearer = HTTPBearer(auto_error=False)
|
||||||
bearer_credentials = Depends(bearer)
|
bearer_credentials = Depends(bearer)
|
||||||
@@ -170,9 +174,37 @@ async def add_request_id(request: Request, call_next):
|
|||||||
request.state.request_id = request_id
|
request.state.request_id = request_id
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
response.headers["X-Request-ID"] = request_id
|
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
|
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:
|
def _request_id(request: Request) -> str:
|
||||||
return getattr(request.state, "request_id", uuid.uuid4().hex)
|
return getattr(request.state, "request_id", uuid.uuid4().hex)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,6 +21,7 @@ Aufbewahrung. Ein allgemeiner API-Key allein reicht dafür nicht aus.
|
|||||||
|
|
||||||
| Methode | Pfad | Auth | Zweck |
|
| Methode | Pfad | Auth | Zweck |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| `GET` | `/` | Eingabe im UI | Test-Frontend |
|
||||||
| `POST` | `/v1/ask` | Service-Key | belegte Wissensantwort |
|
| `POST` | `/v1/ask` | Service-Key | belegte Wissensantwort |
|
||||||
| `GET` | `/v1/health` | öffentlich | Readiness ohne interne Hostdetails |
|
| `GET` | `/v1/health` | öffentlich | Readiness ohne interne Hostdetails |
|
||||||
| `POST` | `/v1/reindex` | Admin-Key | Index nach KB-Änderung neu aufbauen |
|
| `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
|
Die bisherigen Pfade `/ask`, `/health` und `/reindex` bleiben vorläufig als
|
||||||
`deprecated` Kompatibilitätsrouten erhalten und liefern denselben Vertrag.
|
`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
|
## Authentisierung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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 <PV_API_KEY>' \
|
||||||
|
-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
|
||||||
|
```
|
||||||
@@ -357,4 +357,20 @@ tests/ # pytest: Ingest-, Retrieval-, Grounding-Unit-Tests
|
|||||||
getrennten tenant-autorisierten Vertrag; sie wird nicht durch Anhängen von
|
getrennten tenant-autorisierten Vertrag; sie wird nicht durch Anhängen von
|
||||||
Rohdaten an `/v1/ask` umgesetzt. Vertrag und Odoo-Clientregeln:
|
Rohdaten an `/v1/ask` umgesetzt. Vertrag und Odoo-Clientregeln:
|
||||||
`docs/API.md`.
|
`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`.
|
- Betrieb: `agent/README.md`.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi>=0.115
|
||||||
|
uvicorn>=0.30
|
||||||
|
httpx>=0.27
|
||||||
|
PyYAML>=6.0
|
||||||
|
numpy>=2.0
|
||||||
@@ -69,6 +69,32 @@ def configure_state(*, api_key: str = "", admin_key: str = "") -> None:
|
|||||||
app.state.rag.retriever = FakeRetriever()
|
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):
|
def test_nonlocal_bind_without_api_key_is_rejected(capsys):
|
||||||
class Args:
|
class Args:
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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();
|
||||||
+96
-64
@@ -1,69 +1,101 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta</head> name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>PV RAG Agent — Test-Chat</title>
|
<meta name="description" content="Testoberfläche für den PV RAG Agent">
|
||||||
<style>
|
<title>PV Wissen — Test-Chat</title>
|
||||||
:root { color-scheme: light dark; }
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
body { font-family: system-ui, sans-serif; max-width: 780px; margin: 2rem auto; padding: 0 1rem; }
|
<script src="/assets/app.js" defer></script>
|
||||||
h1 { font-size: 1.2rem; }
|
|
||||||
#log { display: flex; flex-direction: column; gap: 0.8rem; margin: 1.5rem 0; }
|
|
||||||
.msg { border: 1px solid rgba(128,128,128,.35); border-radius: 8px; padding: .7rem .9rem; white-space: pre-wrap; }
|
|
||||||
.q { background: rgba(128,128,128,.12); }
|
|
||||||
.src { font-size: .78rem; margin-top: .4rem; display: flex; flex-wrap: wrap; gap: .3rem; }
|
|
||||||
.chip { border-radius: 999px; padding: .1rem .5rem; background: rgba(128,128,128,.15); }
|
|
||||||
.warn { border-color: #c08020; }
|
|
||||||
form { display: flex; gap: .5rem; }
|
|
||||||
input { flex: 1; padding: .5rem; border-radius: 6px; border: 1px solid rgba(128,128,128,.5); }
|
|
||||||
button { padding: .5rem 1rem; border-radius: 6px; cursor: pointer; }
|
|
||||||
.meta { font-size: .75rem; opacity: .7; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>PV RAG Agent — Wissensbasis Personalverrechnung</h1>
|
<div class="shell">
|
||||||
<p class="meta">Antworten ausschließlich aus der kuratierten Wissensbasis, mit ID- und Stand-Beleg.</p>
|
<header class="topbar">
|
||||||
<div id="log"></div>
|
<a class="brand" href="/" aria-label="PV Wissen Startseite">
|
||||||
<form id="f">
|
<span class="brand-mark" aria-hidden="true">PV</span>
|
||||||
<input id="q" placeholder="Frage zur österreichischen Personalverrechnung …" autocomplete="off" required>
|
<span>
|
||||||
<button>Senden</button>
|
<strong>PV Wissen</strong>
|
||||||
</form>
|
<small>Agent für österreichische Personalverrechnung</small>
|
||||||
<script>
|
</span>
|
||||||
const log = document.getElementById("log");
|
</a>
|
||||||
function add(cls, html) {
|
<div id="health" class="health health-loading" role="status" aria-live="polite">
|
||||||
const d = document.createElement("div");
|
<span class="health-dot" aria-hidden="true"></span>
|
||||||
d.className = "msg " + cls;
|
<span>Verbindung wird geprüft</span>
|
||||||
d.innerHTML = html;
|
</div>
|
||||||
log.appendChild(d);
|
</header>
|
||||||
}
|
|
||||||
document.getElementById("f").addEventListener("submit", async (e) => {
|
<main>
|
||||||
e.preventDefault();
|
<section class="hero" aria-labelledby="page-title">
|
||||||
const q = document.getElementById("q").value.trim();
|
<p class="eyebrow">Belegt. Nachvollziehbar. Lokal.</p>
|
||||||
if (!q) return;
|
<h1 id="page-title">Was möchtest du zur Personalverrechnung wissen?</h1>
|
||||||
add("q", q);
|
<p>Antworten stammen ausschließlich aus der kuratierten Wissensbasis und führen ihre Quellen direkt an.</p>
|
||||||
document.getElementById("q").value = "";
|
</section>
|
||||||
try {
|
|
||||||
const r = await fetch("/ask", {
|
<section class="workspace">
|
||||||
method: "POST",
|
<aside class="settings" aria-labelledby="settings-title">
|
||||||
headers: { "Content-Type": "application/json" },
|
<div class="settings-heading">
|
||||||
body: JSON.stringify({ question: q }),
|
<h2 id="settings-title">Verbindung</h2>
|
||||||
});
|
<button id="settings-toggle" class="icon-button" type="button" aria-expanded="true" aria-controls="settings-body" title="Einstellungen ein- oder ausblenden">⚙</button>
|
||||||
if (!r.ok) {
|
</div>
|
||||||
add("warn", "Fehler " + r.status + ": " + (await r.text()));
|
<div id="settings-body">
|
||||||
return;
|
<label for="api-key">Service-Key</label>
|
||||||
}
|
<div class="key-row">
|
||||||
const d = await r.json();
|
<input id="api-key" type="password" autocomplete="off" spellcheck="false" placeholder="Bearer-Key, falls aktiviert">
|
||||||
const chips = (d.sources || []).map(s =>
|
<button id="key-visibility" class="secondary compact" type="button" aria-label="Service-Key anzeigen">Anzeigen</button>
|
||||||
`<span class="chip">${s.id} · ${s.stand || ""}</span>`).join("");
|
</div>
|
||||||
add(d.refused ? "warn" : "",
|
<label class="check-row" for="remember-key">
|
||||||
`${d.answer.replace(/</g, "<")}` +
|
<input id="remember-key" type="checkbox">
|
||||||
(d.sources && d.sources.length ? `<div class="src">${chips}</div>` : "") +
|
<span>Nur für diesen Tab merken</span>
|
||||||
`<div class="meta">Modell: ${d.model} · ${d.latency_ms} ms · ` +
|
</label>
|
||||||
`${d.verified ? "zitiergeprüft" : "UNVERIFIZIERT"}${d.refused ? " · verweigert" : ""}</div>`);
|
|
||||||
} catch (err) {
|
<label for="top-k">Kontextumfang</label>
|
||||||
add("warn", "Netzwerkfehler: " + err);
|
<select id="top-k">
|
||||||
}
|
<option value="">Automatisch</option>
|
||||||
});
|
<option value="6">6 Quellenblöcke</option>
|
||||||
</script>
|
<option value="8">8 Quellenblöcke</option>
|
||||||
|
<option value="12">12 Quellenblöcke</option>
|
||||||
|
<option value="16">16 Quellenblöcke</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="privacy-note">
|
||||||
|
<strong>Datenschutzgrenze</strong>
|
||||||
|
<span>Keine Namen, Personalnummern oder Lohndaten eingeben. Diese Testversion verarbeitet nur Fachfragen.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="chat" aria-label="Chat mit dem PV Agenten">
|
||||||
|
<div id="empty-state" class="empty-state">
|
||||||
|
<div class="empty-icon" aria-hidden="true">§</div>
|
||||||
|
<h2>Mit einer Fachfrage starten</h2>
|
||||||
|
<p>Zum Beispiel zu Reisekosten, Abgaben, Kollektivverträgen oder arbeitsrechtlichen Ansprüchen.</p>
|
||||||
|
<div class="suggestions" aria-label="Beispielfragen">
|
||||||
|
<button type="button" data-question="Ich will meinem Mitarbeiter 500 Euro zusätzlich auszahlen. Was ist die günstigste Lösung?">500 Euro zusätzlich auszahlen</button>
|
||||||
|
<button type="button" data-question="Wie hoch ist der steuerfreie Tagesgeldsatz bei einer Inlandsdienstreise?">Steuerfreies Tagesgeld</button>
|
||||||
|
<button type="button" data-question="Welche Voraussetzungen gelten für die Mitarbeiterprämie 2026?">Mitarbeiterprämie 2026</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="messages" class="messages" aria-live="polite" aria-label="Nachrichtenverlauf"></div>
|
||||||
|
|
||||||
|
<form id="ask-form" class="composer">
|
||||||
|
<label class="sr-only" for="question">Frage</label>
|
||||||
|
<textarea id="question" rows="3" maxlength="2000" required placeholder="Frage zur österreichischen Personalverrechnung …"></textarea>
|
||||||
|
<div class="composer-footer">
|
||||||
|
<span id="char-count" class="character-count">0 / 2000</span>
|
||||||
|
<div class="composer-actions">
|
||||||
|
<button id="cancel" class="secondary" type="button" hidden>Abbrechen</button>
|
||||||
|
<button id="submit" class="primary" type="submit">
|
||||||
|
<span>Frage senden</span>
|
||||||
|
<span class="send-icon" aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p class="footnote">Der Agent kann Fehler machen. Fachliche Entscheidungen anhand der angeführten Quellen prüfen.</p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user