Simplify control into LLM video and music modes

This commit is contained in:
Mikei386
2026-10-01 09:59:32 +02:00
parent a72ebe045a
commit 7b96d4b3bc
3 changed files with 59 additions and 4 deletions
+13
View File
@@ -264,3 +264,16 @@ aktuellen Browserseite. Beim erneuten Öffnen einer Bibliothek erscheinen deren
zuletzt geladene Dateien sofort mit Aktualisierungshinweis. Aktionen bleiben bis zuletzt geladene Dateien sofort mit Aktualisierungshinweis. Aktionen bleiben bis
zur frischen Serverantwort gesperrt; Löschschutz und Profilzuordnungen werden zur frischen Serverantwort gesperrt; Löschschutz und Profilzuordnungen werden
weiterhin serverseitig geprüft. Ein Neuladen der Browserseite leert diesen Cache. weiterhin serverseitig geprüft. Ein Neuladen der Browserseite leert diesen Cache.
### Vereinfachte Steuerung
Unter Steuerung zuerst LLM, Video oder Musik auswählen und anschließend starten.
Die Auswahl allein verändert den laufenden Betrieb nicht. Start wechselt bei
Bedarf die Betriebsart und öffnet den gemeinsamen API-Listener. Stoppen schließt
den Listener und beendet bei Video/Musik den exklusiven Betrieb durch Rückkehr
auf LLM; die bisherigen Schutzprüfungen und Wartebedingungen bleiben bestehen.
Während eines Wechsels erscheint ein unbestimmter Ladebalken mit der echten
Wechselphase, ohne erfundene Prozentangabe. Bereit bedeutet API-erreichbar; der
Hinweis zu Gewichten unterscheidet geladene Modelle vom Laden bei Anfrage.
API-Adressen und der Port werden für die ausgewählte Betriebsart angezeigt.
Technische Konfiguration und Profilfreigaben bleiben unter Einstellungen → Endpunkt.
+43 -4
View File
@@ -1,22 +1,61 @@
window.EndpointUI=(()=>{ window.EndpointUI=(()=>{
const e=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 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'}; 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; 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;} 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 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>`;} 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>`:`<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){ async function render(settings=false){
const view=document.querySelector('#view');let root=view.querySelector('#endpoint-page'); const view=document.querySelector('#view');let root=view.querySelector('#endpoint-page');
if(!root||root.dataset.settings!==String(settings)){ if(!root||root.dataset.settings!==String(settings)){
sequence++;view.innerHTML=`<section id="endpoint-page" data-settings="${settings}"><div class="kicker">${settings?'EINSTELLUNGEN / API':'ATHENA DECK / ROUTER'}</div><h1>${settings?'Endpunkt':'Steuerung'}</h1><p>Eigener Router für Deck-Profile. Bestehende Athena-Dienste bleiben unabhängig.</p><div class="card-actions"><button id="endpoint-start">Endpunkt starten</button><button class="secondary" id="endpoint-stop">Endpunkt stoppen</button></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>`; 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'); root=view.querySelector('#endpoint-page');
for(const action of ['start','stop'])root.querySelector('#endpoint-'+action).onclick=()=>mutate('/'+action,{}); 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)});}); 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; if(pending)return;const id=sequence;
try{const s=await api();if(!root.isConnected||id!==sequence)return; 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)); put(root.querySelector('#endpoint-summary'),summary(s));
if(s.video)VideoUI.mode(root.querySelector('#video-mode-panel'),s.video,s.profiles,s.port);
root.querySelector('#endpoint-start').disabled=s.state!=='stopped';root.querySelector('#endpoint-stop').disabled=s.state!=='running'; 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'; 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.'; 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.';
+3
View File
@@ -42,3 +42,6 @@ nav a.nav-sub{padding-left:28px;font-size:12px;color:var(--deck-tone-a4bda8, #a4
/* Compact, expandable detail lists share one interaction across model categories. */ /* Compact, expandable detail lists share one interaction across model categories. */
.detail-list{display:grid;gap:8px;margin-top:20px}.detail-row{border:1px solid var(--deck-tone-303a37,#303a37);border-radius:9px;background:var(--deck-tone-181f21,#181f21);overflow:hidden}.detail-summary{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1.3fr) minmax(110px,.7fr) minmax(140px,1fr);gap:18px;align-items:center;padding:18px 18px 18px 38px;position:relative;list-style:none;line-height:1.5}.detail-summary::-webkit-details-marker{display:none}.detail-summary:before{content:'▸';position:absolute;left:16px;top:19px;color:var(--deck-tone-c5edaf,#c5edaf)}.detail-row[open]>.detail-summary:before{content:'▾'}.detail-summary:hover{background:var(--deck-tone-20292a,#20292a)}.detail-summary:focus-visible{outline:2px solid var(--deck-tone-c5edaf,#c5edaf);outline-offset:-3px}.detail-summary strong{font-size:14px;overflow-wrap:anywhere}.detail-summary span{font-size:12px;color:var(--deck-tone-97a79e,#97a79e);overflow-wrap:anywhere}.detail-summary small{display:block;margin-top:4px}.detail-body{padding:8px 22px 22px;border-top:1px solid var(--deck-tone-303a37,#303a37)}.detail-body>.card-actions{margin-top:16px}.detail-body .execution-setup{margin-top:12px;padding-top:12px}.detail-row .endpoint-choice{margin:0 8px 8px 0;font-size:12px}#studio[data-page=runtimes] .detail-summary{grid-template-columns:1fr 1fr 1fr}.download-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 20px;padding:18px;margin-top:8px;border-radius:9px}.download-row .card-top,.download-row progress,.download-row .warning{grid-column:1/-1}.download-row h3,.download-row p{margin:0}.download-row h3{font-size:14px;overflow-wrap:anywhere}.download-row .hub-filename{grid-column:1/-1}.download-row button{margin:0;font-size:12px}.download-row .card-top{display:flex;justify-content:space-between}.download-row .card-top strong{font-size:14px}.nav-heading{margin:16px 12px 4px}.eyebrow{margin-top:24px}aside{padding-top:24px}nav a{padding:9px 12px}nav .nav-heading:first-child{margin-top:0}.studio-tabs{flex-wrap:wrap} .detail-list{display:grid;gap:8px;margin-top:20px}.detail-row{border:1px solid var(--deck-tone-303a37,#303a37);border-radius:9px;background:var(--deck-tone-181f21,#181f21);overflow:hidden}.detail-summary{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1.3fr) minmax(110px,.7fr) minmax(140px,1fr);gap:18px;align-items:center;padding:18px 18px 18px 38px;position:relative;list-style:none;line-height:1.5}.detail-summary::-webkit-details-marker{display:none}.detail-summary:before{content:'▸';position:absolute;left:16px;top:19px;color:var(--deck-tone-c5edaf,#c5edaf)}.detail-row[open]>.detail-summary:before{content:'▾'}.detail-summary:hover{background:var(--deck-tone-20292a,#20292a)}.detail-summary:focus-visible{outline:2px solid var(--deck-tone-c5edaf,#c5edaf);outline-offset:-3px}.detail-summary strong{font-size:14px;overflow-wrap:anywhere}.detail-summary span{font-size:12px;color:var(--deck-tone-97a79e,#97a79e);overflow-wrap:anywhere}.detail-summary small{display:block;margin-top:4px}.detail-body{padding:8px 22px 22px;border-top:1px solid var(--deck-tone-303a37,#303a37)}.detail-body>.card-actions{margin-top:16px}.detail-body .execution-setup{margin-top:12px;padding-top:12px}.detail-row .endpoint-choice{margin:0 8px 8px 0;font-size:12px}#studio[data-page=runtimes] .detail-summary{grid-template-columns:1fr 1fr 1fr}.download-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 20px;padding:18px;margin-top:8px;border-radius:9px}.download-row .card-top,.download-row progress,.download-row .warning{grid-column:1/-1}.download-row h3,.download-row p{margin:0}.download-row h3{font-size:14px;overflow-wrap:anywhere}.download-row .hub-filename{grid-column:1/-1}.download-row button{margin:0;font-size:12px}.download-row .card-top{display:flex;justify-content:space-between}.download-row .card-top strong{font-size:14px}.nav-heading{margin:16px 12px 4px}.eyebrow{margin-top:24px}aside{padding-top:24px}nav a{padding:9px 12px}nav .nav-heading:first-child{margin-top:0}.studio-tabs{flex-wrap:wrap}
@media(max-width:1000px){.detail-summary{grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px 18px}}@media(max-width:600px){.detail-summary,#studio[data-page=runtimes] .detail-summary{grid-template-columns:1fr;gap:5px}.detail-body{padding:8px 16px 16px}.download-row{grid-template-columns:1fr}.download-row>*{grid-column:1!important}nav{grid-template-columns:1fr 1fr}nav .nav-heading{grid-column:1/-1}.card-actions{gap:6px}} @media(max-width:1000px){.detail-summary{grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px 18px}}@media(max-width:600px){.detail-summary,#studio[data-page=runtimes] .detail-summary{grid-template-columns:1fr;gap:5px}.detail-body{padding:8px 16px 16px}.download-row{grid-template-columns:1fr}.download-row>*{grid-column:1!important}nav{grid-template-columns:1fr 1fr}nav .nav-heading{grid-column:1/-1}.card-actions{gap:6px}}
/* Control is a focused mode chooser; detailed API configuration lives in settings. */
.control-modes{display:flex;gap:12px;margin:24px 0 20px}.control-modes button{min-width:110px;margin:0;font-size:15px}.control-card{max-width:900px}.control-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.control-heading h2{margin:0;font-size:25px}.control-card>.card-actions{margin-top:18px}.control-api{border-top:1px solid var(--deck-tone-303a37,#303a37);margin-top:24px;padding-top:8px}.control-api h3{font-size:14px}.control-api>div{display:grid;grid-template-columns:130px minmax(0,1fr);gap:14px;margin:12px 0;font-size:13px}.control-api span{color:var(--deck-tone-97a79e,#97a79e)}.control-api code{overflow-wrap:anywhere}.control-details{border-top:1px solid var(--deck-tone-303a37,#303a37);padding-top:16px;margin-top:20px}.control-card .small{font-size:12px}@media(max-width:600px){.control-modes{gap:8px}.control-modes button{min-width:0;flex:1;padding:12px}.control-api>div{grid-template-columns:1fr;gap:4px}}