"""Server-persisted library-backed profiles with validated, typed parameters.""" import json import re 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)}, 'image':{'width':(256,2048,1024),'height':(256,2048,1024),'steps':(1,100,25),'seed':(-1,2147483647,-1),'guidance':(0,30,1)}, 'audio':{'speed':(.25,4,1)}, 'stt':{},'music':{},'voice':{}, 'video':{'width':(256,1920,768),'height':(256,1088,512),'frames':(1,241,33),'fps':(1,60,24),'steps':(1,100,20),'seed':(-1,2147483647,-1)} } VIDEO_DEVICE_DEFAULTS={'video_device':'auto','text_encoder_device':'same'} PROMPT_ENHANCER_REPOS={'t2i':'Qwen/Qwen-Image-2.1-PE-T2I','i2i':'Qwen/Qwen-Image-2.1-PE-I2I'} PROMPT_ENHANCER_DEFAULTS={'t2i':None,'i2i':None,'device':'auto'} def video_parameters(params): params={**VIDEO_DEVICE_DEFAULTS,**params} for key in VIDEO_DEVICE_DEFAULTS: value=params[key] if not isinstance(value,str) or not (value in (('auto',) if key=='video_device' else ('same',)) or re.fullmatch(r'GPU-[a-zA-Z0-9-]+',value)): raise ValueError('Video-Gerät muss eine GPU-UUID sein; CPU wird im Disk-Streaming-Adapter noch nicht unterstützt.') return params CHAT_GPU_DEFAULTS={'gpu_devices':[], 'split_mode':'none', 'tensor_split':[], 'mtp':False,'gpu_offload':'auto','gpu_reserve_mode':'auto','gpu_reserve_mib':{},'vision_projector':None,'vision_device':'cpu','cache_type_k':'q4_0','cache_type_v':'q4_0'} CHAT_SAMPLING={'temperature':.8,'top_p':.95,'top_k':40,'mtp_tokens':2,'mtp_min_p':.05} def chat_parameters(params): params={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**params} if params['gpu_reserve_mode'] not in ('auto','manual','none'):raise ValueError('Ungültiger GPU-Reservemodus.') reserve=params['gpu_reserve_mib'] if not isinstance(reserve,dict) or any(k not in params['gpu_devices'] or type(v) is not int or not 0<=v<=32768 for k,v in reserve.items()):raise ValueError('GPU-Reserve muss je ausgewählter GPU zwischen 0 und 32768 MiB liegen.') if params['gpu_reserve_mode']=='manual' and (not params['gpu_devices'] or set(reserve)!=set(params['gpu_devices'])):raise ValueError('Manuelle Reserve erfordert einen Wert für jede ausgewählte GPU.') if params['gpu_offload'] not in ('auto','full'):raise ValueError('Ungültiger GPU-Auslagerungsmodus.') if params['cache_type_k'] not in ('f16','q8_0','q4_0') or params['cache_type_v'] not in ('f16','q8_0','q4_0'):raise ValueError('KV-Cache: nur f16, q8_0 oder q4_0 unterstützt.') if type(params['mtp']) is not bool:raise ValueError('MTP muss an oder aus sein.') if params['vision_projector'] is not None and (not isinstance(params['vision_projector'],str) or not re.fullmatch(r'[a-f0-9]{64}',params['vision_projector'])):raise ValueError('Ungültige Projektor-ID.') if params['vision_device']!='cpu' and (not isinstance(params['vision_device'],str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',params['vision_device'])):raise ValueError('Projektor-Gerät muss CPU oder eine gültige GPU-UUID sein.') devices=params['gpu_devices'];split=params['tensor_split'] if not isinstance(devices,list) or len(devices)>16 or any(not isinstance(v,str) or not re.fullmatch(r'GPU-[0-9a-fA-F-]{36}',v) for v in devices) or len(set(devices))!=len(devices):raise ValueError('GPUs müssen als eindeutige, geordnete GPU-UUIDs angegeben werden.') if params['split_mode'] not in ('none','layer','row'):raise ValueError('Ungültiger GPU-Split-Modus.') if not isinstance(split,list) or any(isinstance(v,bool) or not isinstance(v,(int,float)) or not 01 and params['split_mode']=='none':raise ValueError('Bei mehreren GPUs Layer- oder Row-Split auswählen.') return params # Explicit model-card recipe. This is a source recommendation, not a runtime test. QWEN_REPO='abenzerps/Qwen-Image-2.1-Uncensored-GGUF' QWEN_COMPONENTS={ 'text_encoder':{'label':'Textencoder','files':['text_encoders/qwen3vl_8b_int8_convrot.safetensors','text_encoders/qwen3vl_8b_bf16.safetensors']}, 'vae':{'label':'VAE','files':['vae/qwen_image_2.1_vae_bf16.safetensors']} } FLUX_REPO='RunningHubAI/rh-flux.2-klein-9b-fp16-unet-2067980602644717569' FLUX_COMPONENT_REPO='Comfy-Org/flux2-klein-9B' FLUX_COMPONENTS={ 'text_encoder':{'label':'Textencoder für FLUX.2 Klein 9B','repo':FLUX_COMPONENT_REPO,'files':['split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors']}, 'vae':{'label':'VAE für FLUX.2','repo':FLUX_COMPONENT_REPO,'files':['split_files/vae/flux2-vae.safetensors']} } # One registry owns runtime, workflow, format, components and resource policy. IMAGE_ADAPTERS={ 'qwen_image_21':dict(label='Qwen Image 2.1',workflow='qwen',model_filename='model.gguf',suffix='.gguf',runtime='ComfyUI + ComfyUI-GGUF',reference_images=4,offload=False,components={k:{'repo':QWEN_REPO,**v} for k,v in QWEN_COMPONENTS.items()},sources={QWEN_REPO.lower(),'qwen/qwen-image-2.1','comfy-org/qwen-image-2.1'},architectures={'qwenimage21transformer2dmodel','qwen_image_2_1'}), 'flux2_klein_9b':dict(label='FLUX.2 Klein 9B',workflow='flux',model_filename='model.safetensors',suffix='.safetensors',runtime='ComfyUI',reference_images=4,offload=True,components=FLUX_COMPONENTS,sources={FLUX_REPO.lower(),'black-forest-labs/flux.2-klein-9b','comfy-org/flux2-klein-9b'},architectures={'flux2klein9btransformer2dmodel','flux2_klein_9b'}), } def image_adapter(model): if not model:return None filename=model.get('file','') if not file_role(filename)['profile_eligible'] or '-of-' in filename:return None meta=model.get('runtime_metadata') or {};architecture=str(meta.get('architecture') or '').lower() lineage={str(x).lower() for x in meta.get('base_models',[]) if isinstance(x,str)} repo=model.get('repo','').lower() matches=[a for a in IMAGE_ADAPTERS.values() if filename.endswith(a['suffix']) and (repo in a['sources'] or architecture in a['architectures'] or bool(lineage & a['sources']))] return matches[0] if len(matches)==1 else None def image_recipe(model): adapter=image_adapter(model) return adapter['components'] if adapter else None def image_capabilities(model): adapter=image_adapter(model) return {'reference_images':adapter['reference_images'] if adapter else 0} LTX_REPO='Lightricks/LTX-2.5' LTX_FILE='diffusion_models/ltx-2.5-22b-distilled-transformer-bf16.safetensors' LTX_COMPONENTS={ 'text_encoder':{'label':'Gemma 4 12B · Textencoder mit LTX-Projektionen','files':['text_encoders/gemma4-12b-with-proj-ltx-2.5-bf16.safetensors']}, 'video_vae':{'label':'Video-VAE · BF16','files':['vae/ltx-2.5-video-vae-bf16.safetensors']}, 'audio_vae':{'label':'Audio-VAE und Vocoder · BF16','files':['vae/ltx-2.5-audio-vae-bf16.safetensors']}, 'spatial_upsampler':{'label':'Spatial-Upsampler · zweistufige Distilled-Pipeline','files':['latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors']} } def video_recipe(model): return LTX_COMPONENTS if model.get('kind')=='video' and model.get('repo')==LTX_REPO and model.get('file')==LTX_FILE else None YUE2_REPO='audio-cpp/Yue2-3B-GGUF' YUE2_COMPONENTS={ 'vae':{'label':'YuE2-VAE · F16 empfohlen, F32 alternativ','files':['yue2-vae-f16.gguf','yue2-vae-f32.gguf']}, 'model_config':{'label':'Modellkonfiguration','files':['sidecars/yue2-model-config.json']}, 'generation_config':{'label':'Generierungskonfiguration','files':['sidecars/yue2-generation-config.json']}, 'tokenizer':{'label':'Qwen-Tokenizer','files':['sidecars/yue2-qwen.tiktoken']}, 'vae_config':{'label':'VAE-Konfiguration','files':['sidecars/yue2-vae-config.json']} } def music_recipe(model): return YUE2_COMPONENTS if model.get('kind')=='music' and model.get('repo')==YUE2_REPO and model.get('file') in ('yue2-3b-bf16.gguf','yue2-3b-q8_0.gguf','yue2-3b-q4_0.gguf') else None 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 self.prompt_enhancer_ready=lambda task:False self.video_blockers=None;self.chat_blockers=None;self.tts_blockers=None;self.stt_blockers=None self.rows=json.loads(self.path.read_text()) if self.path.exists() else [] def status(self): with self.lock: rows=json.loads(json.dumps(self.rows)) for p in rows: if p['kind']=='image':p['prompt_enhancer']={**PROMPT_ENHANCER_DEFAULTS,**p.get('prompt_enhancer',{})} if p['kind']=='video':p['parameters']={**VIDEO_DEVICE_DEFAULTS,**p['parameters']} if p['kind']=='chat':p['parameters']={**CHAT_GPU_DEFAULTS,**CHAT_SAMPLING,**p['parameters']} try: p['model']=self.catalog.entry(p['model_id']) if p['kind']=='image':p['capabilities']=image_capabilities(p['model']) p['blockers']=['Für dieses Profil ist noch kein ausführbarer Worker angebunden.'] if p['kind']=='image' and image_recipe(p['model']): p['blockers']=['Eine eigene Bildlaufzeit muss eingerichtet werden; llama.cpp führt keine Bildmodelle aus.'] for role,info in reversed(list(image_recipe(p['model']).items())): try:self._component(p['model'],role,p.get('components',{}).get(role)) except ValueError:p['blockers'].insert(0,info['label']+' fehlt oder ist noch nicht zugeordnet.') except ValueError as exc:p['model']=None;p['blockers']=[str(exc)] if p.get('model') and image_recipe(p['model']) and self.image_runtime_ready(): p['blockers']=[b for b in p['blockers'] if not b.startswith('Eine eigene Bildlaufzeit')] if p.get('model') and video_recipe(p['model']): for role,info in video_recipe(p['model']).items(): try:self._component(p['model'],role,p.get('components',{}).get(role)) except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.') if p.get('model') and music_recipe(p['model']): for role,info in music_recipe(p['model']).items(): try:self._component(p['model'],role,p.get('components',{}).get(role)) except ValueError:p['blockers'].append(info['label']+' fehlt oder ist noch nicht zugeordnet.') if p['kind']=='video' and self.video_blockers and p.get('model'):p['blockers']=self.video_blockers(p) if p['kind']=='audio' and self.tts_blockers and p.get('model'):p['blockers']=self.tts_blockers(p) if p['kind']=='music' and getattr(self,'music_blockers',None) and p.get('model'):p['blockers']=self.music_blockers(p) if p['kind']=='stt' and self.stt_blockers and p.get('model'):p['blockers']=self.stt_blockers(p) if p['kind']=='chat' and self.chat_blockers and p.get('model'):p['blockers']=self.chat_blockers(p) if p['kind']=='image' and (p['parameters']['width']>1024 or p['parameters']['height']>1024):p['blockers'].append('Die Bildlaufzeit unterstützt derzeit maximal 1024 × 1024 Pixel.') 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): if set(data)!={'id','revision','name','kind','model_id','parameters'}:raise ValueError('Ungültige Profilfelder.') kind=data['kind'];name=data['name'];params=data['parameters'] if kind=='video':raise ValueError('Videoprofile werden nicht mehr in Deck verwaltet. Originale LTX-Oberfläche verwenden.') if not isinstance(kind,str) or kind not in SCHEMAS or not isinstance(name,str) or not (2<=len(name)<=64 and name[0].isalnum() and name[-1].isalnum() and re.fullmatch(r'[\w .-]+',name)):raise ValueError('API-Name: 2–64 Zeichen; Buchstaben, Ziffern, Leerzeichen, Punkt, Bindestrich oder Unterstrich. Anfang und Ende müssen Buchstabe oder Ziffer sein.') model=self.catalog.entry(data['model_id']) if model['repo']==QWEN_REPO and any(model['file'] in r['files'] for r in QWEN_COMPONENTS.values()):raise ValueError('Textencoder und VAE werden über Komponenten zugeordnet, nicht als Hauptmodell.') if model['kind']!=kind or not model['profile_eligible']:raise ValueError('Eine Gewichtsdatei dieses Bereichs auswählen, keine Konfiguration.') if kind=='chat' and isinstance(params,dict): params=chat_parameters(params) if params['vision_projector']: projector=self.catalog.entry(params['vision_projector']) if projector.get('role')!='vision_projector' or not projector['file'].endswith('.gguf'):raise ValueError('Eine heruntergeladene mmproj-GGUF-Datei auswählen.') if kind=='video' and isinstance(params,dict):params=video_parameters(params) if not isinstance(params,dict) or set(params)!=(set(SCHEMAS[kind]) | (set(CHAT_GPU_DEFAULTS) if kind=='chat' else set(VIDEO_DEVICE_DEFAULTS) if kind=='video' else set())):raise ValueError('Unvollständige oder unbekannte Profilparameter.') for key,(lo,hi,_) in SCHEMAS[kind].items(): value=params[key] floating=key in ('guidance','speed','temperature','top_p','mtp_min_p') if isinstance(value,bool) or not isinstance(value,(float,int) if floating else int) or not lo<=value<=hi:raise ValueError('Ungültiger Parameter: '+key) if kind in ('image','video') and (params['width']%64 or params['height']%64):raise ValueError('Breite und Höhe müssen durch 64 teilbar sein.') if kind=='chat' and params['ubatch']>params['batch']:raise ValueError('Microbatch darf nicht größer als Batch sein.') with self.lock: # Recheck under the profile lock so a concurrent library deletion cannot # leave a newly saved profile pointing at a removed file. self.catalog.entry(data['model_id']) existing=next((p for p in self.rows if p['id']==data['id']),None) if data['id'] is not None and not existing:raise ValueError('Profil nicht gefunden.') if data['revision']!=(existing['revision'] if existing else 0):raise ValueError('Profil wurde zwischenzeitlich geändert. Ansicht neu laden.') if any(p['name']==name and p is not existing for p in self.rows):raise ValueError('API-Profilname bereits vergeben.') components=existing.get('components',{}) if existing and existing['model_id']==model['id'] else {} row=dict(components=components,prompt_enhancer=existing.get('prompt_enhancer',dict(PROMPT_ENHANCER_DEFAULTS)) if existing and existing['model_id']==model['id'] and kind=='image' else dict(PROMPT_ENHANCER_DEFAULTS) if kind=='image' else None,id=existing['id'] if existing else uuid.uuid4().hex,revision=data['revision']+1,name=name,kind=kind,model_id=model['id'],parameters=params,updated_at=time.time()) rows=[row if p is existing else p for p in self.rows] if existing else self.rows+[row] self.path.parent.mkdir(parents=True,exist_ok=True,mode=0o700) temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows return row def references(self,model_id): with self.lock: return [dict(id=p['id'],name=p['name'],kind=p['kind']) for p in self.rows if model_id in (p.get('model_id'),p.get('parameters',{}).get('vision_projector')) or model_id in (p.get('components') or {}).values()] def configure_prompt_enhancer(self,data): if not isinstance(data,dict) or set(data)!={'id','revision','prompt_enhancer'}:raise ValueError('Ungültige Prompt-Aufwerter-Einstellung.') config=data['prompt_enhancer'] if not isinstance(config,dict) or set(config)!=set(PROMPT_ENHANCER_DEFAULTS):raise ValueError('Unvollständige Prompt-Aufwerter-Einstellung.') if any(config[task] not in (None,repo) for task,repo in PROMPT_ENHANCER_REPOS.items()):raise ValueError('Für diese Aufgabe ist der Aufwerter nicht geprüft.') if config['device'] not in ('auto','5080','3060','cpu'):raise ValueError('Ungültiges Gerät für den Aufwerter.') with self.lock: old=next((p for p in self.rows if p['id']==data['id'] and p['kind']=='image'),None) if not old or data['revision']!=old['revision']:raise ValueError('Bildprofil geändert oder nicht gefunden. Ansicht neu laden.') updated=dict(old,prompt_enhancer=dict(config),revision=old['revision']+1,updated_at=time.time()) rows=[updated if p is old else p for p in self.rows] temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows return updated def delete(self,data): if set(data)!={'id','revision'}:raise ValueError('Ungültige Profilfelder.') with self.lock: old=next((p for p in self.rows if p['id']==data['id']),None) if not old or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.') rows=[p for p in self.rows if p is not old] temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows return {'deleted':old['id']} def _component(self,model,role,ident): 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,extra_repo=None): model=self.catalog.entry(model_id) 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: try: self._component(model,role,item['id']);available.append(item) except ValueError:pass candidates=[dict(f,repo=source.get('repo',info.get('repo',model['repo'])),revision=source['revision'],gated=source['gated'],auth_configured=source.get('auth_configured',False)) for name in info['files'] for f in source['files'] if f['name']==name] 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. Die YuE2-Musikanbindung verwendet diese Dateien über audio.cpp. Speichern startet kein Modell; anschließend Musik → Testen verwenden.') 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=image_adapter(model)['runtime'],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 image_adapter(model)['workflow']=='flux' 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 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(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] temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows return updated