feat: add provider logo overlays to workflow template thumbnails (#8365)

## Summary

Add support for overlaying provider logos on workflow template
thumbnails at runtime.

## Changes

- **What**: 
  - Add `LogoInfo` interface and `logos` field to `TemplateInfo` type
  - Create `LogoOverlay.vue` component for rendering positioned logos
  - Fetch logo index from `templates/index_logo.json` in store
  - Add `getLogoUrl` helper to `useTemplateWorkflows` composable
  - Integrate `LogoOverlay` into `WorkflowTemplateSelectorDialog`

## Review Focus

- Logo positioning uses Tailwind classes (e.g. `absolute bottom-2
right-2`)
- Supports multiple logos per template with configurable size/opacity
- Gracefully handles missing logos (returns empty string, renders
nothing)
- Templates must explicitly declare logos - no magic inference from
models

## Dependencies

Requires separate PR in workflow_templates repo to:
1. Update `index.schema.json` with logos definition
2. Add `logos` field to templates in `index.json`

## Screenshots (if applicable)

<img width="869" height="719" alt="image"
src="https://github.com/user-attachments/assets/65ed1ee4-fbb4-42c9-95d4-7e37813b3655"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8365-feat-add-provider-logo-overlays-to-workflow-template-thumbnails-2f66d73d365081309236c6b991cb6f7b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Subagent 5 <subagent@example.com>
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Christian Byrne
2026-01-31 19:18:13 -08:00
committed by GitHub
parent 4f2872460c
commit 1bbbcfedf0
7 changed files with 349 additions and 7 deletions

View File

@@ -256,6 +256,11 @@
"
/>
</template>
<LogoOverlay
v-if="template.logos?.length"
:logos="template.logos"
:get-logo-url="workflowTemplatesStore.getLogoUrl"
/>
<ProgressSpinner
v-if="loadingTemplate === template.name"
class="absolute inset-0 z-10 m-auto h-12 w-12"
@@ -397,6 +402,7 @@ import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'

View File

@@ -0,0 +1,157 @@
import { mount } from '@vue/test-utils'
import type { ComponentProps } from 'vue-component-type-helpers'
import { nextTick, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) =>
key === 'templates.logoProviderSeparator' ? ' & ' : key,
locale: ref('en')
})
}))
type LogoOverlayProps = ComponentProps<typeof LogoOverlay>
describe('LogoOverlay', () => {
function mockGetLogoUrl(provider: string) {
return `/logos/${provider}.png`
}
function mountOverlay(
logos: LogoInfo[],
props: Partial<LogoOverlayProps> = {}
) {
return mount(LogoOverlay, {
props: {
logos,
getLogoUrl: mockGetLogoUrl,
...props
}
})
}
it('renders nothing when logos array is empty', () => {
const wrapper = mountOverlay([])
expect(wrapper.findAll('img')).toHaveLength(0)
})
it('renders a single logo with correct src and alt', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('/logos/Google.png')
expect(img.attributes('alt')).toBe('Google')
})
it('renders multiple separate logo entries', () => {
const wrapper = mountOverlay([
{ provider: 'Google' },
{ provider: 'OpenAI' },
{ provider: 'Stability' }
])
expect(wrapper.findAll('img')).toHaveLength(3)
})
it('displays provider name as label for single provider', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const span = wrapper.find('span')
expect(span.text()).toBe('Google')
})
it('images are not draggable', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('img')
expect(img.attributes('draggable')).toBe('false')
})
it('filters out logos with empty URLs', () => {
function getLogoUrl(provider: string) {
return provider === 'Google' ? '/logos/Google.png' : ''
}
const wrapper = mount(LogoOverlay, {
props: {
logos: [{ provider: 'Google' }, { provider: 'Unknown' }],
getLogoUrl
}
})
expect(wrapper.findAll('img')).toHaveLength(1)
})
it('renders one logo per unique provider', () => {
const wrapper = mountOverlay([
{ provider: 'Google' },
{ provider: 'OpenAI' }
])
expect(wrapper.findAll('img')).toHaveLength(2)
})
describe('stacked logos', () => {
it('renders multiple providers as stacked overlapping logos', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const images = wrapper.findAll('img')
expect(images).toHaveLength(2)
expect(images[0].attributes('alt')).toBe('WaveSpeed')
expect(images[1].attributes('alt')).toBe('Hunyuan')
})
it('joins provider names with locale-aware conjunction for default label', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const span = wrapper.find('span')
expect(span.text()).toBe('WaveSpeed and Hunyuan')
})
it('uses custom label when provided', () => {
const wrapper = mountOverlay([
{ provider: ['WaveSpeed', 'Hunyuan'], label: 'Custom Label' }
])
const span = wrapper.find('span')
expect(span.text()).toBe('Custom Label')
})
it('applies negative gap for overlap effect', () => {
const wrapper = mountOverlay([
{ provider: ['WaveSpeed', 'Hunyuan'], gap: -8 }
])
const images = wrapper.findAll('img')
expect(images[1].attributes('style')).toContain('margin-left: -8px')
})
it('applies default gap when not specified', () => {
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
const images = wrapper.findAll('img')
expect(images[1].attributes('style')).toContain('margin-left: -6px')
})
it('filters out invalid providers from stacked logos', () => {
function getLogoUrl(provider: string) {
return provider === 'WaveSpeed' ? '/logos/WaveSpeed.png' : ''
}
const wrapper = mount(LogoOverlay, {
props: {
logos: [{ provider: ['WaveSpeed', 'Unknown'] }],
getLogoUrl
}
})
expect(wrapper.findAll('img')).toHaveLength(1)
expect(wrapper.find('span').text()).toBe('WaveSpeed')
})
})
describe('error handling', () => {
it('keeps showing remaining providers when one image fails in stacked logos', async () => {
const wrapper = mountOverlay([{ provider: ['Google', 'OpenAI'] }])
const images = wrapper.findAll('[data-testid="logo-img"]')
expect(images).toHaveLength(2)
await images[0].trigger('error')
await nextTick()
const remainingImages = wrapper.findAll('[data-testid="logo-img"]')
expect(remainingImages).toHaveLength(2)
expect(remainingImages[1].attributes('alt')).toBe('OpenAI')
})
})
})

