diff --git a/tests/test_dom_patchers.mjs b/tests/test_dom_patchers.mjs
index c065ec5..444858c 100644
--- a/tests/test_dom_patchers.mjs
+++ b/tests/test_dom_patchers.mjs
@@ -10,6 +10,7 @@ import {
syncElementAttributes,
} from '../web/resolver/utils/dom_patch_utils.js';
import * as domPatchUtils from '../web/resolver/utils/dom_patch_utils.js';
+import { classifyLocalMatches } from '../web/resolver/utils/local_match_utils.js';
const projectRoot = path.resolve(import.meta.dirname, '..');
const resolveDownloadMethodsSource = fs.readFileSync(
diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs
index 84fa240..2ef1940 100644
--- a/tests/test_downloads_tab_workflow_route.mjs
+++ b/tests/test_downloads_tab_workflow_route.mjs
@@ -9,6 +9,7 @@ import {
safeStorage,
} from '../web/resolver/utils/html_utils.js';
import { getModelCardUrl } from '../web/resolver/utils/url_utils.js';
+import { classifyLocalMatches } from '../web/resolver/utils/local_match_utils.js';
import { extractComfyWorkflow } from '../web/resolver/utils/workflow_metadata.js';
import {
buildModelResolverNodeMenu,
@@ -6020,6 +6021,130 @@ test('local match rows preserve best-match and alternative rendering contracts',
assert.equal((html.match(/class="status-group"/g) || []).length, 2);
});
+test('local match classification preserves the confidence threshold and alternative partition', () => {
+ globalThis.getSvgIcon = () => '';
+ const renderLocalMatchRow = eval(`(${extractMethod(searchPanelMethodsSource, 'renderLocalMatchRow')})`);
+ const renderLocalMatchesContent = eval(`(${extractMethod(searchPanelMethodsSource, 'renderLocalMatchesContent')})`);
+ const dialog = {
+ renderLocalMatchRow,
+ getHashMatchLabelMap() {
+ return null;
+ },
+ buildContextMenuModelData() {
+ return { model: 'context' };
+ },
+ getLocalMatchContextData() {
+ return { source: 'local' };
+ },
+ getLocalMatchIdentity(match) {
+ return match.identity;
+ },
+ escapeHtml(value) {
+ return String(value).replace(/[&<>"']/g, char => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ }[char]));
+ },
+ getContextMenuAttrs() {
+ return '';
+ },
+ getConfidenceBadge(confidence) {
+ return `${confidence}`;
+ },
+ getModelPreviewTooltipAttrs() {
+ return '';
+ },
+ renderLocalMatchStatusGroup() {
+ return '';
+ },
+ areLocalMatchAlternativesCollapsed() {
+ return false;
+ },
+ };
+ const html = renderLocalMatchesContent.call(dialog, {
+ node_id: 1,
+ widget_index: 2,
+ matches: [
+ { confidence: 69, identity: 'below-threshold', model: { relative_path: 'below.safetensors' } },
+ { confidence: 70, identity: 'minimum-threshold', model: { relative_path: 'minimum.safetensors' } },
+ { confidence: 80, identity: 'alternative', model: { relative_path: 'alternative.safetensors' } },
+ { confidence: 100, identity: 'perfect', model: { relative_path: 'perfect.safetensors' } },
+ ],
+ }, 0);
+
+ assert.match(html, /perfect\.safetensors/);
+ assert.match(html, /minimum\.safetensors/);
+ assert.match(html, /alternative\.safetensors/);
+ assert.doesNotMatch(html, /below\.safetensors/);
+ assert.match(html, /Alternatives \(2\)/);
+});
+
+test('local match classification limits visible non-perfect matches to the five highest confidences', () => {
+ globalThis.getSvgIcon = () => '';
+ const renderLocalMatchRow = eval(`(${extractMethod(searchPanelMethodsSource, 'renderLocalMatchRow')})`);
+ const renderLocalMatchesContent = eval(`(${extractMethod(searchPanelMethodsSource, 'renderLocalMatchesContent')})`);
+ const dialog = {
+ renderLocalMatchRow,
+ getHashMatchLabelMap() {
+ return null;
+ },
+ buildContextMenuModelData() {
+ return { model: 'context' };
+ },
+ getLocalMatchContextData() {
+ return { source: 'local' };
+ },
+ getLocalMatchIdentity(match) {
+ return match.identity;
+ },
+ escapeHtml(value) {
+ return String(value).replace(/[&<>"']/g, char => ({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ }[char]));
+ },
+ getContextMenuAttrs() {
+ return '';
+ },
+ getConfidenceBadge(confidence) {
+ return `${confidence}`;
+ },
+ getModelPreviewTooltipAttrs() {
+ return '';
+ },
+ renderLocalMatchStatusGroup() {
+ return '';
+ },
+ areLocalMatchAlternativesCollapsed() {
+ return false;
+ },
+ };
+ const html = renderLocalMatchesContent.call(dialog, {
+ node_id: 1,
+ widget_index: 2,
+ matches: [
+ { confidence: 69, identity: 'below-threshold', model: { relative_path: 'below.safetensors' } },
+ ...[70, 75, 80, 85, 90, 95].map(confidence => ({
+ confidence,
+ identity: `match-${confidence}`,
+ model: { relative_path: `match-${confidence}.safetensors` },
+ })),
+ ],
+ }, 0);
+
+ assert.match(html, /match-95\.safetensors/);
+ assert.match(html, /match-75\.safetensors/);
+ assert.doesNotMatch(html, /match-70\.safetensors/);
+ assert.doesNotMatch(html, /below\.safetensors/);
+ assert.equal((html.match(/class="mr-match-row/g) || []).length, 5);
+});
+
test('URN resolution preserves the download panel container', () => {
const resolveUrnAsync = extractMethod(resolveDownloadMethodsSource, 'resolveUrnAsync');
diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js
index f6b4631..3d42d21 100644
--- a/web/resolver/search/search_panel.js
+++ b/web/resolver/search/search_panel.js
@@ -5,6 +5,7 @@ import { getCivitaiModelUrl } from "../globals.js";
import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js";
import { getSha256Field } from "../utils/hash_utils.js";
import { bindEventOnce } from "../utils/dom_patch_utils.js";
+import { classifyLocalMatches } from "../utils/local_match_utils.js";
const localStorage = safeStorage;
export const searchPanelMethods = {
isAutoFillBaseModelEnabled() {
@@ -2399,18 +2400,19 @@ export const searchPanelMethods = {
renderLocalMatchesContent(missing, missingIndex = 0) {
const allMatches = missing.matches || [];
- const filteredMatches = allMatches.filter(m => m.confidence >= 70);
- const hasMatches = filteredMatches.length > 0;
- const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
- const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
+ const {
+ filteredMatches,
+ hasMatches,
+ perfectMatches,
+ otherMatches,
+ visibleMatches,
+ } = classifyLocalMatches(allMatches);
const hashLabelMap = this.getHashMatchLabelMap?.(missing) || null;
let html = '';
if (hasMatches) {
- const matchesToShow = perfectMatches.length > 0
- ? perfectMatches
- : otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
+ const matchesToShow = perfectMatches.length > 0 ? perfectMatches : visibleMatches;
const sortedMatches = matchesToShow.sort((a, b) => {
if (a.confidence === 100 && b.confidence !== 100) return -1;
@@ -2479,12 +2481,10 @@ export const searchPanelMethods = {
const matchIndex = Number(row?.dataset.localMatchIndex);
if (!row || !Number.isInteger(matchIndex) || matchIndex < 0) return;
- const filteredMatches = (currentMissing.matches || []).filter(match => match.confidence >= 70);
- const perfectMatches = filteredMatches.filter(match => match.confidence === 100);
- const otherMatches = filteredMatches.filter(match => match.confidence < 100 && match.confidence >= 70);
- const visibleMatches = perfectMatches.length > 0
- ? perfectMatches
- : otherMatches.sort((left, right) => right.confidence - left.confidence).slice(0, 5);
+ const {
+ otherMatches,
+ visibleMatches,
+ } = classifyLocalMatches(currentMissing.matches || []);
const match = row.dataset.localMatchAlternative === 'true'
? otherMatches[matchIndex]
: visibleMatches[matchIndex];
diff --git a/web/resolver/utils/local_match_utils.js b/web/resolver/utils/local_match_utils.js
new file mode 100644
index 0000000..cdd6084
--- /dev/null
+++ b/web/resolver/utils/local_match_utils.js
@@ -0,0 +1,21 @@
+export function classifyLocalMatches(
+ matches = [],
+ { minConfidence = 70, perfectConfidence = 100, visibleLimit = 5 } = {},
+) {
+ const filteredMatches = matches.filter(match => match.confidence >= minConfidence);
+ const perfectMatches = filteredMatches.filter(match => match.confidence === perfectConfidence);
+ const otherMatches = filteredMatches.filter(
+ match => match.confidence < perfectConfidence && match.confidence >= minConfidence,
+ );
+ const visibleMatches = perfectMatches.length > 0
+ ? perfectMatches
+ : [...otherMatches].sort((left, right) => right.confidence - left.confidence).slice(0, visibleLimit);
+
+ return {
+ filteredMatches,
+ perfectMatches,
+ otherMatches,
+ visibleMatches,
+ hasMatches: filteredMatches.length > 0,
+ };
+}
diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js
index a9d2344..7f04a26 100644
--- a/web/resolver/views/missing_browser_methods.js
+++ b/web/resolver/views/missing_browser_methods.js
@@ -4,6 +4,7 @@ import { startSplitterDrag } from "../utils/splitter_drag.js";
import { getCivitaiModelUrl } from "../globals.js";
import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js";
import { matchesWorkflowModelReference } from "../node_context_menu.js";
+import { classifyLocalMatches } from "../utils/local_match_utils.js";
const MISSING_ROW_FALLBACK_HEIGHT = 70;
const MISSING_VIRTUAL_INITIAL_ROWS = 24;
@@ -2409,7 +2410,7 @@ export const missingBrowserMethods = {
// Check if there are active downloads
const activeCount = Object.keys(this.activeDownloads).length;
const hasAny100Match = visibleMissingModels.some(missing =>
- (missing.matches || []).some(match => match.confidence === 100)
+ classifyLocalMatches(missing.matches || []).perfectMatches.length > 0
);
const hasAnyModelsToDisplay = rawMissingCount > 0 || resolvedModels.length > 0;
@@ -2481,13 +2482,13 @@ export const missingBrowserMethods = {
const aMatches = a.matches || [];
const bMatches = b.matches || [];
- // Filter to 70%+ confidence
- const aFiltered = aMatches.filter(m => m.confidence >= 70);
- const bFiltered = bMatches.filter(m => m.confidence >= 70);
+ const aClassification = classifyLocalMatches(aMatches);
+ const bClassification = classifyLocalMatches(bMatches);
+ const aFiltered = aClassification.filteredMatches;
+ const bFiltered = bClassification.filteredMatches;
- // Check if they have 100% matches
- const aHas100 = aFiltered.some(m => m.confidence === 100);
- const bHas100 = bFiltered.some(m => m.confidence === 100);
+ const aHas100 = aClassification.perfectMatches.length > 0;
+ const bHas100 = bClassification.perfectMatches.length > 0;
// If one has 100% and the other doesn't, prioritize the one with 100%
if (aHas100 && !bHas100) return -1;
@@ -2612,11 +2613,7 @@ export const missingBrowserMethods = {
renderMissingModel(missing, missingIndex = 0) {
const allMatches = missing.matches || [];
- // Filter out matches below 70% confidence threshold
- const filteredMatches = allMatches.filter(m => m.confidence >= 70);
-
- // Calculate 100% matches upfront (needed for download section)
- const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
+ const { perfectMatches } = classifyLocalMatches(allMatches);
const missingFilename = this.getMissingFilename(missing);