diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
index 8580e29f9..2ed71befc 100644
--- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
+++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
@@ -256,6 +256,11 @@
"
/>
+
({
+ useI18n: () => ({
+ t: (key: string) =>
+ key === 'templates.logoProviderSeparator' ? ' & ' : key,
+ locale: ref('en')
+ })
+}))
+
+type LogoOverlayProps = ComponentProps
+
+describe('LogoOverlay', () => {
+ function mockGetLogoUrl(provider: string) {
+ return `/logos/${provider}.png`
+ }
+
+ function mountOverlay(
+ logos: LogoInfo[],
+ props: Partial = {}
+ ) {
+ 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')
+ })
+ })
+})
diff --git a/src/components/templates/thumbnails/LogoOverlay.vue b/src/components/templates/thumbnails/LogoOverlay.vue
new file mode 100644
index 000000000..3dc4846b4
--- /dev/null
+++ b/src/components/templates/thumbnails/LogoOverlay.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
![]()
+
+
+ {{ logo.label }}
+
+
+
+
+
+
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index 1e1b8d44f..71d243b40 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -966,6 +966,9 @@
"searchPlaceholder": "Search..."
}
},
+ "templates": {
+ "logoProviderSeparator": " & "
+ },
"graphCanvasMenu": {
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
diff --git a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts
index f96872f79..463d76ec7 100644
--- a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts
+++ b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts
@@ -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([])
const englishTemplates = shallowRef([])
+ const logoIndex = shallowRef({})
const isLoaded = ref(false)
const knownTemplateNames = ref(new Set())
@@ -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 {
+ 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
}
}
)
diff --git a/src/platform/workflow/templates/schemas/templateSchema.ts b/src/platform/workflow/templates/schemas/templateSchema.ts
new file mode 100644
index 000000000..148bd6357
--- /dev/null
+++ b/src/platform/workflow/templates/schemas/templateSchema.ts
@@ -0,0 +1,5 @@
+import { z } from 'zod'
+
+export const zLogoIndex = z.record(z.string(), z.string())
+
+export type LogoIndex = z.infer
diff --git a/src/platform/workflow/templates/types/template.ts b/src/platform/workflow/templates/types/template.ts
index 1a9d839f8..2d52a5629 100644
--- a/src/platform/workflow/templates/types/template.ts
+++ b/src/platform/workflow/templates/types/template.ts
@@ -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 {