View File

@@ -0,0 +1,117 @@
<template>
<div
v-for="logo in validLogos"
:key="logo.key"
:class="
cn('pointer-events-none absolute z-10', logo.position ?? defaultPosition)
"
>
<div
v-show="!hasAllFailed(logo.providers)"
data-testid="logo-pill"
class="flex items-center gap-1.5 rounded-full bg-black/20 py-1 pr-2"
:style="{ opacity: logo.opacity ?? 0.85 }"
>
<div class="ml-0.5 flex items-center">
<img
v-for="(provider, providerIndex) in logo.providers"
:key="provider"
data-testid="logo-img"
:src="logo.urls[providerIndex]"
:alt="provider"
class="h-6 w-6 rounded-full border-2 border-white object-cover"
:class="{ relative: providerIndex > 0 }"
:style="
providerIndex > 0 ? { marginLeft: `${logo.gap ?? -6}px` } : {}
"
draggable="false"
@error="onImageError(provider)"
/>
</div>
<span class="text-sm font-medium text-white">
{{ logo.label }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
import { cn } from '@/utils/tailwindUtil'
const { t, locale } = useI18n()
function formatProviderList(providers: string[]): string {
const localeValue = String(locale.value)
try {
return new Intl.ListFormat(localeValue, {
style: 'long',
type: 'conjunction'
}).format(providers)
} catch {
return providers.join(t('templates.logoProviderSeparator'))
}
}
const {
logos,
getLogoUrl,
defaultPosition = 'top-2 left-2'
} = defineProps<{
logos: LogoInfo[]
getLogoUrl: (provider: string) => string
defaultPosition?: string
}>()
const failedLogos = ref(new Set<string>())
function onImageError(provider: string) {
failedLogos.value = new Set([...failedLogos.value, provider])
}
function hasAllFailed(providers: string[]): boolean {
return providers.every((p) => failedLogos.value.has(p))
}
interface ValidatedLogo {
key: string
providers: string[]
urls: string[]
label: string
position: string | undefined
opacity: number | undefined
gap: number | undefined
}
const validLogos = computed<ValidatedLogo[]>(() => {
const result: ValidatedLogo[] = []
logos.forEach((logo, index) => {
const providers = Array.isArray(logo.provider)
? logo.provider
: [logo.provider]
const urls = providers.map((p) => getLogoUrl(p))
const validProviders = providers.filter((_, i) => urls[i])
const validUrls = urls.filter((url) => url)
if (validProviders.length === 0) return
const providerKey = validProviders.join('-')
const layoutKey = `${logo.position ?? ''}-${logo.opacity ?? ''}-${logo.gap ?? ''}`
result.push({
key: providerKey ? `${providerKey}-${layoutKey}` : `logo-${index}`,
providers: validProviders,
urls: validUrls,
label: logo.label ?? formatProviderList(validProviders),
position: logo.position,
opacity: logo.opacity,
gap: logo.gap
})
})
return result
})
</script>

View File

@@ -966,6 +966,9 @@
"searchPlaceholder": "Search..."
}
},
"templates": {
"logoProviderSeparator": " & "
},
"graphCanvasMenu": {
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",

View File

@@ -8,6 +8,8 @@ import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { zLogoIndex } from '../schemas/templateSchema'
import type { LogoIndex } from '../schemas/templateSchema'
import type {
TemplateGroup,
TemplateInfo,
@@ -31,6 +33,7 @@ export const useWorkflowTemplatesStore = defineStore(
const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({})
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
const englishTemplates = shallowRef<WorkflowTemplates[]>([])
const logoIndex = shallowRef<LogoIndex>({})
const isLoaded = ref(false)
const knownTemplateNames = ref(new Set<string>())
@@ -475,15 +478,18 @@ export const useWorkflowTemplatesStore = defineStore(
customTemplates.value = await api.getWorkflowTemplates()
const locale = i18n.global.locale.value
const [coreResult, englishResult] = await Promise.all([
api.getCoreWorkflowTemplates(locale),
isCloud && locale !== 'en'
? api.getCoreWorkflowTemplates('en')
: Promise.resolve([])
])
const [coreResult, englishResult, logoIndexResult] =
await Promise.all([
api.getCoreWorkflowTemplates(locale),
isCloud && locale !== 'en'
? api.getCoreWorkflowTemplates('en')
: Promise.resolve([]),
fetchLogoIndex()
])
coreTemplates.value = coreResult
englishTemplates.value = englishResult
logoIndex.value = logoIndexResult
const coreNames = coreTemplates.value.flatMap((category) =>
category.templates.map((template) => template.name)
@@ -498,6 +504,36 @@ export const useWorkflowTemplatesStore = defineStore(
}
}
async function fetchLogoIndex(): Promise<LogoIndex> {
try {
const response = await api.fetchApi('/templates/index_logo.json')
const contentType = response.headers.get('content-type')
if (!contentType?.includes('application/json')) return {}
const data = await response.json()
const result = zLogoIndex.safeParse(data)
return result.success ? result.data : {}
} catch {
return {}
}
}
function getLogoUrl(provider: string): string {
const logoPath = logoIndex.value[provider]
if (!logoPath) return ''
// Validate path to prevent directory traversal and ensure safe file extensions
const safePathPattern = /^[a-zA-Z0-9_\-./]+\.(png|jpg|jpeg|svg|webp)$/i
if (
!safePathPattern.test(logoPath) ||
logoPath.includes('..') ||
logoPath.startsWith('/')
) {
return ''
}
return api.fileURL(`/templates/${logoPath}`)
}
function getEnglishMetadata(templateName: string): {
tags?: string[]
category?: string
@@ -534,7 +570,8 @@ export const useWorkflowTemplatesStore = defineStore(
loadWorkflowTemplates,
knownTemplateNames,
getTemplateByName,
getEnglishMetadata
getEnglishMetadata,
getLogoUrl
}
}
)

View File

@@ -0,0 +1,5 @@
import { z } from 'zod'
export const zLogoIndex = z.record(z.string(), z.string())
export type LogoIndex = z.infer<typeof zLogoIndex>

View File

@@ -1,3 +1,16 @@
export interface LogoInfo {
/** Provider name(s) matching index_logo.json. String for single, array for stacked logos. */
provider: string | string[]
/** Custom label text. If omitted, defaults to provider names joined with " & " */
label?: string
/** Gap between stacked logos in pixels. Negative for overlap effect. Default: -6 */
gap?: number
/** Tailwind positioning classes */
position?: string
/** Opacity 0-1, default 0.85 */
opacity?: number
}
export interface TemplateInfo {
name: string
/**
@@ -47,6 +60,10 @@ export interface TemplateInfo {
* If not specified, the template will be included on all distributions.
*/
includeOnDistributions?: TemplateIncludeOnDistributionEnum[]
/**
* Logo overlays to display on the template thumbnail.
*/
logos?: LogoInfo[]
}
export enum TemplateIncludeOnDistributionEnum {