diff --git a/catalog-ui.js b/catalog-ui.js index ea879a4..ec1d696 100644 --- a/catalog-ui.js +++ b/catalog-ui.js @@ -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 => `
${escape(x.role_label)} · HERUNTERGELADEN

${escape(x.repo)}

${escape(x.file)}

${size(x.size)}

${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

${executionHTML(x.execution)}${x.profile_eligible===true?`
`:""}
`).join('') || '

Deine Bibliothek ist noch leer

Unter „Entdecken“ kannst du Dateien für diesen Bereich auswählen und herunterladen.

'; + el('hub-results').innerHTML = state.entries.filter(x => x.kind === kind).map(x => `
${escape(x.role_label)} · HERUNTERGELADEN

${escape(x.repo)}

${escape(x.file)}

${size(x.size)}

${x.profile_eligible?'Modelldatei · über ein Profil verwenden':'Zusatzdatei · wird einem Modellprofil unter Komponenten zugeordnet'}

Version und Integrität

Revision ${escape(x.revision)}

${x.upstream_hash_verified ? 'SHA-256 mit Quelle geprüft' : 'SHA-256 lokal erfasst; Größe mit Quelle geprüft'}

${executionHTML(x.execution)}${x.used_by_profiles?.length?`

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)