Files
ComfyUI_frontend/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
Dante 858946b0f5 fix: use getAssetFilename in ModelInfoPanel filename field (#10836)
# Summary

* Model Info sidebar panel displays `asset.name` (registry name) instead
of the actual filename from `user_metadata.filename`
* Other UI components (asset cards, widgets, missing model scan)
correctly use `getAssetFilename()` which prefers
`user_metadata.filename` over `asset.name`
* One-line template fix: `{{ asset.name }}` → `{{
getAssetFilename(asset) }}`
* Fixes #10598 

# Bug

`ModelInfoPanel.vue:35` used raw `asset.name` for the "File Name" field.
When `user_metadata.filename` differs from `asset.name` (e.g. registry
name vs actual path like `checkpoints/v1-5-pruned.safetensors`), users
see inconsistent filenames across the UI.

# AS-IS / TO-BE

<img width="800" height="600" alt="before-after-10598"
src="https://github.com/user-attachments/assets/15beb6c8-4bad-4ed2-9c85-6f8c7c0b6d3e"
/>


| | File Name field shows |
| :--- | :--- |
| **AS-IS** (bug) | `sdxl-lightning-4step` — raw `asset.name` (registry
display name) |
| **TO-BE** (fix) | `checkpoints/sdxl_lightning_4step.safetensors` —
`getAssetFilename(asset)` (actual file path) |

# Red-Green Verification

| Commit | CI Status | Purpose |
| :--- | :--- | :--- |
| `test: add failing test for ModelInfoPanel showing wrong filename` | 🔴
Red | Proves the test catches the bug |
| `fix: use getAssetFilename in ModelInfoPanel filename field` | 🟢 Green
| Proves the fix resolves the bug |

# Test Plan

- [x] CI red on test-only commit
- [x] CI green on fix commit
- [x] Unit test: `prefers user_metadata.filename over asset.name for
filename field`
- [ ] Manual: open Asset Browser → click a model → verify File Name in
Model Info panel matches the actual file path (requires
`--enable-assets`)
2026-04-07 21:26:47 +09:00

354 lines
12 KiB
Vue

<template>
<div
data-component-id="ModelInfoPanel"
class="flex scrollbar-custom h-full flex-col"
>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="font-inter text-xs uppercase select-none">
{{ t('assetBrowser.modelInfo.basicInfo') }}
</span>
</template>
<ModelInfoField :label="t('assetBrowser.modelInfo.displayName')">
<div class="group flex justify-between">
<EditableText
:model-value="displayName"
:is-editing="isEditingDisplayName"
:class="cn('flex-auto break-all text-muted-foreground')"
@dblclick="isEditingDisplayName = !isImmutable"
@edit="handleDisplayNameEdit"
@cancel="isEditingDisplayName = false"
/>
<Button
v-if="!isImmutable && !isEditingDisplayName"
size="icon-sm"
variant="muted-textonly"
class="opacity-0 transition-opacity group-hover:opacity-100"
:aria-label="t('assetBrowser.modelInfo.editDisplayName')"
@click="isEditingDisplayName = !isImmutable"
>
<i class="icon-[lucide--square-pen] size-4 self-center" />
</Button>
</div>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.fileName')">
<span class="break-all text-muted-foreground">{{
getAssetFilename(asset)
}}</span>
</ModelInfoField>
<ModelInfoField
v-if="sourceUrl"
:label="t('assetBrowser.modelInfo.source')"
>
<a
:href="sourceUrl"
target="_blank"
rel="noopener noreferrer"
class="hover:text-foreground inline-flex items-center gap-1.5 text-muted-foreground no-underline transition-colors"
>
<img
v-if="sourceName === 'Civitai'"
src="/assets/images/civitai.svg"
alt=""
class="size-4 shrink-0"
/>
<img
v-else-if="sourceName === 'Hugging Face'"
src="/assets/images/hf-logo.svg"
alt=""
class="size-4 shrink-0"
/>
{{ t('assetBrowser.modelInfo.viewOnSource', { source: sourceName }) }}
<i class="icon-[lucide--external-link] size-4 shrink-0" />
</a>
</ModelInfoField>
</PropertiesAccordionItem>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="font-inter text-xs uppercase select-none">
{{ t('assetBrowser.modelInfo.modelTagging') }}
</span>
</template>
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
<Select v-if="!isImmutable" v-model="selectedModelType">
<SelectTrigger class="w-full">
<SelectValue
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in modelTypes"
:key="option.value"
:value="option.value"
>
{{ option.name }}
</SelectItem>
</SelectContent>
</Select>
<div v-else class="p-2 text-sm text-muted-foreground">
{{
modelTypes.find((o) => o.value === selectedModelType)?.name ??
t('assetBrowser.unknown')
}}
</div>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.compatibleBaseModels')">
<TagsInput
v-slot="{ isEmpty }"
v-model="baseModels"
:disabled="isImmutable"
>
<TagsInputItem
v-for="model in baseModels"
:key="model"
:value="model"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:is-empty="isEmpty"
:placeholder="
isImmutable
? t('assetBrowser.modelInfo.baseModelUnknown')
: t('assetBrowser.modelInfo.addBaseModel')
"
/>
</TagsInput>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.additionalTags')">
<TagsInput
v-slot="{ isEmpty }"
v-model="additionalTags"
:disabled="isImmutable"
>
<TagsInputItem v-for="tag in additionalTags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:is-empty="isEmpty"
:placeholder="
isImmutable
? t('assetBrowser.modelInfo.noAdditionalTags')
: t('assetBrowser.modelInfo.addTag')
"
/>
</TagsInput>
</ModelInfoField>
</PropertiesAccordionItem>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="font-inter text-xs uppercase select-none">
{{ t('assetBrowser.modelInfo.modelDescription') }}
</span>
</template>
<ModelInfoField
v-if="triggerPhrases.length > 0"
:label="t('assetBrowser.modelInfo.triggerPhrases')"
>
<template #label-action>
<Button
variant="muted-textonly"
size="icon-sm"
:title="t('g.copyAll')"
:aria-label="t('g.copyAll')"
class="p-0"
@click="copyToClipboard(triggerPhrases.join(', '))"
>
<i class="icon-[lucide--copy] size-4 min-h-4 min-w-4 opacity-60" />
</Button>
</template>
<div class="flex flex-wrap gap-1 pt-1">
<Button
v-for="phrase in triggerPhrases"
:key="phrase"
variant="muted-textonly"
size="unset"
:title="t('g.copyToClipboard')"
class="text-left text-xs text-pretty whitespace-normal"
@click="copyToClipboard(phrase)"
>
{{ phrase }}
<i class="icon-[lucide--copy] size-4 min-h-4 min-w-4 opacity-60" />
</Button>
</div>
</ModelInfoField>
<ModelInfoField
v-if="description"
:label="t('assetBrowser.modelInfo.description')"
>
<p class="text-sm whitespace-pre-wrap">{{ description }}</p>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.description')">
<textarea
ref="descriptionTextarea"
v-model="userDescription"
:disabled="isImmutable"
:placeholder="
isImmutable
? t('assetBrowser.modelInfo.descriptionNotSet')
: t('assetBrowser.modelInfo.descriptionPlaceholder')
"
rows="3"
:class="
cn(
'w-full resize-y rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground transition-colors outline-none focus:bg-component-node-widget-background',
isImmutable && 'cursor-not-allowed'
)
"
@keydown.escape.stop="descriptionTextarea?.blur()"
/>
</ModelInfoField>
</PropertiesAccordionItem>
</div>
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.vue'
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetUserMetadata } from '@/platform/assets/schemas/assetSchema'
import {
getAssetAdditionalTags,
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetsStore } from '@/stores/assetsStore'
import { cn } from '@/utils/tailwindUtil'
import ModelInfoField from './ModelInfoField.vue'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const descriptionTextarea = useTemplateRef<HTMLTextAreaElement>(
'descriptionTextarea'
)
const accordionClass = cn(
'border-t border-border-default bg-modal-panel-background'
)
const { asset, cacheKey } = defineProps<{
asset: AssetDisplayItem
cacheKey?: string
}>()
const assetsStore = useAssetsStore()
const { modelTypes } = useModelTypes()
const pendingUpdates = ref<AssetUserMetadata>({})
const pendingModelType = ref<string | undefined>(undefined)
const isEditingDisplayName = ref(false)
const isImmutable = computed(() => asset.is_immutable ?? true)
const displayName = computed(
() => pendingUpdates.value.name ?? getAssetDisplayName(asset)
)
const sourceUrl = computed(() => getAssetSourceUrl(asset))
const sourceName = computed(() =>
sourceUrl.value ? getSourceName(sourceUrl.value) : ''
)
const description = computed(() => getAssetDescription(asset))
const triggerPhrases = computed(() => getAssetTriggerPhrases(asset))
watch(
() => asset.user_metadata,
() => {
pendingUpdates.value = {}
}
)
watch(
() => asset.tags,
() => {
pendingModelType.value = undefined
}
)
const debouncedFlushMetadata = useDebounceFn(() => {
if (isImmutable.value) return
assetsStore.updateAssetMetadata(
asset,
{ ...(asset.user_metadata ?? {}), ...pendingUpdates.value },
cacheKey
)
}, 500)
function queueMetadataUpdate(updates: AssetUserMetadata) {
pendingUpdates.value = { ...pendingUpdates.value, ...updates }
debouncedFlushMetadata()
}
function handleDisplayNameEdit(newName: string) {
isEditingDisplayName.value = false
if (newName && newName !== displayName.value) {
queueMetadataUpdate({ name: newName })
}
}
const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
if (isImmutable.value) return
const currentModelType = getAssetModelType(asset)
if (currentModelType === newModelType) return
const newTags = asset.tags
.filter((tag) => tag !== currentModelType)
.concat(newModelType)
assetsStore.updateAssetTags(asset, newTags, cacheKey)
}, 500)
const baseModels = computed({
get: () => pendingUpdates.value.base_model ?? getAssetBaseModels(asset),
set: (value: string[]) => queueMetadataUpdate({ base_model: value })
})
const additionalTags = computed({
get: () =>
pendingUpdates.value.additional_tags ?? getAssetAdditionalTags(asset),
set: (value: string[]) => queueMetadataUpdate({ additional_tags: value })
})
const userDescription = computed({
get: () =>
pendingUpdates.value.user_description ?? getAssetUserDescription(asset),
set: (value: string) => queueMetadataUpdate({ user_description: value })
})
const selectedModelType = computed({
get: () => pendingModelType.value ?? getAssetModelType(asset) ?? undefined,
set: (value: string | undefined) => {
if (!value) return
pendingModelType.value = value
debouncedSaveModelType(value)
}
})
</script>