diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 46b05a7..e1b5542 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -38,6 +38,37 @@ Diese Befehle verwalten ausschließlich den eigenen, per Label geprüften Deck-Container. Produktiver Router, Modelle, WireGuard und Hostkonfiguration werden nicht verändert. Kein Docker-Socket im Webcontainer. -Modellkatalog, Downloads, Profile und llama.cpp-Build/Update sind weiterhin -GUI-Entwürfe. Die Netzwerk-Erweiterung ist in dieser Installation deaktiviert. +Profile und llama.cpp-Build/Update sind weiterhin GUI-Entwürfe. Die Netzwerk-Erweiterung ist in dieser Installation deaktiviert. 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. diff --git a/README.md b/README.md index cfeafa4..fcf8c55 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,11 @@ Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript. 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`. 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 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 diff --git a/STUDIO.md b/STUDIO.md index 0c87ed6..9aceb3c 100644 --- a/STUDIO.md +++ b/STUDIO.md @@ -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 Diese Erweiterung ist ausdrücklich rein optisch und lokal bedienbar. Keine diff --git a/catalog-ui.js b/catalog-ui.js new file mode 100644 index 0000000..8cb4760 --- /dev/null +++ b/catalog-ui.js @@ -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 `
LIVE · HUGGING FACE

${library?'Dateien auf Athena':'Modelle suchen'}

Ö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.

${library?'':''}

`;} + 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=`

Freier Speicher: ${size(v.free_bytes)} · Reserve: 10 GiB

`+(j?`

${e(j.repo)} / ${e(j.file)} · ${e(j.state)} · ${size(j.bytes)} / ${size(j.total)}

${j.error?`

${e(j.error)}

`:''}${j.state==='downloading'?'':''}`:'');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=>`

${e(x.repo)}

${e(x.file)} · ${size(x.size)}

Heruntergeladen · Laufzeit noch nicht angebunden

