Files
ComfyUI-Lora-Manager/static/js/components/shared/utils.js
T
Will Miao adeb40bfff fix(links): let CivitAI and HuggingFace links coexist (#1094)
A model could have CivitAI metadata and a HuggingFace link at the same time,
but only one of the two "View on ..." entries ever rendered, because both the
model modal and the card globe asked the `from_civitai` provenance flag which
source to show. `set_hf_url` wrote `false` and a CivitAI refresh wrote `true`,
so whichever ran last erased the other: linking HF hid "View on CivitAI" even
though the civitai payload was still in the sidecar, and (on the card) a later
refresh pointed the single globe icon back at CivitAI, hiding the HF entry.

Decide the links from the data itself instead:

- `set_hf_url` no longer touches `from_civitai`; it records where the metadata
  came from, and HF provenance is already tracked by `hf_url`.
- Add `hasCivitaiSource(civitai)` in the shared card/modal utils and gate the
  modal's CivitAI link, the card globe (title, enabled state, click target,
  new `data-has_civitai`) and the context-menu `civitai` action on actual
  CivitAI data (`modelId` / `model_id` / `id`). A dual-source model now shows
  both links, and a CivitAI-only model with no `hf_url` stays as before.
- Agent HF enrichment (`PostProcessor.is_hf_model`) keyed off
  `not from_civitai`, which stopped being a synonym for "has an HF source" once
  both sources can coexist (and already broke after a CivitAI refresh flipped
  the flag back to true). Key it off `hf_url` directly; the post-processor
  tests move to that discriminator and gain a dual-source case.

Regression tests: the set-hf-url handler preserves civitai + `from_civitai`
and no longer forces the flag false, the modal renders both links (including
with `from_civitai: false`), and the card globe targets/opens the right source
and is disabled when neither is available.

Backend: 2749 passed. Frontend: 1098 JS + 91 Vue tests passed.
2026-09-13 21:12:54 +08:00

116 lines
3.9 KiB
JavaScript

/**
* utils.js
* Helper functions for the Model Modal component - General version
*/
export function escapeHtml(value = '') {
if (value === null || value === undefined) return '';
return String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function escapeAttribute(value = '') {
return escapeHtml(value);
}
/**
* Format file size
* @param {number} bytes - Number of bytes
* @returns {string} Formatted file size
*/
export function formatFileSize(bytes) {
if (!bytes) return 'N/A';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* Whether a model has usable CivitAI metadata to link to.
*
* CivitAI links must be gated on the presence of actual CivitAI data rather
* than the `from_civitai` provenance flag: linking a model to HuggingFace used
* to flip `from_civitai` to false, which hid the CivitAI link even though the
* model still had CivitAI metadata. See issue #1094.
*
* @param {Object} [civitaiData] - The model's `civitai` payload
* @returns {boolean} True when a CivitAI model/version id is available
*/
export function hasCivitaiSource(civitaiData) {
if (!civitaiData || typeof civitaiData !== 'object') return false;
return Boolean(
civitaiData.modelId ?? civitaiData.model_id ?? civitaiData.id
);
}
/**
* Render compact tags
* @param {Array} tags - Array of tags
* @param {string} filePath - File path for the edit button
* @returns {string} HTML content
*/
export function renderCompactTags(tags, filePath = '') {
// Remove the early return and always render the container
const tagsList = tags || [];
const safeFilePath = escapeAttribute(filePath || '');
// Display up to 5 tags, with a tooltip indicator if there are more
const visibleTags = tagsList.slice(0, 5);
const remainingCount = Math.max(0, tagsList.length - 5);
return `
<div class="model-tags-container">
<div class="model-tags-header">
<div class="model-tags-compact">
${visibleTags.map(tag => `<span class="model-tag-compact">${escapeHtml(tag)}</span>`).join('')}
${remainingCount > 0 ?
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
''}
${tagsList.length === 0 ? `<span class="model-tag-empty">No tags</span>` : ''}
</div>
<button class="edit-tags-btn" data-file-path="${safeFilePath}" title="Edit tags">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${tagsList.length > 0 ?
`<div class="model-tags-tooltip">
<div class="tooltip-content">
${tagsList.map(tag => `<span class="tooltip-tag">${escapeHtml(tag)}</span>`).join('')}
</div>
</div>` :
''}
</div>
`;
}
/**
* Set up tag tooltip functionality
* @param {Element} [scopeContainer] - Optional container to scope the querySelector
*/
export function setupTagTooltip(scopeContainer = null) {
const root = scopeContainer || document;
const tagsContainer = root.querySelector('.model-tags-container');
const tooltip = root.querySelector('.model-tags-tooltip');
if (tagsContainer && tooltip) {
tagsContainer.addEventListener('mouseenter', () => {
tooltip.classList.add('visible');
});
tagsContainer.addEventListener('mouseleave', () => {
tooltip.classList.remove('visible');
});
}
}