From 94bfb1ac78bceee9a5a4b433fcac53d4ed3c95ed Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Wed, 30 Sep 2026 21:36:47 +0200
Subject: [PATCH] Unify component setup across categories and retain two
installation backups
---
backup.py | 2 +-
catalog-ui.js | 2 +-
deploy/install.py | 3 +++
docs/RUNTIME_DISCOVERY.md | 8 +++++++
profiles-ui.js | 13 ++++++-----
profiles.py | 46 +++++++++++++++++++++++++++++++++------
server.py | 11 +++++++++-
test_music_components.py | 21 ++++++++++++++++++
8 files changed, 90 insertions(+), 16 deletions(-)
diff --git a/backup.py b/backup.py
index 50060cb..9c80d5f 100644
--- a/backup.py
+++ b/backup.py
@@ -51,7 +51,7 @@ def validate(doc):
if kind=='chat' and (params['ubatch']>params['batch'] or params.get('vision_projector') and params['vision_projector'] not in ids):raise ValueError('Ungültige Chat-Zuordnung.')
if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Ungültige Bildgröße.')
components=p.get('components',{})
- if not isinstance(components,dict) or any(k not in ('text_encoder','vae','video_vae','audio_vae','spatial_upsampler') or v not in ids for k,v in components.items()):raise ValueError('Ungültige Komponentenreferenz.')
+ if not isinstance(components,dict) or any((k not in ('text_encoder','vae','video_vae','audio_vae','spatial_upsampler','model_config','generation_config','tokenizer','vae_config','manual_text_encoder','manual_vae','manual_tokenizer','manual_configuration','manual_projector','manual_auxiliary') and not re.fullmatch(r'runtime_file_[0-9]{1,2}',k)) or v not in ids for k,v in components.items()):raise ValueError('Ungültige Komponentenreferenz.')
pids.add(p['id']);names.add(name)
settings=doc.get('settings')
if not isinstance(settings,dict) or set(settings)-set(SETTINGS):raise ValueError('Unbekannte Einstellungsdateien.')
diff --git a/catalog-ui.js b/catalog-ui.js
index eb14c7f..f3759a4 100644
--- a/catalog-ui.js
+++ b/catalog-ui.js
@@ -9,7 +9,7 @@ window.CatalogUI = (() => {
}
function executionHTML(x) {
if(!x)return '
${escape(x.runtime)} · ${x.installed?'bereits installiert – wird wiederverwendet':'noch nicht eingerichtet'}
`:''}
${escape(x.message)}
${x.components?.length?`
Benötigt: ${x.components.map(escape).join(' · ')}. Modellabhängig zuordnen; benötigte Zusatzdateien gehören zum vollständigen Paket.
`:''}${x.suggested_components?.length?`
Zusatzdateien in dieser Quelle (Kompatibilität ungeprüft): ${x.suggested_components.map(escape).join(' · ')}. Nach dem Download unter Profil → Einrichtung & Komponenten prüfen.
Installierte Laufzeit bedeutet nicht, dass Modell und Kontext in den Speicher passen. Ein Test startet nur auf deinen Auftrag.
`;
}
function html(library, downloads=false) {
return `
HUGGING FACE · LIVE-KATALOG
${downloads ? 'Downloads' : library ? 'Deine Dateien auf Athena' : 'Das nächste Modell entdecken'}
${downloads ? 'Abgeschlossene Einträge ausblenden. Die Dateien bleiben in deiner Bibliothek.' : library ? 'Heruntergeladene Dateien mit festgehaltener Version und Integritätsprüfung.' : 'Modell suchen, Variante vergleichen und gezielt auf Athena herunterladen.'}
diff --git a/deploy/install.py b/deploy/install.py
index d7ecb5f..7fad487 100644
--- a/deploy/install.py
+++ b/deploy/install.py
@@ -218,6 +218,9 @@ def update(base, rollback=False, force=False, api_ports=None, reuse_runtime=Fals
if was_running:run('docker','start',config['name'])
raise RuntimeError('Update fehlgeschlagen. Vorheriger Deck-Container wiederhergestellt.') from None
run('docker','rm',backup_name)
+ # Keep only two automatic pre-update state snapshots after a successful update.
+ snapshots=sorted((p for p in (base/'backups').iterdir() if p.is_dir() and not p.is_symlink() and p.name.isdigit()),key=lambda p:int(p.name))
+ for snapshot in snapshots[:-2]:shutil.rmtree(snapshot)
print('Nur Athena Deck wurde aktualisiert. Vorheriges Image bleibt für --rollback erhalten.')
diff --git a/docs/RUNTIME_DISCOVERY.md b/docs/RUNTIME_DISCOVERY.md
index e7df274..4d338d3 100644
--- a/docs/RUNTIME_DISCOVERY.md
+++ b/docs/RUNTIME_DISCOVERY.md
@@ -19,3 +19,11 @@ Interne Admin-API: `GET /api/v1/audio-cpp-runtime`, `GET /api/v1/audio-cpp-runti
## YuE2-Komponenten in der GUI
Unter **Musik → Profile → Komponenten** bietet Deck für das offizielle YuE2-GGUF-Hauptmodell fünf Rollen: VAE (F16 empfohlen oder F32), Modellkonfiguration, Generierungskonfiguration, Qwen-Tokenizer und VAE-Konfiguration. „Alle fehlenden Dateien herunterladen“ reiht je eine Datei pro Rolle in die Download-Warteschlange ein. Danach „Bibliothek aktualisieren“ und „Zuordnung auf Athena speichern“. Bereits vorhandene Dateien werden wiederverwendet. Quelle und Zuweisung müssen dieselbe Hub-Revision wie das Hauptmodell haben. Diese Zusatzdateien sind keine eigenständigen Modelle. Der fehlende Musikworker bleibt auch nach vollständiger Zuweisung als Blocker sichtbar.
+
+## Gemeinsame Einrichtung in allen Kategorien
+
+Alle Profile haben „Einrichtung & Komponenten“. Zuerst wird ein gepflegtes Modellrezept verwendet. Fehlt dieses, können installierte audio.cpp-Paketdefinitionen für exakt passende audio-cpp-Repositories Zusatzdateien liefern. Ohne vollständiges Rezept zeigt Deck Dateinamen-Hinweise der Modellquelle als **ungeprüft** an. Ein anderer Hugging-Face-Link kann als Komponentenquelle ausgewählt werden. Vorhandene Zusatzdateien lassen sich in allen Kategorien zuordnen. Manuelle Rollen sind optional und werden niemals automatisch als kompatibel angenommen; ihre Zuordnung blockiert die Ausführung bis zur passenden Worker-Anbindung. Beim Wechsel zur manuellen Quelle bleiben vorhandene Rezept-Zuordnungen erhalten.
+
+Die Entdecken-Dateiansicht zeigt benötigte Komponenten aus bekannten Rezepten und Laufzeit-Paketen bereits vor dem Hauptdownload; weitere sichtbare Projektoren, Encoder und VAE werden als ungeprüfte Hinweise aufgeführt. Es gibt keine universelle, vollständige Erkennung aller Hub-Modelle.
+
+Download-Annahme und Fehler erscheinen direkt neben der betroffenen Komponente. Die 10-GiB-Diskreserve bleibt erhalten. Der Installer behält nach erfolgreichen Updates nur die zwei neuesten automatischen Zustands-Sicherungen; manuell exportierte Backup-Dateien werden nicht angefasst.
diff --git a/profiles-ui.js b/profiles-ui.js
index 2b76a49..2ec140d 100644
--- a/profiles-ui.js
+++ b/profiles-ui.js
@@ -7,7 +7,7 @@ window.ProfilesUI=(()=>{
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},enabled=[],gpus=[],projectors=[],entries=[],panelSequence=0;
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
async function load(){try{const [p,c,h,ep]=await Promise.all([api('profiles'),api('catalog'),['chat','video'].includes(kind)?api('hardware').catch(()=>({gpus:[]})):Promise.resolve({gpus:[]}),api('endpoint')]);entries=c.entries;projectors=c.entries.filter(x=>x.role==='vision_projector'&&x.file.endsWith('.gguf'));enabled=ep.profiles.filter(x=>x.enabled).map(x=>x.id);gpus=h.gpus||[];if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
- el('profile-list').innerHTML=rows.map(p=>`GESPEICHERT AUF ATHENA
${e(p.name)}
${e(p.model?.file||'Modelldatei nicht verfügbar')}
`:''}${CatalogUI.executionHTML(p.execution)}${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`
`:''}${CatalogUI.executionHTML(p.execution)}${p.runnable?(kind==='video'?'Laufzeit bereit · Video-Modus in Übersicht aktivieren':'Laufzeit bereit · lädt bei Anfrage'):'Was fehlt zur Ausführung?'}${p.blockers.map(t=>`
${e(t)}
`).join('')}
${kind==='image'?``:''}
`).join('')||`
Noch keine Profile
${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}
${kind==='image'?'Der feste API-Name athena-image verwendet das hier freigegebene Bildprofil und dessen Auflösung. Höchstens ein Bildprofil ist am API-Endpunkt freigegeben. Ein neuer Haken ersetzt die bisherige Auswahl.':['audio','stt'].includes(kind)?'Eingerichtete Sprachprofile können am API-Endpunkt freigegeben werden.':kind==='chat'?'Mehrere Sprachmodellprofile können gleichzeitig am API-Endpunkt freigegeben sein.':kind==='video'?'Genau ein Videoprofil kann am API-Endpunkt freigegeben sein. Ein neuer Haken ersetzt die bisherige Auswahl. Die Übersicht lädt dieses Profil beim Wechsel auf Video; API-Name: athena-video. Zum Ändern der Freigabe zuerst auf LLM wechseln.':'Für diesen Bereich ist noch keine ausführbare Laufzeit am Endpunkt angebunden.'} Freigegeben bedeutet nicht bereits in den GPU-Speicher geladen.
`);
root.querySelectorAll('[data-publish]').forEach(b=>b.onchange=async()=>{const checked=b.checked;root.querySelectorAll('[data-publish]').forEach(x=>x.disabled=true);try{await api('endpoint/profile',{id:b.dataset.publish,enabled:checked});await load();message(kind==='video'?'Video-Freigabe gespeichert. Die Übersicht lädt dieses Profil beim Wechsel auf Video.':'Endpunkt-Freigabe gespeichert. Geladen wird erst bei einer API-Anfrage.');}catch(error){await load();message(error.message);}});
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
@@ -31,22 +31,23 @@ window.ProfilesUI=(()=>{
}
refresh();el('profile-editor').scrollIntoView({behavior:'smooth',block:'start'});
}
- async function components(profile){
+ async function components(profile,extraRepo){
const sequence=++panelSequence;
el('profile-editor').innerHTML='
Laufzeit: ${e(data.runtime)} · ${data.manual?'Zuordnung ungeprüft':data.runtime_installed?'installiert':'noch nicht eingerichtet'}
${!data.manual?'':'
Nur benötigte Dateien einzeln auswählen; diese Liste ist kein automatischer Downloadplan.
'}`:''}`;
const missing=data.requirements?.filter(r=>!r.available.length)||[];
if(el('components-download-all')){el('components-download-all').disabled=!missing.length;el('components-download-all').onclick=async()=>{
const b=el('components-download-all');b.disabled=true;let count=0;
try{for(const r of missing){const f=r.candidates.find(f=>!f.gated||f.auth_configured);if(!f)throw Error('Keine herunterladbare Datei; HF-Zugang oder Quellversion prüfen für '+r.label);const catalog=await api('catalog');if((catalog.downloads||[]).some(j=>['queued','downloading'].includes(j.state)&&j.repo===f.repo&&j.file===f.name&&j.revision===f.revision))continue;await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});count++;}if(el('components-message'))el('components-message').textContent='Dateien sind im Downloadplan. Nach Abschluss Bibliothek aktualisieren und Zuordnung speichern; passende Dateien werden vorausgewählt.';}
catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}
};}
+ el('component-source-open').onclick=()=>{const value=el('component-source').value.trim();if(value)components(profile,value);};
el('components-close').onclick=()=>el('profile-editor').replaceChildren();
- el('components-refresh')?.addEventListener('click',()=>components(profile));
+ el('components-refresh')?.addEventListener('click',()=>components(profile,extraRepo));
el('components-form')?.addEventListener('submit',async event=>{event.preventDefault();const button=el('components-save');button.disabled=true;try{await api('profiles/components',{id:profile.id,revision:profile.revision,components:Object.fromEntries(new FormData(event.target))});if(!root.isConnected)return;el('profile-editor').replaceChildren();await load();message('Komponenten dem Profil zugeordnet. Kein Modell gestartet.');}catch(error){if(el('components-message'))el('components-message').textContent=error.message;}finally{button.disabled=false;}});
- root.querySelectorAll('[data-component-download]').forEach(b=>b.onclick=async()=>{const [role,index]=b.dataset.componentDownload.split(':'),f=data.requirements.find(r=>r.role===role).candidates[Number(index)];b.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});if(el('components-message'))el('components-message').textContent='Download gestartet. Fortschritt und Abbruch findest du unter Downloads. Nach Abschluss hier die Bibliothek aktualisieren.';}catch(error){if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}});
+ root.querySelectorAll('[data-component-download]').forEach(b=>b.onclick=async()=>{const feedback=b.parentElement.querySelector('[data-download-feedback]');feedback.textContent='Download wird angefragt …';const [role,index]=b.dataset.componentDownload.split(':'),f=data.requirements.find(r=>r.role===role).candidates[Number(index)];b.disabled=true;try{await api('catalog/download',{repo:f.repo,filename:f.name,revision:f.revision,kind:profile.kind});feedback.textContent='In Download-Warteschlange aufgenommen.';if(el('components-message'))el('components-message').textContent='Download gestartet. Fortschritt und Abbruch findest du unter Downloads. Nach Abschluss hier die Bibliothek aktualisieren.';}catch(error){feedback.textContent=error.message;feedback.className='warning';if(el('components-message'))el('components-message').textContent=error.message;b.disabled=false;}});
}catch(error){message(error.message);el('profile-editor').innerHTML='
Komponenten konnten nicht geladen werden. Öffne die Ansicht erneut.
';}
}
function gpuEditor(params){
diff --git a/profiles.py b/profiles.py
index 36cd917..b844170 100644
--- a/profiles.py
+++ b/profiles.py
@@ -5,6 +5,7 @@ import threading
import time
import uuid
from pathlib import Path
+from catalog import file_role
SCHEMAS={
'chat':{'context':(512,2097152,8192),'slots':(1,16,1),'threads':(1,256,6),'batch':(1,8192,512),'ubatch':(1,8192,128),'temperature':(0,5,.8),'top_p':(0,1,.95),'top_k':(0,1000,40),'mtp_tokens':(1,8,2),'mtp_min_p':(0,1,.05)},
@@ -92,7 +93,22 @@ def music_recipe(model):
def component_recipe(model):
return music_recipe(model) or video_recipe(model) or image_recipe(model)
+MANUAL_ROLES={'manual_text_encoder':'Textencoder (ungeprüft)','manual_vae':'VAE (ungeprüft)','manual_tokenizer':'Tokenizer (ungeprüft)','manual_configuration':'Konfiguration (ungeprüft)','manual_projector':'Projektor (ungeprüft)','manual_auxiliary':'Weitere Komponente (ungeprüft)'}
+
+def audio_package_recipe(model,source):
+ if not source or not model.get('repo','').startswith('audio-cpp/'):return None
+ for path in sorted(Path(source,'model_specs').glob('*.json')):
+ try:spec=json.loads(path.read_text())
+ except (ValueError,OSError):continue
+ for package in spec.get('packages',[]):
+ files=package.get('files',[])
+ if package.get('target_directory')!=model['repo'].split('/')[-1] or model['file'] not in files:continue
+ extras=[f for f in files if isinstance(f,str) and f!=model['file'] and not f.startswith('/') and '..' not in Path(f).parts]
+ return {f'runtime_file_{i}':dict(label='Laufzeit-Paket: '+f,files=[f]) for i,f in enumerate(extras)} or None
+ return None
+
class Profiles:
+ def _recipe(self,model):return component_recipe(model) or getattr(self,'runtime_recipe',lambda m:None)(model)
def __init__(self,path,catalog):
self.path=Path(path);self.catalog=catalog;self.lock=threading.RLock()
self.image_runtime_ready=lambda:False
@@ -134,6 +150,7 @@ class Profiles:
if p['kind']=='image':
for task,repo in PROMPT_ENHANCER_REPOS.items():
if p['prompt_enhancer'].get(task)==repo and not self.prompt_enhancer_ready(task):p['blockers'].append('Prompt-Aufwerter '+task.upper()+' fehlt. Unter Bildgenerierung → Aufwerter installieren.')
+ if any(k in MANUAL_ROLES for k in p.get('components',{})):p['blockers'].append('Manuelle Zusatzkomponenten sind ungeprüft und noch nicht an den Worker angebunden.')
p['runnable']=not p['blockers'];p['state']='ready' if p['runnable'] else 'configured'
return {'profiles':[p for p in rows if p['kind']!='video'],'schemas':{k:v for k,v in SCHEMAS.items() if k!='video'}}
def save(self,data):
@@ -202,16 +219,20 @@ class Profiles:
return {'deleted':old['id']}
def _component(self,model,role,ident):
- recipe=component_recipe(model)
+ if role in MANUAL_ROLES:
+ item=self.catalog.entry(ident)
+ if item['kind']!=model['kind'] or item['id']==model['id']:raise ValueError('Andere Zusatzdatei aus derselben Kategorie wählen.')
+ return item
+ recipe=self._recipe(model)
if not recipe or role not in recipe:raise ValueError('Für dieses Modell ist kein geprüftes Komponentenrezept hinterlegt.')
item=self.catalog.entry(ident)
if item['repo']!=recipe[role].get('repo',model['repo']) or item['kind']!=model['kind'] or item['file'] not in recipe[role]['files']:raise ValueError('Die Datei gehört nicht zu dieser Komponente des Modells.')
if (video_recipe(model) or music_recipe(model)) and item['revision']!=model['revision']:raise ValueError('Komponente muss dieselbe Quellversion wie das Hauptmodell verwenden.')
return item
- def components(self,model_id):
+ def components(self,model_id,extra_repo=None):
model=self.catalog.entry(model_id)
- if not component_recipe(model):return dict(supported=False,requirements=[],message='Für dieses Modell ist noch kein Komponentenrezept hinterlegt. Keine automatische Zuordnung.')
- recipe=component_recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video or music_recipe(model) else self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[]
+ if extra_repo or not self._recipe(model):return self.manual_components(model,extra_repo)
+ recipe=self._recipe(model);video=bool(video_recipe(model));source=self.catalog.files(model['repo'],model['revision']) if video or music_recipe(model) else self.catalog.files(next(iter(recipe.values())).get('repo',model['repo']));entries=self.catalog.status()['entries'];requirements=[]
for role,info in recipe.items():
available=[]
for item in entries:
@@ -222,16 +243,27 @@ class Profiles:
requirements.append(dict(role=role,label=info['label'],available=available,candidates=candidates))
if music_recipe(model):return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='audio.cpp',runtime_installed=bool(getattr(self,'music_runtime_ready',lambda:False)()),message='Passendes YuE2-GGUF-Paket: je eine VAE-Datei und die vier Zusatzdateien derselben Quellversion. F16-VAE ist die erste Downloadempfehlung. Vorhandene Dateien wiederverwenden, fehlende herunterladen und danach zuordnen. Der Deck-Musikworker und die Ladypoly-Anbindung fehlen weiterhin; vollständige Komponenten starten kein Modell.')
if video:return dict(supported=True,requirements=requirements,source='https://huggingface.co/'+model['repo']+'/blob/'+model['revision']+'/README.md',license=source['license'],runtime='ComfyUI · gemeinsame Bild- und Videolaufzeit',runtime_installed=bool(getattr(self,'video_runtime_ready',lambda:False)()),message='Geprüftes Dateirezept nach Herstellerdokumentation für die zweistufige Distilled-Pipeline mit fester Bildanzahl. Alle Komponenten stammen aus derselben Version wie dein Transformer. Kein beliebiger Gemma-Encoder; keine Comfy-INT8-Dateien. Optionaler Duration-Head, Temporal-Upsampling und DFR sind nicht Teil dieses Rezepts. Vorhandene ComfyUI-Laufzeit wiederverwenden; Videomodell im Bereich Video aktivieren. Vollständige Dateien sind keine Speicherzusage.')
+ if not image_recipe(model):return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='audio.cpp · Paketdefinition',runtime_installed=bool(getattr(self,'music_runtime_ready',lambda:False)()),message='Zusatzdateien aus der installierten Laufzeit-Paketdefinition. Weitere modellabhängige Pakete können erforderlich sein; dies ist keine vollständige Worker-Zusage.')
return dict(supported=True,requirements=requirements,source=source['url'],license=source['license'],runtime='ComfyUI + ComfyUI-GGUF',runtime_installed=self.image_runtime_ready(),
message='Fehlende Dateien herunterladen, danach Bibliothek aktualisieren und zuordnen. Für FLUX.2 Klein empfehlen sich zum Einstieg 4 Schritte und Guidance 1. Die Bildgewichte werden bei Bedarf zwischen RAM und RTX 5080 verschoben.' if model['repo']==FLUX_REPO else 'Zusatzdateien laut Modellkarte für ComfyUI. Kompatibilität und Speicherbedarf müssen mit der eingerichteten Laufzeit noch praktisch geprüft werden.')
+ def manual_components(self,model,repo=None):
+ source=self.catalog.files(repo or model['repo'],None if repo else model['revision'])
+ entries=self.catalog.status()['entries'];requirements=[]
+ for role,label in MANUAL_ROLES.items():
+ match={'manual_text_encoder':('text_encoder',),'manual_vae':('vae',),'manual_projector':('vision_projector','audio_projector'),'manual_configuration':('configuration',),'manual_auxiliary':('auxiliary',),'manual_tokenizer':('configuration','auxiliary')}[role]
+ def matches(name):return name.endswith('.tiktoken') or 'tokenizer' in name.lower() if role=='manual_tokenizer' else file_role(name)['role'] in match
+ candidates=[dict(f,repo=source['repo'],revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for f in source['files'] if f['name']!=model['file'] and matches(f['name'])]
+ available=[x for x in entries if x['kind']==model['kind'] and x['id']!=model['id'] and matches(x['file'])]
+ requirements.append(dict(role=role,label=label,available=available,candidates=candidates,optional=True))
+ return dict(supported=True,manual=True,requirements=requirements,source=source['url'],license=source['license'],runtime='Siehe Laufzeiterkennung am Modell',runtime_installed=False,message='Kein vollständiges geprüftes Rezept für diese Auswahl. Diese Dateien sind Hinweise aus Dateinamen und keine bestätigten Pflichtkomponenten. Manuell ausgewählte Dateien werden als ungeprüft gespeichert und schalten keinen Worker frei.')
def assign(self,data):
if set(data)!={'id','revision','components'} or not isinstance(data['components'],dict):raise ValueError('Ungültige Komponentenzuordnung.')
with self.lock:
old=next((p for p in self.rows if p['id']==data['id']),None)
- if not old or old['kind'] not in ('image','video','music') or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.')
+ if not old or old['kind'] not in SCHEMAS or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.')
model=self.catalog.entry(old['model_id'])
- if set(data['components'])-set(component_recipe(model) or {}):raise ValueError('Unbekannte Komponente für dieses Modell.')
- components={k:v for k,v in data['components'].items() if v}
+ if set(data['components'])-(set(self._recipe(model) or {})|set(MANUAL_ROLES)):raise ValueError('Unbekannte Komponente für dieses Modell.')
+ components={k:v for k,v in {**old.get('components',{}),**data['components']}.items() if v}
for role,ident in components.items():self._component(model,role,ident)
updated=dict(old,components=components,revision=old['revision']+1,updated_at=time.time())
rows=[updated if p is old else p for p in self.rows]
diff --git a/server.py b/server.py
index ff53255..2835270 100644
--- a/server.py
+++ b/server.py
@@ -118,6 +118,8 @@ class Server(ThreadingHTTPServer):
from audio_cpp_runtime import AudioCppRuntime
self.audio_cpp_runtime=AudioCppRuntime(self.catalog.root.parent/'audio-cpp-runtime')
self.profiles.music_runtime_ready=lambda:self.audio_cpp_runtime.status()['installed']
+ from profiles import audio_package_recipe
+ self.profiles.runtime_recipe=lambda model:audio_package_recipe(model,self.audio_cpp_runtime.paths()[1])
self.ltx_original_runtime=LTXOriginalRuntime(self.catalog.root.parent/'ltx-original-runtime',self.image_runtime)
self.video=VideoRuntimes(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video',self.catalog,self.profiles,self.image_runtime,original_runtime=self.ltx_original_runtime)
self.endpoint.video=self.video
@@ -326,7 +328,7 @@ class Handler(BaseHTTPRequestHandler):
try:return self.respond(self.server.image_tests.image(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='image/png')
except (OSError,ValueError):return self.respond({'error':'Bild nicht verfügbar.'},404)
if urlsplit(self.path).path == '/api/v1/profiles/components':
- try:return self.respond(self.server.profiles.components(parse_qs(urlsplit(self.path).query).get('model_id',[''])[0]))
+ try:return self.respond(self.server.profiles.components(parse_qs(urlsplit(self.path).query).get('model_id',[''])[0],parse_qs(urlsplit(self.path).query).get('repo',[None])[0]))
except ValueError as exc:return self.respond({'error':str(exc)},400)
except Exception:return self.respond({'error':'Komponentenquelle nicht erreichbar. Bitte erneut versuchen.'},503)
if self.path == '/api/v1/profiles':
@@ -372,6 +374,13 @@ class Handler(BaseHTTPRequestHandler):
if path.path=='/api/v1/catalog/files':
data=dict(self.server.catalog.files(q.get('repo',[''])[0]));hardware=self.server.hardware.snapshot()
data['files']=[dict(f,assessment=assess(f['size'],hardware,f['name']),execution=self.execution_setup(dict(repo=data['repo'],revision=data['revision'],file=f['name'],kind=q.get('kind',['chat'])[0],runtime_metadata=data.get('runtime_metadata',{})))) for f in data['files']]
+ from profiles import component_recipe
+ from catalog import file_role
+ for f in data['files']:
+ model=dict(repo=data['repo'],file=f['name'],kind=q.get('kind',['chat'])[0])
+ recipe=component_recipe(model) or self.server.profiles.runtime_recipe(model)
+ if recipe:f['execution']['components']=[r['label'] for r in recipe.values()]
+ elif f['execution']['state']!='component':f['execution']['suggested_components']=[x['name'] for x in data['files'] if file_role(x['name'])['role'] in ('text_encoder','vae','vision_projector','audio_projector')][:12]
return self.respond(data)
if path.path=='/api/v1/catalog':
data=self.server.catalog.status()
diff --git a/test_music_components.py b/test_music_components.py
index 971c9e7..aee804d 100644
--- a/test_music_components.py
+++ b/test_music_components.py
@@ -24,3 +24,24 @@ class MusicComponentsTests(unittest.TestCase):
for info in YUE2_COMPONENTS.values():
for filename in info['files']:self.assertFalse(file_role(filename)['profile_eligible'])
self.assertIsNone(music_recipe(dict(kind='music',repo='other/Yue2',file='yue2-3b-bf16.gguf')))
+ def test_manual_setup_available_in_every_category_and_preserves_recipe(self):
+ for kind in ('chat','image','audio','stt','music','voice','video'):
+ model=dict(id='main',kind=kind,repo='unknown/model',file='weights.gguf',revision='a'*40)
+ component=dict(id='aux',kind=kind,repo='external/repo',file='vae.safetensors',revision='b'*40,size=1)
+ c=Mock();c.entry.side_effect=lambda id: {'main':model,'aux':component}[id]
+ c.status.return_value=dict(entries=[component]);c.files.return_value=dict(repo='unknown/model',revision='a'*40,files=[],gated=False,url='https://huggingface.co/unknown/model',license=None)
+ with tempfile.TemporaryDirectory() as d:
+ p=Profiles(Path(d)/'profiles.json',c);p.rows=[dict(id='p',kind=kind,revision=1,name='Test',model_id='main',parameters={})]
+ self.assertTrue(p.components('main')['manual'])
+ p.assign(dict(id='p',revision=1,components={'manual_vae':'aux'}))
+ self.assertEqual(p.rows[0]['components']['manual_vae'],'aux')
+ self.assertTrue(p.references('aux'))
+ with self.assertRaises(ValueError):p.assign(dict(id='p',revision=2,components={'arbitrary':'aux'}))
+ def test_audio_runtime_package_definition_matches_exact_repo_and_model(self):
+ import json
+ from profiles import audio_package_recipe
+ with tempfile.TemporaryDirectory() as d:
+ Path(d,'model_specs').mkdir();Path(d,'model_specs/test.json').write_text(json.dumps(dict(packages=[dict(target_directory='Example-GGUF',files=['model.gguf','config.json'])])))
+ model=dict(repo='audio-cpp/Example-GGUF',file='model.gguf')
+ self.assertEqual(audio_package_recipe(model,d)['runtime_file_0']['files'],['config.json'])
+ self.assertIsNone(audio_package_recipe(dict(model,repo='other/Example-GGUF'),d))