From 23af5627214fb1388bb6e5e0067ced77db85e581 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Mon, 27 Jul 2026 15:31:59 +0200 Subject: [PATCH] Separate model description from version description in metadata, responses, and UI --- __init__.py | 85 ++++++++++++- core/downloader.py | 17 ++- core/sources/civitai.py | 31 ++--- tests/test_downloader_metadata.py | 32 +++++ tests/test_model_info_description_ui.mjs | 41 +++++++ tests/test_search_orchestration.py | 70 +++++++++++ tests/test_unified_helpers.py | 19 +++ web/resolver/views/model_info_methods.js | 144 ++++++++++++++++------- 8 files changed, 376 insertions(+), 63 deletions(-) create mode 100644 tests/test_model_info_description_ui.mjs diff --git a/__init__.py b/__init__.py index 568d6e5..704e3de 100644 --- a/__init__.py +++ b/__init__.py @@ -362,6 +362,7 @@ class ModelResolverExtension: calculate_file_sha256, dedupe_local_base_directories, extract_safetensors_header_sha256, + find_external_metadata_sidecar_path, get_comfy_root_path, get_filename_from_path, get_path_abs, @@ -2286,9 +2287,56 @@ class ModelResolverExtension: if file_location and not file_location.endswith("/"): file_location += "/" + external_model_description = "" + if file_path: + external_metadata_path = find_external_metadata_sidecar_path( + file_path + ) + external_metadata = ( + read_json_safe(external_metadata_path, {}) + if external_metadata_path + else {} + ) + if isinstance(external_metadata, dict): + external_model_description = str( + external_metadata.get("modelDescription") + or external_metadata.get("model_description") + or external_metadata.get("description") + or "" + ).strip() + def infer_model_type_from_category(value): return normalize_category_to_model_type(value) + def split_result_descriptions(result, preferred_model_description=""): + result = result if isinstance(result, dict) else {} + explicit_model_description = ( + result.get("model_description") + or result.get("modelDescription") + or "" + ) + generic_description = result.get("description") or "" + model_description = ( + preferred_model_description + or explicit_model_description + or generic_description + ) + version_description = ( + result.get("version_description") + or result.get("versionDescription") + or "" + ) + if ( + not version_description + and explicit_model_description + and generic_description + and generic_description != explicit_model_description + ): + version_description = generic_description + if version_description == model_description: + version_description = "" + return model_description, version_description + def build_info_response( result=None, *, @@ -2298,6 +2346,12 @@ class ModelResolverExtension: local_payload=False, ): result = result or {} + model_description, version_description = ( + split_result_descriptions( + result, + external_model_description, + ) + ) size_value = result.get("size") if not size_value and file_path and _os.path.exists(file_path): try: @@ -2345,8 +2399,9 @@ class ModelResolverExtension: "trained_words": result.get("trained_words", []), "images": result.get("images", []), "clip_skip": result.get("clip_skip"), - "description": result.get("description", ""), - "model_description": result.get("model_description", ""), + "description": model_description, + "model_description": model_description, + "version_description": version_description, "from_metadata": bool(result.get("from_metadata")), "local_only": bool(local_payload), "metadata_checked": bool(civitai_checked), @@ -2589,6 +2644,9 @@ class ModelResolverExtension: ): return metadata_path, metadata_saved try: + model_description, version_description = ( + split_result_descriptions(result) + ) metadata_payload = { "source": source_name, "details_source": result.get("details_source") @@ -2613,8 +2671,9 @@ class ModelResolverExtension: "trained_words": result.get("trained_words", []), "images": result.get("images", []), "clip_skip": result.get("clip_skip"), - "description": result.get("description", ""), - "model_description": result.get("model_description", ""), + "description": model_description, + "model_description": model_description, + "version_description": version_description, "download_url": result.get("download_url"), "source_url": result.get("version_url") or result.get("url"), "version_url": result.get("version_url") or result.get("url"), @@ -2690,6 +2749,9 @@ class ModelResolverExtension: preview_path = get_existing_model_preview_path(file_path) if preview_path: preview_url = preview_path.replace("\\", "/") + model_description, version_description = ( + split_result_descriptions(result) + ) metadata_payload = { "source": "metadata_import", "details_source": result.get("source") or "metadata", @@ -2711,8 +2773,9 @@ class ModelResolverExtension: "trained_words": result.get("trained_words", []), "images": result.get("images", []), "clip_skip": result.get("clip_skip"), - "description": result.get("description", ""), - "model_description": result.get("model_description", ""), + "description": model_description, + "model_description": model_description, + "version_description": version_description, "download_url": result.get("download_url"), "preview_url": preview_url, "source_url": result.get("version_url") @@ -2967,6 +3030,16 @@ class ModelResolverExtension: "base_model": response.get("base_model"), "base_model_source": response.get("base_model_source"), "base_model_inferred": bool(response.get("base_model_inferred")), + "description": response.get("model_description") + or response.get("description") + or "", + "model_description": response.get("model_description") + or response.get("description") + or "", + "version_description": response.get( + "version_description" + ) + or "", "civitai_deleted": True, "civitai_checked": True, "remote_metadata_missing": True, diff --git a/core/downloader.py b/core/downloader.py index b469b33..0635046 100644 --- a/core/downloader.py +++ b/core/downloader.py @@ -602,16 +602,26 @@ def build_model_resolver_metadata( source.get("thumbnail_url"), source.get("thumbnailUrl"), ) - model_description = _first_present( + explicit_model_description = _first_present( source.get("modelDescription"), source.get("model_description"), - source.get("description"), details.get("description"), _as_dict(source.get("model")).get("description"), ) + model_description = _first_present( + explicit_model_description, + source.get("description"), + ) version_description = _first_present( - selected_version.get("description"), + source.get("versionDescription"), source.get("version_description"), + selected_version.get("description"), + ( + source.get("description") + if explicit_model_description + and source.get("description") != explicit_model_description + else None + ), ) is_civitai_source = source_name in { @@ -700,6 +710,7 @@ def build_model_resolver_metadata( "civitai": civitai_payload, "tags": tags, "modelDescription": str(model_description or ""), + "version_description": str(version_description or ""), "civitai_deleted": bool(source.get("is_deleted") or source.get("civitai_deleted")), "source": source_name, "details_source": source_name, diff --git a/core/sources/civitai.py b/core/sources/civitai.py index 74c411a..274a6d6 100644 --- a/core/sources/civitai.py +++ b/core/sources/civitai.py @@ -1909,6 +1909,20 @@ def _metadata_to_model_info(metadata: Dict[str, Any]) -> Dict[str, Any]: else civitai_model_url ) page_url = stored_page_url or civitai_version_url + model_description = first_non_empty( + metadata.get("modelDescription"), + metadata.get("model_description"), + metadata.get("description"), + model_info.get("description"), + ) or "" + version_description = first_non_empty( + metadata.get("versionDescription"), + metadata.get("version_description"), + selected_version.get("description"), + civitai_data.get("description"), + ) or "" + if version_description == model_description: + version_description = "" return { "source": details_source, @@ -1984,20 +1998,9 @@ def _metadata_to_model_info(metadata: Dict[str, Any]) -> Dict[str, Any]: ) or "", "clip_skip": civitai_data.get("clipSkip"), - "description": first_non_empty( - metadata.get("modelDescription"), - metadata.get("model_description"), - metadata.get("description"), - model_info.get("description"), - civitai_data.get("description"), - ) - or "", - "model_description": first_non_empty( - metadata.get("modelDescription"), - metadata.get("model_description"), - model_info.get("description"), - ) - or "", + "description": model_description, + "model_description": model_description, + "version_description": version_description, "from_metadata": True, } diff --git a/tests/test_downloader_metadata.py b/tests/test_downloader_metadata.py index 227ce3b..730319e 100644 --- a/tests/test_downloader_metadata.py +++ b/tests/test_downloader_metadata.py @@ -19,6 +19,38 @@ from core.path_utils import get_model_resolver_sidecar_path class DownloaderMetadataSidecarTests(unittest.TestCase): + def test_keeps_model_description_separate_from_version_notes(self): + with tempfile.TemporaryDirectory() as tmpdir: + model_path = os.path.join(tmpdir, "model.safetensors") + with open(model_path, "wb") as handle: + handle.write(b"abc") + + payload = build_lora_manager_metadata( + model_path, + { + "source": "civitai", + "model_id": 123, + "version_id": 456, + "model_description": "Model page description", + "description": "Version-specific release notes", + }, + category="checkpoints", + ) + + self.assertEqual("Model page description", payload["modelDescription"]) + self.assertEqual( + "Version-specific release notes", + payload["version_description"], + ) + self.assertEqual( + "Model page description", + payload["civitai"]["model"]["description"], + ) + self.assertEqual( + "Version-specific release notes", + payload["civitai"]["description"], + ) + def test_civarchive_metadata_keeps_provider_page_separate_from_hf_mirror(self): with tempfile.TemporaryDirectory() as tmpdir: model_path = os.path.join(tmpdir, "model.safetensors") diff --git a/tests/test_model_info_description_ui.mjs b/tests/test_model_info_description_ui.mjs new file mode 100644 index 0000000..945b46e --- /dev/null +++ b/tests/test_model_info_description_ui.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const sourceUrl = new URL( + '../web/resolver/views/model_info_methods.js', + import.meta.url +); +const source = await readFile(sourceUrl, 'utf8'); + +test('Show info keeps model description separate from version notes', () => { + assert.match(source, /'Version notes'/); + assert.match( + source, + /class="mr-info-description-row mr-info-version-description-row mr-hidden-initial"/ + ); + assert.ok( + source.indexOf("'Version notes'") < source.indexOf("'Description'"), + 'Version notes should be rendered above Description' + ); + assert.match( + source, + /const modelDescription = data\.model_description\s*\|\|\s*data\.modelDescription\s*\|\|\s*data\.description/ + ); + assert.match( + source, + /const versionDescription = data\.version_description\s*\|\|\s*data\.versionDescription/ + ); + assert.match(source, /contentSelector: '\.mr-info-version-description'/); +}); + +test('Refetch maps the response into stable model and version fields', () => { + assert.match( + source, + /description: modelDescription,\s*model_description: modelDescription,\s*version_description: result\.version_description/ + ); + assert.match( + source, + /data-description-target="\.mr-info-version-description"/ + ); +}); diff --git a/tests/test_search_orchestration.py b/tests/test_search_orchestration.py index 4954b5d..18cc8b7 100644 --- a/tests/test_search_orchestration.py +++ b/tests/test_search_orchestration.py @@ -283,6 +283,76 @@ class SearchOrchestrationTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(body["source"], "civarchive") self.assertEqual(body["sha256"], file_hash) + async def test_refetch_keeps_external_model_description_and_returns_version_notes(self): + file_hash = "b" * 64 + with tempfile.TemporaryDirectory() as temp_dir: + model_path = os.path.join(temp_dir, "model.safetensors") + external_path = os.path.join(temp_dir, "model.metadata.json") + resolver_path = f"{model_path}.modelresolver.json" + external_payload = { + "description": "User-managed model description", + "sha256": file_hash, + } + with open(model_path, "wb") as model_file: + model_file.write(b"model") + with open(external_path, "w", encoding="utf-8") as metadata_file: + json.dump(external_payload, metadata_file) + + mock_request = MagicMock() + mock_request.json = AsyncMock(return_value={ + "filename": "model.safetensors", + "category": "checkpoints", + "resolved_path": model_path, + "sha256": file_hash, + "force_refresh": True, + }) + remote_result = { + "source": "civitai", + "filename": "model.safetensors", + "model_name": "Remote model", + "model_id": 123, + "version_id": 456, + "sha256": file_hash, + "url": "https://civitai.com/models/123", + "version_url": ( + "https://civitai.com/models/123?modelVersionId=456" + ), + "model_description": "Fresh CivitAI model description", + "description": "Fresh CivitAI version notes", + } + + with ( + patch.object( + civitai_sources, + "get_model_info_by_hash", + return_value=remote_result, + ), + patch("folder_paths.get_folder_paths", return_value=[temp_dir]), + ): + response = await civitai_search_handler(mock_request) + + body = json.loads(response.text) + with open(external_path, encoding="utf-8") as metadata_file: + external_after = json.load(metadata_file) + with open(resolver_path, encoding="utf-8") as metadata_file: + resolver_metadata = json.load(metadata_file) + + self.assertEqual("User-managed model description", body["description"]) + self.assertEqual("User-managed model description", body["model_description"]) + self.assertEqual( + "Fresh CivitAI version notes", + body["version_description"], + ) + self.assertEqual(external_payload, external_after) + self.assertEqual( + "Fresh CivitAI model description", + resolver_metadata["modelDescription"], + ) + self.assertEqual( + "Fresh CivitAI version notes", + resolver_metadata["version_description"], + ) + async def test_local_matches_by_hash_route_returns_enriched_hash_matches(self): file_hash = "b" * 64 with tempfile.TemporaryDirectory() as temp_dir: diff --git a/tests/test_unified_helpers.py b/tests/test_unified_helpers.py index 6fdb4e7..6efe801 100644 --- a/tests/test_unified_helpers.py +++ b/tests/test_unified_helpers.py @@ -107,6 +107,25 @@ class UnifiedHelpersTests(unittest.TestCase): self.assertEqual(result["preview_url"], "E:/Models/video-model.mp4") self.assertEqual(result["images"][0]["type"], "video") + def test_local_civitai_metadata_separates_model_and_version_descriptions(self): + from core.sources.civitai import _metadata_to_model_info + + result = _metadata_to_model_info({ + "modelDescription": "Model page description", + "version_description": "Version-specific release notes", + "civitai": { + "description": "Nested version notes", + "model": {"description": "Nested model description"}, + }, + }) + + self.assertEqual("Model page description", result["description"]) + self.assertEqual("Model page description", result["model_description"]) + self.assertEqual( + "Version-specific release notes", + result["version_description"], + ) + def test_normalize_model_image_preserves_metadata_when_normalized_again(self): workflow = '{"workflow":{"nodes":[{"id":1,"type":"KSampler"}]}}' normalized_image = { diff --git a/web/resolver/views/model_info_methods.js b/web/resolver/views/model_info_methods.js index 9d3c861..e5267b9 100644 --- a/web/resolver/views/model_info_methods.js +++ b/web/resolver/views/model_info_methods.js @@ -1266,13 +1266,24 @@ export const modelInfoMethods = { ${this.renderInfoFieldLabel('wrench', 'Clip Skip', 'Recommended Clip Skip value from the model author, if one is provided.')} + + ${this.renderInfoFieldLabel('fileText', 'Version notes', 'Description or release notes for the exact model version matched by SHA-256.')} + +
+
+
+ +
+
+ + ${this.renderInfoFieldLabel('fileText', 'Description', 'Model description from CivitAI or local metadata. Long descriptions are shortened until you click Show more.')}
- +
@@ -1765,7 +1776,8 @@ export const modelInfoMethods = { const descToggleBtn = event.target.closest('.mr-info-description-toggle'); if (descToggleBtn && dialog.contains(descToggleBtn)) { - const descEl = dialog.querySelector('.mr-info-description'); + const targetSelector = descToggleBtn.dataset.descriptionTarget || '.mr-info-description'; + const descEl = dialog.querySelector(targetSelector); if (!descEl) return; const isExpanded = descEl.classList.toggle('is-expanded'); @@ -1874,6 +1886,17 @@ export const modelInfoMethods = { base_model: modelData?.base_model || modelData?.baseModel || '', tags: modelData?.tags || [], trained_words: modelData?.trained_words || modelData?.trainedWords || [], + description: modelData?.model_description + || modelData?.modelDescription + || modelData?.description + || '', + model_description: modelData?.model_description + || modelData?.modelDescription + || modelData?.description + || '', + version_description: modelData?.version_description + || modelData?.versionDescription + || '', local_only: true, civitai_checked: false }; @@ -1981,6 +2004,13 @@ export const modelInfoMethods = { }) }, 'Fetch model metadata'); + const modelDescription = result.model_description + || result.modelDescription + || result.description + || data.model_description + || data.modelDescription + || data.description + || ''; const merged = { ...data, ...result, @@ -1989,6 +2019,13 @@ export const modelInfoMethods = { file_path: result.file_path || data.file_path || resolvedPath, resolved_path: result.resolved_path || data.resolved_path || resolvedPath, sha256: result.sha256 || hash, + description: modelDescription, + model_description: modelDescription, + version_description: result.version_description + || result.versionDescription + || data.version_description + || data.versionDescription + || '', metadata_checked: true, civitai_checked: true }; @@ -2049,6 +2086,48 @@ export const modelInfoMethods = { return Boolean(data.metadata_checked || data.metadataChecked || data.civitai_checked || data.civitaiChecked); }, + updateInfoDialogDescriptionSection(dialog, { + value = '', + contentSelector, + actionsSelector, + toggleSelector + } = {}) { + const descEl = dialog.querySelector(contentSelector); + if (!descEl) return; + + const row = descEl.closest('tr'); + if (!value) { + this.setInfoTableRowVisible(row, false); + return; + } + + const actionsEl = dialog.querySelector(actionsSelector); + const toggleBtn = dialog.querySelector(toggleSelector); + let sanitizedHtml = ''; + try { + sanitizedHtml = this.sanitizeDescriptionHtml(value); + } catch (error) { + console.error('Model Resolver: Failed to sanitize description HTML:', error); + } + + const fallbackText = this.escapeHtml(String(value).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); + const finalHtml = sanitizedHtml && sanitizedHtml.trim() ? sanitizedHtml : `

${fallbackText}

`; + const textOnly = finalHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + + descEl.innerHTML = finalHtml; + descEl.classList.remove('is-expanded'); + + const shouldCollapse = textOnly.length > 520 || finalHtml.length > 900; + this.setInfoElementVisible(actionsEl, shouldCollapse); + if (toggleBtn) { + toggleBtn.textContent = 'Show more'; + } + if (!shouldCollapse) { + descEl.classList.add('is-expanded'); + } + this.setInfoTableRowVisible(row, true); + }, + /** * Update the info dialog with data */ @@ -2242,44 +2321,29 @@ export const modelInfoMethods = { } } - // Update description - const descEl = dialog.querySelector('.mr-info-description'); - if (descEl) { - const desc = data.description || data.model_description || data.modelDescription || ''; - if (desc) { - const actionsEl = dialog.querySelector('.mr-info-description-actions'); - const toggleBtn = dialog.querySelector('.mr-info-description-toggle'); - - let sanitizedHtml = ''; - try { - sanitizedHtml = this.sanitizeDescriptionHtml(desc); - } catch (error) { - console.error('Model Resolver: Failed to sanitize description HTML:', error); - } - - const fallbackText = this.escapeHtml(String(desc).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()); - const finalHtml = sanitizedHtml && sanitizedHtml.trim() ? sanitizedHtml : `

${fallbackText}

`; - const textOnly = finalHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); - - descEl.innerHTML = finalHtml; - descEl.classList.remove('is-expanded'); - - const shouldCollapse = textOnly.length > 520 || finalHtml.length > 900; - this.setInfoElementVisible(actionsEl, shouldCollapse); - if (toggleBtn) { - toggleBtn.textContent = 'Show more'; - } - if (!shouldCollapse) { - descEl.classList.add('is-expanded'); - } - - const row = descEl.closest('tr'); - this.setInfoTableRowVisible(row, true); - } else { - const row = descEl.closest('tr'); - this.setInfoTableRowVisible(row, false); - } - } + // Keep the model description stable while exposing version-specific + // release notes separately. + const modelDescription = data.model_description + || data.modelDescription + || data.description + || ''; + const versionDescription = data.version_description + || data.versionDescription + || ''; + this.updateInfoDialogDescriptionSection(dialog, { + value: modelDescription, + contentSelector: '.mr-info-description:not(.mr-info-version-description)', + actionsSelector: '.mr-info-description-actions:not(.mr-info-version-description-actions)', + toggleSelector: '.mr-info-description-toggle:not(.mr-info-version-description-toggle)' + }); + this.updateInfoDialogDescriptionSection(dialog, { + value: versionDescription && versionDescription !== modelDescription + ? versionDescription + : '', + contentSelector: '.mr-info-version-description', + actionsSelector: '.mr-info-version-description-actions', + toggleSelector: '.mr-info-version-description-toggle' + }); // Update preview media this.updateInfoDialogImages(