Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.
`:""}
${x.profile_eligible===true?``:""}
`).join('') || '
Deine Bibliothek ist noch leer
Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.
';
+ root.querySelectorAll('[data-delete-model]').forEach(b=>b.onclick=async()=>{const entry=state.entries.find(x=>x.id===b.dataset.deleteModel);if(!entry||!confirm(`Datei „${entry.file}“ (${size(entry.size)}) dauerhaft von Athena löschen?`))return;b.disabled=true;try{await api('/delete',{id:entry.id});message('Datei aus der Bibliothek gelöscht.');await refresh();}catch(error){message(error.message);b.disabled=false;}});
}
function renderJob() {
el('hub-capacity').textContent = `Athena · ${size(state.free_bytes)} freier Speicher · 10 GiB bleiben als Reserve frei`;
diff --git a/catalog.py b/catalog.py
index 89008eb..9d63bd9 100644
--- a/catalog.py
+++ b/catalog.py
@@ -140,6 +140,23 @@ class Catalog:
path=self.root/ident/('model'+PurePosixPath(entry['file']).suffix)
if not path.is_file() or path.stat().st_size!=entry['size']:raise ValueError('Modelldatei fehlt oder ist unvollständig.')
return entry
+ def delete(self,ident,profiles,active_model_id=None):
+ if not isinstance(ident,str) or not re.fullmatch(r'[a-f0-9]{64}',ident):raise ValueError('Ungültige Modelldatei-ID.')
+ # Profile writes and deletions use the same lock order: profiles, catalog.
+ with profiles.lock,self.lock:
+ users=profiles.references(ident)
+ if users:raise ValueError('Datei wird noch verwendet von: '+', '.join(x['name'] for x in users)+'. Bitte zuerst diese Profile löschen.')
+ if ident==active_model_id:raise ValueError('Datei ist noch in der Deck-Laufzeit geladen. Modell zuerst entladen.')
+ if any(x.get('entry_id')==ident and x['state'] in ('queued','downloading') for x in self.history):raise ValueError('Laufenden oder vorgemerkten Download zuerst abbrechen.')
+ entry=self.entry(ident)
+ target=self.root/ident
+ if target.is_symlink() or not target.is_dir():raise ValueError('Ungültiges Bibliotheksverzeichnis.')
+ shutil.rmtree(target)
+ for job in self.history:
+ if job.get('entry_id')==ident or job.get('id')=='import-'+ident:job['dismissed']=True
+ if self.job and self.job.get('entry_id')==ident:self.job=None
+ self._save_history()
+ return {'deleted':ident,'bytes_freed':entry['size']}
def search(self,q,kind,sort="downloads",base_only=False,purpose="model"):
if purpose not in ('model','projector'):raise ValueError('Ungültiger Suchzweck.')
if kind not in KINDS or not isinstance(q,str) or len(q)>120:raise ValueError('Ungültige Suche.')
diff --git a/profiles.py b/profiles.py
index 4d13a86..a02ebf9 100644
--- a/profiles.py
+++ b/profiles.py
@@ -143,6 +143,9 @@ class Profiles:
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.')
@@ -154,6 +157,12 @@ class Profiles:
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']
diff --git a/server.py b/server.py
index 2d687fe..89838cf 100644
--- a/server.py
+++ b/server.py
@@ -351,7 +351,9 @@ class Handler(BaseHTTPRequestHandler):
return self.respond(data)
if path.path=='/api/v1/catalog':
data=self.server.catalog.status()
- for entry in data['entries']:entry['execution']=self.execution_setup(entry)
+ for entry in data['entries']:
+ entry['execution']=self.execution_setup(entry)
+ entry['used_by_profiles']=self.server.profiles.references(entry['id'])
return self.respond(data)
except Exception as exc:
return self.respond({'error':str(exc) if isinstance(exc,ValueError) else 'Katalog nicht erreichbar.'},400)
@@ -536,9 +538,14 @@ class Handler(BaseHTTPRequestHandler):
try:return self.respond(self.server.profiles.save(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400)
except OSError:return self.respond({'error':'Profil konnte nicht gespeichert werden.'},503)
- if self.path in ('/api/v1/catalog/download','/api/v1/catalog/cancel','/api/v1/catalog/dismiss'):
+ if self.path in ('/api/v1/catalog/download','/api/v1/catalog/cancel','/api/v1/catalog/dismiss','/api/v1/catalog/delete'):
try:
data=self.read_json()
+ if self.path.endswith('/delete'):
+ if set(data)!={'id'}:raise ValueError('Modelldatei-ID erwartet.')
+ with self.server.worker.lock:
+ active=self.server.worker.profile['model_id'] if self.server.worker.profile else None
+ return self.respond(self.server.catalog.delete(data['id'],self.server.profiles,active))
if self.path.endswith('/dismiss') and set(data)=={'id'}:return self.respond(self.server.catalog.dismiss(data['id']))
if self.path.endswith('/cancel') and not data:return self.respond(self.server.catalog.stop())
if set(data)!={'repo','filename','revision','kind'}:raise ValueError('Ungültige Downloadparameter.')
diff --git a/test_model_management.py b/test_model_management.py
index 11bb0e4..0f599db 100644
--- a/test_model_management.py
+++ b/test_model_management.py
@@ -70,6 +70,33 @@ class ManagementTests(unittest.TestCase):
self.profiles.delete(dict(id=row['id'],revision=row['revision']))
self.assertEqual(Profiles(self.root/'profiles.json',self.catalog).rows,[])
self.assertTrue((self.root/'models'/self.model_id/'model.gguf').exists())
+ def test_library_delete_requires_profile_removal(self):
+ row=self.profiles.save(self.request())
+ with self.assertRaisesRegex(ValueError,'image-test'):
+ self.catalog.delete(self.model_id,self.profiles)
+ self.assertTrue((self.root/'models'/self.model_id/'model.gguf').exists())
+ self.profiles.delete(dict(id=row['id'],revision=row['revision']))
+ with self.assertRaisesRegex(ValueError,'geladen'):
+ self.catalog.delete(self.model_id,self.profiles,active_model_id=self.model_id)
+ result=self.catalog.delete(self.model_id,self.profiles)
+ self.assertEqual(result['deleted'],self.model_id)
+ self.assertFalse((self.root/'models'/self.model_id).exists())
+ self.assertEqual(self.catalog.status()['entries'],[])
+ def test_library_delete_checks_component_references(self):
+ row=self.profiles.save(self.request())
+ component_id='b'*64
+ target=self.root/'models'/component_id;target.mkdir()
+ (target/'model.safetensors').write_bytes(b'component')
+ (target/'entry.json').write_text(json.dumps(dict(repo='test/components',file='vae.safetensors',kind='image',size=9,revision='c'*40)))
+ self.profiles.rows[0]['components']={'vae':component_id}
+ with self.assertRaisesRegex(ValueError,'image-test'):
+ self.catalog.delete(component_id,self.profiles)
+ self.profiles.rows[0]['components']={}
+ self.profiles.rows[0]['parameters']['vision_projector']=component_id
+ with self.assertRaisesRegex(ValueError,'image-test'):
+ self.catalog.delete(component_id,self.profiles)
+ self.profiles.rows[0]['parameters']['vision_projector']=None
+ self.assertEqual(self.catalog.delete(component_id,self.profiles)['deleted'],component_id)
def test_clear_history_keeps_models_and_survives_restart(self):
old=self.catalog.status()['downloads'][0];self.catalog.dismiss(old['id'])
restart=Catalog(self.root/'models');self.assertEqual(restart.status()['downloads'],[]);self.assertEqual(len(restart.status()['entries']),1)