Vendored
+24
-23
@@ -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 > 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 > 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});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user