Compare commits

...

4 Commits

Author SHA1 Message Date
Will Miao
f92f958682 fix(SaveImageLM): correct scheduler mapping and deduplicate sampler map
- Fix incorrect mapping: "normal" -> "Normal" (was "Simple")
- Replace inline sampler_mapping with CIVITAI_SAMPLER_MAP reference
  to eliminate duplicate definition
2026-07-28 21:39:09 +08:00
Will Miao
f63fab0676 fix(cache): deduplicate model entries on add and reconcile to prevent duplicate cards (#1041) 2026-07-28 20:44:57 +08:00
Will Miao
cfc4903c0c fix(update): read ahead_by from GitHub compare API when status is ahead/diverged
The compare API URL format compare/{local_hash}...main returns
status='ahead' when main is ahead of the local commit. The count is
in the ahead_by field, not behind_by. The old code only read behind_by
which is always 0 in this case, causing the UI to show 'Up to date'
when actually several commits behind.

Also handle status='diverged' (both sides have unique commits) by
reading ahead_by for the remote-ahead count.

Frontend adds a hash comparison fallback: if behind_by is 0 but local
and remote commit hashes differ, show 'Behind main' instead of the
incorrect 'Up to date'.

Tests: _AheadCompareDownloader and _DivergedCompareDownloader mocks
for the two status paths.
2026-07-28 17:47:38 +08:00
Will Miao
a527a847fe fix(download): route UNet/diffusion model downloads to unet roots in location step
When downloading a diffusion model (UNet) from the checkpoints page, the
download modal's location step always showed checkpoint roots and paths.
Now the modal detects the file subtype and switches to unet_roots endpoint,
default_unet_root key, and 'unet' path template.
2026-07-28 17:21:12 +08:00
7 changed files with 154 additions and 24 deletions

View File

@@ -446,7 +446,16 @@ class SaveImageLM:
lora_resource["versionName"] = lora_civitai["name"]
civitai_resources.append(lora_resource)
sampler_display = self._get_civitai_sampler_name(sampler, scheduler)
sampler_name = CIVITAI_SAMPLER_MAP.get(sampler, sampler) if sampler else None
scheduler_mapping = {
"normal": "Normal",
"karras": "Karras",
"exponential": "Exponential",
"sgm_uniform": "SGM Uniform",
"sgm_quadratic": "SGM Quadratic",
}
scheduler_name = scheduler_mapping.get(scheduler, scheduler) if scheduler else None
# Build output lines
lines = [prompt] if prompt else [""]
@@ -456,8 +465,11 @@ class SaveImageLM:
params: list[str] = []
if steps is not None:
params.append(f"Steps: {steps}")
if sampler_display:
params.append(f"Sampler: {sampler_display}")
if sampler_name:
if scheduler_name:
params.append(f"Sampler: {sampler_name} {scheduler_name}")
else:
params.append(f"Sampler: {sampler_name}")
if cfg is not None:
params.append(f"CFG scale: {cfg}")
if seed is not None:

View File

@@ -517,7 +517,10 @@ class UpdateRoutes:
custom_headers={'Accept': 'application/vnd.github+json'}
)
if c_ok:
behind_by = c_data.get('behind_by', 0)
if c_data.get('status') in ('ahead', 'diverged'):
behind_by = c_data.get('ahead_by', 0)
else:
behind_by = c_data.get('behind_by', 0)
return version, changelog, behind_by, commit_date

View File

@@ -927,6 +927,25 @@ class ModelScanner:
# Update cache data
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
dedup_removed = 0
seen_paths: set = set()
deduped: list = []
for item in reversed(self._cache.raw_data):
path = item.get('file_path', '')
if path not in seen_paths:
seen_paths.add(path)
deduped.append(item)
else:
for tag in item.get('tags', []):
if tag in self._tags_count:
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
if self._tags_count[tag] == 0:
del self._tags_count[tag]
dedup_removed += 1
if dedup_removed > 0:
self._cache.raw_data = list(reversed(deduped))
total_removed += dedup_removed
# Resort cache if changes were made
if total_added > 0 or total_removed > 0:
# Update folders list
@@ -1352,18 +1371,25 @@ class ModelScanner:
# Update folder in metadata
metadata_dict['folder'] = folder
# Add to cache
self._cache.raw_data.append(metadata_dict)
self._cache.add_to_version_index(metadata_dict)
file_path = metadata_dict.get('file_path', '')
if file_path:
old_entries = [item for item in self._cache.raw_data if item.get('file_path') == file_path]
for old_entry in old_entries:
for tag in old_entry.get('tags', []):
if tag in self._tags_count:
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
if self._tags_count[tag] == 0:
del self._tags_count[tag]
self._hash_index.remove_by_path(file_path)
self._cache.raw_data = [item for item in self._cache.raw_data if item.get('file_path') != file_path]
for tag in metadata_dict.get('tags', []):
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
self._cache.raw_data.append(metadata_dict)
# Resort cache data
await self._cache.resort()
# Update folders list
all_folders = set(self._cache.folders)
all_folders.add(folder)
self._cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
# Update the hash index
self._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
await self._persist_current_cache()

View File