Revision ${e(x.revision)} · ${x.upstream_hash_verified?'SHA-256 mit Quelle geprüft':'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}
`).join('')||'

Noch keine Dateien für diesen Bereich heruntergeladen.

';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=>`

${e(m.repo)}

${m.gated?'Zugangsbeschränkt':'Öffentlich'} · ${e(m.downloads)} Downloads

`).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=`

${e(repo)}

Lizenz: ${e(v.license||'Nicht angegeben')} · Modellkarte und Bedingungen lesen

VRAM- und Kontext-Eignung noch nicht berechnet. Dateigröße allein belegt keine Lauffähigkeit.

`+v.files.map((f,i)=>`

${e(f.name)} · ${size(f.size)}

`).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}; +})(); diff --git a/catalog.py b/catalog.py new file mode 100644 index 0000000..0a01ea0 --- /dev/null +++ b/catalog.py @@ -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) diff --git a/deploy/Dockerfile b/deploy/Dockerfile index e08dfbc..077e1d7 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.13-slim-bookworm WORKDIR /app -COPY server.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 server.py catalog.py auth.py collect_hardware.py demo.py /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/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \ diff --git a/deploy/install.py b/deploy/install.py index 73e7551..075d98b 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent 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): @@ -181,7 +181,7 @@ def update(base, rollback=False): backup_name=config['name']+'-previous' if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') 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'] if was_running:run('docker','stop','--time','15',config['name']) run('docker','rename',config['name'],backup_name) diff --git a/index.html b/index.html index f868939..fe5aa35 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.4 · GUI-Prototyp
+Athena Deck
ATHENA CONTROL SURFACEv0.5 · Entwicklung
diff --git a/network/install_remote.py b/network/install_remote.py index 74ce7de..94999eb 100644 --- a/network/install_remote.py +++ b/network/install_remote.py @@ -12,7 +12,7 @@ import sys NAME = 'athena-deck-network' 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): return subprocess.run(args, capture_output=True, timeout=600, **kwargs) diff --git a/server.py b/server.py index a5f3988..adf60e2 100644 --- a/server.py +++ b/server.py @@ -6,6 +6,8 @@ import hashlib import secrets from http.cookies import SimpleCookie, CookieError from auth import CredentialStore, verify_password +from catalog import Catalog +from urllib.parse import urlsplit, parse_qs from network.client import NetworkClient from network.config import parse_config, ConfigError import json @@ -94,6 +96,7 @@ class Server(ThreadingHTTPServer): daemon_threads = True def __init__(self, port, state_dir=None): 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.hardware = HardwareProvider() self.started = time.time() @@ -257,14 +260,22 @@ class Handler(BaseHTTPRequestHandler): return self.respond({'error':'Anmeldung erforderlich.'},401) if not self.authenticated() and self.path == '/': 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: name, mime = routes[self.path] return self.respond((ROOT/name).read_bytes(), mime=mime) 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': 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': return self.respond(self.server.network.status(self.ingress())) if self.path == '/api/v1/demo': @@ -300,6 +311,14 @@ class Handler(BaseHTTPRequestHandler): with self.server.auth_lock: self.server.sessions.pop(self.session_token(),None) 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/'): action = self.path.rsplit('/',1)[1] if action not in ('install','import','connect','disconnect','mode','confirm','cancel','delete'): @@ -340,6 +359,7 @@ def main(): try: server.serve_forever() finally: + server.catalog.stop() server.demo.stop() server.server_close() diff --git a/studio.js b/studio.js index effd6c2..016ca86 100644 --- a/studio.js +++ b/studio.js @@ -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 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}}; @@ -12,7 +12,7 @@ const Studio=(()=>{ 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 notice(message){const node=document.querySelector('#studio-notice');if(node)node.textContent=message;} - const banner=()=>'
GUI-VORSCHAUBeispiele und lokale Entwürfe. Keine Downloads, Builds oder Modellstarts.
'; + const banner=()=>'
GUI-VORSCHAUKatalog und Datei-Downloads sind live. Profile und Laufzeiten bleiben Entwürfe; keine Modellstarts.
'; function header(title,description){return `
ATHENA / MODELLVERWALTUNG

${title}

${description}

${banner()}`;} function render(page,force=false){ 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.')+`
${[['discover','Entdecken'],['library','Bibliothek'],['profiles','Profile'],['running','Laufend']].map(([id,label])=>``).join('')}
${section==='discover'?discover(page):section==='library'?library(page):section==='profiles'?profiles(page):running(page)}
`; } function modelCard(m,library=false){return `
${m.kind==='chat'?'Aa':m.kind==='image'?'▧':m.kind==='audio'?'≈':'▷'}${library?'Bibliotheksbeispiel':'Katalogbeispiel'}

${e(m.name)}

${e(m.variant)}

${e(m.description)}

Quelle
${e(m.source)} · nicht verbunden
Downloadgröße
Noch nicht ermittelt
Eignung für Athena
Noch nicht geprüft
${library&&m.kind==='chat'?``:''}
`;} - function discover(page){return `

Die Suche filtert nur diese Beispieldaten. Eine Live-Verbindung zu Hugging Face folgt später.

${models.filter(m=>m.kind===page).map(m=>modelCard(m)).join('')}
`;} - function library(page){return `

Deine Modellbibliothek

Eine Modelldatei kann von mehreren Profilen verwendet werden.

0 verifizierte Installationen
${models.filter(m=>m.kind===page).slice(0,1).map(m=>modelCard(m,true)).join('')}

Die Einträge zeigen die geplante Darstellung. Es wurden weder lokale Modelldateien importiert noch Downloads ausgeführt.

`;} + function discover(page){return CatalogUI.html(false);} + function library(page){return CatalogUI.html(true);} function profiles(page){ if(page!=='chat')return `
VORBEREITET

${page==='image'?'Auflösung und Generierung':page==='audio'?'Sprache, Stimmen und Verarbeitung':'Auflösung und Videolänge'}

${page==='image'?'':page==='audio'?'':''}

Eigene Worker-Adapter und Eignungsregeln folgen. LLM-Kontextparameter werden hier nicht übernommen.

`; return `

Ein Modell. Mehrere Möglichkeiten.

API-Namen und Startparameter als lokale Profilentwürfe.

${state.profiles.map(p=>`
PROFILENTWURF

${e(p.name)}

${e(models.find(m=>m.id===p.model)?.variant||'Modell nicht zugeordnet')}

Kontextbudget${count(p.context)}
Slots${e(p.slots)}
ZustandNicht bereitgestellt
`).join('')||'
Noch keine Profilentwürfe. Lege dein erstes Profil an.
'}
GEPLANTE API · NICHT AKTIV

Ein Endpunkt für deine Profile

GET /v1/models
${e(JSON.stringify({data:state.profiles.map(p=>({id:p.name,object:'model'}))},null,2))}

Später wählt der Client den Profilnamen im Feld model. Deck verwaltet den passenden llama-server-Prozess.

`; @@ -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.')+`
INSTALLIERTE VERSION

Nicht angebunden

Kein Build erkannt oder ausgeführt.

UPDATE-STATUS

Noch nicht geprüft

Keine Live-Abfrage von Releases.

ZIELSYSTEM

Athena

Geplant: RTX 5080 + RTX 3060

Installation & Build

LOKALER ENTWURF
${field('Gewünschtes Release / Commit','revision',r.revision,'text','placeholder="Noch keine Version ausgewählt" maxlength="80"')}${field('Explizite Architekturziele (optional)','custom',r.custom,'text','placeholder="Später anhand beider GPUs prüfen" maxlength="80"')}${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"')}

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.

Updates & Änderungen

Vorgesehen: installierte Version mit verfügbaren Releases vergleichen, Fixes ansehen und einen neuen Build getrennt vorbereiten.

Geplanter Build-Ablauf

  1. Voraussetzungen prüfenCompiler, CUDA, beide GPUs und freier Speicher.
  2. Version beziehenAusgewähltes Release oder festen Commit herunterladen.
  3. Separat bauen & prüfenNeue Binärdatei prüfen; laufende Version beibehalten.
  4. Gezielt aktivierenNach laufenden Anfragen wechseln; vorigen Build behalten.

Kein Build aktiv · keine Warteschlange · keine Wiederherstellungsversion vorhanden.

`;} 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-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);}); diff --git a/test_catalog.py b/test_catalog.py new file mode 100644 index 0000000..cb25bd1 --- /dev/null +++ b/test_catalog.py @@ -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())