From 93e7a4f9f9743308c8839423240316f29f6f20b4 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Wed, 21 Jan 2026 19:43:56 -0800 Subject: [PATCH] feat(assets): add ModelInfoPanel for asset browser right panel (#8090) ## Summary Adds an editable Model Info Panel to show and modify asset details in the asset browser. ## Changes - Add `ModelInfoPanel` component with editable display name, description, model type, base models, and tags - Add `updateAssetMetadata` action in `assetsStore` with optimistic cache updates - Add shadcn-vue `Select` components with design system styling - Add utility functions in `assetMetadataUtils` for extracting model metadata - Convert `BaseModalLayout` right panel state to `defineModel` pattern - Add slide-in animation and collapse button for right panel - Add `class` prop to `PropertiesAccordionItem` for custom styling - Fix keyboard handling: Escape in TagsInput/TextArea doesn't close parent modal ## Testing - Unit tests for `ModelInfoPanel` component - Unit tests for `assetMetadataUtils` functions --------- Co-authored-by: Amp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: GitHub Action --- docs/testing/vitest-patterns.md | 4 + .../layout/PropertiesAccordionItem.vue | 17 +- src/locales/en/main.json | 24 ++ .../assets/components/AssetBrowserModal.vue | 55 +++- src/platform/assets/components/AssetCard.vue | 145 +++++---- .../assets/components/AssetFilterBar.test.ts | 105 +------ .../assets/components/AssetFilterBar.vue | 48 +-- src/platform/assets/components/AssetGrid.vue | 18 +- .../components/modelInfo/ModelInfoField.vue | 12 + .../modelInfo/ModelInfoPanel.test.ts | 165 ++++++++++ .../components/modelInfo/ModelInfoPanel.vue | 296 ++++++++++++++++++ .../composables/useAssetBrowser.test.ts | 17 +- .../assets/composables/useAssetBrowser.ts | 46 +-- .../composables/useAssetFilterOptions.ts | 8 +- .../assets/composables/useModelTypes.ts | 8 +- src/platform/assets/schemas/assetSchema.ts | 18 +- src/platform/assets/services/assetService.ts | 3 +- .../assets/utils/assetMetadataUtils.test.ts | 267 ++++++++++++++-- .../assets/utils/assetMetadataUtils.ts | 138 +++++++- .../extensions/linearMode/LinearPreview.vue | 2 +- .../useAssetWidgetData.desktop.test.ts | 1 + .../composables/useAssetWidgetData.test.ts | 9 + .../widgets/composables/useAssetWidgetData.ts | 8 +- src/stores/assetsStore.test.ts | 8 +- src/stores/assetsStore.ts | 90 +++++- 25 files changed, 1198 insertions(+), 314 deletions(-) create mode 100644 src/platform/assets/components/modelInfo/ModelInfoField.vue create mode 100644 src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts create mode 100644 src/platform/assets/components/modelInfo/ModelInfoPanel.vue diff --git a/docs/testing/vitest-patterns.md b/docs/testing/vitest-patterns.md index 2eb7c8e09..9c7fef7c2 100644 --- a/docs/testing/vitest-patterns.md +++ b/docs/testing/vitest-patterns.md @@ -30,6 +30,10 @@ describe('MyStore', () => { **Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior. +## i18n in Component Tests + +Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example. + ## Mock Patterns ### Reset all mocks at once diff --git a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue index d3ee425dd..0b1b88820 100644 --- a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue +++ b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue @@ -5,25 +5,32 @@ import { cn } from '@/utils/tailwindUtil' import TransitionCollapse from './TransitionCollapse.vue' -const props = defineProps<{ +const { + disabled, + label, + enableEmptyState, + tooltip, + class: className +} = defineProps<{ disabled?: boolean label?: string enableEmptyState?: boolean tooltip?: string + class?: string }>() const isCollapse = defineModel('collapse', { default: false }) -const isExpanded = computed(() => !isCollapse.value && !props.disabled) +const isExpanded = computed(() => !isCollapse.value && !disabled) const tooltipConfig = computed(() => { - if (!props.tooltip) return undefined - return { value: props.tooltip, showDelay: 1000 } + if (!tooltip) return undefined + return { value: tooltip, showDelay: 1000 } }) diff --git a/src/platform/assets/components/AssetCard.vue b/src/platform/assets/components/AssetCard.vue index 11e404d41..ccaf546c1 100644 --- a/src/platform/assets/components/AssetCard.vue +++ b/src/platform/assets/components/AssetCard.vue @@ -9,30 +9,28 @@ cn( 'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full', interactive && - 'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4' + 'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4', + focused && 'bg-secondary-background outline-solid' ) " + @click.stop="interactive && $emit('focus', asset)" + @focus="interactive && $emit('focus', asset)" @keydown.enter.self="interactive && $emit('select', asset)" >
- + + @@ -52,14 +57,19 @@ import VirtualGrid from '@/components/common/VirtualGrid.vue' import AssetCard from '@/platform/assets/components/AssetCard.vue' import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' -const { assets } = defineProps<{ +const { assets, focusedAssetId, emptyTitle, emptyMessage } = defineProps<{ assets: AssetDisplayItem[] loading?: boolean + focusedAssetId?: string | null + emptyTitle?: string + emptyMessage?: string }>() defineEmits<{ + assetFocus: [asset: AssetDisplayItem] assetSelect: [asset: AssetDisplayItem] assetDeleted: [asset: AssetDisplayItem] + assetShowInfo: [asset: AssetDisplayItem] }>() const assetsWithKey = computed(() => @@ -73,7 +83,7 @@ const isLg = breakpoints.greaterOrEqual('lg') const isMd = breakpoints.greaterOrEqual('md') const maxColumns = computed(() => { if (is2Xl.value) return 5 - if (isXl.value) return 4 + if (isXl.value) return 3 if (isLg.value) return 3 if (isMd.value) return 2 return 1 diff --git a/src/platform/assets/components/modelInfo/ModelInfoField.vue b/src/platform/assets/components/modelInfo/ModelInfoField.vue new file mode 100644 index 000000000..34ff0a326 --- /dev/null +++ b/src/platform/assets/components/modelInfo/ModelInfoField.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts b/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts new file mode 100644 index 000000000..89300617f --- /dev/null +++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts @@ -0,0 +1,165 @@ +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { describe, expect, it } from 'vitest' +import { createI18n } from 'vue-i18n' + +import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser' + +import ModelInfoPanel from './ModelInfoPanel.vue' + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: {} }, + missingWarn: false, + fallbackWarn: false +}) + +describe('ModelInfoPanel', () => { + const createMockAsset = ( + overrides: Partial = {} + ): AssetDisplayItem => ({ + id: 'test-id', + name: 'test-model.safetensors', + asset_hash: 'hash123', + size: 1024, + mime_type: 'application/octet-stream', + tags: ['models', 'checkpoints'], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + last_access_time: '2024-01-01T00:00:00Z', + description: 'A test model description', + badges: [], + stats: {}, + ...overrides + }) + + const mountPanel = (asset: AssetDisplayItem) => { + return mount(ModelInfoPanel, { + props: { asset }, + global: { + plugins: [createTestingPinia({ stubActions: false }), i18n] + } + }) + } + + describe('Basic Info Section', () => { + it('renders basic info section', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo') + }) + + it('displays asset filename', () => { + const asset = createMockAsset({ name: 'my-model.safetensors' }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('my-model.safetensors') + }) + + it('displays name from user_metadata when present', () => { + const asset = createMockAsset({ + user_metadata: { name: 'My Custom Model' } + }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('My Custom Model') + }) + + it('falls back to asset name when user_metadata.name not present', () => { + const asset = createMockAsset({ name: 'fallback-model.safetensors' }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('fallback-model.safetensors') + }) + + it('renders source link when source_arn is present', () => { + const asset = createMockAsset({ + user_metadata: { source_arn: 'civitai:model:123:version:456' } + }) + const wrapper = mountPanel(asset) + const link = wrapper.find( + 'a[href="https://civitai.com/models/123?modelVersionId=456"]' + ) + expect(link.exists()).toBe(true) + expect(link.attributes('target')).toBe('_blank') + }) + + it('displays Civitai icon for Civitai source', () => { + const asset = createMockAsset({ + user_metadata: { source_arn: 'civitai:model:123:version:456' } + }) + const wrapper = mountPanel(asset) + expect( + wrapper.find('img[src="/assets/images/civitai.svg"]').exists() + ).toBe(true) + }) + + it('does not render source field when source_arn is absent', () => { + const asset = createMockAsset() + const wrapper = mountPanel(asset) + const links = wrapper.findAll('a') + expect(links).toHaveLength(0) + }) + }) + + describe('Model Tagging Section', () => { + it('renders model tagging section', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging') + }) + + it('renders model type field', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelType') + }) + + it('renders base models field', () => { + const asset = createMockAsset({ + user_metadata: { base_model: ['SDXL'] } + }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain( + 'assetBrowser.modelInfo.compatibleBaseModels' + ) + }) + + it('renders additional tags field', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.additionalTags') + }) + }) + + describe('Model Description Section', () => { + it('renders trigger phrases when present', () => { + const asset = createMockAsset({ + user_metadata: { trained_words: ['trigger1', 'trigger2'] } + }) + const wrapper = mountPanel(asset) + expect(wrapper.text()).toContain('trigger1') + expect(wrapper.text()).toContain('trigger2') + }) + + it('renders description section', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain( + 'assetBrowser.modelInfo.modelDescription' + ) + }) + + it('does not render trigger phrases field when empty', () => { + const asset = createMockAsset() + const wrapper = mountPanel(asset) + expect(wrapper.text()).not.toContain( + 'assetBrowser.modelInfo.triggerPhrases' + ) + }) + }) + + describe('Accordion Structure', () => { + it('renders all three section labels', () => { + const wrapper = mountPanel(createMockAsset()) + expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo') + expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging') + expect(wrapper.text()).toContain( + 'assetBrowser.modelInfo.modelDescription' + ) + }) + }) +}) diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.vue b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue new file mode 100644 index 000000000..c69cb6429 --- /dev/null +++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue @@ -0,0 +1,296 @@ +