feat: fetch publish tag suggestions from hub labels API

Replace hardcoded COMFY_HUB_TAG_OPTIONS with dynamic fetch from
GET /hub/labels?type=tag, falling back to the static list on failure.
This commit is contained in:
dante01yoon
2026-03-25 10:54:36 +09:00
parent 23c22e4c52
commit 39408b1d23
5 changed files with 128 additions and 22 deletions

View File

@@ -1,8 +1,16 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
const mockFetchTagLabels = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/sharing/services/comfyHubService', () => ({
useComfyHubService: () => ({
fetchTagLabels: mockFetchTagLabels
})
}))
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
function mountStep(
@@ -66,7 +74,9 @@ function mountStep(
describe('ComfyHubDescribeStep', () => {
it('emits name and description updates', async () => {
mockFetchTagLabels.mockRejectedValue(new Error('offline'))
const wrapper = mountStep()
await flushPromises()
await wrapper.find('[data-testid="name-input"]').setValue('New workflow')
await wrapper
@@ -77,35 +87,72 @@ describe('ComfyHubDescribeStep', () => {
expect(wrapper.emitted('update:description')).toEqual([['New description']])
})
it('adds a suggested tag when clicked', async () => {
it('uses fetched tags from API', async () => {
const apiTags = ['Alpha', 'Beta', 'Gamma']
mockFetchTagLabels.mockResolvedValue(apiTags)
const wrapper = mountStep()
const suggestionButtons = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
await flushPromises()
expect(suggestionButtons.length).toBeGreaterThan(0)
const firstSuggestion = suggestionButtons[0].attributes('data-value')
await suggestionButtons[0].trigger('click')
const tagUpdates = wrapper.emitted('update:tags')
expect(tagUpdates?.at(-1)).toEqual([[firstSuggestion]])
})
it('hides already-selected tags from suggestions', () => {
const selectedTag = COMFY_HUB_TAG_OPTIONS[0]
const wrapper = mountStep({ tags: [selectedTag] })
const suggestionValues = wrapper
.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
.map((button) => button.attributes('data-value'))
expect(suggestionValues).not.toContain(selectedTag)
expect(suggestionValues).toEqual(apiTags)
})
it('falls back to hardcoded tags when API fails', async () => {
mockFetchTagLabels.mockRejectedValue(new Error('network error'))
const wrapper = mountStep()
await flushPromises()
const suggestionValues = wrapper
.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
.map((button) => button.attributes('data-value'))
expect(suggestionValues).toHaveLength(10)
expect(suggestionValues[0]).toBe(COMFY_HUB_TAG_OPTIONS[0])
})
it('adds a suggested tag when clicked', async () => {
const apiTags = ['Alpha', 'Beta']
mockFetchTagLabels.mockResolvedValue(apiTags)
const wrapper = mountStep()
await flushPromises()
const suggestionButtons = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
await suggestionButtons[0].trigger('click')
const tagUpdates = wrapper.emitted('update:tags')
expect(tagUpdates?.at(-1)).toEqual([['Alpha']])
})
it('hides already-selected tags from suggestions', async () => {
const apiTags = ['Alpha', 'Beta', 'Gamma']
mockFetchTagLabels.mockResolvedValue(apiTags)
const wrapper = mountStep({ tags: ['Alpha'] })
await flushPromises()
const suggestionValues = wrapper
.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
.map((button) => button.attributes('data-value'))
expect(suggestionValues).not.toContain('Alpha')
expect(suggestionValues).toEqual(['Beta', 'Gamma'])
})
it('toggles between default and full suggestion lists', async () => {
mockFetchTagLabels.mockRejectedValue(new Error('offline'))
const wrapper = mountStep()
await flushPromises()
const defaultSuggestions = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'

View File

@@ -89,7 +89,8 @@ import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
import { computed, ref } from 'vue'
import { useComfyHubService } from '@/platform/workflow/sharing/services/comfyHubService'
import { computed, onMounted, ref } from 'vue'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
const { tags } = defineProps<{
@@ -107,9 +108,20 @@ const emit = defineEmits<{
const INITIAL_TAG_SUGGESTION_COUNT = 10
const showAllSuggestions = ref(false)
const tagOptions = ref<string[]>(COMFY_HUB_TAG_OPTIONS)
const { fetchTagLabels } = useComfyHubService()
onMounted(async () => {
try {
tagOptions.value = await fetchTagLabels()
} catch {
// Fall back to hardcoded tags
}
})
const availableSuggestions = computed(() =>
COMFY_HUB_TAG_OPTIONS.filter((tag) => !tags.includes(tag))
tagOptions.value.filter((tag) => !tags.includes(tag))
)
const displayedSuggestions = computed(() =>

View File

@@ -70,3 +70,13 @@ export const zHubWorkflowPublishResponse = z.object({
workflow_id: z.string(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional()
})
export const zHubLabelInfo = z.object({
name: z.string(),
display_name: z.string(),
type: z.enum(['tag', 'model', 'custom_node'])
})
export const zHubLabelListResponse = z.object({
labels: z.array(zHubLabelInfo)
})

View File

@@ -171,6 +171,23 @@ describe('useComfyHubService', () => {
})
})
it('fetches tag labels from /hub/labels?type=tag', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
labels: [
{ name: 'video', display_name: 'Video', type: 'tag' },
{ name: 'text-to-image', display_name: 'Text to Image', type: 'tag' }
]
})
)
const service = useComfyHubService()
const tags = await service.fetchTagLabels()
expect(mockFetchApi).toHaveBeenCalledWith('/hub/labels?type=tag')
expect(tags).toEqual(['Video', 'Text to Image'])
})
it('fetches current profile from /hub/profiles/me', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({

View File

@@ -1,6 +1,7 @@
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import {
zHubAssetUploadUrlResponse,
zHubLabelListResponse,
zHubProfileResponse,
zHubWorkflowPublishResponse
} from '@/platform/workflow/sharing/schemas/shareSchemas'
@@ -213,11 +214,30 @@ export function useComfyHubService() {
)
}
async function fetchTagLabels(): Promise<string[]> {
const response = await api.fetchApi('/hub/labels?type=tag')
if (!response.ok) {
throw new Error(
await parseErrorMessage(response, 'Failed to fetch hub labels')
)
}
const data = await parseRequiredJson(
response,
zHubLabelListResponse,
'Invalid label list response from server'
)
return data.labels.map((label) => label.display_name)
}
return {
requestAssetUploadUrl,
uploadFileToPresignedUrl,
getMyProfile,
createProfile,
publishWorkflow
publishWorkflow,
fetchTagLabels
}
}