RC-6
release-tag / release-image (push) Successful in 2m10s

This commit is contained in:
2026-08-10 19:44:17 +02:00
parent 551ed007e6
commit 185ccf1101
20 changed files with 875 additions and 124 deletions
+142
View File
@@ -0,0 +1,142 @@
package artifact
import (
"context"
"errors"
"fmt"
"sync"
"time"
"neuralhunt/internal/settings"
)
var ErrOpenAIBudgetExceeded = errors.New("OpenAI cost circuit breaker active")
type OpenAIBudgetError struct {
Reason string
RetryAfter time.Duration
}
func (e *OpenAIBudgetError) Error() string {
if e == nil {
return ErrOpenAIBudgetExceeded.Error()
}
return fmt.Sprintf("%s: %s", ErrOpenAIBudgetExceeded, e.Reason)
}
func (e *OpenAIBudgetError) Unwrap() error { return ErrOpenAIBudgetExceeded }
type openAIBudgetSnapshot struct {
Calls1H int64
Calls24H int64
Cost24H float64
Oldest1H int64
Oldest24H int64
}
var openAIBudgetMu sync.Mutex
func (w *Worker) openAIBudgetSnapshot(ctx context.Context) (openAIBudgetSnapshot, error) {
now := time.Now().UTC()
oneHour := now.Add(-time.Hour).UnixMilli()
day := now.Add(-24 * time.Hour).UnixMilli()
var x openAIBudgetSnapshot
if err := w.db.QueryRowContext(ctx, `SELECT
COALESCE(sum(CASE WHEN created_at>=? THEN 1 ELSE 0 END),0),
count(*),COALESCE(sum(estimated_cost_usd),0),
COALESCE(min(CASE WHEN created_at>=? THEN created_at END),0),
COALESCE(min(created_at),0)
FROM artifact_api_usage
WHERE provider='openai' AND created_at>=?`, oneHour, oneHour, day).
Scan(&x.Calls1H, &x.Calls24H, &x.Cost24H, &x.Oldest1H, &x.Oldest24H); err != nil {
return x, err
}
return x, nil
}
func retryFromOldest(oldestMS int64, window time.Duration) time.Duration {
if oldestMS <= 0 {
return time.Minute
}
d := time.Until(time.UnixMilli(oldestMS).UTC().Add(window))
if d < time.Second {
d = time.Second
}
return d
}
func (w *Worker) checkOpenAIBudget(ctx context.Context, cfg settings.Runtime) error {
if cfg.OpenAIMaxCalls1H <= 0 && cfg.OpenAIMaxCalls24H <= 0 && cfg.OpenAIMaxCost24HUSD <= 0 {
return nil
}
if cfg.OpenAIMaxCost24HUSD > 0 {
if _, ok := openAIImagePricing(cfg.ArtifactModel); !ok {
return &OpenAIBudgetError{Reason: "daily USD budget is enabled but this image model has no pinned local price", RetryAfter: 10 * time.Minute}
}
}
x, err := w.openAIBudgetSnapshot(ctx)
if err != nil {
return err
}
var wait time.Duration
var reasons []string
if cfg.OpenAIMaxCalls1H > 0 && x.Calls1H >= int64(cfg.OpenAIMaxCalls1H) {
reasons = append(reasons, fmt.Sprintf("%d/%d OpenAI image calls in the last hour", x.Calls1H, cfg.OpenAIMaxCalls1H))
wait = maxDuration(wait, retryFromOldest(x.Oldest1H, time.Hour))
}
if cfg.OpenAIMaxCalls24H > 0 && x.Calls24H >= int64(cfg.OpenAIMaxCalls24H) {
reasons = append(reasons, fmt.Sprintf("%d/%d OpenAI image calls in the last 24h", x.Calls24H, cfg.OpenAIMaxCalls24H))
wait = maxDuration(wait, retryFromOldest(x.Oldest24H, 24*time.Hour))
}
if cfg.OpenAIMaxCost24HUSD > 0 && x.Cost24H+cfg.OpenAIBudgetReserveUSD > cfg.OpenAIMaxCost24HUSD {
reasons = append(reasons, fmt.Sprintf("estimated cost $%.4f + $%.4f safety reserve exceeds $%.4f/24h", x.Cost24H, cfg.OpenAIBudgetReserveUSD, cfg.OpenAIMaxCost24HUSD))
// Re-evaluate periodically because several old calls may age out together.
wait = maxDuration(wait, 5*time.Minute)
}
if len(reasons) > 0 {
return &OpenAIBudgetError{Reason: joinBudgetReasons(reasons), RetryAfter: wait}
}
return nil
}
func joinBudgetReasons(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += "; "
}
out += p
}
return out
}
func maxDuration(a, b time.Duration) time.Duration {
if b > a {
return b
}
return a
}
// openAITrackedRequest serializes budget check -> provider call -> usage log.
// That prevents two concurrent admin/worker requests from both observing the
// same remaining budget before either successful call is recorded.
func (w *Worker) openAITrackedRequest(ctx context.Context, cfg settings.Runtime, taskID, kind, prompt string, refs []referenceImage) (imageResult, error) {
openAIBudgetMu.Lock()
defer openAIBudgetMu.Unlock()
if err := w.checkOpenAIBudget(ctx, cfg); err != nil {
return imageResult{}, err
}
res, err := w.openAIRequest(ctx, cfg, prompt, refs)
if err != nil {
return imageResult{}, err
}
if err := w.recordOpenAIUsage(ctx, taskID, kind, res); err != nil {
if res.Meta == nil {
res.Meta = map[string]any{}
}
// Never retry a successfully generated image merely because telemetry
// failed; that could double-spend. Surface the logging failure in metadata.
res.Meta["usage_log_error"] = err.Error()
}
return res, nil
}
+3 -22
View File
@@ -71,16 +71,10 @@ func (w *Worker) createCharacterAnchorLocked(ctx context.Context, cfg settings.R
// 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)
res, err := w.openAITrackedRequest(ctx, cfg, "", "character_anchor", 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")
}
@@ -112,17 +106,7 @@ func (w *Worker) openAI(ctx context.Context, cfg settings.Runtime, x win, prompt
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
return w.openAITrackedRequest(ctx, cfg, x.ID, "artifact", prompt, nil)
}
anchor, created, err := w.ensureCharacterAnchor(ctx, cfg)
if err != nil {
@@ -132,7 +116,7 @@ func (w *Worker) openAI(ctx context.Context, cfg settings.Runtime, x win, prompt
if err != nil {
return imageResult{}, err
}
res, err := w.openAIRequest(ctx, cfg, prompt, []referenceImage{
res, err := w.openAITrackedRequest(ctx, cfg, x.ID, "artifact", prompt, []referenceImage{
{Name: "character_anchor.png", ContentType: "image/png", Bytes: anchor},
{Name: styleRef.Name, ContentType: styleRef.ContentType, Bytes: styleRef.Bytes},
})
@@ -149,9 +133,6 @@ func (w *Worker) openAI(ctx context.Context, cfg settings.Runtime, x win, prompt
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
}
+41 -5
View File
@@ -18,17 +18,20 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"neuralhunt/internal/settings"
)
type Worker struct {
db *sql.DB
dir string
publicBase string
settings *settings.Manager
http *http.Client
db *sql.DB
dir string
publicBase string
settings *settings.Manager
http *http.Client
budgetHoldMu sync.Mutex
budgetHoldUntil time.Time
}
func New(db *sql.DB, dir string, sm *settings.Manager) (*Worker, error) {
@@ -122,7 +125,33 @@ type imageResult struct {
PricingBasis string
}
func (w *Worker) budgetHoldActive() bool {
w.budgetHoldMu.Lock()
defer w.budgetHoldMu.Unlock()
return time.Now().Before(w.budgetHoldUntil)
}
func (w *Worker) holdBudgetFor(d time.Duration) {
if d < time.Second {
d = time.Minute
}
w.budgetHoldMu.Lock()
until := time.Now().Add(d)
if until.After(w.budgetHoldUntil) {
w.budgetHoldUntil = until
}
w.budgetHoldMu.Unlock()
}
func (w *Worker) deferBudget(ctx context.Context, taskID string, err error) {
msg := err.Error()
_, _ = w.db.ExecContext(ctx, `UPDATE tasks SET artifact_status='pending',artifact_error=? WHERE id=? AND artifact_status='generating'`, msg, taskID)
}
func (w *Worker) one(ctx context.Context) error {
if w.budgetHoldActive() {
return nil
}
x, err := w.claim(ctx)
if errors.Is(err, sql.ErrNoRows) {
return nil
@@ -144,6 +173,13 @@ func (w *Worker) one(ctx context.Context) error {
}
img, err := w.generate(ctx, cfg, x, prompt, negativePrompt)
if err != nil {
var budgetErr *OpenAIBudgetError
if errors.As(err, &budgetErr) {
w.deferBudget(ctx, x.ID, err)
w.holdBudgetFor(budgetErr.RetryAfter)
log.Printf("artifact worker task %s deferred by OpenAI circuit breaker: %v", x.ID, err)
return nil
}
w.fail(ctx, x.ID, err)
return fmt.Errorf("task %s: %w", x.ID, err)
}
+53 -1
View File
@@ -44,6 +44,7 @@ type Claims struct {
type challengeEntry struct {
ClientID string
ExpiresAt time.Time
ProofBits int
}
type Manager struct {
@@ -109,6 +110,10 @@ func randomB64(n int) (string, error) {
}
func (m *Manager) NewChallenge(ctx context.Context, cid string) (string, error) {
return m.NewChallengeWithProof(ctx, cid, 0)
}
func (m *Manager) NewChallengeWithProof(ctx context.Context, cid string, proofBits int) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
@@ -128,11 +133,55 @@ func (m *Manager) NewChallenge(ctx context.Context, cid string) (string, error)
delete(m.challenges, nonce)
}
}
m.challenges[c] = challengeEntry{ClientID: cid, ExpiresAt: expires}
if len(m.challenges) >= 50000 {
return "", errors.New("too many pending authentication challenges")
}
if proofBits < 0 {
proofBits = 0
}
if proofBits > 22 {
proofBits = 22
}
m.challenges[c] = challengeEntry{ClientID: cid, ExpiresAt: expires, ProofBits: proofBits}
return c, nil
}
func (m *Manager) ConsumeChallenge(ctx context.Context, cid, challenge string) error {
return m.ConsumeChallengeWithProof(ctx, cid, challenge, "")
}
func leadingZeroBits(b []byte) int {
n := 0
for _, x := range b {
if x == 0 {
n += 8
continue
}
for mask := byte(0x80); mask != 0 && x&mask == 0; mask >>= 1 {
n++
}
break
}
return n
}
func verifyChallengeProof(challenge, cid, counter string, bits int) bool {
if bits <= 0 {
return true
}
if len(counter) == 0 || len(counter) > 24 {
return false
}
for _, r := range counter {
if r < '0' || r > '9' {
return false
}
}
h := sha256.Sum256([]byte("nh-pow-v1|" + challenge + "|" + cid + "|" + counter))
return leadingZeroBits(h[:]) >= bits
}
func (m *Manager) ConsumeChallengeWithProof(ctx context.Context, cid, challenge, counter string) error {
if err := ctx.Err(); err != nil {
return err
}
@@ -151,6 +200,9 @@ func (m *Manager) ConsumeChallenge(ctx context.Context, cid, challenge string) e
if entry.ClientID != cid {
return errors.New("challenge identity mismatch")
}
if !verifyChallengeProof(challenge, cid, counter, entry.ProofBits) {
return errors.New("identity proof of work invalid")
}
return nil
}
+24
View File
@@ -78,3 +78,27 @@ func TestManyConcurrentChallenges(t *testing.T) {
}
}
}
func TestChallengeProofOfWorkForNewIdentity(t *testing.T) {
m := New(nil, "test-secret")
ctx := context.Background()
const cid = "client-pow"
challenge, err := m.NewChallengeWithProof(ctx, cid, 8)
if err != nil {
t.Fatal(err)
}
counter := ""
for i := 0; i < 100000; i++ {
candidate := fmt.Sprint(i)
if verifyChallengeProof(challenge, cid, candidate, 8) {
counter = candidate
break
}
}
if counter == "" {
t.Fatal("failed to solve small test proof")
}
if err := m.ConsumeChallengeWithProof(ctx, cid, challenge, counter); err != nil {
t.Fatalf("valid proof rejected: %v", err)
}
}
+8
View File
@@ -183,6 +183,14 @@ func (s *Store) ClientExists(ctx context.Context, id string) bool {
return n == 1
}
func (s *Store) ClientCreatedAt(ctx context.Context, id string) (time.Time, error) {
var ms int64
if err := s.DB.QueryRowContext(ctx, `SELECT created_at FROM clients WHERE id=?`, id).Scan(&ms); err != nil {
return time.Time{}, err
}
return time.UnixMilli(ms).UTC(), nil
}
func (s *Store) ClientPublicJWK(ctx context.Context, id string) (auth.PublicJWK, error) {
var raw string
if err := s.DB.QueryRowContext(ctx, `SELECT public_jwk FROM clients WHERE id=?`, id).Scan(&raw); err != nil {
+4
View File
@@ -34,6 +34,10 @@ func (s *Server) adminCreateCharacterAnchor(w http.ResponseWriter, r *http.Reque
jsonOut(w, http.StatusConflict, map[string]string{"error": "character anchor already exists"})
return
}
if errors.Is(err, artifact.ErrOpenAIBudgetExceeded) {
jsonOut(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
+23
View File
@@ -64,6 +64,19 @@ func (s *Server) adminArtifactUsage(w http.ResponseWriter, r *http.Request) {
_ = 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)
var rollingCalls1H, rollingCalls24H int64
var rollingCost24H float64
oneHourMS := now.Add(-time.Hour).UnixMilli()
twentyFourHoursMS := now.Add(-24 * time.Hour).UnixMilli()
_ = s.store.DB.QueryRowContext(r.Context(), `SELECT
COALESCE(sum(CASE WHEN created_at>=? THEN 1 ELSE 0 END),0),
count(*),COALESCE(sum(estimated_cost_usd),0)
FROM artifact_api_usage WHERE provider='openai' AND created_at>=?`, oneHourMS, twentyFourHoursMS).Scan(&rollingCalls1H, &rollingCalls24H, &rollingCost24H)
cfg := s.settings.Get()
circuitBlocked := (cfg.OpenAIMaxCalls1H > 0 && rollingCalls1H >= int64(cfg.OpenAIMaxCalls1H)) ||
(cfg.OpenAIMaxCalls24H > 0 && rollingCalls24H >= int64(cfg.OpenAIMaxCalls24H)) ||
(cfg.OpenAIMaxCost24HUSD > 0 && rollingCost24H+cfg.OpenAIBudgetReserveUSD > cfg.OpenAIMaxCost24HUSD)
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
@@ -114,6 +127,16 @@ func (s *Server) adminArtifactUsage(w http.ResponseWriter, r *http.Request) {
"output_tokens": outputTokens,
"total_tokens": totalTokens,
},
"circuit_breaker": map[string]any{
"blocked": circuitBlocked,
"calls_1h": rollingCalls1H,
"calls_24h": rollingCalls24H,
"cost_24h_usd": rollingCost24H,
"max_calls_1h": cfg.OpenAIMaxCalls1H,
"max_calls_24h": cfg.OpenAIMaxCalls24H,
"max_cost_24h_usd": cfg.OpenAIMaxCost24HUSD,
"reserve_per_call_usd": cfg.OpenAIBudgetReserveUSD,
},
"cost_method": "estimated from provider-reported token usage using pinned OpenAI standard public token rates; not invoice reconciliation",
"recent": recent,
})
+280 -26
View File
@@ -10,11 +10,14 @@ import (
"log"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
gort "runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"neuralhunt/internal/artifact"
@@ -40,23 +43,69 @@ type Server struct {
lottery *guessLottery
adminUser, adminPass, staticDir, artifactDir string
upgrader websocket.Upgrader
wsAllowedOrigins map[string]struct{}
maxUserWS, maxLeaderboardWS int64
userWSCount, leaderboardWSCount atomic.Int64
adminSessions sync.Map // sid -> exp unix seconds
}
func New(store *data.Store, a *auth.Manager, sm *settings.Manager, hub *wsx.Hub, runtimeState *rtx.State, artifactDir string, artifactWorker *artifact.Worker) *Server {
return &Server{
store: store,
auth: a,
settings: sm,
hub: hub,
runtime: runtimeState,
artifactWorker: artifactWorker,
lottery: newGuessLottery(),
adminUser: env("ADMIN_USER", "admin"),
adminPass: env("ADMIN_PASSWORD", "change-me"),
staticDir: env("STATIC_DIR", ""),
artifactDir: artifactDir,
upgrader: websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }},
s := &Server{
store: store,
auth: a,
settings: sm,
hub: hub,
runtime: runtimeState,
artifactWorker: artifactWorker,
lottery: newGuessLottery(),
adminUser: env("ADMIN_USER", "admin"),
adminPass: env("ADMIN_PASSWORD", "change-me"),
staticDir: env("STATIC_DIR", ""),
artifactDir: artifactDir,
wsAllowedOrigins: parseOriginAllowlist(os.Getenv("WS_ALLOWED_ORIGINS")),
maxUserWS: int64(envIntServer("WS_MAX_USER_CONNECTIONS", 5000)),
maxLeaderboardWS: int64(envIntServer("WS_MAX_LEADERBOARD_CONNECTIONS", 500)),
}
s.upgrader = websocket.Upgrader{CheckOrigin: s.checkWSOrigin, Subprotocols: []string{"neuralhunt.v1"}}
return s
}
func envIntServer(k string, d int) int {
if raw := strings.TrimSpace(os.Getenv(k)); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
return n
}
}
return d
}
func parseOriginAllowlist(raw string) map[string]struct{} {
out := map[string]struct{}{}
for _, item := range strings.Split(raw, ",") {
item = strings.TrimSpace(strings.TrimRight(item, "/"))
if item != "" {
out[strings.ToLower(item)] = struct{}{}
}
}
return out
}
func (s *Server) checkWSOrigin(r *http.Request) bool {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" {
// Native clients do not normally send Origin. Authentication and global
// connection caps still apply; browser cross-site websockets do send it.
return true
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return false
}
if strings.EqualFold(u.Host, r.Host) {
return true
}
_, ok := s.wsAllowedOrigins[strings.ToLower(strings.TrimRight(origin, "/"))]
return ok
}
func env(k, d string) string {
@@ -130,9 +179,53 @@ func (s *Server) bearer(r *http.Request) (auth.Claims, error) {
return s.auth.Parse(strings.TrimPrefix(h, "Bearer "))
}
const adminSessionCookie = "neuralhunt_admin_session"
func adminCookieSecure(r *http.Request) bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv("ADMIN_COOKIE_SECURE")))
switch raw {
case "0", "false", "no", "off":
return false
case "auto":
if r.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https")
default:
// Public deployments should terminate HTTPS in front of this listener.
// Defaulting to Secure avoids a proxy-header mistake silently weakening
// the admin session. Local plain-HTTP development can set false explicitly.
return true
}
}
func (s *Server) adminCookieClaims(r *http.Request) (auth.Claims, error) {
cookie, err := r.Cookie(adminSessionCookie)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return auth.Claims{}, errors.New("missing admin session")
}
c, err := s.auth.Parse(cookie.Value)
if err != nil || c.Role != "admin" || c.SessionID == "" {
return auth.Claims{}, errors.New("invalid admin session")
}
expRaw, ok := s.adminSessions.Load(c.SessionID)
exp, typed := expRaw.(int64)
if !ok || !typed || exp < time.Now().Unix() {
s.adminSessions.Delete(c.SessionID)
return auth.Claims{}, errors.New("admin session revoked or expired")
}
return c, nil
}
func (s *Server) require(role string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := s.bearer(r)
var c auth.Claims
var err error
if role == "admin" {
c, err = s.adminCookieClaims(r)
} else {
c, err = s.bearer(r)
}
if err != nil || (role != "" && c.Role != role) {
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
return
@@ -143,15 +236,52 @@ func (s *Server) require(role string, next http.Handler) http.Handler {
func claims(r *http.Request) auth.Claims { return r.Context().Value(claimsKey).(auth.Claims) }
// PublicRoutes is safe to expose to the Internet. The admin UI and every
// /api/admin endpoint are deliberately absent from this listener.
func (s *Server) PublicRoutes() http.Handler {
next := s.Routes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.ToLower(r.URL.Path)
if p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// AdminRoutes is intended for the private/VPN listener. It serves only the
// control plane, its static frontend assets and health check.
func (s *Server) AdminRoutes() http.Handler {
next := s.Routes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.ToLower(r.URL.Path)
if p == "/" {
http.Redirect(w, r, "/admin", http.StatusTemporaryRedirect)
return
}
allowed := p == "/admin" || strings.HasPrefix(p, "/admin/") || p == "/api/healthz" || p == "/api/admin" || strings.HasPrefix(p, "/api/admin/") || p == "/app.js" || p == "/styles.css" || p == "/index.html"
if !allowed {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) Routes() http.Handler {
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self' data:; form-action 'self'")
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
path := strings.ToLower(req.URL.Path)
if path == "/" || path == "/admin" || path == "/leaderboard" || strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
if path == "/" || path == "/admin" || path == "/leaderboard" || strings.HasPrefix(path, "/api/admin") || strings.HasPrefix(path, "/api/auth") || strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
w.Header().Set("Cache-Control", "no-store")
}
next.ServeHTTP(w, req)
@@ -166,6 +296,7 @@ func (s *Server) Routes() http.Handler {
r.Post("/api/auth/challenge", s.challenge)
r.Post("/api/auth/login", s.login)
r.Post("/api/admin/login", s.adminLogin)
r.Post("/api/admin/logout", s.adminLogout)
r.Group(func(r chi.Router) {
r.Use(func(n http.Handler) http.Handler { return s.require("user", n) })
r.Get("/api/tasks", s.clientTasks)
@@ -178,6 +309,7 @@ func (s *Server) Routes() http.Handler {
})
r.Group(func(r chi.Router) {
r.Use(func(n http.Handler) http.Handler { return s.require("admin", n) })
r.Get("/api/admin/session", s.adminSession)
r.Get("/api/admin/overview", s.adminOverview)
r.Get("/api/admin/performance", s.adminPerformance)
r.Get("/api/admin/profiles/cleanup-preview", s.adminProfileCleanupPreview)
@@ -265,20 +397,25 @@ func (s *Server) challenge(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
c, err := s.auth.NewChallenge(r.Context(), cid)
proofBits := 0
if !s.store.ClientExists(r.Context(), cid) {
proofBits = s.settings.Get().SybilProofOfWorkBits
}
c, err := s.auth.NewChallengeWithProof(r.Context(), cid, proofBits)
if err != nil {
log.Printf("auth challenge for %s: %v", cid, err)
jsonOut(w, 500, map[string]string{"error": "challenge failed"})
return
}
jsonOut(w, 200, map[string]string{"client_id": cid, "challenge": c})
jsonOut(w, 200, map[string]any{"client_id": cid, "challenge": c, "proof_of_work_bits": proofBits})
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var in struct {
PublicJWK auth.PublicJWK `json:"public_jwk"`
Challenge string `json:"challenge"`
Signature string `json:"signature"`
PublicJWK auth.PublicJWK `json:"public_jwk"`
Challenge string `json:"challenge"`
Signature string `json:"signature"`
ProofOfWorkCounter string `json:"proof_of_work_counter"`
}
if err := decodeAuth(r, &in); err != nil {
log.Printf("auth login decode: %v", err)
@@ -295,7 +432,7 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 401, map[string]string{"error": "invalid signature"})
return
}
if err = s.auth.ConsumeChallenge(r.Context(), cid, in.Challenge); err != nil {
if err = s.auth.ConsumeChallengeWithProof(r.Context(), cid, in.Challenge, in.ProofOfWorkCounter); err != nil {
log.Printf("auth login challenge for %s: %v", cid, err)
jsonOut(w, 401, map[string]string{"error": err.Error()})
return
@@ -323,13 +460,53 @@ func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 401, map[string]string{"error": "invalid credentials"})
return
}
tok, _, err := s.auth.Issue("admin", "admin", 8*time.Hour)
tok, sessionClaims, err := s.auth.Issue("admin", "admin", 8*time.Hour)
if err != nil {
log.Printf("admin auth issue token: %v", err)
jsonOut(w, 500, map[string]string{"error": "token creation failed"})
jsonOut(w, 500, map[string]string{"error": "session creation failed"})
return
}
jsonOut(w, 200, map[string]string{"token": tok})
nowUnix := time.Now().Unix()
s.adminSessions.Range(func(key, value any) bool {
if exp, ok := value.(int64); !ok || exp < nowUnix {
s.adminSessions.Delete(key)
}
return true
})
s.adminSessions.Store(sessionClaims.SessionID, sessionClaims.Exp)
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookie,
Value: tok,
Path: "/",
MaxAge: int((8 * time.Hour).Seconds()),
HttpOnly: true,
Secure: adminCookieSecure(r),
SameSite: http.SameSiteStrictMode,
})
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Server) adminLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(adminSessionCookie); err == nil {
if c, err := s.auth.Parse(cookie.Value); err == nil && c.SessionID != "" {
s.adminSessions.Delete(c.SessionID)
}
}
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: adminCookieSecure(r),
SameSite: http.SameSiteStrictMode,
})
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Server) adminSession(w http.ResponseWriter, r *http.Request) {
c := claims(r)
jsonOut(w, 200, map[string]any{"ok": true, "role": c.Role, "expires_at": time.Unix(c.Exp, 0).UTC()})
}
func taskIntervals(t data.Task, sm settings.Runtime) (int, int) {
@@ -476,6 +653,19 @@ func (s *Server) guess(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 400, false)
return
}
if warmup := s.settings.Get().SybilWarmupSec; warmup > 0 {
created, err := s.store.ClientCreatedAt(r.Context(), c.ClientID)
if err != nil {
jsonOut(w, 401, false)
return
}
readyAt := created.Add(time.Duration(warmup) * time.Second)
if wait := time.Until(readyAt); wait > 0 {
retry := int(math.Ceil(wait.Seconds()))
jsonAPIError(w, http.StatusTooManyRequests, "identity_warmup", "new identity is still in anti-sybil warmup", map[string]any{"retry_after_sec": retry})
return
}
}
t, err := s.store.SecretTask(r.Context(), id)
if err != nil || t.Status != "active" {
jsonAPIError(w, http.StatusConflict, "task_inactive", "task is no longer active", nil)
@@ -751,6 +941,11 @@ func (s *Server) publicArtifactPreview(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) leaderboardWS(w http.ResponseWriter, r *http.Request) {
if !acquireWSCap(&s.leaderboardWSCount, s.maxLeaderboardWS) {
http.Error(w, "leaderboard websocket capacity reached", http.StatusServiceUnavailable)
return
}
defer s.leaderboardWSCount.Add(-1)
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
return
@@ -760,9 +955,31 @@ func (s *Server) leaderboardWS(w http.ResponseWriter, r *http.Request) {
defer s.hub.Remove(cl)
initial, _ := s.store.LiveLeaderboard(r.Context(), 200)
cl.Enqueue(wsx.Event{Type: "leaderboard", Data: initial})
conn.SetReadLimit(1024)
_ = conn.SetReadDeadline(time.Now().Add(90 * time.Second))
conn.SetPongHandler(func(string) error { return conn.SetReadDeadline(time.Now().Add(90 * time.Second)) })
ping := time.NewTicker(30 * time.Second)
defer ping.Stop()
done := make(chan struct{})
go func() {
defer close(done)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
for {
if _, _, err := conn.ReadMessage(); err != nil {
select {
case <-done:
return
case <-r.Context().Done():
return
case <-ping.C:
if err := cl.Ping(); err != nil {
return
}
}
}
}
@@ -1226,8 +1443,40 @@ func (s *Server) adminEnsure(w http.ResponseWriter, r *http.Request) {
jsonOut(w, 200, map[string]bool{"ok": true})
}
func websocketAuthToken(r *http.Request) string {
if h := strings.TrimSpace(r.Header.Get("Authorization")); strings.HasPrefix(h, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
}
for _, part := range strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "nh-auth.") {
return strings.TrimPrefix(part, "nh-auth.")
}
}
if strings.EqualFold(strings.TrimSpace(os.Getenv("WS_ALLOW_QUERY_TOKEN")), "1") {
return r.URL.Query().Get("token")
}
return ""
}
func acquireWSCap(counter *atomic.Int64, max int64) bool {
if max <= 0 {
counter.Add(1)
return true
}
for {
cur := counter.Load()
if cur >= max {
return false
}
if counter.CompareAndSwap(cur, cur+1) {
return true
}
}
}
func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
tok := r.URL.Query().Get("token")
tok := websocketAuthToken(r)
c, err := s.auth.Parse(tok)
if err != nil || c.Role != "user" {
http.Error(w, "unauthorized", 401)
@@ -1247,6 +1496,11 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
return
}
s.runtime.SetTaskSelection(c.ClientID, t.ID)
if !acquireWSCap(&s.userWSCount, s.maxUserWS) {
http.Error(w, "websocket capacity reached", http.StatusServiceUnavailable)
return
}
defer s.userWSCount.Add(-1)
leaseID, err := s.runtime.AcquirePresence(c.ClientID, c.SessionID)
if err != nil {
http.Error(w, "identity already connected", 409)
+57 -18
View File
@@ -13,24 +13,30 @@ import (
)
type Runtime struct {
GuessMinIntervalSec int `json:"guess_min_interval_sec"`
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
GuessLotteryWindowSec int `json:"guess_lottery_window_sec"`
GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"`
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"`
GuessMinIntervalSec int `json:"guess_min_interval_sec"`
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
GuessLotteryWindowSec int `json:"guess_lottery_window_sec"`
GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"`
SybilProofOfWorkBits int `json:"sybil_pow_bits"`
SybilWarmupSec int `json:"sybil_warmup_sec"`
OpenAIMaxCalls1H int `json:"openai_max_calls_1h"`
OpenAIMaxCalls24H int `json:"openai_max_calls_24h"`
OpenAIMaxCost24HUSD float64 `json:"openai_max_cost_24h_usd"`
OpenAIBudgetReserveUSD float64 `json:"openai_budget_reserve_usd"`
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 {
@@ -55,12 +61,27 @@ func envString(k, def string) string {
return def
}
func envFloat(k string, def float64) float64 {
if s := strings.TrimSpace(os.Getenv(k)); s != "" {
if n, err := strconv.ParseFloat(s, 64); err == nil {
return n
}
}
return def
}
func Defaults() Runtime {
return Runtime{
GuessMinIntervalSec: envInt("DEFAULT_GUESS_MIN_INTERVAL_SEC", 10),
ClientSubmitIntervalSec: envInt("DEFAULT_CLIENT_SUBMIT_INTERVAL_SEC", 11),
GuessLotteryWindowSec: envInt("DEFAULT_GUESS_LOTTERY_WINDOW_SEC", 60),
GuessLotteryMaxAccepted: envInt("DEFAULT_GUESS_LOTTERY_MAX_ACCEPTED", 0),
SybilProofOfWorkBits: envInt("DEFAULT_SYBIL_POW_BITS", 15),
SybilWarmupSec: envInt("DEFAULT_SYBIL_WARMUP_SEC", 15),
OpenAIMaxCalls1H: envInt("DEFAULT_OPENAI_MAX_CALLS_1H", 20),
OpenAIMaxCalls24H: envInt("DEFAULT_OPENAI_MAX_CALLS_24H", 100),
OpenAIMaxCost24HUSD: envFloat("DEFAULT_OPENAI_MAX_COST_24H_USD", 5.00),
OpenAIBudgetReserveUSD: envFloat("DEFAULT_OPENAI_BUDGET_RESERVE_USD", 0.20),
TaskRangeBits: envInt("DEFAULT_TASK_RANGE_BITS", 28),
ActiveTaskCount: envInt("DEFAULT_ACTIVE_TASK_COUNT", 1),
PresenceTTLSec: envInt("DEFAULT_PRESENCE_TTL_SEC", 35),
@@ -209,6 +230,24 @@ func Validate(v Runtime) error {
if v.GuessLotteryMaxAccepted < 0 || v.GuessLotteryMaxAccepted > 100000 {
return fmt.Errorf("guess_lottery_max_accepted must be 0..100000 (0 disables lottery)")
}
if v.SybilProofOfWorkBits < 0 || v.SybilProofOfWorkBits > 22 {
return fmt.Errorf("sybil_pow_bits must be 0..22")
}
if v.SybilWarmupSec < 0 || v.SybilWarmupSec > 86400 {
return fmt.Errorf("sybil_warmup_sec must be 0..86400")
}
if v.OpenAIMaxCalls1H < 0 || v.OpenAIMaxCalls1H > 100000 {
return fmt.Errorf("openai_max_calls_1h must be 0..100000")
}
if v.OpenAIMaxCalls24H < 0 || v.OpenAIMaxCalls24H > 1000000 {
return fmt.Errorf("openai_max_calls_24h must be 0..1000000")
}
if v.OpenAIMaxCost24HUSD < 0 || v.OpenAIMaxCost24HUSD > 100000 {
return fmt.Errorf("openai_max_cost_24h_usd must be 0..100000")
}
if v.OpenAIBudgetReserveUSD < 0 || v.OpenAIBudgetReserveUSD > 10000 {
return fmt.Errorf("openai_budget_reserve_usd must be 0..10000")
}
if v.TaskRangeBits < 8 || v.TaskRangeBits > 128 {
return fmt.Errorf("task_range_bits must be 8..128")
}
+24 -23
View File
@@ -8,7 +8,6 @@ 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}
@@ -20,11 +19,11 @@ 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 token = admin ? '' : 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});
const r = await fetch(path,{...init,headers,credentials:'same-origin'});
if (!r.ok) {
let msg = `HTTP ${r.status}`, payload = null;
try { payload=await r.json(); msg=payload?.error || msg; } catch {}
@@ -34,8 +33,8 @@ async function api(path, init={}, admin=false) {
}
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;
if(!img)return;const token=admin?'':getToken();const headers={};if(token)headers.Authorization='Bearer '+token;
const r=await fetch(path,{headers,credentials:'same-origin'});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
@@ -57,7 +56,9 @@ async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(r
async function clientId(pub){const subtle=requireWebCrypto();const s=`${pub.kty}|${pub.crv}|${pub.x}|${pub.y}`;return b64u(await subtle.digest('SHA-256',new TextEncoder().encode(s)))}
async function sign(message){const subtle=requireWebCrypto();const b=await ensureIdentity();const k=await subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);return b64u(await 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()}
function zeroBits(bytes){let n=0;for(const x of bytes){if(x===0){n+=8;continue}for(let m=0x80;m&&!(x&m);m>>=1)n++;break}return n}
async function solveIdentityProof(challenge,cid,bits){bits=Number(bits||0);if(bits<=0)return '';const subtle=requireWebCrypto(),enc=new TextEncoder(),prefix=`nh-pow-v1|${challenge}|${cid}|`;let counter=0;const batch=96;while(true){const nums=Array.from({length:batch},(_,i)=>counter+i),hashes=await Promise.all(nums.map(n=>subtle.digest('SHA-256',enc.encode(prefix+n))));for(let i=0;i<hashes.length;i++)if(zeroBits(new Uint8Array(hashes[i]))>=bits)return String(nums[i]);counter+=batch;if(counter%3072===0)await new Promise(r=>setTimeout(r,0))}}
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(),bits=Number(c.proof_of_work_bits||0);if(bits>0&&$('status'))$('status').textContent=`Neue Identität wird geprüft · ${bits}-Bit Proof-of-Work …`;const proof_of_work_counter=await solveIdentityProof(c.challenge,c.client_id,bits),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,proof_of_work_counter})});if(!r.ok)throw new Error(await responseError(r,'Login fehlgeschlagen'));return r.json()}
async function deterministicGuess(taskID,seed,cid,seq,bits){const subtle=requireWebCrypto();const h=new Uint8Array(await 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 subtle=requireWebCrypto();const b=await ensureIdentity();const salt=crypto.getRandomValues(new Uint8Array(16));const iv=crypto.getRandomValues(new Uint8Array(12));const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt,iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['encrypt']);const ct=await 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 subtle=requireWebCrypto();const x=JSON.parse(raw);const base=await subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await subtle.deriveKey({name:'PBKDF2',salt:ub64(x.salt),iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['decrypt']);const pt=await 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}
@@ -308,7 +309,7 @@ function userShell(){
<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>
<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>
</nav>
<section id="taskLanding" class="task-landing visible">
<div class="task-landing-bg"></div>
@@ -360,8 +361,8 @@ async function runUser(){
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}`);if(Number(task.guess_lottery_max_accepted||0)>0)status(`wartet auf Losziehung · max. ${Number(task.guess_lottery_max_accepted).toLocaleString('de-DE')} Tipps / ${Number(task.guess_lottery_window_sec||60)}s`,'thinking');const 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!':Number(task.guess_lottery_max_accepted||0)>0?'Tipp gezogen & geprüft':'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;if(e.code==='lottery_not_selected'){status('Tipp diesmal nicht gezogen · nächstes Los folgt','living');return}if(e.code==='lottery_full'){status('Losfenster voll · nächster Versuch folgt','living');return}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 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}`);if(Number(task.guess_lottery_max_accepted||0)>0)status(`wartet auf Losziehung · max. ${Number(task.guess_lottery_max_accepted).toLocaleString('de-DE')} Tipps / ${Number(task.guess_lottery_window_sec||60)}s`,'thinking');const 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!':Number(task.guess_lottery_max_accepted||0)>0?'Tipp gezogen & geprüft':'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;if(e.code==='identity_warmup'){const wait=Math.max(1,Number(e.data?.retry_after_sec||task?.client_submit_interval_sec||15));nextGuessAt=Date.now()+wait*1000;status(`Anti-Sybil-Wartezeit · ${wait}s`,'living');return}if(e.code==='lottery_not_selected'){status('Tipp diesmal nicht gezogen · nächstes Los folgt','living');return}if(e.code==='lottery_full'){status('Losfenster voll · nächster Versuch folgt','living');return}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?max_nodes=${encodeURIComponent(mx)}`,['neuralhunt.v1',`nh-auth.${getToken()}`]);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();
@@ -375,7 +376,7 @@ async function runUser(){
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>
<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></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>
@@ -409,10 +410,10 @@ async function runLeaderboard(){
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 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></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>
<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><span class="small">PRIVATE CONTROL PLANE</span><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">
@@ -423,13 +424,13 @@ function adminShell(){
}
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}
let adminOK=false;try{await api('/api/admin/session',{},true);adminOK=true}catch{}if(!adminOK){adminLoginShell();$('adminlogin').onclick=async()=>{try{const r=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'same-origin',body:JSON.stringify({user:$('adminuser').value,password:$('adminpass').value})});if(!r.ok)throw new Error('Login fehlgeschlagen');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','guess_lottery_window_sec','guess_lottery_max_accepted','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)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',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 runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','guess_lottery_window_sec','guess_lottery_max_accepted','sybil_pow_bits','sybil_warmup_sec','openai_max_calls_1h','openai_max_calls_24h','openai_max_cost_24h_usd','openai_budget_reserve_usd','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)',guess_lottery_window_sec:'Lotterie-Zeitfenster (s)',guess_lottery_max_accepted:'Max. gezogene Tipps je Task/Fenster (0 = aus)',sybil_pow_bits:'Anti-Sybil Proof-of-Work (Bit, 0 = aus)',sybil_warmup_sec:'Neue Identität Wartezeit (s)',openai_max_calls_1h:'OpenAI max. Bild-Calls / 1h (0 = aus)',openai_max_calls_24h:'OpenAI max. Bild-Calls / 24h (0 = aus)',openai_max_cost_24h_usd:'OpenAI max. geschätzte Kosten / 24h USD (0 = aus)',openai_budget_reserve_usd:'OpenAI Sicherheitsreserve pro nächstem Call USD',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}`;
@@ -439,10 +440,10 @@ async function runAdmin(){
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.rejected_per_sec||0).toFixed(1)}</b> Reject/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)}}
async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{credentials:'same-origin'});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=>{const aerr=String(t.artifact_error||'').trim();return `<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 class="artifact-state-wrap"><b class="artifact-state ${esc(t.artifact_status||'')}">${esc(t.artifact_status||'—')}</b>${aerr?`<small class="danger artifact-error-summary" title="${esc(aerr)}">${esc(aerr.length>76?aerr.slice(0,75)+'…':aerr)}</small>`:`<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">Die Tipp-Lotterie gilt <b>getrennt pro aktivem Task</b>. Bei einem Wert &gt; 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. <b>0 = Lotterie aus</b>. Änderungen greifen für neu beginnende Fenster.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
$('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" step="any" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Die Tipp-Lotterie gilt <b>getrennt pro aktivem Task</b>. Bei einem Wert &gt; 0 werden alle gültigen Tipps eines Zeitfensters gesammelt und am Fensterende exakt bis zur eingestellten Menge zufällig gezogen. Nicht gezogene Tipps werden nicht gegen das Ziel geprüft und verändern den Score nicht; ihre Sequenz wird trotzdem verbraucht. <b>0 = Lotterie aus</b>. Änderungen greifen für neu beginnende Fenster.</p><p class="small"><b>Anti-Sybil:</b> Neue Browser-Identitäten lösen einmalig einen Proof-of-Work und warten anschließend die konfigurierte Warmup-Zeit, bevor Tipps gewertet werden. Das erhöht die Kosten massenhafter Identitätserstellung, ersetzt aber keine externe echte Identitätsprüfung.</p><p class="small"><b>OpenAI Circuit Breaker:</b> Vor jedem Bild-Call werden rollierende 1h-/24h-Call-Limits und das geschätzte 24h-Kostenbudget geprüft. Bei Überschreitung bleibt die Gewinnerkarte in der Queue und wird später erneut versucht.</p><p class="small">Die übrigen Defaults gelten global. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
<div class="control-section profile-cleanup"><div class="section-title">ALTE PROFILE BEREINIGEN</div><p class="small">Löscht ausschließlich Accounts, die <b>seit mindestens X Zeit inaktiv</b>, aktuell <b>nicht verbunden</b> und <b>niemals Gewinner</b> eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.</p>
<div class="cleanup-controls"><label><span>Inaktiv seit mindestens</span><input id="profileCleanupValue" data-draft="profileCleanupValue" type="number" min="1" step="1" value="30"></label><label><span>Einheit</span><select id="profileCleanupUnit" data-draft="profileCleanupUnit"><option value="hours">Stunden</option><option value="days" selected>Tage</option><option value="weeks">Wochen</option></select></label></div>
<div class="cleanup-actions"><button id="previewProfileCleanup">PRÜFEN</button><button id="runProfileCleanup" class="danger-button">PROFILE LÖSCHEN</button></div><div id="profileCleanupResult" class="cleanup-result">Noch nicht geprüft.</div>
@@ -454,7 +455,7 @@ async function runAdmin(){
$('runProfileCleanup').onclick=async()=>{try{const p=await previewCleanup();const n=Number(p?.eligible||0);if(!n){msg('Keine passenden inaktiven Nicht-Gewinner-Profile gefunden');return}const value=$('profileCleanupValue').value,unitLabel=$('profileCleanupUnit').selectedOptions[0]?.textContent||'';if(!confirm(`${n.toLocaleString('de-DE')} Profile endgültig löschen?\n\nKriterium: seit mindestens ${value} ${unitLabel} inaktiv, offline und niemals Gewinner.\nGewinner und aktuell verbundene Accounts bleiben geschützt.`))return;const out=await api('/api/admin/profiles/cleanup',{method:'POST',body:JSON.stringify({inactive_for_seconds:cleanupSeconds()})},true);msg(`${Number(out.deleted||0).toLocaleString('de-DE')} alte Profile gelöscht`);showCleanup({...p,eligible:Math.max(0,n-Number(out.deleted||0))});await load(true,false)}catch(e){msg(e.message)}};
}
function renderArtifact(){
const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[];
const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},cb=u.circuit_breaker||{},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'){
@@ -464,7 +465,7 @@ async function runAdmin(){
<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="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 class="cost-card"><small>CIRCUIT BREAKER</small><b id="artifactCircuitState">${cb.blocked?'BLOCKED':'READY'}</b><span id="artifactCircuitMeta">${Number(cb.calls_1h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_1h||0).toLocaleString('de-DE')} Calls 1h · ${Number(cb.calls_24h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_24h||0).toLocaleString('de-DE')} Calls 24h · ${usd(cb.cost_24h_usd,3)} / ${usd(cb.max_cost_24h_usd,2)}</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>`;
@@ -501,7 +502,7 @@ async function runAdmin(){
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 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 resp=await fetch(r.url,{credentials:'same-origin'});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 pending. DONE im Audit bestätigt nur das Einreihen; der Artifact-Status zeigt danach generating, ready oder error.':'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)}};
@@ -515,20 +516,20 @@ async function runAdmin(){
// 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);
const cb=u.circuit_breaker||{};$('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);if($('artifactCircuitState'))$('artifactCircuitState').textContent=cb.blocked?'BLOCKED':'READY';if($('artifactCircuitMeta'))$('artifactCircuitMeta').textContent=`${Number(cb.calls_1h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_1h||0).toLocaleString('de-DE')} Calls 1h · ${Number(cb.calls_24h||0).toLocaleString('de-DE')} / ${Number(cb.max_calls_24h||0).toLocaleString('de-DE')} Calls 24h · ${usd(cb.cost_24h_usd,3)} / ${usd(cb.max_cost_24h_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 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){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()};
$('adminlogout').onclick=async()=>{try{await fetch('/api/admin/logout',{method:'POST',credentials:'same-origin'})}finally{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});
}
+17 -9
View File
@@ -17,15 +17,16 @@ type Event struct {
}
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
Conn *websocket.Conn
TaskID string
ClientID string
All bool
send chan []byte
closed chan struct{}
onWrite func(int)
onDrop func()
closeOnce sync.Once
dropStreak atomic.Uint32
}
func NewClient(conn *websocket.Conn, taskID, clientID string, all bool) *Client {
@@ -64,11 +65,18 @@ func (c *Client) EnqueueBytes(b []byte) bool {
}
select {
case c.send <- b:
c.dropStreak.Store(0)
return true
default:
if c.onDrop != nil {
c.onDrop()
}
// A persistently slow reader can otherwise hold memory/socket resources
// forever while every broadcast is dropped. Close after a short burst of
// consecutive queue overflows; healthy clients reset the streak on enqueue.
if c.dropStreak.Add(1) >= 8 {
c.Close()
}
return false
}
}