This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.env
|
||||
web/node_modules
|
||||
web/dist
|
||||
data
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.zip
|
||||
59
.env.example
Normal file
59
.env.example
Normal file
@@ -0,0 +1,59 @@
|
||||
# Core
|
||||
HTTP_ADDR=:8080
|
||||
JWT_SECRET=change-me-to-a-long-random-secret
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=change-me
|
||||
|
||||
# Local data paths. Docker Compose overrides both to /data/... so the named volume remains persistent.
|
||||
SQLITE_PATH=./data/neuralhunt.db
|
||||
ARTIFACT_DIR=./data/artifacts
|
||||
# Optional absolute base URL. Leave empty to store relative /artifacts/... links.
|
||||
ARTIFACT_PUBLIC_BASE_URL=
|
||||
|
||||
# Runtime defaults. These are copied into SQLite on first start and can then be
|
||||
# changed in the Admin UI.
|
||||
DEFAULT_GUESS_MIN_INTERVAL_SEC=10
|
||||
DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC=11
|
||||
DEFAULT_TASK_RANGE_BITS=28
|
||||
DEFAULT_ACTIVE_TASK_COUNT=1
|
||||
DEFAULT_PRESENCE_TTL_SEC=35
|
||||
DEFAULT_MAX_NODES=2000
|
||||
DEFAULT_PUBLIC_SCORE_PRECISION=2
|
||||
|
||||
# RIFT full-art collection (default). For normal operation you only need these
|
||||
# two values. Create the global RIFT character anchor once from Admin → ARTIFACT
|
||||
# (or let the first winner create it as a fallback). Each task can then receive
|
||||
# its own JPEG/PNG style reference in Admin → TASK ACTIONS; the bundled style
|
||||
# reference is only the fallback for tasks without a custom style.
|
||||
ARTIFACT_MODEL=gpt-image-2
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# Optional OpenAI endpoint override. Normally leave this unchanged/omitted.
|
||||
OPENAI_BASE_URL=https://api.openai.com
|
||||
|
||||
# Advanced overrides only; the built-in preset already uses OpenAI, 1024x1536
|
||||
# portrait output, medium quality and a deterministic programmatic SVG card frame.
|
||||
# ARTIFACT_PRESET=raccoon_full_art_v1
|
||||
# ARTIFACT_PROVIDER=openai
|
||||
# ARTIFACT_WIDTH=1024
|
||||
# ARTIFACT_HEIGHT=1536
|
||||
# ARTIFACT_QUALITY=medium
|
||||
# ARTIFACT_HTTP_TIMEOUT=4m
|
||||
# ARTIFACT_PROMPT=... # only used by legacy preset/providers
|
||||
# ARTIFACT_NEGATIVE_PROMPT=... # only used by legacy/local providers
|
||||
|
||||
# ComfyUI local API. COMFYUI_WORKFLOW_PATH must point to a workflow exported in
|
||||
# API format. The workflow can use placeholders documented in README.md.
|
||||
# When Neural Hunt runs in Docker and ComfyUI runs on the host, use
|
||||
# http://host.docker.internal:8188 and mount/copy the workflow into /data.
|
||||
COMFYUI_URL=
|
||||
COMFYUI_WORKFLOW_PATH=
|
||||
COMFYUI_POLL_TIMEOUT=4m
|
||||
|
||||
# AUTOMATIC1111 Stable Diffusion WebUI API. Start A1111 with --api.
|
||||
# Docker-to-host example: http://host.docker.internal:7860
|
||||
A1111_URL=
|
||||
A1111_USER=
|
||||
A1111_PASSWORD=
|
||||
A1111_SAMPLER=
|
||||
A1111_CFG_SCALE=7
|
||||
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
name: release-tag
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
jobs:
|
||||
release-image:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_ORG: sendnrw
|
||||
DOCKER_LATEST: latest
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with: # replace it with your local IP
|
||||
config-inline: |
|
||||
[registry."git.send.nrw"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: git.send.nrw # replace it with your local IP
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Get Meta
|
||||
id: meta
|
||||
run: |
|
||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
web/node_modules
|
||||
web/dist
|
||||
data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.log
|
||||
.DS_Store
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY cmd ./cmd
|
||||
COPY internal ./internal
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt ./cmd/server \
|
||||
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/neuralhunt-client ./cmd/client
|
||||
|
||||
FROM alpine:3.24
|
||||
RUN adduser -D -H app && mkdir -p /data /app && chown -R app:app /data /app
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/neuralhunt /app/neuralhunt
|
||||
COPY --from=build /out/neuralhunt-client /app/neuralhunt-client
|
||||
USER app
|
||||
ENV SQLITE_PATH=/data/neuralhunt.db \
|
||||
ARTIFACT_DIR=/data/artifacts
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/neuralhunt"]
|
||||
17
Makefile
Normal file
17
Makefile
Normal file
@@ -0,0 +1,17 @@
|
||||
.PHONY: dev local client build test
|
||||
|
||||
dev:
|
||||
docker compose up --build
|
||||
|
||||
local:
|
||||
go run ./cmd/server
|
||||
|
||||
client:
|
||||
go run ./cmd/client
|
||||
|
||||
build:
|
||||
go build -o neuralhunt ./cmd/server
|
||||
go build -o neuralhunt-client ./cmd/client
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
631
README.md
631
README.md
@@ -1,2 +1,631 @@
|
||||
# neural-hunt
|
||||
# Neural Hunt — V2.8 RIFT Task-Style Collection
|
||||
|
||||
|
||||
|
||||
> **V2.8 RIFT Task-Styles:** RIFT-Identität und Rendering-Stil sind jetzt sauber getrennt. `/data/artifacts/_collection/character_anchor.png` ist ein globaler, neutraler Identity-Lock für den Waschbären RIFT und kann im Admin-Tab **ARTIFACT** einmalig manuell erzeugt und geprüft werden. Jeder Task kann im Admin-Tab **TASK ACTIONS** ein eigenes JPEG-/PNG-Style-Referenzbild erhalten; Nutzer sehen dieses Stylebild bereits in der Task-Auswahl und wählen damit indirekt die gewünschte NFT-Art. Bei jeder RIFT-Karte sendet Neural Hunt **Image 1 = Character Anchor** und **Image 2 = Task Style Reference** an die Images Edit API. Ohne eigenen Task-Style bleibt `internal/artifact/assets/style_reference.jpg` nur noch der Default-Fallback. Folge-Tasks erben ihren Style. `medium` bleibt Standard und die OpenAI-Usage-/Kosten-KPIs aus V2.7 bleiben erhalten.
|
||||
|
||||
|
||||
> **V2.5.2 Hotfix:** Der 3-Sekunden-Telemetrie-Poll hat in V2.5.1 den kompletten Control-Plane-DOM neu aufgebaut. Dadurch wurden native `<select>`-Dropdowns geschlossen und Cursor-/Scrollzustände in Eingabefeldern zerstört, obwohl die Werte über Drafts erhalten blieben. V2.5.2 trennt Telemetrie-Refresh und Formular-Rendering: Overview, Performance, Taskliste und Map aktualisieren sich weiterhin live, die Formular-Controls werden aber nur noch bei explizitem Tab-/Taskwechsel oder nach einer Admin-Aktion neu aufgebaut. Offene Dropdowns und laufende Eingaben bleiben damit stabil.
|
||||
|
||||
> **V2.5.1 Hotfix:** Ein WebSocket-Heartbeat-Fehler in V2.5 konnte Browser-Verbindungen sehr regelmäßig nach ungefähr 90 Sekunden beenden. Der Server hatte zwar eine Pong-Deadline gesetzt, aber nur ein JSON-`ping` statt eines echten WebSocket-Control-Pings gesendet. Danach fehlte die Presence-Lease und der nächste Tipp erhielt HTTP 409, bis F5 eine neue Verbindung öffnete. V2.5.1 sendet echte Ping-Frames, schützt Reconnects mit generationsgebundenen Presence-Leases und verbindet Browser/Shell automatisch neu. HTTP-409-Antworten besitzen zusätzlich maschinenlesbare Fehlercodes und werden clientseitig selbstständig resynchronisiert.
|
||||
|
||||
> **V2.5:** Nutzer wählen ihren Task auf einer eigenen Landing-Page und können jederzeit wechseln. Jeder Task besitzt eigene NFT-Prompt-Anweisungen, Anzeigename und Beschreibung; sein Folge-Task erbt diese Konfiguration. Admin-Formulare behalten ungespeicherte Eingaben auch bei Auto-Refresh und Browser-Reload. Zusätzlich gibt es mit `cmd/client` einen vollwertigen signierten Shell-Client für unbeaufsichtigten Betrieb.
|
||||
>
|
||||
> Die **V2.4-SCALE-Architektur** bleibt erhalten: Verlierende Tipps bleiben im RAM, Map-Updates werden gebatcht, WebSockets schreiben asynchron und Map-Snapshots sind serverseitig begrenzt. Bitte weiterhin in ein **neues/leeres Verzeichnis** entpacken.
|
||||
|
||||
|
||||
Neural Hunt ist eine eigenständige Go-Webanwendung für das beschriebene soziale Wahrscheinlichkeitsexperiment. Sie benötigt für den Normalbetrieb **nur das Go-Binary und SQLite über `modernc.org/sqlite`**. Es gibt kein PostgreSQL, Redis, S3/MinIO und keinen separaten Frontend-Build.
|
||||
|
||||
Die Weboberfläche liegt unter `internal/webui/dist/` und wird mit `//go:embed` in das Binary eingebettet. Der Server lädt beim lokalen Start automatisch eine vorhandene `.env` (bereits gesetzte Prozess-Umgebungsvariablen haben Vorrang), daher reicht `go run ./cmd/server` für Backend, Client-UI, Admin-UI und öffentliches Echtzeit-Leaderboard.
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# JWT_SECRET, ADMIN_PASSWORD und OPENAI_API_KEY setzen.
|
||||
# ARTIFACT_MODEL nur ändern, wenn du bewusst ein anderes GPT-Image-Modell nutzen willst.
|
||||
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
oder:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Danach:
|
||||
|
||||
- Client: `http://localhost:8080/`
|
||||
- Echtzeit-Leaderboard: `http://localhost:8080/leaderboard`
|
||||
- Admin: `http://localhost:8080/admin`
|
||||
|
||||
|
||||
## V2.5: Task-Landing-Page und Task-Serien
|
||||
|
||||
Der Browser startet nach der Anmeldung nicht mehr stillschweigend in einem zufällig zugewiesenen Task. Stattdessen erscheint eine responsive **Task-Landing-Page** mit allen aktiven Tasks. Jede Karte zeigt Anzeigename, Beschreibung, Zahlenraum, Teilnehmerzahl, eigenen Score/Rank und Pause-Status. Ein Klick wählt den Task aus; über **TASKS** kann der Client die laufende Session sauber verlassen und später auf einen anderen aktiven Task wechseln.
|
||||
|
||||
Die Auswahl wird serverseitig in SQLite an die kryptografische Client-ID gebunden. Wird dieselbe Identität exportiert und auf einem anderen Gerät importiert, bleibt damit auch die zuletzt gewählte Task-Serie erhalten. Die Regel **eine Identity = eine aktive WebSocket-Verbindung** gilt weiterhin.
|
||||
|
||||
Wenn ein Task gewonnen oder administrativ geschlossen wird, erzeugt Neural Hunt genau einen Folge-Task. Dieser erbt vom Vorgänger:
|
||||
|
||||
- Zahlenraum (`range_bits`),
|
||||
- task-spezifisches Server- und Client-Tippintervall,
|
||||
- Anzeigename,
|
||||
- Landing-Page-Beschreibung,
|
||||
- NFT-Prompt-Anweisungen,
|
||||
- task-spezifischen Negative Prompt,
|
||||
- NFT-Style-Referenz.
|
||||
|
||||
`paused` wird absichtlich nicht vererbt: Ein neuer Folge-Task startet aktiv. Bestehende Client-Auswahlen des Vorgängers werden atomar auf den Folge-Task verschoben.
|
||||
|
||||
### Task-spezifische NFT-Prompts
|
||||
|
||||
Im Admin-Tab **TASK** besitzt jeder Task jetzt einen eigenen Bereich **TASK-KONFIGURATION & NFT-PROMPT**. Der globale Artifact-Prompt bleibt die stilistische Basis; die Task-Anweisungen werden anschließend angefügt. Damit kann beispielsweise eine Serie ihren eigenen visuellen Stil oder ihr eigenes Motiv erhalten, ohne die globale Provider-Konfiguration zu verändern.
|
||||
|
||||
Beispiel für einen Task:
|
||||
|
||||
```text
|
||||
Anzeigename: Aurora Vault
|
||||
Beschreibung: 48-Bit-Raum, Gewinner erhält die Aurora-Serie.
|
||||
|
||||
NFT-Prompt-Anweisungen:
|
||||
Create a crystalline aurora data-vault, radial neural filaments,
|
||||
no readable words, emphasize the winner as a singular luminous core.
|
||||
|
||||
Negative Prompt:
|
||||
text, watermark, logo, duplicated core, low detail
|
||||
```
|
||||
|
||||
Der Folge-Task übernimmt diese Werte automatisch. OpenAI verwendet die positiven Task-Anweisungen zusammen mit dem globalen Prompt; ComfyUI und A1111 erhalten zusätzlich den kombinierten globalen + task-spezifischen Negative Prompt.
|
||||
|
||||
### Admin-Entwürfe überleben Refresh
|
||||
|
||||
Die dynamischen Admin-Formulare speichern ungespeicherte Eingaben lokal als Draft. Das betrifft Runtime-/Artifact-Settings, Task-Konfiguration und die Felder für geplante Aktionen. Der 3-Sekunden-Auto-Refresh und ein normaler Browser-Reload überschreiben diese Eingaben nicht mehr. Nach erfolgreichem Speichern wird nur der zugehörige Draft-Bereich gelöscht.
|
||||
|
||||
## Shell-Client
|
||||
|
||||
`cmd/client` ist ein eigenständiger Neural-Hunt-Client ohne Browser. Er verwendet dieselbe P-256-Identität, dieselbe Challenge/Login-Signatur, denselben deterministischen Guess und denselben WebSocket-Presence-Mechanismus wie die Weboberfläche.
|
||||
|
||||
Interaktiv starten:
|
||||
|
||||
```bash
|
||||
go run ./cmd/client -url http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
Beim ersten Start wird standardmäßig `~/.neuralhunt/identity.json` mit Dateirechten `0600` erzeugt. Danach zeigt die Shell eine Task-Auswahl ähnlich der Browser-Landing-Page.
|
||||
|
||||
Wichtige Befehle:
|
||||
|
||||
```text
|
||||
tasks aktive Tasks + eigener Score/Rank
|
||||
use <nr|id|name> Task wechseln
|
||||
status aktueller Task, Score, Rank, Sequence, Wins
|
||||
map [n] textuelles TARGET FIELD nach Nähe-Zonen
|
||||
leaderboard [n] Echtzeit-Leaderboard abrufen
|
||||
leaderboard watch Leaderboard alle 5 Sekunden anzeigen
|
||||
leaderboard stop Watch beenden
|
||||
nfts [n] Winner-Artefakte mit Wasserzeichen-URLs
|
||||
nft get <task-id> <datei> öffentliche Wasserzeichen-Preview speichern
|
||||
identity Client-ID + lokale Identity-Datei
|
||||
identity export <datei> browser-kompatibler verschlüsselter Export
|
||||
quit
|
||||
```
|
||||
|
||||
Für `tmux`, `screen`, systemd oder einen Server ohne Vordergrund-Browser:
|
||||
|
||||
```bash
|
||||
go run ./cmd/client \
|
||||
-url https://hunt.example.org \
|
||||
-non-interactive \
|
||||
-task "Aurora Vault" \
|
||||
-max-nodes 250
|
||||
```
|
||||
|
||||
Im Non-Interactive-Modus bleibt der WebSocket offen, sendet automatisch im vom Task vorgegebenen Intervall signierte Tipps und folgt einem abgeschlossenen Task automatisch auf dessen Nachfolger. `SIGINT`/`SIGTERM` beendet sauber.
|
||||
|
||||
Für eine feste Binary:
|
||||
|
||||
```bash
|
||||
go build -trimpath -o neuralhunt-client ./cmd/client
|
||||
./neuralhunt-client -url https://hunt.example.org -non-interactive -task "Aurora Vault"
|
||||
```
|
||||
|
||||
Ein minimales systemd-Beispiel:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Neural Hunt Shell Client
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/opt/neuralhunt/neuralhunt-client -url https://hunt.example.org -non-interactive -task "Aurora Vault"
|
||||
Environment=NEURALHUNT_IDENTITY=/var/lib/neuralhunt-client/identity.json
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=neuralhunt
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
### Browser-Identität im Terminal verwenden
|
||||
|
||||
Die Browseroberfläche exportiert die Identität verschlüsselt mit PBKDF2-HMAC-SHA256 (250.000 Iterationen) + AES-256-GCM. Der Shell-Client versteht exakt dieses Format:
|
||||
|
||||
```bash
|
||||
export NEURALHUNT_IDENTITY_PASSPHRASE='ein-langes-passwort'
|
||||
go run ./cmd/client \
|
||||
-import ./neuralhunt-identity-abc123.json \
|
||||
-identity ~/.neuralhunt/identity.json
|
||||
```
|
||||
|
||||
Um die Terminal-Identity wieder browser-kompatibel zu exportieren:
|
||||
|
||||
```bash
|
||||
export NEURALHUNT_IDENTITY_PASSPHRASE='ein-langes-passwort'
|
||||
go run ./cmd/client \
|
||||
-identity ~/.neuralhunt/identity.json \
|
||||
-export ./neuralhunt-browser-import.json
|
||||
```
|
||||
|
||||
**Nicht dieselbe Identity gleichzeitig im Browser und im Shell-Client verbinden.** Das ist absichtlich durch die Single-Connection-Regel gesperrt. Unterschiedliche Identities dürfen natürlich vom selben Rechner bzw. derselben IP verbunden sein.
|
||||
|
||||
Unterstützte Shell-Parameter:
|
||||
|
||||
```text
|
||||
-url Server-URL (oder NEURALHUNT_URL)
|
||||
-identity persistente Raw-Identity (oder NEURALHUNT_IDENTITY)
|
||||
-task Start-Task per Nummer, ID-Präfix oder Anzeigename
|
||||
-max-nodes Größe des lokalen Target-Field-Working-Sets
|
||||
-non-interactive unbeaufsichtigter Betrieb
|
||||
-quiet weniger Konsolenausgabe
|
||||
-import Browser-/Terminal-Identity importieren
|
||||
-export verschlüsselten Browser-Export schreiben und beenden
|
||||
-passphrase Import/Export-Passphrase; bevorzugt ENV verwenden
|
||||
```
|
||||
|
||||
## Faire signierte Tipps
|
||||
|
||||
Der Browser darf den Tipp nicht frei wählen. Die Zahl ist deterministisch:
|
||||
|
||||
```text
|
||||
SHA-256(task_id | public_seed | client_id | sequence) mod 2^range_bits
|
||||
```
|
||||
|
||||
Danach signiert der Browser:
|
||||
|
||||
```text
|
||||
guess|task_id|sequence|guess
|
||||
```
|
||||
|
||||
mit seinem persistenten P-256-Schlüssel. Der Server berechnet den erwarteten Guess erneut und prüft die Signatur. Standardmäßig akzeptiert der Server höchstens einen Tipp alle 10 Sekunden; der Browser sendet alle 11 Sekunden.
|
||||
|
||||
Falsche Roh-Tipps und deren Signaturen werden **nicht gespeichert**. In V2.4 erzeugt ein akzeptierter Tipp, der den persönlichen Best-Score nicht verbessert, sogar **gar keinen SQLite-Write und gar kein WebSocket-Map-Event**. `next_seq`, Rate-Limit, Guess-Zähler und Bestwert liegen auf dem Hotpath im RAM. Erst eine Score-Verbesserung checkpointet den aggregierten Zustand in SQLite; beim Gewinner bleiben zusätzlich der korrekte Guess und dessen Signatur erhalten.
|
||||
|
||||
## TARGET FIELD — lesbare 3D-Nähe
|
||||
|
||||
Die vorherige Visualisierung hatte ein grundsätzliches Wahrnehmungsproblem: Eine echte 3D-Kugel um den Task kann in der 2D-Projektion einen weit entfernten Punkt scheinbar direkt neben dem Task zeigen, wenn er entlang der Kameratiefe liegt. Damit war „wer ist wirklich näher?“ trotz korrekter Daten nicht zuverlässig ablesbar.
|
||||
|
||||
V2.3 verwendet deshalb standardmäßig ein **3D-TARGET-FIELD mit unverzerrter Score-Ebene**. Der Score bestimmt ausschließlich den sichtbaren Orbitalradius; die Client-ID bestimmt nur den Winkel. Die dritte Dimension wird über Tiefensortierung, Perspektivgröße, Helligkeit und Vorder-/Rückseiten-Cues codiert, verschiebt den Node aber nicht radial. Dadurch kann Kameratiefe die Nähe nicht mehr optisch umkehren. Die Ringe und Nodes benutzen exakt dieselbe Radiusfunktion:
|
||||
|
||||
```text
|
||||
kleinerer Ringradius = höherer Score = näher am Task
|
||||
```
|
||||
|
||||
Die Wahrnehmungsfunktion ist bewusst im Bereich 90–100 gedehnt:
|
||||
|
||||
```text
|
||||
field radius = 0.045 + 0.955 * (1 - score/100)^0.38
|
||||
```
|
||||
|
||||
Damit sind z. B. 90, 95, 99 und 99.9 noch klar getrennt. Zusätzlich:
|
||||
|
||||
- Score-Ringe bei `0 / 25 / 50 / 75 / 90 / 95 / 99+`,
|
||||
- Vorder-/Rückseite der Ringe mit unterschiedlicher Intensität als 3D-Tiefencue,
|
||||
- Top-10 und eigene ID mit Score-Label,
|
||||
- eigene ID mit weißem Doppelring,
|
||||
- Kandidaten ab 95 mit zusätzlichem Nähe-Ring,
|
||||
- Hover zeigt Score, Rank und eine verständliche Distanzzone,
|
||||
- `TARGET RADAR` zeigt die besten Kandidaten zusätzlich numerisch,
|
||||
- `TARGET FIELD` kann weiterhin auf `RAW 3D` umgeschaltet werden, um die serverseitigen Rohkoordinaten zu inspizieren.
|
||||
|
||||
### Keine erfundenen Synapsen mehr
|
||||
|
||||
Die alte Option `SYNAPSEN` verband Clients pseudozufällig miteinander. Dafür gibt es im Experiment keine fachliche Beziehung und die Linien erzeugten nur visuelles Rauschen. Diese Darstellung wurde entfernt.
|
||||
|
||||
`SIGNALWEGE` zeichnen jetzt nur noch **Client → Task**:
|
||||
|
||||
- nur Top-Kandidaten und der eigene Client erhalten einen Pfad,
|
||||
- höhere Scores machen den Pfad deutlicher,
|
||||
- unter 90 werden Pfade gestrichelt,
|
||||
- wenn ein Client seinen Best-Score verbessert, laufen drei kurze Signalimpulse entlang genau dieses Pfades zum Task,
|
||||
- es gibt keine zufälligen Ambient-Partikel und keine Client-zu-Client-Kanten mehr.
|
||||
|
||||
Der gleiche Renderer wird im Admin-Bereich verwendet.
|
||||
|
||||
## Mobile-Modus
|
||||
|
||||
Neural Hunt aktiviert auf kleinen Displays bzw. Geräten mit grobem Pointer automatisch einen Mobile-Modus. Er kann über den Button **MOBILE** auch manuell an- und ausgeschaltet werden; die Auswahl wird im Browser gespeichert.
|
||||
|
||||
Im Client reduziert Mobile Mode die Renderlast automatisch (ECO, weniger Nodes, keine optionalen Signalwege/Labels), zeigt Rank/Score/Countdown kompakt im Header und bietet eine einklappbare Detailkarte. Die 3D-Steuerung wird auf die wichtigsten Touch-Aktionen reduziert.
|
||||
|
||||
Im Admin-Bereich gibt es auf Mobile drei klare Ansichten **MAP / TASKS / CONTROL**, damit Task-Liste und Control Plane nicht mehr durch Responsive-CSS verschwinden. Das Leaderboard wechselt auf Karten statt horizontaler Tabellen.
|
||||
|
||||
## Öffentliches Echtzeit-Leaderboard
|
||||
|
||||
`/leaderboard` ist eine eigene, nicht authentifizierungspflichtige Ansicht. Sie bietet:
|
||||
|
||||
- Live-Ranking nach aktuellem Score,
|
||||
- All-Time-Ranking nach Wins / Best Score,
|
||||
- Online/Offline-Indikator,
|
||||
- Best Score, Wins, Tippanzahl und Unlocks,
|
||||
- Client-ID-Suche,
|
||||
- WebSocket-getriggerte Aktualisierung bei neuen Punkten, Task-Änderungen und Task-Abschlüssen.
|
||||
|
||||
Zusätzlich zeigt das Leaderboard eine Gewinner-Galerie. Die öffentliche API liefert **keine Original-Artifact-URI**, sondern nur eine Wasserzeichen-Vorschau. Für PNG/JPEG/GIF wird das Wasserzeichen serverseitig in eine neue PNG-Vorschau gerastert; lokale SVG-Artefakte erhalten eine sichtbare, wiederholte SVG-Wasserzeichenebene.
|
||||
|
||||
Die Originaldatei und das Manifest sind nur noch über authentifizierte Admin-Endpunkte erreichbar. Der frühere öffentliche `/artifacts/*`-File-Server wurde entfernt.
|
||||
|
||||
API:
|
||||
|
||||
```text
|
||||
GET /api/public/leaderboard?mode=live&limit=500
|
||||
GET /api/public/leaderboard?mode=alltime&limit=500
|
||||
GET /api/public/artifacts?limit=96
|
||||
GET /api/public/artifacts/{task_id}/preview
|
||||
WS /api/leaderboard/ws
|
||||
|
||||
# nur Admin-JWT
|
||||
GET /api/admin/tasks/{task_id}/artifact
|
||||
GET /api/admin/tasks/{task_id}/manifest
|
||||
```
|
||||
|
||||
## Admin: planbare Task-Aktionen
|
||||
|
||||
Jeder Task besitzt jetzt einen persistierten Aktionsplan/Audit-Log. Aktionen können **sofort** oder für einen Zeitpunkt in der Zukunft geplant werden. Ein 1-Sekunden-Scheduler führt fällige Aktionen aus.
|
||||
|
||||
Unterstützt sind:
|
||||
|
||||
- `set_range_bits` — Zahlenraum eines laufenden Tasks ändern,
|
||||
- `set_intervals` — Tippintervalle pro Task überschreiben,
|
||||
- `clear_intervals` — wieder globale Defaults verwenden,
|
||||
- `pause` — Tippabgaben pausieren,
|
||||
- `resume` — fortsetzen,
|
||||
- `reroll` — neues Secret + neuer Public Seed, Scores/Sequenzen zurücksetzen,
|
||||
- `close` — Task beenden und Ersatz-Task sicherstellen,
|
||||
- `regenerate_artifact` — Gewinnerbild erneut in die Queue legen.
|
||||
|
||||
Damit lassen sich z. B. Difficulty-Ramps planen:
|
||||
|
||||
```text
|
||||
20:00 24 Bit
|
||||
20:15 28 Bit
|
||||
20:30 32 Bit
|
||||
20:45 pause
|
||||
20:50 resume
|
||||
```
|
||||
|
||||
### Zahlenraum live ändern
|
||||
|
||||
Es gibt zwei Modi:
|
||||
|
||||
**preserve**
|
||||
|
||||
- das geheime Ziel und der Public Seed bleiben bestehen,
|
||||
- Sequenzen bleiben bestehen,
|
||||
- Scores werden auf den neuen Bit-Denominator re-skaliert,
|
||||
- die räumlichen Positionen werden neu berechnet,
|
||||
- Verkleinern ist nur möglich, wenn das bestehende Secret in den neuen Zahlenraum passt.
|
||||
|
||||
**reroll**
|
||||
|
||||
- neues Secret,
|
||||
- neuer Public Seed,
|
||||
- Score/Position/Sequenz werden zurückgesetzt,
|
||||
- kumulative Tippanzahl bleibt erhalten.
|
||||
|
||||
Die Clients empfangen `task_changed` via WebSocket und laden `range_bits`, Seed, Revision und Intervalle neu. Ein Browser verwendet deshalb nach einer Live-Änderung nicht versehentlich die alte Bitzahl.
|
||||
|
||||
## KI-/NFT-artige Gewinnerbilder
|
||||
|
||||
Ein abgeschlossenes Gewinnerbild ist ein **NFT-artiges digitales Artefakt**, aber noch kein Blockchain-NFT. Neural Hunt mintet nichts on-chain. Es erzeugt Bild + Manifest mit eindeutiger ID und kryptografischen Bindungen.
|
||||
|
||||
Das Manifest enthält u. a.:
|
||||
|
||||
```text
|
||||
artifact_id
|
||||
artifact_preset
|
||||
task_id
|
||||
task_range_bits
|
||||
winner_client_id
|
||||
winner_public_jwk
|
||||
winning_guess
|
||||
winner_guess_signature
|
||||
raw_art_sha256
|
||||
image_sha256
|
||||
prompt_sha256
|
||||
collection_character
|
||||
collection_traits
|
||||
layout
|
||||
provider
|
||||
provider_meta
|
||||
```
|
||||
|
||||
Wichtig: Die Gewinner-Signatur authentifiziert den erfolgreichen Guess. Sie ist keine nachträgliche Signatur der vom Bildprovider erzeugten Pixel. Das erzeugte Bild wird stattdessen per `image_sha256` an das Server-Manifest gebunden.
|
||||
|
||||
### Provider
|
||||
|
||||
Das Standard-Preset `raccoon_full_art_v1` ist im Admin-Tab **ARTIFACT** bewusst auf OpenAI + Character-Anchor-Workflow vorkonfiguriert; dort muss normalerweise nur das Bildmodell geändert werden. Die älteren Provider `local`, `comfyui`, `a1111` und `auto` bleiben für `artifact_preset=legacy` kompatibel.
|
||||
|
||||
Provider-Secrets und Provider-URLs bleiben absichtlich ENV-only und werden nicht an den Browser ausgeliefert. Das Dashboard zeigt für OpenAI nur, ob der API-Key vorhanden ist, und ob der kanonische Character Anchor bereits erzeugt wurde.
|
||||
|
||||
### 1. Local
|
||||
|
||||
Kein externer Dienst. Erzeugt ein deterministisches SVG wie bisher.
|
||||
|
||||
```env
|
||||
ARTIFACT_PROVIDER=local
|
||||
```
|
||||
|
||||
### 2. OpenAI Images API — RIFT Collection (Standard)
|
||||
|
||||
Für das neue Full-Art-Preset reichen im Normalfall genau zwei Werte:
|
||||
|
||||
```env
|
||||
ARTIFACT_MODEL=gpt-image-2
|
||||
OPENAI_API_KEY=...
|
||||
```
|
||||
|
||||
`OPENAI_BASE_URL` kann optional überschrieben werden; standardmäßig wird `https://api.openai.com` verwendet. Provider, Portrait-Größe (`1024x1536`), Quality (`medium`) und das Collection-Preset sind bereits als Defaults hinterlegt. Eine mit älteren Projektversionen ausgelieferte lokale 1024×1024-Artifact-Konfiguration wird beim ersten Start einmalig auf das neue Preset migriert.
|
||||
|
||||
Die RIFT-Quality wird außerdem einmalig von einem früheren `high`-Default auf `medium` migriert. Eine später bewusst gesetzte `high`-Einstellung wird durch diese Migration nicht erneut überschrieben.
|
||||
|
||||
Ablauf:
|
||||
|
||||
1. Im Admin-Tab **ARTIFACT** kann `/data/artifacts/_collection/character_anchor.png` einmalig manuell erzeugt werden. Der Anchor entsteht nur aus dem neutralen RIFT-Masterprompt und enthält bewusst **keinen Task-Style**. Ist beim ersten Gewinner noch kein Anchor vorhanden, erzeugt der Worker ihn weiterhin automatisch als Sicherheits-Fallback.
|
||||
2. Im Admin-Tab **TASK ACTIONS** kann jeder Task ein eigenes JPEG-/PNG-Stylebild erhalten. Es wird content-addressed unter `data/artifacts/_styles/<sha256>.<ext>` gespeichert und über `tasks.nft_style_reference` dem Task zugeordnet. Ein Folge-Task erbt dieselbe Style-Referenz. Ohne Custom-Style wird das eingebettete `internal/artifact/assets/style_reference.jpg` als Default verwendet.
|
||||
3. Für jede RIFT-Karte sendet Neural Hunt zwei Bildinputs an `/v1/images/edits`: **Image 1 = globaler Character Anchor (Identität)** und **Image 2 = Task Style Reference (Rendering-Look)**. Der Prompt weist das Modell explizit an, Gesicht/Fell/Proportionen aus Image 1 und Rendering-Technik/Material/Licht/Farbverhalten aus Image 2 zu übernehmen. Der Kartenprompt erzwingt dabei bewusst keinen festen 3D-Look mehr: ein Task-Style darf z. B. 3D-Cartoon, Cel-Shading, Comic, malerisch, Watercolor-artig, Clay/Toy, Low-Poly oder Retro-Game sein, solange RIFT als Charakter erkennbar und die Ausgabe nicht fotorealistisch bleibt.
|
||||
4. `internal/artifact/collection.go` wählt deterministisch Theme, Outfit, Accessoires, Szene, Mood, Pose, Atmosphäre, Rarity und Akzentfarben. Task-spezifische kreative Vorgaben und Ausschlüsse bleiben optionale Overrides.
|
||||
5. Das generierte PNG wird als `art.png` gespeichert. `internal/artifact/card.go` baut daraus anschließend das finale `image.svg` im Format 1024×1536. Die KI muss daher keine Karten-Typografie oder UI exakt rendern.
|
||||
6. Das Manifest bindet sowohl Roh-Art als auch fertige Karte per SHA-256 und speichert Collection-Traits sowie die verwendete Task-Style-Referenz/Provider-Metadaten.
|
||||
|
||||
Der Character Anchor ist absichtlich global und wird im Admin nach Erstellung als **LOCKED** behandelt. Ein Wechsel des NFT-Stils erfolgt deshalb nicht mehr durch Austausch des Anchors, sondern durch ein anderes Style-Referenzbild am jeweiligen Task. Das eingebettete `style_reference.jpg` muss dafür nicht neu kompiliert oder ausgetauscht werden.
|
||||
|
||||
### OpenAI-Nutzung und Kosten
|
||||
|
||||
Jeder erfolgreiche OpenAI-Bildaufruf (Character-Anchor und Gewinnerkarte) wird lokal in `artifact_api_usage` protokolliert. Wenn OpenAI im Images-Response die Usage-Felder liefert, speichert Neural Hunt die gemeldeten Text-/Bild-Input-Tokens sowie Output-/Total-Tokens und berechnet daraus eine lokale USD-Kostenschätzung. Die Preisbasis ist im Datensatz fixiert, damit historische Werte reproduzierbar bleiben; unbekannte Modelle werden weiter protokolliert, aber ohne erfundene Kostenschätzung.
|
||||
|
||||
Im Adminbereich zeigt **OPENAI NUTZUNG & KOSTEN** die Kosten des heutigen lokalen Kalendertags, den durchschnittlichen Preis pro fertiger Gewinnerkarte und die hochgerechneten Kosten pro 1.000 Karten sowie die letzten API-Aufrufe. Character-Anchor-Aufrufe zählen zu den heutigen API-Kosten, werden aber bewusst nicht in den Karten-Durchschnitt eingerechnet. Die USD-Werte sind eine aus den Provider-Tokens berechnete Standardpreis-Schätzung (ohne separat ausgewiesenen Cached-Input-Rabatt) und keine Rechnungs-/Billing-Abstimmung; dafür ist kein zusätzlicher OpenAI-Admin-Key nötig. Die Rohdaten sind zusätzlich über `GET /api/admin/artifact/usage` verfügbar.
|
||||
|
||||
### 3. ComfyUI
|
||||
|
||||
```env
|
||||
ARTIFACT_PROVIDER=comfyui
|
||||
COMFYUI_URL=http://127.0.0.1:8188
|
||||
COMFYUI_WORKFLOW_PATH=/absolute/path/workflow-api.json
|
||||
ARTIFACT_MODEL=your-checkpoint.safetensors
|
||||
```
|
||||
|
||||
Der Workflow muss im **API-Format** vorliegen. Neural Hunt ersetzt rekursiv folgende String-Platzhalter:
|
||||
|
||||
```text
|
||||
{{PROMPT}}
|
||||
{{NEGATIVE_PROMPT}}
|
||||
{{SEED}}
|
||||
{{WIDTH}}
|
||||
{{HEIGHT}}
|
||||
{{STEPS}}
|
||||
{{MODEL}}
|
||||
```
|
||||
|
||||
Beispiel in einem KSampler-/Text-Encode-Input:
|
||||
|
||||
```json
|
||||
{
|
||||
"text": "{{PROMPT}}",
|
||||
"seed": "{{SEED}}",
|
||||
"steps": "{{STEPS}}"
|
||||
}
|
||||
```
|
||||
|
||||
Der Worker sendet den Workflow an `/prompt`, pollt `/history/{prompt_id}` und lädt das erste gefundene Ergebnis über `/view`.
|
||||
|
||||
### 4. AUTOMATIC1111
|
||||
|
||||
A1111 muss mit API gestartet werden, z. B. `--api`. Optional ist `--api-auth user:pass` möglich.
|
||||
|
||||
```env
|
||||
ARTIFACT_PROVIDER=a1111
|
||||
A1111_URL=http://127.0.0.1:7860
|
||||
A1111_USER=
|
||||
A1111_PASSWORD=
|
||||
ARTIFACT_MODEL=my-checkpoint.safetensors
|
||||
ARTIFACT_STEPS=28
|
||||
A1111_CFG_SCALE=7
|
||||
```
|
||||
|
||||
Neural Hunt verwendet `/sdapi/v1/txt2img`, übernimmt das Base64-Ergebnis und speichert es lokal. Ist `ARTIFACT_MODEL` kein `gpt-image-*`-Name, wird es für A1111 als `sd_model_checkpoint` pro Request gesetzt.
|
||||
|
||||
### 5. Auto
|
||||
|
||||
```env
|
||||
ARTIFACT_PROVIDER=auto
|
||||
```
|
||||
|
||||
Reihenfolge:
|
||||
|
||||
```text
|
||||
OpenAI (wenn Key gesetzt)
|
||||
→ ComfyUI (wenn URL + Workflow gesetzt)
|
||||
→ A1111 (wenn URL gesetzt)
|
||||
→ lokales SVG
|
||||
```
|
||||
|
||||
Fehler der fehlgeschlagenen Provider werden im Manifest des lokalen Fallbacks vermerkt.
|
||||
|
||||
### Docker + lokale ComfyUI/A1111-Instanz
|
||||
|
||||
`docker-compose.yml` enthält `host.docker.internal:host-gateway`. Dadurch kann der App-Container unter Linux/Windows/macOS typischerweise einen Bildserver auf dem Docker-Host erreichen:
|
||||
|
||||
```env
|
||||
COMFYUI_URL=http://host.docker.internal:8188
|
||||
A1111_URL=http://host.docker.internal:7860
|
||||
```
|
||||
|
||||
Ein ComfyUI-Workflow, den der Container lesen soll, muss zusätzlich in den Container gemountet bzw. unter `/data` abgelegt werden.
|
||||
|
||||
## Architektur
|
||||
|
||||
```text
|
||||
Browser
|
||||
├─ Client UI + P-256 Identity
|
||||
├─ /leaderboard
|
||||
└─ Admin Control Plane
|
||||
│ HTTP / WS
|
||||
▼
|
||||
+------------------------------------------+
|
||||
| Go Binary |
|
||||
| auth | tasks | scheduler | WS | artifact |
|
||||
+--------------------+---------------------+
|
||||
│
|
||||
modernc.org/sqlite
|
||||
/data/neuralhunt.db
|
||||
│
|
||||
└─ /data/artifacts/
|
||||
└─ artifact_<id>/
|
||||
├─ image.png|svg
|
||||
└─ manifest.json
|
||||
|
||||
Optional nur für Bildgenerierung:
|
||||
OpenAI API ODER ComfyUI ODER A1111
|
||||
```
|
||||
|
||||
## V2.4 Scale-Architektur
|
||||
|
||||
Die Standalone-Edition bleibt bewusst **ein Go-Prozess auf einem Host**. Dafür ist der heiße Pfad jetzt konsequent aus SQLite herausgezogen:
|
||||
|
||||
```text
|
||||
normaler falscher Guess
|
||||
-> JWT / Signatur / deterministischen Guess prüfen
|
||||
-> Sequence + Rate Limit + GuessCount im RAM
|
||||
-> Score berechnen
|
||||
-> schlechter als Bestwert
|
||||
-> Antwort false
|
||||
|
||||
SQLite writes: 0
|
||||
Map events: 0
|
||||
```
|
||||
|
||||
Nur Registrierung, erstmaliger Task-Punkt, Score-Verbesserungen, Gewinner, Unlocks, Admin-/Task-Änderungen und Artefakte werden dauerhaft geschrieben. Presence (`eine ID = eine aktive Verbindung`) liegt ebenfalls im Prozess und verursacht keine regelmäßigen SQLite-Lease-Writes mehr. Die alte Tabelle `presence_leases` kann aus Kompatibilitätsgründen in bestehenden Datenbanken vorhanden bleiben, wird aber vom V2.4-Hotpath nicht genutzt.
|
||||
|
||||
### Gebatchte WebSockets
|
||||
|
||||
Verbesserungen werden pro Task und Client koalesziert und alle **250 ms** als ein `points`-Frame gesendet. Jeder Socket hat eine bounded Outbound-Queue; ein langsamer Browser blockiert daher niemals einen Guess-Request. Ist seine Queue voll, darf ein transienter Map-Frame verworfen werden. Der persistierte Zustand bleibt maßgeblich.
|
||||
|
||||
### Server-seitig begrenzte Map-Snapshots
|
||||
|
||||
Der Browser lädt nicht mehr pauschal 50.000 Nodes. `max_nodes` wird an den WebSocket übergeben; der Server liefert höchstens `3 × max_nodes` Overscan (hart gedeckelt auf 10.000) und garantiert, dass die eigene Node enthalten ist. Nachfolgende Events werden clientseitig ebenfalls auf dieses Overscan-Budget gekürzt. Das vorhandene LOD arbeitet nur noch auf diesem begrenzten Working Set.
|
||||
|
||||
### Performance-Telemetrie
|
||||
|
||||
`GET /api/admin/performance` liefert live u. a. `Guess/s`, `Improvements/s`, `SQLite Writes/s`, verworfene Requests, WebSocket Frames/s, WebSocket Bytes/s, Queue-Drops, Goroutines und Heap. Diese Werte erscheinen zusätzlich im Admin-Dashboard.
|
||||
|
||||
### Lastgenerator
|
||||
|
||||
Der integrierte Lastgenerator erzeugt echte P-256-Identitäten, führt Challenge/Login aus, öffnet WebSockets und sendet deterministische signierte Tipps:
|
||||
|
||||
```bash
|
||||
go run ./cmd/loadtest \
|
||||
-url http://127.0.0.1:8080 \
|
||||
-clients 10000 \
|
||||
-ramp 60s \
|
||||
-duration 5m \
|
||||
-max-nodes 250
|
||||
```
|
||||
|
||||
Für einen realen 10k-Test sollten Server und Lastgenerator nach Möglichkeit auf getrennten Hosts laufen. Die erreichbare Obergrenze hängt von CPU, RAM, TLS, Dateideskriptoren und Netzwerk ab; V2.4 setzt **10.000 gleichzeitige Clients als Lasttest-Ziel**, nicht als hardwareunabhängige Garantie.
|
||||
|
||||
## SQLite und Skalierungsgrenze
|
||||
|
||||
SQLite läuft mit Foreign Keys, WAL, `busy_timeout` und kurzen Transaktionen. Weil normale falsche Tipps SQLite nicht mehr berühren, ist die Single-Writer-Eigenschaft deutlich weniger dominant. Für echtes Multi-Host-Horizontal-Scaling wäre trotzdem wieder eine gemeinsame Koordinations-/Datenbankschicht nötig.
|
||||
|
||||
## Mehrere lokale Test-Clients
|
||||
|
||||
Die IP-Adresse ist nicht Teil der Identität. Mehrere Clients auf `127.0.0.1` funktionieren. Sie brauchen lediglich unterschiedliche Browser-Identitäten, z. B. normales Fenster + Inkognito oder getrennte Browserprofile.
|
||||
|
||||
Zwei Tabs desselben Profils teilen `localStorage` und damit dieselbe ID. Pro ID ist weiterhin nur eine aktive Client-WebSocket-Verbindung erlaubt.
|
||||
|
||||
## Persistenz / Backup
|
||||
|
||||
Relevant sind:
|
||||
|
||||
```text
|
||||
/data/neuralhunt.db
|
||||
/data/artifacts/
|
||||
```
|
||||
|
||||
In SQLite liegen u. a.:
|
||||
|
||||
```text
|
||||
clients
|
||||
tasks
|
||||
task_points
|
||||
settings
|
||||
presence_leases
|
||||
task_actions
|
||||
```
|
||||
|
||||
## API-Überblick
|
||||
|
||||
User:
|
||||
|
||||
```text
|
||||
POST /api/auth/challenge
|
||||
POST /api/auth/login
|
||||
GET /api/tasks/current
|
||||
POST /api/tasks/{id}/guess
|
||||
GET /api/tasks/{id}/points
|
||||
GET /api/me
|
||||
GET /api/leaderboard
|
||||
GET /api/ws
|
||||
```
|
||||
|
||||
Public:
|
||||
|
||||
```text
|
||||
GET /api/public/leaderboard
|
||||
WS /api/leaderboard/ws
|
||||
```
|
||||
|
||||
Admin:
|
||||
|
||||
```text
|
||||
POST /api/admin/login
|
||||
GET /api/admin/overview
|
||||
GET /api/admin/performance
|
||||
GET /api/admin/settings
|
||||
PUT /api/admin/settings
|
||||
GET /api/admin/tasks
|
||||
GET /api/admin/tasks/{id}/points
|
||||
GET /api/admin/tasks/{id}/actions
|
||||
POST /api/admin/tasks/{id}/actions
|
||||
POST /api/admin/actions/{id}/cancel
|
||||
GET /api/admin/artifact/providers
|
||||
POST /api/admin/tasks/{id}/close
|
||||
POST /api/admin/tasks/ensure
|
||||
```
|
||||
|
||||
## Produktions-Hinweise
|
||||
|
||||
- TLS erzwingen.
|
||||
- `JWT_SECRET` und Admin-Passwort ändern.
|
||||
- `CheckOrigin` für WebSockets auf eine Origin-Allowlist begrenzen.
|
||||
- `/data` sichern und freien Speicher überwachen.
|
||||
- API-Keys nur als ENV/Secret injizieren, niemals in Runtime-Settings speichern.
|
||||
- Bei ComfyUI/A1111 nur vertrauenswürdige lokale/netzinterne Instanzen konfigurieren.
|
||||
- Datenschutz-/Einwilligungstexte für das soziale Experiment ergänzen.
|
||||
|
||||
## V2.9: lokaler RIFT-Pipeline-Test
|
||||
|
||||
Im Adminbereich unter **TASK ACTIONS** kann für den ausgewählten Task eine komplette lokale Testkarte erzeugt werden. Der Test verwendet den vorhandenen `character_anchor.png` als Mock-Character-Artwork und die Style-Referenz des Tasks als Hintergrund, führt die deterministische Trait-Auswahl, das programmatische Kartenlayout und das Schreiben der Testdateien aus, ruft aber **keine externe Bild-API** auf. Die Dateien landen unter `data/artifacts/_test/<task-id>/`; der echte Task- und Artifact-Status bleibt unverändert.
|
||||
|
||||
321
TESTING.md
Normal file
321
TESTING.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Validation — V2.5.1 Reconnect / Presence / Heartbeat
|
||||
|
||||
## Regression: regelmäßiger HTTP 409 nach ~90 Sekunden
|
||||
|
||||
1. Browser-Client mit einem aktiven Task verbinden und mindestens 3 Minuten ohne F5 laufen lassen.
|
||||
2. Prüfen, dass Auto-Guesses über die frühere 90-Sekunden-Grenze hinweg weiterlaufen.
|
||||
3. DevTools → Network → WS beobachten: der Server sendet echte WebSocket-Ping-Control-Frames; Browser-Pongs verlängern die Read-Deadline.
|
||||
4. Netzwerk für einige Sekunden trennen und wieder aktivieren. Der Browser muss automatisch reconnecten und ohne F5 weitertippen.
|
||||
5. Während eines Task-Wechsels bzw. schnellen Reconnects darf ein verspätetes `ReleasePresence` der alten Verbindung die neue Presence nicht entfernen. Das wird zusätzlich durch `TestPresenceReconnectOldReleaseCannotDeleteNewLease` geprüft.
|
||||
6. Shell-Client ebenfalls mindestens 3 Minuten laufen lassen, Verbindung kurz unterbrechen und prüfen, dass `[ws] wieder verbunden` erscheint und Guessing fortgesetzt wird.
|
||||
|
||||
Maschinenlesbare 409-Codes:
|
||||
|
||||
```text
|
||||
presence_required
|
||||
sequence_mismatch
|
||||
task_config_changed
|
||||
selection_conflict
|
||||
task_inactive
|
||||
```
|
||||
|
||||
|
||||
|
||||
## V2.5 — Task-Auswahl, Vererbung und Shell-Client
|
||||
|
||||
### Browser-Landing-Page
|
||||
|
||||
1. Server mit mindestens zwei aktiven Tasks starten (`ACTIVE_TASK_COUNT=2`).
|
||||
2. `/` in einem neuen Browserprofil öffnen.
|
||||
3. Prüfen, dass vor der 3D-Ansicht die Task-Landing-Page erscheint.
|
||||
4. Einen Task wählen und prüfen, dass WebSocket/Auto-Guess starten.
|
||||
5. **TASKS** wählen, einen anderen Task anklicken und prüfen, dass keine `identity already connected`-Meldung entsteht.
|
||||
6. Browser neu laden: die serverseitig zuletzt gewählte Task-Karte muss als ausgewählt markiert sein.
|
||||
|
||||
### Task-spezifisches NFT-Prompt + Admin-Draft
|
||||
|
||||
1. `/admin` → **TASK** → Task auswählen.
|
||||
2. Anzeigename, Beschreibung, NFT-Prompt-Anweisungen und Negative Prompt eintippen, **noch nicht speichern**.
|
||||
3. Mindestens einen Auto-Refresh-Zyklus (>3 s) abwarten und danach F5 drücken.
|
||||
4. Prüfen, dass die noch nicht gespeicherten Eingaben weiter im Formular stehen.
|
||||
5. **TASK-KONFIG SPEICHERN** verwenden und anschließend neu laden; nun müssen die Werte vom Server kommen.
|
||||
6. Task gewinnen oder über Admin `close` beenden.
|
||||
7. Folge-Task öffnen und prüfen, dass `range_bits`, task-spezifische Intervalle, Anzeigename, Beschreibung und beide Prompt-Felder identisch geerbt wurden.
|
||||
|
||||
### Shell-Client interaktiv
|
||||
|
||||
```bash
|
||||
go run ./cmd/client -url http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
Prüfen:
|
||||
|
||||
```text
|
||||
tasks
|
||||
status
|
||||
map 25
|
||||
leaderboard 20
|
||||
nfts 10
|
||||
use 2
|
||||
status
|
||||
```
|
||||
|
||||
Der Shell-Client muss weiter automatisch Tipps senden. Nach `use` darf der Server die neue WebSocket-Verbindung nicht wegen der alten Presence ablehnen.
|
||||
|
||||
### Shell-Client unbeaufsichtigt
|
||||
|
||||
```bash
|
||||
go run ./cmd/client \
|
||||
-url http://127.0.0.1:8080 \
|
||||
-non-interactive \
|
||||
-task 1 \
|
||||
-max-nodes 250
|
||||
```
|
||||
|
||||
Mindestens zwei Tippintervalle laufen lassen und im Admin prüfen, dass Guess-/Score-Aktivität vorhanden ist. Dann `Ctrl+C`; die Presence muss freigegeben werden.
|
||||
|
||||
### Browser-/Shell-Identity-Portabilität
|
||||
|
||||
Im Browser eine Identity exportieren. Danach **Browser-Tab schließen**, damit die Single-Connection-Lease frei ist:
|
||||
|
||||
```bash
|
||||
export NEURALHUNT_IDENTITY_PASSPHRASE='test-passphrase'
|
||||
go run ./cmd/client \
|
||||
-import ./neuralhunt-identity-....json \
|
||||
-identity ./tmp-shell-identity.json
|
||||
```
|
||||
|
||||
Die Client-ID muss mit der Browser-ID übereinstimmen.
|
||||
|
||||
Umgekehrt:
|
||||
|
||||
```bash
|
||||
export NEURALHUNT_IDENTITY_PASSPHRASE='test-passphrase'
|
||||
go run ./cmd/client \
|
||||
-identity ./tmp-shell-identity.json \
|
||||
-export ./tmp-browser-import.json
|
||||
```
|
||||
|
||||
Diese Datei anschließend über **Import** in einem Browserprofil laden; die Client-ID muss identisch bleiben.
|
||||
|
||||
### Automatische Tests
|
||||
|
||||
Zusätzlich zu den bisherigen Tests:
|
||||
|
||||
```bash
|
||||
go test ./cmd/client
|
||||
```
|
||||
|
||||
enthält PBKDF2-Testvektor sowie Raw-/verschlüsselten Identity-Roundtrip. `internal/data/successor_test.go` prüft mit einem echten SQLite-Treiber die Task-Vererbung, Idempotenz des Nachfolgers und das atomare Migrieren der Client-Auswahl.
|
||||
|
||||
In dieser Erstellungsumgebung erfolgreich ausgeführt:
|
||||
|
||||
```bash
|
||||
node --check internal/webui/dist/app.js
|
||||
|
||||
gofmt -w cmd/client/*.go internal/data/store.go internal/server/server.go internal/artifact/worker.go
|
||||
|
||||
# kompletter Source-Compile mit lokalen API-Stubs für die drei nicht ladbaren Module
|
||||
go test -run '^$' ./...
|
||||
|
||||
# echte Shell-Client-Krypto-/Identity-Tests; nur der WebSocket-Import ist dabei gestubbt
|
||||
go test ./cmd/client
|
||||
```
|
||||
|
||||
Zusätzlich wurde eine **V2.4-artige SQLite-Datenbank** mit Python/SQLite angelegt und die Upgrade-Reihenfolge aus `OpenSQLite()` reproduziert. Dabei wurden die fünf neuen Task-Spalten, `client_task_selection` und der erst nach `parent_task_id` angelegte `tasks_parent_unique_idx` erfolgreich erzeugt. Das ist relevant, weil der Index bei Bestandsdatenbanken nicht vor dem `ALTER TABLE` angelegt werden darf.
|
||||
|
||||
Ergebnis dieser ausführbaren Prüfungen: erfolgreich.
|
||||
|
||||
## Statisch geprüft / implementiert
|
||||
|
||||
- UI ist weiterhin vollständig unter `internal/webui/dist/` eingebettet; kein npm/Vite-Build nötig.
|
||||
- `/leaderboard` ist eine SPA-Route und besitzt einen öffentlichen Live-WebSocket.
|
||||
- Mobile Mode ist für Client, Leaderboard und Admin implementiert; Admin bietet mobile MAP/TASKS/CONTROL-Navigation.
|
||||
- Das Leaderboard lädt Gewinner-Artefakte ausschließlich über `/api/public/artifacts/{task}/preview`.
|
||||
- Die öffentlichen Leaderboard-Daten enthalten NFT-Anzahl und letzte Wasserzeichen-Preview, aber keine Original-Artifact-URI.
|
||||
- Der frühere öffentliche `/artifacts/*`-File-Server ist entfernt; Original und Manifest benötigen ein Admin-JWT.
|
||||
- Raster-Previews werden serverseitig mit einem eingebrannten Bitmap-Wasserzeichen neu als PNG codiert; SVG-Previews erhalten eine wiederholte Wasserzeichenebene.
|
||||
- TARGET FIELD bildet Score monoton und direkt auf den Orbitalradius ab; Kameratiefe kann die wahrgenommene Nähe nicht mehr umkehren.
|
||||
- Score-Ringe: 0 / 25 / 50 / 75 / 90 / 95 / 99+; Vorder- und Rückhälfte werden zur Tiefenwahrnehmung unterschiedlich gerendert.
|
||||
- Pseudozufällige Client-zu-Client-Synapsen sind vollständig entfernt.
|
||||
- SIGNALWEGE verbinden ausschließlich Top-Kandidaten/eigenen Client mit dem Task-Core; Score-Verbesserungen erzeugen nur entlang dieses realen Pfads Signalimpulse.
|
||||
- Client lädt bei `task_changed` Range Bits, Seed, Revision, Intervalle und Punkte neu.
|
||||
- Client-Autosubmit verwendet keinen statischen `setInterval` mehr, sondern die jeweils aktuelle Task-Konfiguration.
|
||||
- Task-Aktionen werden in `task_actions` persistiert, jede Sekunde geprüft und mit Status/Audit gespeichert.
|
||||
- Alte SQLite-Datenbanken erhalten die neuen `tasks`-Spalten per `ALTER TABLE`-Migration.
|
||||
- `set_range_bits` unterstützt `preserve` und `reroll`.
|
||||
- Task-spezifische Tippintervalle überschreiben globale Runtime-Defaults.
|
||||
- OpenAI-, ComfyUI- und A1111-Provider speichern nur lokal erzeugte Ergebnisbytes + Manifest; kein S3 notwendig.
|
||||
- OpenAI-Provider validiert für `gpt-image-2` die dokumentierten Dimensionsgrenzen, bevor ein Request gesendet wird.
|
||||
- Provider-Secrets/URLs werden nicht über die Runtime-Settings-API exponiert.
|
||||
- ComfyUI unterstützt Workflow-Platzhalter für Prompt/Negative Prompt/Seed/Größe/Steps/Modell.
|
||||
- A1111 unterstützt optional Basic Auth und Checkpoint-Override pro Request.
|
||||
|
||||
## In dieser Umgebung nicht vollständig ausführbar
|
||||
|
||||
`go test ./...` und `go mod tidy` können hier nicht abgeschlossen werden, weil ausgehender DNS/Netzwerkzugriff auf `proxy.golang.org` gesperrt ist. Der Versuch scheitert beim Download dieser bereits im `go.mod` referenzierten Module:
|
||||
|
||||
```text
|
||||
modernc.org/sqlite
|
||||
github.com/go-chi/chi/v5
|
||||
github.com/gorilla/websocket
|
||||
```
|
||||
|
||||
Dadurch konnten `internal/data`, `internal/server`, `internal/ws` und das komplette Binary hier nicht gegen frisch heruntergeladene Dependencies gebaut werden. Die direkt von ihnen unabhängigen geänderten Go-Packages wurden kompiliert/getestet.
|
||||
|
||||
Docker ist in der Erstellungsumgebung ebenfalls nicht verfügbar.
|
||||
|
||||
## Empfohlene Tests auf deinem Rechner
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
go test ./...
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
Dann:
|
||||
|
||||
1. `/` öffnen und prüfen, dass TARGET FIELD standardmäßig aktiv ist. Einen Score-90-, Score-95- und Score-99-Punkt vergleichen: 99 muss unabhängig von Orbit/Neigung sichtbar auf dem kleinsten Ring liegen.
|
||||
2. `ORBIT` laufen lassen und die Ansicht ziehen: Die Reihenfolge der Ringradien darf sich durch Kamerabewegung nicht umkehren.
|
||||
3. `SIGNALWEGE` einschalten: Es dürfen keine Client→Client-Kanten erscheinen; nur ausgewählte Client→Task-Pfade sind erlaubt.
|
||||
4. Zwei unabhängige Browserprofile auf `127.0.0.1` verbinden.
|
||||
5. `/leaderboard` in einem dritten Tab öffnen; Punktänderungen sollen ohne manuelles Reload erscheinen.
|
||||
6. Im Admin-Bereich einen aktiven Task auswählen und `pause` sofort ausführen. Clients müssen `PAUSE` anzeigen und keine Tipps senden.
|
||||
7. `resume` ausführen; Autosubmit muss mit dem aktuellen Client-Intervall weiterlaufen.
|
||||
8. `set_range_bits` im Modus `preserve` erhöhen; Client muss neue Bitzahl ohne Reload verwenden.
|
||||
9. `set_range_bits` im Modus `reroll` ausführen; Punkte/Scores müssen auf 0 zurückfallen und neuer Seed/Revision aktiv werden.
|
||||
10. Eine Range-Bit-Änderung fünf Minuten in die Zukunft planen und anschließend abbrechen; Status `cancelled` prüfen.
|
||||
11. Zwei geplante Aktionen hintereinander setzen, z. B. 24 Bit und später 32 Bit, und Ausführung/Audit prüfen.
|
||||
12. Einen kleinen Zahlenraum verwenden, einen Task lösen und lokales SVG + Manifest prüfen.
|
||||
13. `/leaderboard` öffnen: das Gewinner-Artefakt muss in Galerie und Ranking nur als Wasserzeichen-Vorschau erscheinen.
|
||||
14. Direkter Aufruf der früheren `/artifacts/...`-URI muss 404 liefern; Original/Manifest aus dem Admin-Dashboard müssen weiterhin nach Authentifizierung öffnen.
|
||||
15. Browser auf <850 px setzen oder MOBILE aktivieren: Client-Details müssen einklappbar sein; im Admin müssen MAP/TASKS/CONTROL einzeln erreichbar sein.
|
||||
|
||||
### OpenAI Provider
|
||||
|
||||
```env
|
||||
ARTIFACT_PROVIDER=openai
|
||||
ARTIFACT_MODEL=gpt-image-2
|
||||
OPENAI_API_KEY=...
|
||||
ARTIFACT_WIDTH=1024
|
||||
ARTIFACT_HEIGHT=1024
|
||||
ARTIFACT_QUALITY=medium
|
||||
```
|
||||
|
||||
Task lösen bzw. bei einem abgeschlossenen Task `NFT-Bild neu erzeugen` ausführen. Erwartet:
|
||||
|
||||
```text
|
||||
/data/artifacts/artifact_<id>/image.png
|
||||
/data/artifacts/artifact_<id>/manifest.json
|
||||
```
|
||||
|
||||
### ComfyUI Provider
|
||||
|
||||
ComfyUI starten, API-Workflow exportieren und im Workflow mindestens `{{PROMPT}}` sowie optional die anderen Platzhalter verwenden. Danach:
|
||||
|
||||
```env
|
||||
ARTIFACT_PROVIDER=comfyui
|
||||
COMFYUI_URL=http://127.0.0.1:8188
|
||||
COMFYUI_WORKFLOW_PATH=/path/workflow-api.json
|
||||
ARTIFACT_MODEL=checkpoint.safetensors
|
||||
```
|
||||
|
||||
### AUTOMATIC1111 Provider
|
||||
|
||||
A1111 mit `--api` starten:
|
||||
|
||||
```env
|
||||
ARTIFACT_PROVIDER=a1111
|
||||
A1111_URL=http://127.0.0.1:7860
|
||||
ARTIFACT_MODEL=checkpoint.safetensors
|
||||
```
|
||||
|
||||
Bei `--api-auth user:pass` zusätzlich `A1111_USER` und `A1111_PASSWORD` setzen.
|
||||
|
||||
## Regressionen aus früheren Fixes
|
||||
|
||||
- Auth challenge/login toleriert optionale JWK-Metadaten (`alg`, `use`, `kid` etc.).
|
||||
- Mehrere Challenges derselben Identität überschreiben einander nicht.
|
||||
- Eine Browser-ID darf weiterhin nur eine aktive Client-WebSocket-Verbindung besitzen.
|
||||
- Leere Listen werden als `[]`, nicht als `null`, an die UI geliefert.
|
||||
- Client-Punkt wird direkt beim WebSocket-Connect erzeugt.
|
||||
|
||||
## Additional v2 checks
|
||||
|
||||
- SQLite schema parsed successfully with Python `sqlite3`, including `task_actions` and the live task override columns.
|
||||
- The legacy `migrations/001_init.sql` mirror was synchronized with `internal/data/schema.sql` so manual schema inspection does not show an obsolete layout.
|
||||
- Responsive CSS keeps the Admin Task Actions / Artifact control plane visible on common laptop widths (851–1280px).
|
||||
|
||||
## V2.1 package-integrity regression
|
||||
|
||||
The V2 archive was missing `internal/data/`, which caused an old `store.go` to survive when users extracted over a previous checkout. That produced compile errors such as missing `Task.GuessMinIntervalSec`, `Task.Paused`, `LiveLeaderboard`, and `TaskAction`. V2.1 explicitly includes:
|
||||
|
||||
- `internal/data/store.go`
|
||||
- `internal/data/schema.sql`
|
||||
- the matching V2 `internal/server/server.go`
|
||||
|
||||
Always test from a clean extraction directory.
|
||||
|
||||
## V2.2 zusätzliche Validierung
|
||||
|
||||
- `node --check internal/webui/dist/app.js` erfolgreich.
|
||||
- `go test ./...` wurde zusätzlich gegen lokale Compile-Stubs für `chi`, `gorilla/websocket` und `modernc.org/sqlite` ausgeführt, um alle Neural-Hunt-Packages inklusive `internal/server/watermark_test.go` ohne Netzwerkzugriff zu kompilieren.
|
||||
- `watermark_test.go` prüft Traversal-Abwehr, SVG-Wasserzeichen und Raster-Wasserzeichen.
|
||||
- Die erweiterten Leaderboard-/Artifact-SQL-Abfragen wurden gegen das reale `schema.sql` mit Python `sqlite3` ausgeführt.
|
||||
|
||||
|
||||
## V2.4 Scale smoke test
|
||||
|
||||
```bash
|
||||
node --check internal/webui/dist/app.js
|
||||
go test ./internal/runtime ./internal/ws ./internal/server ./cmd/loadtest
|
||||
|
||||
# Server in Terminal 1
|
||||
go run ./cmd/server
|
||||
|
||||
# Start small, then increase.
|
||||
go run ./cmd/loadtest -url http://127.0.0.1:8080 -clients 250 -ramp 10s -duration 1m -max-nodes 100
|
||||
go run ./cmd/loadtest -url http://127.0.0.1:8080 -clients 1000 -ramp 30s -duration 2m -max-nodes 100
|
||||
```
|
||||
|
||||
Im Admin unter `/admin` sollten dabei `Guess/s`, `Improve/s`, `SQLite W/s`, `WS Frames/s`, `WS MB/s`, `Drops/s`, `Goroutines` und `Heap MB` live aktualisiert werden. Bei einem stabilen Test sollten `Drops/s` nahe 0 bleiben. Ein niedriger `SQLite W/s` relativ zu `Guess/s` bestätigt, dass verlierende Tipps den DB-Hotpath nicht mehr belasten.
|
||||
|
||||
|
||||
## V2.5.2 Admin form stability regression
|
||||
|
||||
The periodic 3-second admin telemetry refresh must never rebuild `#settingfields`.
|
||||
Only explicit task/tab changes or completed admin actions may refresh the control plane.
|
||||
|
||||
Manual regression:
|
||||
|
||||
1. Open `/admin` and select `ARTIFACT`.
|
||||
2. Open the Provider or Quality native select and leave it open for more than 6 seconds.
|
||||
3. The dropdown must remain open while Overview/Performance counters continue updating.
|
||||
4. Type into Prompt-Zusatz for more than 6 seconds; caret position and textarea scroll position must not jump.
|
||||
5. Switch tabs and back; the draft value must still be restored.
|
||||
|
||||
Static/syntax checks used for this release:
|
||||
|
||||
```bash
|
||||
node --check internal/webui/dist/app.js
|
||||
go test ./internal/webui ./internal/core ./internal/auth ./internal/artifact ./internal/settings
|
||||
```
|
||||
## RIFT Medium + OpenAI usage/cost telemetry
|
||||
|
||||
- Start with `ARTIFACT_PRESET=raccoon_full_art_v1` and no explicit `ARTIFACT_QUALITY`: the effective quality must be `medium`.
|
||||
- Generate one RIFT winner artifact and verify that the OpenAI request sends `quality=medium`.
|
||||
- Verify that `artifact_api_usage` receives one `artifact` row per successful winner-image API call and a `character_anchor` row when the canonical anchor is created. A successful call is logged even if the provider omits its optional `usage` block; in that case token counts remain zero and `estimated_cost_usd` is NULL.
|
||||
- When OpenAI returns usage data, verify that `input_tokens`, text/image input split, `output_tokens`, `total_tokens`, `estimated_cost_usd`, and `pricing_basis` are persisted and mirrored in the artifact manifest/provider metadata.
|
||||
- In Admin → Artifact, verify **KOSTEN HEUTE**, **Ø KOSTEN PRO KARTE**, **KOSTEN PRO 1.000 KARTEN**, and the recent-call table. Anchor cost belongs to today's total but must not affect the per-card average.
|
||||
- Verify `GET /api/admin/artifact/usage?day_start_ms=<local-midnight-ms>` returns the same aggregates.
|
||||
|
||||
|
||||
## RIFT V2.8 — per-task style references + manual anchor
|
||||
|
||||
- In Admin → ARTIFACT, with `OPENAI_API_KEY` configured and no existing `_collection/character_anchor.png`, click **RIFT-ANCHOR JETZT ERZEUGEN**. Verify exactly one OpenAI image-generation request is made, the file is stored at `data/artifacts/_collection/character_anchor.png`, the usage row has `kind=character_anchor`, and the UI changes to `LOCKED` without an overwrite button.
|
||||
- Verify a second direct `POST /api/admin/artifact/character-anchor` returns HTTP 409 while the anchor exists.
|
||||
- In Admin → TASK ACTIONS upload a JPEG/PNG style reference. Verify it is stored content-addressed under `data/artifacts/_styles/`, `tasks.nft_style_reference` receives only the basename, and the admin preview shows the uploaded image.
|
||||
- Verify the public Task landing card displays `/api/public/tasks/<id>/style-reference` and marks a custom task as `TASK STYLE`; tasks without an upload display the bundled default reference.
|
||||
- Complete a task and verify its successor inherits `nft_style_reference`.
|
||||
- Generate a RIFT winner card and inspect the multipart OpenAI edit request: `image[]` must contain two files in this order: `character_anchor.png`, then the task style reference. Provider metadata should contain `reference_mode=character-plus-task-style`, the character-anchor hash and the style-reference hash.
|
||||
- Use **AUF DEFAULT ZURÜCK** and verify the task DB reference is empty and generation falls back to `internal/artifact/assets/style_reference.jpg` without deleting shared content-addressed style files.
|
||||
326
cmd/client/api.go
Normal file
326
cmd/client/api.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"neuralhunt/internal/core"
|
||||
)
|
||||
|
||||
func pad32Int(x *big.Int) []byte {
|
||||
b := x.Bytes()
|
||||
out := make([]byte, 32)
|
||||
if len(b) > 32 {
|
||||
b = b[len(b)-32:]
|
||||
}
|
||||
copy(out[32-len(b):], b)
|
||||
return out
|
||||
}
|
||||
|
||||
func signRaw(key *ecdsa.PrivateKey, message string) (string, error) {
|
||||
h := sha256.Sum256([]byte(message))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, key, h[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw := append(pad32Int(r), pad32Int(s)...)
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string {
|
||||
return fmt.Sprintf("HTTP %d: %s", e.Status, strings.TrimSpace(e.Body))
|
||||
}
|
||||
|
||||
func (e *apiError) Code() string {
|
||||
var v struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if json.Unmarshal([]byte(e.Body), &v) == nil {
|
||||
return v.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type apiClient struct {
|
||||
base string
|
||||
hc *http.Client
|
||||
token string
|
||||
cid string
|
||||
id identityFile
|
||||
key *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
func newAPI(base string, id identityFile, key *ecdsa.PrivateKey) *apiClient {
|
||||
return &apiClient{
|
||||
base: strings.TrimRight(base, "/"),
|
||||
hc: &http.Client{Timeout: 15 * time.Second},
|
||||
id: id,
|
||||
key: key,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *apiClient) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return &apiError{Status: resp.StatusCode, Body: string(b)}
|
||||
}
|
||||
if out != nil && len(bytes.TrimSpace(b)) != 0 {
|
||||
if err := json.Unmarshal(b, out); err != nil {
|
||||
return fmt.Errorf("decode %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *apiClient) login(ctx context.Context) error {
|
||||
var ch struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, "/api/auth/challenge", map[string]any{"public_jwk": c.id.PublicJWK}, &ch); err != nil {
|
||||
return fmt.Errorf("challenge: %w", err)
|
||||
}
|
||||
sig, err := signRaw(c.key, "login|"+ch.Challenge+"|"+ch.ClientID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lg struct {
|
||||
Token string `json:"token"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, "/api/auth/login", map[string]any{
|
||||
"public_jwk": c.id.PublicJWK,
|
||||
"challenge": ch.Challenge,
|
||||
"signature": sig,
|
||||
}, &lg); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
c.token, c.cid = lg.Token, lg.ClientID
|
||||
return nil
|
||||
}
|
||||
|
||||
type taskCard struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
Paused bool `json:"paused"`
|
||||
Revision int64 `json:"revision"`
|
||||
PointCount int `json:"point_count"`
|
||||
OwnScore float64 `json:"own_score"`
|
||||
OwnRank int64 `json:"own_rank"`
|
||||
Selected bool `json:"selected"`
|
||||
GuessMinIntervalSec *int `json:"guess_min_interval_sec,omitempty"`
|
||||
ClientSubmitIntervalSec *int `json:"client_submit_interval_sec,omitempty"`
|
||||
}
|
||||
|
||||
type taskDTO struct {
|
||||
ID string `json:"id"`
|
||||
PublicSeed string `json:"public_seed"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
NextSeq int64 `json:"next_seq"`
|
||||
ServerMinIntervalSec int `json:"server_min_interval_sec"`
|
||||
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
||||
DefaultMaxNodes int `json:"default_max_nodes"`
|
||||
Paused bool `json:"paused"`
|
||||
Revision int64 `json:"revision"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
ParentTaskID *string `json:"parent_task_id"`
|
||||
}
|
||||
|
||||
type point struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Score float64 `json:"score"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
GuessCount int64 `json:"guess_count"`
|
||||
Rank int64 `json:"rank"`
|
||||
}
|
||||
|
||||
type meDTO struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Score float64 `json:"score"`
|
||||
Rank int64 `json:"rank"`
|
||||
Wins int `json:"wins"`
|
||||
Unlocks []string `json:"unlocks"`
|
||||
}
|
||||
|
||||
type leader struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Wins int `json:"wins"`
|
||||
BestScore float64 `json:"best_score"`
|
||||
LiveScore float64 `json:"live_score"`
|
||||
GuessCount int64 `json:"guess_count"`
|
||||
Connected bool `json:"connected"`
|
||||
Unlocks []string `json:"unlocks"`
|
||||
NFTCount int `json:"nft_count"`
|
||||
NFTTaskID *string `json:"nft_task_id,omitempty"`
|
||||
NFTPreviewURI *string `json:"nft_preview_uri,omitempty"`
|
||||
}
|
||||
|
||||
type publicArtifact struct {
|
||||
TaskID string `json:"task_id"`
|
||||
WinnerClientID string `json:"winner_client_id"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
PreviewURI string `json:"preview_uri"`
|
||||
}
|
||||
|
||||
func (c *apiClient) tasks(ctx context.Context) ([]taskCard, error) {
|
||||
var out []taskCard
|
||||
err := c.do(ctx, http.MethodGet, "/api/tasks", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) selectTask(ctx context.Context, taskID string) (taskDTO, error) {
|
||||
var out taskDTO
|
||||
err := c.do(ctx, http.MethodPost, "/api/tasks/select", map[string]string{"task_id": taskID}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) currentTask(ctx context.Context) (taskDTO, error) {
|
||||
var out taskDTO
|
||||
err := c.do(ctx, http.MethodGet, "/api/tasks/current", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) points(ctx context.Context, taskID string, limit int) ([]point, error) {
|
||||
var out []point
|
||||
err := c.do(ctx, http.MethodGet, "/api/tasks/"+url.PathEscape(taskID)+"/points?limit="+strconv.Itoa(limit), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) me(ctx context.Context) (meDTO, error) {
|
||||
var out meDTO
|
||||
err := c.do(ctx, http.MethodGet, "/api/me", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) leaderboard(ctx context.Context) ([]leader, error) {
|
||||
var out []leader
|
||||
err := c.do(ctx, http.MethodGet, "/api/leaderboard", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) artifacts(ctx context.Context, limit int) ([]publicArtifact, error) {
|
||||
var out []publicArtifact
|
||||
err := c.do(ctx, http.MethodGet, "/api/public/artifacts?limit="+strconv.Itoa(limit), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64) (bool, error) {
|
||||
guess := expectedGuess(t.ID, t.PublicSeed, c.cid, seq, t.RangeBits)
|
||||
sig, err := signRaw(c.key, fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var correct bool
|
||||
err = c.do(ctx, http.MethodPost, "/api/tasks/"+url.PathEscape(t.ID)+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig}, &correct)
|
||||
return correct, err
|
||||
}
|
||||
|
||||
func (c *apiClient) dialWS(ctx context.Context, maxNodes int) (*websocket.Conn, error) {
|
||||
u, err := url.Parse(c.base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scheme := "ws"
|
||||
if u.Scheme == "https" {
|
||||
scheme = "wss"
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("token", c.token)
|
||||
q.Set("max_nodes", strconv.Itoa(maxNodes))
|
||||
wu := scheme + "://" + u.Host + "/api/ws?" + q.Encode()
|
||||
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, wu, nil)
|
||||
if err != nil && resp != nil {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("websocket %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func (c *apiClient) downloadPreview(ctx context.Context, taskID, dest string) error {
|
||||
path := "/api/public/artifacts/" + url.PathEscape(taskID) + "/preview"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return &apiError{Status: resp.StatusCode, Body: string(b)}
|
||||
}
|
||||
if dir := filepath.Dir(dest); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 32<<20))
|
||||
return err
|
||||
}
|
||||
|
||||
func expectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
|
||||
// Keep this exactly aligned with internal/core.ExpectedGuess without importing
|
||||
// implementation details into the terminal UX layer.
|
||||
return core.ExpectedGuess(taskID, seed, clientID, seq, bits)
|
||||
}
|
||||
|
||||
var errSwitching = errors.New("task switch in progress")
|
||||
261
cmd/client/identity.go
Normal file
261
cmd/client/identity.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"neuralhunt/internal/auth"
|
||||
)
|
||||
|
||||
var rawURL = base64.RawURLEncoding
|
||||
|
||||
type privateJWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
D string `json:"d"`
|
||||
Ext bool `json:"ext,omitempty"`
|
||||
KeyOps []string `json:"key_ops,omitempty"`
|
||||
Alg string `json:"alg,omitempty"`
|
||||
Use string `json:"use,omitempty"`
|
||||
Kid string `json:"kid,omitempty"`
|
||||
}
|
||||
|
||||
type identityFile struct {
|
||||
Version int `json:"version"`
|
||||
PublicJWK auth.PublicJWK `json:"publicJwk"`
|
||||
PrivateJWK privateJWK `json:"privateJwk"`
|
||||
}
|
||||
|
||||
type encryptedIdentity struct {
|
||||
Version int `json:"version"`
|
||||
Salt string `json:"salt"`
|
||||
IV string `json:"iv"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
}
|
||||
|
||||
func pad32Bytes(b []byte) []byte {
|
||||
out := make([]byte, 32)
|
||||
if len(b) > len(out) {
|
||||
b = b[len(b)-len(out):]
|
||||
}
|
||||
copy(out[len(out)-len(b):], b)
|
||||
return out
|
||||
}
|
||||
|
||||
func generateIdentity() (identityFile, *ecdsa.PrivateKey, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return identityFile{}, nil, err
|
||||
}
|
||||
x := rawURL.EncodeToString(pad32Bytes(key.X.Bytes()))
|
||||
y := rawURL.EncodeToString(pad32Bytes(key.Y.Bytes()))
|
||||
d := rawURL.EncodeToString(pad32Bytes(key.D.Bytes()))
|
||||
pub := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: x, Y: y, Ext: true, KeyOps: []string{"verify"}}
|
||||
priv := privateJWK{Kty: "EC", Crv: "P-256", X: x, Y: y, D: d, Ext: true, KeyOps: []string{"sign"}}
|
||||
return identityFile{Version: 1, PublicJWK: pub, PrivateJWK: priv}, key, nil
|
||||
}
|
||||
|
||||
func privateKeyFromIdentity(id identityFile) (*ecdsa.PrivateKey, error) {
|
||||
if id.Version != 1 || id.PrivateJWK.Kty != "EC" || id.PrivateJWK.Crv != "P-256" || id.PrivateJWK.D == "" {
|
||||
return nil, errors.New("unsupported identity; expected version 1 P-256 JWK")
|
||||
}
|
||||
db, err := rawURL.DecodeString(id.PrivateJWK.D)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode private JWK: %w", err)
|
||||
}
|
||||
d := new(big.Int).SetBytes(db)
|
||||
curve := elliptic.P256()
|
||||
if d.Sign() <= 0 || d.Cmp(curve.Params().N) >= 0 {
|
||||
return nil, errors.New("invalid P-256 private scalar")
|
||||
}
|
||||
x, y := curve.ScalarBaseMult(pad32Bytes(db))
|
||||
if rawURL.EncodeToString(pad32Bytes(x.Bytes())) != id.PublicJWK.X || rawURL.EncodeToString(pad32Bytes(y.Bytes())) != id.PublicJWK.Y {
|
||||
return nil, errors.New("identity public/private key mismatch")
|
||||
}
|
||||
return &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: curve, X: x, Y: y}, D: d}, nil
|
||||
}
|
||||
|
||||
func defaultIdentityPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return "./neuralhunt-identity.json"
|
||||
}
|
||||
return filepath.Join(home, ".neuralhunt", "identity.json")
|
||||
}
|
||||
|
||||
func saveIdentity(path string, id identityFile) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(id, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(path, 0o600)
|
||||
}
|
||||
|
||||
func loadOrCreateIdentity(path string) (identityFile, *ecdsa.PrivateKey, bool, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
var id identityFile
|
||||
if err := json.Unmarshal(b, &id); err != nil {
|
||||
return identityFile{}, nil, false, fmt.Errorf("parse identity %q: %w", path, err)
|
||||
}
|
||||
key, err := privateKeyFromIdentity(id)
|
||||
return id, key, false, err
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return identityFile{}, nil, false, err
|
||||
}
|
||||
id, key, err := generateIdentity()
|
||||
if err != nil {
|
||||
return identityFile{}, nil, false, err
|
||||
}
|
||||
if err := saveIdentity(path, id); err != nil {
|
||||
return identityFile{}, nil, false, err
|
||||
}
|
||||
return id, key, true, nil
|
||||
}
|
||||
|
||||
func readIdentityImport(path, passphrase string) (identityFile, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &probe); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if _, encrypted := probe["ciphertext"]; !encrypted {
|
||||
var id identityFile
|
||||
if err := json.Unmarshal(b, &id); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if _, err := privateKeyFromIdentity(id); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
if passphrase == "" {
|
||||
return identityFile{}, errors.New("encrypted browser identity requires --passphrase or NEURALHUNT_IDENTITY_PASSPHRASE")
|
||||
}
|
||||
var enc encryptedIdentity
|
||||
if err := json.Unmarshal(b, &enc); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
salt, err := rawURL.DecodeString(enc.Salt)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
iv, err := rawURL.DecodeString(enc.IV)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
ct, err := rawURL.DecodeString(enc.Ciphertext)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
plain, err := gcm.Open(nil, iv, ct, nil)
|
||||
if err != nil {
|
||||
return identityFile{}, errors.New("identity decrypt failed (wrong passphrase or damaged export)")
|
||||
}
|
||||
var id identityFile
|
||||
if err := json.Unmarshal(plain, &id); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if _, err := privateKeyFromIdentity(id); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func exportBrowserIdentity(path, passphrase string, id identityFile) error {
|
||||
if passphrase == "" {
|
||||
return errors.New("export requires --passphrase or NEURALHUNT_IDENTITY_PASSPHRASE")
|
||||
}
|
||||
plain, err := json.Marshal(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
iv := make([]byte, 12)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return err
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ct := gcm.Seal(nil, iv, plain, nil)
|
||||
enc := encryptedIdentity{Version: 1, Salt: rawURL.EncodeToString(salt), IV: rawURL.EncodeToString(iv), Ciphertext: rawURL.EncodeToString(ct)}
|
||||
b, err := json.MarshalIndent(enc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.WriteFile(path, b, 0o600)
|
||||
}
|
||||
|
||||
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
|
||||
hLen := sha256.Size
|
||||
blocks := (keyLen + hLen - 1) / hLen
|
||||
out := make([]byte, 0, blocks*hLen)
|
||||
for block := 1; block <= blocks; block++ {
|
||||
mac := hmac.New(sha256.New, password)
|
||||
mac.Write(salt)
|
||||
var n [4]byte
|
||||
binary.BigEndian.PutUint32(n[:], uint32(block))
|
||||
mac.Write(n[:])
|
||||
u := mac.Sum(nil)
|
||||
t := append([]byte(nil), u...)
|
||||
for i := 1; i < iterations; i++ {
|
||||
mac = hmac.New(sha256.New, password)
|
||||
mac.Write(u)
|
||||
u = mac.Sum(nil)
|
||||
for j := range t {
|
||||
t[j] ^= u[j]
|
||||
}
|
||||
}
|
||||
out = append(out, t...)
|
||||
}
|
||||
return out[:keyLen]
|
||||
}
|
||||
55
cmd/client/identity_test.go
Normal file
55
cmd/client/identity_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPBKDF2SHA256Vector(t *testing.T) {
|
||||
got := hex.EncodeToString(pbkdf2SHA256([]byte("password"), []byte("salt"), 2, 32))
|
||||
const want = "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43"
|
||||
if got != want {
|
||||
t.Fatalf("pbkdf2 mismatch: got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserIdentityExportImportRoundTrip(t *testing.T) {
|
||||
id, _, err := generateIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "identity-export.json")
|
||||
if err := exportBrowserIdentity(path, "correct horse battery staple", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readIdentityImport(path, "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PublicJWK.X != id.PublicJWK.X || got.PublicJWK.Y != id.PublicJWK.Y || got.PrivateJWK.D != id.PrivateJWK.D {
|
||||
t.Fatal("identity changed during export/import")
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawIdentityImport(t *testing.T) {
|
||||
id, _, err := generateIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "identity.json")
|
||||
if err := saveIdentity(path, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readIdentityImport(path, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PrivateJWK.D != id.PrivateJWK.D {
|
||||
t.Fatal("raw identity import mismatch")
|
||||
}
|
||||
}
|
||||
89
cmd/client/main.go
Normal file
89
cmd/client/main.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
base := flag.String("url", envOr("NEURALHUNT_URL", "http://127.0.0.1:8080"), "Neural Hunt server URL")
|
||||
identityPath := flag.String("identity", envOr("NEURALHUNT_IDENTITY", defaultIdentityPath()), "persistent terminal identity file")
|
||||
taskSelector := flag.String("task", "", "initial task: number, ID prefix or display name")
|
||||
maxNodes := flag.Int("max-nodes", 500, "maximum target-field working set")
|
||||
nonInteractive := flag.Bool("non-interactive", false, "run unattended without command prompt")
|
||||
quiet := flag.Bool("quiet", false, "suppress connection/status chatter")
|
||||
importPath := flag.String("import", "", "import browser/terminal identity JSON before login")
|
||||
exportPath := flag.String("export", "", "export current identity in browser-compatible encrypted format and exit")
|
||||
passphrase := flag.String("passphrase", os.Getenv("NEURALHUNT_IDENTITY_PASSPHRASE"), "identity import/export passphrase (prefer environment variable)")
|
||||
flag.Parse()
|
||||
|
||||
if strings.TrimSpace(*importPath) != "" {
|
||||
id, err := readIdentityImport(*importPath, *passphrase)
|
||||
if err != nil {
|
||||
log.Fatal("identity import: ", err)
|
||||
}
|
||||
if err := saveIdentity(*identityPath, id); err != nil {
|
||||
log.Fatal("save imported identity: ", err)
|
||||
}
|
||||
fmt.Println("Identity importiert nach", *identityPath)
|
||||
}
|
||||
|
||||
id, key, created, err := loadOrCreateIdentity(*identityPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if *exportPath != "" {
|
||||
if err := exportBrowserIdentity(*exportPath, *passphrase, id); err != nil {
|
||||
log.Fatal("identity export: ", err)
|
||||
}
|
||||
fmt.Println("Browser-kompatibler verschlüsselter Export:", *exportPath)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
api := newAPI(*base, id, key)
|
||||
if err := api.login(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
fmt.Println("Neue Terminal-Identität erzeugt:", *identityPath)
|
||||
}
|
||||
fmt.Println("NEURAL HUNT SHELL")
|
||||
fmt.Println("Identity:", api.cid)
|
||||
fmt.Println("Server :", strings.TrimRight(*base, "/"))
|
||||
fmt.Println("Hinweis : Dieselbe Identity darf nicht gleichzeitig im Browser verbunden sein.")
|
||||
|
||||
a := newApp(api, *identityPath, *passphrase, *maxNodes, *quiet, *nonInteractive)
|
||||
initial, err := selectInitialTask(ctx, a, *taskSelector, !*nonInteractive)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := a.startTask(ctx, initial); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go a.watcher(ctx)
|
||||
|
||||
if *nonInteractive {
|
||||
<-ctx.Done()
|
||||
a.stopSession()
|
||||
return
|
||||
}
|
||||
if err := a.commandLoop(ctx, os.Stdin); err != nil && ctx.Err() == nil {
|
||||
log.Println(err)
|
||||
}
|
||||
a.stopSession()
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
782
cmd/client/ui.go
Normal file
782
cmd/client/ui.go
Normal file
@@ -0,0 +1,782 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type wsEvent struct {
|
||||
Type string `json:"type"`
|
||||
TaskID string `json:"task_id"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type app struct {
|
||||
api *apiClient
|
||||
identityPath string
|
||||
passphrase string
|
||||
maxNodes int
|
||||
quiet bool
|
||||
unattended bool
|
||||
|
||||
mu sync.RWMutex
|
||||
task taskDTO
|
||||
seq int64
|
||||
points map[string]point
|
||||
me meDTO
|
||||
ws *websocket.Conn
|
||||
wsConnected bool
|
||||
sessionCancel context.CancelFunc
|
||||
switching bool
|
||||
leaderWatch bool
|
||||
lastGuessAt time.Time
|
||||
lastGuessOK bool
|
||||
}
|
||||
|
||||
func newApp(api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app {
|
||||
if maxNodes < 50 {
|
||||
maxNodes = 50
|
||||
}
|
||||
if maxNodes > 10000 {
|
||||
maxNodes = 10000
|
||||
}
|
||||
return &app{api: api, identityPath: identityPath, passphrase: passphrase, maxNodes: maxNodes, quiet: quiet, unattended: unattended, points: make(map[string]point)}
|
||||
}
|
||||
|
||||
func shortID(s string) string {
|
||||
if len(s) <= 12 {
|
||||
return s
|
||||
}
|
||||
return s[:6] + "…" + s[len(s)-4:]
|
||||
}
|
||||
|
||||
func taskName(t taskCard) string {
|
||||
if strings.TrimSpace(t.DisplayName) != "" {
|
||||
return strings.TrimSpace(t.DisplayName)
|
||||
}
|
||||
return "Task " + shortID(t.ID)
|
||||
}
|
||||
|
||||
func dtoName(t taskDTO) string {
|
||||
if strings.TrimSpace(t.DisplayName) != "" {
|
||||
return strings.TrimSpace(t.DisplayName)
|
||||
}
|
||||
return "Task " + shortID(t.ID)
|
||||
}
|
||||
|
||||
func (a *app) printTasks(ctx context.Context) ([]taskCard, error) {
|
||||
ts, err := a.api.tasks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Println("\nACTIVE TASKS")
|
||||
fmt.Println("────────────────────────────────────────────────────────────────────────────")
|
||||
for i, t := range ts {
|
||||
mark := " "
|
||||
if t.Selected {
|
||||
mark = "*"
|
||||
}
|
||||
state := "RUN"
|
||||
if t.Paused {
|
||||
state = "PAUSED"
|
||||
}
|
||||
rank := "—"
|
||||
if t.OwnRank > 0 {
|
||||
rank = fmt.Sprintf("#%d", t.OwnRank)
|
||||
}
|
||||
fmt.Printf("%s %2d %-24s %3dbit %-6s nodes=%-5d score=%7.3f rank=%s\n", mark, i+1, clip(taskName(t), 24), t.RangeBits, state, t.PointCount, t.OwnScore, rank)
|
||||
if d := strings.TrimSpace(t.Description); d != "" {
|
||||
fmt.Printf(" %s\n", clip(d, 70))
|
||||
}
|
||||
fmt.Printf(" id=%s\n", t.ID)
|
||||
}
|
||||
if len(ts) == 0 {
|
||||
fmt.Println("(keine aktiven Tasks)")
|
||||
}
|
||||
fmt.Println()
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) <= n {
|
||||
return string(r)
|
||||
}
|
||||
if n < 2 {
|
||||
return string(r[:n])
|
||||
}
|
||||
return string(r[:n-1]) + "…"
|
||||
}
|
||||
|
||||
func resolveTask(tasks []taskCard, selector string) (taskCard, error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
for _, t := range tasks {
|
||||
if t.Selected {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
if len(tasks) > 0 {
|
||||
return tasks[0], nil
|
||||
}
|
||||
return taskCard{}, fmt.Errorf("no active task")
|
||||
}
|
||||
if n, err := strconv.Atoi(selector); err == nil && n >= 1 && n <= len(tasks) {
|
||||
return tasks[n-1], nil
|
||||
}
|
||||
low := strings.ToLower(selector)
|
||||
var matches []taskCard
|
||||
for _, t := range tasks {
|
||||
if strings.EqualFold(t.ID, selector) || strings.HasPrefix(strings.ToLower(t.ID), low) || strings.Contains(strings.ToLower(t.DisplayName), low) {
|
||||
matches = append(matches, t)
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], nil
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return taskCard{}, fmt.Errorf("task selector %q is ambiguous", selector)
|
||||
}
|
||||
return taskCard{}, fmt.Errorf("task %q not found", selector)
|
||||
}
|
||||
|
||||
func (a *app) stopSession() {
|
||||
a.mu.Lock()
|
||||
cancel := a.sessionCancel
|
||||
conn := a.ws
|
||||
a.sessionCancel = nil
|
||||
a.ws = nil
|
||||
a.wsConnected = false
|
||||
a.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if conn != nil {
|
||||
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "switch task"), time.Now().Add(200*time.Millisecond))
|
||||
_ = conn.Close()
|
||||
}
|
||||
// Give the server's single-identity presence cleanup a short chance to run.
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
}
|
||||
|
||||
func (a *app) startTask(ctx context.Context, taskID string) error {
|
||||
a.mu.Lock()
|
||||
if a.switching {
|
||||
a.mu.Unlock()
|
||||
return errSwitching
|
||||
}
|
||||
a.switching = true
|
||||
a.mu.Unlock()
|
||||
defer func() {
|
||||
a.mu.Lock()
|
||||
a.switching = false
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
|
||||
a.stopSession()
|
||||
var (
|
||||
t taskDTO
|
||||
err error
|
||||
)
|
||||
if taskID != "" {
|
||||
t, err = a.api.selectTask(ctx, taskID)
|
||||
} else {
|
||||
t, err = a.api.currentTask(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ws, err := a.api.dialWS(ctx, a.maxNodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ps, _ := a.api.points(ctx, t.ID, a.maxNodes*3)
|
||||
m, _ := a.api.me(ctx)
|
||||
|
||||
sctx, cancel := context.WithCancel(context.Background())
|
||||
a.mu.Lock()
|
||||
a.task, a.seq, a.ws, a.sessionCancel, a.me = t, t.NextSeq, ws, cancel, m
|
||||
a.wsConnected = true
|
||||
a.points = make(map[string]point, len(ps))
|
||||
for _, p := range ps {
|
||||
a.points[p.ClientID] = p
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
go a.wsLoop(sctx, ws, t.ID)
|
||||
go a.guessLoop(sctx, t.ID)
|
||||
if !a.quiet {
|
||||
fmt.Printf("\n▶ %s [%s] %d bit\n", dtoName(t), shortID(t.ID), t.RangeBits)
|
||||
if t.Paused {
|
||||
fmt.Println(" Task ist pausiert; automatische Tipps warten.")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) wsLoop(ctx context.Context, initial *websocket.Conn, taskID string) {
|
||||
conn := initial
|
||||
backoff := 500 * time.Millisecond
|
||||
for {
|
||||
completed, err := a.readWSOnce(ctx, conn, taskID)
|
||||
a.mu.Lock()
|
||||
if a.ws == conn {
|
||||
a.ws = nil
|
||||
a.wsConnected = false
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if completed || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil && !a.quiet {
|
||||
fmt.Printf("\n[ws] Verbindung beendet: %v; Reconnect folgt automatisch\n", err)
|
||||
}
|
||||
|
||||
for {
|
||||
timer := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
next, dialErr := a.api.dialWS(ctx, a.maxNodes)
|
||||
if dialErr != nil {
|
||||
if !a.quiet {
|
||||
fmt.Printf("[ws] Reconnect fehlgeschlagen: %v\n", dialErr)
|
||||
}
|
||||
backoff = time.Duration(minInt64(int64(8*time.Second), int64(float64(backoff)*1.7)))
|
||||
continue
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.task.ID != taskID || ctx.Err() != nil {
|
||||
a.mu.Unlock()
|
||||
_ = next.Close()
|
||||
return
|
||||
}
|
||||
a.ws = next
|
||||
a.wsConnected = true
|
||||
a.mu.Unlock()
|
||||
conn = next
|
||||
backoff = 500 * time.Millisecond
|
||||
go a.refreshTask(taskID)
|
||||
if !a.quiet {
|
||||
fmt.Println("[ws] wieder verbunden")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func minInt64(a, b int64) int64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (a *app) forceWSReconnect() {
|
||||
a.mu.Lock()
|
||||
conn := a.ws
|
||||
a.ws = nil
|
||||
a.wsConnected = false
|
||||
a.mu.Unlock()
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) readWSOnce(ctx context.Context, conn *websocket.Conn, taskID string) (bool, error) {
|
||||
for {
|
||||
_, b, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return false, ctx.Err()
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
var ev wsEvent
|
||||
if json.Unmarshal(b, &ev) != nil {
|
||||
continue
|
||||
}
|
||||
switch ev.Type {
|
||||
case "snapshot":
|
||||
var ps []point
|
||||
if json.Unmarshal(ev.Data, &ps) == nil {
|
||||
a.replacePoints(ps)
|
||||
}
|
||||
case "point":
|
||||
var p point
|
||||
if json.Unmarshal(ev.Data, &p) == nil {
|
||||
a.upsertPoint(p)
|
||||
}
|
||||
case "points":
|
||||
var ps []point
|
||||
if json.Unmarshal(ev.Data, &ps) == nil {
|
||||
for _, p := range ps {
|
||||
a.upsertPoint(p)
|
||||
}
|
||||
}
|
||||
case "task_changed":
|
||||
if ev.TaskID == "" || ev.TaskID == taskID {
|
||||
go a.refreshTask(taskID)
|
||||
}
|
||||
case "task_completed":
|
||||
if ev.TaskID == taskID {
|
||||
if !a.quiet {
|
||||
fmt.Println("\n✓ Task abgeschlossen. Wechsle auf den Folge-Task …")
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(450 * time.Millisecond)
|
||||
if err := a.startTask(context.Background(), ""); err != nil && !a.quiet {
|
||||
fmt.Printf("[task] Folge-Task noch nicht bereit: %v\n", err)
|
||||
}
|
||||
}()
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) replacePoints(ps []point) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.points = make(map[string]point, len(ps))
|
||||
for _, p := range ps {
|
||||
a.points[p.ClientID] = p
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) upsertPoint(p point) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.points == nil {
|
||||
a.points = make(map[string]point)
|
||||
}
|
||||
a.points[p.ClientID] = p
|
||||
if p.ClientID == a.api.cid {
|
||||
a.me.Score, a.me.Rank = p.Score, p.Rank
|
||||
}
|
||||
if len(a.points) > a.maxNodes*3 {
|
||||
a.trimPointsLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) trimPointsLocked() {
|
||||
ps := make([]point, 0, len(a.points))
|
||||
for _, p := range a.points {
|
||||
ps = append(ps, p)
|
||||
}
|
||||
sort.Slice(ps, func(i, j int) bool { return ps[i].Score > ps[j].Score })
|
||||
keep := a.maxNodes * 3
|
||||
if keep > len(ps) {
|
||||
keep = len(ps)
|
||||
}
|
||||
next := make(map[string]point, keep+1)
|
||||
for _, p := range ps[:keep] {
|
||||
next[p.ClientID] = p
|
||||
}
|
||||
if own, ok := a.points[a.api.cid]; ok {
|
||||
next[own.ClientID] = own
|
||||
}
|
||||
a.points = next
|
||||
}
|
||||
|
||||
func (a *app) refreshTask(taskID string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
t, err := a.api.currentTask(ctx)
|
||||
if err != nil || t.ID != taskID {
|
||||
return
|
||||
}
|
||||
ps, _ := a.api.points(ctx, t.ID, a.maxNodes*3)
|
||||
m, _ := a.api.me(ctx)
|
||||
a.mu.Lock()
|
||||
oldSeed := a.task.PublicSeed
|
||||
a.task = t
|
||||
// Preserve the hot sequence for same-seed changes (bits/intervals/config).
|
||||
// A reroll changes public_seed and intentionally resets to the server state.
|
||||
if oldSeed != t.PublicSeed {
|
||||
a.seq = t.NextSeq
|
||||
} else if t.NextSeq > a.seq {
|
||||
a.seq = t.NextSeq
|
||||
}
|
||||
if m.ClientID != "" {
|
||||
a.me = m
|
||||
}
|
||||
if ps != nil {
|
||||
a.points = make(map[string]point, len(ps))
|
||||
for _, p := range ps {
|
||||
a.points[p.ClientID] = p
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if !a.quiet {
|
||||
fmt.Printf("\n[task] Konfiguration aktualisiert: %d bit, Intervall %ds, paused=%v\n", t.RangeBits, t.ClientSubmitIntervalSec, t.Paused)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) guessLoop(ctx context.Context, taskID string) {
|
||||
for {
|
||||
a.mu.RLock()
|
||||
t := a.task
|
||||
a.mu.RUnlock()
|
||||
if t.ID != taskID {
|
||||
return
|
||||
}
|
||||
sec := t.ClientSubmitIntervalSec
|
||||
if sec <= 0 {
|
||||
sec = 11
|
||||
}
|
||||
timer := time.NewTimer(time.Duration(sec) * time.Second)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
// Re-read state after sleeping. Admin actions may have changed seed/bits,
|
||||
// sequence or pause state while this iteration was waiting.
|
||||
a.mu.RLock()
|
||||
t = a.task
|
||||
seq := a.seq
|
||||
connected := a.wsConnected
|
||||
a.mu.RUnlock()
|
||||
if t.ID != taskID {
|
||||
return
|
||||
}
|
||||
if t.Paused || !connected {
|
||||
continue
|
||||
}
|
||||
correct, err := a.api.guess(ctx, t, seq)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if ae := new(apiError); errorsAs(err, &ae) && (ae.Status == 409 || ae.Status == 423 || ae.Status == 429) {
|
||||
a.refreshTask(taskID)
|
||||
if ae.Status == 409 && ae.Code() == "presence_required" {
|
||||
a.forceWSReconnect()
|
||||
}
|
||||
} else if !a.quiet {
|
||||
fmt.Printf("\n[guess] %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.task.ID == taskID && a.seq == seq {
|
||||
a.seq++
|
||||
}
|
||||
a.lastGuessAt = time.Now()
|
||||
a.lastGuessOK = true
|
||||
a.mu.Unlock()
|
||||
if correct {
|
||||
fmt.Printf("\n★ GEWONNEN: %s ★\n", dtoName(t))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// errorsAs is a tiny wrapper to keep the UI file's imports readable.
|
||||
func errorsAs(err error, target any) bool { return errors.As(err, target) }
|
||||
|
||||
func (a *app) printStatus(ctx context.Context) {
|
||||
m, err := a.api.me(ctx)
|
||||
if err == nil {
|
||||
a.mu.Lock()
|
||||
a.me = m
|
||||
a.mu.Unlock()
|
||||
}
|
||||
a.mu.RLock()
|
||||
t, seq, points, me, last := a.task, a.seq, len(a.points), a.me, a.lastGuessAt
|
||||
a.mu.RUnlock()
|
||||
fmt.Println("\nSTATUS")
|
||||
fmt.Println("────────────────────────────────────────────────────────")
|
||||
fmt.Printf("Identity : %s\n", a.api.cid)
|
||||
fmt.Printf("Task : %s (%s)\n", dtoName(t), t.ID)
|
||||
fmt.Printf("Raum : %d bit Revision %d Paused %v\n", t.RangeBits, t.Revision, t.Paused)
|
||||
fmt.Printf("Score : %.4f Rank #%d Wins %d\n", me.Score, me.Rank, me.Wins)
|
||||
fmt.Printf("Sequence : %d Nodes im Working Set %d\n", seq, points)
|
||||
if !last.IsZero() {
|
||||
fmt.Printf("Letzter Tipp: %s\n", last.Format("15:04:05"))
|
||||
}
|
||||
if len(me.Unlocks) > 0 {
|
||||
fmt.Printf("Unlocks : %s\n", strings.Join(me.Unlocks, ", "))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func (a *app) printMap(limit int) {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
a.mu.RLock()
|
||||
ps := make([]point, 0, len(a.points))
|
||||
for _, p := range a.points {
|
||||
ps = append(ps, p)
|
||||
}
|
||||
cid := a.api.cid
|
||||
t := a.task
|
||||
a.mu.RUnlock()
|
||||
sort.Slice(ps, func(i, j int) bool {
|
||||
if ps[i].Score == ps[j].Score {
|
||||
return ps[i].ClientID < ps[j].ClientID
|
||||
}
|
||||
return ps[i].Score > ps[j].Score
|
||||
})
|
||||
if len(ps) > limit {
|
||||
ps = ps[:limit]
|
||||
}
|
||||
fmt.Printf("\nTARGET FIELD — %s\n", dtoName(t))
|
||||
fmt.Println("TASK ◉ ← höherer Score bedeutet näher am Zentrum")
|
||||
fmt.Println("────────────────────────────────────────────────────────────────")
|
||||
bands := []struct {
|
||||
name string
|
||||
min float64
|
||||
max float64
|
||||
}{{"99+ INNER CORE", 99, 101}, {"95–99 NEAR", 95, 99}, {"90–95 CLOSE", 90, 95}, {"75–90 MID", 75, 90}, {"50–75 FAR", 50, 75}, {"0–50 OUTER", 0, 50}}
|
||||
for _, b := range bands {
|
||||
var names []string
|
||||
for _, p := range ps {
|
||||
if p.Score >= b.min && p.Score < b.max {
|
||||
mark := ""
|
||||
if p.ClientID == cid {
|
||||
mark = "*"
|
||||
}
|
||||
names = append(names, fmt.Sprintf("%s%s %.3f", mark, shortID(p.ClientID), p.Score))
|
||||
}
|
||||
}
|
||||
fmt.Printf("%-14s │ %s\n", b.name, strings.Join(names, " "))
|
||||
}
|
||||
fmt.Println("* = deine Identität")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func (a *app) printLeaderboard(ctx context.Context, limit int) error {
|
||||
ls, err := a.api.leaderboard(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if limit <= 0 || limit > len(ls) {
|
||||
limit = minInt(25, len(ls))
|
||||
}
|
||||
fmt.Println("\nREALTIME LEADERBOARD")
|
||||
fmt.Println("──────────────────────────────────────────────────────────────────────────")
|
||||
fmt.Printf("%-5s %-15s %6s %10s %10s %8s %5s\n", "RANK", "CLIENT", "WINS", "LIVE", "BEST", "GUESSES", "NFT")
|
||||
for i, l := range ls[:limit] {
|
||||
conn := " "
|
||||
if l.Connected {
|
||||
conn = "●"
|
||||
}
|
||||
self := ""
|
||||
if l.ClientID == a.api.cid {
|
||||
self = "*"
|
||||
}
|
||||
fmt.Printf("#%-4d %-15s %6d %10.4f %10.4f %8d %5d\n", i+1, conn+self+shortID(l.ClientID), l.Wins, l.LiveScore, l.BestScore, l.GuessCount, l.NFTCount)
|
||||
}
|
||||
fmt.Println("● online * du")
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) printNFTs(ctx context.Context, limit int) error {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
items, err := a.api.artifacts(ctx, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("\nNFT / WINNER ARTIFACTS (öffentliche Wasserzeichen-Previews)")
|
||||
fmt.Println("──────────────────────────────────────────────────────────────────────────")
|
||||
for i, n := range items {
|
||||
fmt.Printf("%2d task=%-16s winner=%-14s %3dbit %s\n", i+1, shortID(n.TaskID), shortID(n.WinnerClientID), n.RangeBits, n.CompletedAt.Local().Format("2006-01-02 15:04"))
|
||||
fmt.Printf(" %s%s\n", a.api.base, n.PreviewURI)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
fmt.Println("(noch keine fertigen Artefakte)")
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) watcher(ctx context.Context) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
a.mu.RLock()
|
||||
watch := a.leaderWatch
|
||||
task := a.task
|
||||
seq := a.seq
|
||||
me := a.me
|
||||
a.mu.RUnlock()
|
||||
if watch {
|
||||
_ = a.printLeaderboard(ctx, 15)
|
||||
} else if a.unattended && !a.quiet {
|
||||
fmt.Printf("%s task=%s score=%.4f rank=%d seq=%d paused=%v\n", time.Now().Format("15:04:05"), shortID(task.ID), me.Score, me.Rank, seq, task.Paused)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
s := bufio.NewScanner(in)
|
||||
fmt.Println("Befehle: help, tasks, use <nr|id|name>, status, map [n], leaderboard [n|watch|stop], nfts [n], nft get <task-id> <datei>, identity, identity export <datei>, quit")
|
||||
for {
|
||||
fmt.Print("neuralhunt> ")
|
||||
if !s.Scan() {
|
||||
return s.Err()
|
||||
}
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
cmd := strings.ToLower(parts[0])
|
||||
switch cmd {
|
||||
case "help", "?":
|
||||
fmt.Println(" tasks aktive Tasks anzeigen")
|
||||
fmt.Println(" use <nr|id|name> Task wechseln")
|
||||
fmt.Println(" status Score, Rank, Sequence, Task")
|
||||
fmt.Println(" map [n] textuelles Target Field / Nähe")
|
||||
fmt.Println(" leaderboard [n] Live-Rangliste")
|
||||
fmt.Println(" leaderboard watch|stop Rangliste alle 5s ein/aus")
|
||||
fmt.Println(" nfts [n] Wasserzeichen-NFTs anzeigen")
|
||||
fmt.Println(" nft get <task-id> <datei> Wasserzeichen-Preview speichern")
|
||||
fmt.Println(" identity Client-ID und Identity-Datei")
|
||||
fmt.Println(" identity export <datei> browser-kompatiblen verschlüsselten Export schreiben")
|
||||
fmt.Println(" quit beenden")
|
||||
case "tasks":
|
||||
if _, err := a.printTasks(ctx); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "use":
|
||||
if len(parts) < 2 {
|
||||
fmt.Println("use benötigt Nummer, ID-Präfix oder Namen")
|
||||
continue
|
||||
}
|
||||
ts, err := a.api.tasks(ctx)
|
||||
if err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
continue
|
||||
}
|
||||
t, err := resolveTask(ts, strings.Join(parts[1:], " "))
|
||||
if err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
continue
|
||||
}
|
||||
if err := a.startTask(ctx, t.ID); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "status":
|
||||
a.printStatus(ctx)
|
||||
case "map":
|
||||
n := 30
|
||||
if len(parts) > 1 {
|
||||
n, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
a.printMap(n)
|
||||
case "leaderboard", "lb":
|
||||
if len(parts) > 1 && strings.EqualFold(parts[1], "watch") {
|
||||
a.mu.Lock()
|
||||
a.leaderWatch = true
|
||||
a.mu.Unlock()
|
||||
fmt.Println("Leaderboard-Watch aktiviert.")
|
||||
continue
|
||||
}
|
||||
if len(parts) > 1 && (strings.EqualFold(parts[1], "stop") || strings.EqualFold(parts[1], "off")) {
|
||||
a.mu.Lock()
|
||||
a.leaderWatch = false
|
||||
a.mu.Unlock()
|
||||
fmt.Println("Leaderboard-Watch deaktiviert.")
|
||||
continue
|
||||
}
|
||||
n := 25
|
||||
if len(parts) > 1 {
|
||||
n, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
if err := a.printLeaderboard(ctx, n); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "nfts":
|
||||
n := 30
|
||||
if len(parts) > 1 {
|
||||
n, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
if err := a.printNFTs(ctx, n); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "nft":
|
||||
if len(parts) == 4 && strings.EqualFold(parts[1], "get") {
|
||||
if err := a.api.downloadPreview(ctx, parts[2], parts[3]); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
} else {
|
||||
fmt.Println("Wasserzeichen-Preview gespeichert:", parts[3])
|
||||
}
|
||||
} else {
|
||||
fmt.Println("nft get <task-id> <datei>")
|
||||
}
|
||||
case "identity", "id":
|
||||
if len(parts) >= 2 && strings.EqualFold(parts[1], "export") {
|
||||
if len(parts) != 3 {
|
||||
fmt.Println("identity export <datei>")
|
||||
continue
|
||||
}
|
||||
if err := exportBrowserIdentity(parts[2], a.passphrase, a.api.id); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
} else {
|
||||
fmt.Println("Verschlüsselter Browser-Export geschrieben:", parts[2])
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Client-ID:", a.api.cid)
|
||||
fmt.Println("Identity :", a.identityPath)
|
||||
}
|
||||
case "quit", "exit", "q":
|
||||
return nil
|
||||
default:
|
||||
fmt.Println("Unbekannter Befehl. 'help' zeigt die Befehle.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func selectInitialTask(ctx context.Context, a *app, selector string, interactive bool) (string, error) {
|
||||
ts, err := a.printTasks(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ts) == 0 {
|
||||
return "", fmt.Errorf("server has no active tasks")
|
||||
}
|
||||
if selector != "" || !interactive {
|
||||
t, err := resolveTask(ts, selector)
|
||||
return t.ID, err
|
||||
}
|
||||
fmt.Print("Task auswählen [Nummer/ID/Name, Enter = markierter Task]: ")
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
t, err := resolveTask(ts, strings.TrimSpace(line))
|
||||
return t.ID, err
|
||||
}
|
||||
239
cmd/loadtest/main.go
Normal file
239
cmd/loadtest/main.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"neuralhunt/internal/auth"
|
||||
"neuralhunt/internal/core"
|
||||
)
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
type apiClient struct {
|
||||
base string
|
||||
hc *http.Client
|
||||
token, cid string
|
||||
key *ecdsa.PrivateKey
|
||||
}
|
||||
type taskDTO struct {
|
||||
ID string `json:"id"`
|
||||
PublicSeed string `json:"public_seed"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
NextSeq int64 `json:"next_seq"`
|
||||
SubmitSec int `json:"client_submit_interval_sec"`
|
||||
Paused bool `json:"paused"`
|
||||
}
|
||||
|
||||
func pad32(x *big.Int) []byte {
|
||||
b := x.Bytes()
|
||||
out := make([]byte, 32)
|
||||
copy(out[32-len(b):], b)
|
||||
return out
|
||||
}
|
||||
func signRaw(k *ecdsa.PrivateKey, msg string) (string, error) {
|
||||
h := sha256.Sum256([]byte(msg))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, k, h[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw := append(pad32(r), pad32(s)...)
|
||||
return b64.EncodeToString(raw), nil
|
||||
}
|
||||
func (c *apiClient) do(method, path string, body any, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, c.base+path, rd)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("%s: %s", resp.Status, string(b))
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *apiClient) authn() error {
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.key = k
|
||||
j := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: b64.EncodeToString(pad32(k.X)), Y: b64.EncodeToString(pad32(k.Y)), Ext: true}
|
||||
var ch struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
if err = c.do("POST", "/api/auth/challenge", map[string]any{"public_jwk": j}, &ch); err != nil {
|
||||
return err
|
||||
}
|
||||
c.cid = ch.ClientID
|
||||
sig, _ := signRaw(k, "login|"+ch.Challenge+"|"+c.cid)
|
||||
var lg struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err = c.do("POST", "/api/auth/login", map[string]any{"public_jwk": j, "challenge": ch.Challenge, "signature": sig}, &lg); err != nil {
|
||||
return err
|
||||
}
|
||||
c.token = lg.Token
|
||||
return nil
|
||||
}
|
||||
func (c *apiClient) current() (taskDTO, error) {
|
||||
var t taskDTO
|
||||
err := c.do("GET", "/api/tasks/current", nil, &t)
|
||||
return t, err
|
||||
}
|
||||
func (c *apiClient) ws(ctx context.Context, maxNodes int) (*websocket.Conn, error) {
|
||||
u, err := url.Parse(c.base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scheme := "ws"
|
||||
if u.Scheme == "https" {
|
||||
scheme = "wss"
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("token", c.token)
|
||||
q.Set("max_nodes", fmt.Sprint(maxNodes))
|
||||
wu := scheme + "://" + u.Host + "/api/ws?" + q.Encode()
|
||||
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wu, nil)
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func main() {
|
||||
base := flag.String("url", "http://127.0.0.1:8080", "server URL")
|
||||
clients := flag.Int("clients", 1000, "virtual clients")
|
||||
ramp := flag.Duration("ramp", 30*time.Second, "connection ramp")
|
||||
duration := flag.Duration("duration", 2*time.Minute, "test duration after ramp")
|
||||
nodes := flag.Int("max-nodes", 250, "snapshot budget per client")
|
||||
flag.Parse()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var connected, guesses, errs atomic.Uint64
|
||||
var wg sync.WaitGroup
|
||||
start := time.Now()
|
||||
step := time.Duration(0)
|
||||
if *clients > 0 {
|
||||
step = *ramp / time.Duration(*clients)
|
||||
}
|
||||
for i := 0; i < *clients; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
if step > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(step * time.Duration(i)):
|
||||
}
|
||||
}
|
||||
c := &apiClient{base: strings.TrimRight(*base, "/"), hc: &http.Client{Timeout: 10 * time.Second}}
|
||||
if err := c.authn(); err != nil {
|
||||
errs.Add(1)
|
||||
return
|
||||
}
|
||||
t, err := c.current()
|
||||
if err != nil {
|
||||
errs.Add(1)
|
||||
return
|
||||
}
|
||||
ws, err := c.ws(ctx, *nodes)
|
||||
if err != nil {
|
||||
errs.Add(1)
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
connected.Add(1)
|
||||
go func() {
|
||||
for {
|
||||
if _, _, e := ws.ReadMessage(); e != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
interval := time.Duration(t.SubmitSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 11 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
seq := t.NextSeq
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if t.Paused {
|
||||
continue
|
||||
}
|
||||
guess := core.ExpectedGuess(t.ID, t.PublicSeed, c.cid, seq, t.RangeBits)
|
||||
sig, _ := signRaw(c.key, fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess))
|
||||
var ok bool
|
||||
err := c.do("POST", "/api/tasks/"+t.ID+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig}, &ok)
|
||||
if err != nil {
|
||||
errs.Add(1)
|
||||
nt, e := c.current()
|
||||
if e == nil {
|
||||
t = nt
|
||||
seq = t.NextSeq
|
||||
}
|
||||
continue
|
||||
}
|
||||
guesses.Add(1)
|
||||
seq++
|
||||
if ok {
|
||||
nt, e := c.current()
|
||||
if e == nil {
|
||||
t = nt
|
||||
seq = t.NextSeq
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
tick := time.NewTicker(5 * time.Second)
|
||||
defer tick.Stop()
|
||||
end := time.After(*ramp + *duration)
|
||||
for {
|
||||
select {
|
||||
case <-end:
|
||||
cancel()
|
||||
wg.Wait()
|
||||
fmt.Printf("done connected=%d accepted_guesses=%d errors=%d elapsed=%s\n", connected.Load(), guesses.Load(), errs.Load(), time.Since(start).Round(time.Second))
|
||||
return
|
||||
case <-tick.C:
|
||||
log.Printf("connected=%d guesses=%d errors=%d", connected.Load(), guesses.Load(), errs.Load())
|
||||
}
|
||||
}
|
||||
}
|
||||
109
cmd/server/main.go
Normal file
109
cmd/server/main.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/artifact"
|
||||
"neuralhunt/internal/auth"
|
||||
"neuralhunt/internal/data"
|
||||
rtx "neuralhunt/internal/runtime"
|
||||
"neuralhunt/internal/server"
|
||||
"neuralhunt/internal/settings"
|
||||
wsx "neuralhunt/internal/ws"
|
||||
)
|
||||
|
||||
func env(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func loadDotEnv(path string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "export ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := os.LookupEnv(k); exists {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) {
|
||||
v = v[1 : len(v)-1]
|
||||
}
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
loadDotEnv(".env")
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||
defer cancel()
|
||||
|
||||
db, err := data.OpenSQLite(ctx, env("SQLITE_PATH", "./data/neuralhunt.db"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
sm, err := settings.New(ctx, db)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go sm.Run(ctx)
|
||||
|
||||
store := data.New(db)
|
||||
a := auth.New(db, env("JWT_SECRET", "dev-secret-change-me"))
|
||||
hub := wsx.New()
|
||||
runtimeState := rtx.New()
|
||||
go hub.Run(ctx)
|
||||
|
||||
artifactDir := env("ARTIFACT_DIR", "./data/artifacts")
|
||||
aw, err := artifact.New(db, artifactDir, sm)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go aw.Run(ctx)
|
||||
|
||||
srv := server.New(store, a, sm, hub, runtimeState, artifactDir, aw)
|
||||
go srv.Scheduler(ctx)
|
||||
|
||||
httpSrv := &http.Server{Addr: env("HTTP_ADDR", ":8080"), Handler: srv.Routes(), ReadHeaderTimeout: 5 * time.Second}
|
||||
go func() {
|
||||
log.Printf("listening on %s", httpSrv.Addr)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
shutdown, done := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer done()
|
||||
_ = httpSrv.Shutdown(shutdown)
|
||||
}
|
||||
66
deploy/k8s/app.yaml
Normal file
66
deploy/k8s/app.yaml
Normal file
@@ -0,0 +1,66 @@
|
||||
# SQLite edition: intentionally ONE replica. Horizontal multi-host writers are
|
||||
# not safe with a shared SQLite WAL file. Use the Docker deployment for the
|
||||
# intended standalone mode, or switch back to a client/server DB for multi-host.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: neuralhunt-data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 5Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: neuralhunt
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: neuralhunt
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: neuralhunt
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: your-registry/neuralhunt:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
env:
|
||||
- name: SQLITE_PATH
|
||||
value: /data/neuralhunt.db
|
||||
- name: ARTIFACT_DIR
|
||||
value: /data/artifacts
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: neuralhunt-secrets
|
||||
- configMapRef:
|
||||
name: neuralhunt-config
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/healthz
|
||||
port: 8080
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 5
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: neuralhunt-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: neuralhunt
|
||||
spec:
|
||||
selector:
|
||||
app: neuralhunt
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
env_file: .env
|
||||
environment:
|
||||
SQLITE_PATH: /data/neuralhunt.db
|
||||
ARTIFACT_DIR: /data/artifacts
|
||||
ports:
|
||||
- "8080:8080"
|
||||
# Lets the optional ComfyUI/A1111 providers reach a UI running on the Docker host.
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- neuralhunt_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
neuralhunt_data: {}
|
||||
22
go.mod
Normal file
22
go.mod
Normal file
@@ -0,0 +1,22 @@
|
||||
module neuralhunt
|
||||
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
modernc.org/sqlite v1.38.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
modernc.org/libc v1.66.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
53
go.sum
Normal file
53
go.sum
Normal file
@@ -0,0 +1,53 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
BIN
internal/artifact/assets/style_reference.jpg
Normal file
BIN
internal/artifact/assets/style_reference.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
116
internal/artifact/card.go
Normal file
116
internal/artifact/card.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func imageMIME(ext string) string {
|
||||
switch strings.ToLower(strings.TrimPrefix(ext, ".")) {
|
||||
case "jpg", "jpeg":
|
||||
return "image/jpeg"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
case "svg":
|
||||
return "image/svg+xml"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
func trimLabel(s string, maxRunes int) string {
|
||||
s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
|
||||
if s == "" {
|
||||
return "NEURAL HUNT"
|
||||
}
|
||||
if utf8.RuneCountInString(s) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
return string(r[:maxRunes-1]) + "…"
|
||||
}
|
||||
|
||||
// renderCardSVG converts raw generated artwork into the deterministic final
|
||||
// collectible card. Keeping the typography/layout outside the image model makes
|
||||
// all cards line up exactly and avoids model-generated fake text/logos.
|
||||
func renderCardSVG(art []byte, artExt string, x win, traits collectionTraits) []byte {
|
||||
encoded := base64.StdEncoding.EncodeToString(art)
|
||||
series := html.EscapeString(strings.ToUpper(trimLabel(x.DisplayName, 28)))
|
||||
theme := html.EscapeString(strings.ToUpper(trimLabel(traits.ThemeName, 24)))
|
||||
rarity := html.EscapeString(traits.Rarity)
|
||||
completed := x.Completed.UTC().Format("2006.01.02")
|
||||
mime := imageMIME(artExt)
|
||||
|
||||
var sparkles strings.Builder
|
||||
h := sha256.Sum256([]byte(x.ID + "|card|" + x.Winner + "|" + x.Seed))
|
||||
for i := 0; i < 42; i++ {
|
||||
b0 := int(h[i%32])
|
||||
b1 := int(h[(i*7+5)%32])
|
||||
xv := 62 + (b0*37+i*97)%900
|
||||
yv := 54 + (b1*29+i*83)%1420
|
||||
r := 1 + (b0+i)%4
|
||||
op := 12 + (b1 % 34)
|
||||
fmt.Fprintf(&sparkles, `<circle cx="%d" cy="%d" r="%d" fill="white" opacity="0.%02d"/>`, xv, yv, r, op)
|
||||
}
|
||||
|
||||
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1536" viewBox="0 0 1024 1536" role="img" aria-label="Neural Hunt collectible card">
|
||||
<defs>
|
||||
<clipPath id="cardClip"><rect x="26" y="26" width="972" height="1484" rx="58"/></clipPath>
|
||||
<clipPath id="artClip"><rect x="42" y="42" width="940" height="1452" rx="48"/></clipPath>
|
||||
<linearGradient id="metal" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#f4f7ff"/><stop offset=".18" stop-color="%s"/><stop offset=".48" stop-color="#101725"/><stop offset=".73" stop-color="%s"/><stop offset="1" stop-color="#e9f2ff"/></linearGradient>
|
||||
<linearGradient id="topShade" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#050811" stop-opacity=".82"/><stop offset="1" stop-color="#050811" stop-opacity="0"/></linearGradient>
|
||||
<linearGradient id="bottomShade" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#07101d" stop-opacity="0"/><stop offset=".34" stop-color="#07101d" stop-opacity=".58"/><stop offset="1" stop-color="#030711" stop-opacity=".96"/></linearGradient>
|
||||
<linearGradient id="holo" x1="0" y1="0" x2="1" y2="1"><stop stop-color="%s" stop-opacity=".72"/><stop offset=".42" stop-color="#ffffff" stop-opacity=".22"/><stop offset=".67" stop-color="%s" stop-opacity=".64"/><stop offset="1" stop-color="#ffffff" stop-opacity=".06"/></linearGradient>
|
||||
<filter id="shadow"><feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#000000" flood-opacity=".55"/></filter>
|
||||
</defs>
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="18" y="18" width="988" height="1500" rx="66" fill="url(#metal)"/>
|
||||
<rect x="28" y="28" width="968" height="1480" rx="56" fill="#0b101b"/>
|
||||
</g>
|
||||
<g clip-path="url(#artClip)">
|
||||
<image x="42" y="42" width="940" height="1452" preserveAspectRatio="xMidYMid slice" href="data:%s;base64,%s"/>
|
||||
<rect x="42" y="42" width="940" height="360" fill="url(#topShade)"/>
|
||||
<rect x="42" y="940" width="940" height="554" fill="url(#bottomShade)"/>
|
||||
<path d="M-80 270 L760 -80 L1100 80 L180 480 Z" fill="url(#holo)" opacity=".10"/>
|
||||
<path d="M120 1530 L1130 1030 L1130 1190 L330 1530 Z" fill="url(#holo)" opacity=".12"/>
|
||||
<g>%s</g>
|
||||
</g>
|
||||
<rect x="42" y="42" width="940" height="1452" rx="48" fill="none" stroke="#ffffff" stroke-opacity=".28" stroke-width="2"/>
|
||||
<rect x="49" y="49" width="926" height="1438" rx="42" fill="none" stroke="url(#holo)" stroke-opacity=".72" stroke-width="3"/>
|
||||
|
||||
<!-- Header -->
|
||||
<g font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Arial,sans-serif" fill="#ffffff">
|
||||
<text x="76" y="104" font-size="22" font-weight="800" letter-spacing="5" opacity=".76">NEURAL HUNT</text>
|
||||
<line x1="76" y1="118" x2="286" y2="118" stroke="#ffc400" stroke-width="5" stroke-linecap="round" opacity=".92"/>
|
||||
<text x="76" y="154" font-size="17" font-weight="700" letter-spacing="4" opacity=".76">%s</text>
|
||||
<g transform="translate(770 77)">
|
||||
<rect width="166" height="54" rx="27" fill="#060a12" fill-opacity=".72" stroke="url(#holo)" stroke-width="2"/>
|
||||
<text x="83" y="35" text-anchor="middle" font-size="17" font-weight="900" letter-spacing="2">%s</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Bottom collectible information panel -->
|
||||
<g font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Arial,sans-serif" fill="#ffffff">
|
||||
<text x="76" y="1234" font-size="15" font-weight="800" letter-spacing="5" opacity=".62">WINNING EDITION</text>
|
||||
<text x="76" y="1290" font-size="42" font-weight="900" letter-spacing=".8">%s</text>
|
||||
<line x1="76" y1="1320" x2="948" y2="1320" stroke="url(#holo)" stroke-width="2" opacity=".72"/>
|
||||
|
||||
<g transform="translate(76 1345)">
|
||||
<rect width="176" height="50" rx="25" fill="#ffffff" fill-opacity=".10" stroke="#ffffff" stroke-opacity=".18"/>
|
||||
<text x="88" y="32" text-anchor="middle" font-size="15" font-weight="800" letter-spacing="2">%d-BIT HUNT</text>
|
||||
</g>
|
||||
|
||||
<text x="76" y="1442" font-size="14" font-weight="700" letter-spacing="2" opacity=".56">%s · ORIGINAL FULL-ART SERIES</text>
|
||||
<circle cx="930" cy="1437" r="11" fill="%s"/><circle cx="902" cy="1437" r="7" fill="%s"/>
|
||||
</g>
|
||||
</svg>`,
|
||||
traits.AccentA, traits.AccentB, traits.AccentA, traits.AccentB,
|
||||
mime, encoded, sparkles.String(),
|
||||
series, rarity, theme, x.RangeBits, completed, traits.AccentA, traits.AccentB,
|
||||
)
|
||||
return []byte(svg)
|
||||
}
|
||||
316
internal/artifact/collection.go
Normal file
316
internal/artifact/collection.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const collectionPresetRaccoon = "raccoon_full_art_v1"
|
||||
|
||||
const characterAnchorPrompt = `Create the canonical character reference for an original collectible-art series.
|
||||
|
||||
CANONICAL CHARACTER — LOCK THIS IDENTITY:
|
||||
Create one original anthropomorphic silver-gray raccoon named RIFT.
|
||||
Permanent visual features:
|
||||
- charcoal-black raccoon eye mask with a clean, recognizable shape
|
||||
- creamy white muzzle, cheeks and chest
|
||||
- large expressive amber-gold eyes
|
||||
- small rounded ears; one tiny notch in the RIGHT ear
|
||||
- short broad muzzle and a subtle confident half-smile
|
||||
- oversized head with compact athletic humanoid proportions
|
||||
- thick striped raccoon tail with exactly five dark rings
|
||||
- soft sculpted stylized fur clumps, never photographic fur
|
||||
- friendly, clever, confident personality
|
||||
|
||||
WARDROBE FOR THE ANCHOR ONLY:
|
||||
A simple charcoal utility overshirt with no logos, no writing and no theme-specific accessories. Keep the outfit neutral so future images can replace it completely.
|
||||
|
||||
COMPOSITION:
|
||||
Vertical 3/4 character portrait, full torso and most of the tail visible, neutral elegant studio background, clean readable silhouette, centered, no props obscuring the face or tail.
|
||||
|
||||
STYLE:
|
||||
Neutral high-end stylized 3D character illustration, deliberately non-photorealistic, premium animated-feature quality, rounded shapes, rich material detail, cinematic soft key light plus rim light, subtle depth of field, controlled color, polished collectible character reference. Keep the rendering neutral enough that future task-specific style references can deliberately restyle RIFT without changing his identity.
|
||||
|
||||
STRICT:
|
||||
Exactly one raccoon character. No human face. No tiger features. No dreadlocks. No text, numbers, logo, trademark, watermark, card border, UI, duplicate character, photorealistic wildlife or realistic human anatomy.`
|
||||
|
||||
type themeProfile struct {
|
||||
Name string
|
||||
Outfit string
|
||||
Accessories string
|
||||
Scene string
|
||||
Mood string
|
||||
}
|
||||
|
||||
var collectionThemes = []themeProfile{
|
||||
{
|
||||
Name: "Neon Courier",
|
||||
Outfit: "oversized midnight technical bomber, black utility vest with reflective piping, loose futuristic cargo trousers, high-top sneakers and fingerless gloves",
|
||||
Accessories: "compact cross-body courier satchel and a translucent visor pushed above the eyes",
|
||||
Scene: "rain-slick future city alley, glowing shop fronts, cables overhead, thin steam and floating light particles",
|
||||
Mood: "energetic, adventurous and cool; electric night light with warm facial highlights",
|
||||
},
|
||||
{
|
||||
Name: "Sakura Ronin",
|
||||
Outfit: "layered modern ronin jacket, asymmetrical sash, armored fabric panels, tapered trousers and split-toe boots",
|
||||
Accessories: "ornamental sheathed short sword worn as a costume prop, braided cord and a small lacquer charm",
|
||||
Scene: "windy hill path beneath flowering cherry trees, distant lanterns and drifting petals at blue hour",
|
||||
Mood: "calm, disciplined and cinematic with warm lantern rim light",
|
||||
},
|
||||
{
|
||||
Name: "Lunar Botanist",
|
||||
Outfit: "cream exploration suit with padded joints, soft graphite harness, compact boots and botanical specimen pockets",
|
||||
Accessories: "clear bubble helmet carried under one arm and a glowing seed capsule",
|
||||
Scene: "moon greenhouse with alien plants, curved windows, pale regolith outside and tiny floating pollen motes",
|
||||
Mood: "curious, serene and optimistic with silver-blue light and soft green bioluminescence",
|
||||
},
|
||||
{
|
||||
Name: "Apex Racer",
|
||||
Outfit: "sleek original motorsport suit with angular panels, quilted shoulders, racing gloves and lightweight boots; absolutely no real-world team branding",
|
||||
Accessories: "custom unbranded racing helmet tucked at the hip and a small timing wrist display",
|
||||
Scene: "futuristic pit lane after sunset, abstract light streaks, glossy track surface and atmospheric haze",
|
||||
Mood: "focused, fast and triumphant with hard edge lighting and motion energy",
|
||||
},
|
||||
{
|
||||
Name: "Sky Captain",
|
||||
Outfit: "tailored deep-green flight coat, brass-toned fasteners, layered leather-like harness, high boots and a long scarf",
|
||||
Accessories: "round aviator goggles on the forehead, miniature compass and folded flight chart",
|
||||
Scene: "fantasy airship observation deck above glowing clouds, propeller silhouettes and warm sunrise",
|
||||
Mood: "adventurous, noble and windswept with golden backlight",
|
||||
},
|
||||
{
|
||||
Name: "Deep Salvager",
|
||||
Outfit: "stylized pressure-diver jacket, segmented shoulder guards, flexible utility trousers and heavy magnetic boots",
|
||||
Accessories: "compact breathing collar, antique-looking lamp and a small salvage pouch",
|
||||
Scene: "underwater ruin with rays of blue light, bubbles, coral growth, old machinery and drifting particles",
|
||||
Mood: "mysterious, brave and exploratory with teal caustics and warm lamp glow",
|
||||
},
|
||||
{
|
||||
Name: "Rooftop Skater",
|
||||
Outfit: "oversized graphic-free hoodie under a cropped windbreaker, wide technical pants, knee pads and chunky skate shoes",
|
||||
Accessories: "original blank skateboard with abstract geometric deck art and wireless headphones around the neck",
|
||||
Scene: "sunset rooftop skate spot, concrete shapes, distant skyline, spray mist and long shadows",
|
||||
Mood: "playful, rebellious and relaxed with saturated sunset gradients",
|
||||
},
|
||||
{
|
||||
Name: "Arcane Archivist",
|
||||
Outfit: "layered indigo scholar robe, structured vest, embroidered star-map patterns without readable glyphs, soft boots and fingerless reading gloves",
|
||||
Accessories: "floating crystal lens, chained key and a closed oversized book with no text on the cover",
|
||||
Scene: "towering fantasy archive, suspended shelves, luminous dust and circular windows showing a starry sky",
|
||||
Mood: "wise, curious and magical with violet-gold volumetric light",
|
||||
},
|
||||
{
|
||||
Name: "Desert Nomad",
|
||||
Outfit: "sand-colored hooded field jacket, layered scarf, reinforced trousers, wrapped boots and lightweight shoulder guards",
|
||||
Accessories: "goggles hanging at the collar, water canteen and a compact navigation device",
|
||||
Scene: "vast stylized dunes around a half-buried futuristic relay station, windblown dust and distant heat shimmer",
|
||||
Mood: "resilient, solitary and heroic with warm amber sunlight and cool shadow fill",
|
||||
},
|
||||
{
|
||||
Name: "Harbor Corsair",
|
||||
Outfit: "navy captain coat with exaggerated collar, striped sash, fitted waistcoat, weathered trousers and tall deck boots",
|
||||
Accessories: "ornamental brass spyglass, rope bracelet and a broad captain hat with no insignia",
|
||||
Scene: "fantasy harbor at dawn, stylized sailing vessel, gull silhouettes, rolling mist and bright spray",
|
||||
Mood: "mischievous, bold and charismatic with crisp sea light",
|
||||
},
|
||||
{
|
||||
Name: "Alpine Rider",
|
||||
Outfit: "bright technical snow jacket, insulated bib trousers, soft neck gaiter, mittens and oversized snowboard boots",
|
||||
Accessories: "reflective snow goggles lifted above the eyes and a custom blank snowboard with geometric patterning",
|
||||
Scene: "high alpine ridge after fresh snowfall, powder plume, distant peaks and sparkling ice crystals",
|
||||
Mood: "joyful, fearless and fresh with clean winter sunlight and blue shadows",
|
||||
},
|
||||
{
|
||||
Name: "Noir Detective",
|
||||
Outfit: "charcoal trench coat over a fitted waistcoat and shirt, narrow tie, tailored trousers and polished ankle boots",
|
||||
Accessories: "soft-brim hat, small notebook with blank pages and an old-fashioned camera",
|
||||
Scene: "stylized rainy midnight street, glowing windows, puddle reflections, fog and dramatic architectural shadows",
|
||||
Mood: "clever, suspicious and composed with high-contrast noir lighting plus a single warm practical light",
|
||||
},
|
||||
{
|
||||
Name: "Jungle Soundmaster",
|
||||
Outfit: "bold woven-pattern jacket, relaxed dark trousers, colorful knit cap and layered fabric wristbands; original patterns only",
|
||||
Accessories: "large studio headphones, portable sampler with abstract unlabelled controls and bead accents",
|
||||
Scene: "lush tropical night stage tucked into giant leaves, warm string lights, mist and floating firefly-like particles",
|
||||
Mood: "musical, warm and charismatic with saturated green, gold and red accents",
|
||||
},
|
||||
{
|
||||
Name: "Solar Guardian",
|
||||
Outfit: "ceremonial cream-and-gold mantle over fitted plated fabric armor, broad belt, tapered trousers and soft armored boots",
|
||||
Accessories: "floating sun-disc ornament, geometric shoulder charms and a staff-shaped decorative prop",
|
||||
Scene: "monumental desert temple terrace, shafts of sunlight, drifting sand and abstract solar motifs",
|
||||
Mood: "regal, radiant and protective with intense gold rim light and deep cyan shadows",
|
||||
},
|
||||
{
|
||||
Name: "Retro Astronaut",
|
||||
Outfit: "chunky retro-future space suit with rounded padding, orange utility tabs, ribbed joints and oversized moon boots",
|
||||
Accessories: "dome helmet carried at the side, analog-style wrist module with no readable numbers and tether clips",
|
||||
Scene: "colorful retro orbital station window, star field, distant planet and floating equipment shapes",
|
||||
Mood: "wonder-filled, optimistic and iconic with soft cosmic blues and warm cabin light",
|
||||
},
|
||||
{
|
||||
Name: "Storm Mechanic",
|
||||
Outfit: "rolled-sleeve heavy work jacket, dark overalls, reinforced gloves, tool belt and rugged lace-up boots",
|
||||
Accessories: "large wrench-shaped prop, protective goggles on the head and a compact diagnostic tablet with abstract graphics",
|
||||
Scene: "open-air repair platform during an approaching electrical storm, turbine parts, sparks and rain mist",
|
||||
Mood: "resourceful, gritty and confident with cool storm light and warm sparks",
|
||||
},
|
||||
}
|
||||
|
||||
var poseOptions = []string{
|
||||
"standing in a relaxed three-quarter stance with one shoulder slightly forward",
|
||||
"mid-step with a confident turn toward camera",
|
||||
"leaning casually against a thematic prop while keeping the full facial mask visible",
|
||||
"slightly crouched in an energetic ready pose with the tail creating a strong S-curve",
|
||||
"standing upright with one hand adjusting an accessory and the other relaxed",
|
||||
"heroic low-angle three-quarter pose, expressive but not aggressive",
|
||||
}
|
||||
|
||||
var atmosphereOptions = []string{
|
||||
"subtle floating particles and soft volumetric haze",
|
||||
"small foreground elements crossing the lower frame for depth",
|
||||
"gentle wind motion in fabric and fur",
|
||||
"bright rim-light bloom plus restrained lens glow",
|
||||
"layered foreground bokeh and crisp facial focus",
|
||||
"tiny environmental sparks or motes that echo the outfit materials",
|
||||
}
|
||||
|
||||
type collectionTraits struct {
|
||||
ThemeName string `json:"theme"`
|
||||
Outfit string `json:"outfit"`
|
||||
Accessories string `json:"accessories"`
|
||||
Scene string `json:"scene"`
|
||||
Mood string `json:"mood"`
|
||||
Pose string `json:"pose"`
|
||||
Atmosphere string `json:"atmosphere"`
|
||||
Rarity string `json:"rarity"`
|
||||
AccentA string `json:"accent_a"`
|
||||
AccentB string `json:"accent_b"`
|
||||
EditionCode string `json:"edition_code"`
|
||||
}
|
||||
|
||||
var accentPairs = [][2]string{
|
||||
{"#79F7FF", "#7D6CFF"},
|
||||
{"#FFB84D", "#FF5D8F"},
|
||||
{"#9BFF9E", "#16D7C5"},
|
||||
{"#F8E16C", "#D979FF"},
|
||||
{"#6AE4FF", "#FF7AD9"},
|
||||
{"#FFD36A", "#53A8FF"},
|
||||
{"#B7FF6A", "#FF8A5B"},
|
||||
{"#C7B2FF", "#6CFFD7"},
|
||||
}
|
||||
|
||||
func deriveCollectionTraits(x win) collectionTraits {
|
||||
h := sha256.Sum256([]byte(x.ID + "|" + x.Winner + "|" + x.Seed + "|" + x.Guess))
|
||||
theme := collectionThemes[int(h[0])%len(collectionThemes)]
|
||||
pair := accentPairs[int(h[1])%len(accentPairs)]
|
||||
rarityRoll := binary.BigEndian.Uint16(h[2:4]) % 10000
|
||||
rarity := "RARE"
|
||||
switch {
|
||||
case rarityRoll < 35:
|
||||
rarity = "MYTHIC"
|
||||
case rarityRoll < 260:
|
||||
rarity = "LEGENDARY"
|
||||
case rarityRoll < 1250:
|
||||
rarity = "EPIC"
|
||||
case rarityRoll < 4200:
|
||||
rarity = "RARE"
|
||||
default:
|
||||
rarity = "SIGNATURE"
|
||||
}
|
||||
return collectionTraits{
|
||||
ThemeName: theme.Name,
|
||||
Outfit: theme.Outfit,
|
||||
Accessories: theme.Accessories,
|
||||
Scene: theme.Scene,
|
||||
Mood: theme.Mood,
|
||||
Pose: poseOptions[int(h[4])%len(poseOptions)],
|
||||
Atmosphere: atmosphereOptions[int(h[5])%len(atmosphereOptions)],
|
||||
Rarity: rarity,
|
||||
AccentA: pair[0],
|
||||
AccentB: pair[1],
|
||||
EditionCode: strings.ToUpper(fmt.Sprintf("NH-%02X%02X-%02X%02X", h[6], h[7], h[8], h[9])),
|
||||
}
|
||||
}
|
||||
|
||||
func buildCollectionPrompt(x win, traits collectionTraits) string {
|
||||
taskDirection := strings.TrimSpace(x.PromptInstructions)
|
||||
if taskDirection == "" {
|
||||
taskDirection = "No additional task-specific direction."
|
||||
}
|
||||
avoid := strings.TrimSpace(x.NegativePrompt)
|
||||
if avoid == "" {
|
||||
avoid = "No additional task-specific avoid list."
|
||||
}
|
||||
return fmt.Sprintf(`Create one original premium full-art collectible character illustration.
|
||||
|
||||
REFERENCE IMAGES — SEPARATE IDENTITY FROM STYLE:
|
||||
Image 1 is the canonical RIFT CHARACTER reference. Use Image 1 only to preserve identity: silver-gray raccoon species, charcoal eye-mask shape, creamy muzzle/chest, amber-gold eyes, right-ear notch, muzzle proportions, head-to-body proportions, five-ring tail and overall face identity. Do NOT treat Image 1's neutral wardrobe, background, lighting or rendering style as fixed.
|
||||
Image 2 is the TASK STYLE reference. Use Image 2 only for visual language: rendering technique, shape language, material treatment, color behavior, lighting character, texture/detail level and overall illustration mood. Do NOT copy Image 2's subject, species, pose, clothing, cultural symbols, logos, text or composition literally.
|
||||
Priority rule: identity comes from Image 1; visual style comes from Image 2; wardrobe/theme/scene come from this prompt. If the references conflict, preserve RIFT's identity first and adopt the style reference second.
|
||||
|
||||
SUBJECT:
|
||||
RIFT, one anthropomorphic raccoon character. The character must remain unmistakably animal-like and deliberately stylized rather than realistic or human-faced.
|
||||
|
||||
THEME:
|
||||
%s
|
||||
|
||||
CLOTHING — PRIMARY VARIABLE:
|
||||
%s.
|
||||
The clothing is the main collectible trait. It must instantly communicate the theme, look custom-tailored to RIFT, use premium materials and retain a clear silhouette. No logos or recognizable real-world brand marks.
|
||||
|
||||
ACCESSORIES:
|
||||
%s.
|
||||
|
||||
SCENE:
|
||||
%s.
|
||||
|
||||
POSE:
|
||||
%s.
|
||||
|
||||
MOOD / LIGHTING:
|
||||
%s.
|
||||
|
||||
ATMOSPHERE:
|
||||
%s.
|
||||
|
||||
COMPOSITION:
|
||||
Vertical 1024x1536 full-art composition. Exactly one dominant character occupying about 65–75%% of the frame. Three-quarter body view with the face and right-ear notch clearly visible and at least most of the striped tail visible. Put the eyes near the upper third. Use foreground/background layering and dynamic depth, but keep the silhouette immediately readable. Leave natural darker breathing room near the very top and bottom so a programmatic collectible-card overlay can remain legible.
|
||||
|
||||
VISUAL LANGUAGE — TASK STYLE HAS AUTHORITY:
|
||||
Follow Image 2 decisively for the rendering language. Recreate its level of stylization, line/edge treatment, brushwork or surface treatment, material abstraction, shading model, texture density, palette behavior, lighting language and finish. The Task Style may be 3D-cartoon, cel-shaded, graphic comic, painterly, watercolor-like, clay/toy-like, low-poly, retro-game-inspired or another deliberately illustrated collectible-art language. Do NOT force RIFT back into the neutral 3D look of Image 1. Preserve the permanent identity markers from Image 1 while translating them naturally into Image 2's visual language. Keep the result premium, coherent, richly art-directed and clearly non-photorealistic.
|
||||
|
||||
FULL-ART ENERGY:
|
||||
The environment should visually merge with the character through thematic particles, light, fabric motion, mist, sparks, petals, snow, dust or other scene-appropriate elements. Create many small visual discoveries while keeping RIFT instantly readable.
|
||||
|
||||
TASK-SPECIFIC CREATIVE DIRECTION:
|
||||
%s
|
||||
|
||||
TASK-SPECIFIC AVOID LIST:
|
||||
%s
|
||||
|
||||
STRICT CONSTRAINTS:
|
||||
Original character design only. Exactly one raccoon. No human face. No photorealistic wildlife. No card frame or UI inside the generated artwork. No readable words, letters, numbers, signatures, logos, trademarks or watermarks. No duplicate character. No extra tails. Tail must retain exactly five dark rings. Do not copy the style-reference subject or its clothing. Do not add random costume elements unrelated to the theme.
|
||||
|
||||
Deterministic creative fingerprint only, never render it as text: task=%s winner=%s difficulty=%d-bit seed=%s.`,
|
||||
traits.ThemeName,
|
||||
traits.Outfit,
|
||||
traits.Accessories,
|
||||
traits.Scene,
|
||||
traits.Pose,
|
||||
traits.Mood,
|
||||
traits.Atmosphere,
|
||||
taskDirection,
|
||||
avoid,
|
||||
shortHash(x.ID), shortHash(x.Winner), x.RangeBits, shortHash(x.Seed),
|
||||
)
|
||||
}
|
||||
|
||||
func collectionNegativePrompt(x win) string {
|
||||
return joinPrompt(
|
||||
"photorealistic, realistic wildlife, human face, human skin, duplicate character, two characters, extra tail, wrong tail rings, unreadable anatomy, malformed paws, text, letters, numbers, logo, trademark, watermark, signature, card border, UI, low detail, blur",
|
||||
x.NegativePrompt,
|
||||
)
|
||||
}
|
||||
236
internal/artifact/collection_test.go
Normal file
236
internal/artifact/collection_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/settings"
|
||||
)
|
||||
|
||||
func sampleWin() win {
|
||||
return win{
|
||||
ID: "task_1234567890abcdef",
|
||||
Seed: "public-seed-42",
|
||||
Winner: "client_abcdef1234567890",
|
||||
Signature: "signature",
|
||||
Guess: "777",
|
||||
DisplayName: "Aurora Vault",
|
||||
RangeBits: 28,
|
||||
Completed: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC),
|
||||
PromptInstructions: "Add a subtle aurora motif to the environmental lighting.",
|
||||
NegativePrompt: "no giant hat",
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionTraitsDeterministic(t *testing.T) {
|
||||
x := sampleWin()
|
||||
a := deriveCollectionTraits(x)
|
||||
b := deriveCollectionTraits(x)
|
||||
if a != b {
|
||||
t.Fatalf("traits are not deterministic: %#v != %#v", a, b)
|
||||
}
|
||||
if a.ThemeName == "" || a.Outfit == "" || a.EditionCode == "" || a.AccentA == "" {
|
||||
t.Fatalf("missing dynamic traits: %#v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCollectionPromptUsesIdentityLockAndVariables(t *testing.T) {
|
||||
x := sampleWin()
|
||||
traits := deriveCollectionTraits(x)
|
||||
p := buildCollectionPrompt(x, traits)
|
||||
for _, want := range []string{
|
||||
"Image 1 is the canonical RIFT CHARACTER reference",
|
||||
"Image 2 is the TASK STYLE reference",
|
||||
"identity comes from Image 1; visual style comes from Image 2",
|
||||
"VISUAL LANGUAGE — TASK STYLE HAS AUTHORITY",
|
||||
"Do NOT force RIFT back into the neutral 3D look of Image 1",
|
||||
traits.ThemeName,
|
||||
traits.Outfit,
|
||||
x.PromptInstructions,
|
||||
x.NegativePrompt,
|
||||
"exactly five dark rings",
|
||||
"No card frame or UI inside the generated artwork",
|
||||
} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Fatalf("prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCardSVG(t *testing.T) {
|
||||
x := sampleWin()
|
||||
traits := deriveCollectionTraits(x)
|
||||
art := []byte("fake-png-bytes")
|
||||
card := string(renderCardSVG(art, "png", x, traits))
|
||||
for _, want := range []string{
|
||||
`width="1024" height="1536"`,
|
||||
"NEURAL HUNT",
|
||||
strings.ToUpper(traits.ThemeName),
|
||||
"WINNING EDITION",
|
||||
base64.StdEncoding.EncodeToString(art),
|
||||
"data:image/png;base64,",
|
||||
} {
|
||||
if !strings.Contains(card, want) {
|
||||
t.Fatalf("card missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPTImage2PortraitSize(t *testing.T) {
|
||||
if err := validateOpenAIImageSize("gpt-image-2", 1024, 1536); err != nil {
|
||||
t.Fatalf("1024x1536 should be valid: %v", err)
|
||||
}
|
||||
if err := validateOpenAIImageSize("gpt-image-2", 1000, 1536); err == nil {
|
||||
t.Fatal("non-multiple-of-16 width should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRIFTDefaultQualityIsMedium(t *testing.T) {
|
||||
t.Setenv("ARTIFACT_QUALITY", "")
|
||||
if got := settings.Defaults().ArtifactQuality; got != "medium" {
|
||||
t.Fatalf("default artifact quality = %q, want medium", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestSendsReferenceAsImageEdit(t *testing.T) {
|
||||
var sawImage bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/images/edits" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
|
||||
t.Errorf("bad auth: %q", got)
|
||||
}
|
||||
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
||||
t.Fatalf("parse multipart: %v", err)
|
||||
}
|
||||
if r.FormValue("model") != "gpt-image-2" || r.FormValue("size") != "1024x1536" || r.FormValue("quality") != "medium" {
|
||||
t.Errorf("unexpected fields: model=%q size=%q quality=%q", r.FormValue("model"), r.FormValue("size"), r.FormValue("quality"))
|
||||
}
|
||||
files := r.MultipartForm.File["image[]"]
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected one reference image, got %d", len(files))
|
||||
}
|
||||
f, err := files[0].Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(f)
|
||||
_ = f.Close()
|
||||
sawImage = string(b) == "reference-bytes"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("x-request-id", "req_test")
|
||||
fmt.Fprintf(w, `{"data":[{"b64_json":%q}],"usage":{"total_tokens":6000,"input_tokens":3000,"output_tokens":3000,"input_tokens_details":{"text_tokens":1000,"image_tokens":2000}}}`, base64.StdEncoding.EncodeToString([]byte("generated-png")))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
t.Setenv("OPENAI_API_KEY", "test-key")
|
||||
t.Setenv("OPENAI_BASE_URL", ts.URL)
|
||||
w := &Worker{http: ts.Client()}
|
||||
cfg := settings.Defaults()
|
||||
cfg.ArtifactModel = "gpt-image-2"
|
||||
cfg.ArtifactWidth = 1024
|
||||
cfg.ArtifactHeight = 1536
|
||||
cfg.ArtifactQuality = "medium"
|
||||
res, err := w.openAIRequest(context.Background(), cfg, "test prompt", []referenceImage{{Name: "reference.png", Bytes: []byte("reference-bytes")}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sawImage {
|
||||
t.Fatal("reference image was not transmitted")
|
||||
}
|
||||
if string(res.Bytes) != "generated-png" || res.Meta["endpoint"] != "images/edits" {
|
||||
t.Fatalf("unexpected response: %#v", res)
|
||||
}
|
||||
if res.Usage == nil || res.Usage.InputTokens != 3000 || res.Usage.TextInputTokens != 1000 || res.Usage.ImageInputTokens != 2000 || res.Usage.OutputTokens != 3000 {
|
||||
t.Fatalf("unexpected usage: %#v", res.Usage)
|
||||
}
|
||||
if res.EstimatedCostUSD == nil || *res.EstimatedCostUSD < 0.110999 || *res.EstimatedCostUSD > 0.111001 {
|
||||
t.Fatalf("unexpected cost estimate: %#v", res.EstimatedCostUSD)
|
||||
}
|
||||
}
|
||||
func TestEstimateOpenAIImageCost(t *testing.T) {
|
||||
u := imageUsage{InputTokens: 3000, TextInputTokens: 1000, ImageInputTokens: 2000, OutputTokens: 3000, TotalTokens: 6000}
|
||||
cost, basis, ok := estimateOpenAIImageCost("gpt-image-2", u)
|
||||
if !ok || basis == "" {
|
||||
t.Fatalf("expected priced usage, got ok=%v basis=%q", ok, basis)
|
||||
}
|
||||
if cost < 0.110999 || cost > 0.111001 {
|
||||
t.Fatalf("unexpected gpt-image-2 cost: %.9f", cost)
|
||||
}
|
||||
if _, _, ok := estimateOpenAIImageCost("future-image-model", u); ok {
|
||||
t.Fatal("unknown model must not get an invented price")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStyleReferenceDefaultsAndCustom(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := &Worker{dir: dir}
|
||||
def, err := w.loadStyleReference("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if def.Custom || def.Name != "default_style_reference.jpg" || len(def.Bytes) < 1000 || def.SHA256 == "" {
|
||||
t.Fatalf("unexpected default style: %#v", def)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "_styles"), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
customBytes := append([]byte(nil), styleReferenceJPEG...)
|
||||
customName := "custom.jpg"
|
||||
if err := os.WriteFile(filepath.Join(dir, "_styles", customName), customBytes, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
custom, err := w.loadStyleReference(customName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !custom.Custom || custom.Name != customName || custom.SHA256 != def.SHA256 {
|
||||
t.Fatalf("unexpected custom style: %#v", custom)
|
||||
}
|
||||
if _, err := w.loadStyleReference("../escape.jpg"); err == nil {
|
||||
t.Fatal("path traversal style reference should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestSendsCharacterAndStyleReferences(t *testing.T) {
|
||||
var names []string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
t.Fatalf("parse multipart: %v", err)
|
||||
}
|
||||
files := r.MultipartForm.File["image[]"]
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected two references, got %d", len(files))
|
||||
}
|
||||
for _, f := range files {
|
||||
names = append(names, f.Filename)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"data":[{"b64_json":%q}]}`, base64.StdEncoding.EncodeToString([]byte("generated")))
|
||||
}))
|
||||
defer ts.Close()
|
||||
t.Setenv("OPENAI_API_KEY", "test-key")
|
||||
t.Setenv("OPENAI_BASE_URL", ts.URL)
|
||||
w := &Worker{http: ts.Client()}
|
||||
cfg := settings.Defaults()
|
||||
_, err := w.openAIRequest(context.Background(), cfg, "prompt", []referenceImage{
|
||||
{Name: "character_anchor.png", Bytes: []byte("anchor")},
|
||||
{Name: "task_style.jpg", Bytes: []byte("style")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(names, ",") != "character_anchor.png,task_style.jpg" {
|
||||
t.Fatalf("unexpected reference order: %v", names)
|
||||
}
|
||||
}
|
||||
323
internal/artifact/openai.go
Normal file
323
internal/artifact/openai.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"neuralhunt/internal/settings"
|
||||
)
|
||||
|
||||
type referenceImage struct {
|
||||
Name string
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
var collectionAnchorMu sync.Mutex
|
||||
|
||||
var ErrCharacterAnchorExists = errors.New("character anchor already exists")
|
||||
|
||||
func validateOpenAIImageSize(model string, width, height int) error {
|
||||
if !strings.EqualFold(strings.TrimSpace(model), "gpt-image-2") {
|
||||
return nil
|
||||
}
|
||||
pixels := width * height
|
||||
longEdge, shortEdge := width, height
|
||||
if height > width {
|
||||
longEdge, shortEdge = height, width
|
||||
}
|
||||
if width%16 != 0 || height%16 != 0 || longEdge > 3840 || shortEdge <= 0 || longEdge > 3*shortEdge || pixels < 655360 || pixels > 8294400 {
|
||||
return fmt.Errorf("gpt-image-2 size %dx%d is invalid: edges must be multiples of 16, max edge 3840, ratio <=3:1, pixels 655360..8294400", width, height)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) characterAnchorPath() string {
|
||||
return filepath.Join(w.dir, "_collection", "character_anchor.png")
|
||||
}
|
||||
|
||||
func (w *Worker) createCharacterAnchorLocked(ctx context.Context, cfg settings.Runtime, failIfExists bool) ([]byte, bool, error) {
|
||||
anchorPath := w.characterAnchorPath()
|
||||
if b, err := os.ReadFile(anchorPath); err == nil && len(b) > 1024 {
|
||||
if failIfExists {
|
||||
return nil, false, ErrCharacterAnchorExists
|
||||
}
|
||||
return b, false, nil
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.ArtifactModel)), "gpt-image-") {
|
||||
return nil, false, fmt.Errorf("collection character consistency requires a GPT Image model; got %q", cfg.ArtifactModel)
|
||||
}
|
||||
if err := validateOpenAIImageSize(cfg.ArtifactModel, cfg.ArtifactWidth, cfg.ArtifactHeight); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(anchorPath), 0o750); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// The global anchor is intentionally generated without a style image. It is
|
||||
// an identity reference only; visual style is supplied independently per
|
||||
// task as Image 2 during actual card generation.
|
||||
res, err := w.openAIRequest(ctx, cfg, characterAnchorPrompt, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("create canonical RIFT anchor: %w", err)
|
||||
}
|
||||
if err := w.recordOpenAIUsage(ctx, "", "character_anchor", res); err != nil {
|
||||
if res.Meta == nil {
|
||||
res.Meta = map[string]any{}
|
||||
}
|
||||
res.Meta["usage_log_error"] = err.Error()
|
||||
}
|
||||
if len(res.Bytes) == 0 {
|
||||
return nil, false, errors.New("create canonical RIFT anchor: OpenAI returned empty image")
|
||||
}
|
||||
if err := atomicWrite(anchorPath, res.Bytes, 0o640); err != nil {
|
||||
return nil, false, fmt.Errorf("store canonical RIFT anchor: %w", err)
|
||||
}
|
||||
return res.Bytes, true, nil
|
||||
}
|
||||
|
||||
// CreateCharacterAnchor creates the collection-wide RIFT identity reference on
|
||||
// demand from the admin UI. It deliberately refuses to overwrite an existing
|
||||
// anchor so the identity cannot be changed accidentally after a collection has
|
||||
// started.
|
||||
func (w *Worker) CreateCharacterAnchor(ctx context.Context) error {
|
||||
collectionAnchorMu.Lock()
|
||||
defer collectionAnchorMu.Unlock()
|
||||
_, _, err := w.createCharacterAnchorLocked(ctx, w.settings.Get(), true)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *Worker) ensureCharacterAnchor(ctx context.Context, cfg settings.Runtime) ([]byte, bool, error) {
|
||||
collectionAnchorMu.Lock()
|
||||
defer collectionAnchorMu.Unlock()
|
||||
return w.createCharacterAnchorLocked(ctx, cfg, false)
|
||||
}
|
||||
|
||||
func (w *Worker) openAI(ctx context.Context, cfg settings.Runtime, x win, prompt string) (imageResult, error) {
|
||||
if err := validateOpenAIImageSize(cfg.ArtifactModel, cfg.ArtifactWidth, cfg.ArtifactHeight); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(cfg.ArtifactPreset), collectionPresetRaccoon) {
|
||||
res, err := w.openAIRequest(ctx, cfg, prompt, nil)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if err := w.recordOpenAIUsage(ctx, x.ID, "artifact", res); err != nil {
|
||||
if res.Meta == nil {
|
||||
res.Meta = map[string]any{}
|
||||
}
|
||||
res.Meta["usage_log_error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
anchor, created, err := w.ensureCharacterAnchor(ctx, cfg)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
styleRef, err := w.loadStyleReference(x.StyleReference)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
res, err := w.openAIRequest(ctx, cfg, prompt, []referenceImage{
|
||||
{Name: "character_anchor.png", Bytes: anchor},
|
||||
{Name: styleRef.Name, Bytes: styleRef.Bytes},
|
||||
})
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
h := sha256.Sum256(anchor)
|
||||
if res.Meta == nil {
|
||||
res.Meta = map[string]any{}
|
||||
}
|
||||
res.Meta["character_anchor_sha256"] = hex.EncodeToString(h[:])
|
||||
res.Meta["character_anchor_created"] = created
|
||||
res.Meta["style_reference_sha256"] = styleRef.SHA256
|
||||
res.Meta["style_reference_name"] = styleRef.Name
|
||||
res.Meta["style_reference_custom"] = styleRef.Custom
|
||||
res.Meta["reference_mode"] = "character-plus-task-style"
|
||||
if err := w.recordOpenAIUsage(ctx, x.ID, "artifact", res); err != nil {
|
||||
res.Meta["usage_log_error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (w *Worker) openAIRequest(ctx context.Context, cfg settings.Runtime, prompt string, refs []referenceImage) (imageResult, error) {
|
||||
key := strings.TrimSpace(os.Getenv("OPENAI_API_KEY"))
|
||||
if key == "" {
|
||||
return imageResult{}, errors.New("OPENAI_API_KEY is not configured")
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(os.Getenv("OPENAI_BASE_URL")), "/")
|
||||
if base == "" {
|
||||
base = "https://api.openai.com"
|
||||
}
|
||||
size := fmt.Sprintf("%dx%d", cfg.ArtifactWidth, cfg.ArtifactHeight)
|
||||
quality := strings.ToLower(strings.TrimSpace(cfg.ArtifactQuality))
|
||||
if quality == "" {
|
||||
quality = "medium"
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
var err error
|
||||
endpoint := ""
|
||||
if len(refs) > 0 {
|
||||
endpoint = base + "/v1/images/edits"
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
endpoint = base + "/images/edits"
|
||||
}
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fields := map[string]string{
|
||||
"model": cfg.ArtifactModel,
|
||||
"prompt": prompt,
|
||||
"n": "1",
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"output_format": "png",
|
||||
}
|
||||
for k, v := range fields {
|
||||
if err := mw.WriteField(k, v); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
}
|
||||
for i, ref := range refs {
|
||||
if len(ref.Bytes) == 0 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(ref.Name)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("reference_%d.png", i+1)
|
||||
}
|
||||
part, err := mw.CreateFormFile("image[]", name)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if _, err := part.Write(ref.Bytes); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body)
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
}
|
||||
} else {
|
||||
endpoint = base + "/v1/images/generations"
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
endpoint = base + "/images/generations"
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model": cfg.ArtifactModel,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"output_format": "png",
|
||||
}
|
||||
body, marshalErr := json.Marshal(payload)
|
||||
if marshalErr != nil {
|
||||
return imageResult{}, marshalErr
|
||||
}
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
resp, err := w.http.Do(req)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 128<<20))
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
requestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
if requestID != "" {
|
||||
return imageResult{}, fmt.Errorf("OpenAI images API HTTP %d (request %s): %s", resp.StatusCode, requestID, truncate(string(raw), 1400))
|
||||
}
|
||||
return imageResult{}, fmt.Errorf("OpenAI images API HTTP %d: %s", resp.StatusCode, truncate(string(raw), 1400))
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
B64 string `json:"b64_json"`
|
||||
} `json:"data"`
|
||||
Usage *struct {
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
InputDetails struct {
|
||||
TextTokens int64 `json:"text_tokens"`
|
||||
ImageTokens int64 `json:"image_tokens"`
|
||||
} `json:"input_tokens_details"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return imageResult{}, fmt.Errorf("decode OpenAI image response: %w", err)
|
||||
}
|
||||
if len(out.Data) == 0 || out.Data[0].B64 == "" {
|
||||
return imageResult{}, errors.New("OpenAI images API returned no b64_json")
|
||||
}
|
||||
img, err := base64.StdEncoding.DecodeString(out.Data[0].B64)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
meta := map[string]any{
|
||||
"model": cfg.ArtifactModel,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"endpoint": map[bool]string{true: "images/edits", false: "images/generations"}[len(refs) > 0],
|
||||
"references": len(refs),
|
||||
}
|
||||
if requestID != "" {
|
||||
meta["request_id"] = requestID
|
||||
}
|
||||
result := imageResult{Bytes: img, Ext: "png", Provider: "openai", Meta: meta}
|
||||
if out.Usage != nil {
|
||||
u := imageUsage{
|
||||
TotalTokens: out.Usage.TotalTokens,
|
||||
InputTokens: out.Usage.InputTokens,
|
||||
OutputTokens: out.Usage.OutputTokens,
|
||||
TextInputTokens: out.Usage.InputDetails.TextTokens,
|
||||
ImageInputTokens: out.Usage.InputDetails.ImageTokens,
|
||||
}
|
||||
if u.available() {
|
||||
result.Usage = &u
|
||||
meta["usage"] = map[string]any{
|
||||
"total_tokens": u.TotalTokens,
|
||||
"input_tokens": u.InputTokens,
|
||||
"input_text_tokens": u.TextInputTokens,
|
||||
"input_image_tokens": u.ImageInputTokens,
|
||||
"output_tokens": u.OutputTokens,
|
||||
}
|
||||
if cost, basis, ok := estimateOpenAIImageCost(cfg.ArtifactModel, u); ok {
|
||||
result.EstimatedCostUSD = &cost
|
||||
result.PricingBasis = basis
|
||||
meta["estimated_cost_usd"] = cost
|
||||
meta["pricing_basis"] = basis
|
||||
} else if basis != "" {
|
||||
result.PricingBasis = basis
|
||||
meta["pricing_basis"] = basis
|
||||
meta["estimated_cost_available"] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
21
internal/artifact/reference.go
Normal file
21
internal/artifact/reference.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package artifact
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// styleReferenceJPEG is the bundled fallback style used for tasks that do not
|
||||
// have their own uploaded style reference. In the RIFT v2.8 pipeline it is no
|
||||
// longer used to define the global character identity: character_anchor.png is
|
||||
// generated independently, while every card receives the global character
|
||||
// anchor plus a task-specific (or this default) style reference.
|
||||
//
|
||||
//go:embed assets/style_reference.jpg
|
||||
var styleReferenceJPEG []byte
|
||||
|
||||
// DefaultStyleReferenceJPEG returns a copy of the bundled fallback reference so
|
||||
// the authenticated/admin and public task-style preview handlers can render the
|
||||
// same fallback that the image worker will use.
|
||||
func DefaultStyleReferenceJPEG() []byte {
|
||||
out := make([]byte, len(styleReferenceJPEG))
|
||||
copy(out, styleReferenceJPEG)
|
||||
return out
|
||||
}
|
||||
81
internal/artifact/style.go
Normal file
81
internal/artifact/style.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TaskStyleReference is the style-only reference used as Image 2 for RIFT
|
||||
// generation. Custom style assets live below <ARTIFACT_DIR>/_styles; an empty
|
||||
// task reference resolves to the bundled default image.
|
||||
type TaskStyleReference struct {
|
||||
Bytes []byte `json:"-"`
|
||||
Name string `json:"name"`
|
||||
ContentType string `json:"content_type"`
|
||||
Custom bool `json:"custom"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
func styleContentType(name string, b []byte) string {
|
||||
ct := http.DetectContentType(b)
|
||||
if ct == "application/octet-stream" {
|
||||
if ext := filepath.Ext(name); ext != "" {
|
||||
if v := mime.TypeByExtension(ext); v != "" {
|
||||
ct = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Split(ct, ";")[0]
|
||||
}
|
||||
|
||||
func (w *Worker) loadStyleReference(ref string) (TaskStyleReference, error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
b := DefaultStyleReferenceJPEG()
|
||||
h := sha256.Sum256(b)
|
||||
return TaskStyleReference{
|
||||
Bytes: b,
|
||||
Name: "default_style_reference.jpg",
|
||||
ContentType: "image/jpeg",
|
||||
Custom: false,
|
||||
SHA256: hex.EncodeToString(h[:]),
|
||||
}, nil
|
||||
}
|
||||
if filepath.Base(ref) != ref || strings.ContainsAny(ref, `/\\`) {
|
||||
return TaskStyleReference{}, fmt.Errorf("invalid task style reference %q", ref)
|
||||
}
|
||||
p := filepath.Join(w.dir, "_styles", ref)
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return TaskStyleReference{}, fmt.Errorf("read task style reference %q: %w", ref, err)
|
||||
}
|
||||
if len(b) < 128 {
|
||||
return TaskStyleReference{}, fmt.Errorf("task style reference %q is empty or invalid", ref)
|
||||
}
|
||||
h := sha256.Sum256(b)
|
||||
return TaskStyleReference{
|
||||
Bytes: b,
|
||||
Name: ref,
|
||||
ContentType: styleContentType(ref, b),
|
||||
Custom: true,
|
||||
SHA256: hex.EncodeToString(h[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TaskStyleReference reads the configured style for a task. It is used by the
|
||||
// UI preview endpoints and intentionally returns the bundled default when the
|
||||
// task has no custom style assigned.
|
||||
func (w *Worker) TaskStyleReference(ctx context.Context, taskID string) (TaskStyleReference, error) {
|
||||
var ref string
|
||||
if err := w.db.QueryRowContext(ctx, `SELECT nft_style_reference FROM tasks WHERE id=?`, taskID).Scan(&ref); err != nil {
|
||||
return TaskStyleReference{}, err
|
||||
}
|
||||
return w.loadStyleReference(ref)
|
||||
}
|
||||
76
internal/artifact/testcard.go
Normal file
76
internal/artifact/testcard.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreatePipelineTestCard exercises the local, post-generation RIFT pipeline
|
||||
// without calling any image provider. The existing character anchor is used as
|
||||
// the mock subject and the selected task style image as the background.
|
||||
func (w *Worker) CreatePipelineTestCard(ctx context.Context, taskID string) (string, error) {
|
||||
var x win
|
||||
if err := w.db.QueryRowContext(ctx, `SELECT id,public_seed,display_name,range_bits,nft_prompt_instructions,nft_negative_prompt,nft_style_reference FROM tasks WHERE id=?`, taskID).
|
||||
Scan(&x.ID, &x.Seed, &x.DisplayName, &x.RangeBits, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference); err != nil {
|
||||
return "", err
|
||||
}
|
||||
anchor, err := os.ReadFile(w.characterAnchorPath())
|
||||
if err != nil || len(anchor) <= 1024 {
|
||||
return "", errors.New("character_anchor.png fehlt; bitte zuerst den RIFT-Anchor erzeugen")
|
||||
}
|
||||
style, err := w.loadStyleReference(x.StyleReference)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
x.Winner = "LOCAL-PIPELINE-TEST"
|
||||
x.Signature = "NO-API-NO-SIGNATURE"
|
||||
x.Guess = "LOCAL-TEST"
|
||||
x.Completed = time.Now().UTC()
|
||||
x.PublicJWK = json.RawMessage(`{}`)
|
||||
traits := deriveCollectionTraits(x)
|
||||
art := mockTestArtwork(anchor, style)
|
||||
card := renderCardSVG(art, "svg", x, traits)
|
||||
dir := filepath.Join(w.dir, "_test", taskID)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := atomicWrite(filepath.Join(dir, "art.svg"), art, 0o640); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := atomicWrite(filepath.Join(dir, "image.svg"), card, 0o640); err != nil {
|
||||
return "", err
|
||||
}
|
||||
manifest, _ := json.MarshalIndent(map[string]any{
|
||||
"kind": "local_pipeline_test", "api_calls": 0, "estimated_cost_usd": 0,
|
||||
"task_id": taskID, "style_reference": style.Name, "style_custom": style.Custom,
|
||||
"collection_character": "RIFT", "collection_traits": traits,
|
||||
"note": "Local test only. No OpenAI/API request was made and no task artifact status was changed.",
|
||||
}, "", " ")
|
||||
_ = atomicWrite(filepath.Join(dir, "manifest.json"), manifest, 0o640)
|
||||
return filepath.Join(dir, "image.svg"), nil
|
||||
}
|
||||
|
||||
func mockTestArtwork(anchor []byte, style TaskStyleReference) []byte {
|
||||
a := base64.StdEncoding.EncodeToString(anchor)
|
||||
s := base64.StdEncoding.EncodeToString(style.Bytes)
|
||||
styleMime := html.EscapeString(style.ContentType)
|
||||
if styleMime == "" {
|
||||
styleMime = "image/jpeg"
|
||||
}
|
||||
return []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1536" viewBox="0 0 1024 1536">
|
||||
<defs>
|
||||
<filter id="blur"><feGaussianBlur stdDeviation="18"/></filter>
|
||||
<linearGradient id="shade" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#07101d" stop-opacity=".18"/><stop offset="1" stop-color="#07101d" stop-opacity=".72"/></linearGradient>
|
||||
</defs>
|
||||
<image href="data:%s;base64,%s" x="-70" y="-50" width="1164" height="1636" preserveAspectRatio="xMidYMid slice" filter="url(#blur)" opacity=".62"/>
|
||||
<rect width="1024" height="1536" fill="url(#shade)"/>
|
||||
<image href="data:image/png;base64,%s" x="92" y="120" width="840" height="1180" preserveAspectRatio="xMidYMid meet"/>
|
||||
</svg>`, styleMime, s, a))
|
||||
}
|
||||
147
internal/artifact/usage.go
Normal file
147
internal/artifact/usage.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// imageUsage mirrors the usage object returned by GPT Image endpoints. Keeping
|
||||
// it typed avoids losing precision when provider metadata is persisted.
|
||||
type imageUsage struct {
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TextInputTokens int64 `json:"text_input_tokens"`
|
||||
ImageInputTokens int64 `json:"image_input_tokens"`
|
||||
}
|
||||
|
||||
func (u imageUsage) available() bool {
|
||||
return u.TotalTokens > 0 || u.InputTokens > 0 || u.OutputTokens > 0 || u.TextInputTokens > 0 || u.ImageInputTokens > 0
|
||||
}
|
||||
|
||||
type imagePricing struct {
|
||||
TextInputPerMTok float64
|
||||
ImageInputPerMTok float64
|
||||
ImageOutputPerMTok float64
|
||||
Basis string
|
||||
}
|
||||
|
||||
// openAIImagePricing intentionally supports only models whose public standard
|
||||
// token rates are pinned here. Unknown/future model names still get full usage
|
||||
// logging, but their local cost remains NULL instead of inventing a price.
|
||||
func openAIImagePricing(model string) (imagePricing, bool) {
|
||||
m := strings.ToLower(strings.TrimSpace(model))
|
||||
switch {
|
||||
case m == "gpt-image-2" || strings.HasPrefix(m, "gpt-image-2-"):
|
||||
return imagePricing{
|
||||
TextInputPerMTok: 5.00,
|
||||
ImageInputPerMTok: 8.00,
|
||||
ImageOutputPerMTok: 30.00,
|
||||
Basis: "openai-standard-2026-08-10:gpt-image-2:text-in-5,image-in-8,image-out-30-usd-per-mtok",
|
||||
}, true
|
||||
case m == "gpt-image-1.5" || strings.HasPrefix(m, "gpt-image-1.5-"):
|
||||
return imagePricing{
|
||||
TextInputPerMTok: 5.00,
|
||||
ImageInputPerMTok: 8.00,
|
||||
ImageOutputPerMTok: 32.00,
|
||||
Basis: "openai-standard-2026-08-10:gpt-image-1.5:text-in-5,image-in-8,image-out-32-usd-per-mtok",
|
||||
}, true
|
||||
case m == "chatgpt-image-latest":
|
||||
return imagePricing{
|
||||
TextInputPerMTok: 5.00,
|
||||
ImageInputPerMTok: 8.00,
|
||||
ImageOutputPerMTok: 32.00,
|
||||
Basis: "openai-standard-2026-08-10:chatgpt-image-latest:text-in-5,image-in-8,image-out-32-usd-per-mtok",
|
||||
}, true
|
||||
case m == "gpt-image-1":
|
||||
return imagePricing{
|
||||
TextInputPerMTok: 5.00,
|
||||
ImageInputPerMTok: 10.00,
|
||||
ImageOutputPerMTok: 40.00,
|
||||
Basis: "openai-standard-2026-08-10:gpt-image-1:text-in-5,image-in-10,image-out-40-usd-per-mtok",
|
||||
}, true
|
||||
case m == "gpt-image-1-mini":
|
||||
return imagePricing{
|
||||
TextInputPerMTok: 2.00,
|
||||
ImageInputPerMTok: 2.50,
|
||||
ImageOutputPerMTok: 8.00,
|
||||
Basis: "openai-standard-2026-08-10:gpt-image-1-mini:text-in-2,image-in-2.5,image-out-8-usd-per-mtok",
|
||||
}, true
|
||||
default:
|
||||
return imagePricing{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func estimateOpenAIImageCost(model string, u imageUsage) (float64, string, bool) {
|
||||
p, ok := openAIImagePricing(model)
|
||||
if !ok || !u.available() {
|
||||
return 0, "", false
|
||||
}
|
||||
// GPT Image reports the text/image input split. It matters because the two
|
||||
// modalities have different rates. If the split is missing, store usage but
|
||||
// do not claim a precise local cost.
|
||||
if u.InputTokens > 0 && u.TextInputTokens+u.ImageInputTokens <= 0 {
|
||||
return 0, p.Basis, false
|
||||
}
|
||||
if split := u.TextInputTokens + u.ImageInputTokens; u.InputTokens > 0 && split > 0 && split != u.InputTokens {
|
||||
return 0, p.Basis, false
|
||||
}
|
||||
cost := (float64(u.TextInputTokens)*p.TextInputPerMTok +
|
||||
float64(u.ImageInputTokens)*p.ImageInputPerMTok +
|
||||
float64(u.OutputTokens)*p.ImageOutputPerMTok) / 1_000_000
|
||||
return cost, p.Basis, true
|
||||
}
|
||||
|
||||
func (w *Worker) recordOpenAIUsage(ctx context.Context, taskID, kind string, res imageResult) error {
|
||||
// Log every successful OpenAI image call, even if a provider response omits
|
||||
// the optional usage block. That keeps the call/card counters truthful while
|
||||
// leaving the local cost NULL when there is not enough data to calculate it.
|
||||
u := imageUsage{}
|
||||
if res.Usage != nil {
|
||||
u = *res.Usage
|
||||
}
|
||||
model := metaString(res.Meta, "model")
|
||||
endpoint := metaString(res.Meta, "endpoint")
|
||||
size := metaString(res.Meta, "size")
|
||||
quality := metaString(res.Meta, "quality")
|
||||
requestID := metaString(res.Meta, "request_id")
|
||||
|
||||
var cost any
|
||||
if res.EstimatedCostUSD != nil {
|
||||
cost = *res.EstimatedCostUSD
|
||||
}
|
||||
metaJSON, _ := json.Marshal(res.Meta)
|
||||
_, err := w.db.ExecContext(ctx, `INSERT INTO artifact_api_usage(
|
||||
created_at,task_id,kind,provider,model,endpoint,size,quality,request_id,
|
||||
input_tokens,input_text_tokens,input_image_tokens,output_tokens,total_tokens,
|
||||
estimated_cost_usd,pricing_basis,meta_json)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
time.Now().UTC().UnixMilli(), nullableString(taskID), kind, res.Provider, model, endpoint, size, quality, nullableString(requestID),
|
||||
u.InputTokens, u.TextInputTokens, u.ImageInputTokens, u.OutputTokens, u.TotalTokens,
|
||||
cost, res.PricingBasis, string(metaJSON))
|
||||
return err
|
||||
}
|
||||
|
||||
func nullableString(s string) any {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func metaString(meta map[string]any, key string) string {
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := meta[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
589
internal/artifact/worker.go
Normal file
589
internal/artifact/worker.go
Normal file
@@ -0,0 +1,589 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/settings"
|
||||
)
|
||||
|
||||
type Worker struct {
|
||||
db *sql.DB
|
||||
dir string
|
||||
publicBase string
|
||||
settings *settings.Manager
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(db *sql.DB, dir string, sm *settings.Manager) (*Worker, error) {
|
||||
if dir == "" {
|
||||
dir = "./data/artifacts"
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Worker{
|
||||
db: db,
|
||||
dir: abs,
|
||||
publicBase: strings.TrimRight(os.Getenv("ARTIFACT_PUBLIC_BASE_URL"), "/"),
|
||||
settings: sm,
|
||||
http: &http.Client{Timeout: envDuration("ARTIFACT_HTTP_TIMEOUT", 4*time.Minute)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func envDuration(k string, d time.Duration) time.Duration {
|
||||
if raw := strings.TrimSpace(os.Getenv(k)); raw != "" {
|
||||
if v, err := time.ParseDuration(raw); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type win struct {
|
||||
ID, Seed, Winner, Signature, Guess string
|
||||
DisplayName string
|
||||
RangeBits int
|
||||
Completed time.Time
|
||||
PublicJWK json.RawMessage
|
||||
PromptInstructions string
|
||||
NegativePrompt string
|
||||
StyleReference string
|
||||
}
|
||||
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
t := time.NewTicker(3 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
_ = w.one(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) claim(ctx context.Context) (win, error) {
|
||||
tx, err := w.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return win{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var x win
|
||||
var completedMS int64
|
||||
var raw string
|
||||
err = tx.QueryRowContext(ctx, `SELECT t.id,t.public_seed,t.winner_client_id,t.winner_signature,t.winning_guess,t.display_name,t.range_bits,t.completed_at,c.public_jwk,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference
|
||||
FROM tasks t JOIN clients c ON c.id=t.winner_client_id
|
||||
WHERE t.artifact_status='pending' ORDER BY t.completed_at LIMIT 1`).Scan(&x.ID, &x.Seed, &x.Winner, &x.Signature, &x.Guess, &x.DisplayName, &x.RangeBits, &completedMS, &raw, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference)
|
||||
if err != nil {
|
||||
return win{}, err
|
||||
}
|
||||
x.Completed = time.UnixMilli(completedMS).UTC()
|
||||
x.PublicJWK = json.RawMessage(raw)
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE tasks SET artifact_status='generating',artifact_error=NULL WHERE id=? AND artifact_status='pending'`, x.ID); err != nil {
|
||||
return win{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return win{}, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type imageResult struct {
|
||||
Bytes []byte
|
||||
Ext string
|
||||
Provider string
|
||||
Meta map[string]any
|
||||
Usage *imageUsage
|
||||
EstimatedCostUSD *float64
|
||||
PricingBasis string
|
||||
}
|
||||
|
||||
func (w *Worker) one(ctx context.Context) error {
|
||||
x, err := w.claim(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := w.settings.Get()
|
||||
preset := strings.ToLower(strings.TrimSpace(cfg.ArtifactPreset))
|
||||
traits := deriveCollectionTraits(x)
|
||||
prompt := ""
|
||||
negativePrompt := ""
|
||||
if preset == collectionPresetRaccoon {
|
||||
prompt = buildCollectionPrompt(x, traits)
|
||||
negativePrompt = collectionNegativePrompt(x)
|
||||
} else {
|
||||
prompt = buildPrompt(cfg.ArtifactPrompt, x.PromptInstructions, x)
|
||||
negativePrompt = joinPrompt(cfg.ArtifactNegativePrompt, x.NegativePrompt)
|
||||
}
|
||||
img, err := w.generate(ctx, cfg, x, prompt, negativePrompt)
|
||||
if err != nil {
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
if len(img.Bytes) == 0 {
|
||||
err := errors.New("image provider returned empty output")
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
if img.Ext == "" {
|
||||
img.Ext = "png"
|
||||
}
|
||||
|
||||
idSum := sha256.Sum256([]byte(x.ID + "|" + x.Winner + "|" + x.Signature))
|
||||
artifactID := "artifact_" + hex.EncodeToString(idSum[:12])
|
||||
promptSum := sha256.Sum256([]byte(prompt))
|
||||
rawArtSum := sha256.Sum256(img.Bytes)
|
||||
|
||||
finalBytes := img.Bytes
|
||||
finalExt := strings.TrimPrefix(strings.ToLower(img.Ext), ".")
|
||||
if preset == collectionPresetRaccoon {
|
||||
finalBytes = renderCardSVG(img.Bytes, img.Ext, x, traits)
|
||||
finalExt = "svg"
|
||||
}
|
||||
finalSum := sha256.Sum256(finalBytes)
|
||||
|
||||
manifest := map[string]any{
|
||||
"artifact_id": artifactID,
|
||||
"artifact_preset": preset,
|
||||
"task_id": x.ID,
|
||||
"task_display_name": x.DisplayName,
|
||||
"task_range_bits": x.RangeBits,
|
||||
"winner_client_id": x.Winner,
|
||||
"winner_public_jwk": json.RawMessage(x.PublicJWK),
|
||||
"winning_guess": x.Guess,
|
||||
"winner_guess_signature": x.Signature,
|
||||
"completed_at": x.Completed,
|
||||
"image_sha256": hex.EncodeToString(finalSum[:]),
|
||||
"raw_art_sha256": hex.EncodeToString(rawArtSum[:]),
|
||||
"prompt_sha256": hex.EncodeToString(promptSum[:]),
|
||||
"task_prompt_instructions": x.PromptInstructions,
|
||||
"task_style_reference": x.StyleReference,
|
||||
"provider": img.Provider,
|
||||
"provider_meta": img.Meta,
|
||||
"note": "winner_guess_signature authenticates the winning guess; image_sha256 binds the final programmatically laid-out collectible card into the server manifest",
|
||||
}
|
||||
if preset == collectionPresetRaccoon {
|
||||
manifest["collection_character"] = "RIFT"
|
||||
manifest["collection_traits"] = traits
|
||||
manifest["layout"] = map[string]any{
|
||||
"format": "svg",
|
||||
"width": 1024,
|
||||
"height": 1536,
|
||||
"mode": "programmatic-full-art-card-v1",
|
||||
}
|
||||
}
|
||||
mb, _ := json.MarshalIndent(manifest, "", " ")
|
||||
|
||||
relDir := filepath.Join("artifacts", artifactID)
|
||||
outDir := filepath.Join(w.dir, artifactID)
|
||||
if err := os.MkdirAll(outDir, 0o750); err != nil {
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
if preset == collectionPresetRaccoon {
|
||||
rawName := "art." + strings.TrimPrefix(strings.ToLower(img.Ext), ".")
|
||||
if err := atomicWrite(filepath.Join(outDir, rawName), img.Bytes, 0o640); err != nil {
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
imageName := "image." + finalExt
|
||||
if err := atomicWrite(filepath.Join(outDir, imageName), finalBytes, 0o640); err != nil {
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
if err := atomicWrite(filepath.Join(outDir, "manifest.json"), mb, 0o640); err != nil {
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
imgURI := w.uri(filepath.ToSlash(filepath.Join(relDir, imageName)))
|
||||
manURI := w.uri(filepath.ToSlash(filepath.Join(relDir, "manifest.json")))
|
||||
_, err = w.db.ExecContext(ctx, `UPDATE tasks SET artifact_status='ready',artifact_uri=?,artifact_manifest_uri=?,artifact_error=NULL WHERE id=?`, imgURI, manURI, x.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func joinPrompt(parts ...string) string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func buildPrompt(base, taskInstructions string, x win) string {
|
||||
winner := x.Winner
|
||||
if len(winner) > 24 {
|
||||
winner = winner[:24]
|
||||
}
|
||||
creative := joinPrompt(base, taskInstructions)
|
||||
return creative + fmt.Sprintf("\nTask fingerprint: %s. Winner fingerprint: %s. Difficulty: %d-bit probability space. Public seed fingerprint: %s. Treat these values only as deterministic creative seeds; do not render them as readable text.", x.ID, winner, x.RangeBits, shortHash(x.Seed))
|
||||
}
|
||||
|
||||
func shortHash(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
func (w *Worker) generate(ctx context.Context, cfg settings.Runtime, x win, prompt, negativePrompt string) (imageResult, error) {
|
||||
provider := strings.ToLower(strings.TrimSpace(cfg.ArtifactProvider))
|
||||
switch provider {
|
||||
case "local":
|
||||
return imageResult{Bytes: procedural(x), Ext: "svg", Provider: "local-procedural", Meta: map[string]any{"model": "deterministic-svg"}}, nil
|
||||
case "openai":
|
||||
return w.openAI(ctx, cfg, x, prompt)
|
||||
case "comfyui":
|
||||
return w.comfyUI(ctx, cfg, x, prompt, negativePrompt)
|
||||
case "a1111":
|
||||
return w.a1111(ctx, cfg, x, prompt, negativePrompt)
|
||||
case "auto":
|
||||
var errs []string
|
||||
if strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != "" {
|
||||
if r, err := w.openAI(ctx, cfg, x, prompt); err == nil {
|
||||
return r, nil
|
||||
} else {
|
||||
errs = append(errs, "openai: "+err.Error())
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("COMFYUI_URL")) != "" && strings.TrimSpace(os.Getenv("COMFYUI_WORKFLOW_PATH")) != "" {
|
||||
if r, err := w.comfyUI(ctx, cfg, x, prompt, negativePrompt); err == nil {
|
||||
return r, nil
|
||||
} else {
|
||||
errs = append(errs, "comfyui: "+err.Error())
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("A1111_URL")) != "" {
|
||||
if r, err := w.a1111(ctx, cfg, x, prompt, negativePrompt); err == nil {
|
||||
return r, nil
|
||||
} else {
|
||||
errs = append(errs, "a1111: "+err.Error())
|
||||
}
|
||||
}
|
||||
meta := map[string]any{"model": "deterministic-svg"}
|
||||
if len(errs) > 0 {
|
||||
meta["fallback_errors"] = errs
|
||||
}
|
||||
return imageResult{Bytes: procedural(x), Ext: "svg", Provider: "local-procedural-fallback", Meta: meta}, nil
|
||||
default:
|
||||
return imageResult{}, fmt.Errorf("unknown artifact provider %q", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) comfyUI(ctx context.Context, cfg settings.Runtime, x win, prompt, negativePrompt string) (imageResult, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(os.Getenv("COMFYUI_URL")), "/")
|
||||
workflowPath := strings.TrimSpace(os.Getenv("COMFYUI_WORKFLOW_PATH"))
|
||||
if base == "" || workflowPath == "" {
|
||||
return imageResult{}, errors.New("COMFYUI_URL and COMFYUI_WORKFLOW_PATH are required")
|
||||
}
|
||||
raw, err := os.ReadFile(workflowPath)
|
||||
if err != nil {
|
||||
return imageResult{}, fmt.Errorf("read ComfyUI workflow: %w", err)
|
||||
}
|
||||
var workflow any
|
||||
if err := json.Unmarshal(raw, &workflow); err != nil {
|
||||
return imageResult{}, fmt.Errorf("parse ComfyUI workflow: %w", err)
|
||||
}
|
||||
seed := deterministicSeed(x)
|
||||
replacements := map[string]string{
|
||||
"{{PROMPT}}": prompt,
|
||||
"{{NEGATIVE_PROMPT}}": negativePrompt,
|
||||
"{{SEED}}": strconv.FormatInt(seed, 10),
|
||||
"{{WIDTH}}": strconv.Itoa(cfg.ArtifactWidth),
|
||||
"{{HEIGHT}}": strconv.Itoa(cfg.ArtifactHeight),
|
||||
"{{STEPS}}": strconv.Itoa(cfg.ArtifactSteps),
|
||||
"{{MODEL}}": cfg.ArtifactModel,
|
||||
}
|
||||
workflow = replaceJSON(workflow, replacements)
|
||||
clientID := "neuralhunt-" + shortHash(x.ID)
|
||||
payload := map[string]any{"prompt": workflow, "client_id": clientID}
|
||||
var queued struct {
|
||||
PromptID string `json:"prompt_id"`
|
||||
Error any `json:"error"`
|
||||
Nodes map[string]any `json:"node_errors"`
|
||||
}
|
||||
if err := w.doJSON(ctx, http.MethodPost, base+"/prompt", payload, &queued, "", ""); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if queued.PromptID == "" {
|
||||
return imageResult{}, fmt.Errorf("ComfyUI rejected workflow: error=%v node_errors=%v", queued.Error, queued.Nodes)
|
||||
}
|
||||
deadline := time.Now().Add(envDuration("COMFYUI_POLL_TIMEOUT", 4*time.Minute))
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imageResult{}, ctx.Err()
|
||||
case <-time.After(900 * time.Millisecond):
|
||||
}
|
||||
resp, err := w.http.Get(base + "/history/" + url.PathEscape(queued.PromptID))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
resp.Body.Close()
|
||||
if readErr != nil || resp.StatusCode/100 != 2 {
|
||||
continue
|
||||
}
|
||||
var history map[string]any
|
||||
if json.Unmarshal(b, &history) != nil {
|
||||
continue
|
||||
}
|
||||
entry, ok := history[queued.PromptID].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ref, ok := findComfyImage(entry["outputs"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("filename", ref.Filename)
|
||||
q.Set("subfolder", ref.Subfolder)
|
||||
q.Set("type", ref.Type)
|
||||
imgResp, err := w.http.Get(base + "/view?" + q.Encode())
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
img, err := io.ReadAll(io.LimitReader(imgResp.Body, 64<<20))
|
||||
imgResp.Body.Close()
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if imgResp.StatusCode/100 != 2 {
|
||||
return imageResult{}, fmt.Errorf("ComfyUI /view HTTP %d", imgResp.StatusCode)
|
||||
}
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(ref.Filename)), ".")
|
||||
if ext == "" {
|
||||
ext = "png"
|
||||
}
|
||||
return imageResult{Bytes: img, Ext: ext, Provider: "comfyui", Meta: map[string]any{"prompt_id": queued.PromptID, "model": cfg.ArtifactModel, "seed": seed}}, nil
|
||||
}
|
||||
return imageResult{}, errors.New("ComfyUI generation timed out")
|
||||
}
|
||||
|
||||
type comfyImageRef struct{ Filename, Subfolder, Type string }
|
||||
|
||||
func findComfyImage(v any) (comfyImageRef, bool) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
if fn, ok := x["filename"].(string); ok && fn != "" {
|
||||
sub, _ := x["subfolder"].(string)
|
||||
typ, _ := x["type"].(string)
|
||||
if typ == "" {
|
||||
typ = "output"
|
||||
}
|
||||
return comfyImageRef{fn, sub, typ}, true
|
||||
}
|
||||
for _, child := range x {
|
||||
if r, ok := findComfyImage(child); ok {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range x {
|
||||
if r, ok := findComfyImage(child); ok {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return comfyImageRef{}, false
|
||||
}
|
||||
|
||||
func replaceJSON(v any, repl map[string]string) any {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
for k, child := range x {
|
||||
x[k] = replaceJSON(child, repl)
|
||||
}
|
||||
return x
|
||||
case []any:
|
||||
for i := range x {
|
||||
x[i] = replaceJSON(x[i], repl)
|
||||
}
|
||||
return x
|
||||
case string:
|
||||
original := x
|
||||
for from, to := range repl {
|
||||
x = strings.ReplaceAll(x, from, to)
|
||||
}
|
||||
// Exact numeric placeholders become JSON numbers where possible.
|
||||
if original == "{{SEED}}" || original == "{{WIDTH}}" || original == "{{HEIGHT}}" || original == "{{STEPS}}" {
|
||||
if n, err := strconv.ParseInt(x, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return x
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) a1111(ctx context.Context, cfg settings.Runtime, x win, prompt, negativePrompt string) (imageResult, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(os.Getenv("A1111_URL")), "/")
|
||||
if base == "" {
|
||||
return imageResult{}, errors.New("A1111_URL is not configured")
|
||||
}
|
||||
seed := deterministicSeed(x)
|
||||
payload := map[string]any{
|
||||
"prompt": prompt,
|
||||
"negative_prompt": negativePrompt,
|
||||
"steps": cfg.ArtifactSteps,
|
||||
"width": cfg.ArtifactWidth,
|
||||
"height": cfg.ArtifactHeight,
|
||||
"seed": seed,
|
||||
"cfg_scale": envFloat("A1111_CFG_SCALE", 7.0),
|
||||
}
|
||||
if sampler := strings.TrimSpace(os.Getenv("A1111_SAMPLER")); sampler != "" {
|
||||
payload["sampler_name"] = sampler
|
||||
}
|
||||
if model := strings.TrimSpace(cfg.ArtifactModel); model != "" && !strings.HasPrefix(strings.ToLower(model), "gpt-image") {
|
||||
payload["override_settings"] = map[string]any{"sd_model_checkpoint": model}
|
||||
}
|
||||
var out struct {
|
||||
Images []string `json:"images"`
|
||||
Info string `json:"info"`
|
||||
}
|
||||
if err := w.doJSON(ctx, http.MethodPost, base+"/sdapi/v1/txt2img", payload, &out, os.Getenv("A1111_USER"), os.Getenv("A1111_PASSWORD")); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if len(out.Images) == 0 {
|
||||
return imageResult{}, errors.New("A1111 returned no images")
|
||||
}
|
||||
b64 := out.Images[0]
|
||||
if i := strings.Index(b64, ","); strings.HasPrefix(b64, "data:") && i >= 0 {
|
||||
b64 = b64[i+1:]
|
||||
}
|
||||
img, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
return imageResult{Bytes: img, Ext: "png", Provider: "a1111", Meta: map[string]any{"seed": seed, "steps": cfg.ArtifactSteps, "model": cfg.ArtifactModel}}, nil
|
||||
}
|
||||
|
||||
func (w *Worker) doJSON(ctx context.Context, method, endpoint string, payload any, out any, user, pass string) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if strings.TrimSpace(user) != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := w.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("HTTP %d from %s: %s", resp.StatusCode, endpoint, truncate(string(raw), 1200))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("decode %s: %w", endpoint, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envFloat(k string, d float64) float64 {
|
||||
if raw := strings.TrimSpace(os.Getenv(k)); raw != "" {
|
||||
if v, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func deterministicSeed(x win) int64 {
|
||||
h := sha256.Sum256([]byte(x.ID + "|" + x.Winner + "|" + x.Seed))
|
||||
var n uint64
|
||||
for i := 0; i < 8; i++ {
|
||||
n = (n << 8) | uint64(h[i])
|
||||
}
|
||||
return int64(n & 0x7fffffffffffffff)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func (w *Worker) uri(rel string) string {
|
||||
p := "/" + strings.TrimLeft(rel, "/")
|
||||
if w.publicBase == "" {
|
||||
return p
|
||||
}
|
||||
return w.publicBase + p
|
||||
}
|
||||
|
||||
func (w *Worker) fail(ctx context.Context, taskID string, err error) {
|
||||
_, _ = w.db.ExecContext(ctx, `UPDATE tasks SET artifact_status='error',artifact_error=? WHERE id=?`, err.Error(), taskID)
|
||||
}
|
||||
|
||||
func atomicWrite(path string, data []byte, mode os.FileMode) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func procedural(x win) []byte {
|
||||
h := sha256.Sum256([]byte(x.ID + x.Winner + x.Seed))
|
||||
a := int(h[0]) % 360
|
||||
b := int(h[1]) % 360
|
||||
c := int(h[2]) % 360
|
||||
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><defs><radialGradient id="g"><stop stop-color="hsl(%d 100%% 70%%)"/><stop offset=".55" stop-color="hsl(%d 85%% 45%%)"/><stop offset="1" stop-color="#04111f"/></radialGradient><filter id="gl"><feGaussianBlur stdDeviation="8"/></filter></defs><rect width="1024" height="1024" fill="#04111f"/><circle cx="512" cy="512" r="390" fill="none" stroke="#6574ff" opacity=".18" stroke-width="2"/><circle cx="512" cy="512" r="290" fill="url(#g)" opacity=".22" filter="url(#gl)"/><g fill="hsl(%d 100%% 75%%)">`, a, b, c)
|
||||
for i := 0; i < 480; i++ {
|
||||
v := int(h[i%32])
|
||||
xv := 512 + ((i*73 + v*11) % 620) - 310
|
||||
yv := 512 + ((i*97 + v*7) % 620) - 310
|
||||
r := 1 + (v % 5)
|
||||
svg += fmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" opacity=".65"/>`, xv, yv, r)
|
||||
}
|
||||
svg += fmt.Sprintf(`</g><text x="52" y="900" fill="#d9f7ff" font-family="monospace" font-size="28">%s</text><text x="52" y="940" fill="#7fe7ff" font-family="monospace" font-size="18">winner %s</text></svg>`, x.ID, x.Winner[:min(20, len(x.Winner))])
|
||||
return []byte(svg)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
198
internal/auth/auth.go
Normal file
198
internal/auth/auth.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
type PublicJWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
Ext bool `json:"ext,omitempty"`
|
||||
KeyOps []string `json:"key_ops,omitempty"`
|
||||
// Optional standard JWK metadata. Authentication intentionally does not
|
||||
// depend on these values; accepting them keeps WebCrypto exports portable
|
||||
// across Chrome, Firefox, Safari and other standards-compliant browsers.
|
||||
Alg string `json:"alg,omitempty"`
|
||||
Use string `json:"use,omitempty"`
|
||||
Kid string `json:"kid,omitempty"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
ClientID string `json:"cid"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"sid"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
type challengeEntry struct {
|
||||
ClientID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
|
||||
// Login challenges are intentionally process-local ephemeral state. They do
|
||||
// not belong in SQLite: issuing a challenge is on the hot path when many
|
||||
// browsers connect at once and must not compete with guesses/presence writes
|
||||
// for SQLite's single writer lock. Challenges are keyed by the random nonce,
|
||||
// not by client ID, so two concurrent tabs can each finish their own login.
|
||||
challengeMu sync.Mutex
|
||||
challenges map[string]challengeEntry
|
||||
}
|
||||
|
||||
// New keeps the database parameter for source compatibility with older builds.
|
||||
// Authentication challenge state no longer writes to the database.
|
||||
func New(_ *sql.DB, secret string) *Manager {
|
||||
return &Manager{secret: []byte(secret), challenges: make(map[string]challengeEntry)}
|
||||
}
|
||||
|
||||
func ClientID(j PublicJWK) (string, error) {
|
||||
if j.Kty != "EC" || j.Crv != "P-256" || j.X == "" || j.Y == "" {
|
||||
return "", errors.New("only P-256 EC JWK is supported")
|
||||
}
|
||||
s := j.Kty + "|" + j.Crv + "|" + j.X + "|" + j.Y
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return b64.EncodeToString(h[:]), nil
|
||||
}
|
||||
|
||||
func PublicKey(j PublicJWK) (*ecdsa.PublicKey, error) {
|
||||
xb, err := b64.DecodeString(j.X)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
yb, err := b64.DecodeString(j.Y)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x, y := new(big.Int).SetBytes(xb), new(big.Int).SetBytes(yb)
|
||||
if !elliptic.P256().IsOnCurve(x, y) {
|
||||
return nil, errors.New("invalid P-256 point")
|
||||
}
|
||||
return &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, nil
|
||||
}
|
||||
|
||||
func VerifyRaw(pub *ecdsa.PublicKey, message string, sigB64 string) bool {
|
||||
sig, err := b64.DecodeString(sigB64)
|
||||
if err != nil || len(sig) != 64 {
|
||||
return false
|
||||
}
|
||||
h := sha256.Sum256([]byte(message))
|
||||
r := new(big.Int).SetBytes(sig[:32])
|
||||
s := new(big.Int).SetBytes(sig[32:])
|
||||
return ecdsa.Verify(pub, h[:], r, s)
|
||||
}
|
||||
|
||||
func randomB64(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b64.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (m *Manager) NewChallenge(ctx context.Context, cid string) (string, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
c, err := randomB64(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
expires := now.Add(2 * time.Minute)
|
||||
|
||||
m.challengeMu.Lock()
|
||||
defer m.challengeMu.Unlock()
|
||||
|
||||
// Opportunistic cleanup keeps the map bounded without a maintenance goroutine.
|
||||
for nonce, entry := range m.challenges {
|
||||
if !entry.ExpiresAt.After(now) {
|
||||
delete(m.challenges, nonce)
|
||||
}
|
||||
}
|
||||
m.challenges[c] = challengeEntry{ClientID: cid, ExpiresAt: expires}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) ConsumeChallenge(ctx context.Context, cid, challenge string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.challengeMu.Lock()
|
||||
defer m.challengeMu.Unlock()
|
||||
|
||||
entry, ok := m.challenges[challenge]
|
||||
if !ok {
|
||||
return errors.New("challenge expired or unknown")
|
||||
}
|
||||
// A known challenge is one-shot even if a malformed login tries to consume it.
|
||||
delete(m.challenges, challenge)
|
||||
if !entry.ExpiresAt.After(time.Now().UTC()) {
|
||||
return errors.New("challenge expired")
|
||||
}
|
||||
if entry.ClientID != cid {
|
||||
return errors.New("challenge identity mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Issue(cid, role string, ttl time.Duration) (string, Claims, error) {
|
||||
sid, err := randomB64(18)
|
||||
if err != nil {
|
||||
return "", Claims{}, err
|
||||
}
|
||||
c := Claims{ClientID: cid, Role: role, SessionID: sid, Exp: time.Now().Add(ttl).Unix()}
|
||||
hdr := b64.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||
p, _ := json.Marshal(c)
|
||||
payload := b64.EncodeToString(p)
|
||||
unsigned := hdr + "." + payload
|
||||
mac := hmac.New(sha256.New, m.secret)
|
||||
mac.Write([]byte(unsigned))
|
||||
sig := b64.EncodeToString(mac.Sum(nil))
|
||||
return unsigned + "." + sig, c, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Parse(token string) (Claims, error) {
|
||||
var c Claims
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return c, errors.New("bad token")
|
||||
}
|
||||
unsigned := parts[0] + "." + parts[1]
|
||||
mac := hmac.New(sha256.New, m.secret)
|
||||
mac.Write([]byte(unsigned))
|
||||
want := mac.Sum(nil)
|
||||
got, err := b64.DecodeString(parts[2])
|
||||
if err != nil || !hmac.Equal(want, got) {
|
||||
return c, errors.New("bad token signature")
|
||||
}
|
||||
pb, err := b64.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
if err := json.Unmarshal(pb, &c); err != nil {
|
||||
return c, err
|
||||
}
|
||||
if c.Exp < time.Now().Unix() {
|
||||
return c, errors.New("token expired")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
80
internal/auth/auth_test.go
Normal file
80
internal/auth/auth_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConcurrentChallengesSameIdentityDoNotOverwrite(t *testing.T) {
|
||||
m := New(nil, "test-secret")
|
||||
ctx := context.Background()
|
||||
c1, err := m.NewChallenge(ctx, "client-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c2, err := m.NewChallenge(ctx, "client-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c1 == c2 {
|
||||
t.Fatal("expected unique challenges")
|
||||
}
|
||||
if err := m.ConsumeChallenge(ctx, "client-a", c1); err != nil {
|
||||
t.Fatalf("first challenge should remain valid: %v", err)
|
||||
}
|
||||
if err := m.ConsumeChallenge(ctx, "client-a", c2); err != nil {
|
||||
t.Fatalf("second challenge should remain valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChallengeIsOneShotAndIdentityBound(t *testing.T) {
|
||||
m := New(nil, "test-secret")
|
||||
ctx := context.Background()
|
||||
c, err := m.NewChallenge(ctx, "client-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.ConsumeChallenge(ctx, "client-b", c); err == nil {
|
||||
t.Fatal("challenge must not be consumable by another identity")
|
||||
}
|
||||
if err := m.ConsumeChallenge(ctx, "client-a", c); err == nil {
|
||||
t.Fatal("challenge must be one-shot after a consume attempt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManyConcurrentChallenges(t *testing.T) {
|
||||
m := New(nil, "test-secret")
|
||||
ctx := context.Background()
|
||||
const n = 64
|
||||
type item struct{ cid, challenge string }
|
||||
out := make(chan item, n)
|
||||
errCh := make(chan error, n)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cid := fmt.Sprintf("client-%d", i)
|
||||
c, err := m.NewChallenge(ctx, cid)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
out <- item{cid: cid, challenge: c}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(out)
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for x := range out {
|
||||
if err := m.ConsumeChallenge(ctx, x.cid, x.challenge); err != nil {
|
||||
t.Fatalf("consume %s: %v", x.cid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
88
internal/core/core.go
Normal file
88
internal/core/core.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
func RandomDecimal(bits int) (string, error) {
|
||||
max := new(big.Int).Lsh(big.NewInt(1), uint(bits))
|
||||
n, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return n.String(), nil
|
||||
}
|
||||
|
||||
func RandomSeed() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func ExpectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
|
||||
h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%s|%d", taskID, seed, clientID, seq)))
|
||||
n := new(big.Int).SetBytes(h[:])
|
||||
mod := new(big.Int).Lsh(big.NewInt(1), uint(bits))
|
||||
n.Mod(n, mod)
|
||||
return n.String()
|
||||
}
|
||||
|
||||
func Distance(a, b string) (*big.Int, error) {
|
||||
x, ok := new(big.Int).SetString(a, 10)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid integer")
|
||||
}
|
||||
y, ok := new(big.Int).SetString(b, 10)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid integer")
|
||||
}
|
||||
d := new(big.Int).Sub(x, y)
|
||||
d.Abs(d)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func log2Big(x *big.Int) float64 {
|
||||
if x.Sign() <= 0 {
|
||||
return 0
|
||||
}
|
||||
n := x.BitLen()
|
||||
if n <= 53 {
|
||||
return math.Log2(float64(x.Uint64()))
|
||||
}
|
||||
shift := n - 53
|
||||
top := new(big.Int).Rsh(new(big.Int).Set(x), uint(shift))
|
||||
return math.Log2(float64(top.Uint64())) + float64(shift)
|
||||
}
|
||||
|
||||
func Score(distance *big.Int, bits int) float64 {
|
||||
if distance.Sign() == 0 {
|
||||
return 100
|
||||
}
|
||||
s := 100 * (1 - log2Big(new(big.Int).Add(distance, big.NewInt(1)))/float64(bits))
|
||||
if s < 0 {
|
||||
return 0
|
||||
}
|
||||
if s > 100 {
|
||||
return 100
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func Position(clientID string, score float64) (float64, float64, float64) {
|
||||
h := sha256.Sum256([]byte(clientID))
|
||||
f := func(i int) float64 { v := uint16(h[i])<<8 | uint16(h[i+1]); return float64(v)/65535*2 - 1 }
|
||||
x, y, z := f(0), f(2), f(4)
|
||||
norm := math.Sqrt(x*x + y*y + z*z)
|
||||
if norm < 0.001 {
|
||||
norm = 1
|
||||
}
|
||||
r := 0.25 + 13*(1-score/100)
|
||||
return r * x / norm, r * y / norm, r * z / norm
|
||||
}
|
||||
29
internal/core/core_test.go
Normal file
29
internal/core/core_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpectedGuessDeterministic(t *testing.T) {
|
||||
a := ExpectedGuess("task_x", "seed", "client", 7, 28)
|
||||
b := ExpectedGuess("task_x", "seed", "client", 7, 28)
|
||||
if a != b {
|
||||
t.Fatalf("not deterministic: %s != %s", a, b)
|
||||
}
|
||||
n, ok := new(big.Int).SetString(a, 10)
|
||||
if !ok || n.Sign() < 0 || n.BitLen() > 28 {
|
||||
t.Fatalf("guess outside range: %s", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore(t *testing.T) {
|
||||
if Score(big.NewInt(0), 32) != 100 {
|
||||
t.Fatal("exact hit must score 100")
|
||||
}
|
||||
near := Score(big.NewInt(1), 32)
|
||||
far := Score(new(big.Int).Lsh(big.NewInt(1), 30), 32)
|
||||
if near <= far {
|
||||
t.Fatalf("near score %f should exceed far %f", near, far)
|
||||
}
|
||||
}
|
||||
285
internal/runtime/state.go
Normal file
285
internal/runtime/state.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/data"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPresenceConflict = errors.New("identity already connected")
|
||||
ErrRateLimited = errors.New("rate limited")
|
||||
ErrBadSequence = errors.New("bad sequence")
|
||||
)
|
||||
|
||||
type guessKey struct{ TaskID, ClientID string }
|
||||
|
||||
type presenceEntry struct {
|
||||
SessionID string
|
||||
LeaseID uint64
|
||||
}
|
||||
|
||||
type GuessState struct {
|
||||
NextSeq int64
|
||||
LastGuess time.Time
|
||||
BestScore float64
|
||||
GuessCount int64
|
||||
PublicSeed string
|
||||
Revision int64
|
||||
}
|
||||
|
||||
type State struct {
|
||||
mu sync.RWMutex
|
||||
presence map[string]presenceEntry
|
||||
selected map[string]string
|
||||
guesses map[guessKey]GuessState
|
||||
|
||||
started time.Time
|
||||
presenceSeq atomic.Uint64
|
||||
guessesTotal atomic.Uint64
|
||||
improvementsTotal atomic.Uint64
|
||||
sqliteWritesTotal atomic.Uint64
|
||||
rejectedTotal atomic.Uint64
|
||||
|
||||
rateMu sync.Mutex
|
||||
sec [60]rateBucket
|
||||
}
|
||||
|
||||
type rateBucket struct {
|
||||
unix int64
|
||||
guesses uint64
|
||||
improvements uint64
|
||||
sqliteWrites uint64
|
||||
rejected uint64
|
||||
}
|
||||
|
||||
func New() *State {
|
||||
return &State{presence: make(map[string]presenceEntry), selected: make(map[string]string), guesses: make(map[guessKey]GuessState), started: time.Now()}
|
||||
}
|
||||
|
||||
// AcquirePresence reserves a client identity for one authenticated browser/session.
|
||||
// Reconnects from the SAME session are allowed and receive a new lease generation.
|
||||
// The generation is critical: an old websocket may finish closing after a replacement
|
||||
// connection has already been established. Its deferred ReleasePresence must not delete
|
||||
// the newer connection's presence entry.
|
||||
func (s *State) AcquirePresence(clientID, sessionID string) (uint64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if cur, ok := s.presence[clientID]; ok && cur.SessionID != sessionID {
|
||||
return 0, ErrPresenceConflict
|
||||
}
|
||||
lease := s.presenceSeq.Add(1)
|
||||
s.presence[clientID] = presenceEntry{SessionID: sessionID, LeaseID: lease}
|
||||
return lease, nil
|
||||
}
|
||||
func (s *State) HasPresence(clientID, sessionID string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cur, ok := s.presence[clientID]
|
||||
return ok && cur.SessionID == sessionID
|
||||
}
|
||||
func (s *State) ReleasePresence(clientID, sessionID string, leaseID uint64) {
|
||||
s.mu.Lock()
|
||||
if cur, ok := s.presence[clientID]; ok && cur.SessionID == sessionID && cur.LeaseID == leaseID {
|
||||
delete(s.presence, clientID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *State) IsConnected(clientID string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, ok := s.presence[clientID]
|
||||
return ok
|
||||
}
|
||||
func (s *State) ConnectedCount() int64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return int64(len(s.presence))
|
||||
}
|
||||
|
||||
func (s *State) SetTaskSelection(clientID, taskID string) {
|
||||
s.mu.Lock()
|
||||
if taskID == "" {
|
||||
delete(s.selected, clientID)
|
||||
} else {
|
||||
s.selected[clientID] = taskID
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *State) TaskSelection(clientID string) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.selected[clientID]
|
||||
}
|
||||
|
||||
func (s *State) ReplaceTaskSelection(oldTaskID, newTaskID string) {
|
||||
s.mu.Lock()
|
||||
for cid, taskID := range s.selected {
|
||||
if taskID == oldTaskID {
|
||||
s.selected[cid] = newTaskID
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *State) InitGuess(task data.Task, cid string, persisted GuessState) GuessState {
|
||||
k := guessKey{task.ID, cid}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if cur, ok := s.guesses[k]; ok {
|
||||
// Preserve the hot-path sequence across admin changes that keep the same seed.
|
||||
// A reroll changes public_seed, which intentionally reloads the persisted reset.
|
||||
if cur.PublicSeed == task.PublicSeed {
|
||||
cur.Revision = task.Revision
|
||||
s.guesses[k] = cur
|
||||
return cur
|
||||
}
|
||||
}
|
||||
persisted.PublicSeed = task.PublicSeed
|
||||
persisted.Revision = task.Revision
|
||||
s.guesses[k] = persisted
|
||||
return persisted
|
||||
}
|
||||
|
||||
func (s *State) Current(task data.Task, cid string) (GuessState, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
g, ok := s.guesses[guessKey{task.ID, cid}]
|
||||
if !ok || g.PublicSeed != task.PublicSeed {
|
||||
return GuessState{}, false
|
||||
}
|
||||
return g, true
|
||||
}
|
||||
|
||||
type AcceptResult struct {
|
||||
State GuessState
|
||||
Previous GuessState
|
||||
Improved bool
|
||||
}
|
||||
|
||||
func (s *State) Accept(task data.Task, cid string, seq int64, score float64, minInterval time.Duration) (AcceptResult, error) {
|
||||
k := guessKey{task.ID, cid}
|
||||
now := time.Now().UTC()
|
||||
s.mu.Lock()
|
||||
g, ok := s.guesses[k]
|
||||
if !ok || g.PublicSeed != task.PublicSeed {
|
||||
s.mu.Unlock()
|
||||
return AcceptResult{}, ErrBadSequence
|
||||
}
|
||||
if seq != g.NextSeq {
|
||||
s.mu.Unlock()
|
||||
s.record(false, false, true)
|
||||
return AcceptResult{}, ErrBadSequence
|
||||
}
|
||||
prev := g
|
||||
if !g.LastGuess.IsZero() && minInterval > 0 && now.Sub(g.LastGuess) < minInterval {
|
||||
s.mu.Unlock()
|
||||
s.record(false, false, true)
|
||||
return AcceptResult{}, ErrRateLimited
|
||||
}
|
||||
g.NextSeq++
|
||||
g.GuessCount++
|
||||
g.LastGuess = now
|
||||
improved := score > g.BestScore
|
||||
if improved {
|
||||
g.BestScore = score
|
||||
}
|
||||
g.Revision = task.Revision
|
||||
s.guesses[k] = g
|
||||
s.mu.Unlock()
|
||||
s.record(true, improved, false)
|
||||
return AcceptResult{State: g, Previous: prev, Improved: improved}, nil
|
||||
}
|
||||
|
||||
func (s *State) Restore(task data.Task, cid string, acceptedNextSeq int64, previous GuessState) {
|
||||
k := guessKey{task.ID, cid}
|
||||
s.mu.Lock()
|
||||
if cur, ok := s.guesses[k]; ok && cur.NextSeq == acceptedNextSeq && cur.PublicSeed == task.PublicSeed {
|
||||
s.guesses[k] = previous
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *State) MarkSQLiteWrite() {
|
||||
s.sqliteWritesTotal.Add(1)
|
||||
s.rateMu.Lock()
|
||||
b := s.bucketLocked(time.Now().Unix())
|
||||
b.sqliteWrites++
|
||||
s.rateMu.Unlock()
|
||||
}
|
||||
func (s *State) record(guess, improvement, rejected bool) {
|
||||
if guess {
|
||||
s.guessesTotal.Add(1)
|
||||
}
|
||||
if improvement {
|
||||
s.improvementsTotal.Add(1)
|
||||
}
|
||||
if rejected {
|
||||
s.rejectedTotal.Add(1)
|
||||
}
|
||||
s.rateMu.Lock()
|
||||
b := s.bucketLocked(time.Now().Unix())
|
||||
if guess {
|
||||
b.guesses++
|
||||
}
|
||||
if improvement {
|
||||
b.improvements++
|
||||
}
|
||||
if rejected {
|
||||
b.rejected++
|
||||
}
|
||||
s.rateMu.Unlock()
|
||||
}
|
||||
func (s *State) bucketLocked(sec int64) *rateBucket {
|
||||
i := sec % 60
|
||||
if s.sec[i].unix != sec {
|
||||
s.sec[i] = rateBucket{unix: sec}
|
||||
}
|
||||
return &s.sec[i]
|
||||
}
|
||||
|
||||
func (s *State) Rates(window int64) (guesses, improvements, sqliteWrites, rejected float64) {
|
||||
if window < 1 {
|
||||
window = 5
|
||||
}
|
||||
if window > 60 {
|
||||
window = 60
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
var g, i, w, r uint64
|
||||
s.rateMu.Lock()
|
||||
for x := range s.sec {
|
||||
b := s.sec[x]
|
||||
if b.unix > now-window {
|
||||
g += b.guesses
|
||||
i += b.improvements
|
||||
w += b.sqliteWrites
|
||||
r += b.rejected
|
||||
}
|
||||
}
|
||||
s.rateMu.Unlock()
|
||||
d := float64(window)
|
||||
return float64(g) / d, float64(i) / d, float64(w) / d, float64(r) / d
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
Connected int64 `json:"connected"`
|
||||
GuessesPerSec float64 `json:"guesses_per_sec"`
|
||||
ImprovementsPerSec float64 `json:"improvements_per_sec"`
|
||||
SQLiteWritesPerSec float64 `json:"sqlite_writes_per_sec"`
|
||||
RejectedPerSec float64 `json:"rejected_per_sec"`
|
||||
GuessesTotal uint64 `json:"guesses_total"`
|
||||
ImprovementsTotal uint64 `json:"improvements_total"`
|
||||
SQLiteWritesTotal uint64 `json:"sqlite_writes_total"`
|
||||
RejectedTotal uint64 `json:"rejected_total"`
|
||||
UptimeSec int64 `json:"uptime_sec"`
|
||||
}
|
||||
|
||||
func (s *State) Metrics() Metrics {
|
||||
g, i, w, r := s.Rates(5)
|
||||
return Metrics{Connected: s.ConnectedCount(), GuessesPerSec: g, ImprovementsPerSec: i, SQLiteWritesPerSec: w, RejectedPerSec: r, GuessesTotal: s.guessesTotal.Load(), ImprovementsTotal: s.improvementsTotal.Load(), SQLiteWritesTotal: s.sqliteWritesTotal.Load(), RejectedTotal: s.rejectedTotal.Load(), UptimeSec: int64(time.Since(s.started).Seconds())}
|
||||
}
|
||||
81
internal/runtime/state_test.go
Normal file
81
internal/runtime/state_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/data"
|
||||
)
|
||||
|
||||
func TestPresenceSingleSession(t *testing.T) {
|
||||
s := New()
|
||||
lease1, err := s.AcquirePresence("c", "s1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.AcquirePresence("c", "s2"); err == nil {
|
||||
t.Fatal("expected conflict")
|
||||
}
|
||||
if !s.HasPresence("c", "s1") {
|
||||
t.Fatal("missing presence")
|
||||
}
|
||||
s.ReleasePresence("c", "s1", lease1)
|
||||
if s.ConnectedCount() != 0 {
|
||||
t.Fatal("presence not released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceReconnectOldReleaseCannotDeleteNewLease(t *testing.T) {
|
||||
s := New()
|
||||
oldLease, err := s.AcquirePresence("c", "same-session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newLease, err := s.AcquirePresence("c", "same-session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oldLease == newLease {
|
||||
t.Fatal("expected a new lease generation")
|
||||
}
|
||||
|
||||
// This is the race that caused periodic HTTP 409 responses: the old
|
||||
// websocket's defer ran after the replacement websocket had connected.
|
||||
s.ReleasePresence("c", "same-session", oldLease)
|
||||
if !s.HasPresence("c", "same-session") {
|
||||
t.Fatal("stale websocket release removed the replacement presence")
|
||||
}
|
||||
|
||||
s.ReleasePresence("c", "same-session", newLease)
|
||||
if s.ConnectedCount() != 0 {
|
||||
t.Fatal("new lease was not released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessHotPathAndReroll(t *testing.T) {
|
||||
s := New()
|
||||
task := data.Task{ID: "t", PublicSeed: "a", Revision: 1}
|
||||
s.InitGuess(task, "c", GuessState{})
|
||||
r, err := s.Accept(task, "c", 0, 12.5, time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.Improved || r.State.NextSeq != 1 || r.State.GuessCount != 1 {
|
||||
t.Fatalf("bad state: %+v", r)
|
||||
}
|
||||
if _, err = s.Accept(task, "c", 1, 10, time.Hour); err != ErrRateLimited {
|
||||
t.Fatalf("want rate limit, got %v", err)
|
||||
}
|
||||
// Same seed/admin revision preserves sequence.
|
||||
task.Revision = 2
|
||||
g := s.InitGuess(task, "c", GuessState{})
|
||||
if g.NextSeq != 1 {
|
||||
t.Fatalf("sequence lost: %d", g.NextSeq)
|
||||
}
|
||||
// New seed is a reroll and reloads the persisted reset.
|
||||
task.PublicSeed = "b"
|
||||
g = s.InitGuess(task, "c", GuessState{})
|
||||
if g.NextSeq != 0 || g.BestScore != 0 {
|
||||
t.Fatalf("reroll not reset: %+v", g)
|
||||
}
|
||||
}
|
||||
254
internal/server/artifact_references.go
Normal file
254
internal/server/artifact_references.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/artifact"
|
||||
wsx "neuralhunt/internal/ws"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const maxStyleReferenceBytes = 12 << 20
|
||||
|
||||
func (s *Server) adminCreateCharacterAnchor(w http.ResponseWriter, r *http.Request) {
|
||||
if s.artifactWorker == nil {
|
||||
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "artifact worker unavailable"})
|
||||
return
|
||||
}
|
||||
if err := s.artifactWorker.CreateCharacterAnchor(r.Context()); err != nil {
|
||||
if errors.Is(err, artifact.ErrCharacterAnchorExists) {
|
||||
jsonOut(w, http.StatusConflict, map[string]string{"error": "character anchor already exists"})
|
||||
return
|
||||
}
|
||||
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
jsonOut(w, http.StatusCreated, map[string]any{
|
||||
"ok": true,
|
||||
"url": "/api/admin/artifact/character-anchor?ts=" + fmt.Sprint(time.Now().UnixMilli()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) adminCharacterAnchorFile(w http.ResponseWriter, r *http.Request) {
|
||||
path := filepath.Join(s.artifactDir, "_collection", "character_anchor.png")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || len(b) <= 1024 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(b)))
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func normalizeUploadedStyle(raw []byte) (ext, contentType string, width, height int, err error) {
|
||||
if len(raw) < 128 {
|
||||
return "", "", 0, 0, errors.New("style reference is empty")
|
||||
}
|
||||
contentType = strings.Split(http.DetectContentType(raw), ";")[0]
|
||||
switch contentType {
|
||||
case "image/jpeg":
|
||||
ext = ".jpg"
|
||||
case "image/png":
|
||||
ext = ".png"
|
||||
default:
|
||||
return "", "", 0, 0, fmt.Errorf("unsupported style image type %q; use JPEG or PNG", contentType)
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", "", 0, 0, fmt.Errorf("decode style image: %w", err)
|
||||
}
|
||||
if cfg.Width < 128 || cfg.Height < 128 || cfg.Width > 8192 || cfg.Height > 8192 || int64(cfg.Width)*int64(cfg.Height) > 40_000_000 {
|
||||
return "", "", 0, 0, fmt.Errorf("style image dimensions %dx%d are outside the allowed range", cfg.Width, cfg.Height)
|
||||
}
|
||||
return ext, contentType, cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
func writeStyleAsset(dir, name string, raw []byte) error {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dir, name)
|
||||
if st, err := os.Stat(target); err == nil && st.Size() == int64(len(raw)) {
|
||||
// The filename is a SHA-256 of the file contents, so an existing asset
|
||||
// with the same name is the same immutable reference. This also avoids
|
||||
// Windows rename-over-existing behavior.
|
||||
return nil
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".style-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(0o640); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, target)
|
||||
}
|
||||
|
||||
func (s *Server) adminTaskStyleReferencePut(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxStyleReferenceBytes+1<<20)
|
||||
if err := r.ParseMultipartForm(maxStyleReferenceBytes); err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
f, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "multipart field 'file' is required"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(f, maxStyleReferenceBytes+1))
|
||||
if err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "read upload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(raw) > maxStyleReferenceBytes {
|
||||
jsonOut(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "style reference exceeds 12 MiB"})
|
||||
return
|
||||
}
|
||||
ext, contentType, width, height, err := normalizeUploadedStyle(raw)
|
||||
if err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h := sha256.Sum256(raw)
|
||||
name := hex.EncodeToString(h[:]) + ext
|
||||
if err := writeStyleAsset(filepath.Join(s.artifactDir, "_styles"), name, raw); err != nil {
|
||||
jsonOut(w, http.StatusInternalServerError, map[string]string{"error": "store style reference: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := s.store.SetTaskStyleReference(r.Context(), id, name); err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_changed", TaskID: id, Data: map[string]string{"action": "style_reference"}})
|
||||
jsonOut(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"name": name,
|
||||
"sha256": hex.EncodeToString(h[:]),
|
||||
"content_type": contentType,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"url": "/api/admin/tasks/" + id + "/style-reference?ts=" + fmt.Sprint(time.Now().UnixMilli()),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) adminTaskStyleReferenceDelete(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := s.store.SetTaskStyleReference(r.Context(), id, ""); err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = s.hub.Publish(r.Context(), wsx.Event{Type: "task_changed", TaskID: id, Data: map[string]string{"action": "style_reference"}})
|
||||
jsonOut(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func serveTaskStyleReference(w http.ResponseWriter, r *http.Request, ref artifact.TaskStyleReference, cache string) {
|
||||
if len(ref.Bytes) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ct := ref.ContentType
|
||||
if ct == "" {
|
||||
ct = http.DetectContentType(ref.Bytes)
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("Cache-Control", cache)
|
||||
w.Header().Set("ETag", `"`+ref.SHA256+`"`)
|
||||
w.Header().Set("Content-Length", fmt.Sprint(len(ref.Bytes)))
|
||||
_, _ = w.Write(ref.Bytes)
|
||||
}
|
||||
|
||||
func (s *Server) adminTaskStyleReferenceFile(w http.ResponseWriter, r *http.Request) {
|
||||
if s.artifactWorker == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ref, err := s.artifactWorker.TaskStyleReference(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveTaskStyleReference(w, r, ref, "private, no-store")
|
||||
}
|
||||
|
||||
func (s *Server) publicTaskStyleReference(w http.ResponseWriter, r *http.Request) {
|
||||
if s.artifactWorker == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
var status string
|
||||
if err := s.store.DB.QueryRowContext(r.Context(), `SELECT status FROM tasks WHERE id=?`, id).Scan(&status); err != nil || status != "active" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ref, err := s.artifactWorker.TaskStyleReference(r.Context(), id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
serveTaskStyleReference(w, r, ref, "public, max-age=300")
|
||||
}
|
||||
|
||||
func (s *Server) adminCreatePipelineTestCard(w http.ResponseWriter, r *http.Request) {
|
||||
if s.artifactWorker == nil {
|
||||
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "artifact worker unavailable"})
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
path, err := s.artifactWorker.CreatePipelineTestCard(r.Context(), id)
|
||||
if err != nil {
|
||||
jsonOut(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = path
|
||||
jsonOut(w, http.StatusCreated, map[string]any{"ok": true, "url": "/api/admin/tasks/" + id + "/pipeline-test/card?ts=" + fmt.Sprint(time.Now().UnixMilli()), "api_calls": 0, "cost_usd": 0})
|
||||
}
|
||||
|
||||
func (s *Server) adminPipelineTestCardFile(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if filepath.Base(id) != id || strings.ContainsAny(id, `/\\`) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(s.artifactDir, "_test", id, "image.svg")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || len(b) < 128 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
120
internal/server/artifact_usage.go
Normal file
120
internal/server/artifact_usage.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type artifactUsageRow struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Model string `json:"model"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Size string `json:"size"`
|
||||
Quality string `json:"quality"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
InputTextTokens int64 `json:"input_text_tokens"`
|
||||
InputImageTokens int64 `json:"input_image_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
EstimatedCostUSD *float64 `json:"estimated_cost_usd,omitempty"`
|
||||
PricingBasis string `json:"pricing_basis,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) adminArtifactUsage(w http.ResponseWriter, r *http.Request) {
|
||||
now := time.Now().UTC()
|
||||
dayStartMS := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||
if raw := r.URL.Query().Get("day_start_ms"); raw != "" {
|
||||
if v, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
// Accept a browser-local midnight in a reasonable window. This lets the
|
||||
// "today" KPI follow the admin's local calendar day without adding a
|
||||
// timezone setting to the server.
|
||||
candidate := time.UnixMilli(v).UTC()
|
||||
if candidate.After(now.Add(-48*time.Hour)) && candidate.Before(now.Add(24*time.Hour)) {
|
||||
dayStartMS = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var todayCalls, todayCards, todayPriced int64
|
||||
var todayCost float64
|
||||
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*),
|
||||
COALESCE(sum(CASE WHEN kind='artifact' THEN 1 ELSE 0 END),0),
|
||||
COALESCE(sum(CASE WHEN estimated_cost_usd IS NOT NULL THEN 1 ELSE 0 END),0),
|
||||
COALESCE(sum(estimated_cost_usd),0)
|
||||
FROM artifact_api_usage WHERE created_at>=?`, dayStartMS).Scan(&todayCalls, &todayCards, &todayPriced, &todayCost)
|
||||
|
||||
var cardCalls, pricedCardCalls int64
|
||||
var totalCardCost, avgCardCost float64
|
||||
var inputTokens, inputTextTokens, inputImageTokens, outputTokens, totalTokens int64
|
||||
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*),count(estimated_cost_usd),
|
||||
COALESCE(sum(estimated_cost_usd),0),COALESCE(avg(estimated_cost_usd),0),
|
||||
COALESCE(sum(input_tokens),0),COALESCE(sum(input_text_tokens),0),COALESCE(sum(input_image_tokens),0),
|
||||
COALESCE(sum(output_tokens),0),COALESCE(sum(total_tokens),0)
|
||||
FROM artifact_api_usage WHERE kind='artifact'`).Scan(
|
||||
&cardCalls, &pricedCardCalls, &totalCardCost, &avgCardCost,
|
||||
&inputTokens, &inputTextTokens, &inputImageTokens, &outputTokens, &totalTokens)
|
||||
|
||||
var anchorCalls int64
|
||||
var anchorCost float64
|
||||
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT count(*),COALESCE(sum(estimated_cost_usd),0)
|
||||
FROM artifact_api_usage WHERE kind='character_anchor'`).Scan(&anchorCalls, &anchorCost)
|
||||
|
||||
recent := make([]artifactUsageRow, 0, 20)
|
||||
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT created_at,task_id,kind,model,endpoint,size,quality,request_id,
|
||||
input_tokens,input_text_tokens,input_image_tokens,output_tokens,total_tokens,estimated_cost_usd,pricing_basis
|
||||
FROM artifact_api_usage ORDER BY created_at DESC,id DESC LIMIT 20`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var createdMS int64
|
||||
var taskID, requestID sql.NullString
|
||||
var cost sql.NullFloat64
|
||||
var row artifactUsageRow
|
||||
if err := rows.Scan(&createdMS, &taskID, &row.Kind, &row.Model, &row.Endpoint, &row.Size, &row.Quality, &requestID,
|
||||
&row.InputTokens, &row.InputTextTokens, &row.InputImageTokens, &row.OutputTokens, &row.TotalTokens, &cost, &row.PricingBasis); err != nil {
|
||||
continue
|
||||
}
|
||||
row.CreatedAt = time.UnixMilli(createdMS).UTC()
|
||||
if taskID.Valid {
|
||||
row.TaskID = taskID.String
|
||||
}
|
||||
if requestID.Valid {
|
||||
row.RequestID = requestID.String
|
||||
}
|
||||
if cost.Valid {
|
||||
v := cost.Float64
|
||||
row.EstimatedCostUSD = &v
|
||||
}
|
||||
recent = append(recent, row)
|
||||
}
|
||||
}
|
||||
|
||||
jsonOut(w, 200, map[string]any{
|
||||
"day_start": time.UnixMilli(dayStartMS).UTC(),
|
||||
"today_calls": todayCalls,
|
||||
"today_cards": todayCards,
|
||||
"today_priced_calls": todayPriced,
|
||||
"today_cost_usd": todayCost,
|
||||
"card_generations": cardCalls,
|
||||
"priced_card_generations": pricedCardCalls,
|
||||
"total_card_cost_usd": totalCardCost,
|
||||
"avg_card_cost_usd": avgCardCost,
|
||||
"cost_per_1000_usd": avgCardCost * 1000,
|
||||
"anchor_generations": anchorCalls,
|
||||
"anchor_cost_usd": anchorCost,
|
||||
"card_usage": map[string]int64{
|
||||
"input_tokens": inputTokens,
|
||||
"input_text_tokens": inputTextTokens,
|
||||
"input_image_tokens": inputImageTokens,
|
||||
"output_tokens": outputTokens,
|
||||
"total_tokens": totalTokens,
|
||||
},
|
||||
"cost_method": "estimated from provider-reported token usage using pinned OpenAI standard public token rates; not invoice reconciliation",
|
||||
"recent": recent,
|
||||
})
|
||||
}
|
||||
31
internal/server/auth_decode_test.go
Normal file
31
internal/server/auth_decode_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"neuralhunt/internal/auth"
|
||||
)
|
||||
|
||||
func TestDecodeAuthAcceptsExtraJWKMembers(t *testing.T) {
|
||||
body := `{"public_jwk":{"kty":"EC","crv":"P-256","x":"abc","y":"def","ext":true,"key_ops":["verify"],"alg":"ES256","use":"sig","kid":"browser-key","vendor_future_field":"ok"}}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/challenge", strings.NewReader(body))
|
||||
var in struct {
|
||||
PublicJWK auth.PublicJWK `json:"public_jwk"`
|
||||
}
|
||||
if err := decodeAuth(req, &in); err != nil {
|
||||
t.Fatalf("decodeAuth should tolerate optional/unknown JWK metadata: %v", err)
|
||||
}
|
||||
if in.PublicJWK.Kty != "EC" || in.PublicJWK.Crv != "P-256" || in.PublicJWK.X != "abc" || in.PublicJWK.Y != "def" {
|
||||
t.Fatalf("core JWK fields were not decoded: %+v", in.PublicJWK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAuthRejectsSecondJSONValue(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/auth/challenge", strings.NewReader(`{"public_jwk":{}} {"x":1}`))
|
||||
var in any
|
||||
if err := decodeAuth(req, &in); err == nil {
|
||||
t.Fatal("expected second JSON value to be rejected")
|
||||
}
|
||||
}
|
||||
1160
internal/server/server.go
Normal file
1160
internal/server/server.go
Normal file
File diff suppressed because it is too large
Load Diff
213
internal/server/watermark.go
Normal file
213
internal/server/watermark.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// artifactLocalPath maps an artifact URI emitted by the worker back into the
|
||||
// configured artifact directory. It deliberately rejects traversal and files
|
||||
// outside /artifacts/ so the public preview endpoint cannot become a generic
|
||||
// file reader.
|
||||
func artifactLocalPath(root, artifactURI string) (string, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return "", fmt.Errorf("artifact storage disabled")
|
||||
}
|
||||
u, err := url.Parse(artifactURI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := u.Path
|
||||
const prefix = "/artifacts/"
|
||||
i := strings.Index(p, prefix)
|
||||
if i < 0 {
|
||||
return "", fmt.Errorf("artifact URI outside artifact namespace")
|
||||
}
|
||||
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(p[i:], prefix)))
|
||||
if rel == "." || rel == "" || filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("invalid artifact path")
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
candidate := filepath.Join(rootAbs, rel)
|
||||
candidateAbs, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
check, err := filepath.Rel(rootAbs, candidateAbs)
|
||||
if err != nil || check == ".." || strings.HasPrefix(check, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("artifact path escapes storage")
|
||||
}
|
||||
return candidateAbs, nil
|
||||
}
|
||||
|
||||
func watermarkPreviewFile(path, label string) ([]byte, string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(b) > 64<<20 {
|
||||
return nil, "", fmt.Errorf("artifact too large for preview")
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext == ".svg" || bytes.Contains(bytes.ToLower(b[:minInt(len(b), 512)]), []byte("<svg")) {
|
||||
out, err := watermarkSVG(b, label)
|
||||
return out, "image/svg+xml; charset=utf-8", err
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode preview image: %w", err)
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
if bounds.Dx() < 1 || bounds.Dy() < 1 {
|
||||
return nil, "", fmt.Errorf("empty artifact image")
|
||||
}
|
||||
dst := image.NewNRGBA(bounds)
|
||||
draw.Draw(dst, bounds, img, bounds.Min, draw.Src)
|
||||
drawRasterWatermark(dst, label)
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, dst); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return out.Bytes(), "image/png", nil
|
||||
}
|
||||
|
||||
func watermarkSVG(src []byte, label string) ([]byte, error) {
|
||||
s := string(src)
|
||||
i := strings.LastIndex(strings.ToLower(s), "</svg>")
|
||||
if i < 0 {
|
||||
return nil, fmt.Errorf("invalid svg artifact")
|
||||
}
|
||||
label = html.EscapeString(strings.ToUpper(strings.TrimSpace(label)))
|
||||
if label == "" {
|
||||
label = "NEURAL HUNT PREVIEW"
|
||||
}
|
||||
overlay := fmt.Sprintf(`<defs><pattern id="nh-preview-watermark" width="420" height="190" patternUnits="userSpaceOnUse" patternTransform="rotate(-24)"><text x="18" y="96" fill="white" fill-opacity="0.16" font-family="system-ui,sans-serif" font-size="28" font-weight="800" letter-spacing="5">%s</text></pattern></defs><rect x="0" y="0" width="100%%" height="100%%" fill="url(#nh-preview-watermark)" pointer-events="none"/><rect x="2%%" y="92%%" width="96%%" height="5%%" rx="12" fill="black" fill-opacity="0.30"/><text x="50%%" y="95.5%%" text-anchor="middle" fill="white" fill-opacity="0.72" font-family="system-ui,sans-serif" font-size="18" font-weight="800" letter-spacing="4">WATERMARKED LEADERBOARD PREVIEW</text>`, label)
|
||||
return []byte(s[:i] + overlay + s[i:]), nil
|
||||
}
|
||||
|
||||
var pixelFont = map[rune][7]string{
|
||||
'A': {"01110", "10001", "10001", "11111", "10001", "10001", "10001"},
|
||||
'E': {"11111", "10000", "10000", "11110", "10000", "10000", "11111"},
|
||||
'H': {"10001", "10001", "10001", "11111", "10001", "10001", "10001"},
|
||||
'I': {"11111", "00100", "00100", "00100", "00100", "00100", "11111"},
|
||||
'L': {"10000", "10000", "10000", "10000", "10000", "10000", "11111"},
|
||||
'N': {"10001", "11001", "10101", "10101", "10011", "10001", "10001"},
|
||||
'P': {"11110", "10001", "10001", "11110", "10000", "10000", "10000"},
|
||||
'R': {"11110", "10001", "10001", "11110", "10100", "10010", "10001"},
|
||||
'T': {"11111", "00100", "00100", "00100", "00100", "00100", "00100"},
|
||||
'U': {"10001", "10001", "10001", "10001", "10001", "10001", "01110"},
|
||||
'V': {"10001", "10001", "10001", "10001", "10001", "01010", "00100"},
|
||||
'W': {"10001", "10001", "10001", "10101", "10101", "10101", "01010"},
|
||||
}
|
||||
|
||||
func drawRasterWatermark(img *image.NRGBA, label string) {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w < 1 || h < 1 {
|
||||
return
|
||||
}
|
||||
label = strings.ToUpper(strings.TrimSpace(label))
|
||||
if label == "" {
|
||||
label = "NEURAL HUNT PREVIEW"
|
||||
}
|
||||
// Keep the bitmap watermark large enough to survive thumbnail scaling.
|
||||
scale := minInt(w, h) / 360
|
||||
if scale < 1 {
|
||||
scale = 1
|
||||
}
|
||||
if scale > 6 {
|
||||
scale = 6
|
||||
}
|
||||
textW := pixelTextWidth(label, scale)
|
||||
rowStep := 72 * scale
|
||||
colStep := textW + 54*scale
|
||||
for y, row := b.Min.Y+18*scale, 0; y < b.Max.Y; y, row = y+rowStep, row+1 {
|
||||
offset := 0
|
||||
if row%2 == 1 {
|
||||
offset = -(colStep / 2)
|
||||
}
|
||||
for x := b.Min.X + offset; x < b.Max.X; x += colStep {
|
||||
drawPixelText(img, x+scale, y+scale, label, scale, color.NRGBA{0, 0, 0, 72})
|
||||
drawPixelText(img, x, y, label, scale, color.NRGBA{255, 255, 255, 48})
|
||||
}
|
||||
}
|
||||
// Strong lower preview band so cropped screenshots still visibly carry a
|
||||
// watermark. It contains a repeated NH glyph rather than metadata.
|
||||
bandH := maxInt(16*scale, h/18)
|
||||
bandY := b.Max.Y - bandH
|
||||
draw.Draw(img, image.Rect(b.Min.X, bandY, b.Max.X, b.Max.Y), &image.Uniform{C: color.NRGBA{0, 0, 0, 92}}, image.Point{}, draw.Over)
|
||||
for x := b.Min.X + 8*scale; x < b.Max.X; x += 40 * scale {
|
||||
drawPixelText(img, x, bandY+4*scale, "NH", scale, color.NRGBA{255, 255, 255, 118})
|
||||
}
|
||||
}
|
||||
|
||||
func pixelTextWidth(text string, scale int) int {
|
||||
if scale < 1 {
|
||||
scale = 1
|
||||
}
|
||||
w := 0
|
||||
for _, r := range text {
|
||||
if r == ' ' {
|
||||
w += 4 * scale
|
||||
} else {
|
||||
w += 6 * scale
|
||||
}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func drawPixelText(dst draw.Image, x, y int, text string, scale int, col color.Color) {
|
||||
if scale < 1 {
|
||||
scale = 1
|
||||
}
|
||||
cx := x
|
||||
for _, r := range text {
|
||||
if r == ' ' {
|
||||
cx += 4 * scale
|
||||
continue
|
||||
}
|
||||
glyph, ok := pixelFont[r]
|
||||
if !ok {
|
||||
cx += 6 * scale
|
||||
continue
|
||||
}
|
||||
for gy, row := range glyph {
|
||||
for gx, bit := range row {
|
||||
if bit != '1' {
|
||||
continue
|
||||
}
|
||||
rect := image.Rect(cx+gx*scale, y+gy*scale, cx+(gx+1)*scale, y+(gy+1)*scale)
|
||||
draw.Draw(dst, rect, &image.Uniform{C: col}, image.Point{}, draw.Over)
|
||||
}
|
||||
}
|
||||
cx += 6 * scale
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
65
internal/server/watermark_test.go
Normal file
65
internal/server/watermark_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArtifactLocalPathRejectsTraversal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if _, err := artifactLocalPath(root, "/artifacts/../secret.txt"); err == nil {
|
||||
t.Fatal("expected traversal rejection")
|
||||
}
|
||||
got, err := artifactLocalPath(root, "/artifacts/a/image.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(root, "a", "image.png")
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatermarkSVG(t *testing.T) {
|
||||
out, err := watermarkSVG([]byte(`<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10"/></svg>`), "NEURAL HUNT PREVIEW")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), "WATERMARKED LEADERBOARD PREVIEW") || !strings.Contains(string(out), "nh-preview-watermark") {
|
||||
t.Fatalf("watermark missing: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatermarkRasterProducesPNG(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "source.png")
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 320, 240))
|
||||
for y := 0; y < 240; y++ {
|
||||
for x := 0; x < 320; x++ {
|
||||
img.Set(x, y, color.NRGBA{20, 40, 60, 255})
|
||||
}
|
||||
}
|
||||
var src bytes.Buffer
|
||||
if err := png.Encode(&src, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, src.Bytes(), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, ct, err := watermarkPreviewFile(path, "NEURAL HUNT PREVIEW")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ct != "image/png" || len(out) == 0 {
|
||||
t.Fatalf("unexpected preview %q %d", ct, len(out))
|
||||
}
|
||||
if bytes.Equal(out, src.Bytes()) {
|
||||
t.Fatal("preview should differ from source")
|
||||
}
|
||||
}
|
||||
261
internal/settings/settings.go
Normal file
261
internal/settings/settings.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Runtime struct {
|
||||
GuessMinIntervalSec int `json:"guess_min_interval_sec"`
|
||||
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
||||
TaskRangeBits int `json:"task_range_bits"`
|
||||
ActiveTaskCount int `json:"active_task_count"`
|
||||
PresenceTTLSec int `json:"presence_ttl_sec"`
|
||||
DefaultMaxNodes int `json:"default_max_nodes"`
|
||||
PublicScorePrecision int `json:"public_score_precision"`
|
||||
ArtifactPreset string `json:"artifact_preset"`
|
||||
ArtifactProvider string `json:"artifact_provider"`
|
||||
ArtifactModel string `json:"artifact_model"`
|
||||
ArtifactPrompt string `json:"artifact_prompt"`
|
||||
ArtifactNegativePrompt string `json:"artifact_negative_prompt"`
|
||||
ArtifactWidth int `json:"artifact_width"`
|
||||
ArtifactHeight int `json:"artifact_height"`
|
||||
ArtifactSteps int `json:"artifact_steps"`
|
||||
ArtifactQuality string `json:"artifact_quality"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
v Runtime
|
||||
}
|
||||
|
||||
func envInt(k string, def int) int {
|
||||
if s := os.Getenv(k); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envString(k, def string) string {
|
||||
if s := strings.TrimSpace(os.Getenv(k)); s != "" {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func Defaults() Runtime {
|
||||
return Runtime{
|
||||
GuessMinIntervalSec: envInt("DEFAULT_GUESS_MIN_INTERVAL_SEC", 10),
|
||||
ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11),
|
||||
TaskRangeBits: envInt("DEFAULT_TASK_RANGE_BITS", 28),
|
||||
ActiveTaskCount: envInt("DEFAULT_ACTIVE_TASK_COUNT", 1),
|
||||
PresenceTTLSec: envInt("DEFAULT_PRESENCE_TTL_SEC", 35),
|
||||
DefaultMaxNodes: envInt("DEFAULT_MAX_NODES", 2000),
|
||||
PublicScorePrecision: envInt("DEFAULT_PUBLIC_SCORE_PRECISION", 2),
|
||||
ArtifactPreset: strings.ToLower(envString("ARTIFACT_PRESET", "raccoon_full_art_v1")),
|
||||
ArtifactProvider: strings.ToLower(envString("ARTIFACT_PROVIDER", "openai")),
|
||||
ArtifactModel: envString("ARTIFACT_MODEL", "gpt-image-2"),
|
||||
ArtifactPrompt: envString("ARTIFACT_PROMPT", "Optional global creative override for legacy artifact providers. The raccoon_full_art_v1 preset uses its built-in structured master prompt and task-specific NFT instructions."),
|
||||
ArtifactNegativePrompt: envString("ARTIFACT_NEGATIVE_PROMPT", "text, watermark, logo, signature, low quality, blurry, malformed"),
|
||||
ArtifactWidth: envInt("ARTIFACT_WIDTH", 1024),
|
||||
ArtifactHeight: envInt("ARTIFACT_HEIGHT", 1536),
|
||||
ArtifactSteps: envInt("ARTIFACT_STEPS", 28),
|
||||
ArtifactQuality: strings.ToLower(envString("ARTIFACT_QUALITY", "medium")),
|
||||
}
|
||||
}
|
||||
|
||||
func New(ctx context.Context, db *sql.DB) (*Manager, error) {
|
||||
m := &Manager{db: db, v: Defaults()}
|
||||
b, _ := json.Marshal(m.v)
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
_, err := db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES('runtime',?,?) ON CONFLICT(key) DO NOTHING`, string(b), now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.Refresh(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.migrateRIFTMediumDefault(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// migrateRIFTMediumDefault upgrades deployments created by the first RIFT
|
||||
// release, where high quality was hard-coded as the collection default. The
|
||||
// migration runs once only; after that an operator can still deliberately set
|
||||
// high (or low) without it being overwritten on restart.
|
||||
func (m *Manager) migrateRIFTMediumDefault(ctx context.Context) error {
|
||||
const marker = "migration_rift_medium_quality_v1"
|
||||
var n int
|
||||
if err := m.db.QueryRowContext(ctx, `SELECT count(*) FROM settings WHERE key=?`, marker).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
// A deployment that explicitly configured ARTIFACT_QUALITY keeps that
|
||||
// operator choice. The migration only changes the former implicit RIFT
|
||||
// default.
|
||||
if strings.TrimSpace(os.Getenv("ARTIFACT_QUALITY")) != "" {
|
||||
_, err := m.db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO NOTHING`, marker, "explicit-env-preserved", time.Now().UTC().UnixMilli())
|
||||
return err
|
||||
}
|
||||
v := m.Get()
|
||||
if strings.EqualFold(strings.TrimSpace(v.ArtifactPreset), "raccoon_full_art_v1") && strings.EqualFold(strings.TrimSpace(v.ArtifactQuality), "high") {
|
||||
v.ArtifactQuality = "medium"
|
||||
b, _ := json.Marshal(v)
|
||||
now := time.Now().UTC().UnixMilli()
|
||||
if _, err := m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), now); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.v = v
|
||||
m.mu.Unlock()
|
||||
}
|
||||
_, err := m.db.ExecContext(ctx, `INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) ON CONFLICT(key) DO NOTHING`, marker, "done", time.Now().UTC().UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) Get() Runtime {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.v
|
||||
}
|
||||
|
||||
func (m *Manager) Refresh(ctx context.Context) error {
|
||||
var raw string
|
||||
if err := m.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key='runtime'`).Scan(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
// Start from current defaults so databases created by older Neural Hunt
|
||||
// versions automatically receive newly introduced settings.
|
||||
var stored map[string]json.RawMessage
|
||||
_ = json.Unmarshal([]byte(raw), &stored)
|
||||
_, hadArtifactPreset := stored["artifact_preset"]
|
||||
v := Defaults()
|
||||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// One-time upgrade for the bundled pre-collection database: older builds
|
||||
// shipped the offline square SVG preset. The new collection is designed to
|
||||
// work after only OPENAI_API_KEY + model are configured, so that exact legacy
|
||||
// default is migrated to OpenAI portrait generation automatically. Explicit
|
||||
// non-local provider choices are preserved.
|
||||
if !hadArtifactPreset {
|
||||
if strings.EqualFold(v.ArtifactProvider, "local") && v.ArtifactWidth == 1024 && v.ArtifactHeight == 1024 {
|
||||
v.ArtifactPreset = "raccoon_full_art_v1"
|
||||
v.ArtifactProvider = "openai"
|
||||
v.ArtifactWidth = 1024
|
||||
v.ArtifactHeight = 1536
|
||||
v.ArtifactQuality = "medium"
|
||||
} else {
|
||||
// Existing deployments that deliberately chose another provider keep
|
||||
// their old behavior until they explicitly opt into the collection preset.
|
||||
v.ArtifactPreset = "legacy"
|
||||
}
|
||||
if b, err := json.Marshal(v); err == nil {
|
||||
_, _ = m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), time.Now().UTC().UnixMilli())
|
||||
}
|
||||
}
|
||||
if err := Validate(v); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.v = v
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Update(ctx context.Context, v Runtime) error {
|
||||
if err := Validate(v); err != nil {
|
||||
return err
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
if _, err := m.db.ExecContext(ctx, `UPDATE settings SET value=?,updated_at=? WHERE key='runtime'`, string(b), time.Now().UTC().UnixMilli()); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.v = v
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func Validate(v Runtime) error {
|
||||
if v.GuessMinIntervalSec < 1 || v.GuessMinIntervalSec > 3600 {
|
||||
return fmt.Errorf("guess_min_interval_sec must be 1..3600")
|
||||
}
|
||||
if v.ClientSubmitIntervalSec <= v.GuessMinIntervalSec || v.ClientSubmitIntervalSec > 7200 {
|
||||
return fmt.Errorf("client_submit_interval_sec must be > guess_min_interval_sec and <= 7200")
|
||||
}
|
||||
if v.TaskRangeBits < 8 || v.TaskRangeBits > 128 {
|
||||
return fmt.Errorf("task_range_bits must be 8..128")
|
||||
}
|
||||
if v.ActiveTaskCount < 1 || v.ActiveTaskCount > 64 {
|
||||
return fmt.Errorf("active_task_count must be 1..64")
|
||||
}
|
||||
if v.PresenceTTLSec < 15 || v.PresenceTTLSec > 600 {
|
||||
return fmt.Errorf("presence_ttl_sec must be 15..600")
|
||||
}
|
||||
if v.DefaultMaxNodes < 50 || v.DefaultMaxNodes > 100000 {
|
||||
return fmt.Errorf("default_max_nodes must be 50..100000")
|
||||
}
|
||||
if v.PublicScorePrecision < 0 || v.PublicScorePrecision > 6 {
|
||||
return fmt.Errorf("public_score_precision must be 0..6")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(v.ArtifactPreset)) {
|
||||
case "legacy", "raccoon_full_art_v1":
|
||||
default:
|
||||
return fmt.Errorf("artifact_preset must be legacy or raccoon_full_art_v1")
|
||||
}
|
||||
switch strings.ToLower(v.ArtifactProvider) {
|
||||
case "local", "openai", "comfyui", "a1111", "auto":
|
||||
default:
|
||||
return fmt.Errorf("artifact_provider must be local, openai, comfyui, a1111 or auto")
|
||||
}
|
||||
if strings.TrimSpace(v.ArtifactModel) == "" {
|
||||
return fmt.Errorf("artifact_model must not be empty")
|
||||
}
|
||||
if len(v.ArtifactPrompt) < 10 || len(v.ArtifactPrompt) > 12000 {
|
||||
return fmt.Errorf("artifact_prompt length must be 10..12000")
|
||||
}
|
||||
if len(v.ArtifactNegativePrompt) > 8000 {
|
||||
return fmt.Errorf("artifact_negative_prompt too long")
|
||||
}
|
||||
if v.ArtifactWidth < 256 || v.ArtifactWidth > 4096 || v.ArtifactHeight < 256 || v.ArtifactHeight > 4096 {
|
||||
return fmt.Errorf("artifact dimensions must be 256..4096")
|
||||
}
|
||||
if v.ArtifactSteps < 1 || v.ArtifactSteps > 200 {
|
||||
return fmt.Errorf("artifact_steps must be 1..200")
|
||||
}
|
||||
switch strings.ToLower(v.ArtifactQuality) {
|
||||
case "low", "medium", "high", "auto":
|
||||
default:
|
||||
return fmt.Errorf("artifact_quality must be low, medium, high or auto")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
_ = m.Refresh(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
517
internal/webui/dist/app.js
vendored
Normal file
517
internal/webui/dist/app.js
vendored
Normal file
@@ -0,0 +1,517 @@
|
||||
'use strict';
|
||||
|
||||
const app = document.getElementById('app');
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = (s='') => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const clamp = (v,a,b) => Math.max(a, Math.min(b, v));
|
||||
const lerp = (a,b,t) => a + (b-a)*t;
|
||||
const smooth = t => { t=clamp(t,0,1); return t*t*(3-2*t); };
|
||||
|
||||
const tokenKey = 'neuralhunt.token';
|
||||
const adminTokenKey = 'neuralhunt.adminToken';
|
||||
const mobileModeKey = 'neuralhunt.mobileMode';
|
||||
const getToken = () => localStorage.getItem(tokenKey) || '';
|
||||
function autoMobileMode(){return matchMedia('(max-width: 850px)').matches || matchMedia('(pointer: coarse)').matches}
|
||||
function mobileModeEnabled(){const v=localStorage.getItem(mobileModeKey);return v===null?autoMobileMode():v==='1'}
|
||||
function applyMobileMode(on,persist=false){document.documentElement.classList.toggle('mobile-mode',!!on);if(persist)localStorage.setItem(mobileModeKey,on?'1':'0');window.dispatchEvent(new CustomEvent('neuralhunt-mobile-mode',{detail:{enabled:!!on}}))}
|
||||
function toggleMobileMode(){applyMobileMode(!document.documentElement.classList.contains('mobile-mode'),true)}
|
||||
function mobileButtonLabel(){return document.documentElement.classList.contains('mobile-mode')?'MOBILE ON':'MOBILE OFF'}
|
||||
const setToken = t => localStorage.setItem(tokenKey, t);
|
||||
const clearToken = () => localStorage.removeItem(tokenKey);
|
||||
|
||||
async function api(path, init={}, admin=false) {
|
||||
const token = admin ? localStorage.getItem(adminTokenKey) : getToken();
|
||||
const headers = new Headers(init.headers || {});
|
||||
if (!(init.body instanceof FormData)) headers.set('Content-Type','application/json');
|
||||
if (token) headers.set('Authorization','Bearer '+token);
|
||||
const r = await fetch(path,{...init,headers});
|
||||
if (!r.ok) {
|
||||
let msg = `HTTP ${r.status}`, payload = null;
|
||||
try { payload=await r.json(); msg=payload?.error || msg; } catch {}
|
||||
const err = new Error(msg); err.status = r.status; err.code = payload?.code || ''; err.data = payload || null; throw err;
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function loadProtectedImage(path,img,admin=false){
|
||||
if(!img)return;const token=admin?localStorage.getItem(adminTokenKey):getToken();const headers={};if(token)headers.Authorization='Bearer '+token;
|
||||
const r=await fetch(path,{headers});if(!r.ok)throw new Error(`Bild HTTP ${r.status}`);const blob=await r.blob();const old=img.dataset.objectUrl;if(old)URL.revokeObjectURL(old);const u=URL.createObjectURL(blob);img.dataset.objectUrl=u;img.src=u;
|
||||
}
|
||||
|
||||
// Browser-persistent cryptographic identity. The private key never leaves the
|
||||
// browser unencrypted. Export/import is password-protected AES-GCM.
|
||||
const identityKey='neuralhunt.identity.v1';
|
||||
const b64u=b=>{const a=b instanceof Uint8Array?b:new Uint8Array(b);let s='';a.forEach(x=>s+=String.fromCharCode(x));return btoa(s).replaceAll('+','-').replaceAll('/','_').replaceAll('=','')};
|
||||
const ub64=s=>{s=s.replaceAll('-','+').replaceAll('_','/');while(s.length%4)s+='=';const x=atob(s);return Uint8Array.from(x,c=>c.charCodeAt(0))};
|
||||
async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(raw)return JSON.parse(raw);const kp=await crypto.subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']);const b={version:1,publicJwk:await crypto.subtle.exportKey('jwk',kp.publicKey),privateJwk:await crypto.subtle.exportKey('jwk',kp.privateKey)};localStorage.setItem(identityKey,JSON.stringify(b));return b}
|
||||
async function clientId(pub){const s=`${pub.kty}|${pub.crv}|${pub.x}|${pub.y}`;return b64u(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(s)))}
|
||||
async function sign(message){const b=await ensureIdentity();const k=await crypto.subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);return b64u(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},k,new TextEncoder().encode(message)))}
|
||||
async function responseError(r,fallback){try{const b=await r.json();return b?.error?`${fallback}: ${b.error}`:`${fallback} (HTTP ${r.status})`}catch{return `${fallback} (HTTP ${r.status})`}}
|
||||
async function loginIdentity(){const b=await ensureIdentity();const cr=await fetch('/api/auth/challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk})});if(!cr.ok)throw new Error(await responseError(cr,'Challenge fehlgeschlagen'));const c=await cr.json();const signature=await sign(`login|${c.challenge}|${c.client_id}`);const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk,challenge:c.challenge,signature})});if(!r.ok)throw new Error(await responseError(r,'Login fehlgeschlagen'));return r.json()}
|
||||
async function deterministicGuess(taskID,seed,cid,seq,bits){const h=new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(`${taskID}|${seed}|${cid}|${seq}`)));let n=0n;for(const x of h)n=(n<<8n)|BigInt(x);return (n%(1n<<BigInt(bits))).toString()}
|
||||
async function exportIdentity(passphrase){const b=await ensureIdentity();const salt=crypto.getRandomValues(new Uint8Array(16));const iv=crypto.getRandomValues(new Uint8Array(12));const base=await crypto.subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await crypto.subtle.deriveKey({name:'PBKDF2',salt,iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['encrypt']);const ct=await crypto.subtle.encrypt({name:'AES-GCM',iv},aes,new TextEncoder().encode(JSON.stringify(b)));return JSON.stringify({version:1,salt:b64u(salt),iv:b64u(iv),ciphertext:b64u(ct)},null,2)}
|
||||
async function importIdentity(raw,passphrase){const x=JSON.parse(raw);const base=await crypto.subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await crypto.subtle.deriveKey({name:'PBKDF2',salt:ub64(x.salt),iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['decrypt']);const pt=await crypto.subtle.decrypt({name:'AES-GCM',iv:ub64(x.iv)},aes,ub64(x.ciphertext));const b=JSON.parse(new TextDecoder().decode(pt));localStorage.setItem(identityKey,JSON.stringify(b));return b}
|
||||
|
||||
function hashInt(s){let h=2166136261>>>0;s=String(s||'');for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619)}return h>>>0}
|
||||
function pseudo(s,o=0){return ((Math.sin((hashInt(`${s}:${o}`)+1)*0.00000137+o*12.345)*43758.5453123)%1+1)%1}
|
||||
function scoreRGB(score){
|
||||
const t=clamp(Number(score||0)/100,0,1);
|
||||
// low = deep blue, middle = cyan, high = mint/amber, near-perfect = magenta-white
|
||||
const stops=[[0,[75,123,255]],[.45,[82,231,255]],[.78,[93,255,189]],[.94,[255,180,82]],[1,[255,111,188]]];
|
||||
for(let i=1;i<stops.length;i++) if(t<=stops[i][0]){const [a,ca]=stops[i-1],[b,cb]=stops[i];const q=(t-a)/(b-a);return ca.map((v,j)=>Math.round(lerp(v,cb[j],q)))}
|
||||
return stops.at(-1)[1];
|
||||
}
|
||||
function rgba(rgb,a){return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`}
|
||||
|
||||
function makeLOD(points,maxNodes,selfId,enabled=true){
|
||||
const source=(points||[]).map(p=>({...p,_count:1,_group:false,_bestScore:Number(p.score||0)}));
|
||||
if(source.length<=maxNodes) return source;
|
||||
const self=source.find(p=>p.client_id===selfId);
|
||||
if(!enabled){
|
||||
const rest=self?source.filter(p=>p!==self):source;
|
||||
rest.sort((a,b)=>(Number(b.score||0)-Number(a.score||0))||(Number(a.rank||1e12)-Number(b.rank||1e12))||a.client_id.localeCompare(b.client_id));
|
||||
const keep=rest.slice(0,Math.max(1,maxNodes-(self?1:0)));
|
||||
return self?[...keep,self]:keep;
|
||||
}
|
||||
const others=self?source.filter(p=>p!==self):source;
|
||||
const target=Math.max(8,maxNodes-(self?1:0));
|
||||
if(others.length<=target) return self?[...others,self]:others;
|
||||
let minX=Infinity,maxX=-Infinity,minY=Infinity,maxY=-Infinity,minZ=Infinity,maxZ=-Infinity;
|
||||
for(const p of others){minX=Math.min(minX,p.x);maxX=Math.max(maxX,p.x);minY=Math.min(minY,p.y);maxY=Math.max(maxY,p.y);minZ=Math.min(minZ,p.z);maxZ=Math.max(maxZ,p.z)}
|
||||
const span=Math.max(1,maxX-minX,maxY-minY,maxZ-minZ);
|
||||
let cell=span/Math.max(2,Math.cbrt(target)*.82), grouped=[];
|
||||
for(let attempt=0;attempt<12;attempt++){
|
||||
const buckets=new Map();
|
||||
for(const p of others){
|
||||
const ix=Math.floor((p.x-minX)/cell),iy=Math.floor((p.y-minY)/cell),iz=Math.floor((p.z-minZ)/cell),key=`${ix}:${iy}:${iz}`;
|
||||
let b=buckets.get(key);
|
||||
if(!b){b={client_id:`cluster:${key}`,x:0,y:0,z:0,score:0,rank:Number.MAX_SAFE_INTEGER,guess_count:0,_count:0,_group:true,_bestScore:0,_scoreSum:0};buckets.set(key,b)}
|
||||
const weight=1+Math.sqrt(Math.max(0,Number(p.score||0)))/12;
|
||||
b.x+=Number(p.x)*weight;b.y+=Number(p.y)*weight;b.z+=Number(p.z)*weight;b._mass=(b._mass||0)+weight;
|
||||
b._count++;b._scoreSum+=Number(p.score||0);b._bestScore=Math.max(b._bestScore,Number(p.score||0));b.rank=Math.min(b.rank,Number(p.rank||Number.MAX_SAFE_INTEGER));b.guess_count+=Number(p.guess_count||0);
|
||||
}
|
||||
grouped=[...buckets.values()].map(b=>({...b,x:b.x/b._mass,y:b.y/b._mass,z:b.z/b._mass,score:b._scoreSum/b._count}));
|
||||
if(grouped.length<=target) break;
|
||||
cell*=1.24;
|
||||
}
|
||||
if(grouped.length>target){
|
||||
grouped.sort((a,b)=>(b._bestScore-a._bestScore)||(b._count-a._count)||a.client_id.localeCompare(b.client_id));
|
||||
grouped=grouped.slice(0,target);
|
||||
}
|
||||
return self?[...grouped,self]:grouped;
|
||||
}
|
||||
|
||||
class NeuralMap {
|
||||
constructor(host, opts={}) {
|
||||
this.host=host;
|
||||
this.opts={panelOffset:0,admin:false,...opts};
|
||||
this.canvas=document.createElement('canvas'); this.canvas.className='brain-canvas'; host.appendChild(this.canvas);
|
||||
this.ctx=this.canvas.getContext('2d',{alpha:false});
|
||||
this.tooltip=document.createElement('div'); this.tooltip.className='tooltip hidden'; host.appendChild(this.tooltip);
|
||||
this.points=[]; this.rawPoints=[]; this.selfId=''; this.maxNodes=2000; this.lodEnabled=true;
|
||||
// TARGET FIELD is the default: score controls orbital radius exactly. RAW 3D
|
||||
// remains available for inspecting the server-provided x/y/z coordinates.
|
||||
this.yaw=.18; this.pitch=-.58; this.zoom=1.02; this.autoRotate=true; this.labels=true; this.edges=true; this.shells=true; this.eco=false;
|
||||
this.proximityFocus=true;
|
||||
this.drag=false; this.moved=false; this.lastX=0; this.lastY=0; this.mouseX=0; this.mouseY=0; this.hover=null;
|
||||
this.width=1; this.height=1; this.dpr=1; this.last=performance.now(); this.lastPaint=0; this.fps=0; this.fpsFrames=0; this.fpsStart=performance.now();
|
||||
this.renderCount=0; this.background=document.createElement('canvas'); this.backgroundKey='';
|
||||
this.motion=new Map(); this.scoreMotion=new Map(); this.particles=[]; this.projected=[]; this.statsAt=0;
|
||||
this.ro=new ResizeObserver(()=>this.resize()); this.ro.observe(host); this.resize(); this.bind();
|
||||
this.frame=requestAnimationFrame(t=>this.draw(t));
|
||||
}
|
||||
bind(){
|
||||
this.canvas.addEventListener('pointerdown',e=>{this.drag=true;this.moved=false;this.lastX=e.clientX;this.lastY=e.clientY;this.canvas.setPointerCapture(e.pointerId)});
|
||||
this.canvas.addEventListener('pointermove',e=>{const r=this.canvas.getBoundingClientRect();this.mouseX=e.clientX-r.left;this.mouseY=e.clientY-r.top;if(!this.drag){this.pick();return}const dx=e.clientX-this.lastX,dy=e.clientY-this.lastY;if(Math.abs(dx)+Math.abs(dy)>2)this.moved=true;this.yaw+=dx*.006;this.pitch=clamp(this.pitch+dy*.004,-1.02,-.12);this.lastX=e.clientX;this.lastY=e.clientY});
|
||||
this.canvas.addEventListener('pointerup',()=>{this.drag=false;this.pick()});
|
||||
this.canvas.addEventListener('pointercancel',()=>this.drag=false);
|
||||
this.canvas.addEventListener('pointerleave',()=>{if(!this.drag){this.hover=null;this.tooltip.classList.add('hidden')}});
|
||||
this.canvas.addEventListener('wheel',e=>{e.preventDefault();this.zoom=clamp(this.zoom*Math.exp(-e.deltaY*.001),.55,2.4)},{passive:false});
|
||||
this.canvas.addEventListener('dblclick',()=>this.resetView());
|
||||
}
|
||||
resize(){const r=this.host.getBoundingClientRect();this.width=Math.max(1,r.width);this.height=Math.max(1,r.height);this.dpr=Math.min(devicePixelRatio||1,this.eco?1.15:2);this.canvas.width=Math.max(1,Math.floor(this.width*this.dpr));this.canvas.height=Math.max(1,Math.floor(this.height*this.dpr));this.canvas.style.width=this.width+'px';this.canvas.style.height=this.height+'px';this.backgroundKey=''}
|
||||
setOptions(opts={}){Object.assign(this,opts);if('eco' in opts)this.resize();if('lodEnabled' in opts||'maxNodes' in opts)this.rebuild()}
|
||||
resetView(){this.yaw=.18;this.pitch=-.58;this.zoom=1.02}
|
||||
fieldRadius(score){
|
||||
// Score itself is logarithmic. This perceptual expansion deliberately gives
|
||||
// the 90..100 region much more room so 95, 98 and 99+ are visibly distinct.
|
||||
const miss=clamp(1-Number(score||0)/100,0,1);
|
||||
return .045+.955*Math.pow(miss,.38);
|
||||
}
|
||||
fieldGeometry(){
|
||||
const panel=this.width>1050?this.opts.panelOffset:0;
|
||||
const scale=Math.min(this.width*(this.opts.admin?.36:.34),this.height*.42)*this.zoom;
|
||||
// TARGET FIELD stays face-on so screen distance is a mathematically exact
|
||||
// proximity cue. Pitch changes only depth/perspective intensity.
|
||||
const depth=clamp(.07+(-this.pitch-.12)*.10,.07,.16);
|
||||
return {cx:this.width/2+panel,cy:this.height/2+8,scale,depth};
|
||||
}
|
||||
currentMotion(id,now=performance.now()){const m=this.motion.get(id);if(!m)return null;const q=smooth((now-m.start)/m.duration);return {x:lerp(m.fx,m.tx,q),y:lerp(m.fy,m.ty,q),z:lerp(m.fz,m.tz,q)}}
|
||||
currentScore(p,now=performance.now()){
|
||||
const m=this.scoreMotion.get(p.client_id); if(!m)return Number(p._group?(p._bestScore||p.score):p.score||0);
|
||||
const q=clamp((now-m.start)/m.duration,0,1); if(q>=1){this.scoreMotion.delete(p.client_id);return Number(m.to)}
|
||||
return lerp(m.from,m.to,smooth(q));
|
||||
}
|
||||
update(points,selfId,maxNodes){
|
||||
const oldRaw=new Map(this.rawPoints.map(p=>[p.client_id,p])),now=performance.now();
|
||||
this.rawPoints=Array.isArray(points)?points:[]; this.selfId=selfId||''; this.maxNodes=Math.max(10,Number(maxNodes||this.maxNodes));
|
||||
for(const p of this.rawPoints){const prev=oldRaw.get(p.client_id);if(prev&&Number(p.score||0)>Number(prev.score||0)+.0001){this.scoreMotion.set(p.client_id,{from:Number(prev.score||0),to:Number(p.score||0),start:now,duration:900});this.emitSignal(p)}}
|
||||
this.rebuild();
|
||||
}
|
||||
rebuild(){
|
||||
const now=performance.now(),next=makeLOD(this.rawPoints,this.maxNodes,this.selfId,this.lodEnabled),oldBy=new Map(this.points.map(p=>[p.client_id,p]));
|
||||
for(const p of next){const prior=this.currentMotion(p.client_id,now)||oldBy.get(p.client_id)||p;this.motion.set(p.client_id,{fx:Number(prior.x||0),fy:Number(prior.y||0),fz:Number(prior.z||0),tx:Number(p.x||0),ty:Number(p.y||0),tz:Number(p.z||0),start:now,duration:650})}
|
||||
this.points=next;
|
||||
}
|
||||
emitSignal(p){
|
||||
if(this.particles.length>180)this.particles.splice(0,this.particles.length-120);
|
||||
const now=performance.now(),col=scoreRGB(p.score);
|
||||
for(let i=0;i<3;i++)this.particles.push({id:p.client_id,start:now+i*95,duration:720+i*90,color:col,size:1+clamp(Number(p.score||0)/100,0,1)*.55});
|
||||
}
|
||||
rawCamera(){const panel=this.width>1050?this.opts.panelOffset:0;return {cy:Math.cos(this.yaw),sy:Math.sin(this.yaw),cp:Math.cos(this.pitch),sp:Math.sin(this.pitch),cx:this.width/2+panel,cyy:this.height/2,scale:Math.min(this.width*(this.opts.admin?.44:.40),this.height*.48)*this.zoom}}
|
||||
projectRawXYZ(x,y,z){const cam=this.rawCamera();const x1=x*cam.cy-z*cam.sy,z1=x*cam.sy+z*cam.cy,y1=y*cam.cp-z1*cam.sp,z2=y*cam.sp+z1*cam.cp;const perspective=2.9/(3.3-z2*.042);return {x:cam.cx+x1*cam.scale*perspective/13.5,y:cam.cyy-y1*cam.scale*perspective/13.5,z:z2,p:perspective}}
|
||||
projectXYZ(x,y,z){return this.projectRawXYZ(x,y,z)}
|
||||
projectFieldPoint(p,now){
|
||||
const g=this.fieldGeometry(),score=this.currentScore(p,now),r=this.fieldRadius(score);
|
||||
const id=p.client_id||'node',a=pseudo(id,41)*Math.PI*2+this.yaw;
|
||||
const side=Math.cos(a)*r,vertical=Math.sin(a)*r;
|
||||
// Screen radius is exactly r*scale. The third dimension is encoded only as
|
||||
// perspective/brightness, never as a positional offset that could invert
|
||||
// who appears closer to the task.
|
||||
const z=(Math.sin(a)*.78+(pseudo(id,42)-.5)*.22)*r;
|
||||
const x=g.cx+side*g.scale;
|
||||
const y=g.cy+vertical*g.scale;
|
||||
const perspective=clamp(1+z*g.depth,.90,1.10);
|
||||
return {x,y,z,p:perspective,score,fieldR:r,angle:a};
|
||||
}
|
||||
projectPoint(p,now){
|
||||
if(this.proximityFocus)return this.projectFieldPoint(p,now);
|
||||
const m=this.currentMotion(p.client_id,now)||p;return this.projectRawXYZ(Number(m.x||0),Number(m.y||0),Number(m.z||0));
|
||||
}
|
||||
buildBackground(){const key=`${Math.floor(this.width)}:${Math.floor(this.height)}:${this.eco?1:0}`;if(key===this.backgroundKey)return;this.backgroundKey=key;this.background.width=Math.ceil(this.width);this.background.height=Math.ceil(this.height);const c=this.background.getContext('2d',{alpha:false}),g=c.createRadialGradient(this.width*.5,this.height*.48,30,this.width*.5,this.height*.48,Math.max(this.width,this.height)*.78);g.addColorStop(0,'#071827');g.addColorStop(.5,'#020711');g.addColorStop(1,'#010207');c.fillStyle=g;c.fillRect(0,0,this.width,this.height);c.globalAlpha=this.eco?.08:.14;const stars=this.eco?28:90;for(let i=0;i<stars;i++){const x=pseudo('star',i*2)*this.width,y=pseudo('star',i*2+1)*this.height;c.fillStyle=i%11===0?'#52e7ff':'#5d7890';const s=i%13===0?1.4:.7;c.fillRect(x,y,s,s)}c.globalAlpha=1}
|
||||
drawBackground(now){this.buildBackground();const c=this.ctx;c.drawImage(this.background,0,0,this.width,this.height);c.save();c.globalAlpha=this.eco?.04:.08;for(let i=0;i<(this.eco?6:16);i++){const x=(pseudo(`moving:${i}`,1)*this.width+now*.002*(i%3+1))%this.width,y=pseudo(`moving:${i}`,2)*this.height;c.fillStyle=i%7===0?'#52e7ff':'#6a8194';c.fillRect(x,y,i%9===0?1.2:.65,i%9===0?1.2:.65)}c.restore()}
|
||||
drawRawRing3D(radius,plane,col,alpha,width,dash){const c=this.ctx;c.strokeStyle=rgba(col,alpha);c.lineWidth=width;c.setLineDash(dash||[]);c.beginPath();const steps=this.eco?32:64;for(let i=0;i<=steps;i++){const a=i/steps*Math.PI*2,co=Math.cos(a)*radius,si=Math.sin(a)*radius;let q;if(plane==='xy')q=this.projectRawXYZ(co,si,0);else if(plane==='xz')q=this.projectRawXYZ(co,0,si);else q=this.projectRawXYZ(0,co,si);if(i===0)c.moveTo(q.x,q.y);else c.lineTo(q.x,q.y)}c.stroke();c.setLineDash([])}
|
||||
drawTargetField(){
|
||||
if(!this.shells||!this.proximityFocus)return;
|
||||
const c=this.ctx,g=this.fieldGeometry(),bands=[0,25,50,75,90,95,99];
|
||||
c.save();c.globalCompositeOperation='screen';
|
||||
// radial guide spokes: orientation only, never data relationships
|
||||
c.strokeStyle='rgba(87,135,160,.065)';c.lineWidth=.5;
|
||||
for(let i=0;i<12;i++){const a=i/12*Math.PI*2+this.yaw,rx=Math.cos(a)*g.scale,ry=Math.sin(a)*g.scale;c.beginPath();c.moveTo(g.cx,g.cy);c.lineTo(g.cx+rx,g.cy+ry);c.stroke()}
|
||||
for(const score of bands){
|
||||
const r=this.fieldRadius(score),rx=g.scale*r,col=scoreRGB(score),major=score>=90;
|
||||
// Back half is dim/dashed; front half is brighter. This depth cue makes
|
||||
// the target plane read as 3D while ring radius stays a precise score cue.
|
||||
c.setLineDash(major?[3,6]:[2,9]);c.strokeStyle=rgba(col,major?.14:.06);c.lineWidth=major?.9:.55;c.beginPath();c.arc(g.cx,g.cy,rx,Math.PI,Math.PI*2);c.stroke();
|
||||
c.setLineDash([]);c.strokeStyle=rgba(col,major?.38:.14);c.lineWidth=major?1.05:.65;c.beginPath();c.arc(g.cx,g.cy,rx,0,Math.PI);c.stroke();
|
||||
if(this.labels&&this.width>650){const lx=g.cx+rx+5,ly=g.cy;c.font=major?'800 9px Inter,system-ui':'700 8px Inter,system-ui';c.fillStyle=rgba(col,major?.82:.47);c.fillText(score===99?'99+':String(score),lx,ly+3)}
|
||||
}
|
||||
if(this.labels&&this.width>720){c.font='700 8px Inter,system-ui';c.fillStyle='rgba(134,171,190,.58)';c.fillText('AUSSEN = WEIT',g.cx-g.scale-2,g.cy+g.scale+19);c.fillText('INNEN = NAH AM TASK',g.cx+14,g.cy-18)}
|
||||
c.restore();
|
||||
}
|
||||
drawRawShells(){if(!this.shells||this.proximityFocus)return;const bands=[0,25,50,75,90,95,99];const c=this.ctx;c.save();c.globalCompositeOperation='screen';for(const s of bands){const r=.34+13*Math.sqrt(clamp(1-s/100,0,1)),col=scoreRGB(s);this.drawRawRing3D(r,'xy',col,s>=95?.19:.06,s>=95?1:.6,[3,7])}c.restore()}
|
||||
drawAura(now){const c=this.ctx,g=this.proximityFocus?this.fieldGeometry():null,core=g?{x:g.cx,y:g.cy}:this.projectRawXYZ(0,0,0),pulse=.5+.5*Math.sin(now*.0032),radius=this.eco?52:74+pulse*9;c.save();c.globalCompositeOperation='screen';const grad=c.createRadialGradient(core.x,core.y,0,core.x,core.y,radius);grad.addColorStop(0,'rgba(82,231,255,.17)');grad.addColorStop(.25,'rgba(82,231,255,.045)');grad.addColorStop(1,'rgba(82,231,255,0)');c.fillStyle=grad;c.beginPath();c.arc(core.x,core.y,radius,0,Math.PI*2);c.fill();c.restore()}
|
||||
drawClouds(now){
|
||||
if(this.eco||this.points.length<6||!this.proximityFocus)return;
|
||||
const c=this.ctx,g=this.fieldGeometry(),groups=[{lo:0,hi:50},{lo:50,hi:75},{lo:75,hi:90},{lo:90,hi:95},{lo:95,hi:101}];
|
||||
c.save();c.globalCompositeOperation='screen';
|
||||
for(const band of groups){const members=this.points.filter(p=>{const s=Number(p._group?(p._bestScore||p.score):p.score||0);return s>=band.lo&&s<band.hi});if(!members.length)continue;const mid=(band.lo+band.hi)/2,r=this.fieldRadius(mid),col=scoreRGB(mid),alpha=band.lo>=90?.018:.008;c.strokeStyle=rgba(col,alpha*Math.min(4,1+Math.log10(members.length+1)));c.lineWidth=Math.max(8,g.scale*(band.hi-band.lo)/900);c.beginPath();c.arc(g.cx,g.cy,g.scale*r,0,Math.PI*2);c.stroke()}c.restore()
|
||||
}
|
||||
selectedPaths(projected){
|
||||
const singles=projected.filter(x=>!x.p._group).slice().sort((a,b)=>Number((b.q.score??b.p.score)||0)-Number((a.q.score??a.p.score)||0));
|
||||
const chosen=singles.filter(x=>Number((x.q.score??x.p.score)||0)>=75).slice(0,this.eco?4:9);
|
||||
const self=singles.find(x=>x.p.client_id===this.selfId);if(self&&!chosen.includes(self))chosen.push(self);
|
||||
return chosen;
|
||||
}
|
||||
pathControl(q,core,id){const dx=core.x-q.x,dy=core.y-q.y,len=Math.max(1,Math.hypot(dx,dy)),nx=-dy/len,ny=dx/len,bend=(pseudo(id,73)-.5)*Math.min(46,len*.18);return {x:(q.x+core.x)/2+nx*bend,y:(q.y+core.y)/2+ny*bend}}
|
||||
drawEdges(projected){
|
||||
// "Signalwege" are intentionally NOT client-to-client graph edges. There
|
||||
// is no semantic client relation in Neural Hunt. Only contenders -> task
|
||||
// paths are drawn, so every visible line has a real meaning.
|
||||
if(!this.edges||!projected.length)return;
|
||||
const c=this.ctx,core=this.proximityFocus?(()=>{const g=this.fieldGeometry();return{x:g.cx,y:g.cy}})():this.projectRawXYZ(0,0,0),chosen=this.selectedPaths(projected);
|
||||
c.save();c.globalCompositeOperation='screen';
|
||||
chosen.forEach((x,i)=>{const score=Number((x.q.score??x.p.score)||0),self=x.p.client_id===this.selfId,col=self?[255,255,255]:scoreRGB(score),ctrl=this.pathControl(x.q,core,x.p.client_id),alpha=self?.36:i<3?.20:.07+.08*clamp((score-75)/25,0,1);c.strokeStyle=rgba(col,alpha);c.lineWidth=self?1.35:i<3?.9:.55;c.setLineDash(score<90?[2,7]:[]);c.beginPath();c.moveTo(x.q.x,x.q.y);c.quadraticCurveTo(ctrl.x,ctrl.y,core.x,core.y);c.stroke()});
|
||||
c.setLineDash([]);c.restore();
|
||||
}
|
||||
drawParticles(now){
|
||||
if(!this.particles.length)return;const c=this.ctx,by=new Map(this.projected.map(x=>[x.p.client_id,x])),core=this.proximityFocus?(()=>{const g=this.fieldGeometry();return{x:g.cx,y:g.cy}})():this.projectRawXYZ(0,0,0);
|
||||
c.save();c.globalCompositeOperation='lighter';
|
||||
for(let i=this.particles.length-1;i>=0;i--){const x=this.particles[i],t=(now-x.start)/x.duration;if(t<0)continue;if(t>=1){this.particles.splice(i,1);continue}const px=by.get(x.id);if(!px)continue;const q=px.q,ctrl=this.pathControl(q,core,x.id),e=smooth(t),u=1-e,bx=u*u*q.x+2*u*e*ctrl.x+e*e*core.x,byy=u*u*q.y+2*u*e*ctrl.y+e*e*core.y,r=(this.eco?2.0:5.0)*x.size*(.8+Math.sin(t*Math.PI)*.35);if(this.eco){c.fillStyle=rgba(x.color,.68);c.beginPath();c.arc(bx,byy,Math.max(1,r),0,Math.PI*2);c.fill()}else{const grad=c.createRadialGradient(bx,byy,0,bx,byy,r);grad.addColorStop(0,'rgba(255,255,255,.98)');grad.addColorStop(.2,rgba(x.color,.85));grad.addColorStop(1,rgba(x.color,0));c.fillStyle=grad;c.beginPath();c.arc(bx,byy,r,0,Math.PI*2);c.fill()}}
|
||||
c.restore();
|
||||
}
|
||||
drawNodes(projected,now){
|
||||
const c=this.ctx,sorted=[...projected].filter(x=>!x.p._group).sort((a,b)=>Number((b.q.score??b.p.score)||0)-Number((a.q.score??a.p.score)||0)),topIDs=new Set(sorted.slice(0,10).map(x=>x.p.client_id));
|
||||
c.save();c.globalCompositeOperation='lighter';
|
||||
for(const {p,q} of projected){const self=p.client_id===this.selfId,group=p._group||p._count>1,score=Number((q.score??(group?(p._bestScore||p.score):p.score))||0),col=self?[255,255,255]:scoreRGB(score),depth=this.proximityFocus?clamp(q.p,.88,1.12):clamp(.74+q.p*.18,.72,1.22),mass=group?Math.min(7,1+Math.log2((p._count||1)+1)*.82):0,near=clamp((score-75)/25,0,1),base=self?5.6:group?3.4+mass:1.15+clamp(score/75,0,1)*.55+near*2.25,breathe=.5+.5*Math.sin(now*.0016+hashInt(p.client_id)*.00001),r=Math.max(.9,base*depth*(.94+breathe*.08)),important=self||score>=90||group||topIDs.has(p.client_id);
|
||||
if(important&&!this.eco){const hr=r*(self?5.0:group?3.0:3.1+near*1.2),grad=c.createRadialGradient(q.x,q.y,0,q.x,q.y,hr);grad.addColorStop(0,rgba(col,self?.62:.20+near*.12));grad.addColorStop(.3,rgba(col,self?.15:.05+near*.06));grad.addColorStop(1,rgba(col,0));c.fillStyle=grad;c.beginPath();c.arc(q.x,q.y,hr,0,Math.PI*2);c.fill()}
|
||||
c.fillStyle=rgba(col,self?.99:group?.70:.10+.17*clamp(score/75,0,1)+near*.62);c.beginPath();c.arc(q.x,q.y,r,0,Math.PI*2);c.fill();
|
||||
if(score>=95&&!group){c.strokeStyle=rgba(col,.35+near*.35);c.lineWidth=.7;c.beginPath();c.arc(q.x,q.y,r*(1.75+near*.55)+breathe,0,Math.PI*2);c.stroke()}
|
||||
if(group){c.strokeStyle=rgba(col,.40);c.lineWidth=.75;c.beginPath();c.arc(q.x,q.y,r*1.34+breathe,0,Math.PI*2);c.stroke();if(!this.eco&&p._count>=5&&r>4.2){c.save();c.globalCompositeOperation='source-over';c.font='700 8px Inter,system-ui';c.textAlign='center';c.textBaseline='middle';c.fillStyle='rgba(236,249,255,.88)';c.fillText(p._count>999?`${Math.round(p._count/100)/10}k`:String(p._count),q.x,q.y+.4);c.restore()}}
|
||||
if(self){c.strokeStyle='rgba(255,255,255,.96)';c.lineWidth=1.15;c.beginPath();c.arc(q.x,q.y,r*2.2+breathe*1.7,0,Math.PI*2);c.stroke()}
|
||||
}
|
||||
c.restore();
|
||||
if(this.labels){c.save();c.globalCompositeOperation='source-over';c.font='10px Inter,system-ui';c.textBaseline='middle';let n=0,max=this.eco?8:24,occupied=[];for(const {p,q} of [...projected].sort((a,b)=>Number((b.q.score??b.p.score)||0)-Number((a.q.score??a.p.score)||0))){if(n>=max)break;const self=p.client_id===this.selfId,group=p._group||p._count>1,score=Number((q.score??p.score)||0),important=self||topIDs.has(p.client_id)||score>=95||this.hover?.p?.client_id===p.client_id;if(!important)continue;const text=self?`DU · ${score.toFixed(2)}`:group?`${p._count} Clients · best ${Number(p._bestScore||0).toFixed(1)}`:`#${p.rank||'—'} · ${score.toFixed(2)}`,w=c.measureText(text).width+14,x=clamp(q.x+10,4,this.width-w-4),y=clamp(q.y-9,4,this.height-22);if(!self&&occupied.some(b=>Math.abs(b.x-x)<(b.w+w)*.48&&Math.abs(b.y-y)<17))continue;occupied.push({x,y,w});c.fillStyle='rgba(2,7,14,.88)';c.fillRect(x,y,w,18);c.strokeStyle=rgba(self?[255,255,255]:scoreRGB(score),self?.55:.16);c.lineWidth=.5;c.strokeRect(x,y,w,18);c.fillStyle=rgba(self?[255,255,255]:scoreRGB(score),.98);c.fillText(text,x+7,y+9);n++}c.restore()}
|
||||
}
|
||||
drawCore(now){const c=this.ctx,g=this.proximityFocus?this.fieldGeometry():null,q=g?{x:g.cx,y:g.cy}:this.projectRawXYZ(0,0,0),pulse=.5+.5*Math.sin(now*.004);c.save();c.globalCompositeOperation='lighter';const radius=this.eco?30:48+pulse*7;if(!this.eco){const grad=c.createRadialGradient(q.x,q.y,0,q.x,q.y,radius);grad.addColorStop(0,'rgba(255,255,255,.99)');grad.addColorStop(.10,'rgba(99,243,255,.92)');grad.addColorStop(.44,'rgba(82,231,255,.13)');grad.addColorStop(1,'rgba(82,231,255,0)');c.fillStyle=grad;c.beginPath();c.arc(q.x,q.y,radius,0,Math.PI*2);c.fill()}c.fillStyle='#fff';c.beginPath();c.arc(q.x,q.y,5.7+pulse*.8,0,Math.PI*2);c.fill();c.strokeStyle=`rgba(82,231,255,${.54+pulse*.28})`;c.lineWidth=1;c.beginPath();c.arc(q.x,q.y,13+pulse*3,0,Math.PI*2);c.stroke();c.restore();if(this.labels&&this.width>610){c.save();c.font='800 10px Inter,system-ui';c.fillStyle='rgba(224,251,255,.92)';c.fillText('TASK · 100',q.x+20,q.y+3);c.restore()}}
|
||||
pick(){if(!this.projected.length)return;let best=null,bestD=20;for(const x of this.projected){const d=Math.hypot(x.q.x-this.mouseX,x.q.y-this.mouseY);if(d<bestD){best=x;bestD=d}}this.hover=best;if(!best){this.tooltip.classList.add('hidden');return}const p=best.p,group=p._group||p._count>1,score=Number((best.q.score??p.score)||0),zone=score>=99?'99+ · unmittelbar am Task':score>=95?'95–99 · sehr nah':score>=90?'90–95 · nah':score>=75?'75–90 · gutes Feld':score>=50?'50–75 · mittlere Distanz':'<50 · weit';this.tooltip.innerHTML=group?`<strong>LOD-Gruppe · ${p._count} Clients</strong><small>Ø Score ${Number(p.score||0).toFixed(2)} · Best ${Number(p._bestScore||0).toFixed(2)}<br>${Number(p.guess_count||0).toLocaleString('de-DE')} Tipps</small>`:`<strong>${p.client_id===this.selfId?'DU':esc(String(p.client_id).slice(0,16))}</strong><small>Score ${score.toFixed(2)} · Rank #${p.rank||'—'}<br>${zone}<br>${Number(p.guess_count||0).toLocaleString('de-DE')} Tipps</small>`;this.tooltip.classList.remove('hidden');this.tooltip.style.left=clamp(this.mouseX+14,8,this.width-270)+'px';this.tooltip.style.top=clamp(this.mouseY+14,8,this.height-100)+'px'}
|
||||
draw(now){const minFrame=this.eco?33:0;if(minFrame&&this.lastPaint&&now-this.lastPaint<minFrame){this.frame=requestAnimationFrame(t=>this.draw(t));return}this.lastPaint=now;const dt=Math.min(.075,(now-this.last)/1000);this.last=now;if(this.autoRotate&&!this.drag)this.yaw+=dt*.035;this.fpsFrames++;if(now-this.fpsStart>800){this.fps=Math.round(this.fpsFrames*1000/(now-this.fpsStart));this.fpsFrames=0;this.fpsStart=now}
|
||||
const c=this.ctx;c.setTransform(this.dpr,0,0,this.dpr,0,0);this.drawBackground(now);this.drawAura(now);this.drawTargetField();this.drawRawShells();this.drawClouds(now);
|
||||
const projected=[];for(const p of this.points)projected.push({p,q:this.projectPoint(p,now)});projected.sort((a,b)=>a.q.z-b.q.z);this.projected=projected;this.renderCount=projected.length;this.drawEdges(projected);this.drawParticles(now);this.drawNodes(projected,now);this.drawCore(now);if(this.hover)this.pick();
|
||||
if(this.opts.onStats&&now>this.statsAt){this.statsAt=now+500;this.opts.onStats({fps:this.fps,render:this.renderCount,raw:this.rawPoints.length,groups:this.points.filter(p=>p._group).length})}
|
||||
this.frame=requestAnimationFrame(t=>this.draw(t));
|
||||
}
|
||||
destroy(){cancelAnimationFrame(this.frame);this.ro.disconnect();this.canvas.remove();this.tooltip.remove()}
|
||||
}
|
||||
|
||||
function markHTML(){return '<span class="mark"><i></i><i></i><i></i></span>'}
|
||||
function setActive(id,on){const el=$(id);if(el)el.classList.toggle('active',!!on)}
|
||||
function shortID(s,n=10){return String(s||'').slice(0,n)}
|
||||
function fmtDate(v){try{return new Date(v).toLocaleString('de-DE')}catch{return '—'}}
|
||||
function fmtScore(v){return Number(v||0).toFixed(2)}
|
||||
function actionLabel(a){return ({set_range_bits:'Zahlenraum ändern',set_intervals:'Intervalle setzen',clear_intervals:'Intervalle erben',pause:'Task pausieren',resume:'Task fortsetzen',reroll:'Ziel neu würfeln',close:'Task beenden',regenerate_artifact:'NFT-Bild neu erzeugen'})[a]||a}
|
||||
|
||||
function proximityRows(points,selfId,limit=7){
|
||||
const rows=(Array.isArray(points)?points:[]).filter(p=>!p._group).slice().sort((a,b)=>Number(b.score||0)-Number(a.score||0)).slice(0,limit);
|
||||
const self=(points||[]).find(p=>p.client_id===selfId);if(self&&!rows.some(x=>x.client_id===selfId))rows.push(self);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function userShell(){
|
||||
app.className='hunt';
|
||||
app.innerHTML=`
|
||||
<div class="stage"><div id="map" class="neural-map"></div><div class="vignette"></div></div>
|
||||
<header class="topbar glass">
|
||||
<div class="brand">${markHTML()}<div><strong>NEURAL HUNT</strong><small>Social Probability Experiment · signed clients</small></div></div>
|
||||
<div class="mode-status living" id="visualMode"><i></i><div><b id="modeTitle">LIVING</b><small id="modeDetail">Task auswählen</small></div></div>
|
||||
<div class="mobile-quick"><span>RANK <b id="rankMobile">#—</b></span><span>SCORE <b id="scoreMobile">0.00</b></span><span id="guessMobile">—</span></div>
|
||||
<div class="metrics"><span><b id="nodecount">0</b> Clients</span><span><b id="rendercount">0</b> Render</span><span><b id="fpscount">0</b> FPS</span><span>Task <b id="taskid">—</b></span><span class="state" id="systemState"><i></i><span id="status">initialisiert</span></span></div>
|
||||
</header>
|
||||
<aside class="signal-panel glass" id="signalPanel">
|
||||
<div class="panel-title"><span>DEIN SIGNAL</span><span class="chip" id="guessCountdown">—</span></div>
|
||||
<div class="rank-hero"><small>RANK</small><strong id="rank">#—</strong></div>
|
||||
<div class="signal-metrics"><div><span>Score</span><b id="score">0.00</b></div><div><span>Wins</span><b id="wins">0</b></div><div><span>Clients</span><b id="clientmetric">0</b></div></div>
|
||||
<div class="proximity-mini"><div class="leaderboard-head"><span>TARGET RADAR</span><small>100 = Task</small></div><div id="proximityRows"></div></div>
|
||||
<div class="identity-block"><span class="eyebrow">IDENTITÄT</span><code id="cid">…</code><div class="identity-actions"><button id="exportid">Export</button><label class="button">Import<input id="importid" hidden type="file" accept="application/json"></label></div><div class="chips" id="unlocks"></div></div>
|
||||
<div class="leaderboard-head"><span>LEADERBOARD</span><a href="/leaderboard">ECHTZEIT →</a></div><div class="leaderboard" id="leaders"></div>
|
||||
</aside>
|
||||
<div class="distance-legend glass"><b>TARGET FIELD</b><span>0 · WEIT</span><i></i><span>75</span><span>90</span><span>95</span><span>99+</span><span>100 · TASK</span></div>
|
||||
<div class="node-limit glass"><span>MAX NODES</span><input id="maxnodes" type="range" min="100" max="25000" step="100" value="2000"><b id="maxnodesvalue">2000</b></div>
|
||||
<nav class="dock glass" aria-label="3D-Steuerung">
|
||||
<button id="chooseTask">TASKS</button><button id="toggleMobile">MOBILE</button><button id="toggleDetails">DETAILS</button><button id="toggleProximity" class="active">TARGET FIELD</button><button id="toggleRotate" class="active">ORBIT</button><button id="toggleLabels" class="active">LABELS</button><button id="toggleEdges" class="active">SIGNALWEGE</button><button id="toggleShells" class="active">SCORE-RINGE</button><button id="toggleLOD" class="active">LOD</button><button id="toggleEco">ECO</button><button id="resetView">ZENTRIEREN</button><a class="dock-link" href="/leaderboard">RANKING</a><a class="dock-link" href="/admin">ADMIN</a>
|
||||
</nav>
|
||||
<section id="taskLanding" class="task-landing visible">
|
||||
<div class="task-landing-bg"></div>
|
||||
<div class="task-landing-inner">
|
||||
<div class="task-landing-head">
|
||||
<div><span class="eyebrow">CHOOSE YOUR FIELD</span><h1>Wähle deinen Task</h1><p>Jeder Task ist ein eigener Wahrscheinlichkeitsraum. Du kannst jederzeit wechseln; deine Identität und bereits erreichte Bestwerte bleiben erhalten.</p></div>
|
||||
<div class="task-landing-id glass"><span>DEINE IDENTITÄT</span><code id="landingCid">initialisiere …</code><button id="landingRefresh">AKTUALISIEREN</button></div>
|
||||
</div>
|
||||
<div id="taskCards" class="task-cards"><div class="task-card-loading">Tasks werden geladen …</div></div>
|
||||
<div class="task-landing-foot"><span>Ein Client kann immer nur mit <b>einem</b> Task aktiv verbunden sein.</span><a href="/leaderboard">Echtzeit-Leaderboard →</a></div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
async function runUser(){
|
||||
userShell(); let task=null,points=[],cid='',scheduler=null,countdownTimer=null,ws=null,submitting=false,nextGuessAt=0,refreshing=false,landingBusy=false,landingTimer=null,wsReconnectTimer=null,wsBackoff=500;
|
||||
const map=new NeuralMap($('map'),{panelOffset:-115,onStats:s=>{if($('rendercount'))$('rendercount').textContent=s.render.toLocaleString('de-DE');if($('fpscount'))$('fpscount').textContent=s.fps}});
|
||||
let detailsOpen=false;
|
||||
const syncMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('toggleMobile').textContent=mobileButtonLabel();setActive('toggleMobile',on);$('signalPanel').classList.toggle('expanded',on&&detailsOpen);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.96);setActive('toggleEco',true);setActive('toggleLabels',false);setActive('toggleEdges',false);if(+$('maxnodes').value>1000){$('maxnodes').value=1000;$('maxnodesvalue').textContent='1.000';map.update(points,cid,1000)}}else{map.eco=false;setActive('toggleEco',false)}map.resize()};
|
||||
const status=(s,mode='living')=>{if($('status'))$('status').textContent=s;const m=$('visualMode');if(m){m.className=`mode-status ${mode}`;$('modeTitle').textContent=mode==='thinking'?'GUESS':mode==='researching'?'WIN':task?.paused?'PAUSED':'LIVING';$('modeDetail').textContent=s}};
|
||||
const renderRadar=()=>{const rows=proximityRows(points,cid);$('proximityRows').innerHTML=rows.map((p,i)=>`<div class="proximity-row ${p.client_id===cid?'self':''}"><span>${p.client_id===cid?'DU':`#${p.rank||i+1}`}</span><div><i style="width:${clamp(Number(p.score||0),0,100)}%"></i></div><b>${fmtScore(p.score)}</b></div>`).join('')||'<div class="empty small">Noch keine Signale</div>'};
|
||||
const render=()=>{points=Array.isArray(points)?points:[];const budget=Math.min(10000,Math.max(300,(+$('maxnodes').value||task?.default_max_nodes||2000)*3));if(points.length>budget){const own=points.find(p=>p.client_id===cid),top=points.filter(p=>p.client_id!==cid).sort((a,b)=>Number(b.score||0)-Number(a.score||0)).slice(0,budget-(own?1:0));points=own?[...top,own]:top}if($('nodecount'))$('nodecount').textContent=points.length.toLocaleString('de-DE');if($('clientmetric'))$('clientmetric').textContent=points.length.toLocaleString('de-DE');map.update(points,cid,+$('maxnodes').value);renderRadar()};
|
||||
const countdown=()=>{let text='—';if(task?.paused)text='PAUSE';else if(task&&nextGuessAt){const sec=Math.max(0,Math.ceil((nextGuessAt-Date.now())/1000));text=sec?`${sec}s`:'jetzt'}$('guessCountdown').textContent=text;if($('guessMobile'))$('guessMobile').textContent=text};
|
||||
async function refreshMe(){try{const me=await api('/api/me'),rank='#'+(me.rank||'—'),score=fmtScore(me.score);$('rank').textContent=rank;$('score').textContent=score;if($('rankMobile'))$('rankMobile').textContent=rank;if($('scoreMobile'))$('scoreMobile').textContent=score;$('wins').textContent=me.wins||0;$('unlocks').innerHTML=(me.unlocks||[]).map(x=>`<span>${esc(x)}</span>`).join('')}catch{}}
|
||||
async function refreshLeaders(){try{const raw=await api('/api/leaderboard'),ls=Array.isArray(raw)?raw:[];$('leaders').innerHTML=ls.slice(0,12).map((l,i)=>`<div class="leader-row ${l.client_id===cid?'self':''}"><span class="leader-rank">${i+1}</span><span><b>${esc(shortID(l.client_id,11))}</b><small>${l.wins} Wins · live ${fmtScore(l.live_score)}</small></span><strong>${Number(l.best_score||0).toFixed(1)}</strong></div>`).join('')||'<div class="empty">Noch keine Teilnehmer</div>'}catch{}}
|
||||
async function ensureSession(){if(!getToken()){const x=await loginIdentity();setToken(x.token);return}try{const me=await api('/api/me');if(me?.client_id!==cid)throw Object.assign(new Error('identity mismatch'),{status:401})}catch(e){if(e.status!==401)throw e;clearToken();const x=await loginIdentity();setToken(x.token)}}
|
||||
function stopTimers(){if(scheduler){clearInterval(scheduler);scheduler=null}if(countdownTimer){clearInterval(countdownTimer);countdownTimer=null}nextGuessAt=0;countdown()}
|
||||
function clearWSReconnect(){if(wsReconnectTimer){clearTimeout(wsReconnectTimer);wsReconnectTimer=null}}
|
||||
function wsReady(){return !!ws&&ws.readyState===WebSocket.OPEN}
|
||||
async function closeWS(){clearWSReconnect();if(!ws)return;const socket=ws;ws=null;socket._plannedClose=true;await new Promise(resolve=>{let done=false;const finish=()=>{if(done)return;done=true;resolve()};socket.addEventListener('close',finish,{once:true});try{socket.close(1000,'task switch')}catch{}setTimeout(finish,650)})}
|
||||
async function stopTaskSession(){stopTimers();clearWSReconnect();await closeWS();submitting=false}
|
||||
function taskCardName(t){return String(t.display_name||'').trim()||`Task ${String(t.id||'').slice(-8)}`}
|
||||
function renderTaskCards(items){const host=$('taskCards');items=Array.isArray(items)?items:[];host.innerHTML=items.length?items.map((t,i)=>`<article class="task-card glass ${t.selected?'selected':''} ${t.paused?'paused':''}">
|
||||
<div class="task-card-top"><span class="task-orbit">${String(i+1).padStart(2,'0')}</span><div><span class="eyebrow">${t.selected?'DEIN AKTUELLER TASK':'ACTIVE FIELD'}</span><h2>${esc(taskCardName(t))}</h2><code>${esc(shortID(t.id.slice(-16),16))}</code></div><span class="task-bit">${t.range_bits}<small>BIT</small></span></div>
|
||||
<div class="task-style-preview"><img src="/api/public/tasks/${encodeURIComponent(t.id)}/style-reference?v=${encodeURIComponent(t.revision||0)}" alt="Style-Referenz für ${esc(taskCardName(t))}"><span>${t.has_custom_style_reference?'TASK STYLE':'DEFAULT STYLE'}</span></div>
|
||||
<p>${esc(t.description||'Ein aktiver Neural-Hunt-Zahlenraum. Bewege dein Signal mit jedem besseren Tipp näher an den Task-Kern.')}</p>
|
||||
<div class="task-card-stats"><span><small>CLIENTS</small><b>${Number(t.point_count||0).toLocaleString('de-DE')}</b></span><span><small>DEIN SCORE</small><b>${fmtScore(t.own_score)}</b></span><span><small>DEIN RANK</small><b>${t.own_rank?`#${t.own_rank}`:'—'}</b></span><span><small>STATUS</small><b>${t.paused?'PAUSE':'LIVE'}</b></span></div>
|
||||
<button data-task-choice="${esc(t.id)}">${t.selected?'FORTSETZEN':'TASK WÄHLEN'} <span>→</span></button>
|
||||
</article>`).join(''):'<div class="task-card-empty glass"><b>Keine aktiven Tasks</b><span>Der Server erzeugt gerade einen neuen Wahrscheinlichkeitsraum.</span></div>';
|
||||
document.querySelectorAll('[data-task-choice]').forEach(b=>b.onclick=()=>enterTask(b.dataset.taskChoice));
|
||||
}
|
||||
async function showLanding(){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=null;points=[];render();$('taskid').textContent='—';$('taskLanding').classList.add('visible');status('Task auswählen');const items=await api('/api/tasks');renderTaskCards(items)}catch(e){status(e.message||'Tasks konnten nicht geladen werden');$('taskCards').innerHTML=`<div class="task-card-empty glass"><b>Fehler beim Laden</b><span>${esc(e.message||'Unbekannter Fehler')}</span></div>`}finally{landingBusy=false}}
|
||||
async function refreshTaskConfig(forcePoints=false){if(refreshing||!task)return;refreshing=true;try{const current=await api('/api/tasks/current');if(task&¤t.id!==task.id){setTimeout(()=>showLanding(),0);return}const changed=current.revision!==task.revision||current.public_seed!==task.public_seed||current.range_bits!==task.range_bits;task=current;$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);if(changed||forcePoints){points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,(+$('maxnodes').value||task.default_max_nodes||2000)*3))}`);points=Array.isArray(points)?points:[];render()}if(task.paused){status(`Task pausiert · ${task.range_bits} Bit`);nextGuessAt=0}else if(changed){status(`Task aktualisiert · ${task.range_bits} Bit`);nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000}}finally{refreshing=false}}
|
||||
function scheduleWSReconnect(){if(wsReconnectTimer||!task||$('taskLanding').classList.contains('visible'))return;const delay=Math.min(8000,wsBackoff)+Math.floor(Math.random()*250);wsReconnectTimer=setTimeout(()=>{wsReconnectTimer=null;openWS()},delay);wsBackoff=Math.min(8000,Math.max(750,wsBackoff*1.7))}
|
||||
async function recover409(e){
|
||||
if(e.code==='task_inactive'||e.code==='selection_conflict'){await showLanding();return}
|
||||
try{const current=await api('/api/tasks/current');if(!task||current.id!==task.id){await showLanding();return}task=current;$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12)}catch{}
|
||||
if(e.code==='presence_required')openWS(true);else if(!wsReady())scheduleWSReconnect();
|
||||
if(e.code==='task_config_changed')await refreshTaskConfig(true);
|
||||
nextGuessAt=Date.now()+1500;status(e.code==='presence_required'?'Live-Verbindung wird automatisch wiederhergestellt …':'Client wird automatisch synchronisiert …')
|
||||
}
|
||||
async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),signature=await sign(`guess|${task.id}|${seq}|${guess}`),correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;status(correct?'Treffer — Task gelöst!':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}}
|
||||
function openWS(force=false){if(!task||$('taskLanding').classList.contains('visible'))return;clearWSReconnect();if(ws&&(ws.readyState===WebSocket.OPEN||ws.readyState===WebSocket.CONNECTING)){if(!force)return;const old=ws;old._plannedClose=true;try{old.close(1000,'reconnect')}catch{}}const proto=location.protocol==='https:'?'wss':'ws',mx=+$('maxnodes').value||task.default_max_nodes||2000,socket=new WebSocket(`${proto}://${location.host}/api/ws?token=${encodeURIComponent(getToken())}&max_nodes=${encodeURIComponent(mx)}`);ws=socket;socket.onopen=()=>{if(ws!==socket)return;wsBackoff=500;status(task?.paused?'Task pausiert':'verbunden')};socket.onmessage=async ev=>{if(ws!==socket)return;const e=JSON.parse(ev.data);if(e.type==='snapshot'){points=Array.isArray(e.data)?e.data:[];render()}else if(e.type==='point'){const p=e.data,i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p;render()}else if(e.type==='points'){for(const p of (Array.isArray(e.data)?e.data:[])){const i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p}render()}else if(e.type==='task_changed'){await refreshTaskConfig(true);await Promise.all([refreshMe(),refreshLeaders()])}else if(e.type==='task_completed'){status('Task abgeschlossen — Folge-Task ist bereit','researching');setTimeout(()=>showLanding(),1300)}};socket.onclose=e=>{if(ws===socket)ws=null;if(!socket._plannedClose&&task&&!$('taskLanding').classList.contains('visible')){status('Live-Verbindung unterbrochen · verbinde automatisch neu …');scheduleWSReconnect()}};socket.onerror=()=>{if(!socket._plannedClose&&ws===socket)status('WebSocket-Fehler · Reconnect folgt automatisch')}}
|
||||
async function enterTask(taskID){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=await api('/api/tasks/select',{method:'POST',body:JSON.stringify({task_id:taskID})});$('taskLanding').classList.remove('visible');$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);let max=+$('maxnodes').value||clamp(Number(task.default_max_nodes||2000),100,25000);if(document.documentElement.classList.contains('mobile-mode'))max=Math.min(max,1000);$('maxnodes').value=max;$('maxnodesvalue').textContent=max.toLocaleString('de-DE');points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,max*3))}`);points=Array.isArray(points)?points:[];render();await Promise.all([refreshMe(),refreshLeaders()]);syncMobile();openWS();status(task.paused?'Task pausiert':`${task.range_bits} Bit · verbunden`);nextGuessAt=task.paused?0:Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;scheduler=setInterval(()=>{if(task&&!task.paused&&nextGuessAt&&Date.now()>=nextGuessAt)submit()},300);countdownTimer=setInterval(countdown,250);countdown()}catch(e){status(e.message||'Task konnte nicht gestartet werden');$('taskLanding').classList.add('visible')}finally{landingBusy=false}}
|
||||
try{const id=await ensureIdentity();cid=await clientId(id.publicJwk);$('cid').textContent=cid;$('landingCid').textContent=cid;await ensureSession();syncMobile();await Promise.all([refreshLeaders()]);await showLanding()}catch(e){status(e.message||'Startfehler');$('taskCards').innerHTML=`<div class="task-card-empty glass"><b>Startfehler</b><span>${esc(e.message||'')}</span></div>`}
|
||||
$('landingRefresh').onclick=()=>showLanding();$('chooseTask').onclick=()=>showLanding();
|
||||
$('maxnodes').addEventListener('input',e=>{$('maxnodesvalue').textContent=Number(e.target.value).toLocaleString('de-DE');render()});
|
||||
$('toggleMobile').onclick=()=>toggleMobileMode();$('toggleDetails').onclick=()=>{detailsOpen=!detailsOpen;$('signalPanel').classList.toggle('expanded',detailsOpen);setActive('toggleDetails',detailsOpen)};window.addEventListener('neuralhunt-mobile-mode',syncMobile);
|
||||
$('toggleProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('toggleProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('toggleProximity',map.proximityFocus)};$('toggleRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('toggleRotate',map.autoRotate)};$('toggleLabels').onclick=()=>{map.labels=!map.labels;setActive('toggleLabels',map.labels)};$('toggleEdges').onclick=()=>{map.edges=!map.edges;setActive('toggleEdges',map.edges)};$('toggleShells').onclick=()=>{map.shells=!map.shells;setActive('toggleShells',map.shells)};$('toggleLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('toggleLOD',map.lodEnabled)};$('toggleEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('toggleEco',map.eco)};$('resetView').onclick=()=>map.resetView();
|
||||
$('exportid').onclick=async()=>{const p=prompt('Passphrase für den verschlüsselten Identitäts-Export');if(!p)return;try{const text=await exportIdentity(p),a=document.createElement('a');a.href=URL.createObjectURL(new Blob([text],{type:'application/json'}));a.download=`neuralhunt-identity-${cid.slice(0,10)}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000)}catch(e){status(e.message||'Export fehlgeschlagen')}};
|
||||
$('importid').onchange=async e=>{const f=e.target.files?.[0];if(!f)return;const p=prompt('Passphrase für diesen Identitäts-Export');if(!p)return;try{await importIdentity(await f.text(),p);clearToken();location.reload()}catch(err){status(err.message||'Import fehlgeschlagen')}};
|
||||
addEventListener('beforeunload',()=>{stopTimers();clearWSReconnect();if(landingTimer)clearTimeout(landingTimer);if(ws){ws._plannedClose=true;ws.close()}window.removeEventListener('neuralhunt-mobile-mode',syncMobile);map.destroy()},{once:true});
|
||||
}
|
||||
|
||||
function leaderboardShell(){
|
||||
app.className='leaderboard-page';app.innerHTML=`
|
||||
<header class="leaderboard-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / REALTIME LEADERBOARD</strong><small>Live score · wins · watermarked winner NFTs</small></div></div><div class="leaderboard-nav"><button id="lbMobile">MOBILE</button><a href="/">CLIENT</a><a href="/admin">ADMIN</a></div></header>
|
||||
<main class="lb-main">
|
||||
<section class="lb-hero"><div class="lb-title"><span class="eyebrow">PUBLIC SIGNAL</span><h1>Echtzeit-Ranking</h1><p>Live-Score zeigt die beste Position in einem aktuell aktiven Task. Gewinner-Artefakte werden öffentlich ausschließlich über eine serverseitig erzeugte Vorschau mit Wasserzeichen angezeigt.</p></div><div class="lb-controls glass"><div class="segmented"><button id="lbLive" class="active">LIVE</button><button id="lbAll">ALL-TIME</button></div><input id="lbSearch" placeholder="Client-ID / Task suchen"><span id="lbState">verbinde …</span></div></section>
|
||||
<section id="lbPodium" class="lb-podium"></section>
|
||||
<section class="nft-showcase"><div class="nft-showcase-head"><div><span class="eyebrow">WINNER ARTIFACTS</span><h2>NFT-Galerie</h2></div><span><b id="nftCount">0</b> Artefakte · nur Wasserzeichen-Vorschau</span></div><div id="nftGallery" class="nft-gallery"></div></section>
|
||||
<section class="glass lb-table-wrap"><div class="lb-table-head"><span id="lbCount">0 Clients</span><span>automatische Aktualisierung über WebSocket</span></div><div class="lb-table" id="lbTable"></div></section>
|
||||
</main>
|
||||
<div id="nftLightbox" class="nft-lightbox hidden" role="dialog" aria-modal="true" aria-label="NFT Vorschau"><button id="nftClose" aria-label="Vorschau schließen">×</button><div class="nft-lightbox-card glass"><img id="nftLarge" alt="Wasserzeichen-Vorschau des Gewinner-Artefakts"><div><b id="nftLargeTitle">Winner NFT</b><small id="nftLargeMeta">Watermarked preview</small></div></div></div>`;
|
||||
}
|
||||
|
||||
async function runLeaderboard(){
|
||||
leaderboardShell();let mode='live',rows=[],artifacts=[],ws=null,refreshTimer=null;
|
||||
const openNFT=a=>{if(!a?.preview_uri)return;$('nftLarge').src=a.preview_uri;$('nftLargeTitle').textContent=`Task ${shortID(a.task_id?.slice(-14),14)}`;$('nftLargeMeta').textContent=`Winner ${shortID(a.winner_client_id,18)} · ${a.range_bits} Bit · WATERMARKED PREVIEW`;$('nftLightbox').classList.remove('hidden')};
|
||||
const closeNFT=()=>{$('nftLightbox').classList.add('hidden');$('nftLarge').removeAttribute('src')};
|
||||
const render=()=>{
|
||||
const q=$('lbSearch').value.trim().toLowerCase(),filtered=rows.filter(x=>!q||String(x.client_id).toLowerCase().includes(q)||String(x.nft_task_id||'').toLowerCase().includes(q));
|
||||
$('lbCount').textContent=`${filtered.length.toLocaleString('de-DE')} Clients`;
|
||||
const top=filtered.slice(0,3);
|
||||
$('lbPodium').innerHTML=top.map((x,i)=>`<div class="podium rank-${i+1} glass">${x.nft_preview_uri?`<img class="podium-nft" src="${esc(x.nft_preview_uri)}" alt="Wasserzeichen NFT Vorschau" loading="lazy">`:`<span class="podium-rank">#${i+1}</span>`}<div><b>#${i+1} · ${esc(shortID(x.client_id,16))}</b><small>${x.connected?'● online':'○ offline'} · Best ${fmtScore(x.best_score)} · ${x.nft_count||0} NFTs</small></div><strong>${mode==='live'?fmtScore(x.live_score):`${x.wins} Wins`}</strong></div>`).join('');
|
||||
$('lbTable').innerHTML=filtered.map((x,i)=>`<div class="lb-row"><span class="lb-rank">#${i+1}</span><span class="lb-id"><i class="presence ${x.connected?'on':''}"></i><b>${esc(shortID(x.client_id,22))}</b><small>${(x.unlocks||[]).slice(0,3).map(esc).join(' · ')||'keine Unlocks'}</small></span><div class="lb-stats"><span><small>LIVE</small><strong>${fmtScore(x.live_score)}</strong></span><span><small>BEST</small><strong>${fmtScore(x.best_score)}</strong></span><span><small>WINS</small><strong>${x.wins||0}</strong></span><span><small>TIPPS</small><strong>${Number(x.guess_count||0).toLocaleString('de-DE')}</strong></span></div><span class="lb-nft-cell">${x.nft_preview_uri?`<button class="lb-nft-button" data-nft-task="${esc(x.nft_task_id||'')}"><img src="${esc(x.nft_preview_uri)}" alt="Wasserzeichen NFT Vorschau" loading="lazy"><em>${x.nft_count||1}× NFT</em></button>`:'<small>—</small>'}</span></div>`).join('')||'<div class="empty">Noch keine Teilnehmer</div>';
|
||||
const shownArtifacts=artifacts.filter(a=>!q||String(a.winner_client_id).toLowerCase().includes(q)||String(a.task_id).toLowerCase().includes(q));
|
||||
$('nftCount').textContent=shownArtifacts.length.toLocaleString('de-DE');
|
||||
$('nftGallery').innerHTML=shownArtifacts.length?shownArtifacts.map(a=>`<button class="nft-card" data-gallery-task="${esc(a.task_id)}"><span class="nft-image-wrap"><img src="${esc(a.preview_uri)}" alt="Wasserzeichen-Vorschau für Task ${esc(shortID(a.task_id,12))}" loading="lazy"><i>WATERMARKED</i></span><span><b>${esc(shortID(a.task_id.slice(-16),16))}</b><small>Winner ${esc(shortID(a.winner_client_id,14))} · ${a.range_bits} Bit</small></span></button>`).join(''):'<div class="empty">Noch keine fertigen Gewinner-Artefakte</div>';
|
||||
document.querySelectorAll('[data-gallery-task]').forEach(b=>b.onclick=()=>openNFT(artifacts.find(a=>a.task_id===b.dataset.galleryTask)));
|
||||
document.querySelectorAll('[data-nft-task]').forEach(b=>b.onclick=()=>openNFT(artifacts.find(a=>a.task_id===b.dataset.nftTask)||{task_id:b.dataset.nftTask,winner_client_id:filtered.find(x=>x.nft_task_id===b.dataset.nftTask)?.client_id,range_bits:'—',preview_uri:filtered.find(x=>x.nft_task_id===b.dataset.nftTask)?.nft_preview_uri}));
|
||||
};
|
||||
async function refresh(){try{const [x,a]=await Promise.all([api(`/api/public/leaderboard?mode=${mode}&limit=500`),api('/api/public/artifacts?limit=96')]);rows=Array.isArray(x)?x:[];artifacts=Array.isArray(a)?a:[];$('lbState').textContent='LIVE · '+new Date().toLocaleTimeString('de-DE');render()}catch(e){$('lbState').textContent=e.message}}
|
||||
const debounce=()=>{clearTimeout(refreshTimer);refreshTimer=setTimeout(refresh,120)};
|
||||
$('lbLive').onclick=()=>{mode='live';setActive('lbLive',true);setActive('lbAll',false);refresh()};$('lbAll').onclick=()=>{mode='alltime';setActive('lbLive',false);setActive('lbAll',true);refresh()};$('lbSearch').oninput=render;
|
||||
$('lbMobile').onclick=()=>{toggleMobileMode();$('lbMobile').textContent=mobileButtonLabel();setActive('lbMobile',document.documentElement.classList.contains('mobile-mode'))};$('lbMobile').textContent=mobileButtonLabel();setActive('lbMobile',document.documentElement.classList.contains('mobile-mode'));
|
||||
$('nftClose').onclick=closeNFT;$('nftLightbox').onclick=e=>{if(e.target===$('nftLightbox'))closeNFT()};addEventListener('keydown',e=>{if(e.key==='Escape')closeNFT()});
|
||||
await refresh();const proto=location.protocol==='https:'?'wss':'ws';ws=new WebSocket(`${proto}://${location.host}/api/leaderboard/ws`);ws.onopen=()=>{$('lbState').textContent='LIVE · verbunden'};ws.onmessage=debounce;ws.onclose=()=>{$('lbState').textContent='WebSocket getrennt'};addEventListener('beforeunload',()=>{clearTimeout(refreshTimer);if(ws)ws.close()},{once:true});
|
||||
}
|
||||
|
||||
function adminLoginShell(){app.className='login';app.innerHTML=`<div class="login-card glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / ADMIN</strong><small>Control plane</small></div></div><input id="adminuser" value="admin" placeholder="Benutzer"><input id="adminpass" type="password" placeholder="Passwort"><button id="adminlogin">ANMELDEN</button><p class="danger statusline" id="adminerr"></p><a href="/">← Client-Ansicht</a></div>`}
|
||||
function adminShell(){
|
||||
app.className='admin';app.innerHTML=`
|
||||
<header class="admin-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / ADMIN</strong><small>Tasks · Clients · Runtime · Scheduler</small></div></div><div class="admin-actions"><button id="adminMobileToggle">MOBILE</button><a href="/">CLIENT</a><a href="/leaderboard">LEADERBOARD</a><button id="adminlogout">LOGOUT</button></div></header>
|
||||
<div class="overviewStrip glass" id="overview"></div><div class="overviewStrip glass perf-strip" id="performance"></div>
|
||||
<nav class="admin-mobile-tabs glass" id="adminMobileTabs"><button data-admin-panel="map" class="active">MAP</button><button data-admin-panel="tasks">TASKS</button><button data-admin-panel="control">CONTROL</button></nav>
|
||||
<main class="adminGrid mobile-show-map" id="adminGrid">
|
||||
<section class="glass tasklist"><div class="panel-title"><span>TASKS</span><span class="chip" id="taskCount">0</span></div><div class="toolbar"><select id="statusfilter"><option value="">alle Status</option><option>active</option><option>completed</option><option>closed</option></select><input id="taskquery" placeholder="Task / Winner"><button id="filter">FILTER</button></div><div class="tasks" id="tasks"></div></section>
|
||||
<section class="adminMap glass"><div id="adminmap" class="neural-map"></div><div class="map-overlay top"><span><b id="selectedtask">Task wählen</b></span><span><b id="adminbits">—</b> Bit</span><span><b id="adminpoints">0</b> Clients</span><span><b id="adminrender">0</b> Render</span><span><b id="adminfps">0</b> FPS</span><span id="winner"></span><label class="inline-filter">Score ≥ <input id="adminMinScore" type="number" min="0" max="100" step="1" value="0"></label><input id="adminClientFilter" class="client-filter" placeholder="Client-ID filtern"></div><div class="map-overlay bottom"><label>MAX NODES <input id="adminmaxnodes" type="range" min="100" max="50000" step="100" value="5000"><b id="adminmaxvalue">5.000</b></label><button id="adminProximity" class="active">TARGET FIELD</button><button id="adminRotate" class="active">ORBIT</button><button id="adminEdges" class="active">SIGNALWEGE</button><button id="adminShells" class="active">SCORE-RINGE</button><button id="adminLOD" class="active">LOD</button><button id="adminEco">ECO</button><button id="adminReset">RESET</button></div></section>
|
||||
<section class="glass settings"><div class="panel-title"><span>CONTROL PLANE</span><span class="chip">SQLite</span></div><div class="settings-tabs"><button id="tabRuntime" class="active">RUNTIME</button><button id="tabTask">TASK ACTIONS</button><button id="tabArtifact">ARTIFACT</button></div><div id="settingfields"></div><div class="actions"><button id="savesettings">SPEICHERN</button><button id="ensuretasks">ACTIVE TASKS SICHERN</button></div><p class="small statusline" id="adminstatus"></p></section>
|
||||
</main>`;
|
||||
}
|
||||
|
||||
async function runAdmin(){
|
||||
if(!localStorage.getItem(adminTokenKey)){adminLoginShell();$('adminlogin').onclick=async()=>{try{const r=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:$('adminuser').value,password:$('adminpass').value})});if(!r.ok)throw new Error('Login fehlgeschlagen');const x=await r.json();localStorage.setItem(adminTokenKey,x.token);location.reload()}catch(e){$('adminerr').textContent=e.message}};return}
|
||||
adminShell();const map=new NeuralMap($('adminmap'),{admin:true,onStats:s=>{if($('adminrender'))$('adminrender').textContent=s.render.toLocaleString('de-DE');if($('adminfps'))$('adminfps').textContent=s.fps}});const adminDraftKey='neuralhunt.adminDraft.v2';let draft={};try{draft=JSON.parse(localStorage.getItem(adminDraftKey)||'{}')||{}}catch{draft={}};let tasks=[],selected=null,points=[],settings=null,actions=[],providers=null,artifactUsage=null,poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false;
|
||||
const setAdminPanel=name=>{const grid=$('adminGrid');grid.classList.remove('mobile-show-map','mobile-show-tasks','mobile-show-control');grid.classList.add(`mobile-show-${name}`);document.querySelectorAll('[data-admin-panel]').forEach(b=>b.classList.toggle('active',b.dataset.adminPanel===name));if(name==='map')setTimeout(()=>map.resize(),30)};
|
||||
const syncAdminMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('adminMobileToggle').textContent=mobileButtonLabel();setActive('adminMobileToggle',on);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.94);setActive('adminEco',true);setActive('adminEdges',false);if(+$('adminmaxnodes').value>1500){$('adminmaxnodes').value=1500;$('adminmaxvalue').textContent='1.500'}}else{map.eco=false;setActive('adminEco',false)}map.resize()};
|
||||
$('adminMobileToggle').onclick=()=>toggleMobileMode();document.querySelectorAll('[data-admin-panel]').forEach(b=>b.onclick=()=>setAdminPanel(b.dataset.adminPanel));window.addEventListener('neuralhunt-mobile-mode',syncAdminMobile);syncAdminMobile();
|
||||
const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision'];
|
||||
const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'};
|
||||
const msg=s=>$('adminstatus').textContent=s;
|
||||
const saveDraft=()=>{try{draft.tab=tab;draft.selectedTaskId=selected?.id||draft.selectedTaskId||'';draft.filters={status:$('statusfilter')?.value||'',q:$('taskquery')?.value||''};localStorage.setItem(adminDraftKey,JSON.stringify(draft))}catch{}};
|
||||
const draftScope=()=>tab==='task'&&selected?`task:${selected.id}`:`global:${tab}`;
|
||||
const draftFieldKey=el=>el.dataset.setting?`setting:${el.dataset.setting}`:el.dataset.settingString?`string:${el.dataset.settingString}`:el.dataset.draft||el.id||'';
|
||||
const captureDraft=()=>{const scope=draftScope();draft.fields=draft.fields||{};draft.fields[scope]=draft.fields[scope]||{};document.querySelectorAll('#settingfields input,#settingfields textarea,#settingfields select').forEach(el=>{if(el.type==='file')return;const k=draftFieldKey(el);if(k)draft.fields[scope][k]=el.value});saveDraft()};
|
||||
const restoreDraft=()=>{const scope=draftScope(),values=draft.fields?.[scope]||{};document.querySelectorAll('#settingfields input,#settingfields textarea,#settingfields select').forEach(el=>{if(el.type==='file')return;const k=draftFieldKey(el);if(k&&Object.prototype.hasOwnProperty.call(values,k))el.value=values[k]})};
|
||||
const clearDraftKeys=keys=>{const scope=draftScope(),values=draft.fields?.[scope];if(values){keys.forEach(k=>delete values[k]);saveDraft()}};
|
||||
function renderOverview(o){$('overview').innerHTML=`<span><b>${o.connected}</b> verbunden</span><span><b>${o.clients}</b> Identitäten</span><span><b>${o.active_tasks}</b> aktive Tasks</span><span><b>${o.completed_tasks}</b> abgeschlossen</span><span><b>${Number(o.guesses||0).toLocaleString('de-DE')}</b> Tipps</span><span><b>${o.artifacts_ready}</b> Artefakte</span>`}
|
||||
function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`<span><b>${Number(r.guesses_per_sec||0).toFixed(1)}</b> Guess/s</span><span><b>${Number(r.improvements_per_sec||0).toFixed(1)}</b> Improve/s</span><span><b>${Number(r.sqlite_writes_per_sec||0).toFixed(1)}</b> SQLite W/s</span><span><b>${Number(w.frames_per_sec||0).toFixed(0)}</b> WS Frames/s</span><span><b>${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)}</b> WS MB/s</span><span><b>${Number(w.dropped_per_sec||0).toFixed(1)}</b> Drops/s</span><span><b>${Number(x.goroutines||0).toLocaleString('de-DE')}</b> Goroutines</span><span><b>${(Number(x.heap_bytes||0)/1048576).toFixed(1)}</b> Heap MB</span>`}
|
||||
async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const token=localStorage.getItem(adminTokenKey),r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{headers:{Authorization:'Bearer '+token}});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}}
|
||||
function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>`<button data-id="${esc(t.id)}" class="${selected?.id===t.id?'selected':''}"><span><b>${esc(t.display_name||t.id.slice(-12))}</b><small>${esc(t.id.slice(-12))} · ${fmtDate(t.created_at)}</small></span><span><em class="task-state ${esc(t.status)}">${t.paused?'PAUSED':esc(t.status)}</em><small>${t.range_bits} Bit · rev ${t.revision} · ${Number(t.point_count||0).toLocaleString('de-DE')} Clients · ${Number(t.guess_count||0).toLocaleString('de-DE')} Tipps</small></span><span>${esc(t.artifact_status||'—')}<small>${t.parent_task_id?`↳ ${esc(String(t.parent_task_id).slice(-7))}`:'ROOT'}</small><small class="artifactLinks">${t.artifact_uri?`<span data-artifact-task="${esc(t.id)}">Bild</span> · <span data-manifest-task="${esc(t.id)}">Manifest</span>`:''}</small></span></button>`).join(''):'<div class="empty">Keine Tasks</div>';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})}
|
||||
function renderRuntime(){
|
||||
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Diese Defaults gelten für neue Tasks. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block';
|
||||
}
|
||||
function renderArtifact(){
|
||||
const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[];
|
||||
const usd=(n,d=4)=>Number(n||0).toLocaleString('de-DE',{style:'currency',currency:'USD',minimumFractionDigits:d,maximumFractionDigits:d});
|
||||
const usageRows=recent.length?recent.map(r=>`<tr><td>${fmtDate(r.created_at)}</td><td>${r.kind==='character_anchor'?'ANCHOR':'KARTE'}</td><td>${esc(r.model||'—')}<small>${esc(r.quality||'—')} · ${esc(r.size||'—')}</small></td><td>${Number(r.input_text_tokens||0).toLocaleString('de-DE')} T + ${Number(r.input_image_tokens||0).toLocaleString('de-DE')} I → ${Number(r.output_tokens||0).toLocaleString('de-DE')}</td><td>${r.estimated_cost_usd==null?'—':usd(r.estimated_cost_usd,5)}</td><td><small>${esc(r.request_id?String(r.request_id).slice(-16):'—')}</small></td></tr>`).join(''):'<tr><td colspan="6" class="empty small">Noch keine OpenAI-Bildgenerierung protokolliert.</td></tr>';
|
||||
if(preset==='raccoon_full_art_v1'){
|
||||
const anchorReady=!!providers?.character_anchor;
|
||||
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">RIFT FULL-ART COLLECTION</div><div class="provider-status"><span class="${ps.openai?'ready':'off'}">OPENAI · ${ps.openai?'API KEY READY':'API KEY FEHLT'}</span><span class="${anchorReady?'ready':'off'}">CHARACTER ANCHOR · ${anchorReady?'LOCKED':'NOCH NICHT ERZEUGT'}</span></div>
|
||||
<label class="wide"><span>OpenAI Bildmodell</span><input data-setting-string="artifact_model" value="${esc(settings?.artifact_model||'gpt-image-2')}" placeholder="gpt-image-2"></label>
|
||||
<div class="reference-admin-box"><div><div class="section-title">RIFT CHARACTER ANCHOR</div><p class="small">Der Anchor definiert ausschließlich, <b>wer RIFT ist</b>. Er wird einmalig als neutrales Character-Referenzbild erzeugt und anschließend für alle Tasks verwendet. Die visuelle Stilrichtung kommt getrennt aus der Style-Referenz des jeweiligen Tasks.</p>${anchorReady?'<p class="small ready-copy">Anchor vorhanden · Identität ist gesperrt. Es gibt absichtlich keinen Überschreiben-Button.</p>':'<p class="small">Du kannst den Anchor jetzt kontrolliert erzeugen. Falls du das nicht tust, erzeugt der Worker ihn weiterhin automatisch beim ersten Gewinnerbild als Sicherheits-Fallback.</p>'}</div><div class="reference-preview ${anchorReady?'has-image':''}">${anchorReady?'<img id="anchorPreview" alt="RIFT Character Anchor">':'<span>NO ANCHOR</span>'}</div></div>
|
||||
${anchorReady?'':`<div class="task-config-actions anchor-actions"><button id="createCharacterAnchor" ${ps.openai?'':'disabled'}>RIFT-ANCHOR JETZT ERZEUGEN</button></div>`}
|
||||
<div class="task-config-box"><div class="section-title">PIPELINE</div><p class="small">Provider <b>OpenAI</b> · Ausgabe <b>1024 × 1536</b> · Quality <b>${esc(settings?.artifact_quality||'medium')}</b> · Preset <b>raccoon_full_art_v1</b>.</p><p class="small">Jede Karten-Generierung sendet zwei getrennte Referenzen: <b>Image 1 = globaler RIFT-Character-Anchor</b>, <b>Image 2 = Style-Referenz des gewählten Tasks</b>. Ohne eigenen Task-Style wird das eingebettete <code>internal/artifact/assets/style_reference.jpg</code> nur als Default-Style verwendet.</p><p class="small">Theme, Kleidung, Accessoires, Szene, Pose, Stimmung, Farb-Akzente und Rarity werden deterministisch aus Task/Winner/Seed gewählt. Das Modell erzeugt nur die Full-Art-Illustration; das finale Kartenlayout wird anschließend programmgesteuert aufgebaut.</p></div>
|
||||
<div class="section-title">OPENAI NUTZUNG & KOSTEN</div><div class="cost-grid"><div class="cost-card"><small>KOSTEN HEUTE</small><b id="artifactCostToday">${usd(u.today_cost_usd,4)}</b><span id="artifactCostTodayMeta">${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}</span></div><div class="cost-card"><small>Ø KOSTEN PRO KARTE</small><b id="artifactAvgCardCost">${usd(u.avg_card_cost_usd,5)}</b><span id="artifactAvgCardCostMeta">${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen</span></div><div class="cost-card"><small>KOSTEN PRO 1.000 KARTEN</small><b id="artifactCostPer1000">${usd(u.cost_per_1000_usd,2)}</b><span>hochgerechnet aus dem bisherigen Kartenmittel</span></div></div>
|
||||
<div class="usage-note">Die Token-Nutzung stammt direkt aus der OpenAI-Antwort. Die USD-Werte werden lokal daraus mit fest hinterlegten öffentlichen Standardpreisen berechnet; sie sind kein Rechnungsabgleich. Anchor-Kosten zählen in „heute“, aber nicht in den Karten-Durchschnitt.</div>
|
||||
<div class="usage-table-wrap"><table class="usage-table"><thead><tr><th>Zeit</th><th>Typ</th><th>Modell</th><th>Tokens (Text + Bild → Output)</th><th>Kosten</th><th>Request</th></tr></thead><tbody id="artifactUsageRows">${usageRows}</tbody></table></div>
|
||||
<p class="small">Task-spezifische Style-Bilder und optionale kreative Vorgaben pflegst du im Tab <b>TASK ACTIONS</b>.</p></div>`;
|
||||
if(anchorReady){const img=$('anchorPreview');loadProtectedImage('/api/admin/artifact/character-anchor?ts='+Date.now(),img,true).catch(()=>{if(img)img.alt='Anchor konnte nicht geladen werden'})}
|
||||
const create=$('createCharacterAnchor');if(create)create.onclick=async()=>{if(!confirm('RIFT Character Anchor jetzt einmalig erzeugen? Danach wird er absichtlich nicht automatisch überschrieben.'))return;create.disabled=true;create.textContent='ANCHOR WIRD ERZEUGT …';try{await api('/api/admin/artifact/character-anchor',{method:'POST'},true);msg('RIFT Character Anchor erzeugt und gesperrt');await load(true,true)}catch(e){msg(e.message);create.disabled=false;create.textContent='RIFT-ANCHOR JETZT ERZEUGEN'}};
|
||||
}else{
|
||||
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">LEGACY ARTIFACT GENERATION</div><div class="provider-status">${['local','openai','comfyui','a1111'].map(k=>`<span class="${ps[k]?'ready':'off'}">${k.toUpperCase()} · ${ps[k]?'READY':'ENV FEHLT'}</span>`).join('')}</div>
|
||||
<label><span>Provider</span><select data-setting-string="artifact_provider"><option value="local">local</option><option value="openai">openai</option><option value="comfyui">comfyui</option><option value="a1111">a1111</option><option value="auto">auto fallback</option></select></label>
|
||||
<label><span>Modell / Checkpoint</span><input data-setting-string="artifact_model" value="${esc(settings?.artifact_model||'')}"></label>
|
||||
<label><span>Quality (OpenAI)</span><select data-setting-string="artifact_quality"><option>auto</option><option>low</option><option>medium</option><option>high</option></select></label>
|
||||
<label><span>Breite</span><input type="number" data-setting="artifact_width" value="${settings?.artifact_width||1024}"></label><label><span>Höhe</span><input type="number" data-setting="artifact_height" value="${settings?.artifact_height||1024}"></label><label><span>Steps (lokale UIs)</span><input type="number" data-setting="artifact_steps" value="${settings?.artifact_steps||28}"></label>
|
||||
<label class="wide"><span>Prompt-Zusatz</span><textarea data-setting-string="artifact_prompt" rows="4">${esc(settings?.artifact_prompt||'')}</textarea></label><label class="wide"><span>Negative Prompt</span><textarea data-setting-string="artifact_negative_prompt" rows="3">${esc(settings?.artifact_negative_prompt||'')}</textarea></label></div>`;
|
||||
const pr=document.querySelector('[data-setting-string="artifact_provider"]'),qu=document.querySelector('[data-setting-string="artifact_quality"]');if(pr)pr.value=settings?.artifact_provider||'local';if(qu)qu.value=settings?.artifact_quality||'medium';
|
||||
}
|
||||
$('savesettings').style.display='inline-block';$('ensuretasks').style.display='none';
|
||||
}
|
||||
function payloadText(a){try{const p=typeof a.payload==='string'?JSON.parse(a.payload):a.payload;return Object.keys(p||{}).length?JSON.stringify(p):'—'}catch{return '—'}}
|
||||
function renderTaskControl(){
|
||||
$('savesettings').style.display='none';$('ensuretasks').style.display='none';if(!selected){$('settingfields').innerHTML='<div class="empty">Links einen Task auswählen.</div>';return}
|
||||
const customStyle=!!String(selected.nft_style_reference||'').trim();
|
||||
$('settingfields').innerHTML=`<div class="control-section task-control"><div class="section-title">TASK ${esc(selected.display_name||selected.id.slice(-12))}</div><div class="task-facts"><span><b>${selected.range_bits}</b> Bit</span><span><b>${selected.paused?'PAUSED':selected.status}</b> Status</span><span><b>${selected.revision}</b> Revision</span></div>
|
||||
<div class="task-config-box"><div class="section-title">TASK-DARSTELLUNG & RIFT ART-DIRECTION</div>${selected.parent_task_id?`<span class="inherit-badge">↳ geerbt von ${esc(String(selected.parent_task_id).slice(-12))}</span>`:'<span class="inherit-badge">ROOT TASK</span>'}<p class="draft-note">Der Folge-Task erbt Anzeigename, Beschreibung, kreative Vorgaben, Ausschlüsse und die Style-Referenz. Entwürfe in Textfeldern bleiben bei Auto-Refresh erhalten.</p>
|
||||
<label class="wide"><span>Anzeigename</span><input id="taskDisplayName" data-draft="taskDisplayName" maxlength="80" value="${esc(selected.display_name||'')}"></label>
|
||||
<label class="wide"><span>Beschreibung für Landing-Page</span><textarea id="taskDescription" data-draft="taskDescription" rows="3" maxlength="1200">${esc(selected.description||'')}</textarea></label>
|
||||
<div class="task-style-admin"><div class="reference-preview has-image"><img id="taskStylePreview" alt="Style-Referenz des Tasks"></div><div class="task-style-controls"><div class="section-title">NFT STYLE-REFERENZ · ${customStyle?'CUSTOM':'DEFAULT'}</div><p class="small">Dieses Bild definiert <b>wie</b> RIFT für diese Task-Serie gerendert wird. Der globale Character Anchor definiert separat <b>wer</b> RIFT ist. Nutzer sehen diese Vorschau auf der Task-Auswahl.</p><p class="small">${customStyle?`Aktuell: <code>${esc(String(selected.nft_style_reference).slice(0,18))}…</code>`:'Kein eigener Style hochgeladen · es wird das eingebettete Default-Style-Bild verwendet.'}</p><input id="taskStyleFile" type="file" accept="image/jpeg,image/png"><div class="task-config-actions style-actions"><button id="uploadTaskStyle">STYLE HOCHLADEN / ERSETZEN</button>${customStyle?'<button id="clearTaskStyle" class="danger-button">AUF DEFAULT ZURÜCK</button>':''}</div></div></div>
|
||||
<label class="wide"><span>Kreative Vorgaben für RIFT-Karten dieses Tasks · optional</span><textarea id="taskNFTPrompt" data-draft="taskNFTPrompt" rows="6" maxlength="8000" placeholder="Leer lassen = automatische Theme-/Outfit-/Szenen-Generierung. Optional z.B. Winter, elegante Streetwear, keine Waffen …">${esc(selected.nft_prompt_instructions||'')}</textarea></label>
|
||||
<label class="wide"><span>Zusätzliche Ausschlüsse · optional</span><textarea id="taskNFTNegative" data-draft="taskNFTNegative" rows="3" maxlength="4000" placeholder="Leer lassen = nur globale RIFT-Regeln. Optional z.B. kein Helm, keine Waffen, kein Schnee …">${esc(selected.nft_negative_prompt||'')}</textarea></label>
|
||||
<div class="task-config-actions"><button id="saveTaskConfig">TASK-KONFIG SPEICHERN</button></div><div class="task-config-box pipeline-test-box"><div class="section-title">LOKALER PIPELINE-TEST</div><p class="small">Erzeugt eine komplette Testkarte <b>ohne OpenAI-Aufruf</b>. Der vorhandene <code>character_anchor.png</code> dient als Mock-Artwork, die Style-Referenz dieses Tasks als Hintergrund. Kartenlayout, Traits, Dateischreiben und SVG-Ausgabe werden lokal durchgespielt. Der echte Task-/Artifact-Status bleibt unverändert.</p><div class="task-config-actions"><button id="runPipelineTest">TEST-KARTE ERZEUGEN · 0 API-TOKENS</button></div></div></div>
|
||||
<div class="section-title">AKTIONEN / SCHEDULER</div><label><span>Aktion</span><select id="actionType" data-draft="actionType"><option value="set_range_bits">Zahlenraum ändern</option><option value="set_intervals">Intervalle ändern</option><option value="clear_intervals">Intervalle auf Defaults</option><option value="pause">Pausieren</option><option value="resume">Fortsetzen</option><option value="reroll">Ziel neu würfeln</option><option value="close">Task beenden</option><option value="regenerate_artifact">NFT-Bild neu erzeugen</option></select></label><div id="actionPayload"></div>
|
||||
<label class="wide"><span>Ausführen am</span><div class="schedule-row"><input id="actionAt" data-draft="actionAt" type="datetime-local"><button id="runAction">JETZT</button><button id="scheduleAction">PLANEN</button></div></label><div class="action-warning" id="actionWarning"></div>
|
||||
<div class="section-title action-history-title">AKTIONSPLAN / AUDIT</div><div class="action-list">${actions.length?actions.map(a=>`<div class="action-item ${esc(a.status)}"><span><b>${esc(actionLabel(a.action_type))}</b><small>${fmtDate(a.execute_at)} · ${esc(payloadText(a))}</small>${a.error?`<small class="danger">${esc(a.error)}</small>`:''}</span><em>${esc(a.status)}</em>${a.status==='pending'?`<button data-cancel-action="${esc(a.id)}">×</button>`:''}</div>`).join(''):'<div class="empty small">Noch keine geplanten Aktionen</div>'}</div></div>`;
|
||||
const styleImg=$('taskStylePreview');loadProtectedImage(`/api/admin/tasks/${selected.id}/style-reference?ts=${Date.now()}`,styleImg,true).catch(()=>{if(styleImg)styleImg.alt='Style-Referenz konnte nicht geladen werden'});
|
||||
const uploadStyle=$('uploadTaskStyle');if(uploadStyle)uploadStyle.onclick=async()=>{const file=$('taskStyleFile')?.files?.[0];if(!file){msg('Bitte zuerst ein JPEG- oder PNG-Stylebild auswählen');return}const fd=new FormData();fd.append('file',file,file.name);uploadStyle.disabled=true;uploadStyle.textContent='STYLE WIRD HOCHGELADEN …';try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'PUT',body:fd},true);msg('Task-Style gespeichert · Folge-Task übernimmt ihn');await load(true,true)}catch(e){msg(e.message);uploadStyle.disabled=false;uploadStyle.textContent='STYLE HOCHLADEN / ERSETZEN'}};
|
||||
const clearStyle=$('clearTaskStyle');if(clearStyle)clearStyle.onclick=async()=>{if(!confirm('Eigenen Task-Style entfernen und wieder den eingebetteten Default-Style verwenden?'))return;try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'DELETE'},true);msg('Task-Style auf Default zurückgesetzt');await load(true,true)}catch(e){msg(e.message)}};
|
||||
const pipelineTest=$('runPipelineTest');if(pipelineTest)pipelineTest.onclick=async()=>{pipelineTest.disabled=true;pipelineTest.textContent='TEST-KARTE WIRD LOKAL ERZEUGT …';try{const r=await api(`/api/admin/tasks/${selected.id}/pipeline-test`,{method:'POST'},true);const popup=window.open('','_blank');const token=localStorage.getItem(adminTokenKey),resp=await fetch(r.url,{headers:{Authorization:'Bearer '+token}});if(!resp.ok)throw new Error(await responseError(resp,'Testkarte konnte nicht geöffnet werden'));const blob=await resp.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000);msg('Lokale Testkarte erzeugt · 0 API-Calls · $0.00')}catch(e){msg(e.message)}finally{pipelineTest.disabled=false;pipelineTest.textContent='TEST-KARTE ERZEUGEN · 0 API-TOKENS'}};
|
||||
const renderPayload=()=>{const type=$('actionType').value,host=$('actionPayload');if(type==='set_range_bits')host.innerHTML=`<label><span>Task-Zahlenraum (Bit)</span><input id="actionBits" data-draft="actionBits" type="number" min="8" max="128" value="${selected.range_bits}"></label><label><span>Änderungsmodus</span><select id="actionMode" data-draft="actionMode"><option value="preserve">preserve · bestehendes Ziel</option><option value="reroll">reroll · neues Ziel</option></select></label><p class="small">Preserve erhält Seed/Sequenzen und re-skaliert Scores mathematisch. Reroll setzt Scores/Sequenzen zurück.</p>`;else if(type==='set_intervals')host.innerHTML=`<label><span>Server Minimum (s)</span><input id="actionServer" data-draft="actionServer" type="number" min="1" max="3600" value="${selected.guess_min_interval_sec??settings.guess_min_interval_sec}"></label><label><span>Client Submit (s)</span><input id="actionClient" data-draft="actionClient" type="number" min="2" max="7200" value="${selected.client_submit_interval_sec??settings.client_submit_interval_sec}"></label>`;else host.innerHTML=`<p class="small">${type==='reroll'?'Achtung: neues Ziel und neuer öffentlicher Seed; aktuelle Scores werden auf 0 gesetzt.':type==='close'?'Beendet den Task. Der automatisch erzeugte Folge-Task erbt Zahlenraum, Intervalle, Darstellung, RIFT-Prompt und Style-Referenz.':type==='regenerate_artifact'?'Nur für abgeschlossene Tasks: setzt das Artifact wieder auf queued.':'Keine weiteren Parameter.'}</p>`};
|
||||
const defaultAt=new Date(Date.now()+5*60*1000);defaultAt.setMinutes(defaultAt.getMinutes()-defaultAt.getTimezoneOffset());$('actionAt').value=defaultAt.toISOString().slice(0,16);restoreDraft();renderPayload();restoreDraft();$('actionType').onchange=()=>{renderPayload();restoreDraft();captureDraft()};
|
||||
$('saveTaskConfig').onclick=async()=>{try{captureDraft();await api(`/api/admin/tasks/${selected.id}/config`,{method:'PUT',body:JSON.stringify({display_name:$('taskDisplayName').value,description:$('taskDescription').value,nft_prompt_instructions:$('taskNFTPrompt').value,nft_negative_prompt:$('taskNFTNegative').value})},true);clearDraftKeys(['taskDisplayName','taskDescription','taskNFTPrompt','taskNFTNegative']);msg('Task-Konfiguration gespeichert · Folge-Task übernimmt sie');await load(true,true)}catch(e){msg(e.message)}};
|
||||
const submitAction=async runNow=>{try{captureDraft();const type=$('actionType').value,payload={};if(type==='set_range_bits'){payload.bits=Number($('actionBits').value);payload.mode=$('actionMode').value}else if(type==='set_intervals'){payload.server_min_interval_sec=Number($('actionServer').value);payload.client_submit_interval_sec=Number($('actionClient').value)}const execute_at=runNow?null:new Date($('actionAt').value).toISOString();await api(`/api/admin/tasks/${selected.id}/actions`,{method:'POST',body:JSON.stringify({action_type:type,payload,execute_at})},true);msg(runNow?'Aktion ausgeführt':'Aktion geplant');await load(true,true)}catch(e){msg(e.message)}};
|
||||
$('runAction').onclick=()=>submitAction(true);$('scheduleAction').onclick=()=>submitAction(false);document.querySelectorAll('[data-cancel-action]').forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/actions/${b.dataset.cancelAction}/cancel`,{method:'POST'},true);await load(true,true)}catch(e){msg(e.message)}});
|
||||
}
|
||||
// The control plane is intentionally NOT rebuilt by the 3-second telemetry poll.
|
||||
// Replacing <select> nodes while a native dropdown is open makes the browser close
|
||||
// the popup and also destroys caret/selection/scroll state in text fields. Drafts
|
||||
// protect values, but they cannot protect native UI state. Only explicit user
|
||||
// navigation/actions are allowed to rebuild this subtree.
|
||||
function refreshArtifactUsageTelemetry(){
|
||||
if(!$('artifactCostToday'))return;const u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[],usd=(n,d=4)=>Number(n||0).toLocaleString('de-DE',{style:'currency',currency:'USD',minimumFractionDigits:d,maximumFractionDigits:d});
|
||||
$('artifactCostToday').textContent=usd(u.today_cost_usd,4);$('artifactCostTodayMeta').textContent=`${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}`;$('artifactAvgCardCost').textContent=usd(u.avg_card_cost_usd,5);$('artifactAvgCardCostMeta').textContent=`${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen`;$('artifactCostPer1000').textContent=usd(u.cost_per_1000_usd,2);
|
||||
$('artifactUsageRows').innerHTML=recent.length?recent.map(r=>`<tr><td>${fmtDate(r.created_at)}</td><td>${r.kind==='character_anchor'?'ANCHOR':'KARTE'}</td><td>${esc(r.model||'—')}<small>${esc(r.quality||'—')} · ${esc(r.size||'—')}</small></td><td>${Number(r.input_text_tokens||0).toLocaleString('de-DE')} T + ${Number(r.input_image_tokens||0).toLocaleString('de-DE')} I → ${Number(r.output_tokens||0).toLocaleString('de-DE')}</td><td>${r.estimated_cost_usd==null?'—':usd(r.estimated_cost_usd,5)}</td><td><small>${esc(r.request_id?String(r.request_id).slice(-16):'—')}</small></td></tr>`).join(''):'<tr><td colspan="6" class="empty small">Noch keine OpenAI-Bildgenerierung protokolliert.</td></tr>';
|
||||
}
|
||||
function renderSettings(){setActive('tabRuntime',tab==='runtime');setActive('tabTask',tab==='task');setActive('tabArtifact',tab==='artifact');if(tab==='runtime')renderRuntime();else if(tab==='artifact')renderArtifact();else renderTaskControl();restoreDraft()}
|
||||
function renderMap(){points=Array.isArray(points)?points:[];const min=Number($('adminMinScore')?.value||0),needle=String($('adminClientFilter')?.value||'').trim().toLowerCase(),filtered=points.filter(p=>Number(p.score||0)>=min&&(!needle||String(p.client_id||'').toLowerCase().includes(needle)));$('adminpoints').textContent=`${filtered.length.toLocaleString('de-DE')} / ${points.length.toLocaleString('de-DE')}`;$('selectedtask').textContent=selected?.id||'Task wählen';$('adminbits').textContent=selected?.range_bits??'—';$('winner').textContent=selected?.winner_client_id?`Winner ${shortID(selected.winner_client_id,12)}`:selected?.paused?'PAUSED':'';map.update(filtered,'',+$('adminmaxnodes').value)}
|
||||
async function refreshSelected(refreshControls=false){if(!selected){points=[];actions=[];renderMap();if(refreshControls)renderSettings();return}try{const [ps,as]=await Promise.all([api(`/api/admin/tasks/${selected.id}/points?limit=100000`,{},true),api(`/api/admin/tasks/${selected.id}/actions?limit=100`,{},true)]);points=Array.isArray(ps)?ps:[];actions=Array.isArray(as)?as:[];renderMap();if(refreshControls)renderSettings()}catch(e){msg(e.message)}}
|
||||
async function load(keepMessage=false,refreshControls=false){if(loading)return;loading=true;try{const status=$('statusfilter').value,q=$('taskquery').value,dayStart=new Date();dayStart.setHours(0,0,0,0);const [ts,st,ov,pv,pf,au]=await Promise.all([api(`/api/admin/tasks?status=${encodeURIComponent(status)}&q=${encodeURIComponent(q)}&limit=300`,{},true),api('/api/admin/settings',{},true),api('/api/admin/overview',{},true),api('/api/admin/artifact/providers',{},true),api('/api/admin/performance',{},true),api(`/api/admin/artifact/usage?day_start_ms=${dayStart.getTime()}`,{},true)]);tasks=Array.isArray(ts)?ts:[];settings=st||{};providers=pv||{};artifactUsage=au||{};refreshArtifactUsageTelemetry();renderOverview(ov||{});renderPerformance(pf||{});if(selected){selected=tasks.find(t=>t.id===selected.id)||selected}renderTasks();if(selected)await refreshSelected(refreshControls);else if(refreshControls)renderSettings()}catch(e){if(e.status===401){localStorage.removeItem(adminTokenKey);location.reload();return}msg(e.message||'Laden fehlgeschlagen')}finally{loading=false}}
|
||||
async function openTask(t){captureDraft();selected=t;draft.selectedTaskId=t?.id||'';saveDraft();await refreshSelected(true);renderTasks();if(document.documentElement.classList.contains('mobile-mode'))setAdminPanel('map')}
|
||||
$('filter').onclick=()=>{saveDraft();load()};$('statusfilter').onchange=()=>{saveDraft();load()};$('taskquery').addEventListener('input',saveDraft);$('taskquery').addEventListener('keydown',e=>{if(e.key==='Enter'){saveDraft();load()}});$('adminmaxnodes').oninput=e=>{$('adminmaxvalue').textContent=Number(e.target.value).toLocaleString('de-DE');renderMap()};$('adminMinScore').oninput=renderMap;$('adminClientFilter').oninput=renderMap;
|
||||
$('adminProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('adminProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('adminProximity',map.proximityFocus)};$('adminRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('adminRotate',map.autoRotate)};$('adminEdges').onclick=()=>{map.edges=!map.edges;setActive('adminEdges',map.edges)};$('adminShells').onclick=()=>{map.shells=!map.shells;setActive('adminShells',map.shells)};$('adminLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('adminLOD',map.lodEnabled)};$('adminEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('adminEco',map.eco)};$('adminReset').onclick=()=>map.resetView();
|
||||
$('tabRuntime').onclick=()=>{captureDraft();tab='runtime';saveDraft();renderSettings()};$('tabTask').onclick=()=>{captureDraft();tab='task';saveDraft();renderSettings()};$('tabArtifact').onclick=()=>{captureDraft();tab='artifact';saveDraft();renderSettings()};
|
||||
$('savesettings').onclick=async()=>{try{captureDraft();const out={...settings};document.querySelectorAll('[data-setting]').forEach(i=>out[i.dataset.setting]=Number(i.value));document.querySelectorAll('[data-setting-string]').forEach(i=>out[i.dataset.settingString]=i.value);settings=await api('/api/admin/settings',{method:'PUT',body:JSON.stringify(out)},true);if(draft.fields)delete draft.fields[draftScope()];saveDraft();msg('gespeichert');renderSettings()}catch(e){msg(e.message)}};
|
||||
$('ensuretasks').onclick=async()=>{try{await api('/api/admin/tasks/ensure',{method:'POST'},true);msg('aktive Tasks sichergestellt');await load(true,true)}catch(e){msg(e.message)}};
|
||||
$('adminlogout').onclick=()=>{localStorage.removeItem(adminTokenKey);location.reload()};
|
||||
$('settingfields').addEventListener('input',captureDraft);$('settingfields').addEventListener('change',captureDraft);if(draft.filters){$('statusfilter').value=draft.filters.status||'';$('taskquery').value=draft.filters.q||''}await load();const first=tasks.find(t=>t.id===draft.selectedTaskId)||(tasks.find(t=>t.status==='active')||tasks[0]);if(first)await openTask(first);else renderSettings();poll=setInterval(()=>load(true,false),3000);addEventListener('beforeunload',()=>{captureDraft();clearInterval(poll);window.removeEventListener('neuralhunt-mobile-mode',syncAdminMobile);map.destroy()},{once:true});
|
||||
}
|
||||
|
||||
applyMobileMode(mobileModeEnabled());
|
||||
const mobileMedia=matchMedia('(max-width: 850px)');mobileMedia.addEventListener?.('change',()=>{if(localStorage.getItem(mobileModeKey)===null)applyMobileMode(autoMobileMode())});
|
||||
const path=location.pathname;
|
||||
(path.startsWith('/admin')?runAdmin():path.startsWith('/leaderboard')?runLeaderboard():runUser()).catch(e=>{app.innerHTML=`<pre style="padding:2rem;color:#ff9bad">${esc(e.stack||e.message||e)}</pre>`});
|
||||
15
internal/webui/dist/index.html
vendored
Normal file
15
internal/webui/dist/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#02050b" />
|
||||
<title>Neural Hunt</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
100
internal/webui/dist/styles.css
vendored
Normal file
100
internal/webui/dist/styles.css
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
:root{color-scheme:dark;--bg:#02050b;--panel:rgba(5,12,24,.68);--line:rgba(133,200,255,.16);--text:#eaf7ff;--muted:#7792a8;--cyan:#52e7ff;--blue:#4b7bff;--violet:#b775ff;--amber:#ffb452;--green:#5dffbd;--red:#ff5f88}
|
||||
*{box-sizing:border-box}html,body,#app{margin:0;width:100%;height:100%;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--text)}body{overflow:hidden;background:radial-gradient(circle at 50% 44%,#07192d 0,#02050b 58%,#010207 100%)}button,.button,input,select{font:inherit}button,.button{border:1px solid rgba(82,231,255,.2);background:rgba(82,231,255,.055);color:#a9dbe8;border-radius:10px;padding:8px 10px;font-weight:700;letter-spacing:.06em;font-size:10px;cursor:pointer;text-decoration:none}button:hover,.button:hover{background:rgba(82,231,255,.12);color:#e5fbff}button:disabled{opacity:.42;cursor:not-allowed}input,select{border:1px solid rgba(133,200,255,.15);background:rgba(2,8,17,.78);color:var(--text);border-radius:9px;padding:8px;outline:0}.glass{border:1px solid var(--line);background:linear-gradient(145deg,rgba(9,22,40,.82),rgba(3,8,17,.61));backdrop-filter:blur(18px);box-shadow:inset 0 1px rgba(255,255,255,.035),0 18px 60px rgba(0,0,0,.28)}
|
||||
.stage,.neural-map{position:fixed;inset:0}.brain-canvas{display:block;width:100%;height:100%;cursor:grab;touch-action:none}.brain-canvas:active{cursor:grabbing}.vignette{pointer-events:none;position:fixed;inset:0;background:radial-gradient(circle at center,transparent 47%,rgba(0,0,0,.72) 100%),linear-gradient(rgba(82,231,255,.015) 1px,transparent 1px);background-size:auto,100% 4px;mix-blend-mode:screen}.tooltip{position:absolute;z-index:20;pointer-events:none;background:rgba(2,8,16,.94);border:1px solid rgba(82,231,255,.25);border-radius:10px;padding:8px 10px;font-size:10px;color:#dcecf5;max-width:250px;box-shadow:0 0 30px rgba(0,0,0,.4)}.tooltip strong{display:block;font-size:11px;color:#eef9ff;margin-bottom:3px}.tooltip small{display:block;color:#84a0b3;line-height:1.4}.hidden{display:none!important}
|
||||
.topbar{position:fixed;z-index:5;left:18px;right:18px;top:18px;height:64px;border-radius:18px;display:grid;grid-template-columns:minmax(260px,1fr) auto minmax(330px,1fr);gap:18px;align-items:center;padding:0 20px}.brand{display:flex;gap:13px;align-items:center}.brand strong{display:block;letter-spacing:.18em;font-size:13px}.brand small{display:block;color:var(--muted);letter-spacing:.06em;font-size:9px;margin-top:4px}.mark{position:relative;width:34px;height:34px;display:block;flex:0 0 auto}.mark:before,.mark:after,.mark i{content:"";position:absolute;border:1px solid var(--cyan);border-radius:50%;box-shadow:0 0 14px rgba(82,231,255,.7)}.mark:before{width:8px;height:8px;left:13px;top:13px;background:var(--cyan)}.mark:after{width:28px;height:28px;left:2px;top:2px;opacity:.35}.mark i{width:4px;height:4px}.mark i:nth-child(1){left:1px;top:15px}.mark i:nth-child(2){right:1px;top:5px}.mark i:nth-child(3){right:3px;bottom:3px}.metrics{display:flex;gap:17px;align-items:center;justify-self:end;font-size:10px;color:var(--muted);letter-spacing:.05em}.metrics b{font-size:14px;color:var(--text);font-variant-numeric:tabular-nums}.state{border-left:1px solid var(--line);padding-left:16px;color:var(--green);white-space:nowrap}.state i{display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor;margin-right:6px;animation:pulse 1.6s infinite}@keyframes pulse{50%{opacity:.35;transform:scale(.72)}}
|
||||
.mode-status{justify-self:center;display:flex;align-items:center;gap:9px;min-width:168px;padding:7px 12px;border:1px solid rgba(82,231,255,.16);border-radius:12px;background:rgba(82,231,255,.045);transition:.3s}.mode-status>i{width:9px;height:9px;border-radius:50%;background:var(--cyan);box-shadow:0 0 16px var(--cyan);animation:modeBreath 2.8s ease-in-out infinite}.mode-status b{display:block;font-size:10px;letter-spacing:.16em}.mode-status small{display:block;margin-top:2px;font-size:9px;color:#6f8da2}.mode-status.thinking{border-color:rgba(255,180,82,.34);background:rgba(255,180,82,.08)}.mode-status.thinking>i{background:var(--amber);box-shadow:0 0 18px var(--amber);animation-duration:.72s}.mode-status.researching{border-color:rgba(93,255,189,.34);background:rgba(93,255,189,.08)}.mode-status.researching>i{background:var(--green);box-shadow:0 0 18px var(--green);animation-duration:.82s}@keyframes modeBreath{50%{opacity:.42;transform:scale(.72)}}
|
||||
.signal-panel{position:fixed;z-index:5;right:18px;top:98px;bottom:18px;width:320px;border-radius:18px;padding:14px;display:flex;flex-direction:column;overflow:hidden}.panel-title{height:34px;display:flex;align-items:center;justify-content:space-between;color:#9fb6c8;font-size:10px;font-weight:800;letter-spacing:.19em;border-bottom:1px solid var(--line);margin-bottom:10px}.chip{padding:4px 7px;border-radius:99px;background:rgba(82,231,255,.08);color:var(--cyan);letter-spacing:.08em}.rank-hero{padding:12px 2px 14px;border-bottom:1px solid var(--line)}.rank-hero small{display:block;color:var(--muted);font-size:9px;letter-spacing:.16em}.rank-hero strong{display:block;font-size:46px;line-height:1;margin-top:5px;letter-spacing:-.04em}.signal-metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;padding:11px 0;border-bottom:1px solid var(--line)}.signal-metrics div{padding:8px;border:1px solid rgba(133,200,255,.1);border-radius:10px;background:rgba(255,255,255,.018)}.signal-metrics span{display:block;color:#668397;font-size:8px;text-transform:uppercase;letter-spacing:.1em}.signal-metrics b{display:block;margin-top:5px;font-size:15px}.identity-block{padding:12px 0;border-bottom:1px solid var(--line)}.eyebrow{display:block;color:#7f9bae;font-size:8px;font-weight:800;letter-spacing:.14em;margin-bottom:6px}.identity-block code{display:block;color:#9dddeb;font-size:9px;word-break:break-all;max-height:42px;overflow:hidden}.identity-actions{display:flex;gap:6px;margin-top:9px}.identity-actions>*{flex:1;text-align:center}.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:8px}.chips span{font-size:8px;padding:3px 6px;border:1px solid rgba(93,255,189,.18);background:rgba(93,255,189,.06);color:#baffde;border-radius:999px}.leaderboard-head{display:flex;justify-content:space-between;align-items:center;padding:12px 0 7px;font-size:9px;font-weight:800;letter-spacing:.13em}.leaderboard-head small{color:#657f91;font-size:7px;font-weight:500;letter-spacing:.04em}.leaderboard{overflow:auto;min-height:0;flex:1;padding-right:2px}.leader-row{display:grid;grid-template-columns:24px 1fr auto;gap:8px;align-items:center;padding:7px 5px;border-bottom:1px solid rgba(133,200,255,.08);font-size:9px}.leader-row.self{background:rgba(82,231,255,.055);border-radius:8px}.leader-rank{color:#607c8f;text-align:center}.leader-row span:nth-child(2){display:grid}.leader-row b{font-size:9px;color:#d9f5ff}.leader-row small{font-size:7px;color:#667f90;margin-top:2px}.leader-row strong{font-size:11px;color:#7eeaff}
|
||||
.node-limit{position:fixed;z-index:5;left:18px;bottom:18px;width:278px;border-radius:14px;padding:9px 11px;display:grid;grid-template-columns:auto 1fr auto;gap:9px;align-items:center}.node-limit span{font-size:8px;font-weight:800;letter-spacing:.14em;color:#7c98aa}.node-limit input{padding:0;width:100%}.node-limit b{font-size:10px;font-variant-numeric:tabular-nums}.dock{position:fixed;z-index:6;bottom:18px;left:50%;transform:translateX(-50%);display:flex;gap:5px;border-radius:15px;padding:6px;max-width:calc(100vw - 650px);overflow:auto}.dock button,.dock-link{border:1px solid transparent;background:transparent;color:#6e8799;border-radius:11px;padding:9px 10px;font-weight:700;letter-spacing:.07em;font-size:9px;cursor:pointer;white-space:nowrap;text-decoration:none}.dock button.active{color:var(--cyan);border-color:rgba(82,231,255,.18);background:rgba(82,231,255,.07)}.dock button:hover,.dock-link:hover{background:rgba(82,231,255,.08);color:#c8f7ff}.dock-link{color:#a9dbe8;border-color:rgba(82,231,255,.13)}
|
||||
.login{display:grid;place-items:center;background:radial-gradient(circle at 50% 40%,#07192d,#010207 70%)}.login-card{width:min(390px,92vw);border-radius:18px;padding:22px;display:grid;gap:12px}.login-card .brand{margin-bottom:8px}.login-card a{color:var(--cyan);font-size:10px;text-decoration:none}.danger{color:#ff9bad}.statusline{min-height:1.2em}
|
||||
.admin{background:#01040a;overflow:hidden}.admin-top{position:fixed;z-index:8;left:12px;right:12px;top:12px;height:58px;border-radius:16px;padding:0 16px;display:flex;align-items:center;justify-content:space-between}.admin-actions{display:flex;gap:8px;align-items:center}.admin-actions a{color:#8cecff;text-decoration:none;font-size:9px;font-weight:800;letter-spacing:.1em}.overviewStrip{position:fixed;z-index:7;left:12px;right:12px;top:78px;height:44px;border-radius:13px;padding:0 14px;display:flex;gap:20px;align-items:center;overflow:auto}.overviewStrip span{font-size:9px;color:#7892a5;white-space:nowrap}.overviewStrip b{color:#e4f9ff;font-size:14px;margin-right:4px}.adminGrid{position:fixed;left:12px;right:12px;top:130px;bottom:12px;display:grid;grid-template-columns:380px minmax(420px,1fr) 300px;gap:10px}.tasklist,.settings,.adminMap{border-radius:16px;min-height:0}.tasklist,.settings{padding:13px;overflow:auto}.toolbar{display:grid;grid-template-columns:105px 1fr auto;gap:6px}.toolbar input,.toolbar select{width:100%;font-size:9px}.tasks{display:grid;gap:6px;margin-top:10px}.tasks>button{display:grid;grid-template-columns:1.25fr 1fr 76px;gap:8px;text-align:left;align-items:center;padding:9px}.tasks>button span{display:grid;min-width:0}.tasks>button b{overflow:hidden;text-overflow:ellipsis}.tasks small{color:#6f8b9d;font-size:7px;margin-top:4px}.tasks .selected{border-color:rgba(82,231,255,.6);box-shadow:0 0 0 1px rgba(82,231,255,.15) inset,0 0 22px rgba(82,231,255,.05)}.task-state{font-style:normal;font-size:7px;text-transform:uppercase;letter-spacing:.08em;color:#8db0c2}.task-state.active{color:var(--green)}.task-state.completed{color:var(--amber)}.task-state.closed{color:#ff8ba0}.artifactLinks a{color:#6feeff;text-decoration:none}.adminMap{position:relative;overflow:hidden}.adminMap .neural-map{position:absolute;inset:0}.map-overlay{position:absolute;z-index:6;left:12px;right:12px;border:1px solid rgba(133,200,255,.14);background:rgba(3,10,19,.7);backdrop-filter:blur(12px);border-radius:12px;padding:8px 10px;display:flex;gap:14px;align-items:center;font-size:9px;color:#7894a6}.map-overlay.top{top:12px}.map-overlay.bottom{bottom:12px}.map-overlay label{display:flex;gap:8px;align-items:center;min-width:260px}.map-overlay input[type=range]{padding:0;min-width:130px}.map-overlay button{padding:7px 8px}.map-overlay button.active{color:var(--cyan);border-color:rgba(82,231,255,.4);background:rgba(82,231,255,.08)}.map-overlay .dangerBtn{margin-left:auto;border-color:rgba(255,95,136,.36);color:#ff9bad}.settings label{display:grid;grid-template-columns:1fr 82px;gap:8px;align-items:center;margin:8px 0;font-size:9px;color:#8ba4b5}.settings .actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:13px}.small{font-size:8px;color:#7892a4}.empty{color:#6c8798;text-align:center;padding:18px;font-size:9px}
|
||||
@media(max-width:1280px){.topbar{grid-template-columns:1fr auto}.mode-status{display:none}.metrics{grid-column:2}.metrics span:nth-child(-n+2){display:none}.adminGrid{grid-template-columns:310px 1fr}.settings{display:none}.dock{max-width:calc(100vw - 390px)}}
|
||||
@media(max-width:850px){.topbar{left:9px;right:9px;top:9px;height:56px;padding:0 12px}.brand small{display:none}.metrics span:not(.state){display:none}.signal-panel{right:9px;left:9px;top:auto;bottom:68px;width:auto;height:37vh}.node-limit{left:9px;bottom:9px;width:235px}.dock{left:auto;right:9px;transform:none;bottom:9px;max-width:calc(100vw - 260px)}.dock button:nth-child(2),.dock button:nth-child(3),.dock button:nth-child(4){display:none}.adminGrid{grid-template-columns:1fr;overflow:auto}.tasklist{display:none}.adminMap{height:100%}.map-overlay.bottom{overflow:auto}.map-overlay label{min-width:210px}.overviewStrip{display:none}.adminGrid{top:78px}.admin-top{top:9px;left:9px;right:9px}}
|
||||
.map-overlay.top .inline-filter{margin-left:auto;display:flex;align-items:center;gap:5px;white-space:nowrap;min-width:auto}.map-overlay.top .inline-filter input{width:58px;padding:5px}.map-overlay.top .client-filter{width:130px;padding:5px 7px;font-size:8px}
|
||||
|
||||
/* Proximity-first visualization */
|
||||
.distance-legend{position:fixed;z-index:5;left:50%;top:98px;transform:translateX(-50%);border-radius:999px;padding:7px 12px;display:flex;align-items:center;gap:8px;font-size:8px;color:#7291a6;letter-spacing:.07em;pointer-events:none;white-space:nowrap}.distance-legend b{color:#c8f7ff;font-size:8px;letter-spacing:.12em}.distance-legend span{font-variant-numeric:tabular-nums}.distance-legend i{width:86px;height:4px;border-radius:999px;background:linear-gradient(90deg,#4b7bff,#52e7ff,#5dffbd,#ffb452,#ff6fbc);box-shadow:0 0 12px rgba(82,231,255,.2)}
|
||||
.proximity-mini{padding:9px 0;border-bottom:1px solid var(--line)}.proximity-mini .leaderboard-head{padding:0 0 7px}.proximity-row{display:grid;grid-template-columns:30px 1fr 40px;gap:7px;align-items:center;padding:3px 2px;font-size:8px;color:#6e8a9d}.proximity-row>div{height:5px;background:rgba(132,198,230,.08);border-radius:99px;overflow:hidden}.proximity-row>div i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,#4b7bff,#52e7ff,#5dffbd,#ffb452,#ff6fbc);box-shadow:0 0 8px rgba(82,231,255,.22)}.proximity-row b{text-align:right;color:#d9f7ff;font-variant-numeric:tabular-nums}.proximity-row.self{color:#fff}.proximity-row.self b{color:#fff}.leaderboard-head a{color:var(--cyan);font-size:7px;text-decoration:none;letter-spacing:.08em}
|
||||
|
||||
/* Realtime public leaderboard */
|
||||
.leaderboard-page{min-height:100%;height:auto;overflow:auto;background:radial-gradient(circle at 50% 15%,#081d31 0,#02050b 52%,#010207 100%);padding:96px 18px 40px}.leaderboard-top{position:fixed;z-index:20;left:18px;right:18px;top:18px;height:64px;border-radius:18px;padding:0 20px;display:flex;align-items:center;justify-content:space-between}.leaderboard-nav{display:flex;gap:8px}.leaderboard-nav a{color:#9befff;text-decoration:none;font-size:9px;font-weight:800;letter-spacing:.1em;padding:8px 10px;border:1px solid rgba(82,231,255,.16);border-radius:10px}.lb-main{max-width:1400px;margin:0 auto;display:grid;gap:18px}.lb-hero{display:grid;grid-template-columns:1fr minmax(380px,.8fr);gap:18px;align-items:end;padding:18px 4px}.lb-title h1{font-size:34px;margin:5px 0 7px;letter-spacing:-.03em}.lb-title p{max-width:650px;color:#7995a8;font-size:12px;line-height:1.6;margin:0}.lb-controls{border-radius:16px;padding:12px;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center}.segmented{display:flex;gap:5px}.segmented button.active,.settings-tabs button.active{color:var(--cyan);border-color:rgba(82,231,255,.45);background:rgba(82,231,255,.09)}.lb-controls input{min-width:0;width:100%}.lb-controls>span{font-size:8px;color:var(--green);white-space:nowrap}.lb-podium{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.podium{border-radius:16px;padding:18px;display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center}.podium>span{font-size:20px;color:#7796aa}.podium>b{font-size:12px;letter-spacing:.08em}.podium>strong{font-size:24px;color:#8ff2ff}.podium>small{grid-column:2/-1;color:#708fa3;font-size:9px}.podium.rank-1{border-color:rgba(255,180,82,.38)}.podium.rank-1>strong{color:#ffd18e}.lb-table-wrap{border-radius:18px;padding:14px}.lb-table-head{display:flex;justify-content:space-between;padding:3px 4px 12px;border-bottom:1px solid var(--line);color:#7995a7;font-size:9px}.lb-table{display:grid}.lb-row{display:grid;grid-template-columns:56px minmax(220px,1fr) 110px 110px 80px 120px;gap:12px;align-items:center;padding:12px 7px;border-bottom:1px solid rgba(133,200,255,.08);font-size:10px}.lb-row:hover{background:rgba(82,231,255,.025)}.lb-rank{font-size:14px;color:#678699}.lb-id{display:grid;grid-template-columns:10px 1fr;align-items:center;column-gap:8px}.lb-id small{grid-column:2;color:#637f92;font-size:8px;margin-top:3px}.presence{width:7px;height:7px;border-radius:50%;border:1px solid #60798b}.presence.on{background:var(--green);border-color:var(--green);box-shadow:0 0 10px rgba(93,255,189,.6)}.lb-row>strong{font-size:13px;color:#bdefff;font-variant-numeric:tabular-nums}
|
||||
|
||||
/* Admin control plane additions */
|
||||
.adminGrid{grid-template-columns:380px minmax(420px,1fr) 370px}.settings-tabs{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;margin:0 0 12px}.settings-tabs button{padding:7px 4px;font-size:8px}.section-title{font-size:8px;font-weight:800;letter-spacing:.16em;color:#8ea9bb;margin:10px 0 8px;padding-bottom:7px;border-bottom:1px solid rgba(133,200,255,.1)}.control-section>label,.task-control>label{grid-template-columns:1fr 120px}.control-section label.wide{display:block}.control-section label.wide>span{display:block;margin-bottom:5px}.control-section textarea{width:100%;resize:vertical;min-height:64px;border:1px solid rgba(133,200,255,.15);background:rgba(2,8,17,.78);color:var(--text);border-radius:9px;padding:8px;outline:0;font:inherit;font-size:9px}.control-section select{width:120px}.provider-status{display:flex;gap:5px;flex-wrap:wrap;margin:7px 0 12px}.provider-status span{font-size:7px;padding:4px 6px;border-radius:999px;border:1px solid rgba(133,200,255,.13);color:#708b9d}.provider-status span.ready{color:#a8ffda;border-color:rgba(93,255,189,.24);background:rgba(93,255,189,.05)}.provider-status span.off{color:#8b7481;border-color:rgba(255,95,136,.14)}.task-facts{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;margin:7px 0 12px}.task-facts span{border:1px solid rgba(133,200,255,.1);border-radius:9px;padding:7px;color:#728da0;font-size:7px}.task-facts b{display:block;color:#dff8ff;font-size:11px;margin-bottom:3px}.schedule-row{display:grid;grid-template-columns:1fr auto auto;gap:5px}.schedule-row input{min-width:0;width:100%;font-size:8px}.action-history-title{margin-top:16px}.action-list{display:grid;gap:5px}.action-item{display:grid;grid-template-columns:1fr auto auto;gap:7px;align-items:center;padding:7px;border:1px solid rgba(133,200,255,.1);border-radius:9px;background:rgba(255,255,255,.012)}.action-item span{display:grid;min-width:0}.action-item b{font-size:8px;color:#d8f4ff}.action-item small{font-size:7px;color:#668396;margin-top:3px;word-break:break-word}.action-item em{font-style:normal;font-size:7px;text-transform:uppercase;color:#7796aa}.action-item.done em{color:var(--green)}.action-item.error em{color:var(--red)}.action-item.pending em{color:var(--amber)}.action-item button{padding:3px 6px;color:#ff9bad;border-color:rgba(255,95,136,.18)}.action-warning{min-height:12px;color:#ffbe7c;font-size:7px}.tasks>button{grid-template-columns:1.1fr 1.25fr 72px}.tasks .task-state{font-weight:800}.tasks .task-state.active{color:var(--green)}
|
||||
|
||||
@media(max-width:1500px){.adminGrid{grid-template-columns:330px minmax(420px,1fr) 340px}.signal-panel{width:300px}.dock{max-width:calc(100vw - 620px)}}
|
||||
@media(max-width:1180px){.lb-hero{grid-template-columns:1fr}.lb-podium{grid-template-columns:1fr}.lb-row{grid-template-columns:48px minmax(190px,1fr) 90px 90px 60px 90px}.distance-legend{display:none}}
|
||||
@media(max-width:850px){.leaderboard-page{padding:78px 9px 24px}.leaderboard-top{left:9px;right:9px;top:9px;height:56px;padding:0 12px}.leaderboard-nav a:last-child{display:none}.lb-title h1{font-size:26px}.lb-controls{grid-template-columns:1fr}.lb-podium{display:none}.lb-table-wrap{overflow:auto}.lb-row{min-width:700px}.proximity-mini{display:none}}
|
||||
|
||||
/* Keep the task control plane reachable on laptop widths. The earlier admin
|
||||
breakpoint hid .settings entirely, which made scheduled task actions
|
||||
inaccessible below 1280px. */
|
||||
@media(max-width:1280px) and (min-width:1051px){
|
||||
.adminGrid{grid-template-columns:260px minmax(380px,1fr) 315px}
|
||||
.settings{display:block}
|
||||
.tasklist{display:block}
|
||||
}
|
||||
@media(max-width:1050px) and (min-width:851px){
|
||||
.adminGrid{grid-template-columns:minmax(380px,1fr) 315px}
|
||||
.tasklist{display:none}
|
||||
.settings{display:block}
|
||||
}
|
||||
|
||||
/* V2.2 mobile mode + public watermarked NFT gallery */
|
||||
.mobile-quick,.admin-mobile-tabs{display:none}
|
||||
|
||||
/* New leaderboard row structure. */
|
||||
.lb-row{grid-template-columns:56px minmax(220px,1fr) minmax(360px,.9fr) 86px}
|
||||
.lb-stats{display:grid;grid-template-columns:repeat(4,minmax(72px,1fr));gap:7px}
|
||||
.lb-stats>span{display:grid;gap:3px;padding:6px 7px;border:1px solid rgba(133,200,255,.08);border-radius:9px;background:rgba(255,255,255,.012)}
|
||||
.lb-stats small{font-size:7px;color:#668397;letter-spacing:.09em}.lb-stats strong{font-size:12px;color:#bdefff;font-variant-numeric:tabular-nums}
|
||||
.lb-nft-cell{display:flex;justify-content:center;align-items:center}.lb-nft-button{padding:0;width:66px;height:66px;position:relative;overflow:hidden;border-radius:10px;background:#02060d}
|
||||
.lb-nft-button img{width:100%;height:100%;display:block;object-fit:cover}.lb-nft-button em{position:absolute;left:3px;right:3px;bottom:3px;padding:2px 3px;border-radius:5px;background:rgba(0,0,0,.67);font-style:normal;font-size:6px;letter-spacing:.07em;color:#fff}
|
||||
|
||||
.nft-showcase{display:grid;gap:10px;padding:2px 0 5px}.nft-showcase-head{display:flex;align-items:end;justify-content:space-between;gap:14px}.nft-showcase-head h2{font-size:18px;margin:0}.nft-showcase-head>span{font-size:8px;color:#708da1}.nft-showcase-head>span b{color:#dff8ff}
|
||||
.nft-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px}.nft-card{padding:8px;text-align:left;display:grid;gap:8px;border-radius:14px;background:rgba(5,13,24,.62)}.nft-card:hover{border-color:rgba(82,231,255,.46)}.nft-card>span:last-child{display:grid;gap:3px;min-width:0}.nft-card b{font-size:9px;color:#def8ff;overflow:hidden;text-overflow:ellipsis}.nft-card small{font-size:7px;color:#6e8a9d;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.nft-image-wrap{position:relative;display:block;aspect-ratio:1/1;overflow:hidden;border-radius:10px;background:radial-gradient(circle,#0b2035,#02050b)}.nft-image-wrap img{display:block;width:100%;height:100%;object-fit:cover}.nft-image-wrap i{position:absolute;right:5px;bottom:5px;padding:3px 5px;border-radius:6px;background:rgba(0,0,0,.66);border:1px solid rgba(255,255,255,.18);font-style:normal;font-size:6px;font-weight:800;letter-spacing:.1em;color:#fff}
|
||||
.podium{grid-template-columns:66px 1fr auto}.podium-nft{width:58px;height:58px;border-radius:10px;object-fit:cover;border:1px solid rgba(255,255,255,.14)}.podium-rank{font-size:20px;color:#7796aa}.podium>div{display:grid;gap:4px}.podium>div b{font-size:11px}.podium>div small{color:#708fa3;font-size:8px}.podium>strong{font-size:24px;color:#8ff2ff}
|
||||
.nft-lightbox{position:fixed;z-index:100;inset:0;padding:24px;background:rgba(0,3,8,.86);backdrop-filter:blur(12px);display:grid;place-items:center}.nft-lightbox>button{position:absolute;right:24px;top:20px;width:44px;height:44px;padding:0;font-size:25px;border-radius:50%}.nft-lightbox-card{width:min(760px,94vw);max-height:88vh;overflow:auto;border-radius:18px;padding:12px;display:grid;gap:10px}.nft-lightbox-card img{width:100%;max-height:72vh;object-fit:contain;border-radius:12px;background:#01040a}.nft-lightbox-card>div{display:grid;gap:4px}.nft-lightbox-card b{font-size:11px}.nft-lightbox-card small{font-size:8px;color:#7895a7;letter-spacing:.06em}
|
||||
|
||||
/* Explicit MOBILE mode can be enabled on desktop for testing and is enabled
|
||||
automatically on small/coarse-pointer devices unless the user overrides it. */
|
||||
html.mobile-mode button,html.mobile-mode .button,html.mobile-mode .dock-link{min-height:42px}
|
||||
html.mobile-mode .topbar{left:8px;right:8px;top:8px;height:58px;padding:0 10px;grid-template-columns:auto 1fr;gap:8px;border-radius:14px}
|
||||
html.mobile-mode .topbar .brand strong{font-size:10px;letter-spacing:.1em}.mobile-mode .topbar .brand small,.mobile-mode .mode-status,.mobile-mode .metrics{display:none}
|
||||
html.mobile-mode .mobile-quick{display:flex;justify-self:end;align-items:center;gap:8px}.mobile-mode .mobile-quick span{display:grid;gap:1px;font-size:6px;color:#66859a;letter-spacing:.09em}.mobile-mode .mobile-quick b{font-size:11px;color:#e6faff}.mobile-mode #guessMobile{display:flex;align-items:center;justify-content:center;min-width:38px;height:28px;border-radius:9px;border:1px solid rgba(93,255,189,.2);color:var(--green);font-size:8px}
|
||||
html.mobile-mode .distance-legend{display:none}
|
||||
html.mobile-mode .signal-panel{left:8px;right:8px;top:auto;bottom:112px;width:auto;height:136px;padding:9px;border-radius:14px;transition:height .22s ease;overflow:hidden}
|
||||
html.mobile-mode .signal-panel:not(.expanded){display:grid;grid-template-columns:105px 1fr;grid-template-rows:28px 1fr;gap:0 8px}
|
||||
html.mobile-mode .signal-panel:not(.expanded) .panel-title{grid-column:1/-1;height:28px;margin:0}
|
||||
html.mobile-mode .signal-panel:not(.expanded) .rank-hero{padding:7px 4px 0;border:0}.mobile-mode .signal-panel:not(.expanded) .rank-hero strong{font-size:28px}
|
||||
html.mobile-mode .signal-panel:not(.expanded) .signal-metrics{padding:5px 0;border:0;gap:4px}.mobile-mode .signal-panel:not(.expanded) .signal-metrics div{padding:5px}.mobile-mode .signal-panel:not(.expanded) .signal-metrics span{font-size:6px}.mobile-mode .signal-panel:not(.expanded) .signal-metrics b{font-size:12px;margin-top:3px}
|
||||
html.mobile-mode .signal-panel:not(.expanded) .proximity-mini,html.mobile-mode .signal-panel:not(.expanded) .identity-block,html.mobile-mode .signal-panel:not(.expanded)>.leaderboard-head,html.mobile-mode .signal-panel:not(.expanded)>.leaderboard{display:none}
|
||||
html.mobile-mode .signal-panel.expanded{height:min(58vh,520px);display:flex}.mobile-mode .signal-panel.expanded .rank-hero strong{font-size:32px}.mobile-mode .signal-panel.expanded .identity-block code{max-height:34px}.mobile-mode .signal-panel.expanded .leaderboard{min-height:90px}
|
||||
html.mobile-mode .node-limit{left:8px;right:8px;bottom:64px;width:auto;height:42px;padding:6px 9px;border-radius:12px;grid-template-columns:76px 1fr 48px}.mobile-mode .node-limit span{font-size:7px}.mobile-mode .node-limit b{text-align:right}
|
||||
html.mobile-mode .dock{left:8px;right:8px;bottom:8px;transform:none;max-width:none;width:auto;height:50px;justify-content:space-between;gap:4px;padding:4px;border-radius:13px;overflow:hidden}.mobile-mode .dock button,.mobile-mode .dock-link{padding:7px 8px;font-size:7px;min-width:0;flex:1;text-align:center}.mobile-mode #toggleRotate,.mobile-mode #toggleLabels,.mobile-mode #toggleEdges,.mobile-mode #toggleShells,.mobile-mode #toggleLOD,.mobile-mode #toggleEco,.mobile-mode .dock-link[href="/admin"]{display:none}.mobile-mode #toggleMobile,.mobile-mode #toggleDetails,.mobile-mode #toggleProximity,.mobile-mode #resetView,.mobile-mode .dock-link[href="/leaderboard"]{display:block}
|
||||
|
||||
/* Leaderboard mobile mode uses cards instead of a horizontally scrolling table. */
|
||||
html.mobile-mode .leaderboard-page{padding:76px 8px 28px}.mobile-mode .leaderboard-top{left:8px;right:8px;top:8px;height:58px;padding:0 10px}.mobile-mode .leaderboard-top .brand strong{font-size:9px;letter-spacing:.09em}.mobile-mode .leaderboard-top .brand small{display:none}.mobile-mode .leaderboard-nav{gap:4px}.mobile-mode .leaderboard-nav a,.mobile-mode .leaderboard-nav button{font-size:7px;padding:6px 7px;min-height:38px}.mobile-mode .leaderboard-nav a:last-child{display:none}.mobile-mode .lb-main{gap:13px}.mobile-mode .lb-hero{grid-template-columns:1fr;padding:8px 2px;gap:11px}.mobile-mode .lb-title h1{font-size:24px}.mobile-mode .lb-title p{font-size:10px}.mobile-mode .lb-controls{grid-template-columns:1fr;gap:7px}.mobile-mode .lb-podium{display:grid;grid-template-columns:1fr;gap:7px}.mobile-mode .podium{padding:9px;grid-template-columns:48px 1fr auto}.mobile-mode .podium-nft{width:44px;height:44px}.mobile-mode .podium>strong{font-size:17px}.mobile-mode .nft-showcase-head{align-items:start}.mobile-mode .nft-showcase-head h2{font-size:16px}.mobile-mode .nft-showcase-head>span{max-width:140px;text-align:right}.mobile-mode .nft-gallery{grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.mobile-mode .nft-card{padding:6px}.mobile-mode .lb-table-wrap{padding:7px;overflow:visible}.mobile-mode .lb-table-head span:last-child{display:none}.mobile-mode .lb-row{min-width:0;grid-template-columns:38px minmax(0,1fr) 62px;gap:7px;padding:9px 3px}.mobile-mode .lb-id{min-width:0}.mobile-mode .lb-id b{overflow:hidden;text-overflow:ellipsis}.mobile-mode .lb-stats{grid-column:2/4;grid-row:2;grid-template-columns:repeat(4,1fr);gap:4px}.mobile-mode .lb-stats>span{padding:5px 4px}.mobile-mode .lb-stats small{font-size:6px}.mobile-mode .lb-stats strong{font-size:10px}.mobile-mode .lb-nft-cell{grid-column:3;grid-row:1}.mobile-mode .lb-nft-button{width:56px;height:56px}.mobile-mode .nft-lightbox{padding:10px}.mobile-mode .nft-lightbox>button{right:10px;top:10px}.mobile-mode .nft-lightbox-card{width:100%;padding:8px}
|
||||
|
||||
/* Admin mobile navigation keeps all three major admin surfaces accessible. */
|
||||
html.mobile-mode .admin-top{left:8px;right:8px;top:8px;height:56px;padding:0 10px}.mobile-mode .admin-top .brand strong{font-size:9px;letter-spacing:.08em}.mobile-mode .admin-top .brand small{display:none}.mobile-mode .admin-actions{gap:4px}.mobile-mode .admin-actions a{display:none}.mobile-mode .admin-actions button{font-size:7px;padding:6px 7px;min-height:38px}
|
||||
html.mobile-mode .overviewStrip{display:none}.mobile-mode .admin-mobile-tabs{position:fixed;z-index:9;left:8px;right:8px;top:72px;height:44px;padding:4px;display:grid;grid-template-columns:repeat(3,1fr);gap:4px;border-radius:12px}.mobile-mode .admin-mobile-tabs button{padding:5px;font-size:8px;min-height:34px}.mobile-mode .admin-mobile-tabs button.active{color:var(--cyan);background:rgba(82,231,255,.08);border-color:rgba(82,231,255,.32)}
|
||||
html.mobile-mode .adminGrid{position:fixed;left:8px;right:8px;top:124px;bottom:8px;display:block;overflow:hidden}.mobile-mode .adminGrid>section{display:none;width:100%;height:100%;min-height:0}.mobile-mode .adminGrid.mobile-show-map>.adminMap{display:block}.mobile-mode .adminGrid.mobile-show-tasks>.tasklist{display:block;overflow:auto}.mobile-mode .adminGrid.mobile-show-control>.settings{display:block;overflow:auto}.mobile-mode .tasklist,.mobile-mode .settings,.mobile-mode .adminMap{border-radius:14px}.mobile-mode .toolbar{grid-template-columns:94px 1fr}.mobile-mode .toolbar button{grid-column:1/-1}.mobile-mode .tasks>button{grid-template-columns:1fr;gap:5px}.mobile-mode .tasks>button>span:last-child{display:flex;justify-content:space-between;align-items:center}.mobile-mode .map-overlay{left:7px;right:7px;padding:6px;gap:7px;font-size:7px;overflow:auto}.mobile-mode .map-overlay.top{top:7px;flex-wrap:wrap;max-height:90px}.mobile-mode .map-overlay.top>span:nth-child(4),.mobile-mode .map-overlay.top>span:nth-child(5){display:none}.mobile-mode .map-overlay.top .inline-filter{margin-left:0}.mobile-mode .map-overlay.bottom{bottom:7px;height:48px}.mobile-mode .map-overlay.bottom label{min-width:185px}.mobile-mode .map-overlay.bottom #adminRotate,.mobile-mode .map-overlay.bottom #adminEdges,.mobile-mode .map-overlay.bottom #adminShells,.mobile-mode .map-overlay.bottom #adminLOD,.mobile-mode .map-overlay.bottom #adminEco{display:none}.mobile-mode .settings label{grid-template-columns:1fr 105px;gap:6px}.mobile-mode .control-section>label,.mobile-mode .task-control>label{grid-template-columns:1fr 105px}.mobile-mode .schedule-row{grid-template-columns:1fr 1fr}.mobile-mode .schedule-row input{grid-column:1/-1}.mobile-mode .action-item{grid-template-columns:1fr auto}.mobile-mode .action-item button{grid-column:2}.mobile-mode .task-facts{grid-template-columns:repeat(3,1fr)}
|
||||
|
||||
@media(max-width:520px){html.mobile-mode .nft-gallery{grid-template-columns:1fr 1fr}.mobile-mode .mark{width:28px;height:28px;transform:scale(.82)}.mobile-mode .brand{gap:6px}.mobile-mode .leaderboard-nav a[href="/admin"]{display:none}.mobile-mode .lb-title p{line-height:1.45}.mobile-mode .signal-panel.expanded{height:min(64vh,540px)}}
|
||||
.artifactLinks [data-artifact-task],.artifactLinks [data-manifest-task]{color:var(--cyan);cursor:pointer;text-decoration:none}.artifactLinks [data-artifact-task]:hover,.artifactLinks [data-manifest-task]:hover{text-decoration:underline}
|
||||
|
||||
/* V2.5 task chooser: task selection is a first-class client surface rather
|
||||
than an implicit hash assignment. It deliberately reuses the neural visual
|
||||
language while keeping the decision readable on desktop and mobile. */
|
||||
.task-landing{position:fixed;z-index:40;inset:0;display:none;overflow:auto;background:rgba(1,4,10,.90);backdrop-filter:blur(18px)}.task-landing.visible{display:block}.task-landing-bg{position:fixed;inset:0;pointer-events:none;background:radial-gradient(circle at 22% 20%,rgba(82,231,255,.09),transparent 32%),radial-gradient(circle at 82% 65%,rgba(183,117,255,.08),transparent 34%),linear-gradient(rgba(82,231,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(82,231,255,.018) 1px,transparent 1px);background-size:auto,auto,54px 54px,54px 54px;mask-image:linear-gradient(to bottom,#000 0,rgba(0,0,0,.85) 70%,transparent)}.task-landing-inner{position:relative;width:min(1180px,calc(100% - 48px));margin:0 auto;padding:116px 0 48px}.task-landing-head{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:28px;align-items:end;margin-bottom:26px}.task-landing-head h1{font-size:clamp(34px,5vw,66px);line-height:.98;letter-spacing:-.05em;margin:5px 0 13px;max-width:720px}.task-landing-head p{max-width:720px;color:#89a6b8;font-size:13px;line-height:1.65;margin:0}.task-landing-id{border-radius:17px;padding:14px;display:grid;gap:8px}.task-landing-id span{font-size:8px;letter-spacing:.16em;color:#718fa3;font-weight:800}.task-landing-id code{font-size:9px;color:#9cecff;word-break:break-all;max-height:38px;overflow:hidden}.task-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:13px}.task-card{position:relative;border-radius:20px;padding:16px;min-height:300px;display:flex;flex-direction:column;overflow:hidden;transition:transform .2s ease,border-color .2s ease,background .2s ease}.task-card:before{content:"";position:absolute;inset:auto -40px -80px auto;width:180px;height:180px;border:1px solid rgba(82,231,255,.13);border-radius:50%;box-shadow:0 0 60px rgba(82,231,255,.04)}.task-card:hover{transform:translateY(-3px);border-color:rgba(82,231,255,.34)}.task-card.selected{border-color:rgba(93,255,189,.42);background:linear-gradient(145deg,rgba(8,29,37,.9),rgba(3,10,18,.72))}.task-card.paused{filter:saturate(.65)}.task-card-top{display:grid;grid-template-columns:36px minmax(0,1fr) auto;gap:11px;align-items:start}.task-orbit{width:34px;height:34px;border-radius:50%;border:1px solid rgba(82,231,255,.28);display:grid;place-items:center;font-size:9px;color:#8deaff;box-shadow:inset 0 0 20px rgba(82,231,255,.05)}.task-card h2{font-size:18px;line-height:1.08;margin:0 0 5px;letter-spacing:-.02em}.task-card code{font-size:7px;color:#66899f}.task-bit{font-size:25px;line-height:.8;color:#dffaff;text-align:right}.task-bit small{display:block;font-size:6px;color:#648398;letter-spacing:.12em;margin-top:8px}.task-style-preview{position:relative;height:112px;margin:14px 0 0;border:1px solid rgba(133,200,255,.1);border-radius:12px;overflow:hidden;background:rgba(2,8,17,.65)}.task-style-preview img{width:100%;height:100%;display:block;object-fit:cover;filter:saturate(.9) contrast(1.03)}.task-style-preview:after{content:"";position:absolute;inset:0;background:linear-gradient(to bottom,transparent 45%,rgba(1,5,11,.68))}.task-style-preview span{position:absolute;z-index:1;left:8px;bottom:7px;font-size:6px;letter-spacing:.12em;font-weight:800;color:#d9f8ff;padding:4px 6px;border-radius:999px;background:rgba(2,8,17,.72);border:1px solid rgba(133,200,255,.14)}.task-card>p{color:#7e9bad;font-size:10px;line-height:1.55;min-height:48px;margin:12px 0 17px}.task-card-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:auto}.task-card-stats span{border:1px solid rgba(133,200,255,.09);background:rgba(255,255,255,.018);border-radius:9px;padding:7px}.task-card-stats small{display:block;font-size:6px;color:#607d91;letter-spacing:.08em}.task-card-stats b{display:block;margin-top:4px;font-size:10px}.task-card>button{position:relative;margin-top:12px;width:100%;display:flex;justify-content:space-between;align-items:center;padding:10px 12px}.task-card.selected>button{color:#baffde;border-color:rgba(93,255,189,.28);background:rgba(93,255,189,.06)}.task-card-empty,.task-card-loading{grid-column:1/-1;min-height:180px;border-radius:18px;display:grid;place-items:center;text-align:center;color:#7f9cad;padding:30px}.task-card-empty b{display:block;color:#dff8ff;margin-bottom:6px}.task-landing-foot{display:flex;justify-content:space-between;gap:18px;align-items:center;margin-top:18px;padding:12px 3px;color:#66869a;font-size:9px}.task-landing-foot a{color:#8feeff;text-decoration:none;font-weight:800;letter-spacing:.08em}
|
||||
|
||||
.task-config-box{border:1px solid rgba(82,231,255,.13);border-radius:12px;padding:10px;margin-bottom:12px;background:rgba(82,231,255,.025)}.task-config-box .section-title{margin-top:0}.task-config-box textarea{width:100%;resize:vertical;min-height:76px;border:1px solid rgba(133,200,255,.15);background:rgba(2,8,17,.78);color:var(--text);border-radius:9px;padding:8px;outline:0;font:inherit;font-size:9px;line-height:1.45}.task-config-box input{width:100%}.task-config-actions{display:flex;gap:7px;justify-content:flex-end;margin-top:8px}.draft-note{font-size:7px;color:#7ea7b9;margin:4px 0 9px}.inherit-badge{display:inline-flex;align-items:center;gap:5px;padding:4px 7px;border:1px solid rgba(93,255,189,.16);border-radius:999px;color:#8edbb8;font-size:7px;letter-spacing:.05em}
|
||||
|
||||
html.mobile-mode .task-landing-inner{width:calc(100% - 16px);padding:82px 0 22px}.mobile-mode .task-landing-head{grid-template-columns:1fr;gap:12px;margin-bottom:14px}.mobile-mode .task-landing-head h1{font-size:32px}.mobile-mode .task-landing-head p{font-size:10px}.mobile-mode .task-landing-id{padding:10px}.mobile-mode .task-cards{grid-template-columns:1fr;gap:8px}.mobile-mode .task-card{min-height:0;padding:12px}.mobile-mode .task-card>p{min-height:0;margin:12px 0}.mobile-mode .task-card-stats{grid-template-columns:repeat(4,1fr)}.mobile-mode .task-card-stats span{padding:6px 4px}.mobile-mode .task-landing-foot{align-items:flex-start;flex-direction:column}.mobile-mode #chooseTask{display:block}.mobile-mode .dock #toggleMobile{display:none}
|
||||
|
||||
/* RIFT OpenAI usage/cost telemetry. */
|
||||
.cost-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin:7px 0 9px}.cost-card{border:1px solid rgba(82,231,255,.13);background:rgba(82,231,255,.025);border-radius:11px;padding:10px;min-width:0}.cost-card small{display:block;color:#668ba0;font-size:6px;letter-spacing:.12em;font-weight:800}.cost-card b{display:block;color:#dffaff;font-size:17px;line-height:1.05;margin:7px 0 5px;letter-spacing:-.03em}.cost-card span{display:block;color:#7898aa;font-size:7px;line-height:1.35}.usage-note{border-left:2px solid rgba(82,231,255,.3);padding:6px 8px;margin:7px 0 9px;color:#7194a8;font-size:7px;line-height:1.45}.usage-table-wrap{width:100%;overflow:auto;border:1px solid rgba(133,200,255,.1);border-radius:10px;margin-bottom:10px}.usage-table{width:100%;min-width:690px;border-collapse:collapse;font-size:7px}.usage-table th,.usage-table td{text-align:left;padding:7px 8px;border-bottom:1px solid rgba(133,200,255,.07);vertical-align:top;white-space:nowrap}.usage-table th{position:sticky;top:0;background:rgba(3,10,18,.96);color:#6f91a6;font-size:6px;letter-spacing:.08em}.usage-table td{color:#a6bfcd}.usage-table td small{display:block;color:#617f92;margin-top:2px}.usage-table tr:last-child td{border-bottom:0}
|
||||
html.mobile-mode .cost-grid{grid-template-columns:1fr}.mobile-mode .cost-card{padding:8px}.mobile-mode .usage-table-wrap{max-width:100%}
|
||||
.reference-admin-box,.task-style-admin{display:grid;grid-template-columns:minmax(0,1fr) 128px;gap:10px;align-items:stretch;border:1px solid rgba(82,231,255,.13);border-radius:12px;padding:10px;margin:10px 0;background:rgba(82,231,255,.025)}.reference-preview{min-height:128px;border:1px dashed rgba(133,200,255,.18);border-radius:10px;display:grid;place-items:center;overflow:hidden;background:rgba(2,8,17,.72);color:#607f92;font-size:7px;letter-spacing:.12em}.reference-preview.has-image{border-style:solid}.reference-preview img{width:100%;height:100%;min-height:128px;display:block;object-fit:cover}.ready-copy{color:#8edbb8}.anchor-actions{margin:0 0 12px}.task-style-admin{grid-template-columns:128px minmax(0,1fr);margin:10px 0 12px}.task-style-controls{min-width:0}.task-style-controls .section-title{margin-top:0}.task-style-controls input[type=file]{width:100%;font-size:7px;color:#7898aa;border:1px solid rgba(133,200,255,.12);border-radius:8px;padding:6px;background:rgba(2,8,17,.55)}.style-actions{justify-content:flex-start;flex-wrap:wrap}.danger-button{color:#ff9bad!important;border-color:rgba(255,95,136,.22)!important;background:rgba(255,95,136,.04)!important}.control-section button:disabled{opacity:.45;cursor:not-allowed}
|
||||
html.mobile-mode .reference-admin-box,html.mobile-mode .task-style-admin{grid-template-columns:1fr}.mobile-mode .reference-preview{min-height:160px}.mobile-mode .reference-preview img{min-height:160px;max-height:240px}
|
||||
10
internal/webui/webui.go
Normal file
10
internal/webui/webui.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package webui
|
||||
|
||||
import "embed"
|
||||
|
||||
// Dist contains the production frontend assets. They are compiled into the Go
|
||||
// binary, so running Neural Hunt does not require Node.js, npm, Vite, or a
|
||||
// separate static-file directory.
|
||||
//
|
||||
//go:embed dist/*
|
||||
var Dist embed.FS
|
||||
256
internal/ws/hub.go
Normal file
256
internal/ws/hub.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
Conn *websocket.Conn
|
||||
TaskID string
|
||||
ClientID string
|
||||
All bool
|
||||
send chan []byte
|
||||
closed chan struct{}
|
||||
onWrite func(int)
|
||||
onDrop func()
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func NewClient(conn *websocket.Conn, taskID, clientID string, all bool) *Client {
|
||||
return &Client{Conn: conn, TaskID: taskID, ClientID: clientID, All: all, send: make(chan []byte, 32), closed: make(chan struct{})}
|
||||
}
|
||||
func (c *Client) start() { go c.writeLoop() }
|
||||
func (c *Client) writeLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return
|
||||
case b := <-c.send:
|
||||
_ = c.Conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
if err := c.Conn.WriteMessage(websocket.TextMessage, b); err != nil {
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
if c.onWrite != nil {
|
||||
c.onWrite(len(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
func (c *Client) Enqueue(v any) bool {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return c.EnqueueBytes(b)
|
||||
}
|
||||
func (c *Client) EnqueueBytes(b []byte) bool {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return false
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case c.send <- b:
|
||||
return true
|
||||
default:
|
||||
if c.onDrop != nil {
|
||||
c.onDrop()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
func (c *Client) Close() { c.closeOnce.Do(func() { close(c.closed); _ = c.Conn.Close() }) }
|
||||
|
||||
// Ping sends a real WebSocket control ping. Browsers answer this automatically
|
||||
// with a pong, which refreshes the server-side read deadline. Do not replace
|
||||
// this with an application JSON message: that caused every otherwise-idle
|
||||
// browser connection to be closed after the 90s read deadline.
|
||||
func (c *Client) Ping() error {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return context.Canceled
|
||||
default:
|
||||
}
|
||||
if err := c.Conn.WriteControl(websocket.PingMessage, []byte("nh"), time.Now().Add(5*time.Second)); err != nil {
|
||||
c.Close()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type pointUpdate struct {
|
||||
ClientID string
|
||||
Data any
|
||||
}
|
||||
type rateBucket struct {
|
||||
unix int64
|
||||
frames, bytes, drops uint64
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]struct{}
|
||||
pendingMu sync.Mutex
|
||||
pending map[string]map[string]any
|
||||
frames atomic.Uint64
|
||||
bytes atomic.Uint64
|
||||
drops atomic.Uint64
|
||||
rateMu sync.Mutex
|
||||
rate [60]rateBucket
|
||||
}
|
||||
|
||||
func New() *Hub { return &Hub{clients: map[*Client]struct{}{}, pending: map[string]map[string]any{}} }
|
||||
func (h *Hub) Add(c *Client) {
|
||||
c.onWrite = h.recordWrite
|
||||
c.onDrop = h.recordDrop
|
||||
// Same-session websocket reconnects are intentionally allowed by runtime.
|
||||
// Keep only the newest user socket for a ClientID so a half-dead TCP
|
||||
// connection cannot continue receiving frames beside its replacement.
|
||||
var stale []*Client
|
||||
h.mu.Lock()
|
||||
if !c.All && c.ClientID != "" {
|
||||
for old := range h.clients {
|
||||
if !old.All && old.ClientID == c.ClientID {
|
||||
delete(h.clients, old)
|
||||
stale = append(stale, old)
|
||||
}
|
||||
}
|
||||
}
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
for _, old := range stale {
|
||||
old.Close()
|
||||
}
|
||||
c.start()
|
||||
}
|
||||
func (h *Hub) Remove(c *Client) { h.mu.Lock(); delete(h.clients, c); h.mu.Unlock(); c.Close() }
|
||||
func (h *Hub) Connected() int { h.mu.RLock(); defer h.mu.RUnlock(); return len(h.clients) }
|
||||
|
||||
func (h *Hub) Publish(ctx context.Context, e Event) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.broadcastBytes(e.TaskID, b)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishPoint coalesces repeated improvements for the same client. Every 250ms
|
||||
// all changed points for a task are emitted as one frame per websocket instead
|
||||
// of one synchronous write per guess per client.
|
||||
func (h *Hub) PublishPoint(taskID, clientID string, data any) {
|
||||
h.pendingMu.Lock()
|
||||
m := h.pending[taskID]
|
||||
if m == nil {
|
||||
m = map[string]any{}
|
||||
h.pending[taskID] = m
|
||||
}
|
||||
m[clientID] = data
|
||||
h.pendingMu.Unlock()
|
||||
}
|
||||
func (h *Hub) flushPoints() {
|
||||
h.pendingMu.Lock()
|
||||
pending := h.pending
|
||||
h.pending = map[string]map[string]any{}
|
||||
h.pendingMu.Unlock()
|
||||
for taskID, m := range pending {
|
||||
arr := make([]any, 0, len(m))
|
||||
for _, v := range m {
|
||||
arr = append(arr, v)
|
||||
}
|
||||
b, err := json.Marshal(Event{Type: "points", TaskID: taskID, Data: arr})
|
||||
if err == nil {
|
||||
h.broadcastBytes(taskID, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
func (h *Hub) broadcastBytes(taskID string, b []byte) {
|
||||
h.mu.RLock()
|
||||
clients := make([]*Client, 0, len(h.clients))
|
||||
for c := range h.clients {
|
||||
if c.All || taskID == "" || c.TaskID == taskID {
|
||||
clients = append(clients, c)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
for _, c := range clients {
|
||||
c.EnqueueBytes(b)
|
||||
}
|
||||
}
|
||||
func (h *Hub) recordWrite(n int) {
|
||||
h.frames.Add(1)
|
||||
h.bytes.Add(uint64(n))
|
||||
now := time.Now().Unix()
|
||||
h.rateMu.Lock()
|
||||
i := now % 60
|
||||
if h.rate[i].unix != now {
|
||||
h.rate[i] = rateBucket{unix: now}
|
||||
}
|
||||
h.rate[i].frames++
|
||||
h.rate[i].bytes += uint64(n)
|
||||
h.rateMu.Unlock()
|
||||
}
|
||||
func (h *Hub) recordDrop() {
|
||||
h.drops.Add(1)
|
||||
now := time.Now().Unix()
|
||||
h.rateMu.Lock()
|
||||
i := now % 60
|
||||
if h.rate[i].unix != now {
|
||||
h.rate[i] = rateBucket{unix: now}
|
||||
}
|
||||
h.rate[i].drops++
|
||||
h.rateMu.Unlock()
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
Connected int `json:"connected"`
|
||||
FramesPerSec float64 `json:"frames_per_sec"`
|
||||
BytesPerSec float64 `json:"bytes_per_sec"`
|
||||
DroppedPerSec float64 `json:"dropped_per_sec"`
|
||||
FramesTotal uint64 `json:"frames_total"`
|
||||
BytesTotal uint64 `json:"bytes_total"`
|
||||
DroppedTotal uint64 `json:"dropped_total"`
|
||||
}
|
||||
|
||||
func (h *Hub) Metrics() Metrics {
|
||||
now := time.Now().Unix()
|
||||
var f, b, d uint64
|
||||
h.rateMu.Lock()
|
||||
for _, x := range h.rate {
|
||||
if x.unix > now-5 {
|
||||
f += x.frames
|
||||
b += x.bytes
|
||||
d += x.drops
|
||||
}
|
||||
}
|
||||
h.rateMu.Unlock()
|
||||
return Metrics{Connected: h.Connected(), FramesPerSec: float64(f) / 5, BytesPerSec: float64(b) / 5, DroppedPerSec: float64(d) / 5, FramesTotal: h.frames.Load(), BytesTotal: h.bytes.Load(), DroppedTotal: h.drops.Load()}
|
||||
}
|
||||
func (h *Hub) Run(ctx context.Context) {
|
||||
t := time.NewTicker(250 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
h.flushPoints()
|
||||
}
|
||||
}
|
||||
}
|
||||
103
migrations/001_init.sql
Normal file
103
migrations/001_init.sql
Normal file
@@ -0,0 +1,103 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
public_jwk TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
secret TEXT NOT NULL,
|
||||
public_seed TEXT NOT NULL,
|
||||
range_bits INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','completed','closed')),
|
||||
paused INTEGER NOT NULL DEFAULT 0,
|
||||
guess_min_interval_sec INTEGER,
|
||||
client_submit_interval_sec INTEGER,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
parent_task_id TEXT REFERENCES tasks(id),
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
nft_prompt_instructions TEXT NOT NULL DEFAULT '',
|
||||
nft_negative_prompt TEXT NOT NULL DEFAULT '',
|
||||
nft_style_reference TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
winner_client_id TEXT REFERENCES clients(id),
|
||||
winner_signature TEXT,
|
||||
winning_guess TEXT,
|
||||
artifact_status TEXT NOT NULL DEFAULT 'none' CHECK (artifact_status IN ('none','pending','generating','ready','error')),
|
||||
artifact_uri TEXT,
|
||||
artifact_manifest_uri TEXT,
|
||||
artifact_error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS tasks_status_created_idx ON tasks(status, created_at);
|
||||
|
||||
-- False guesses are intentionally not persisted. This table only keeps the
|
||||
-- aggregate state needed for the 3D map, ranking, rate limiting and next seq.
|
||||
CREATE TABLE IF NOT EXISTS task_points (
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
score REAL NOT NULL DEFAULT 0,
|
||||
x REAL NOT NULL DEFAULT 0,
|
||||
y REAL NOT NULL DEFAULT 0,
|
||||
z REAL NOT NULL DEFAULT 0,
|
||||
guess_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_seq INTEGER NOT NULL DEFAULT 0,
|
||||
last_guess_at INTEGER,
|
||||
PRIMARY KEY (task_id, client_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS task_points_rank_idx ON task_points(task_id, score DESC);
|
||||
|
||||
|
||||
|
||||
-- A browser or shell identity can explicitly choose which active task it works
|
||||
-- on. The selection is persisted so switching devices with the same exported
|
||||
-- identity keeps the chosen task (while single-active-connection enforcement
|
||||
-- still applies at runtime).
|
||||
CREATE TABLE IF NOT EXISTS client_task_selection (
|
||||
client_id TEXT PRIMARY KEY REFERENCES clients(id) ON DELETE CASCADE,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS client_task_selection_task_idx ON client_task_selection(task_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_unlocks (
|
||||
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
unlock_key TEXT NOT NULL,
|
||||
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (client_id, unlock_key)
|
||||
);
|
||||
|
||||
-- A short lease enforces one active websocket per browser identity.
|
||||
CREATE TABLE IF NOT EXISTS presence_leases (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS presence_exp_idx ON presence_leases(expires_at);
|
||||
|
||||
-- Admin task actions may run immediately or at a future timestamp. Payloads are
|
||||
-- small JSON objects validated by the Go server before being scheduled.
|
||||
CREATE TABLE IF NOT EXISTS task_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
action_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
execute_at INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','running','done','error','cancelled')),
|
||||
created_at INTEGER NOT NULL,
|
||||
executed_at INTEGER,
|
||||
error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS task_actions_due_idx ON task_actions(status, execute_at);
|
||||
CREATE INDEX IF NOT EXISTS task_actions_task_idx ON task_actions(task_id, created_at DESC);
|
||||
23
migrations/002_artifact_api_usage.sql
Normal file
23
migrations/002_artifact_api_usage.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS artifact_api_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at INTEGER NOT NULL,
|
||||
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('character_anchor','artifact')),
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
size TEXT NOT NULL,
|
||||
quality TEXT NOT NULL,
|
||||
request_id TEXT,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_text_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
input_image_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
estimated_cost_usd REAL,
|
||||
pricing_basis TEXT NOT NULL DEFAULT '',
|
||||
meta_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_created_idx ON artifact_api_usage(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_kind_created_idx ON artifact_api_usage(kind, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS artifact_api_usage_task_idx ON artifact_api_usage(task_id);
|
||||
1
migrations/003_task_style_reference.sql
Normal file
1
migrations/003_task_style_reference.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE tasks ADD COLUMN nft_style_reference TEXT NOT NULL DEFAULT '';
|
||||
45
run-load.ps1
Normal file
45
run-load.ps1
Normal file
@@ -0,0 +1,45 @@
|
||||
param(
|
||||
[switch]$NoEnv
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$ProjectRoot = $PSScriptRoot
|
||||
Set-Location $ProjectRoot
|
||||
|
||||
function Import-DotEnv {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
Get-Content -LiteralPath $Path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#")) { return }
|
||||
|
||||
$parts = $line.Split("=", 2)
|
||||
if ($parts.Count -ne 2) { return }
|
||||
|
||||
$name = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if (-not $name) { return }
|
||||
|
||||
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or
|
||||
($value.StartsWith("'") -and $value.EndsWith("'"))) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
|
||||
[Environment]::SetEnvironmentVariable($name, $value, "Process")
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $NoEnv) {
|
||||
$envFile = Join-Path $ProjectRoot ".env"
|
||||
if (Test-Path -LiteralPath $envFile) {
|
||||
Import-DotEnv -Path $envFile
|
||||
}
|
||||
else {
|
||||
Write-Warning ".env nicht gefunden. Es werden vorhandene Umgebungsvariablen und Programm-Defaults verwendet."
|
||||
}
|
||||
}
|
||||
|
||||
go run ./cmd/loadtest
|
||||
exit $LASTEXITCODE
|
||||
45
run.ps1
Normal file
45
run.ps1
Normal file
@@ -0,0 +1,45 @@
|
||||
param(
|
||||
[switch]$NoEnv
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$ProjectRoot = $PSScriptRoot
|
||||
Set-Location $ProjectRoot
|
||||
|
||||
function Import-DotEnv {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
|
||||
Get-Content -LiteralPath $Path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#")) { return }
|
||||
|
||||
$parts = $line.Split("=", 2)
|
||||
if ($parts.Count -ne 2) { return }
|
||||
|
||||
$name = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
if (-not $name) { return }
|
||||
|
||||
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or
|
||||
($value.StartsWith("'") -and $value.EndsWith("'"))) {
|
||||
$value = $value.Substring(1, $value.Length - 2)
|
||||
}
|
||||
|
||||
[Environment]::SetEnvironmentVariable($name, $value, "Process")
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $NoEnv) {
|
||||
$envFile = Join-Path $ProjectRoot ".env"
|
||||
if (Test-Path -LiteralPath $envFile) {
|
||||
Import-DotEnv -Path $envFile
|
||||
}
|
||||
else {
|
||||
Write-Warning ".env nicht gefunden. Es werden vorhandene Umgebungsvariablen und Programm-Defaults verwendet."
|
||||
}
|
||||
}
|
||||
|
||||
go run ./cmd/server
|
||||
exit $LASTEXITCODE
|
||||
Reference in New Issue
Block a user