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:
Alexander Brown
2026-01-15 16:27:45 -08:00
parent 23694f37bf
commit 98da79910b
5 changed files with 236 additions and 1 deletions

View File

@@ -2385,6 +2385,21 @@
"assetCard": "{name} - {type} 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": {
"threeDModelPlaceholder": "3D Model",
"audioPlaceholder": "Audio"

View File

@@ -60,12 +60,16 @@
@asset-deleted="refreshAssets"
/>
</template>
<template v-if="selectedAsset" #rightPanel>
<ModelInfoPanel :asset="selectedAsset" />
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, provide } from 'vue'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
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 AssetFilterBar from '@/platform/assets/components/AssetFilterBar.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 { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
@@ -150,6 +155,8 @@ const {
updateFilters
} = useAssetBrowser(fetchedAssets)
const selectedAsset = ref<AssetDisplayItem | null>(null)
const primaryCategoryTag = computed(() => {
const assets = fetchedAssets.value ?? []
const tagFromAssets = assets
@@ -192,6 +199,7 @@ function handleClose() {
}
function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
selectedAsset.value = asset
emit('asset-select', asset)
// onSelect callback is provided by dialog composable layer
// It handles the appropriate transformation (filename extraction or full asset)

View 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>

View 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>

View File

@@ -25,3 +25,63 @@ export function getAssetBaseModel(asset: AssetItem): string | null {
? asset.user_metadata.base_model
: 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'
}