Allow deleting unused library files with profile guards

This commit is contained in:
Mikei386
2026-09-30 10:21:07 +02:00
parent 459150975b
commit ef80b2a0ca
5 changed files with 64 additions and 3 deletions
+2 -1
View File
@@ -30,7 +30,8 @@ window.CatalogUI = (() => {
root.querySelectorAll('[data-repo]').forEach(b => b.onclick = () => loadFiles(b.dataset.repo));
}
function renderLibrary() {
el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">${escape(x.role_label)} · HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${executionHTML(x.execution)}${x.profile_eligible===true?`<div class="card-actions"><button data-profile-model="${x.id}">Profil anlegen</button></div>`:""}</article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>';
el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `<article class="card"><span class="pill">${escape(x.role_label)} · HERUNTERGELADEN</span><h3>${escape(x.repo)}</h3><p class="hub-filename">${escape(x.file)}</p><strong>${size(x.size)}</strong><p>${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}</p><details><summary>Version und Integrität</summary><p class="hub-filename">Revision ${escape(x.revision)}</p><p>${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}</p></details>${executionHTML(x.execution)}${x.used_by_profiles?.length?`<p class="warning">Verwendet von Profil: ${x.used_by_profiles.map(p=>escape(p.name)).join(", ")}. Bitte zuerst das Profil löschen.</p>`:""}<div class="card-actions">${x.profile_eligible===true?`<button data-profile-model="${x.id}">Profil anlegen</button>`:""}<button type="button" class="secondary" data-delete-model="${x.id}" ${x.used_by_profiles?.length?"disabled":""}>Datei löschen</button></div></article>`).join('') || '<div class="empty wide"><h3>Deine Bibliothek ist noch leer</h3><p>Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.</p></div>';
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`;
+17
View File
@@ -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.')
+9
View File
@@ -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']
+9 -2
View File
@@ -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.')
+27
View File
@@ -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)