From c5a028cba96cfe31c50afbfe630fafda39b46966 Mon Sep 17 00:00:00 2001
From: Mikei386 <44135113+Mikei386@users.noreply.github.com>
Date: Tue, 29 Sep 2026 21:16:40 +0200
Subject: [PATCH] Add optional per-image-profile Qwen prompt enhancers
---
ENDPOINT.md | 8 ++
deploy/Dockerfile | 4 +-
deploy/install.py | 2 +-
image-test-ui.js | 31 +++++++-
image_runtime.py | 2 +-
image_test.py | 17 +++--
image_upload.py | 2 +-
profiles-ui.js | 18 ++++-
profiles.py | 23 +++++-
prompt_enhancer.py | 154 ++++++++++++++++++++++++++++++++++++++
prompt_enhancer_worker.py | 57 ++++++++++++++
server.py | 32 +++++++-
test_prompt_enhancer.py | 32 ++++++++
13 files changed, 363 insertions(+), 19 deletions(-)
create mode 100644 prompt_enhancer.py
create mode 100644 prompt_enhancer_worker.py
create mode 100644 test_prompt_enhancer.py
diff --git a/ENDPOINT.md b/ENDPOINT.md
index 6c8db64..9b67f24 100644
--- a/ENDPOINT.md
+++ b/ENDPOINT.md
@@ -194,6 +194,14 @@ Chat akzeptiert `reasoning_effort`: `none`, `minimal`, `low`, `medium`, `high`,
Die Bildgröße wird aus dem Profil übernommen. `size` (z. B. `1536x1024` oder `auto`) ist ein Wunsch und verändert weder Profil noch Speicherbedarf. Die JSON-Antwort enthält zusätzlich `athena_deck.size`, `requested_size` und `size_policy: profile`. Es erfolgt keine automatische Skalierung oder Änderung des Seitenverhältnisses. In Hermes einmalig `image_gen.openai.model: athena-image` setzen.
+### Optionale Bild-Prompt-Aufbereitung
+
+Unter **Bildgenerierung → Profile → Prompt-Aufwerter** kann jedes Bildprofil Text-zu-Bild und Bildbearbeitung mit Referenzbildern getrennt ein- oder ausschalten. Aktuell sind nur die offiziellen `Qwen/Qwen-Image-2.1-PE-T2I` und `Qwen/Qwen-Image-2.1-PE-I2I` als ausführbare Rezepte hinterlegt. Die Modellkarten und die Qwen Research License sind im Dialog verlinkt; beide Downloads sind jeweils etwa 18,8 GB groß. Eine Zuordnung zu anderen Bildfamilien ist möglich, ihre Bildqualität wird dabei nicht zugesichert. Uninstallierte Aufwerter erscheinen als Profilblocker.
+
+Deck lädt den Aufwerter bei Bedarf vor dem Bildworker, führt ihn mit 5080/3060 und bei Bedarf CPU-Auslagerung aus und beendet den Prozess vor dem Start von ComfyUI. Eine CPU-only-Wahl ist möglich, aber sehr langsam. Die generierte `wh_ratio` bleibt ein Vorschlag; die gespeicherte Bildauflösung wird nicht automatisch geändert. Ein Fehler der Aufbereitung beendet den Auftrag sichtbar, statt still den Originalprompt zu verwenden. Der Bildtest zeigt über **Prompt-Aufbereitung ansehen** Original und Vorschlag; „Übernehmen“ oder „Original behalten“ überspringt die erneute Aufbereitung bei genau diesem Testauftrag. API-Bildaufträge verwenden die Profilwahl automatisch.
+
+Verwaltungs-API: `GET /api/v1/prompt-enhancers` (Installation und Vorschau), `POST /api/v1/prompt-enhancers/install` mit `{ "task": "t2i|i2i" }`, `POST /api/v1/prompt-enhancers/cancel` mit `{}`, `POST /api/v1/prompt-enhancers/preview` mit `{ "profile_id": "…", "prompt": "…" }` oder Multipart samt `image[]`; `POST /api/v1/profiles/prompt-enhancer` speichert `{ "id": "…", "revision": 1, "prompt_enhancer": { "t2i": null, "i2i": null, "device": "auto" } }` mit den offiziellen Repository-IDs statt `null` für aktive Aufgaben. Diese Routen erfordern die angemeldete Verwaltungssitzung. Der normale Bildendpunkt behält seine API-Form.
+
### API-Kompatibilität
`api_compat.py` normalisiert Chat-Anfragen unabhängig von Client, Modellprofilen und Prozesssteuerung. Standard-Effortwerte bis `max` werden als Template-Hinweis weitergereicht. Die Erweiterung `ultra` wird auf `max` abgebildet, unabhängig davon, welcher Client sie sendet. Fehlend oder null bleibt ungesetzt; `none` wird unverändert weitergereicht. Es werden keine Tokenbudgets erfunden und keine Modellprofile verändert.
diff --git a/deploy/Dockerfile b/deploy/Dockerfile
index 49d7fcc..f073545 100644
--- a/deploy/Dockerfile
+++ b/deploy/Dockerfile
@@ -8,13 +8,13 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
&& git -C /opt/deck-comfy checkout 8d534945ebd53cff61e8def81757c6a6c1b9cf2d \
&& git clone https://github.com/leejet/ComfyUI-GGUF.git /opt/deck-comfy/custom_nodes/ComfyUI-GGUF \
&& git -C /opt/deck-comfy/custom_nodes/ComfyUI-GGUF checkout 373048b8403a7820620065210a691263d4da0a61 \
- && /opt/deck-image-python/bin/pip install --no-cache-dir -c /tmp/image-requirements.lock -r /opt/deck-comfy/requirements.txt -r /opt/deck-comfy/custom_nodes/ComfyUI-GGUF/requirements.txt \
+ && /opt/deck-image-python/bin/pip install --no-cache-dir -c /tmp/image-requirements.lock -r /opt/deck-comfy/requirements.txt -r /opt/deck-comfy/custom_nodes/ComfyUI-GGUF/requirements.txt accelerate==1.15.0 \
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
-COPY audio_policy.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py /app/
+COPY audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py /app/
COPY video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.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 \
diff --git a/deploy/install.py b/deploy/install.py
index 6b1001f..6187c2d 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 = ['audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.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','deploy/image-requirements.lock']
+FILES = ['prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.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','deploy/image-requirements.lock']
def run(*args, check=True, interactive=False):
diff --git a/image-test-ui.js b/image-test-ui.js
index bc3f07a..f5c1586 100644
--- a/image-test-ui.js
+++ b/image-test-ui.js
@@ -1,16 +1,39 @@
window.ImageTestUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
async function api(path,data){const r=await fetch('/api/v1/'+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(readonly=false){return `
${readonly?'Bildlaufzeit':'Bildmodell testen'}
Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.
${readonly?'':``}
Textencoder: RTX 3060 · Diffusionsmodell und VAE: RTX 5080. CPU/RAM werden weiterhin zum Laden und Auslagern benötigt. GPU-Last entsteht nicht während aller Verarbeitungsschritte. Aktuelle Hardware-Messwerte →
`;}
+ function html(readonly=false){return `
${readonly?'Bildlaufzeit':'Bildmodell testen'}
Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.
${readonly?'':``}
Textencoder: RTX 3060 · Diffusionsmodell und VAE: RTX 5080. CPU/RAM werden weiterhin zum Laden und Auslagern benötigt. GPU-Last entsteht nicht während aller Verarbeitungsschritte. Aktuelle Hardware-Messwerte →
`;}
function bind(readonly=false){
- const root=document.querySelector('#image-test'),el=id=>root.querySelector('#'+id);let profiles=[],status=null,lastJob='',timer,submitting=false;
+ const root=document.querySelector('#image-test'),el=id=>root.querySelector('#'+id);let profiles=[],status=null,lastJob='',timer,submitting=false,previewId=null;
const message=t=>{if(root.isConnected)el('image-test-message').textContent=t;};
- function selection(){if(readonly)return;const p=profiles.find(p=>p.id===el('image-test-profile').value),max=p?.capabilities?.reference_images||0;el('image-profile-info').textContent=p?`${p.parameters.width} × ${p.parameters.height} · ${p.parameters.steps} Schritte · Guidance ${p.parameters.guidance} · Seed ${p.parameters.seed}${p.blockers.length?' · '+p.blockers.join(' '):''}`:'';el('image-reference-label').hidden=!max;el('image-reference-files').disabled=!max;el('image-reference-info').textContent=max?`Dieses Profil erlaubt bis zu ${max} Referenzbilder. Reihenfolge: wie ausgewählt; PNG/JPEG/WebP, je höchstens 10 MiB.`:'Dieses Profil unterstützt keine Referenzbilder.';el('image-generate').disabled=submitting||!p?.runnable||!status?.runtime_installed||status?.job?.state==='running';}
+ function selection(){if(readonly)return;const p=profiles.find(p=>p.id===el('image-test-profile').value),max=p?.capabilities?.reference_images||0;el('image-profile-info').textContent=p?`${p.parameters.width} × ${p.parameters.height} · ${p.parameters.steps} Schritte · Guidance ${p.parameters.guidance} · Seed ${p.parameters.seed}${p.blockers.length?' · '+p.blockers.join(' '):''}`:'';el('image-reference-label').hidden=!max;el('image-reference-files').disabled=!max;el('image-reference-info').textContent=max?`Dieses Profil erlaubt bis zu ${max} Referenzbilder. Reihenfolge: wie ausgewählt; PNG/JPEG/WebP, je höchstens 10 MiB.`:'Dieses Profil unterstützt keine Referenzbilder.';el('image-generate').disabled=submitting||!p?.runnable||!status?.runtime_installed||status?.job?.state==='running';el('image-preview-enhancer').disabled=submitting||!p?.prompt_enhancer?.[el('image-reference-files').files.length?'i2i':'t2i'];}
+ async function preview(){
+ const button=el('image-preview-enhancer'),area=el('image-enhancer-preview');button.disabled=true;area.textContent='Prompt-Aufwerter startet; Bildmodell wartet.';
+ try{
+ const form=new FormData();form.set('profile_id',el('image-test-profile').value);form.set('prompt',el('image-test-form').elements.prompt.value);
+ for(const file of el('image-reference-files').files)form.append('image[]',file);
+ const response=await fetch('/api/v1/prompt-enhancers/preview',{method:'POST',headers:{'X-Athena-Deck':'1'},body:form});const job=await response.json();
+ if(!response.ok)throw Error(job.error||'Vorschau konnte nicht starten.');previewId=job.id;
+ async function poll(){
+ if(!root.isConnected||previewId!==job.id)return;
+ const state=(await api('prompt-enhancers')).preview;if(!state||state.id!==job.id)return;
+ if(state.state==='running'){area.textContent=state.phase;setTimeout(poll,2000);return;}
+ if(state.state!=='complete')throw Error(state.phase||'Aufbereitung fehlgeschlagen.');
+ area.innerHTML=`
`;
+ area.querySelector('textarea').value=state.result.prompt;
+ area.querySelector('#image-use-enhanced').onclick=()=>{el('image-test-form').elements.prompt.value=area.querySelector('textarea').value;el('image-skip-enhancer').value='1';area.textContent='Überarbeiteter Prompt übernommen. Bildgenerierung bereitet ihn nicht erneut auf.';};
+ area.querySelector('#image-use-original').onclick=()=>{el('image-skip-enhancer').value='1';area.textContent='Originalprompt bleibt. Für diesen Bildtest wird die Aufbereitung übersprungen.';};
+ }
+ poll().catch(error=>{area.textContent=error.message;});
+ }catch(error){area.textContent=error.message;}finally{button.disabled=false;}
+ }
async function refresh(){try{status=await api('image-tests');if(!root.isConnected)return;const j=status.job,signature=JSON.stringify(j);if(signature!==lastJob){lastJob=signature;el('image-test-state').innerHTML=j?`${e({running:'LÄUFT',complete:'FERTIG',failed:'FEHLGESCHLAGEN',cancelled:'ABGEBROCHEN',interrupted:'UNTERBROCHEN'}[j.state]||j.state)}
${status.runtime_installed?'Bildlaufzeit ist installiert':'Bildlaufzeit noch nicht installiert'}
Noch kein Bildtest gestartet.
`;
el('image-test-result').innerHTML=j?.state==='complete'?`PNG herunterladen`:'';
el('image-cancel')?.addEventListener('click',async event=>{event.target.disabled=true;try{await api('image-tests/cancel',{});message('Abbruch angefordert; der eigene Bildworker wird beendet.');}catch(error){message(error.message);}});
}selection();}catch(error){message(error.message);}finally{if(root.isConnected)timer=setTimeout(refresh,2000);}}
- if(!readonly){api('profiles').then(v=>{if(!root.isConnected)return;profiles=v.profiles.filter(p=>p.kind==='image');el('image-test-profile').innerHTML=profiles.map(p=>``).join('')||'';selection();}).catch(error=>message(error.message));el('image-test-profile').onchange=()=>{el('image-reference-files').value='';selection();};
+ if(!readonly){api('profiles').then(v=>{if(!root.isConnected)return;profiles=v.profiles.filter(p=>p.kind==='image');el('image-test-profile').innerHTML=profiles.map(p=>``).join('')||'';selection();}).catch(error=>message(error.message));const invalidate=()=>{previewId=null;el('image-skip-enhancer').value='0';el('image-enhancer-preview').replaceChildren();};el('image-test-profile').onchange=()=>{el('image-reference-files').value='';invalidate();selection();};
+ el('image-reference-files').onchange=()=>{invalidate();selection();};
+ el('image-test-form').elements.prompt.oninput=invalidate;
+ el('image-preview-enhancer').onclick=preview;
el('image-test-form').onsubmit=async event=>{event.preventDefault();submitting=true;selection();try{const form=new FormData(event.target),p=profiles.find(p=>p.id===el('image-test-profile').value),files=[...el('image-reference-files').files],max=p?.capabilities?.reference_images||0;if(files.length>max)throw Error(`Dieses Profil erlaubt höchstens ${max} Referenzbilder.`);if(files.some(f=>f.size>10*1024*1024))throw Error('Jedes Referenzbild darf höchstens 10 MiB groß sein.');form.delete('image[]');for(const file of files)form.append('image[]',file);const r=await fetch('/api/v1/image-tests/start',{method:'POST',headers:{'X-Athena-Deck':'1'},body:form});const result=await r.json();if(!r.ok)throw Error(result.error||'Bildtest fehlgeschlagen.');message('Bildtest gestartet.');}catch(error){message(error.message);}finally{submitting=false;selection();}};
}
refresh();
diff --git a/image_runtime.py b/image_runtime.py
index cdd21a9..1b69bb0 100644
--- a/image_runtime.py
+++ b/image_runtime.py
@@ -86,7 +86,7 @@ class ImageRuntime:
self._phase('PyTorch mit CUDA herunterladen · mehrere GiB, kann einige Minuten dauern')
self._command([python,'-m','pip','install','--no-cache-dir','torch==2.11.0','torchvision==0.26.0','--index-url','https://download.pytorch.org/whl/cu128'])
self._phase('Bildlaufzeit-Abhängigkeiten installieren')
- self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/image-requirements.lock','-r',comfy/'requirements.txt','-r',comfy/'custom_nodes/ComfyUI-GGUF/requirements.txt'])
+ self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/image-requirements.lock','-r',comfy/'requirements.txt','-r',comfy/'custom_nodes/ComfyUI-GGUF/requirements.txt','accelerate==1.15.0'])
self._phase('Installation prüfen')
self._command([python,'-m','pip','check'])
self._command([python,'-c','import torch, gguf, safetensors, aiohttp; assert torch.version.cuda'])
diff --git a/image_test.py b/image_test.py
index 1222f19..f2c1f4c 100644
--- a/image_test.py
+++ b/image_test.py
@@ -124,12 +124,12 @@ class ImageTests:
return {'cancellation_requested':True}
def model_path(self,item):
return (self.profiles.catalog.root/item['id']/('model'+Path(item['file']).suffix)).resolve()
- def start(self,profile_id,prompt,wait=False,reserved=False,reference_images=None):
+ def start(self,profile_id,prompt,wait=False,reserved=False,reference_images=None,skip_enhancer=False):
if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Bitte einen Prompt mit 1–4000 Zeichen eingeben.')
release=(lambda:None) if reserved else self.acquire(wait)
- try:return self._start(profile_id,prompt,release,reference_images or [])
+ try:return self._start(profile_id,prompt,release,reference_images or [],skip_enhancer)
except Exception:release();raise
- def _start(self,profile_id,prompt,release,reference_images):
+ def _start(self,profile_id,prompt,release,reference_images,skip_enhancer=False):
if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Bitte einen Prompt mit 1–4000 Zeichen eingeben.')
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Ein Bildtest läuft bereits.')
@@ -153,11 +153,18 @@ class ImageTests:
if shutil.disk_usage(self.root).free<10*GIB:raise ValueError('Weniger als 10 GiB freier Plattenspeicher.')
job_id=uuid.uuid4().hex;seed=params['seed'] if params['seed']>=0 else secrets.randbelow(2147483648)
self.cancel.clear();self.job=dict(id=job_id,state='running',phase='Bildlaufzeit startet',profile_id=profile_id,profile_name=profile['name'],started_at=time.time(),gpu=gpu['name'],encoder_gpu=encoder_gpu['name'],seed=seed,parameters=params,reference_count=len(references))
- self._save();threading.Thread(target=self._run,args=(job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release,references),daemon=True).start()
+ self._save();threading.Thread(target=self._run,args=(job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release,references,profile,skip_enhancer),daemon=True).start()
return dict(self.job)
- def _run(self,job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release=lambda:None,references=()):
+ def _run(self,job_id,prompt,params,seed,gpu,encoder_gpu,model,encoder,vae,release=lambda:None,references=(),profile=None,skip_enhancer=False):
directory=self.root/job_id;process=None
try:
+ if not skip_enhancer and profile and getattr(self,'enhancer',None) and (profile.get('prompt_enhancer') or {}).get('i2i' if references else 't2i'):
+ self._phase('Prompt-Aufwerter lädt; Bildmodell wartet')
+ result=self.enhancer.rewrite(profile,prompt,references,self.cancel)
+ prompt=result['prompt']
+ with self.lock:self.job['prompt_enhanced']=True;self.job['prompt_enhancer']=result['source'];self._save()
+ if self.cancel.is_set():raise InterruptedError()
+ self._phase('Prompt-Aufwerter entladen; Bildlaufzeit startet')
directory.mkdir(mode=0o700)
for role,item,filename in [('unet',model,'model.safetensors' if model['repo']==FLUX_REPO else 'model.gguf'),('clip',encoder,'encoder.safetensors'),('vae',vae,'vae.safetensors')]:
dest=directory/'models'/role;dest.mkdir(parents=True);(dest/filename).symlink_to(self.model_path(item))
diff --git a/image_upload.py b/image_upload.py
index 9c3ae21..84a1441 100644
--- a/image_upload.py
+++ b/image_upload.py
@@ -27,7 +27,7 @@ def read_image_upload(handler,profile_field='model'):
if mime not in ('image/png','image/jpeg','image/webp'):raise ValueError('Nur PNG, JPEG oder WebP erlaubt.')
images.append('data:'+mime+';base64,'+base64.b64encode(data).decode('ascii'))
else:
- if name not in (profile_field,'prompt','size','n','response_format') or name in fields or len(data)>4096:raise ValueError('Ungültiges oder doppeltes Formularfeld.')
+ if name not in (profile_field,'prompt','size','n','response_format','skip_enhancer') or name in fields or len(data)>4096:raise ValueError('Ungültiges oder doppeltes Formularfeld.')
try:fields[name]=data.decode('utf-8')
except UnicodeError:raise ValueError('Ungültiges Textfeld.') from None
if profile_field not in fields:raise ValueError('Bildprofil fehlt.')
diff --git a/profiles-ui.js b/profiles-ui.js
index 21b445f..ac7eb15 100644
--- a/profiles-ui.js
+++ b/profiles-ui.js
@@ -7,14 +7,30 @@ 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=>`
${e(t)}
`).join('')}
${['image','video'].includes(kind)?``:''}
`).join('')||`
Noch keine Profile
${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}
`;
+ 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=>`
${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;}});
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
+ root.querySelectorAll('[data-enhancers]').forEach(b=>b.onclick=()=>enhancers(rows.find(p=>p.id===b.dataset.enhancers)));
root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit)));
root.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>{const p=rows.find(p=>p.id===b.dataset.copy);editor({...p,id:null,revision:0,name:p.name.slice(0,55)+'-kopie'});});
}catch(error){message(error.message);}}
+ async function enhancers(profile){
+ const sequence=++panelSequence,selected=profile.prompt_enhancer||{t2i:null,i2i:null,device:'auto'};
+ el('profile-editor').innerHTML=`
Prompt-Aufwerter · ${e(profile.name)}
Optional pro Bildprofil. Text-zu-Bild und Bearbeitung mit Referenzbildern verwenden getrennte offizielle Qwen-Modelle. Andere Bildfamilien können sie nur nach eigener Prüfung sinnvoll nutzen.
';
diff --git a/profiles.py b/profiles.py
index 88de30c..c50bed8 100644
--- a/profiles.py
+++ b/profiles.py
@@ -14,6 +14,8 @@ SCHEMAS={
'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:
@@ -82,12 +84,14 @@ class Profiles:
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:
@@ -111,6 +115,9 @@ class Profiles:
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.')
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):
@@ -140,12 +147,26 @@ class Profiles:
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,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())
+ 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 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:
diff --git a/prompt_enhancer.py b/prompt_enhancer.py
new file mode 100644
index 0000000..e14827d
--- /dev/null
+++ b/prompt_enhancer.py
@@ -0,0 +1,154 @@
+"""Isolated, one-shot Qwen image prompt enhancement owned by Deck."""
+import json,os,shutil,signal,subprocess,tempfile,threading,time,uuid
+from pathlib import Path
+from image_test import probe,cgroup_headroom,decode_references
+from image_runtime import BUNDLED
+from profiles import PROMPT_ENHANCER_REPOS
+
+WORKER=Path(__file__).with_name('prompt_enhancer_worker.py')
+GIB=1024**3
+
+class PromptEnhancer:
+ def __init__(self,root,catalog,profiles,runtime):
+ self.root=Path(root);self.catalog=catalog;self.profiles=profiles;self.runtime=runtime
+ self.lock=threading.RLock();self.cancel=threading.Event();self.process=None;self.job=None;self.preview=None;self.acquire=lambda wait=False:lambda:None
+ def directory(self,task):return self.root/task
+ def python(self):return BUNDLED[0] if BUNDLED[0].is_file() else self.runtime.paths()[0]
+ def installed(self,task):
+ path=self.directory(task);marker=path/'installed.json'
+ if not marker.is_file():return None
+ try:
+ data=json.loads(marker.read_text());return data if data['repo']==PROMPT_ENHANCER_REPOS[task] and (path/'model.safetensors.index.json').is_file() else None
+ except (OSError,ValueError,KeyError):return None
+ def status(self):
+ with self.lock:
+ return dict(models={task:dict(repo=repo,installed=bool(self.installed(task)),revision=(self.installed(task) or {}).get('revision')) for task,repo in PROMPT_ENHANCER_REPOS.items()},job=dict(self.job) if self.job else None,preview=dict(self.preview) if self.preview else None)
+ def install(self,task):
+ if task not in PROMPT_ENHANCER_REPOS:raise ValueError('Unbekannter Prompt-Aufwerter.')
+ with self.lock:
+ if self.job and self.job['state']=='running':raise ValueError('Ein Aufwerter-Download läuft bereits.')
+ if self.preview and self.preview['state']=='running':raise ValueError('Eine Prompt-Vorschau läuft; zuerst abschließen.')
+ if self.installed(task):raise ValueError('Dieser Aufwerter ist bereits installiert.')
+ if not self.runtime.status()['installed']:raise ValueError('Zuerst die Bildlaufzeit einrichten.')
+ if shutil.disk_usage(self.root if self.root.exists() else self.root.parent).free<30*GIB:raise ValueError('Mindestens 30 GiB freier Plattenspeicher erforderlich.')
+ source=self.catalog.files(PROMPT_ENHANCER_REPOS[task]);revision=source['revision']
+ expected=sum(f['size'] for f in source['files'] if f['name'].endswith('.safetensors'))
+ if expected<15*GIB or expected>25*GIB:raise ValueError('Unerwartete Größe der offiziellen Aufwerter-Gewichte; Rezept prüfen.')
+ self.root.mkdir(parents=True,exist_ok=True,mode=0o700);self.cancel.clear()
+ self.job=dict(id=uuid.uuid4().hex,task=task,state='running',phase='Offizielle Dateien werden heruntergeladen',repo=source['repo'],revision=revision,total_bytes=expected,started_at=time.time())
+ threading.Thread(target=self._download,args=(task,revision),daemon=True).start();return self.status()
+ def _download(self,task,revision):
+ target=self.directory(task);target.mkdir(parents=True,exist_ok=True,mode=0o700)
+ env=dict(os.environ,HF_TOKEN=self.catalog.hub_auth.token() or '',HF_HUB_DISABLE_TELEMETRY='1',HOME=str(self.root),PYTHONDONTWRITEBYTECODE='1')
+ data=dict(repo=PROMPT_ENHANCER_REPOS[task],revision=revision,directory=str(target))
+ state='failed';phase='Aufwerter-Download fehlgeschlagen. Netzwerk, Hub-Zugang und freien Speicher prüfen.'
+ try:
+ python=self.python()
+ with self.lock:
+ if self.cancel.is_set():raise InterruptedError()
+ self.process=subprocess.Popen([str(python),str(WORKER),'download'],stdin=subprocess.PIPE,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,text=True,start_new_session=True,env=env)
+ process=self.process
+ process.stdin.write(json.dumps(data));process.stdin.close()
+ deadline=time.monotonic()+21600
+ while process.poll() is None:
+ if self.cancel.wait(1):raise InterruptedError()
+ if time.monotonic()>deadline:raise ValueError('Download-Zeitlimit erreicht.')
+ if shutil.disk_usage(target).free<10*GIB:raise ValueError('Plattenreserve unter 10 GiB.')
+ if self.cancel.is_set():raise InterruptedError()
+ if process.returncode:raise ValueError('Aufwerter-Dateien konnten nicht vollständig heruntergeladen werden.')
+ marker=target/'installed.tmp';marker.write_text(json.dumps(data));marker.replace(target/'installed.json')
+ state='complete';phase='Offizieller Aufwerter installiert. Es läuft kein Modell.'
+ except InterruptedError:state='cancelled';phase='Download abgebrochen.'
+ except ValueError as exc:phase=str(exc)
+ except Exception:pass
+ finally:
+ self._terminate()
+ with self.lock:self.job.update(state=state,phase=phase,finished_at=time.time())
+ def _terminate(self):
+ with self.lock:process=self.process;self.process=None
+ if process and process.poll() is None:
+ try:
+ os.killpg(process.pid,signal.SIGTERM)
+ try:process.wait(timeout=5)
+ except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
+ except ProcessLookupError:pass
+ def stop(self):
+ self.cancel.set();self._terminate();return {'cancellation_requested':True}
+ def stop_preview(self):
+ with self.lock:active=bool(self.preview and self.preview['state']=='running')
+ if active:self.cancel.set();self._terminate()
+ return {'cancellation_requested':active}
+ def _devices(self,choice):
+ if choice=='cpu':return ''
+ rows=probe();names=['5080','3060'] if choice=='auto' else [choice];selected=[]
+ for name in names:
+ row=next((g for g in rows if ('RTX '+name) in g['name']),None)
+ if not row or row['processes'] or row['free_mib']<('5080'==name and 10000 or 7000):
+ if choice=='auto':continue
+ raise ValueError('Gewählte GPU für Prompt-Aufbereitung nicht frei oder mit zu wenig VRAM.')
+ selected.append(row['uuid'])
+ if not selected:raise ValueError('Keine geeignete freie GPU für den Prompt-Aufwerter.')
+ return ','.join(selected)
+ def rewrite(self,profile,prompt,references=(),cancel=None):
+ task='i2i' if references else 't2i';config=profile.get('prompt_enhancer') or {};repo=config.get(task)
+ if not repo:return dict(prompt=prompt,enhanced=False,wh_ratio='')
+ if repo!=PROMPT_ENHANCER_REPOS[task]:raise ValueError('Unbekannter Prompt-Aufwerter im Profil.')
+ if not self.installed(task):raise ValueError('Prompt-Aufwerter fehlt. Unter Bildgenerierung → Aufwerter installieren.')
+ if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Prompt mit 1–4000 Zeichen erforderlich.')
+ headroom=cgroup_headroom()
+ if headroom is not None and headroom<22*GIB:raise ValueError('Für den Prompt-Aufwerter sind mindestens 22 GiB freier Deck-RAM nötig.')
+ device=config.get('device','auto');visible=self._devices(device)
+ rows={g['uuid']:g for g in probe()}
+ gpu_limits=[min(12 if '5080' in rows[ident]['name'] else 9,max(4,int(rows[ident]['free_mib']/1024)-2)) for ident in visible.split(',') if ident]
+ self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
+ with tempfile.TemporaryDirectory(prefix='rewrite-',dir=self.root) as folder:
+ paths=[]
+ for i,(raw,extension) in enumerate(references):
+ path=Path(folder)/f'image-{i}.{extension}';path.write_bytes(raw);paths.append(str(path))
+ env=dict(os.environ,CUDA_VISIBLE_DEVICES=visible,HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',HF_HUB_DISABLE_TELEMETRY='1',OMP_NUM_THREADS='2',HOME=folder,PYTHONDONTWRITEBYTECODE='1')
+ data=dict(directory=str(self.directory(task)),task=task,images=paths,prompt=prompt,device=device,gpu_limits=gpu_limits)
+ with self.lock:
+ if self.job and self.job['state']=='running':raise ValueError('Aufwerter-Download läuft; Bildauftrag danach erneut starten.')
+ if self.process and self.process.poll() is None:raise ValueError('Ein Prompt-Aufwerter läuft bereits.')
+ self.process=subprocess.Popen([str(self.python()),str(WORKER),'rewrite'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True,start_new_session=True,env=env)
+ process=self.process
+ try:
+ payload=json.dumps(data);deadline=time.monotonic()+900
+ while True:
+ if cancel and cancel.is_set():raise InterruptedError()
+ if time.monotonic()>deadline:raise ValueError('Prompt-Aufwerter hat das Zeitlimit erreicht.')
+ try:output,_=process.communicate(payload,timeout=1);break
+ except subprocess.TimeoutExpired:payload=None
+ if cancel and cancel.is_set():raise InterruptedError()
+ if process.returncode:raise ValueError('Prompt-Aufwerter fehlgeschlagen. GPU/RAM, Modell und Laufzeit prüfen; Bild wurde nicht gestartet.')
+ result=json.loads(output)
+ if not isinstance(result.get('prompt'),str) or not 1<=len(result['prompt'])<=4000:raise ValueError('Prompt-Aufwerter lieferte keinen gültigen Prompt.')
+ return dict(result,enhanced=True,source=repo)
+ finally:self._terminate()
+ def preview_start(self,profile_id,prompt,reference_images):
+ profile=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id and p['kind']=='image'),None)
+ if not profile:raise ValueError('Bildprofil nicht gefunden.')
+ if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Prompt mit 1–4000 Zeichen erforderlich.')
+ references=decode_references(reference_images,profile.get('capabilities',{}).get('reference_images',0))
+ task='i2i' if references else 't2i'
+ if not (profile.get('prompt_enhancer') or {}).get(task):raise ValueError('Für diese Bildaufgabe ist im Profil kein Prompt-Aufwerter aktiviert.')
+ if not self.installed(task):raise ValueError('Prompt-Aufwerter zuerst im Bildprofil installieren.')
+ release=self.acquire(False)
+ try:
+ with self.lock:
+ if self.preview and self.preview['state']=='running':raise ValueError('Eine Prompt-Vorschau läuft bereits.')
+ if self.job and self.job['state']=='running':raise ValueError('Aufwerter-Download läuft bereits.')
+ self.cancel.clear();self.preview=dict(id=uuid.uuid4().hex,state='running',phase='Prompt wird aufbereitet',profile_id=profile_id,task=task,started_at=time.time())
+ threading.Thread(target=self._preview,args=(profile,prompt,references,release),daemon=True).start();return dict(self.preview)
+ except Exception:release();raise
+ def _preview(self,profile,prompt,references,release):
+ try:
+ result=self.rewrite(profile,prompt,references,self.cancel)
+ with self.lock:self.preview.update(state='complete',phase='Prompt-Aufbereitung fertig',result=result)
+ except InterruptedError:
+ with self.lock:self.preview.update(state='cancelled',phase='Prompt-Aufbereitung abgebrochen')
+ except ValueError as exc:
+ with self.lock:self.preview.update(state='failed',phase=str(exc))
+ except Exception:
+ with self.lock:self.preview.update(state='failed',phase='Prompt-Aufwerter fehlgeschlagen. Ressourcen und Laufzeit prüfen.')
+ finally:release()
diff --git a/prompt_enhancer_worker.py b/prompt_enhancer_worker.py
new file mode 100644
index 0000000..7ce3d94
--- /dev/null
+++ b/prompt_enhancer_worker.py
@@ -0,0 +1,57 @@
+"""One-shot official Qwen PE inference or pinned snapshot download.
+
+Runs in Deck's isolated image Python environment. Never retains model weights
+after a request; no prompt or image is written to an application log.
+"""
+import json
+import os
+import sys
+from pathlib import Path
+
+def download(data):
+ from huggingface_hub import snapshot_download
+ snapshot_download(repo_id=data['repo'],revision=data['revision'],local_dir=data['directory'],token=os.environ.get('HF_TOKEN') or None,allow_patterns=['*.safetensors','*.json','*.jinja','system_prompt.txt','LICENSE'],max_workers=2)
+ directory=Path(data['directory'])
+ required=['model.safetensors.index.json','system_prompt.txt','config.json','tokenizer.json']
+ if any(not (directory/name).is_file() for name in required) or len(list(directory.glob('model-*.safetensors')))<4:
+ raise RuntimeError('Prompt-Aufwerter-Dateien unvollständig.')
+
+def rewrite(data):
+ import torch
+ from PIL import Image
+ from transformers import AutoModelForCausalLM,AutoModelForImageTextToText,AutoProcessor,AutoTokenizer
+ directory=data['directory'];task=data['task'];images=data['images']
+ system=Path(directory,'system_prompt.txt').read_text().strip()
+ max_memory=None
+ if data['device']!='cpu':
+ if torch.cuda.device_count()!=len(data['gpu_limits']):raise ValueError('Gewählte GPU-Geräte stehen der PE-Laufzeit nicht zur Verfügung.')
+ max_memory={i:f'{limit}GiB' for i,limit in enumerate(data['gpu_limits'])}
+ max_memory['cpu']='24GiB'
+ if task=='t2i':
+ tokenizer=AutoTokenizer.from_pretrained(directory,local_files_only=True,trust_remote_code=False)
+ model=AutoModelForCausalLM.from_pretrained(directory,dtype=torch.bfloat16,device_map='auto' if data['device']!='cpu' else 'cpu',max_memory=max_memory,local_files_only=True,trust_remote_code=False).eval()
+ prompt=tokenizer.apply_chat_template([{'role':'system','content':system},{'role':'user','content':data['prompt']}],tokenize=False,add_generation_prompt=True,enable_thinking=True)
+ inputs=tokenizer(prompt,return_tensors='pt').to(model.device)
+ elif task=='i2i':
+ processor=AutoProcessor.from_pretrained(directory,local_files_only=True,trust_remote_code=False)
+ model=AutoModelForImageTextToText.from_pretrained(directory,dtype=torch.bfloat16,device_map='auto' if data['device']!='cpu' else 'cpu',max_memory=max_memory,local_files_only=True,trust_remote_code=False).eval()
+ content=[{'type':'image','image':Image.open(path).convert('RGB')} for path in images]+[{'type':'text','text':data['prompt']}]
+ inputs=processor.apply_chat_template([{'role':'system','content':[{'type':'text','text':system}]},{'role':'user','content':content}],add_generation_prompt=True,tokenize=True,return_dict=True,return_tensors='pt',enable_thinking=True).to(model.device)
+ tokenizer=processor.tokenizer
+ else:raise ValueError('Unbekannte Aufwerter-Aufgabe.')
+ with torch.inference_mode():
+ output=model.generate(**inputs,max_new_tokens=8192,do_sample=True,temperature=1.0,top_p=.95,top_k=20)
+ generated=tokenizer.decode(output[0,inputs['input_ids'].shape[1]:],skip_special_tokens=True)
+ answer=generated.partition('')[2] or generated
+ start=answer.find('{');end=answer.rfind('}')
+ if start<0 or end<=start:raise ValueError('Aufwerter lieferte kein JSON-Ergebnis.')
+ result=json.loads(answer[start:end+1]);rewritten=result.get('rewritten_prompt')
+ if not isinstance(rewritten,str) or not 1<=len(rewritten.strip())<=4000:raise ValueError('Überarbeiteter Prompt ist leer oder länger als 4000 Zeichen.')
+ ratio=result.get('wh_ratio','')
+ return {'prompt':rewritten.strip(),'wh_ratio':ratio if isinstance(ratio,str) and len(ratio)<=16 else ''}
+
+if __name__=='__main__':
+ command=sys.argv[1];data=json.load(sys.stdin)
+ if command=='download':download(data)
+ elif command=='rewrite':print(json.dumps(rewrite(data),ensure_ascii=False),flush=True)
+ else:raise ValueError('Unbekannter Aufwerter-Befehl.')
diff --git a/server.py b/server.py
index 9a252e7..9c316ee 100644
--- a/server.py
+++ b/server.py
@@ -18,6 +18,7 @@ from capacity import assess, overview
from runtime import Runtime
from image_runtime import ImageRuntime
from image_test import ImageTests
+from prompt_enhancer import PromptEnhancer
from docker_support import DockerSupport
from inference import LlamaWorker,Scheduler
from endpoint import Endpoint
@@ -75,6 +76,9 @@ class Server(ThreadingHTTPServer):
self.image_tests = ImageTests(self.catalog.root.parent/"image-tests",self.profiles)
self.image_runtime = ImageRuntime(self.catalog.root.parent/"image-runtime")
self.image_tests.runtime = self.image_runtime
+ self.prompt_enhancer=PromptEnhancer(self.catalog.root.parent/'prompt-enhancers',self.catalog,self.profiles,self.image_runtime)
+ self.image_tests.enhancer=self.prompt_enhancer
+ self.profiles.prompt_enhancer_ready=lambda task:bool(self.prompt_enhancer.installed(task))
self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"]
self.tts_runtime=TTSRuntime(self.catalog.root.parent/'tts-runtime')
self.tts_tests=TTSTests(self.catalog.root.parent/'tts-tests',self.profiles,self.tts_runtime)
@@ -95,6 +99,7 @@ class Server(ThreadingHTTPServer):
self.scheduler=Scheduler(self.worker)
self.profiles.chat_blockers=self.worker.blockers
self.image_tests.acquire=self.scheduler.image_reservation
+ self.prompt_enhancer.acquire=self.scheduler.image_reservation
self.tts_tests.acquire=self.scheduler.tts_reservation
self.scheduler.evict_tts=self.tts_tests.unload_idle
self.stt=STT(self.profiles,self.catalog,self.runtime)
@@ -105,7 +110,7 @@ class Server(ThreadingHTTPServer):
self.chat_tests=ChatTests(self.profiles,self.worker,self.scheduler)
self.auto_tests=AutoTests(self.catalog.root.parent/'auto-tests.json',self.profiles,self.worker,self.scheduler)
def stop_gpu_work():
- self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.tts_tests.stop();self.worker.stop()
+ self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.prompt_enhancer.stop_preview();self.tts_tests.stop();self.worker.stop()
self.video=Video(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video')
self.endpoint.video=self.video
if self.endpoint.config['autostart']:
@@ -288,6 +293,7 @@ class Handler(BaseHTTPRequestHandler):
if self.path == '/api/v1/video':return self.respond(self.server.video.status())
if self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status())
if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status())
+ if self.path == '/api/v1/prompt-enhancers':return self.respond(self.server.prompt_enhancer.status())
if urlsplit(self.path).path == '/api/v1/image-tests/image':
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)
@@ -457,14 +463,33 @@ class Handler(BaseHTTPRequestHandler):
if self.path.endswith('/start') and self.headers.get('Content-Type','').startswith('multipart/form-data;'):
from image_upload import read_image_upload
fields,images=read_image_upload(self,'profile_id')
- if set(fields)!={'profile_id','prompt'}:raise ValueError('Ungültige Bildtest-Anfrage.')
- return self.respond(self.server.image_tests.start(fields['profile_id'],fields['prompt'],reference_images=images))
+ if set(fields)-{'profile_id','prompt','skip_enhancer'} or not {'profile_id','prompt'}<=set(fields) or fields.get('skip_enhancer','0') not in ('0','1'):raise ValueError('Ungültige Bildtest-Anfrage.')
+ return self.respond(self.server.image_tests.start(fields['profile_id'],fields['prompt'],reference_images=images,skip_enhancer=fields.get('skip_enhancer')=='1'))
data=self.read_json()
if self.path.endswith('/cancel') and not data:return self.respond(self.server.image_tests.stop())
if self.path.endswith('/start') and set(data)=={'profile_id','prompt'}:return self.respond(self.server.image_tests.start(**data))
raise ValueError('Ungültige Bildtest-Anfrage.')
except ValueError as exc:return self.respond({'error':str(exc)},400)
except Exception:return self.respond({'error':'Bildtest konnte nicht vorbereitet werden; Ressourcen prüfen.'},503)
+ if self.path == '/api/v1/profiles/prompt-enhancer':
+ try:return self.respond(self.server.profiles.configure_prompt_enhancer(self.read_json()))
+ except ValueError as exc:return self.respond({'error':str(exc)},400)
+ except OSError:return self.respond({'error':'Prompt-Aufwerter-Einstellung konnte nicht gespeichert werden.'},503)
+ if self.path in ('/api/v1/prompt-enhancers/install','/api/v1/prompt-enhancers/cancel','/api/v1/prompt-enhancers/preview'):
+ try:
+ enhancer=self.server.prompt_enhancer
+ if self.path.endswith('/preview') and self.headers.get('Content-Type','').startswith('multipart/form-data;'):
+ from image_upload import read_image_upload
+ fields,images=read_image_upload(self,'profile_id')
+ if set(fields)!={'profile_id','prompt'}:raise ValueError('Profil und Prompt erforderlich.')
+ return self.respond(enhancer.preview_start(fields['profile_id'],fields['prompt'],images))
+ data=self.read_json()
+ if self.path.endswith('/install') and set(data)=={'task'}:return self.respond(enhancer.install(data['task']))
+ if self.path.endswith('/cancel') and not data:return self.respond(enhancer.stop())
+ if self.path.endswith('/preview') and set(data)=={'profile_id','prompt'}:return self.respond(enhancer.preview_start(data['profile_id'],data['prompt'],[]))
+ raise ValueError('Ungültige Prompt-Aufwerter-Anfrage.')
+ except ValueError as exc:return self.respond({'error':str(exc)},400)
+ except OSError:return self.respond({'error':'Prompt-Aufwerter nicht erreichbar.'},503)
if self.path == '/api/v1/profiles/components':
try:return self.respond(self.server.profiles.assign(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400)
@@ -542,6 +567,7 @@ def main():
server.tts_tests.stop()
server.tts_runtime.stop()
server.image_runtime.stop()
+ server.prompt_enhancer.stop()
server.image_tests.stop()
server.runtime.stop()
server.catalog.stop(shutdown=True)
diff --git a/test_prompt_enhancer.py b/test_prompt_enhancer.py
new file mode 100644
index 0000000..45e12af
--- /dev/null
+++ b/test_prompt_enhancer.py
@@ -0,0 +1,32 @@
+import tempfile,unittest
+from pathlib import Path
+from unittest.mock import Mock,patch
+from profiles import Profiles,PROMPT_ENHANCER_REPOS
+from prompt_enhancer import PromptEnhancer
+
+class PromptEnhancerTests(unittest.TestCase):
+ def test_profile_settings_are_persisted_and_validated(self):
+ with tempfile.TemporaryDirectory() as directory:
+ path=Path(directory)/'profiles.json';catalog=Mock();catalog.entry.return_value={'id':'model','kind':'image','repo':'example/image','file':'model.safetensors','profile_eligible':True}
+ profiles=Profiles(path,catalog)
+ profiles.rows=[dict(id='p',revision=1,name='Image',kind='image',model_id='model',parameters=dict(width=512,height=512,steps=4,seed=1,guidance=1),components={})]
+ config=dict(t2i=PROMPT_ENHANCER_REPOS['t2i'],i2i=None,device='auto')
+ with self.assertRaises(ValueError):profiles.configure_prompt_enhancer(dict(id='p',revision=1,prompt_enhancer=dict(config,t2i='other/model')))
+ self.assertFalse(path.exists())
+ saved=profiles.configure_prompt_enhancer(dict(id='p',revision=1,prompt_enhancer=config))
+ self.assertEqual(saved['revision'],2)
+ self.assertEqual(Profiles(path,catalog).rows[0]['prompt_enhancer'],config)
+ with self.assertRaises(ValueError):profiles.configure_prompt_enhancer(dict(id='p',revision=1,prompt_enhancer=config))
+ def test_rewrite_off_never_starts_worker(self):
+ with tempfile.TemporaryDirectory() as directory,patch('prompt_enhancer.subprocess.Popen') as spawn:
+ enhancer=PromptEnhancer(directory,Mock(),Mock(),Mock())
+ self.assertEqual(enhancer.rewrite({'prompt_enhancer':{'t2i':None}},'a red bicycle')['prompt'],'a red bicycle')
+ spawn.assert_not_called()
+ def test_device_selection_does_not_use_busy_gpu(self):
+ with tempfile.TemporaryDirectory() as directory:
+ enhancer=PromptEnhancer(directory,Mock(),Mock(),Mock())
+ rows=[dict(name='RTX 5080',uuid='GPU-5080',processes=0,free_mib=12000),dict(name='RTX 3060',uuid='GPU-3060',processes=1,free_mib=11000)]
+ with patch('prompt_enhancer.probe',return_value=rows):
+ self.assertEqual(enhancer._devices('5080'),'GPU-5080')
+ self.assertEqual(enhancer._devices('auto'),'GPU-5080')
+ self.assertEqual(enhancer._devices('cpu'),'')