completion: add local model browser with category-aware filtering and download target synchronization

This commit is contained in:
Dariusz L
2026-06-24 22:30:31 +02:00
parent 46b1e155b6
commit f4f07ccb0a
3 changed files with 403 additions and 83 deletions
+43
View File
@@ -2569,6 +2569,49 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.mr-combo-list.mr-local-model-browser {
box-sizing: border-box;
left: auto;
right: auto;
width: min(760px, calc(100vw - 48px));
max-height: none;
overflow: hidden;
padding: 0;
z-index: var(--mr-z-context-menu);
}
.mr-local-model-browser-scroll {
box-sizing: border-box;
overflow: auto;
padding: 6px;
}
.mr-local-model-browser .mr-folder-browser-root.is-preferred {
border-color: rgba(114,211,138,0.18);
background: rgba(114,211,138,0.035);
}
.mr-local-model-browser .mr-folder-browser-root-title {
font-size: 11px;
}
.mr-local-model-row {
min-height: 30px;
}
.mr-local-model-file-icon {
color: rgba(120,170,255,0.95);
}
.mr-local-model-folder-tag {
min-width: 0;
max-width: 42%;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255,255,255,0.045);
color: var(--mr-text-muted);
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mr-local-model-folder-row {
font-weight: 700;
}
/* Status Messages */
.mr-status {
@@ -2369,6 +2369,10 @@ export const downloadTargetMethods = {
this.syncDownloadTargetFolderContext(categoryEl, subfolderEl);
refreshCategoryTooltip();
this.applySuggestedDownloadSubfolder(missing, categoryEl, subfolderEl);
categoryEl.dispatchEvent(new CustomEvent('mr-download-category-change', {
bubbles: true,
detail: { category: value }
}));
}, categoryEl);
};
@@ -2414,6 +2418,12 @@ export const downloadTargetMethods = {
});
this.syncDownloadTargetFolderContext(categoryEl, subfolderEl);
refreshCategoryTooltip();
if (category !== previousCategory) {
categoryEl.dispatchEvent(new CustomEvent('mr-download-category-change', {
bubbles: true,
detail: { category }
}));
}
populateCategoryOptions(typed);
});
categoryEl.addEventListener('keydown', (event) => {
+350 -83
View File
@@ -735,88 +735,350 @@ export const missingBrowserMethods = {
const comboInput = container.querySelector(`#combo-input-${comboId}`);
const comboList = container.querySelector(`#combo-list-${comboId}`);
const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`);
const comboSection = comboInput?.closest('.mr-combo-section')
|| comboList?.closest('.mr-combo-section')
|| comboRefresh?.closest('.mr-combo-section');
if (comboList) {
this.enableWheelScrollChaining(comboList);
}
const getAllModels = () => Array.isArray(this.allModels) ? this.allModels : [];
const buildLabel = (m) => `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`;
const getFolder = (m) => m.path || m.base_directory || '';
let comboListPointerActive = false;
let outsideComboPointerHandler = null;
const isInsideCombo = (target) => (
Boolean(target) && (
Boolean(comboSection?.contains(target))
|| Boolean(comboInput?.contains(target))
|| Boolean(comboList?.contains(target))
|| Boolean(comboRefresh?.contains(target))
)
);
const stopOutsideComboPointerListener = () => {
if (!outsideComboPointerHandler) return;
window.removeEventListener('pointerdown', outsideComboPointerHandler, true);
outsideComboPointerHandler = null;
const normalizeModelPath = (value = '') => String(value || '')
.replace(/\//g, '\\')
.split('\\')
.map(part => part.trim())
.filter(Boolean)
.join('\\');
const getModelCategory = (model = {}) => this.normalizeDownloadCategory(model.category || 'unknown') || 'unknown';
const getDownloadCategoryElement = () => container.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`)
|| this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`);
const getPreferredModelCategory = () => {
const categoryEl = getDownloadCategoryElement();
const selectedCategory = this.normalizeDownloadCategory(this.getDropdownValue?.(categoryEl) || categoryEl?.value || '');
const savedCategory = this.normalizeDownloadCategory(this.getSavedDownloadTargetSelection?.(missing)?.category || '');
const inferredCategory = this.normalizeDownloadCategory(
missing.category || this.getMissingDownloadCategory?.(missing, 'unknown') || 'unknown'
);
return selectedCategory || savedCategory || inferredCategory || 'unknown';
};
const startOutsideComboPointerListener = () => {
if (outsideComboPointerHandler) return;
outsideComboPointerHandler = (event) => {
if (!comboList?.isConnected) {
stopOutsideComboPointerListener();
const getLocalModelExpandedSet = (preferredCategory = getPreferredModelCategory()) => {
if (!this.localModelBrowserExpandedGroups) {
this.localModelBrowserExpandedGroups = new Map();
}
const key = preferredCategory || 'all';
if (!this.localModelBrowserExpandedGroups.has(key)) {
this.localModelBrowserExpandedGroups.set(key, new Set());
}
return this.localModelBrowserExpandedGroups.get(key);
};
const getCurrentQueuedModelIdentity = () => {
const key = this.getMissingModelKey(missing);
const idx = this.pendingIndex?.get?.(key);
const selection = Number.isInteger(idx) ? this.pendingResolutions?.[idx] : null;
const model = selection?.resolved_model || {};
return [
model.path || selection?.resolved_path || '',
model.relative_path || '',
model.filename || '',
model.category || selection?.category || ''
].map(value => String(value || '').trim().toLowerCase()).join('::');
};
const getModelIdentity = (model = {}) => [
model.path || '',
model.relative_path || '',
model.filename || '',
model.category || ''
].map(value => String(value || '').trim().toLowerCase()).join('::');
const getBaseDirectoryLabel = (baseDirectory = '') => {
const clean = String(baseDirectory || '').replace(/[\\\/]+$/, '');
if (!clean) return 'Default root';
return clean.split(/[\\\/]+/).filter(Boolean).pop() || clean;
};
const makeModelEntry = (model, index, preferredCategory = getPreferredModelCategory()) => {
const category = getModelCategory(model);
const categoryLabel = this.getCategoryDisplayName(category);
const relativePath = normalizeModelPath(model.relative_path || model.filename || '');
const pathParts = relativePath.split('\\').filter(Boolean);
const filename = pathParts.pop() || model.filename || relativePath || 'model';
const folderPath = pathParts.join('\\');
const baseDirectory = String(model.base_directory || '');
const fullPath = String(model.path || '');
return {
index,
model,
category,
categoryLabel,
relativePath: relativePath || filename,
filename,
folderPath,
baseDirectory,
baseLabel: getBaseDirectoryLabel(baseDirectory),
fullPath,
isPreferred: category === preferredCategory,
searchText: [
category,
categoryLabel,
relativePath,
filename,
folderPath,
baseDirectory,
fullPath
].join(' ').toLowerCase()
};
};
const clampNumber = (value, min, max) => Math.min(Math.max(value, min), max);
const cleanupComboPositioning = () => {
if (typeof comboList?._mlFloatingPositionCleanup === 'function') {
comboList._mlFloatingPositionCleanup();
comboList._mlFloatingPositionCleanup = null;
}
};
const positionComboList = () => {
if (!comboList || !comboInput || comboList.style.display === 'none') return;
const rect = comboInput.getBoundingClientRect();
const viewportPadding = 12;
const viewportWidth = Math.max(320, window.innerWidth || document.documentElement.clientWidth || 0);
const viewportHeight = Math.max(240, window.innerHeight || document.documentElement.clientHeight || 0);
const availableWidth = Math.max(280, viewportWidth - viewportPadding * 2);
const targetWidth = Math.min(760, availableWidth, Math.max(rect.width, 520));
const left = clampNumber(
rect.left,
viewportPadding,
Math.max(viewportPadding, viewportWidth - targetWidth - viewportPadding)
);
const gap = 6;
const spaceBelow = viewportHeight - rect.bottom - viewportPadding - gap;
const spaceAbove = rect.top - viewportPadding - gap;
const openAbove = spaceBelow < 280 && spaceAbove > spaceBelow;
const availableHeight = Math.max(180, openAbove ? spaceAbove : spaceBelow);
comboList.style.position = 'fixed';
comboList.style.left = `${Math.round(left)}px`;
comboList.style.right = 'auto';
comboList.style.width = `${Math.round(targetWidth)}px`;
comboList.style.maxWidth = `${Math.round(availableWidth)}px`;
comboList.style.maxHeight = `${Math.round(availableHeight)}px`;
const scrollEl = comboList.querySelector('.mr-local-model-browser-scroll');
if (scrollEl) {
const chromeHeight = Array.from(comboList.children).reduce((height, child) => {
if (child === scrollEl) return height;
const style = window.getComputedStyle(child);
return height
+ child.offsetHeight
+ (Number.parseFloat(style.marginTop) || 0)
+ (Number.parseFloat(style.marginBottom) || 0);
}, 0);
scrollEl.style.maxHeight = `${Math.max(120, availableHeight - chromeHeight)}px`;
}
const popupHeight = Math.min(comboList.offsetHeight || availableHeight, availableHeight);
const top = openAbove
? clampNumber(rect.top - popupHeight - gap, viewportPadding, Math.max(viewportPadding, viewportHeight - viewportPadding - popupHeight))
: clampNumber(rect.bottom + gap, viewportPadding, Math.max(viewportPadding, viewportHeight - viewportPadding - popupHeight));
comboList.style.top = `${Math.round(top)}px`;
};
const bindComboPositioning = () => {
cleanupComboPositioning();
const updatePosition = (event) => {
if (event?.type === 'scroll' && event.target instanceof Node && comboList?.contains(event.target)) {
return;
}
if (isInsideCombo(event.target)) {
return;
}
hideComboList();
positionComboList();
};
window.addEventListener('resize', updatePosition, true);
window.addEventListener('scroll', updatePosition, true);
comboList._mlFloatingPositionCleanup = () => {
window.removeEventListener('resize', updatePosition, true);
window.removeEventListener('scroll', updatePosition, true);
};
window.addEventListener('pointerdown', outsideComboPointerHandler, true);
};
const showComboList = () => {
if (!comboList) return;
comboList.classList.remove('mr-is-hidden');
comboList.classList.add('mr-is-visible');
startOutsideComboPointerListener();
document.querySelectorAll('.mr-combo-list[data-ml-floating-portal="true"]').forEach(existing => {
if (existing !== comboList && existing.id === comboList.id) {
existing.remove();
}
});
if (comboList.dataset.mlFloatingPortal !== 'true') {
comboList.dataset.mlFloatingPortal = 'true';
document.body.appendChild(comboList);
}
comboList.classList.add('mr-local-model-browser', 'mr-download-target-floating');
comboList.style.display = 'block';
positionComboList();
bindComboPositioning();
requestAnimationFrame(positionComboList);
};
const hideComboList = () => {
if (!comboList) return;
comboList.classList.remove('mr-is-visible');
comboList.classList.add('mr-is-hidden');
stopOutsideComboPointerListener();
cleanupComboPositioning();
comboList.style.display = 'none';
};
const populateComboOptions = (filterText, highlightIdx = -1) => {
if (comboList) {
this.bindDropdownOutsideDismiss?.(comboList, [comboInput, comboRefresh], hideComboList);
this.enableWheelScrollChaining(comboList);
}
const buildModelTree = (entries = []) => {
const root = { folders: new Map(), models: [] };
entries.forEach(entry => {
const folderParts = entry.folderPath.split('\\').filter(Boolean);
let current = root;
let currentPath = '';
folderParts.forEach(part => {
currentPath = currentPath ? `${currentPath}\\${part}` : part;
if (!current.folders.has(part)) {
current.folders.set(part, {
name: part,
path: currentPath,
folders: new Map(),
models: []
});
}
current = current.folders.get(part);
});
current.models.push(entry);
});
return root;
};
const countTreeModels = (node) => {
let count = node.models?.length || 0;
node.folders?.forEach(child => {
count += countTreeModels(child);
});
return count;
};
const renderModelRows = (entries = [], selectedIdentity = '') => entries
.sort((a, b) => a.filename.localeCompare(b.filename))
.map(entry => {
const selected = getModelIdentity(entry.model) === selectedIdentity;
const folderText = entry.folderPath || entry.baseLabel || '';
return `
<div class="mr-folder-browser-row mr-local-model-row ${selected ? 'is-selected' : ''}" data-browser-action="select-model" data-model-index="${entry.index}" data-tooltip="${this.escapeHtml(entry.fullPath || entry.relativePath)}">
<span class="mr-folder-browser-toggle mr-folder-browser-toggle-empty"></span>
<span class="mr-folder-browser-folder-icon mr-local-model-file-icon">${getSvgIcon('file', 'currentColor', 'mr-folder-browser-svg')}</span>
<span class="mr-folder-browser-name">${this.escapeHtml(entry.filename)}</span>
${folderText ? `<span class="mr-local-model-folder-tag">${this.escapeHtml(folderText)}</span>` : ''}
</div>
`;
})
.join('');
const renderModelTreeNodes = (node, groupKey, expandedSet, filter, selectedIdentity) => {
const folderHtml = Array.from(node.folders.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map(folder => {
const stateKey = `node:${groupKey}:${folder.path.toLowerCase()}`;
const modelCount = countTreeModels(folder);
const shouldExpand = Boolean(filter) || expandedSet.has(stateKey);
return `
<div class="mr-folder-browser-node">
<div class="mr-folder-browser-row is-expandable mr-local-model-folder-row" data-browser-action="toggle" data-state-key="${encodeURIComponent(stateKey)}">
<button class="mr-folder-browser-toggle ${shouldExpand ? 'is-expanded' : ''}" type="button" aria-label="${shouldExpand ? 'Collapse folder' : 'Expand folder'}"><span class="mr-folder-browser-chevron"></span></button>
<span class="mr-folder-browser-folder-icon">${getSvgIcon('folderOpen', 'currentColor', 'mr-folder-browser-svg')}</span>
<span class="mr-folder-browser-name">${this.escapeHtml(folder.name)}</span>
<span class="mr-folder-browser-count">${modelCount}</span>
</div>
<div class="mr-folder-browser-children ${shouldExpand ? 'is-expanded' : ''}">
${renderModelTreeNodes(folder, groupKey, expandedSet, filter, selectedIdentity)}
</div>
</div>
`;
})
.join('');
return `${renderModelRows(node.models || [], selectedIdentity)}${folderHtml}`;
};
const populateComboOptions = (filterText, options = {}) => {
if (!comboList) return;
const previousScrollEl = comboList.querySelector('.mr-local-model-browser-scroll');
const previousScrollTop = options.preserveScroll && previousScrollEl ? previousScrollEl.scrollTop : 0;
const allModels = getAllModels();
const f = (filterText || '').toLowerCase();
const filtered = f
? allModels.filter(m => buildLabel(m).toLowerCase().includes(f))
: allModels.slice();
let html = '';
for (let i = 0; i < filtered.length; i++) {
const m = filtered[i];
const label = buildLabel(m);
const folder = getFolder(m);
const isHighlighted = i === highlightIdx;
const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : '';
html += `<div data-idx="${allModels.indexOf(m)}" class="mr-combo-option ${isHighlighted ? 'is-highlighted' : ''}">`;
html += `<div class="mr-combo-option-row">`;
html += `<code>${this.escapeHtml(label)}</code>`;
html += `</div>`;
if (folderDisplay) {
html += `<div class="mr-combo-folder" data-tooltip="${this.escapeHtml(folderDisplay)}">Folder ${this.escapeHtml(folderDisplay)}</div>`;
const activePreferredCategory = getPreferredModelCategory();
const allEntries = allModels.map((model, index) => makeModelEntry(model, index, activePreferredCategory));
const rawFilter = String(filterText || '').trim();
const filter = rawFilter.toLowerCase();
const tokens = filter.split(/\s+/).filter(Boolean);
const filteredEntries = allEntries.filter(entry => {
if (!tokens.length) return true;
return tokens.every(token => entry.searchText.includes(token));
});
const grouped = new Map();
filteredEntries.forEach(entry => {
const groupKey = `${entry.category}::${entry.baseDirectory || ''}`;
if (!grouped.has(groupKey)) {
grouped.set(groupKey, {
key: groupKey,
category: entry.category,
label: entry.categoryLabel,
baseDirectory: entry.baseDirectory,
baseLabel: entry.baseLabel,
isPreferred: entry.category === activePreferredCategory,
entries: []
});
}
html += `</div>`;
}
comboList.innerHTML = html;
grouped.get(groupKey).entries.push(entry);
});
const expandedSet = getLocalModelExpandedSet(activePreferredCategory);
const selectedIdentity = getCurrentQueuedModelIdentity();
const groupsHtml = Array.from(grouped.values())
.sort((a, b) => {
if (a.isPreferred !== b.isPreferred) return a.isPreferred ? -1 : 1;
const labelCompare = a.label.localeCompare(b.label);
if (labelCompare !== 0) return labelCompare;
return (a.baseLabel || '').localeCompare(b.baseLabel || '');
})
.map(group => {
const stateKey = `root:${group.key.toLowerCase()}`;
const collapsedStateKey = `collapsed:${stateKey}`;
const isExpanded = Boolean(filter)
|| expandedSet.has(stateKey)
|| (group.isPreferred && !expandedSet.has(collapsedStateKey));
const tree = buildModelTree(group.entries);
return `
<div class="mr-folder-browser-root ${group.isPreferred ? 'is-preferred' : ''}">
<button class="mr-folder-browser-root-head ${isExpanded ? 'is-expanded' : ''}" type="button" data-browser-action="toggle" data-state-key="${encodeURIComponent(stateKey)}">
<span class="mr-folder-browser-chevron"></span>
<span class="mr-folder-browser-root-title">${this.escapeHtml(group.label)}${group.isPreferred ? ' · recommended' : ''}</span>
<span class="mr-folder-browser-root-count">${group.entries.length}</span>
</button>
${group.baseDirectory ? `<div class="mr-folder-browser-root-path">${this.escapeHtml(group.baseDirectory)}</div>` : ''}
<div class="mr-folder-browser-tree ${isExpanded ? 'is-expanded' : ''}">
${renderModelTreeNodes(tree, group.key, expandedSet, filter, selectedIdentity)}
</div>
</div>
`;
})
.join('');
const emptyHtml = tokens.length
? `<div class="mr-folder-browser-empty">No local models match this filter.</div>`
: `<div class="mr-folder-browser-empty">No local models in the enabled categories.</div>`;
comboList.querySelectorAll('div[data-idx]').forEach(el => {
comboList.innerHTML = `
<div class="mr-local-model-browser-scroll">
${groupsHtml || emptyHtml}
</div>
`;
comboList.querySelectorAll('[data-browser-action="toggle"]').forEach(row => {
row.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const scrollElBefore = comboList.querySelector('.mr-local-model-browser-scroll');
const scrollTop = scrollElBefore ? scrollElBefore.scrollTop : 0;
const stateKey = decodeURIComponent(row.dataset.stateKey || '');
if (!stateKey) return;
const collapsedStateKey = `collapsed:${stateKey}`;
const isExpanded = row.classList.contains('is-expanded')
|| Boolean(row.querySelector('.mr-folder-browser-toggle.is-expanded'));
if (isExpanded) {
expandedSet.delete(stateKey);
expandedSet.add(collapsedStateKey);
} else {
expandedSet.add(stateKey);
expandedSet.delete(collapsedStateKey);
}
populateComboOptions(comboInput?.value || '', { preserveScroll: true });
const scrollEl = comboList.querySelector('.mr-local-model-browser-scroll');
if (scrollEl) scrollEl.scrollTop = scrollTop;
positionComboList();
});
});
comboList.querySelectorAll('[data-browser-action="select-model"]').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.idx, 10);
const idx = parseInt(el.dataset.modelIndex, 10);
if (!Number.isNaN(idx) && idx >= 0 && idx < allModels.length) {
const chosenModel = allModels[idx];
if (chosenModel) {
@@ -826,19 +1088,15 @@ export const missingBrowserMethods = {
}
});
});
const scrollEl = comboList.querySelector('.mr-local-model-browser-scroll');
if (scrollEl) {
this.enableWheelScrollChaining(scrollEl);
scrollEl.scrollTop = previousScrollTop;
}
positionComboList();
};
if (comboList) {
comboList.addEventListener('pointerdown', () => {
comboListPointerActive = true;
const releaseComboPointer = () => {
setTimeout(() => {
comboListPointerActive = false;
}, 0);
};
window.addEventListener('pointerup', releaseComboPointer, { once: true, capture: true });
window.addEventListener('pointercancel', releaseComboPointer, { once: true, capture: true });
}, { capture: true });
populateComboOptions('');
}
@@ -851,15 +1109,24 @@ export const missingBrowserMethods = {
showComboList();
populateComboOptions(comboInput.value);
});
comboInput.addEventListener('blur', () => {
setTimeout(() => {
if (!comboList) return;
if (comboListPointerActive || comboList.matches(':hover')) {
return;
}
hideComboList();
}, 200);
});
}
const categoryPriorityEl = getDownloadCategoryElement();
if (categoryPriorityEl && categoryPriorityEl.dataset.mlLocalModelPriorityBound !== 'true') {
categoryPriorityEl.dataset.mlLocalModelPriorityBound = 'true';
let lastPriorityCategory = getPreferredModelCategory();
const refreshLocalModelPriority = () => {
const nextPriorityCategory = getPreferredModelCategory();
if (nextPriorityCategory === lastPriorityCategory && comboList?.style.display === 'none') {
return;
}
lastPriorityCategory = nextPriorityCategory;
populateComboOptions(comboInput?.value || '', { preserveScroll: true });
};
categoryPriorityEl.addEventListener('input', refreshLocalModelPriority);
categoryPriorityEl.addEventListener('change', refreshLocalModelPriority);
categoryPriorityEl.addEventListener('blur', () => setTimeout(refreshLocalModelPriority, 0));
categoryPriorityEl.addEventListener('mr-download-category-change', refreshLocalModelPriority);
}
if (comboRefresh) {