@@ -158,6 +158,7 @@ export class DownloadManager {
this.modelVersionId = null;
this.source = null;
this.selectedFile = null;
this._isDiffusionModel = false;
this.selectedFolder = '';
this.batchModels = [];
@@ -787,24 +788,40 @@ export class DownloadManager {
async proceedToLocationContent() {
try {
// Fetch model roots
const rootsData = await this.apiClient.fetchModelRoots();
const _isDiffusionModel = this.selectedFile
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
: (this.currentVersion?.files || []).some(
f => f.type === 'UNet' || f.type === 'Diffusion Model'
);
this._isDiffusionModel = _isDiffusionModel;
let rootsData;
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
} else {
rootsData = await this.apiClient.fetchModelRoots();
}
const modelRoot = document.getElementById('modelRoot');
modelRoot.innerHTML = rootsData.roots.map(root =>
`<option value="${root}">${root}</option>`
).join('');
// Set default root if available
const singularType = this.apiClient.modelType.replace(/s$/, '');
const singularType = this._isDiffusionModel
? 'unet'
: this.apiClient.modelType.replace(/s$/, '');
const defaultRootKey = `default_${singularType}_root`;
const defaultRoot = state.global.settings[defaultRootKey];
console.log(`Default root for ${this.apiClient.modelType}:`, defaultRoot);
console.log(`Default root for ${singularType}:`, defaultRoot);
console.log('Available roots:', rootsData.roots);
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
console.log(`Setting default root: ${defaultRoot}`);
modelRoot.value = defaultRoot;
}
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
document.getElementById('modelRootLabel').textContent =
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
// Set autocomplete="off" on folderPath input
const folderPathInput = document.getElementById('folderPath');
if (folderPathInput) {
@@ -1776,13 +1793,15 @@ export class DownloadManager {
const modelRoot = document.getElementById('modelRoot').value;
const config = this.apiClient.apiConfig.config;
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: config.displayName });
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : config.displayName;
let fullPath = modelRoot || translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
if (modelRoot) {
if (this.useDefaultPath) {
// Show actual template path
try {
const singularType = this.apiClient.modelType.replace(/s$/, '');
const singularType = this._isDiffusionModel
? 'unet'
: this.apiClient.modelType.replace(/s$/, '');
const templates = state.global.settings.download_path_templates;
const template = templates[singularType];
fullPath += `/${template}`;

View File

@@ -615,13 +615,17 @@ export class UpdateService {
if (newVersionEl) {
if (this.updateInfo?.nightly) {
const behind = this.updateInfo.behind_by || 0;
const hash = this.latestVersion.replace('main-', '');
const remoteHash = this.latestVersion.replace('main-', '');
const localHash = this.gitInfo.short_hash || '';
const date = this.updateInfo.commit_date || '';
const datePart = date ? ` · ${date}` : '';
if (behind > 0) {
newVersionEl.textContent = `${behind} commit${behind !== 1 ? 's' : ''} behind main (${hash}${datePart})`;
newVersionEl.textContent = `${behind} commit${behind !== 1 ? 's' : ''} behind main (${remoteHash}${datePart})`;
} else if (localHash !== remoteHash) {
newVersionEl.textContent = `Behind main (${remoteHash}${datePart})`;
} else {
newVersionEl.textContent = `Up to date (${hash}${datePart})`;
newVersionEl.textContent = `Up to date (${remoteHash}${datePart})`;
}
} else {
newVersionEl.textContent = this.latestVersion;

View File

@@ -333,6 +333,7 @@ export const PATH_TEMPLATE_PLACEHOLDERS = [
export const DEFAULT_PATH_TEMPLATES = {
lora: '{base_model}/{first_tag}',
checkpoint: '{base_model}',
unet: '{base_model}',
embedding: '{first_tag}'
};

View File

@@ -428,6 +428,71 @@ async def test_get_nightly_version_parses_behind_by(monkeypatch):
assert changelog[0] == "test: add nightly feature"
class _AheadCompareDownloader:
"""Fake compare API response with status='ahead' (main is ahead of local)."""
commit_sha = "9999999"
commit_msg = "latest commit"
commit_date = "2026-07-28T00:00:00Z"
ahead_by = 3
async def make_request(self, method, url, **kwargs):
if "/compare/" in url:
return True, {"status": "ahead", "ahead_by": self.ahead_by, "behind_by": 0}
return True, {
"sha": self.commit_sha,
"commit": {
"message": self.commit_msg,
"committer": {"date": self.commit_date},
},
}
@pytest.mark.asyncio
async def test_get_nightly_version_reads_ahead_by_when_ahead(monkeypatch):
"""compare/{local}...main returns status='ahead' → read ahead_by."""
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_AheadCompareDownloader()))
version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version(
local_hash="oldhash"
)
assert version == "main-9999999"
assert behind_by == 3
assert commit_date == "2026-07-28"
class _DivergedCompareDownloader:
"""Fake compare API response with status='diverged' (both have unique commits)."""
commit_sha = "aaaaaaa"
commit_msg = "diverged test"
commit_date = "2026-07-29T00:00:00Z"
async def make_request(self, method, url, **kwargs):
if "/compare/" in url:
return True, {"status": "diverged", "ahead_by": 5, "behind_by": 2}
return True, {
"sha": self.commit_sha,
"commit": {
"message": self.commit_msg,
"committer": {"date": self.commit_date},
},
}
@pytest.mark.asyncio
async def test_get_nightly_version_reads_ahead_by_when_diverged(monkeypatch):
"""compare/{local}...main returns status='diverged' → read ahead_by (remote ahead)."""
monkeypatch.setattr(update_routes, "get_downloader", lambda: _stub_downloader(_DivergedCompareDownloader()))
version, changelog, behind_by, commit_date = await update_routes.UpdateRoutes._get_nightly_version(
local_hash="divhash"
)
assert behind_by == 5
class _CheckUpdatesDownloader:
"""Fake downloader returning both a release list and a nightly commit + compare."""