Compare commits

...

4 Commits

Author SHA1 Message Date
Deep Mehta
536da33486 fix(assets): handle backslash paths and padded names in placeholder label
- Split path on `\` as well as `/` so Windows-style asset names like
  `C:\Models\loras\foo.safetensors` resolve to `foo`.
- Trim the basename before matching extensions so trailing whitespace
  no longer suppresses the strip (`  model.safetensors  ` → `model`).
- Correct the FallbackPlaceholder story description: separators are
  preserved, only the path prefix and known extension are stripped.

Adds 2 unit cases covering both edge cases.
2026-05-11 19:36:37 -07:00
Deep Mehta
a3e7907c3a refactor(assets): scope fallback placeholder token and dedupe extensions
- Move MODEL_FILE_EXTENSIONS Set to assetMetadataUtils.ts and re-export
  from missingModelScan.ts; the placeholder helper now derives its
  extension match from the single source of truth instead of carrying
  a duplicate regex.
- Introduce a scoped --asset-card-fallback-background token (light:
  smoke-500 #c5c5c5, dark: charcoal-700 #202121) instead of mutating
  the shared --modal-card-placeholder-background. Light value chosen
  so muted-foreground text passes WCAG AA at 14px (~5:1).
- Rename stripModelFilename to formatAssetCardPlaceholderLabel; its
  contract is display-only and the new name keeps callers from reusing
  it in serialized-identifier contexts.
- Mark the placeholder span aria-hidden so screen readers announce the
  card title once instead of both the stripped overlay and the full h3.
2026-05-11 18:08:37 -07:00
Deep Mehta
4372b8dd90 refactor(assets): extract stripModelFilename to utils and harden edge cases
Move the placeholder-name helper out of AssetCard.vue and into
assetMetadataUtils.ts alongside the other display-name helpers, and add
unit coverage. Also guards two edge cases that previously short-circuited
to an empty placeholder caption:

- A name ending in `/` now falls back to the preceding path segment
  rather than the empty string returned by split/pop.
- A name that is purely an extension (e.g. `.safetensors`) is kept
  verbatim instead of being eaten by the extension regex.
2026-05-11 17:40:12 -07:00
Deep Mehta
3428aa1b67 fix(assets): show stripped model name on asset card thumbnail fallback
Replace the silver gradient placeholder shown when a model has no preview
image with a neutral dark surface and the stripped filename centered on it,
so cards without thumbnails are informative instead of empty.

Also bumps the dark-mode --modal-card-placeholder-background token one step
darker (charcoal-700) so the placeholder no longer blends with the card's
secondary-background hover state.
2026-05-11 17:27:46 -07:00
6 changed files with 142 additions and 20 deletions

View File

@@ -295,6 +295,7 @@
--modal-card-border-highlighted: var(--secondary-background-selected);
--modal-card-button-surface: var(--color-smoke-300);
--modal-card-placeholder-background: var(--color-smoke-600);
--asset-card-fallback-background: var(--color-smoke-500);
--modal-card-tag-background: var(--color-smoke-200);
--modal-card-tag-foreground: var(--base-foreground);
--modal-panel-background: var(--color-white);
@@ -418,6 +419,7 @@
--modal-card-border-highlighted: var(--color-ash-400);
--modal-card-button-surface: var(--color-charcoal-300);
--modal-card-placeholder-background: var(--secondary-background);
--asset-card-fallback-background: var(--color-charcoal-700);
--modal-card-tag-background: var(--color-ash-800);
--modal-card-tag-foreground: var(--base-foreground);
--modal-panel-background: var(--color-charcoal-600);
@@ -438,6 +440,7 @@
--color-modal-card-placeholder-background: var(
--modal-card-placeholder-background
);
--color-asset-card-fallback-background: var(--asset-card-fallback-background);
--color-modal-card-tag-background: var(--modal-card-tag-background);
--color-modal-card-tag-foreground: var(--modal-card-tag-foreground);
--color-modal-panel-background: var(--modal-panel-background);

View File

@@ -100,7 +100,7 @@ export const WithPreviewImage: Story = {
}
}
export const FallbackGradient: Story = {
export const FallbackPlaceholder: Story = {
args: {
asset: createAssetData({
preview_url: undefined
@@ -116,7 +116,31 @@ export const FallbackGradient: Story = {
docs: {
description: {
story:
'AssetCard showing fallback gradient when no preview image is available.'
'AssetCard showing the neutral placeholder when no preview image is available — filename is reduced to the last path segment with any known model extension stripped; hyphens and underscores are preserved.'
}
}
}
}
export const FallbackPlaceholderLongName: Story = {
args: {
asset: createAssetData({
name: 'Qwen-Image-Edit-2511-Lightning-8steps-V1.0-fp32.safetensors',
preview_url: undefined,
tags: ['models', 'loras']
}),
interactive: true
},
decorators: [
() => ({
template: '<div class="p-8 bg-base-background max-w-96"><story /></div>'
})
],
parameters: {
docs: {
description: {
story:
'Fallback placeholder for an asset with a very long filename — text wraps and clamps to 3 lines.'
}
}
}

View File

@@ -20,8 +20,15 @@
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
<div
v-if="isLoading || error"
class="flex size-full cursor-pointer items-center justify-center bg-linear-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
/>
class="flex size-full cursor-pointer items-center justify-center bg-asset-card-fallback-background p-4"
>
<span
aria-hidden="true"
class="line-clamp-3 px-2 text-center text-sm font-medium tracking-wide wrap-break-word text-muted-foreground"
>
{{ fallbackName }}
</span>
</div>
<img
v-else
:src="asset.preview_url"
@@ -140,7 +147,10 @@ import Button from '@/components/ui/button/Button.vue'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetCardTitle } from '@/platform/assets/utils/assetMetadataUtils'
import {
formatAssetCardPlaceholderLabel,
getAssetCardTitle
} from '@/platform/assets/utils/assetMetadataUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -173,6 +183,10 @@ const descId = useId()
const displayName = computed(() => getAssetCardTitle(asset))
const fallbackName = computed(() =>
formatAssetCardPlaceholderLabel(displayName.value)
)
// Format at render so locale switches re-flow; the upstream WeakMap caches
// AssetItem -> AssetDisplayItem by reference, which would otherwise pin the
// formatted string to whichever locale was active when first transformed.

View File

@@ -14,7 +14,8 @@ import {
getAssetSourceUrl,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName
getSourceName,
formatAssetCardPlaceholderLabel
} from '@/platform/assets/utils/assetMetadataUtils'
describe('assetMetadataUtils', () => {
@@ -383,4 +384,60 @@ describe('assetMetadataUtils', () => {
expect(getAssetCardTitle(asset)).toBe('pretty.png')
})
})
describe('formatAssetCardPlaceholderLabel', () => {
it('strips a single safetensors extension', () => {
expect(
formatAssetCardPlaceholderLabel('v1-5-pruned-emaonly.safetensors')
).toBe('v1-5-pruned-emaonly')
})
it('strips path prefix and keeps only the last segment', () => {
expect(
formatAssetCardPlaceholderLabel('owner/repo/model.safetensors')
).toBe('model')
})
it('preserves hyphens and underscores as part of the name', () => {
expect(formatAssetCardPlaceholderLabel('detail-tweaker_v2.ckpt')).toBe(
'detail-tweaker_v2'
)
})
it('only strips the trailing extension on double-extension names', () => {
expect(formatAssetCardPlaceholderLabel('model.fp16.safetensors')).toBe(
'model.fp16'
)
})
it('matches extensions case-insensitively', () => {
expect(formatAssetCardPlaceholderLabel('Lora_v1.CKPT')).toBe('Lora_v1')
})
it('handles a trailing slash by falling back to the preceding segment', () => {
expect(formatAssetCardPlaceholderLabel('Author/')).toBe('Author')
})
it('does not return an empty string when the input is just an extension', () => {
expect(formatAssetCardPlaceholderLabel('.safetensors')).toBe(
'.safetensors'
)
})
it('returns plain names unchanged', () => {
expect(formatAssetCardPlaceholderLabel('plain-name')).toBe('plain-name')
})
it('handles Windows-style backslash separators', () => {
expect(
formatAssetCardPlaceholderLabel('C:\\Models\\loras\\foo.safetensors')
).toBe('foo')
})
it('trims whitespace before extension matching', () => {
expect(formatAssetCardPlaceholderLabel(' model.safetensors ')).toBe(
'model'
)
})
})
})

View File

@@ -204,3 +204,33 @@ export function getAssetCardTitle(asset: AssetItem): string {
if (curatedName && curatedName !== asset.name) return curatedName
return getAssetDisplayFilename(asset)
}
export const MODEL_FILE_EXTENSIONS = new Set([
'.safetensors',
'.ckpt',
'.pt',
'.pth',
'.bin',
'.sft',
'.onnx',
'.gguf'
])
/**
* Builds a short caption for an asset card's placeholder when no preview
* image is available. Strips path segments and common model file extensions
* while preserving hyphens and underscores (these are part of the model name,
* not delimiters). Never returns an empty string for a non-empty input.
*/
export function formatAssetCardPlaceholderLabel(name: string): string {
const segments = name.split(/[\\/]/).filter(Boolean)
const filename = (segments.at(-1) ?? name).trim()
const lower = filename.toLowerCase()
for (const ext of MODEL_FILE_EXTENSIONS) {
if (lower.endsWith(ext)) {
const stripped = filename.slice(0, -ext.length).trim()
return stripped || filename || name
}
}
return filename || name
}

View File

@@ -6,7 +6,10 @@ import type {
MissingModelViewModel,
EmbeddedModelWithSource
} from './types'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import {
MODEL_FILE_EXTENSIONS,
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// eslint-disable-next-line import-x/no-restricted-paths
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
@@ -70,19 +73,10 @@ function isAssetWidget(widget: IBaseWidget): widget is IAssetWidget {
return widget.type === 'asset'
}
// Full set of model file extensions used for scanning candidate widgets.
// Intentionally broader than ALLOWED_SUFFIXES in missingModelDownload.ts,
// which restricts which files are eligible for download.
export const MODEL_FILE_EXTENSIONS = new Set([
'.safetensors',
'.ckpt',
'.pt',
'.pth',
'.bin',
'.sft',
'.onnx',
'.gguf'
])
// Re-export the canonical model extension set from assets/utils so existing
// callers continue to work; ALLOWED_SUFFIXES in missingModelDownload.ts is
// intentionally narrower and lives there.
export { MODEL_FILE_EXTENSIONS }
export function isModelFileName(name: string): boolean {
const lower = name.toLowerCase()