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.
This commit is contained in:
Will Miao
2026-09-16 08:23:13 +08:00
parent 779bd18e75
commit a0a5b13ab0
2 changed files with 70 additions and 4 deletions
+26 -3
View File
@@ -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
+44 -1
View File
@@ -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()