Separate model description from version description in metadata, responses, and UI

This commit is contained in:
Dariusz L
2026-07-27 15:31:59 +02:00
parent 31ad1cde7c
commit 23af562721
8 changed files with 376 additions and 63 deletions
+79 -6
View File
@@ -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,
+14 -3
View File
@@ -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,
+17 -14
View File
@@ -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,
}
+32
View File
@@ -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")
+41
View File
@@ -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"/
);
});
+70
View File
@@ -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:
+19
View File
@@ -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 = {
+104 -40
View File
@@ -1266,13 +1266,24 @@ export const modelInfoMethods = {
<td>${this.renderInfoFieldLabel('wrench', 'Clip Skip', 'Recommended Clip Skip value from the model author, if one is provided.')}</td>
<td><span class="mr-info-clip-skip"></span></td>
</tr>
<tr class="mr-info-description-row mr-info-version-description-row mr-hidden-initial">
<td>${this.renderInfoFieldLabel('fileText', 'Version notes', 'Description or release notes for the exact model version matched by SHA-256.')}</td>
<td>
<div class="mr-info-description-wrap">
<div class="mr-info-description mr-info-version-description"></div>
<div class="mr-info-description-actions mr-info-version-description-actions mr-hidden-initial">
<button type="button" class="mr-info-description-toggle mr-info-version-description-toggle" data-description-target=".mr-info-version-description">Show more</button>
</div>
</div>
</td>
</tr>
<tr class="mr-info-description-row mr-hidden-initial">
<td>${this.renderInfoFieldLabel('fileText', 'Description', 'Model description from CivitAI or local metadata. Long descriptions are shortened until you click Show more.')}</td>
<td>
<div class="mr-info-description-wrap">
<div class="mr-info-description"></div>
<div class="mr-info-description-actions mr-hidden-initial">
<button type="button" class="mr-info-description-toggle">Show more</button>
<button type="button" class="mr-info-description-toggle" data-description-target=".mr-info-description">Show more</button>
</div>
</div>
</td>
@@ -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 : `<p>${fallbackText}</p>`;
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 : `<p>${fallbackText}</p>`;
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(