mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 05:32:02 +00:00
feat(assets): add ModelInfoPanel for asset browser right panel
Amp-Thread-ID: https://ampcode.com/threads/T-019bc42f-b9b7-71de-9d8f-6584610ab21e Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -2385,6 +2385,21 @@
|
|||||||
"assetCard": "{name} - {type} asset",
|
"assetCard": "{name} - {type} asset",
|
||||||
"loadingAsset": "Loading asset"
|
"loadingAsset": "Loading asset"
|
||||||
},
|
},
|
||||||
|
"modelInfo": {
|
||||||
|
"title": "Model Info",
|
||||||
|
"basicInfo": "Basic Info",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"fileName": "File Name",
|
||||||
|
"source": "Source",
|
||||||
|
"viewOnSource": "View on {source}",
|
||||||
|
"modelTagging": "Model Tagging",
|
||||||
|
"modelType": "Model Type",
|
||||||
|
"compatibleBaseModels": "Compatible Base Models",
|
||||||
|
"additionalTags": "Additional Tags",
|
||||||
|
"modelDescription": "Model Description",
|
||||||
|
"triggerPhrases": "Trigger Phrases",
|
||||||
|
"description": "Description"
|
||||||
|
},
|
||||||
"media": {
|
"media": {
|
||||||
"threeDModelPlaceholder": "3D Model",
|
"threeDModelPlaceholder": "3D Model",
|
||||||
"audioPlaceholder": "Audio"
|
"audioPlaceholder": "Audio"
|
||||||
|
|||||||
@@ -60,12 +60,16 @@
|
|||||||
@asset-deleted="refreshAssets"
|
@asset-deleted="refreshAssets"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-if="selectedAsset" #rightPanel>
|
||||||
|
<ModelInfoPanel :asset="selectedAsset" />
|
||||||
|
</template>
|
||||||
</BaseModalLayout>
|
</BaseModalLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||||
import { computed, provide } from 'vue'
|
import { computed, provide, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import SearchBox from '@/components/common/SearchBox.vue'
|
import SearchBox from '@/components/common/SearchBox.vue'
|
||||||
@@ -74,6 +78,7 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
|||||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||||
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
|
||||||
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
|
||||||
|
import ModelInfoPanel from '@/platform/assets/components/modelInfo/ModelInfoPanel.vue'
|
||||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
|
||||||
@@ -150,6 +155,8 @@ const {
|
|||||||
updateFilters
|
updateFilters
|
||||||
} = useAssetBrowser(fetchedAssets)
|
} = useAssetBrowser(fetchedAssets)
|
||||||
|
|
||||||
|
const selectedAsset = ref<AssetDisplayItem | null>(null)
|
||||||
|
|
||||||
const primaryCategoryTag = computed(() => {
|
const primaryCategoryTag = computed(() => {
|
||||||
const assets = fetchedAssets.value ?? []
|
const assets = fetchedAssets.value ?? []
|
||||||
const tagFromAssets = assets
|
const tagFromAssets = assets
|
||||||
@@ -192,6 +199,7 @@ function handleClose() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
|
function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
|
||||||
|
selectedAsset.value = asset
|
||||||
emit('asset-select', asset)
|
emit('asset-select', asset)
|
||||||
// onSelect callback is provided by dialog composable layer
|
// onSelect callback is provided by dialog composable layer
|
||||||
// It handles the appropriate transformation (filename extraction or full asset)
|
// It handles the appropriate transformation (filename extraction or full asset)
|
||||||
|
|||||||
12
src/platform/assets/components/modelInfo/ModelInfoField.vue
Normal file
12
src/platform/assets/components/modelInfo/ModelInfoField.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1 px-4 py-2">
|
||||||
|
<span class="text-xs text-muted-foreground">{{ label }}</span>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
140
src/platform/assets/components/modelInfo/ModelInfoPanel.vue
Normal file
140
src/platform/assets/components/modelInfo/ModelInfoPanel.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col bg-comfy-menu-bg">
|
||||||
|
<div class="flex h-18 items-center border-b border-divider px-4">
|
||||||
|
<h2 class="text-lg font-semibold">
|
||||||
|
{{ $t('assetBrowser.modelInfo.title') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto scrollbar-custom">
|
||||||
|
<PropertiesAccordionItem>
|
||||||
|
<template #label>
|
||||||
|
<span class="text-xs uppercase">
|
||||||
|
{{ $t('assetBrowser.modelInfo.basicInfo') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ModelInfoField :label="$t('assetBrowser.modelInfo.displayName')">
|
||||||
|
<span class="text-sm">{{ displayName }}</span>
|
||||||
|
</ModelInfoField>
|
||||||
|
<ModelInfoField :label="$t('assetBrowser.modelInfo.fileName')">
|
||||||
|
<span class="text-sm">{{ asset.name }}</span>
|
||||||
|
</ModelInfoField>
|
||||||
|
<ModelInfoField
|
||||||
|
v-if="sourceUrl"
|
||||||
|
:label="$t('assetBrowser.modelInfo.source')"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="sourceUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm text-link hover:underline"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
$t('assetBrowser.modelInfo.viewOnSource', { source: sourceName })
|
||||||
|
}}
|
||||||
|
</a>
|
||||||
|
</ModelInfoField>
|
||||||
|
</PropertiesAccordionItem>
|
||||||
|
|
||||||
|
<PropertiesAccordionItem>
|
||||||
|
<template #label>
|
||||||
|
<span class="text-xs uppercase">
|
||||||
|
{{ $t('assetBrowser.modelInfo.modelTagging') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ModelInfoField
|
||||||
|
v-if="modelType"
|
||||||
|
:label="$t('assetBrowser.modelInfo.modelType')"
|
||||||
|
>
|
||||||
|
<span class="text-sm">{{ modelType }}</span>
|
||||||
|
</ModelInfoField>
|
||||||
|
<ModelInfoField
|
||||||
|
v-if="baseModel"
|
||||||
|
:label="$t('assetBrowser.modelInfo.compatibleBaseModels')"
|
||||||
|
>
|
||||||
|
<span class="text-sm">{{ baseModel }}</span>
|
||||||
|
</ModelInfoField>
|
||||||
|
<ModelInfoField
|
||||||
|
v-if="additionalTags.length > 0"
|
||||||
|
:label="$t('assetBrowser.modelInfo.additionalTags')"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="tag in additionalTags"
|
||||||
|
:key="tag"
|
||||||
|
class="rounded bg-surface-container px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ModelInfoField>
|
||||||
|
</PropertiesAccordionItem>
|
||||||
|
|
||||||
|
<PropertiesAccordionItem>
|
||||||
|
<template #label>
|
||||||
|
<span class="text-xs uppercase">
|
||||||
|
{{ $t('assetBrowser.modelInfo.modelDescription') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ModelInfoField
|
||||||
|
v-if="triggerPhrases.length > 0"
|
||||||
|
:label="$t('assetBrowser.modelInfo.triggerPhrases')"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="phrase in triggerPhrases"
|
||||||
|
:key="phrase"
|
||||||
|
class="rounded bg-surface-container px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{{ phrase }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ModelInfoField>
|
||||||
|
<ModelInfoField
|
||||||
|
v-if="description"
|
||||||
|
:label="$t('assetBrowser.modelInfo.description')"
|
||||||
|
>
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{{ description }}</p>
|
||||||
|
</ModelInfoField>
|
||||||
|
</PropertiesAccordionItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||||
|
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
|
import {
|
||||||
|
getAssetBaseModel,
|
||||||
|
getAssetDescription,
|
||||||
|
getAssetDisplayName,
|
||||||
|
getAssetSourceUrl,
|
||||||
|
getAssetTags,
|
||||||
|
getAssetTriggerPhrases,
|
||||||
|
getSourceName
|
||||||
|
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||||
|
|
||||||
|
import ModelInfoField from './ModelInfoField.vue'
|
||||||
|
|
||||||
|
const { asset } = defineProps<{
|
||||||
|
asset: AssetDisplayItem
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const displayName = computed(() => getAssetDisplayName(asset))
|
||||||
|
const sourceUrl = computed(() => getAssetSourceUrl(asset))
|
||||||
|
const sourceName = computed(() =>
|
||||||
|
sourceUrl.value ? getSourceName(sourceUrl.value) : ''
|
||||||
|
)
|
||||||
|
const baseModel = computed(() => getAssetBaseModel(asset))
|
||||||
|
const description = computed(() => getAssetDescription(asset))
|
||||||
|
const triggerPhrases = computed(() => getAssetTriggerPhrases(asset))
|
||||||
|
const additionalTags = computed(() => getAssetTags(asset))
|
||||||
|
|
||||||
|
const modelType = computed(() => {
|
||||||
|
const typeTag = asset.tags.find((tag) => tag !== 'models')
|
||||||
|
if (!typeTag) return null
|
||||||
|
return typeTag.includes('/') ? typeTag.split('/').pop() : typeTag
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -25,3 +25,63 @@ export function getAssetBaseModel(asset: AssetItem): string | null {
|
|||||||
? asset.user_metadata.base_model
|
? asset.user_metadata.base_model
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the display name for an asset, falling back to filename
|
||||||
|
* @param asset - The asset to get display name from
|
||||||
|
* @returns The display name or filename
|
||||||
|
*/
|
||||||
|
export function getAssetDisplayName(asset: AssetItem): string {
|
||||||
|
return typeof asset.user_metadata?.display_name === 'string'
|
||||||
|
? asset.user_metadata.display_name
|
||||||
|
: asset.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extracts source URL from asset metadata
|
||||||
|
* @param asset - The asset to extract source URL from
|
||||||
|
* @returns The source URL or null if not present
|
||||||
|
*/
|
||||||
|
export function getAssetSourceUrl(asset: AssetItem): string | null {
|
||||||
|
return typeof asset.user_metadata?.source_url === 'string'
|
||||||
|
? asset.user_metadata.source_url
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts trigger phrases from asset metadata
|
||||||
|
* @param asset - The asset to extract trigger phrases from
|
||||||
|
* @returns Array of trigger phrases
|
||||||
|
*/
|
||||||
|
export function getAssetTriggerPhrases(asset: AssetItem): string[] {
|
||||||
|
const phrases = asset.user_metadata?.trigger_phrases
|
||||||
|
if (Array.isArray(phrases)) {
|
||||||
|
return phrases.filter((p): p is string => typeof p === 'string')
|
||||||
|
}
|
||||||
|
if (typeof phrases === 'string') return [phrases]
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts additional tags from asset user_metadata
|
||||||
|
* @param asset - The asset to extract tags from
|
||||||
|
* @returns Array of user-defined tags
|
||||||
|
*/
|
||||||
|
export function getAssetTags(asset: AssetItem): string[] {
|
||||||
|
const tags = asset.user_metadata?.tags
|
||||||
|
if (Array.isArray(tags)) {
|
||||||
|
return tags.filter((t): t is string => typeof t === 'string')
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the source name from a URL
|
||||||
|
* @param url - The source URL
|
||||||
|
* @returns Human-readable source name
|
||||||
|
*/
|
||||||
|
export function getSourceName(url: string): string {
|
||||||
|
if (url.includes('civitai.com')) return 'Civitai'
|
||||||
|
if (url.includes('huggingface.co')) return 'Hugging Face'
|
||||||
|
return 'Source'
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user