From a0a5b13ab01aeb366aa673970ea9ab5ba4aef5a5 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Wed, 16 Sep 2026 08:23:13 +0800 Subject: [PATCH] fix(metadata): keep the saved trigger-word order on refresh `civitai.trainedWords` is an ordered array, and the order is what gets pasted into a prompt: "Copy Trigger Words" and the insert-into-node action join it as-is. The refresh merge unioned the stored words with the freshly fetched ones via `list(set(...))`, so any metadata refresh silently shuffled a user's ordering into an arbitrary one. Now that the UI exposes reordering, that would look like the feature losing the change at random. Merge in order instead: stored words first (in their saved order), then newly discovered ones, duplicates dropped. `_merge_ordered_unique` keeps the behaviour easy to assert, and the existing merge test keeps passing because it compares the result as a set. --- py/services/metadata_sync_service.py | 29 +++++++++++-- tests/services/test_metadata_sync_service.py | 45 +++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/py/services/metadata_sync_service.py b/py/services/metadata_sync_service.py index 4a0c9cb7..92262a0e 100644 --- a/py/services/metadata_sync_service.py +++ b/py/services/metadata_sync_service.py @@ -19,6 +19,28 @@ from .model_sources import has_external_source logger = logging.getLogger(__name__) +def _merge_ordered_unique(existing: Iterable[str], new: Iterable[str]) -> list[str]: + """Concatenate two word lists, dropping duplicates without reordering. + + Trigger word order is meaningful: the sequence stored in + ``civitai.trainedWords`` is the order used when building prompts, and users + can reorder it in the UI. A plain ``set`` union used to shuffle that order on + every metadata refresh, so existing words are kept first (in their saved + order) and newly discovered ones are appended. + """ + + merged: list[str] = [] + seen: set[str] = set() + + for word in list(existing) + list(new): + if word in seen: + continue + seen.add(word) + merged.append(word) + + return merged + + class MetadataProviderProtocol(Protocol): """Subset of metadata provider interface consumed by the sync service.""" @@ -115,9 +137,10 @@ class MetadataSyncService: ) if "trainedWords" in existing_civitai: - existing_trained = existing_civitai.get("trainedWords", []) - new_trained = civitai_metadata.get("trainedWords", []) - merged_trained = list(set(existing_trained + new_trained)) + existing_trained = existing_civitai.get("trainedWords", []) or [] + new_trained = civitai_metadata.get("trainedWords", []) or [] + # Order preserving merge: the saved order drives prompt order. + merged_trained = _merge_ordered_unique(existing_trained, new_trained) merged_civitai["trainedWords"] = merged_trained local_metadata["civitai"] = merged_civitai diff --git a/tests/services/test_metadata_sync_service.py b/tests/services/test_metadata_sync_service.py index 84f45bda..c76dcdce 100644 --- a/tests/services/test_metadata_sync_service.py +++ b/tests/services/test_metadata_sync_service.py @@ -6,7 +6,7 @@ import pytest from py.services.connectivity_guard import OFFLINE_COOLDOWN_ERROR, OFFLINE_FRIENDLY_MESSAGE from py.services.errors import RateLimitError -from py.services.metadata_sync_service import MetadataSyncService +from py.services.metadata_sync_service import MetadataSyncService, _merge_ordered_unique class DummySettings: @@ -112,6 +112,49 @@ async def test_update_model_metadata_merges_and_persists(): ) +def test_merge_ordered_unique_keeps_first_seen_order(): + assert _merge_ordered_unique(["b", "a"], ["a", "c", "b", "d"]) == [ + "b", + "a", + "c", + "d", + ] + assert _merge_ordered_unique([], ["x"]) == ["x"] + assert _merge_ordered_unique(["x"], []) == ["x"] + + +@pytest.mark.asyncio +async def test_update_model_metadata_preserves_trained_word_order(): + """Trigger word order (prompt order) must survive a metadata refresh.""" + + helpers = build_service() + + local = { + "civitai": {"trainedWords": ["zeta style", "alpha", "beta"]}, + "model_name": "Local", + } + remote = { + "source": "api", + "trainedWords": ["beta", "gamma", "alpha"], + "model": {"name": "Remote Model"}, + } + + result = await helpers.service.update_model_metadata( + "path/to/model.metadata.json", + local, + remote, + helpers.default_provider, + ) + + # Saved order first, newly discovered words appended, duplicates dropped + assert result["civitai"]["trainedWords"] == [ + "zeta style", + "alpha", + "beta", + "gamma", + ] + + @pytest.mark.asyncio async def test_update_model_metadata_propagates_civitai_autov3(): helpers = build_service()