Files
Athena-Deck/endpoint-ui.js
T

72 lines
14 KiB
JavaScript

window.EndpointUI=(()=>{
const e=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const stateName={running:'Läuft',stopped:'Gestoppt',stopping:'Wird gestoppt · laufende Aufträge werden beendet',loading:'Modell lädt',ready:'Modell bereit',failed:'Fehlgeschlagen'};
let pending=false,sequence=0,selectedMode=null,controlTimer=null;
async function api(path='',data){const r=await fetch('/api/v1/endpoint'+path,data!==undefined?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Endpunkt nicht erreichbar');return v;}
function put(el,html){if(el._deckHtml!==html){el.innerHTML=html;el._deckHtml=html;}}
function summary(s){return `<div class="grid"><section class="card wide"><div class="card-top"><h2>OpenAI-kompatibler Endpunkt</h2><span class="pill">${e(stateName[s.state])}</span></div><div class="metrics"><div><small>Erreichbarkeit</small><strong>${s.reachable?'Listener bereit':'Nicht erreichbar'}</strong></div><div><small>Lokaler API-Port</small><strong>${s.port}</strong></div><div><small>Laufende / wartende Aufträge</small><strong>${s.scheduler.active_requests} / ${s.scheduler.waiting_requests}</strong></div></div><p><code>${e(s.base_url)}</code> · <a class="link" href="#endpoint">Port und Profile einstellen →</a></p><p>Geladen: ${e(s.worker.profile_name||'Kein Sprachmodell')} · ${e(stateName[s.worker.state]||s.worker.state)}</p>${s.worker.error||s.error?`<p role="alert">${e(s.worker.error||s.error)}</p>`:''}<p>Aktivierte Profile werden angeboten. Erst eine Anfrage lädt das Modell. Wechsel warten auf laufende Antworten; Bildaufträge entladen vorher das eigene Sprachmodell.</p></section>${[['llm','Sprachmodelle'],['image','Bildgenerierung'],['tts','Text → Sprache (TTS)'],['stt','Sprache → Text (STT)'],['music','Musik']].map(([key,label])=>{const c=s.counts[key];return `<section class="card"><h3>${label}</h3><p>${c.supported?`${c.enabled} Profile aktiviert · ${c.available} ausführbar`:'Noch keine eigene Laufzeit eingerichtet'}</p><span class="pill">${c.loaded?'AKTIV IM SPEICHER':c.available?'BEI ANFRAGE LADEN':'NICHT AKTIV'}</span></section>`}).join('')}</div>`;}
const modeLabels={llm:'LLM',video:'Video',music:'Musik'};
function control(s,root){
const v=s.video||{},active=v.mode||'llm';
if(!selectedMode)selectedMode=active==='switching'?(v.target_mode||'llm'):active;
const selected=selectedMode,label=modeLabels[selected],switching=v.state==='switching',busy=pending||switching||s.state==='stopping';
const current=selected===active,available=selected==='video'?Boolean(v.service?.id):Boolean(s.counts[selected]?.available);
const ready=current&&s.reachable&&!switching&&(selected!=='video'||v.service?.ready);
const loading=current&&selected==='llm'&&s.worker.state==='loading';
const address=location.protocol+'//'+location.hostname+':'+s.port;
const routes=selected==='llm'?[['Chat',address+'/v1/chat/completions'],['Modelle',address+'/v1/models']]:selected==='music'?[['Musik erzeugen',address+'/v1/audio/music'],['Musikmodelle',address+'/v1/audio/music/models']]:[['Video-API',address+'/']];
const reason=available?'':selected==='video'?'Wähle zuerst ein eingerichtetes Videomodell.':'Gib zuerst ein ausführbares Profil am API-Endpunkt frei.';
const state=switching?'Wechsel läuft':loading?'Modell wird geladen':ready?'Bereit':current&&s.state==='stopping'?'Wird gestoppt':current&&s.reachable?'Startet':current?'Gestoppt':'Nicht aktiv';
put(root.querySelector('#control-modes'),`<div class="control-modes" role="tablist" aria-label="Betriebsart">${Object.entries(modeLabels).map(([id,name])=>`<button role="tab" aria-selected="${selected===id}" data-control-mode="${id}" class="${selected===id?'chosen':'secondary'}" ${busy?'disabled':''}>${name}</button>`).join('')}</div>`);
put(root.querySelector('#endpoint-summary'),`<section class="card control-card" role="tabpanel"><div class="control-heading"><h2>${label}</h2><span class="pill">${e(state)}</span></div><p>${active==='switching'?`Wechsel zu ${e(modeLabels[v.target_mode]||v.target_mode)}`:s.reachable?`Aktiver Betrieb: ${e(modeLabels[active]||active)}`:'Betrieb gestoppt'}</p><div class="card-actions"><button data-control-start ${busy||!available||ready?'disabled':''}>${label} starten</button><button data-control-stop class="secondary" ${busy||!current||(s.state==='stopped'&&active==='llm')?'disabled':''}>Stoppen</button></div>${switching||loading||pending||s.state==='stopping'?`<div class="video-activity" role="status" aria-live="polite"><strong>${e(switching?v.switch_phase:loading?s.worker.phase||'Modell wird geladen':s.state==='stopping'?'Laufende Aufträge werden beendet':'Start wird angefordert')}</strong><progress aria-label="Fortschritt"></progress></div>`:''}${reason?`<p class="warning">${e(reason)} <a class="link" href="#${selected==='video'?'video':selected==='music'?'music':'chat'}">Einrichten →</a></p>`:''}${(current&&(v.error||s.error||selected==='llm'&&s.worker.error))?`<p class="video-error" role="alert">${e(v.error||s.error||s.worker.error)}</p>`:''}<div class="control-api"><h3>API-Adresse</h3>${routes.map(([name,url])=>`<div><span>${name}</span><code>${e(url)}</code></div>`).join('')}<p class="small">${ready?'Erreichbar':'Erreichbar, sobald dieser Betrieb bereit ist'} · Port ${s.port}</p></div>${selected==='llm'?`<p class="small">${s.counts.llm.available} Chatprofile freigegeben · ${e(s.worker.profile_name||'Gewichte laden bei der ersten Anfrage')}</p>`:selected==='music'?`<p class="small">${e(v.music_profiles?.map(p=>p.name).join(', ')||'Kein Musikprofil freigegeben')} · Gewichte laden bei Anfrage</p>${available?`<a class="link" href="${e(location.protocol+'//'+location.hostname+':8127/')}" target="_blank" rel="noopener">LadyPoly YuE2 öffnen →</a>${!ready?'<p class="small">Vor dem Generieren Musik starten.</p>':''}`:''}`:`<p class="small">${e(v.service?.name||'Kein Videomodell ausgewählt')} · ${v.runtime_kind==='original'?'LTX Original':'ComfyUI'} · Gewichte laden bei Generierung</p>${ready?`<a class="link" href="${e(v.runtime_kind==='original'?location.protocol+'//'+location.hostname+':8118/':address+'/')}" target="_blank" rel="noopener">${v.runtime_kind==='original'?'LTX DeskWEB':'ComfyUI'} öffnen →</a>`:''}`}<details class="control-details"><summary>Weitere Dienste & Einstellungen</summary><p>Im LLM-Betrieb stehen auch freigegebene Bild-, TTS- und STT-Profile bereit.</p><p><a class="link" href="#endpoint">Endpunkt und Port einstellen →</a></p><p>${s.scheduler.active_requests} laufende · ${s.scheduler.waiting_requests} wartende Aufträge</p></details></section>`);
root.querySelectorAll('[data-control-mode]').forEach(b=>b.onclick=()=>{selectedMode=b.dataset.controlMode;control(s,root);});
root.querySelector('[data-control-start]').onclick=()=>controlAction('start',selected);
root.querySelector('[data-control-stop]').onclick=()=>controlAction('stop',selected);
}
async function switchMode(mode){
const r=await fetch('/api/v1/video/mode',{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify({mode})});
const result=await r.json();if(!r.ok)throw Error(result.error||'Wechsel fehlgeschlagen');return result;
}
async function controlAction(action,mode){
if(pending)return;pending=true;const root=document.querySelector('#endpoint-page');
root.querySelectorAll('button').forEach(b=>b.disabled=true);
root.querySelector('#endpoint-message').innerHTML=`<div class="video-activity" role="status"><strong>${action==='start'?e(modeLabels[mode])+' wird gestartet':'Betrieb wird gestoppt'}</strong><progress aria-label="Fortschritt"></progress></div>`;
try{
const s=await api();
if(action==='start'){
if((s.video?.mode||'llm')!==mode||(mode==='video'&&!s.video?.service?.ready))await switchMode(mode);
if(s.state==='stopped')await api('/start',{});
}else{
await api('/stop',{});
if(s.video&&s.video.mode!=='llm')await switchMode('llm');
}
if(root.isConnected)root.querySelector('#endpoint-message').textContent='';
}catch(error){if(root.isConnected)root.querySelector('#endpoint-message').textContent=error.message;}
finally{pending=false;if(root.isConnected){root.querySelector('#control-modes')._deckHtml=null;root.querySelector('#endpoint-summary')._deckHtml=null;render(false);}}
}
async function render(settings=false){
const view=document.querySelector('#view');let root=view.querySelector('#endpoint-page');
if(!root||root.dataset.settings!==String(settings)){
sequence++;clearTimeout(controlTimer);selectedMode=null;view.innerHTML=`<section id="endpoint-page" data-settings="${settings}"><div class="kicker">${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}</div><h1>${settings?'Endpunkt':'Steuerung'}</h1>${settings?'<p>API-Port und freigegebene Profile verwalten.</p><div class="card-actions"><button id="endpoint-start">Endpunkt starten</button><button class="secondary" id="endpoint-stop">Endpunkt stoppen</button></div>':'<p>Betriebsart wählen und starten.</p><div id="control-modes"></div>'}<p id="endpoint-message" role="status"></p><div id="video-mode-panel"></div><div id="endpoint-summary"></div>${settings?'<section class="card"><h2>Lokaler API-Port</h2><form id="endpoint-port-form"><label>Port<input id="endpoint-port" name="port" type="number" required min="1024" max="65535"></label><button id="endpoint-port-save">Port speichern</button></form><p id="endpoint-port-help"></p><p>Der Zugriff verwendet den API-Token aus <a class="link" href="#access">Zugang & API-Token</a>. Verwaltungsoberfläche und API haben getrennte Ports.</p></section><section class="card"><h2>Am Endpunkt angebotene Profile</h2><p>Nur explizit aktivierte, ausführbare Profile erscheinen in <code>GET /v1/models</code>. Aktivieren lädt noch kein Modell.</p><div id="endpoint-profiles"></div></section><section class="card"><h2>API-Routen</h2><p><code>POST /v1/chat/completions</code> · Text und Streaming<br><code>POST /v1/images/generations</code> · Qwen-Image-Rezept, ein Bild als b64_json<br><code>POST /v1/audio/speech</code> und <code>POST /v1/audio/transcriptions</code> · noch nicht eingerichtet (501)</p><p>Alle Routen nutzen denselben Port und Bearer-Token. Bei Bildaufträgen bestimmt das Profil Auflösung, Schritte, Guidance und Seed. Noch keine Bildbearbeitung oder Vision-Eingaben.</p></section>':''}</section>`;
root=view.querySelector('#endpoint-page');
if(settings)for(const action of ['start','stop'])root.querySelector('#endpoint-'+action).onclick=()=>mutate('/'+action,{});
root.querySelector('#endpoint-port-form')?.addEventListener('submit',event=>{event.preventDefault();mutate('/config',{port:Number(root.querySelector('#endpoint-port').value)});});
}
if(pending)return;const id=sequence;
try{const s=await api();if(!root.isConnected||id!==sequence)return;
if(!settings){control(s,root);clearTimeout(controlTimer);if(s.video?.state==='switching'||s.worker.state==='loading'||s.state==='stopping')controlTimer=setTimeout(()=>{if(root.isConnected)render(false);},1000);return;}
put(root.querySelector('#endpoint-summary'),summary(s));
root.querySelector('#endpoint-start').disabled=s.state!=='stopped';root.querySelector('#endpoint-stop').disabled=s.state!=='running';
if(settings){const port=root.querySelector('#endpoint-port');if(document.activeElement!==port)port.value=s.port;port.disabled=s.state!=='stopped';root.querySelector('#endpoint-port-save').disabled=s.state!=='stopped';
root.querySelector('#endpoint-port-help').textContent=s.allowed_ports.length?'Docker-Testinstallation: veröffentlichte Loopback-Ports '+s.allowed_ports.join(', ')+'. Portwechsel nur bei gestopptem Endpunkt. Der SSH-Tunnel muss denselben Port weiterleiten.':'Nativer Dienst: freie Ports zwischen 1024 und 65535, nur bei gestopptem Endpunkt.';
put(root.querySelector('#endpoint-profiles'),s.profiles.map(p=>`<div class="note"><strong>${e(p.name)}</strong> · ${e(p.kind)} · ${p.runnable?'ausführbar':e(p.blockers.join(' '))}<br><button class="secondary" data-enable="${e(p.id)}" data-value="${!p.enabled}" ${!p.runnable&&!p.enabled?'disabled':''}>${p.enabled?'Am Endpunkt deaktivieren':'Am Endpunkt aktivieren'}</button></div>`).join('')||'<p>Noch keine Profile angelegt. Erstelle sie in der jeweiligen Modellkategorie.</p>');
root.querySelectorAll('[data-enable]').forEach(b=>{const p=s.profiles.find(p=>p.id===b.dataset.enable);b.disabled=!p.runnable&&!p.enabled;b.onclick=()=>mutate('/profile',{id:b.dataset.enable,enabled:b.dataset.value==='true'});});
}
}catch(error){if(root.isConnected)root.querySelector('#endpoint-message').textContent=error.message;}
}
async function mutate(path,data){if(pending)return;pending=true;const root=document.querySelector('#endpoint-page');root.querySelectorAll('button').forEach(b=>b.disabled=true);try{await api(path,data);root.querySelector('#endpoint-message').textContent=path==='/stop'?'Neue Anfragen werden abgewiesen; laufende Aufträge werden beendet.':'Einstellung übernommen.';}catch(error){if(root.isConnected)root.querySelector('#endpoint-message').textContent=error.message;}finally{pending=false;if(root.isConnected)render(root.dataset.settings==='true');}}
const runningHTML=()=>'<section id="endpoint-running"><p>Laufzeitstatus wird geladen …</p></section>';
async function bindRunning(){const root=document.querySelector('#endpoint-running');if(!root)return;try{const s=await api();if(root.isConnected)root.innerHTML=`<section class="card"><h2>${e(s.worker.profile_name||'Kein Sprachmodell geladen')}</h2><p>${e(stateName[s.worker.state]||s.worker.state)} · ${s.scheduler.active_requests} laufende Anfragen · ${s.scheduler.waiting_requests} wartend</p><p>${e(s.worker.error||'Aktivierte Profile werden bei einer API-Anfrage automatisch geladen.')}</p><a class="link" href="#endpoint">Endpunkt und Profile verwalten →</a></section>`;}catch(error){if(root.isConnected)root.textContent=error.message;}if(root.isConnected)setTimeout(()=>{if(root.isConnected)bindRunning();},3000);}
return {render,runningHTML,bindRunning};
})();