feat: add provider logo overlays to workflow template thumbnails

- 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

Supports multiple logos per template with configurable:
- Position (Tailwind classes)
- Size (sm/md/lg)
- Opacity (0-1)

Templates can declare logos explicitly. Logo assets are fetched
from the workflow_templates repo (templates/logo/*.png).

Amp-Thread-ID: https://ampcode.com/threads/T-019c03a1-9f05-70bd-b0a3-2848e54e04af
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Subagent 5
2026-01-28 00:09:50 -08:00
parent dd3e4d3edc
commit fb9bc504f2
6 changed files with 197 additions and 10 deletions

View File

@@ -251,6 +251,11 @@
"
/>
</template>
<LogoOverlay
v-if="template.logos?.length"
:logos="template.logos"
:get-logo-url="getLogoUrl"
/>
<ProgressSpinner
v-if="loadingTemplate === template.name"
class="absolute inset-0 z-10 m-auto h-12 w-12"
@@ -392,6 +397,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'
@@ -472,7 +478,8 @@ const {
loadWorkflowTemplate,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription
getTemplateDescription,
getLogoUrl
} = useTemplateWorkflows()
const getEffectiveSourceModule = (template: TemplateInfo) =>

View File

@@ -0,0 +1,95 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
describe('LogoOverlay', () => {
const mockGetLogoUrl = (provider: string) => `/logos/${provider}.png`
const mountOverlay = (logos: LogoInfo[], props = {}) => {
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 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 logos', () => {
const wrapper = mountOverlay([
{ provider: 'Google' },
{ provider: 'OpenAI' },
{ provider: 'Stability' }
])
expect(wrapper.findAll('img')).toHaveLength(3)
})
it('applies default position when not specified', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const container = wrapper.find('div')
expect(container.classes()).toContain('bottom-2')
expect(container.classes()).toContain('right-2')
})
it('applies custom position from logo config', () => {
const wrapper = mountOverlay([
{ provider: 'Google', position: 'top-2 left-2' }
])
const container = wrapper.find('div')
expect(container.classes()).toContain('top-2')
expect(container.classes()).toContain('left-2')
})
it('applies default medium size class', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('img')
expect(img.classes()).toContain('h-8')
expect(img.classes()).toContain('w-8')
})
it('applies small size class', () => {
const wrapper = mountOverlay([{ provider: 'Google', size: 'sm' }])
const img = wrapper.find('img')
expect(img.classes()).toContain('h-6')
expect(img.classes()).toContain('w-6')
})
it('applies large size class', () => {
const wrapper = mountOverlay([{ provider: 'Google', size: 'lg' }])
const img = wrapper.find('img')
expect(img.classes()).toContain('h-12')
expect(img.classes()).toContain('w-12')
})
it('applies default opacity', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const container = wrapper.find('div')
expect(container.attributes('style')).toContain('opacity: 0.9')
})
it('applies custom opacity', () => {
const wrapper = mountOverlay([{ provider: 'Google', opacity: 0.5 }])
const container = wrapper.find('div')
expect(container.attributes('style')).toContain('opacity: 0.5')
})
it('images are not draggable', () => {
const wrapper = mountOverlay([{ provider: 'Google' }])
const img = wrapper.find('img')
expect(img.attributes('draggable')).toBe('false')
})
})

View File

@@ -0,0 +1,39 @@
<template>
<div
v-for="(logo, index) in logos"
:key="index"
:class="
cn('pointer-events-none absolute', logo.position ?? defaultPosition)
"
:style="{ opacity: logo.opacity ?? 0.9 }"
>
<img
:src="getLogoUrl(logo.provider)"
:alt="logo.provider"
:class="sizeClasses[logo.size ?? 'md']"
class="drop-shadow-md"
draggable="false"
/>
</div>
</template>
<script setup lang="ts">
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
import { cn } from '@/utils/tailwindUtil'
const {
logos,
getLogoUrl,
defaultPosition = 'bottom-2 right-2'
} = defineProps<{
logos: LogoInfo[]
getLogoUrl: (provider: string) => string
defaultPosition?: string
}>()
const sizeClasses: Record<'sm' | 'md' | 'lg', string> = {
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-12 w-12'
}
</script>

View File

@@ -158,7 +158,6 @@ export function useTemplateWorkflows() {
*/
const fetchTemplateJson = async (id: string, sourceModule: string) => {
if (sourceModule === 'default') {
// Default templates provided by frontend are served on this separate endpoint
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
} else {
return fetch(
@@ -167,6 +166,13 @@ export function useTemplateWorkflows() {
}
}
/**
* Gets logo URL for a provider name
*/
const getLogoUrl = (provider: string): string => {
return workflowTemplatesStore.getLogoUrl(provider)
}
return {
// State
selectedTemplate,
@@ -183,6 +189,7 @@ export function useTemplateWorkflows() {
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription,
loadWorkflowTemplate
loadWorkflowTemplate,
getLogoUrl
}
}

View File

@@ -9,6 +9,7 @@ import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import type {
LogoIndex,
TemplateGroup,
TemplateInfo,
WorkflowTemplates
@@ -31,6 +32,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 +477,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 +503,22 @@ export const useWorkflowTemplatesStore = defineStore(
}
}
async function fetchLogoIndex(): Promise<LogoIndex> {
try {
const response = await fetch(api.fileURL('/templates/index_logo.json'))
if (!response.ok) return {}
return await response.json()
} catch {
return {}
}
}
function getLogoUrl(provider: string): string {
const logoPath = logoIndex.value[provider]
if (!logoPath) return ''
return api.fileURL(`/templates/${logoPath}`)
}
function getEnglishMetadata(templateName: string): {
tags?: string[]
category?: string
@@ -534,7 +555,8 @@ export const useWorkflowTemplatesStore = defineStore(
loadWorkflowTemplates,
knownTemplateNames,
getTemplateByName,
getEnglishMetadata
getEnglishMetadata,
getLogoUrl
}
}
)

View File

@@ -1,3 +1,16 @@
export interface LogoInfo {
/** Provider name matching index_logo.json */
provider: string
/** Tailwind positioning classes */
position?: string
/** Size: 'sm' (24px), 'md' (32px), 'lg' (48px) */
size?: 'sm' | 'md' | 'lg'
/** Opacity 0-1, default 0.9 */
opacity?: number
}
export type LogoIndex = Record<string, string>
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 {