Enable public model catalogue and isolated file downloads; define native target
This commit is contained in:
+33
-2
@@ -38,6 +38,37 @@ Diese Befehle verwalten ausschließlich den eigenen, per Label geprüften
|
|||||||
Deck-Container. Produktiver Router, Modelle, WireGuard und Hostkonfiguration
|
Deck-Container. Produktiver Router, Modelle, WireGuard und Hostkonfiguration
|
||||||
werden nicht verändert. Kein Docker-Socket im Webcontainer.
|
werden nicht verändert. Kein Docker-Socket im Webcontainer.
|
||||||
|
|
||||||
Modellkatalog, Downloads, Profile und llama.cpp-Build/Update sind weiterhin
|
Profile und llama.cpp-Build/Update sind weiterhin GUI-Entwürfe. Die Netzwerk-Erweiterung ist in dieser Installation deaktiviert.
|
||||||
GUI-Entwürfe. Die Netzwerk-Erweiterung ist in dieser Installation deaktiviert.
|
|
||||||
Das Verschieben der Entwicklung aktiviert keine dieser Funktionen automatisch.
|
Das Verschieben der Entwicklung aktiviert keine dieser Funktionen automatisch.
|
||||||
|
|
||||||
|
## Verbindliche Zielarchitektur
|
||||||
|
|
||||||
|
Das Produkt wird ein nativer Debian-systemd-Dienst unter eingeschränktem Benutzer.
|
||||||
|
Docker ist ausschließlich die vorläufige Testverpackung. Katalog, Downloads und
|
||||||
|
spätere Worker-Adapter dürfen Docker nicht voraussetzen. Kein Docker-Socket und
|
||||||
|
keine Hostprozesssteuerung über Docker. Der vorhandene WireGuard-Container bleibt
|
||||||
|
unverändert; Zugriff derzeit über SSH-Tunnel.
|
||||||
|
|
||||||
|
## Live-Funktionen ab 0.5
|
||||||
|
|
||||||
|
Hugging-Face-Suche nach Bereich, feste Repository-Revision, Dateiauswahl und
|
||||||
|
serieller Download öffentlicher GGUF-/Safetensors-/JSON-Dateien. Eigener Ordner
|
||||||
|
`runtime/state/models`; Fortschritt, Abbruch, 10 GiB Speicherreserve, Größenprüfung
|
||||||
|
und bei vorhandenem LFS-Prüfwert SHA-256-Abgleich. Bei Neustart wird ein laufender
|
||||||
|
Download nicht fortgesetzt; unvollständige Dateien sind keine Bibliothekseinträge.
|
||||||
|
Keine automatische Ausführung heruntergeladener Dateien. Gated/private Modelle,
|
||||||
|
Paketauflösung, VRAM-/Kontextprognose und Worker-Starts fehlen noch.
|
||||||
|
|
||||||
|
Interne API (nur angemeldeter Administrator):
|
||||||
|
- GET `/api/v1/catalog/search?q=...&kind=chat|image|audio|video`
|
||||||
|
- GET `/api/v1/catalog/files?repo=owner/name`
|
||||||
|
- GET `/api/v1/catalog`: Bibliothek, Downloadstatus, freier Speicher
|
||||||
|
- POST `/api/v1/catalog/download`: repo, filename, revision, kind
|
||||||
|
- POST `/api/v1/catalog/cancel`: leeres JSON-Objekt
|
||||||
|
|
||||||
|
Anbieter-Dokumentation: https://huggingface.co/docs/hub/api . Audio-Suche filtert
|
||||||
|
zunächst Sprachausgabe; weitere Audioaufgaben folgen. Einzelne Dateien stellen
|
||||||
|
noch keine vollständige Installation eines mehrteiligen Modells dar.
|
||||||
|
|
||||||
|
Update-Sicherungen enthalten den Konfigurationszustand, nicht die heruntergeladenen
|
||||||
|
Modelldateien. Diese bleiben im persistenten Zustandsverzeichnis erhalten.
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript.
|
Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript.
|
||||||
Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10.
|
Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10.
|
||||||
|
|
||||||
## Installation auf Debian
|
## Vorläufige Testinstallation auf Debian
|
||||||
|
|
||||||
Ein eigenständiger Installer ohne WireGuard ist vorhanden: [Debian-Installation](INSTALL.md).
|
Zielprodukt: nativer systemd-Dienst auf Debian. Der derzeitige Docker-Installer dient ausschließlich der isolierten Testphase.
|
||||||
|
|
||||||
|
Ein eigenständiger Testinstaller ohne WireGuard ist vorhanden: [Debian-Installation](INSTALL.md).
|
||||||
Auf dem Zielserver: `sudo ./install.sh --check`, anschließend `sudo ./install.sh --install`.
|
Auf dem Zielserver: `sudo ./install.sh --check`, anschließend `sudo ./install.sh --install`.
|
||||||
Vorhandenes Docker wird vorausgesetzt; produktive Dienste werden nicht verändert.
|
Vorhandenes Docker wird vorausgesetzt; produktive Dienste werden nicht verändert.
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ Vorhandenes Docker wird vorausgesetzt; produktive Dienste werden nicht veränder
|
|||||||
|
|
||||||
Katalog, Bibliothek, Profilentwürfe und eine Laufzeit-/Update-Ansicht sind als
|
Katalog, Bibliothek, Profilentwürfe und eine Laufzeit-/Update-Ansicht sind als
|
||||||
bedienbarer GUI-Prototyp vorhanden. Entwürfe bleiben im Browser; Modellstarts,
|
bedienbarer GUI-Prototyp vorhanden. Entwürfe bleiben im Browser; Modellstarts,
|
||||||
Downloads und Builds sind deaktiviert. Details: [GUI-Prototyp](STUDIO.md).
|
Builds und Modellstarts sind deaktiviert. Katalogsuche und Datei-Downloads sind live; siehe DEVELOPMENT.md. Details: [GUI-Prototyp](STUDIO.md).
|
||||||
|
|
||||||
## Zugang und API-Token
|
## Zugang und API-Token
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
# Stand 0.5
|
||||||
|
|
||||||
|
Katalog und Datei-Downloads sind jetzt live. Die folgende Beschreibung der
|
||||||
|
Katalogbeispiele ist historisch (0.4). Aktuelle API und Grenzen: DEVELOPMENT.md.
|
||||||
|
Profile und Laufzeitsteuerung bleiben Entwürfe.
|
||||||
|
|
||||||
# Modellverwaltung und llama.cpp · GUI-Prototyp 0.4
|
# Modellverwaltung und llama.cpp · GUI-Prototyp 0.4
|
||||||
|
|
||||||
Diese Erweiterung ist ausdrücklich rein optisch und lokal bedienbar. Keine
|
Diese Erweiterung ist ausdrücklich rein optisch und lokal bedienbar. Keine
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
const CatalogUI=(()=>{
|
||||||
|
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const size=n=>(n/1024**3).toFixed(2)+' GiB';
|
||||||
|
async function api(path,data){const r=await fetch('/api/v1/catalog'+path,data?{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||'Anfrage fehlgeschlagen');return v;}
|
||||||
|
function html(library){return `<section id="live-catalog" class="card"><span class="pill">LIVE · HUGGING FACE</span><h2>${library?'Dateien auf Athena':'Modelle suchen'}</h2><p>Öffentliche Repositories. Downloads laden kein Modell in RAM oder VRAM. Für Bild, Audio und Video können mehrere Dateien und ein eigener Worker nötig sein.</p>${library?'':'<form id="hub-search"><label>Suchbegriff<input name="q" maxlength="120" placeholder="Qwen, FLUX, Whisper …"></label><button>Suchen</button></form>'}<button class="secondary" id="hub-refresh">Downloadstatus aktualisieren</button><p id="hub-message" role="status"></p><div id="hub-job"></div><div id="hub-results"></div><div id="hub-files"></div></section>`;}
|
||||||
|
function bind(kind,library){
|
||||||
|
const root=document.querySelector('#live-catalog');if(!root)return;
|
||||||
|
const message=t=>{if(root.isConnected)root.querySelector('#hub-message').textContent=t;};
|
||||||
|
async function refresh(){try{const v=await api('');if(!root.isConnected)return;const j=v.job;root.querySelector('#hub-job').innerHTML=`<p>Freier Speicher: ${size(v.free_bytes)} · Reserve: 10 GiB</p>`+(j?`<p>${e(j.repo)} / ${e(j.file)} · ${e(j.state)} · ${size(j.bytes)} / ${size(j.total)}</p>${j.error?`<p>${e(j.error)}</p>`:''}${j.state==='downloading'?'<button id="hub-cancel" class="secondary">Download abbrechen</button>':''}`:'');root.querySelector('#hub-cancel')?.addEventListener('click',async()=>{await api('/cancel',{});message('Abbruch angefordert; laufende Netzwerkabfrage kann noch bis zu 20 Sekunden dauern.');});if(library)root.querySelector('#hub-results').innerHTML=v.entries.filter(x=>x.kind===kind).map(x=>`<article class="card"><h3>${e(x.repo)}</h3><p>${e(x.file)} · ${size(x.size)}</p><p>Heruntergeladen · Laufzeit noch nicht angebunden</p><small>Revision ${e(x.revision)} · ${x.upstream_hash_verified?'SHA-256 mit Quelle geprüft':'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</small></article>`).join('')||'<p>Noch keine Dateien für diesen Bereich heruntergeladen.</p>';if(j?.state==='downloading')setTimeout(()=>{if(root.isConnected)refresh();},2000);}catch(err){message(err.message);}}
|
||||||
|
root.querySelector('#hub-refresh').onclick=refresh;
|
||||||
|
root.querySelector('#hub-search')?.addEventListener('submit',async event=>{event.preventDefault();message('Suche …');try{const v=await api('/search?'+new URLSearchParams({q:new FormData(event.target).get('q'),kind}));if(!root.isConnected)return;root.querySelector('#hub-results').innerHTML=v.models.map(m=>`<article class="card"><h3>${e(m.repo)}</h3><p>${m.gated?'Zugangsbeschränkt':'Öffentlich'} · ${e(m.downloads)} Downloads</p><button data-repo="${e(m.repo)}">Dateien auswählen</button></article>`).join('');message(v.models.length+' Ergebnisse');root.querySelectorAll('[data-repo]').forEach(b=>b.onclick=()=>files(b.dataset.repo));}catch(err){message(err.message);}});
|
||||||
|
async function files(repo){message('Dateien werden gelesen …');try{const v=await api('/files?'+new URLSearchParams({repo}));if(!root.isConnected)return;root.querySelector('#hub-files').innerHTML=`<h3>${e(repo)}</h3><p>Lizenz: ${e(v.license||'Nicht angegeben')} · <a target="_blank" rel="noopener noreferrer" href="${e(v.url)}">Modellkarte und Bedingungen lesen</a></p><p>VRAM- und Kontext-Eignung noch nicht berechnet. Dateigröße allein belegt keine Lauffähigkeit.</p>`+v.files.map((f,i)=>`<article class="card"><p>${e(f.name)} · ${size(f.size)}</p><button data-file="${i}" ${v.gated?'disabled':''}>Datei auf Athena herunterladen</button></article>`).join('');message(v.files.length+' unterstützte Dateien (GGUF, Safetensors, JSON).');root.querySelectorAll('[data-file]').forEach(b=>b.onclick=async()=>{b.disabled=true;try{await api('/download',{repo,filename:v.files[Number(b.dataset.file)].name,revision:v.revision,kind});message('Download gestartet. Kein Modell wird ausgeführt.');refresh();}catch(err){message(err.message);}finally{b.disabled=false;}});}catch(err){message(err.message);}}
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
return {html,bind};
|
||||||
|
})();
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
"""Public Hub catalogue and bounded, serial downloads; independent of Docker/systemd."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
KINDS={'chat':'text-generation','image':'text-to-image','audio':'text-to-speech','video':'text-to-video'}
|
||||||
|
|
||||||
|
def safe_url(url):
|
||||||
|
p=urllib.parse.urlsplit(url)
|
||||||
|
if p.scheme!='https' or p.username or p.password or p.port not in (None,443) or not any(p.hostname==d or (p.hostname or '').endswith('.'+d) for d in ('huggingface.co','hf.co','xethub.hf.co')):
|
||||||
|
raise ValueError('Downloadziel außerhalb der erlaubten Hugging-Face-Domains.')
|
||||||
|
return url
|
||||||
|
|
||||||
|
class Redirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
return super().redirect_request(req,fp,code,msg,headers,safe_url(newurl))
|
||||||
|
|
||||||
|
def remote(url):
|
||||||
|
return urllib.request.build_opener(Redirect()).open(urllib.request.Request(safe_url(url),headers={'User-Agent':'Athena-Deck/0.5'}),timeout=20)
|
||||||
|
|
||||||
|
def metadata(path):
|
||||||
|
with remote('https://huggingface.co'+path) as r:
|
||||||
|
raw=r.read(8*1024*1024+1)
|
||||||
|
if len(raw)>8*1024*1024:raise ValueError('Metadaten zu groß; Repository wird noch nicht unterstützt.')
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
def repo_id(value):
|
||||||
|
if not isinstance(value,str) or not re.fullmatch(r'[A-Za-z0-9_-][A-Za-z0-9_.-]{0,95}/[A-Za-z0-9_-][A-Za-z0-9_.-]{0,95}',value):raise ValueError('Ungültige Repository-ID.')
|
||||||
|
return value
|
||||||
|
|
||||||
|
class Catalog:
|
||||||
|
def __init__(self,root):
|
||||||
|
self.root=Path(root);self.lock=threading.RLock();self.job=None;self.cancel=threading.Event()
|
||||||
|
def search(self,q,kind):
|
||||||
|
if kind not in KINDS or not isinstance(q,str) or len(q)>120:raise ValueError('Ungültige Suche.')
|
||||||
|
rows=metadata('/api/models?'+urllib.parse.urlencode(dict(search=q,filter=KINDS[kind],limit=20,sort='downloads',direction=-1)))
|
||||||
|
return {'models':[dict(repo=x['id'],downloads=x.get('downloads'),gated=x.get('gated',False)) for x in rows]}
|
||||||
|
def files(self,repo):
|
||||||
|
repo=repo_id(repo)
|
||||||
|
data=metadata('/api/models/'+repo+'?blobs=true')
|
||||||
|
revision=data.get('sha','')
|
||||||
|
if not re.fullmatch('[a-f0-9]{40}',revision):raise ValueError('Keine feste Repository-Version verfügbar.')
|
||||||
|
files=[]
|
||||||
|
for f in data.get('siblings',[]):
|
||||||
|
name=f['rfilename'];p=PurePosixPath(name)
|
||||||
|
if p.is_absolute() or '..' in p.parts or p.suffix.lower() not in ('.gguf','.safetensors','.json'):continue
|
||||||
|
size=f.get('size',f.get('lfs',{}).get('size'))
|
||||||
|
if not isinstance(size,int) or size<1:continue
|
||||||
|
files.append(dict(name=name,size=size,sha256=f.get('lfs',{}).get('sha256')))
|
||||||
|
return dict(repo=repo,revision=revision,files=files,gated=data.get('gated',False),license=(data.get('cardData') or {}).get('license'),url='https://huggingface.co/'+repo)
|
||||||
|
def status(self):
|
||||||
|
with self.lock:
|
||||||
|
entries=[]
|
||||||
|
if self.root.exists():
|
||||||
|
for p in self.root.glob('*/entry.json'):
|
||||||
|
try:
|
||||||
|
item=json.loads(p.read_text());entries.append(item)
|
||||||
|
except (OSError,ValueError):pass
|
||||||
|
return dict(entries=entries,job=dict(self.job) if self.job else None,free_bytes=shutil.disk_usage(self.root if self.root.exists() else self.root.parent).free)
|
||||||
|
def start(self,repo,filename,revision,kind):
|
||||||
|
if kind not in KINDS:raise ValueError('Ungültiger Bereich.')
|
||||||
|
with self.lock:
|
||||||
|
if self.job and self.job['state']=='downloading':raise ValueError('Ein Download läuft bereits.')
|
||||||
|
data=self.files(repo)
|
||||||
|
if data['gated']:raise ValueError('Zugangsbeschränkte Modelle werden noch nicht unterstützt.')
|
||||||
|
if revision!=data['revision']:raise ValueError('Repository wurde geändert. Dateiliste neu laden.')
|
||||||
|
item=next((x for x in data['files'] if x['name']==filename),None)
|
||||||
|
if not item:raise ValueError('Datei nicht verfügbar.')
|
||||||
|
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||||
|
if item['size']>shutil.disk_usage(self.root).free-10*1024**3:raise ValueError('Nicht genug Platz mit 10 GiB freier Reserve.')
|
||||||
|
ident=hashlib.sha256((repo+revision+filename).encode()).hexdigest()
|
||||||
|
target=self.root/ident
|
||||||
|
if (target/'entry.json').exists():raise ValueError('Datei bereits in der Bibliothek.')
|
||||||
|
target.mkdir(exist_ok=True,mode=0o700)
|
||||||
|
self.cancel.clear()
|
||||||
|
self.job=dict(id=ident,state='downloading',repo=repo,file=filename,bytes=0,total=item['size'],error=None)
|
||||||
|
threading.Thread(target=self._download,args=(data,item,target,kind),daemon=True).start()
|
||||||
|
return dict(self.job)
|
||||||
|
def stop(self):
|
||||||
|
self.cancel.set()
|
||||||
|
return {'cancellation_requested':True}
|
||||||
|
def _download(self,data,item,target,kind):
|
||||||
|
partial=target/'download.part';dest=target/('model'+PurePosixPath(item['name']).suffix)
|
||||||
|
try:
|
||||||
|
digest=hashlib.sha256();received=0
|
||||||
|
url='https://huggingface.co/'+data['repo']+'/resolve/'+data['revision']+'/'+urllib.parse.quote(item['name'],safe='/')
|
||||||
|
with remote(url) as r, partial.open('wb') as out:
|
||||||
|
while True:
|
||||||
|
if self.cancel.is_set():raise InterruptedError()
|
||||||
|
chunk=r.read(1024*1024)
|
||||||
|
if not chunk:break
|
||||||
|
received+=len(chunk)
|
||||||
|
if received>item['size'] or shutil.disk_usage(target).free<10*1024**3:raise ValueError('Größe oder Speicherreserve überschritten.')
|
||||||
|
out.write(chunk);digest.update(chunk)
|
||||||
|
with self.lock:self.job['bytes']=received
|
||||||
|
if received!=item['size']:raise ValueError('Unvollständiger Download.')
|
||||||
|
if item['sha256'] and digest.hexdigest()!=item['sha256']:raise ValueError('SHA-256-Prüfung fehlgeschlagen.')
|
||||||
|
partial.replace(dest)
|
||||||
|
entry=dict(repo=data['repo'],revision=data['revision'],file=item['name'],size=received,sha256=digest.hexdigest(),upstream_hash_verified=bool(item['sha256']),kind=kind,downloaded_at=time.time(),state='downloaded',runtime_ready=False)
|
||||||
|
temp=target/'entry.tmp';temp.write_text(json.dumps(entry));temp.replace(target/'entry.json')
|
||||||
|
with self.lock:self.job['state']='complete'
|
||||||
|
except Exception as exc:
|
||||||
|
with self.lock:
|
||||||
|
self.job['state']='cancelled' if isinstance(exc,InterruptedError) else 'failed'
|
||||||
|
self.job['error']=str(exc) if isinstance(exc,ValueError) else ('Abgebrochen.' if isinstance(exc,InterruptedError) else 'Download fehlgeschlagen; Verbindung oder Anbieter prüfen.')
|
||||||
|
partial.unlink(missing_ok=True)
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
FROM python:3.13-slim-bookworm
|
FROM python:3.13-slim-bookworm
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY server.py auth.py collect_hardware.py demo.py /app/
|
COPY server.py catalog.py auth.py collect_hardware.py demo.py /app/
|
||||||
COPY index.html app.js studio.js style.css login.html login.js access-ui.js network-ui.js /app/
|
COPY index.html app.js studio.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/
|
||||||
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
|
||||||
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
|
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@ import urllib.request
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
LABEL = 'de.casaderoll.athena-deck.standalone'
|
LABEL = 'de.casaderoll.athena-deck.standalone'
|
||||||
FILES = ['server.py','auth.py','collect_hardware.py','demo.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile']
|
FILES = ['catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile']
|
||||||
|
|
||||||
|
|
||||||
def run(*args, check=True, interactive=False):
|
def run(*args, check=True, interactive=False):
|
||||||
@@ -181,7 +181,7 @@ def update(base, rollback=False):
|
|||||||
backup_name=config['name']+'-previous'
|
backup_name=config['name']+'-previous'
|
||||||
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
|
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
|
||||||
stamp=str(time.time_ns())
|
stamp=str(time.time_ns())
|
||||||
shutil.copytree(base/'state',base/'backups'/stamp)
|
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models'))
|
||||||
was_running=previous['State']['Running']
|
was_running=previous['State']['Running']
|
||||||
if was_running:run('docker','stop','--time','15',config['name'])
|
if was_running:run('docker','stop','--time','15',config['name'])
|
||||||
run('docker','rename',config['name'],backup_name)
|
run('docker','rename',config['name'],backup_name)
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/studio.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtime">llama.cpp & Updates <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Getrennte Steuerungsinstanz<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.4 · GUI-Prototyp</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/studio.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / PROTOTYP 01</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><a href="#audio">Audio <span>04</span></a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtime">llama.cpp & Updates <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Getrennte Steuerungsinstanz<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.5 · Entwicklung</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import sys
|
|||||||
|
|
||||||
NAME = 'athena-deck-network'
|
NAME = 'athena-deck-network'
|
||||||
BASE = Path('/opt/athena-deck')
|
BASE = Path('/opt/athena-deck')
|
||||||
FILES = {'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
|
FILES = {'catalog.py', 'catalog-ui.js', 'studio.js', 'auth.py', 'access-ui.js', 'server.py', 'demo.py', 'collect_hardware.py', 'index.html', 'app.js', 'style.css', 'network-ui.js', 'login.html', 'login.js', 'network/__init__.py', 'network/config.py', 'network/policy.py', 'network/rpc.py', 'network/agent.py', 'network/client.py', 'network/Dockerfile', '.dockerignore'}
|
||||||
|
|
||||||
def run(*args, **kwargs):
|
def run(*args, **kwargs):
|
||||||
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
return subprocess.run(args, capture_output=True, timeout=600, **kwargs)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import hashlib
|
|||||||
import secrets
|
import secrets
|
||||||
from http.cookies import SimpleCookie, CookieError
|
from http.cookies import SimpleCookie, CookieError
|
||||||
from auth import CredentialStore, verify_password
|
from auth import CredentialStore, verify_password
|
||||||
|
from catalog import Catalog
|
||||||
|
from urllib.parse import urlsplit, parse_qs
|
||||||
from network.client import NetworkClient
|
from network.client import NetworkClient
|
||||||
from network.config import parse_config, ConfigError
|
from network.config import parse_config, ConfigError
|
||||||
import json
|
import json
|
||||||
@@ -94,6 +96,7 @@ class Server(ThreadingHTTPServer):
|
|||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
def __init__(self, port, state_dir=None):
|
def __init__(self, port, state_dir=None):
|
||||||
super().__init__((os.environ.get('DECK_BIND_HOST','127.0.0.1'), port), Handler)
|
super().__init__((os.environ.get('DECK_BIND_HOST','127.0.0.1'), port), Handler)
|
||||||
|
self.catalog = Catalog(Path(state_dir or os.environ.get("DECK_STATE_DIR", ROOT/".state"))/"models")
|
||||||
self.demo = DemoService()
|
self.demo = DemoService()
|
||||||
self.hardware = HardwareProvider()
|
self.hardware = HardwareProvider()
|
||||||
self.started = time.time()
|
self.started = time.time()
|
||||||
@@ -257,14 +260,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
return self.respond({'error':'Anmeldung erforderlich.'},401)
|
||||||
if not self.authenticated() and self.path == '/':
|
if not self.authenticated() and self.path == '/':
|
||||||
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
|
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
|
||||||
routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript')}
|
routes = {'/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript')}
|
||||||
if self.path in routes:
|
if self.path in routes:
|
||||||
name, mime = routes[self.path]
|
name, mime = routes[self.path]
|
||||||
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
return self.respond((ROOT/name).read_bytes(), mime=mime)
|
||||||
if self.path == '/api/v1/status':
|
if self.path == '/api/v1/status':
|
||||||
return self.respond(dict(name='Athena Deck', version='0.4.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena · Debian-Server'), demo=self.server.demo.status()))
|
return self.respond(dict(name='Athena Deck', version='0.5.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena · Debian-Server'), demo=self.server.demo.status()))
|
||||||
if self.path == '/api/v1/hardware':
|
if self.path == '/api/v1/hardware':
|
||||||
return self.respond(self.server.hardware.snapshot())
|
return self.respond(self.server.hardware.snapshot())
|
||||||
|
if self.path.startswith('/api/v1/catalog'):
|
||||||
|
try:
|
||||||
|
path=urlsplit(self.path);q=parse_qs(path.query)
|
||||||
|
if path.path=='/api/v1/catalog/search':return self.respond(self.server.catalog.search(q.get('q',[''])[0],q.get('kind',['chat'])[0]))
|
||||||
|
if path.path=='/api/v1/catalog/files':return self.respond(self.server.catalog.files(q.get('repo',[''])[0]))
|
||||||
|
if path.path=='/api/v1/catalog':return self.respond(self.server.catalog.status())
|
||||||
|
except Exception as exc:
|
||||||
|
return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Katalog nicht erreichbar.'},400)
|
||||||
if self.path == '/api/v1/network':
|
if self.path == '/api/v1/network':
|
||||||
return self.respond(self.server.network.status(self.ingress()))
|
return self.respond(self.server.network.status(self.ingress()))
|
||||||
if self.path == '/api/v1/demo':
|
if self.path == '/api/v1/demo':
|
||||||
@@ -300,6 +311,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
with self.server.auth_lock:
|
with self.server.auth_lock:
|
||||||
self.server.sessions.pop(self.session_token(),None)
|
self.server.sessions.pop(self.session_token(),None)
|
||||||
return self.respond({'logged_out':True})
|
return self.respond({'logged_out':True})
|
||||||
|
if self.path in ('/api/v1/catalog/download','/api/v1/catalog/cancel'):
|
||||||
|
try:
|
||||||
|
data=self.read_json()
|
||||||
|
if self.path.endswith('/cancel'):return self.respond(self.server.catalog.stop())
|
||||||
|
if set(data)!={'repo','filename','revision','kind'}:raise ValueError('Ungültige Downloadparameter.')
|
||||||
|
return self.respond(self.server.catalog.start(**data))
|
||||||
|
except Exception as exc:
|
||||||
|
return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Download konnte nicht vorbereitet werden.'},400)
|
||||||
if self.path.startswith('/api/v1/network/'):
|
if self.path.startswith('/api/v1/network/'):
|
||||||
action = self.path.rsplit('/',1)[1]
|
action = self.path.rsplit('/',1)[1]
|
||||||
if action not in ('install','import','connect','disconnect','mode','confirm','cancel','delete'):
|
if action not in ('install','import','connect','disconnect','mode','confirm','cancel','delete'):
|
||||||
@@ -340,6 +359,7 @@ def main():
|
|||||||
try:
|
try:
|
||||||
server.serve_forever()
|
server.serve_forever()
|
||||||
finally:
|
finally:
|
||||||
|
server.catalog.stop()
|
||||||
server.demo.stop()
|
server.demo.stop()
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* Visual prototype only. Drafts stay in this browser; no runtime/download API calls. */
|
/* Live catalogue/downloads; runtime and profile drafts remain previews. */
|
||||||
const Studio=(()=>{
|
const Studio=(()=>{
|
||||||
const key='athena-deck-studio-v1';
|
const key='athena-deck-studio-v1';
|
||||||
const seed={profiles:[{id:'alltag',name:'qwen-alltag',model:'qwen-iq4',context:76800,slots:1,gpu:'both',split:'85,15',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false},{id:'parallel',name:'qwen-parallel',model:'qwen-iq4',context:160000,slots:2,gpu:'both',split:'85,15',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false},{id:'lang',name:'qwen-langkontext',model:'qwen-iq4',context:262144,slots:1,gpu:'both',split:'80,20',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false}],runtime:{backend:'CUDA',revision:'',architectures:'auto',custom:'',jobs:4,toolkit:'auto',reserve:2048}};
|
const seed={profiles:[{id:'alltag',name:'qwen-alltag',model:'qwen-iq4',context:76800,slots:1,gpu:'both',split:'85,15',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false},{id:'parallel',name:'qwen-parallel',model:'qwen-iq4',context:160000,slots:2,gpu:'both',split:'85,15',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false},{id:'lang',name:'qwen-langkontext',model:'qwen-iq4',context:262144,slots:1,gpu:'both',split:'80,20',cache:'q8_0',batch:2048,ubatch:128,threads:8,unified:true,flash:true,vision:false}],runtime:{backend:'CUDA',revision:'',architectures:'auto',custom:'',jobs:4,toolkit:'auto',reserve:2048}};
|
||||||
@@ -12,7 +12,7 @@ const Studio=(()=>{
|
|||||||
const check=v=>v?'checked':'';
|
const check=v=>v?'checked':'';
|
||||||
function save(){try{localStorage.setItem(key,JSON.stringify(state));return true;}catch{notice('Speichern im Browser nicht möglich. Entwurf bleibt nur für diese Ansicht erhalten.');return false;}}
|
function save(){try{localStorage.setItem(key,JSON.stringify(state));return true;}catch{notice('Speichern im Browser nicht möglich. Entwurf bleibt nur für diese Ansicht erhalten.');return false;}}
|
||||||
function notice(message){const node=document.querySelector('#studio-notice');if(node)node.textContent=message;}
|
function notice(message){const node=document.querySelector('#studio-notice');if(node)node.textContent=message;}
|
||||||
const banner=()=>'<div class="prototype-banner"><span class="pill">GUI-VORSCHAU</span><span>Beispiele und lokale Entwürfe. Keine Downloads, Builds oder Modellstarts.</span></div>';
|
const banner=()=>'<div class="prototype-banner"><span class="pill">GUI-VORSCHAU</span><span>Katalog und Datei-Downloads sind live. Profile und Laufzeiten bleiben Entwürfe; keine Modellstarts.</span></div>';
|
||||||
function header(title,description){return `<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${title}</h1><p>${description}</p>${banner()}`;}
|
function header(title,description){return `<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${title}</h1><p>${description}</p>${banner()}`;}
|
||||||
function render(page,force=false){
|
function render(page,force=false){
|
||||||
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
|
if(!force&&document.querySelector('#studio')?.dataset.page===page)return;
|
||||||
@@ -25,8 +25,8 @@ const Studio=(()=>{
|
|||||||
return header(labels[page], 'Modelle entdecken, als Bibliothek organisieren und über Profile bereitstellen.')+`<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['profiles','Profile'],['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}${id==='profiles'&&page==='chat'?` <span>${state.profiles.length}</span>`:''}</button>`).join('')}</div><div role="tabpanel">${section==='discover'?discover(page):section==='library'?library(page):section==='profiles'?profiles(page):running(page)}</div>`;
|
return header(labels[page], 'Modelle entdecken, als Bibliothek organisieren und über Profile bereitstellen.')+`<div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['profiles','Profile'],['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}${id==='profiles'&&page==='chat'?` <span>${state.profiles.length}</span>`:''}</button>`).join('')}</div><div role="tabpanel">${section==='discover'?discover(page):section==='library'?library(page):section==='profiles'?profiles(page):running(page)}</div>`;
|
||||||
}
|
}
|
||||||
function modelCard(m,library=false){return `<article class="card model-card"><div class="card-top"><span class="model-icon">${m.kind==='chat'?'Aa':m.kind==='image'?'▧':m.kind==='audio'?'≈':'▷'}</span><span class="pill">${library?'Bibliotheksbeispiel':'Katalogbeispiel'}</span></div><h2>${e(m.name)}</h2><div class="tag">${e(m.variant)}</div><p>${e(m.description)}</p><dl class="model-facts"><div><dt>Quelle</dt><dd>${e(m.source)} · nicht verbunden</dd></div><div><dt>Downloadgröße</dt><dd>Noch nicht ermittelt</dd></div><div><dt>Eignung für Athena</dt><dd><span class="unknown-dot"></span> Noch nicht geprüft</dd></div></dl><div class="card-actions"><button class="secondary" data-detail="${m.id}">Details & Eignung</button>${library&&m.kind==='chat'?`<button data-create="${m.id}">Profil entwerfen</button>`:'<button disabled>Herunterladen</button>'}</div></article>`;}
|
function modelCard(m,library=false){return `<article class="card model-card"><div class="card-top"><span class="model-icon">${m.kind==='chat'?'Aa':m.kind==='image'?'▧':m.kind==='audio'?'≈':'▷'}</span><span class="pill">${library?'Bibliotheksbeispiel':'Katalogbeispiel'}</span></div><h2>${e(m.name)}</h2><div class="tag">${e(m.variant)}</div><p>${e(m.description)}</p><dl class="model-facts"><div><dt>Quelle</dt><dd>${e(m.source)} · nicht verbunden</dd></div><div><dt>Downloadgröße</dt><dd>Noch nicht ermittelt</dd></div><div><dt>Eignung für Athena</dt><dd><span class="unknown-dot"></span> Noch nicht geprüft</dd></div></dl><div class="card-actions"><button class="secondary" data-detail="${m.id}">Details & Eignung</button>${library&&m.kind==='chat'?`<button data-create="${m.id}">Profil entwerfen</button>`:'<button disabled>Herunterladen</button>'}</div></article>`;}
|
||||||
function discover(page){return `<div class="studio-toolbar"><div><label for="model-search">Modell suchen</label><input id="model-search" type="search" placeholder="Beispiele nach Name oder Variante filtern …"></div><div><label for="source-filter">Quelle</label><select id="source-filter"><option value="all">Alle Quellen</option><option>Hugging Face</option><option>Modellkatalog</option></select></div></div><p class="small muted">Die Suche filtert nur diese Beispieldaten. Eine Live-Verbindung zu Hugging Face folgt später.</p><div class="model-grid" id="model-results">${models.filter(m=>m.kind===page).map(m=>modelCard(m)).join('')}</div><p id="no-models" hidden>Keine passenden Beispiele gefunden.</p><section id="model-detail"></section>`;}
|
function discover(page){return CatalogUI.html(false);}
|
||||||
function library(page){return `<div class="section-heading"><div><h2>Deine Modellbibliothek</h2><p>Eine Modelldatei kann von mehreren Profilen verwendet werden.</p></div><span class="pill">0 verifizierte Installationen</span></div><div class="model-grid">${models.filter(m=>m.kind===page).slice(0,1).map(m=>modelCard(m,true)).join('')}</div><p class="note">Die Einträge zeigen die geplante Darstellung. Es wurden weder lokale Modelldateien importiert noch Downloads ausgeführt.</p><section id="model-detail"></section>`;}
|
function library(page){return CatalogUI.html(true);}
|
||||||
function profiles(page){
|
function profiles(page){
|
||||||
if(page!=='chat')return `<section class="card"><span class="pill">VORBEREITET</span><h2>${page==='image'?'Auflösung und Generierung':page==='audio'?'Sprache, Stimmen und Verarbeitung':'Auflösung und Videolänge'}</h2><div class="form-grid">${page==='image'?'<label>Breite / Höhe<input disabled value="1024 × 1024 · Beispiel"></label><label>Schritte<input disabled value="30 · Beispiel"></label>':page==='audio'?'<label>Aufgabe<select disabled><option>Sprachausgabe / Spracherkennung</option></select></label><label>Sprache<select disabled><option>Automatisch</option></select></label>':'<label>Auflösung<input disabled value="1280 × 720 · Beispiel"></label><label>Bildanzahl<input disabled value="Noch nicht festgelegt"></label>'}</div><p>Eigene Worker-Adapter und Eignungsregeln folgen. LLM-Kontextparameter werden hier nicht übernommen.</p><button disabled>Profil speichern</button></section>`;
|
if(page!=='chat')return `<section class="card"><span class="pill">VORBEREITET</span><h2>${page==='image'?'Auflösung und Generierung':page==='audio'?'Sprache, Stimmen und Verarbeitung':'Auflösung und Videolänge'}</h2><div class="form-grid">${page==='image'?'<label>Breite / Höhe<input disabled value="1024 × 1024 · Beispiel"></label><label>Schritte<input disabled value="30 · Beispiel"></label>':page==='audio'?'<label>Aufgabe<select disabled><option>Sprachausgabe / Spracherkennung</option></select></label><label>Sprache<select disabled><option>Automatisch</option></select></label>':'<label>Auflösung<input disabled value="1280 × 720 · Beispiel"></label><label>Bildanzahl<input disabled value="Noch nicht festgelegt"></label>'}</div><p>Eigene Worker-Adapter und Eignungsregeln folgen. LLM-Kontextparameter werden hier nicht übernommen.</p><button disabled>Profil speichern</button></section>`;
|
||||||
return `<div class="section-heading"><div><h2>Ein Modell. Mehrere Möglichkeiten.</h2><p>API-Namen und Startparameter als lokale Profilentwürfe.</p></div><button id="new-profile">+ Neues Profil</button></div><div class="profile-list">${state.profiles.map(p=>`<article class="card profile-row"><div><span class="label">PROFILENTWURF</span><h2>${e(p.name)}</h2><p>${e(models.find(m=>m.id===p.model)?.variant||'Modell nicht zugeordnet')}</p></div><div><span class="label">Kontextbudget</span><strong>${count(p.context)}</strong></div><div><span class="label">Slots</span><strong>${e(p.slots)}</strong></div><div><span class="label">Zustand</span><strong class="muted">Nicht bereitgestellt</strong></div><div class="profile-actions"><button class="secondary" data-edit="${e(p.id)}">Bearbeiten</button><button class="secondary" data-duplicate="${e(p.id)}">Duplizieren</button></div></article>`).join('')||'<section class="empty">Noch keine Profilentwürfe. Lege dein erstes Profil an.</section>'}</div><section class="card endpoint-preview"><span class="label">GEPLANTE API · NICHT AKTIV</span><h2>Ein Endpunkt für deine Profile</h2><code>GET /v1/models</code><pre>${e(JSON.stringify({data:state.profiles.map(p=>({id:p.name,object:'model'}))},null,2))}</pre><p>Später wählt der Client den Profilnamen im Feld <code>model</code>. Deck verwaltet den passenden llama-server-Prozess.</p></section><div id="profile-editor"></div>`;
|
return `<div class="section-heading"><div><h2>Ein Modell. Mehrere Möglichkeiten.</h2><p>API-Namen und Startparameter als lokale Profilentwürfe.</p></div><button id="new-profile">+ Neues Profil</button></div><div class="profile-list">${state.profiles.map(p=>`<article class="card profile-row"><div><span class="label">PROFILENTWURF</span><h2>${e(p.name)}</h2><p>${e(models.find(m=>m.id===p.model)?.variant||'Modell nicht zugeordnet')}</p></div><div><span class="label">Kontextbudget</span><strong>${count(p.context)}</strong></div><div><span class="label">Slots</span><strong>${e(p.slots)}</strong></div><div><span class="label">Zustand</span><strong class="muted">Nicht bereitgestellt</strong></div><div class="profile-actions"><button class="secondary" data-edit="${e(p.id)}">Bearbeiten</button><button class="secondary" data-duplicate="${e(p.id)}">Duplizieren</button></div></article>`).join('')||'<section class="empty">Noch keine Profilentwürfe. Lege dein erstes Profil an.</section>'}</div><section class="card endpoint-preview"><span class="label">GEPLANTE API · NICHT AKTIV</span><h2>Ein Endpunkt für deine Profile</h2><code>GET /v1/models</code><pre>${e(JSON.stringify({data:state.profiles.map(p=>({id:p.name,object:'model'}))},null,2))}</pre><p>Später wählt der Client den Profilnamen im Feld <code>model</code>. Deck verwaltet den passenden llama-server-Prozess.</p></section><div id="profile-editor"></div>`;
|
||||||
@@ -43,6 +43,7 @@ const Studio=(()=>{
|
|||||||
}
|
}
|
||||||
function runtime(){const r=state.runtime;return header('llama.cpp-Laufzeit','Installation, GPU-Unterstützung und Versionen an einem Ort vorbereiten.')+`<div class="runtime-stats"><section class="card"><span class="label">INSTALLIERTE VERSION</span><h2>Nicht angebunden</h2><p>Kein Build erkannt oder ausgeführt.</p></section><section class="card"><span class="label">UPDATE-STATUS</span><h2>Noch nicht geprüft</h2><p>Keine Live-Abfrage von Releases.</p></section><section class="card"><span class="label">ZIELSYSTEM</span><h2>Athena</h2><p>Geplant: RTX 5080 + RTX 3060</p></section></div><section class="card"><div class="card-top"><h2>Installation & Build</h2><span class="pill">LOKALER ENTWURF</span></div><form id="build-form"><div class="form-grid"><label>Rechen-Backend<select name="backend">${['CUDA','CPU','Vulkan'].map(v=>`<option ${selected(v,r.backend)}>${v}</option>`).join('')}</select></label>${field('Gewünschtes Release / Commit','revision',r.revision,'text','placeholder="Noch keine Version ausgewählt" maxlength="80"')}<label>GPU-Architekturen<select name="architectures"><option value="auto" ${selected('auto',r.architectures)}>Auf dem Zielserver automatisch erkennen</option><option value="custom" ${selected('custom',r.architectures)}>Explizite Build-Ziele vorgeben</option></select></label>${field('Explizite Architekturziele (optional)','custom',r.custom,'text','placeholder="Später anhand beider GPUs prüfen" maxlength="80"')}<label>CUDA-Toolkit<select name="toolkit"><option value="auto">Verfügbarkeit und Kompatibilität prüfen</option></select></label>${field('Parallele Build-Jobs','jobs',r.jobs,'number','required min="1" max="64"')}${field('Geplante VRAM-Reserve je GPU (MiB)','reserve',r.reserve,'number','required min="256" max="16384" step="256"')}</div><p class="note">Bei verschiedenen GPUs muss der CUDA-Build die Architektur beider Karten unterstützen. Die konkreten Build-Ziele und die passende Toolkit-Version werden später auf Athena geprüft. Diese Vorschau installiert keine Treiber oder Host-Pakete.</p><button>Build-Entwurf speichern</button><button disabled>llama.cpp herunterladen & bauen</button></form></section><section class="card"><div class="card-top"><h2>Updates & Änderungen</h2><button id="release-preview" class="secondary">Update-Ansicht öffnen</button></div><p>Vorgesehen: installierte Version mit verfügbaren Releases vergleichen, Fixes ansehen und einen neuen Build getrennt vorbereiten.</p><div id="release-panel" hidden><div class="release-comparison"><div><span class="label">AKTUELL</span><h3>Nicht ermittelt</h3></div><span>→</span><div><span class="label">VERFÜGBAR</span><h3>Nicht abgefragt</h3></div></div><h3>Release-Notes & Fixes</h3><p>Hier erscheinen später die tatsächlichen Änderungen mit Quelle und Veröffentlichungsdatum. Keine Beispieldaten werden als neue Version oder behobener Fehler ausgegeben.</p><a class="link" href="https://github.com/ggml-org/llama.cpp/releases" target="_blank" rel="noopener noreferrer">Offizielle llama.cpp-Releases öffnen ↗</a><div class="card-actions"><button disabled>Nach Updates suchen</button><button disabled>Neue Version bauen</button></div></div></section><section class="card"><h2>Geplanter Build-Ablauf</h2><ol class="flow"><li><b>Voraussetzungen prüfen</b><span>Compiler, CUDA, beide GPUs und freier Speicher.</span></li><li><b>Version beziehen</b><span>Ausgewähltes Release oder festen Commit herunterladen.</span></li><li><b>Separat bauen & prüfen</b><span>Neue Binärdatei prüfen; laufende Version beibehalten.</span></li><li><b>Gezielt aktivieren</b><span>Nach laufenden Anfragen wechseln; vorigen Build behalten.</span></li></ol><button disabled>Vorherigen Build wiederherstellen</button><p class="note">Kein Build aktiv · keine Warteschlange · keine Wiederherstellungsversion vorhanden.</p></section>`;}
|
function runtime(){const r=state.runtime;return header('llama.cpp-Laufzeit','Installation, GPU-Unterstützung und Versionen an einem Ort vorbereiten.')+`<div class="runtime-stats"><section class="card"><span class="label">INSTALLIERTE VERSION</span><h2>Nicht angebunden</h2><p>Kein Build erkannt oder ausgeführt.</p></section><section class="card"><span class="label">UPDATE-STATUS</span><h2>Noch nicht geprüft</h2><p>Keine Live-Abfrage von Releases.</p></section><section class="card"><span class="label">ZIELSYSTEM</span><h2>Athena</h2><p>Geplant: RTX 5080 + RTX 3060</p></section></div><section class="card"><div class="card-top"><h2>Installation & Build</h2><span class="pill">LOKALER ENTWURF</span></div><form id="build-form"><div class="form-grid"><label>Rechen-Backend<select name="backend">${['CUDA','CPU','Vulkan'].map(v=>`<option ${selected(v,r.backend)}>${v}</option>`).join('')}</select></label>${field('Gewünschtes Release / Commit','revision',r.revision,'text','placeholder="Noch keine Version ausgewählt" maxlength="80"')}<label>GPU-Architekturen<select name="architectures"><option value="auto" ${selected('auto',r.architectures)}>Auf dem Zielserver automatisch erkennen</option><option value="custom" ${selected('custom',r.architectures)}>Explizite Build-Ziele vorgeben</option></select></label>${field('Explizite Architekturziele (optional)','custom',r.custom,'text','placeholder="Später anhand beider GPUs prüfen" maxlength="80"')}<label>CUDA-Toolkit<select name="toolkit"><option value="auto">Verfügbarkeit und Kompatibilität prüfen</option></select></label>${field('Parallele Build-Jobs','jobs',r.jobs,'number','required min="1" max="64"')}${field('Geplante VRAM-Reserve je GPU (MiB)','reserve',r.reserve,'number','required min="256" max="16384" step="256"')}</div><p class="note">Bei verschiedenen GPUs muss der CUDA-Build die Architektur beider Karten unterstützen. Die konkreten Build-Ziele und die passende Toolkit-Version werden später auf Athena geprüft. Diese Vorschau installiert keine Treiber oder Host-Pakete.</p><button>Build-Entwurf speichern</button><button disabled>llama.cpp herunterladen & bauen</button></form></section><section class="card"><div class="card-top"><h2>Updates & Änderungen</h2><button id="release-preview" class="secondary">Update-Ansicht öffnen</button></div><p>Vorgesehen: installierte Version mit verfügbaren Releases vergleichen, Fixes ansehen und einen neuen Build getrennt vorbereiten.</p><div id="release-panel" hidden><div class="release-comparison"><div><span class="label">AKTUELL</span><h3>Nicht ermittelt</h3></div><span>→</span><div><span class="label">VERFÜGBAR</span><h3>Nicht abgefragt</h3></div></div><h3>Release-Notes & Fixes</h3><p>Hier erscheinen später die tatsächlichen Änderungen mit Quelle und Veröffentlichungsdatum. Keine Beispieldaten werden als neue Version oder behobener Fehler ausgegeben.</p><a class="link" href="https://github.com/ggml-org/llama.cpp/releases" target="_blank" rel="noopener noreferrer">Offizielle llama.cpp-Releases öffnen ↗</a><div class="card-actions"><button disabled>Nach Updates suchen</button><button disabled>Neue Version bauen</button></div></div></section><section class="card"><h2>Geplanter Build-Ablauf</h2><ol class="flow"><li><b>Voraussetzungen prüfen</b><span>Compiler, CUDA, beide GPUs und freier Speicher.</span></li><li><b>Version beziehen</b><span>Ausgewähltes Release oder festen Commit herunterladen.</span></li><li><b>Separat bauen & prüfen</b><span>Neue Binärdatei prüfen; laufende Version beibehalten.</span></li><li><b>Gezielt aktivieren</b><span>Nach laufenden Anfragen wechseln; vorigen Build behalten.</span></li></ol><button disabled>Vorherigen Build wiederherstellen</button><p class="note">Kein Build aktiv · keine Warteschlange · keine Wiederherstellungsversion vorhanden.</p></section>`;}
|
||||||
function bind(page){
|
function bind(page){
|
||||||
|
if(section==='discover'||section==='library')CatalogUI.bind(page,section==='library');
|
||||||
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
|
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
|
||||||
document.querySelectorAll('[data-detail]').forEach(b=>b.onclick=()=>detail(b.dataset.detail));
|
document.querySelectorAll('[data-detail]').forEach(b=>b.onclick=()=>detail(b.dataset.detail));
|
||||||
document.querySelectorAll('[data-create]').forEach(b=>b.onclick=()=>{const model=b.dataset.create;section='profiles';render(page,true);editor(null,model);});
|
document.querySelectorAll('[data-create]').forEach(b=>b.onclick=()=>{const model=b.dataset.create;section='profiles';render(page,true);editor(null,model);});
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from catalog import Catalog, safe_url, repo_id
|
||||||
|
|
||||||
|
class CatalogTests(unittest.TestCase):
|
||||||
|
def test_reject_untrusted_urls_and_repos(self):
|
||||||
|
for value in ['http://huggingface.co/a','https://127.0.0.1/x','https://huggingface.co.evil.org/a','https://user@huggingface.co/a']:
|
||||||
|
with self.assertRaises(ValueError):safe_url(value)
|
||||||
|
for value in ['../foo','a/b/c','https://x','a/..']:
|
||||||
|
with self.assertRaises(ValueError):repo_id(value)
|
||||||
|
def test_download_integrity_and_library(self):
|
||||||
|
for valid in (True,False):
|
||||||
|
with self.subTest(valid=valid),tempfile.TemporaryDirectory() as directory:
|
||||||
|
c=Catalog(directory);payload=b'test-model-file'
|
||||||
|
data=dict(repo='owner/model',revision='a'*40,gated=False,files=[dict(name='weights.gguf',size=len(payload),sha256=hashlib.sha256(payload if valid else b'wrong').hexdigest())])
|
||||||
|
with patch.object(c,'files',return_value=data),patch('catalog.remote',return_value=io.BytesIO(payload)),patch('catalog.shutil.disk_usage',return_value=type('Disk',(),{'free':100*1024**3})()):
|
||||||
|
c.start('owner/model','weights.gguf','a'*40,'chat')
|
||||||
|
for _ in range(100):
|
||||||
|
if c.status()['job']['state']!='downloading':break
|
||||||
|
time.sleep(.01)
|
||||||
|
self.assertEqual(c.status()['job']['state'],'complete' if valid else 'failed')
|
||||||
|
self.assertEqual(len(c.status()['entries']),1 if valid else 0)
|
||||||
|
def test_changed_revision_and_disk_reserve(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
c=Catalog(directory);data=dict(repo='a/b',revision='b'*40,gated=False,files=[dict(name='m.gguf',size=20,sha256=None)])
|
||||||
|
with patch.object(c,'files',return_value=data):
|
||||||
|
with self.assertRaises(ValueError):c.start('a/b','m.gguf','a'*40,'chat')
|
||||||
|
with patch('catalog.shutil.disk_usage',return_value=type('Disk',(),{'free':1024})()):
|
||||||
|
with self.assertRaises(ValueError):c.start('a/b','m.gguf','b'*40,'chat')
|
||||||
|
def test_cancellation(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
c=Catalog(directory);c.job=dict(state='downloading');c.stop()
|
||||||
|
from pathlib import Path
|
||||||
|
target=Path(directory)
|
||||||
|
with patch('catalog.remote',return_value=io.BytesIO(b'x')):
|
||||||
|
c._download(dict(repo='a/b',revision='a'*40),dict(name='m.gguf',size=1,sha256=None),target,'chat')
|
||||||
|
self.assertEqual(c.job['state'],'cancelled');self.assertFalse((target/'download.part').exists())
|
||||||
Reference in New Issue
Block a user