Compare commits

...

3 Commits

Author SHA1 Message Date
Alexander Piskun
09942a5b7f Merge branch 'main' into feat/RemoteComboOptions 2026-05-04 19:15:08 +03:00
bigcat88
28c97d3687 fix: use "item.id" if both label and title are missing. 2026-05-04 15:18:21 +03:00
bigcat88
97c2a0d364 feat(widgets): rich combo widget for remote options with previews
Adds a Vue-native renderer for combo inputs that declare `remote_combo=`
(RemoteComboOptions on the backend). Wired through WidgetSelect; runs in
parallel to the existing useRemoteWidget composable, which continues to
handle plain `remote=` combos.

The widget fetches a single items array from a relative `/proxy/...`
route — the frontend always prepends the comfy-api base URL and injects
auth headers (no opt-out flag while the feature is partner-node-only).
Items are mapped via the per-node `item_schema`, with image/video/audio
previews, search across multiple fields, optional auto-select first/last,
and a refresh button.

Caching: browser Cache API with TTL from `refresh`, partitioned by full
auth scope (workspace / firebase uid / api-key / anon). Refresh button
sequences cache delete before refetch to avoid the fast-response race.
Logging: auth headers and response bodies are redacted from error logs.

Also adds an audio preview branch to FormDropdownMenuItem — used by the
new widget when `preview_type='audio'`.

Tests cover: single-shot fetch, error classification, retry exhaustion,
refresh, deselect, stale-id preservation, cache-key partitioning,
route resolution, item-schema mapping, and Zod relative-route
validation.
2026-05-03 14:15:32 +03:00
18 changed files with 1581 additions and 4 deletions

View File

@@ -2700,6 +2700,12 @@
"placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached"
},
"remoteCombo": {
"loading": "Loading...",
"loadFailed": "Failed to load options",
"playAudioPreview": "Play audio preview",
"pauseAudioPreview": "Pause audio preview"
},
"valueControl": {
"header": {
"prefix": "Automatically update the value",

View File

@@ -0,0 +1,272 @@
import { createTestingPinia } from '@pinia/testing'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor } from '@testing-library/vue'
import axios, { AxiosError, AxiosHeaders } from 'axios'
import type * as AxiosModule from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
RemoteComboConfig,
RemoteItemSchema
} from '@/schemas/nodeDefSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockWidget } from './widgetTestUtils'
// Preserve everything axios exports — only `default.get` is the call site we
// drive. Other modules in the import graph (e.g. workspaceApi) call
// axios.create() at module-load time, so we can't replace the default outright.
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof AxiosModule>()
return {
...actual,
default: { ...actual.default, get: vi.fn() }
}
})
// All four auth-related composables are mocked at module level so the SFC's
// imports never pull in firebase / vuefire. Their return shapes only need to
// satisfy the call sites the widget actually hits.
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: { teamWorkspacesEnabled: false } })
}))
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ currentWorkspace: null })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
userId: undefined,
getAuthHeader: vi.fn(() => Promise.resolve(null))
})
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => ({ getApiKey: () => null })
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
// Minimal stub: surfaces the props the widget binds (so we can assert on them)
// and exposes click affordances that emit `update:selected` for the user-action
// tests. The real FormDropdown's rendering is tested in its own suite.
const FormDropdownStub = {
name: 'FormDropdown',
props: [
'selected',
'items',
'placeholder',
'multiple',
'showSort',
'showLayoutSwitcher',
'searcher',
'layoutMode'
],
emits: ['update:selected', 'update:layoutMode'],
template: `
<div data-testid="dropdown">
<span data-testid="placeholder">{{ placeholder }}</span>
<span data-testid="items-count">{{ items.length }}</span>
<button
v-for="item in items"
:key="item.id"
:data-testid="'item-' + item.id"
@click="$emit('update:selected', new Set([item.id]))"
>
{{ item.name }}
</button>
<button
data-testid="deselect"
@click="$emit('update:selected', new Set())"
>×</button>
</div>
`
}
const baseSchema: RemoteItemSchema = {
value_field: 'id',
label_field: 'name',
preview_type: 'image'
}
function buildWidget(
remoteCombo: Partial<Omit<RemoteComboConfig, 'route' | 'item_schema'>> = {},
value: string | undefined = undefined
): SimplifiedWidget<string | undefined> {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'voice',
remote_combo: {
route: '/voices',
item_schema: baseSchema,
...remoteCombo
}
}
return createMockWidget<string | undefined>({
name: 'voice',
type: 'COMBO',
value,
spec
})
}
function renderWidget(
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined = undefined
) {
return render(RichComboWidget, {
props: {
widget,
modelValue: modelValue ?? widget.value
},
global: {
plugins: [createTestingPinia(), i18n],
stubs: { FormDropdown: FormDropdownStub }
}
})
}
function mockAxiosResponseOnce(data: unknown) {
vi.mocked(axios.get).mockResolvedValueOnce({ data })
}
function mockAxiosErrorOnce(status: number) {
vi.mocked(axios.get).mockRejectedValueOnce(
new AxiosError(`HTTP ${status}`, 'ERR_BAD_RESPONSE', undefined, undefined, {
status,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
})
)
}
function mockAxiosNetworkErrorOnce() {
vi.mocked(axios.get).mockRejectedValueOnce(
new AxiosError('Network Error', 'ERR_NETWORK')
)
}
beforeEach(() => {
vi.clearAllMocks()
// Cache API isn't in happy-dom by default. Stub a no-op cache so getCached
// always returns null (forces a fetch) and setCache/clearCache resolve.
vi.stubGlobal('caches', {
open: vi.fn(() =>
Promise.resolve({
match: vi.fn(() => Promise.resolve(undefined)),
put: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve(true))
})
)
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('RichComboWidget', () => {
it('mounts, fetches, and renders the items returned from the route', async () => {
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
expect(screen.getByText('Alice')).toBeTruthy()
expect(screen.getByText('Bob')).toBeTruthy()
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('shows the load-failed placeholder on a non-retriable 404 without retrying', async () => {
mockAxiosErrorOnce(404)
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.remoteCombo.loadFailed'
)
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('shows the load-failed placeholder when retries are exhausted', async () => {
// max_retries=1 lets us assert exhaustion without sleeping through the
// exponential backoff (`attempts++` then `attempts >= maxRetries` breaks
// before any setTimeout call).
mockAxiosNetworkErrorOnce()
renderWidget(buildWidget({ max_retries: 1 }))
await waitFor(() =>
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.remoteCombo.loadFailed'
)
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('refetches when the refresh button is clicked', async () => {
mockAxiosResponseOnce([{ id: 'a', name: 'Alice' }])
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('1')
)
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
await userEvent.click(screen.getByLabelText('g.refresh'))
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('clears modelValue to undefined when the selected item is toggled off (B1 regression)', async () => {
mockAxiosResponseOnce([{ id: 'a', name: 'Alice' }])
const { emitted } = renderWidget(buildWidget(), 'a')
expect(await screen.findByTestId('item-a')).toBeTruthy()
await userEvent.click(screen.getByTestId('deselect'))
const updates = emitted('update:modelValue')
expect(updates).toBeTruthy()
expect(updates!.at(-1)).toEqual([undefined])
})
it('preserves a stale modelValue when the fetched items do not contain that id', async () => {
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
const { emitted } = renderWidget(buildWidget(), 'stale-id')
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
// The selection sync watcher only mutates the internal selectedSet — it
// never writes to modelValue, so the stale id round-trips intact when the
// workflow is later saved.
expect(emitted('update:modelValue')).toBeFalsy()
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.uploadSelect.placeholder'
)
})
})

View File

@@ -0,0 +1,345 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
RemoteComboConfig,
RemoteItemSchema
} from '@/schemas/nodeDefSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@comfyorg/tailwind-utils'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
import { AssetKindKey } from './form/dropdown/types'
import {
buildSearchText,
extractItems,
getByPath,
mapToDropdownItem
} from '../utils/itemSchemaUtils'
import { fetchRemoteRoute } from '../utils/fetchRemoteRoute'
import {
buildCacheKey,
getBackoff,
isRetriableError,
summarizeError,
summarizePayload
} from '../utils/richComboHelpers'
const DEFAULT_MAX_RETRIES = 5
const DEFAULT_TIMEOUT = 30000
// --- Persistent cache using browser Cache API (survives page reloads) ---
const CACHE_NAME = 'comfy-remote-widget'
// Mirrors useAuthStore().getAuthHeader()'s priority chain so the cache is
// partitioned by the *active* auth context, not just the firebase user.
// Same firebase user across two workspaces, or across workspace ↔ personal,
// would otherwise share a cache and bleed data.
//
// Returns an opaque, non-secret identifier. The API-key branch deliberately
// returns a constant rather than the key value or a hash of it: hashing is
// async (SubtleCrypto), and grouping all keys on one machine under a single
// scope is an acceptable tradeoff for the rare key-rotation case.
function getAuthScope(): string {
const { flags } = useFeatureFlags()
if (flags.teamWorkspacesEnabled) {
const wsId = useWorkspaceAuthStore().currentWorkspace?.id
if (wsId) return `ws:${wsId}`
}
const uid = useAuthStore().userId
if (uid) return `fb:${uid}`
return useApiKeyAuthStore().getApiKey() ? 'apikey' : 'anon'
}
function cacheKeyFor(config: RemoteComboConfig): string {
return buildCacheKey(config, getAuthScope())
}
async function getCached(config: RemoteComboConfig): Promise<unknown[] | null> {
try {
const cache = await caches.open(CACHE_NAME)
const resp = await cache.match(cacheKeyFor(config))
if (!resp) return null
const entry = await resp.json()
const ttl = config.refresh
if (!ttl || ttl <= 0) return entry.data
if (Date.now() - entry.timestamp < ttl) return entry.data
return null
} catch {
return null
}
}
async function clearCache(config: RemoteComboConfig) {
try {
const cache = await caches.open(CACHE_NAME)
await cache.delete(cacheKeyFor(config))
} catch {
// ignore
}
}
async function setCache(config: RemoteComboConfig, data: unknown[]) {
try {
const cache = await caches.open(CACHE_NAME)
const body = JSON.stringify({ data, timestamp: Date.now() })
await cache.put(cacheKeyFor(config), new Response(body))
} catch {
// Cache API unavailable — widget still works, just no persistence
}
}
const { widget } = defineProps<{
widget: SimplifiedWidget<string | undefined>
}>()
const modelValue = defineModel<string>()
const { t } = useI18n()
const comboSpec = computed(() => {
if (widget.spec && isComboInputSpec(widget.spec)) {
return widget.spec
}
return undefined
})
const remoteConfig = computed<RemoteComboConfig | undefined>(
() => comboSpec.value?.remote_combo
)
const itemSchema = computed<RemoteItemSchema | undefined>(
() => remoteConfig.value?.item_schema
)
// --- Fetch state ---
const rawItems = ref<unknown[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
let abortController: AbortController | undefined
// --- Auto-select policy ---
// Only sets modelValue when it's empty; never overrides an existing value
// (valid or stale) — user intent and workflow portability are preserved.
function applyAutoSelect(config: RemoteComboConfig) {
if (modelValue.value) return
const list = items.value
if (list.length === 0) return
if (config.auto_select === 'first') {
modelValue.value = list[0].id
} else if (config.auto_select === 'last') {
modelValue.value = list[list.length - 1].id
}
}
async function fetchAll(config: RemoteComboConfig) {
const controller = abortController!
const maxRetries = config.max_retries ?? DEFAULT_MAX_RETRIES
loading.value = true
error.value = null
let attempts = 0
while (!controller.signal.aborted) {
try {
const res = await fetchRemoteRoute(config.route, {
timeout: config.timeout ?? DEFAULT_TIMEOUT,
signal: controller.signal
})
if (controller.signal.aborted) return
const fetchedItems = extractItems(res.data, config.response_key)
if (fetchedItems === null) {
console.error('RichComboWidget: expected array response', {
route: config.route,
responseKey: config.response_key,
received: summarizePayload(res.data)
})
error.value = t('widgets.remoteCombo.loadFailed')
break
}
await setCache(config, fetchedItems)
if (controller.signal.aborted) return
rawItems.value = fetchedItems
applyAutoSelect(config)
break
} catch (err: unknown) {
if (controller.signal.aborted) return
console.error('RichComboWidget: fetch error', {
route: config.route,
error: summarizeError(err)
})
if (!isRetriableError(err)) {
error.value = t('widgets.remoteCombo.loadFailed')
break
}
attempts++
if (attempts >= maxRetries) {
error.value = t('widgets.remoteCombo.loadFailed')
break
}
const delay = getBackoff(attempts)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
if (!controller.signal.aborted) {
loading.value = false
}
}
async function fetchItems(bypassCache = false) {
const config = remoteConfig.value
if (!config) return
// Claim the active controller before any async work so the cache-hit
// path can bail out if a later fetchItems supersedes us.
abortController?.abort()
const myController = new AbortController()
abortController = myController
// Check cache first (unless manual refresh)
if (!bypassCache) {
const cached = await getCached(config)
if (myController.signal.aborted) return
if (cached) {
rawItems.value = cached
applyAutoSelect(config)
return
}
}
// Reset items for fresh fetch
rawItems.value = []
await fetchAll(config)
}
onMounted(() => {
void fetchItems()
})
onUnmounted(() => {
abortController?.abort()
})
// --- Preview type ---
const assetKind = computed(() => itemSchema.value?.preview_type ?? 'image')
provide(AssetKindKey, assetKind)
// --- Item mapping ---
const items = computed<FormDropdownItem[]>(() => {
const schema = itemSchema.value
if (schema) {
return rawItems.value.map((raw) => mapToDropdownItem(raw, schema))
}
return rawItems.value.map((raw) => {
const val = String(raw ?? '')
return { id: val, name: val }
})
})
// --- Search ---
const searchIndex = computed(() => {
const schema = itemSchema.value
const fields = schema?.search_fields
if (!schema || !fields?.length) return new Map<string, string>()
const index = new Map<string, string>()
for (const raw of rawItems.value) {
const id = String(getByPath(raw, schema.value_field) ?? '')
const text = buildSearchText(raw, fields)
if (text) index.set(id, text)
}
return index
})
const layoutMode = ref<LayoutMode>('list')
const selectedSet = ref<Set<string>>(new Set())
async function searcher(query: string, searchItems: FormDropdownItem[]) {
if (!query.trim()) return searchItems
const q = query.toLowerCase()
return searchItems.filter((item) => {
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
return text.includes(q)
})
}
// --- Selection sync ---
watch(
[modelValue, items],
([val]) => {
selectedSet.value.clear()
if (val) {
const item = items.value.find((i) => i.id === val)
if (item) selectedSet.value.add(item.id)
}
},
{ immediate: true }
)
function handleRefresh() {
abortController?.abort()
error.value = null
const config = remoteConfig.value
// Sequence the cache delete before the refetch: otherwise the (very fast)
// setCache from a quickly-resolved network response can land the new entry
// before the still-pending cache.delete removes it, silently dropping the
// freshly-cached data on the next mount.
void (async () => {
if (config) await clearCache(config)
await fetchItems(true)
})()
}
function handleSelection(selected: Set<string>) {
modelValue.value = selected.values().next().value
}
const placeholder = computed(() => {
if (loading.value) return t('widgets.remoteCombo.loading')
if (error.value) return error.value
return t('widgets.uploadSelect.placeholder')
})
</script>
<template>
<div
class="flex w-full min-w-0 items-center gap-1 rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<FormDropdown
v-model:selected="selectedSet"
v-model:layout-mode="layoutMode"
:items="items"
:placeholder="placeholder"
:multiple="false"
:show-sort="false"
:show-layout-switcher="false"
:searcher="searcher"
class="min-w-0 flex-1"
@update:selected="handleSelection"
/>
<button
v-if="remoteConfig?.refresh_button !== false"
type="button"
:aria-label="t('g.refresh')"
:title="t('g.refresh')"
class="text-secondary flex size-7 shrink-0 items-center justify-center rounded-sm hover:bg-component-node-widget-background-hovered"
@click.stop="handleRefresh"
>
<i
:class="
cn('icon-[lucide--refresh-cw] size-3.5', loading && 'animate-spin')
"
/>
</button>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<template>
<RichComboWidget v-if="hasRemoteCombo" v-model="modelValue" :widget />
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-else-if="isDropdownUIWidget"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
@@ -24,6 +25,7 @@
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
@@ -53,6 +55,8 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
return undefined
})
const hasRemoteCombo = computed(() => !!comboSpec.value?.remote_combo)
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean

View File

@@ -33,6 +33,8 @@ interface Props {
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -59,6 +61,8 @@ const {
accept,
filterOptions = [],
sortOptions = getDefaultSortOptions(),
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -229,6 +233,8 @@ function handleSelection(item: FormDropdownItem, index: number) {
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -68,7 +68,11 @@ const theButtonStyle = computed(() =>
{{ placeholder }}
</span>
<span v-else>
{{ selectedItems.map((item) => item.label ?? item.name).join(', ') }}
{{
selectedItems
.map((item) => item.label || item.name || item.id)
.join(', ')
}}
</span>
</span>
<i

View File

@@ -20,6 +20,8 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -31,6 +33,8 @@ const {
isSelected,
filterOptions,
sortOptions,
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -112,6 +116,8 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter
@@ -145,6 +151,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:description="item.description"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>

View File

@@ -16,8 +16,10 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
defineProps<{
const { showSort = true, showLayoutSwitcher = true } = defineProps<{
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -112,6 +114,7 @@ function toggleBaseModelSelection(item: FilterOption) {
/>
<Button
v-if="showSort"
ref="sortTriggerRef"
:aria-label="t('assetBrowser.sortBy')"
:title="t('assetBrowser.sortBy')"
@@ -132,6 +135,7 @@ function toggleBaseModelSelection(item: FilterOption) {
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<Popover
v-if="showSort"
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
@@ -306,6 +310,7 @@ function toggleBaseModelSelection(item: FilterOption) {
</Popover>
<div
v-if="showLayoutSwitcher"
:class="
cn(
actionButtonStyle,

View File

@@ -28,11 +28,15 @@ const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isMesh = computed(() => assetKind?.value === 'mesh')
const isAudio = computed(() => assetKind?.value === 'audio')
const mediaContainerRef = ref<HTMLElement>()
const resolvedMeshPreview = ref<string | null>(null)
const meshPreviewAttempted = ref(false)
const audioRef = ref<HTMLAudioElement | null>(null)
const isPlayingAudio = ref(false)
function toLookupName(name: string): string {
const stripped = name.replace(/ \[output\]$/, '')
const slash = stripped.lastIndexOf('/')
@@ -68,6 +72,17 @@ function handleClick() {
emit('click', props.index)
}
function toggleAudioPreview(event: Event) {
event.stopPropagation()
const audio = audioRef.value
if (!audio) return
if (audio.paused) {
void audio.play().catch(() => {})
} else {
audio.pause()
}
}
function handleImageLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
@@ -148,6 +163,35 @@ function handleVideoLoad(event: Event) {
muted
@loadeddata="handleVideoLoad"
/>
<button
v-else-if="previewUrl && isAudio"
type="button"
:aria-label="
isPlayingAudio
? t('widgets.remoteCombo.pauseAudioPreview')
: t('widgets.remoteCombo.playAudioPreview')
"
:aria-pressed="isPlayingAudio"
class="flex size-full cursor-pointer items-center justify-center bg-component-node-widget-background hover:bg-component-node-widget-background-hovered"
@click.stop="toggleAudioPreview"
>
<audio
ref="audioRef"
:src="previewUrl"
preload="none"
@play="isPlayingAudio = true"
@pause="isPlayingAudio = false"
@ended="isPlayingAudio = false"
/>
<i
:class="
cn(
'text-secondary size-5',
isPlayingAudio ? 'icon-[lucide--pause]' : 'icon-[lucide--play]'
)
"
/>
</button>
<img
v-else-if="displayedPreviewUrl"
:src="displayedPreviewUrl"
@@ -193,6 +237,13 @@ function handleVideoLoad(event: Event) {
>
{{ label ?? name }}
</span>
<!-- Description -->
<span
v-if="description && layout !== 'grid'"
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
>
{{ description }}
</span>
<!-- Meta Data -->
<span v-if="actualDimensions" class="text-secondary block text-xs">
{{ actualDimensions }}

View File

@@ -12,7 +12,9 @@ export interface FormDropdownItem {
name: string
/** Original/alternate label (e.g., original filename) */
label?: string
/** Preview image/video URL */
/** Short description shown below the name in list view */
description?: string
/** Preview image/video/audio URL */
preview_url?: string
/** Whether the item is immutable (public model) - used for ownership filtering */
is_immutable?: boolean
@@ -47,6 +49,7 @@ export interface FormDropdownMenuItemProps {
previewUrl: string
name: string
label?: string
description?: string
layout?: LayoutMode
}

View File

@@ -0,0 +1,81 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchRemoteRoute } from '@/renderer/extensions/vueNodes/widgets/utils/fetchRemoteRoute'
import type { AuthHeader } from '@/types/authTypes'
const COMFY_API_BASE = 'https://api.example.test'
const mockAuth = vi.hoisted(() => ({
authHeader: null as AuthHeader | null
}))
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof axios>()
return {
default: {
...actual,
get: vi.fn()
}
}
})
vi.mock('@/config/comfyApi', () => ({
getComfyApiBaseUrl: () => COMFY_API_BASE
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi.fn(() => Promise.resolve(mockAuth.authHeader))
}))
}))
describe('fetchRemoteRoute', () => {
beforeEach(() => {
vi.mocked(axios.get).mockResolvedValue({ data: [] })
})
afterEach(() => {
mockAuth.authHeader = null
vi.clearAllMocks()
})
it('prepends the comfy api base URL to the route', async () => {
await fetchRemoteRoute('/voices')
const [url] = vi.mocked(axios.get).mock.calls[0]
expect(url).toBe(`${COMFY_API_BASE}/voices`)
})
it('injects the auth header when one is available', async () => {
mockAuth.authHeader = { Authorization: 'Bearer token-123' }
await fetchRemoteRoute('/voices')
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.headers).toEqual({ Authorization: 'Bearer token-123' })
})
it('does not set headers when no auth header is available', async () => {
mockAuth.authHeader = null
await fetchRemoteRoute('/voices')
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.headers).toBeUndefined()
})
it('forwards params, timeout and signal to axios', async () => {
const controller = new AbortController()
await fetchRemoteRoute('/voices', {
params: { filter: 'pro', limit: '10' },
timeout: 5000,
signal: controller.signal
})
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.params).toEqual({ filter: 'pro', limit: '10' })
expect(config?.timeout).toBe(5000)
expect(config?.signal).toBe(controller.signal)
})
it('returns the axios response', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: { items: [1, 2] } })
const res = await fetchRemoteRoute('/voices')
expect(res.data).toEqual({ items: [1, 2] })
})
})

View File

@@ -0,0 +1,41 @@
import axios from 'axios'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { useAuthStore } from '@/stores/authStore'
import type { AuthHeader } from '@/types/authTypes'
/**
* Resolve a RemoteComboOptions route to a full URL. Routes are always relative
* paths and are prepended with the comfy-api base URL.
*/
function resolveRoute(route: string): string {
return getComfyApiBaseUrl() + route
}
/**
* Get auth headers for a remote request. Always injected — comfy-api requires it.
*/
async function getRemoteAuthHeaders(): Promise<{ headers?: AuthHeader }> {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
if (authHeader) {
return { headers: authHeader }
}
return {}
}
/**
* Convenience: make an authenticated GET request to a remote route.
*/
export async function fetchRemoteRoute(
route: string,
options: {
params?: Record<string, string>
timeout?: number
signal?: AbortSignal
} = {}
) {
const url = resolveRoute(route)
const authHeaders = await getRemoteAuthHeaders()
return axios.get(url, { ...options, ...authHeaders })
}

View File

@@ -0,0 +1,254 @@
import { describe, expect, it } from 'vitest'
import {
buildSearchText,
extractItems,
getByPath,
mapToDropdownItem,
resolveLabel
} from '@/renderer/extensions/vueNodes/widgets/utils/itemSchemaUtils'
describe('getByPath', () => {
it('returns a top-level value for a plain key', () => {
expect(getByPath({ name: 'Alice' }, 'name')).toBe('Alice')
})
it('traverses nested objects via dot-path', () => {
expect(getByPath({ profile: { name: 'Alice' } }, 'profile.name')).toBe(
'Alice'
)
})
it('treats numeric segments as array indices', () => {
expect(getByPath({ items: ['a', 'b', 'c'] }, 'items.1')).toBe('b')
})
it('combines nested objects and array indices', () => {
const obj = { data: { results: [{ id: 'x' }, { id: 'y' }] } }
expect(getByPath(obj, 'data.results.1.id')).toBe('y')
})
it('returns undefined for a missing top-level key', () => {
expect(getByPath({ a: 1 }, 'b')).toBeUndefined()
})
it('returns undefined when traversing past a null segment', () => {
expect(getByPath({ a: null }, 'a.b')).toBeUndefined()
})
it('returns undefined when the root is null', () => {
expect(getByPath(null, 'a')).toBeUndefined()
})
it('returns undefined when the root is undefined', () => {
expect(getByPath(undefined, 'a')).toBeUndefined()
})
it('returns undefined for an out-of-bounds array index', () => {
expect(getByPath({ items: ['a'] }, 'items.5')).toBeUndefined()
})
})
describe('resolveLabel', () => {
it('resolves a plain dot-path to its value', () => {
expect(resolveLabel('name', { name: 'Alice' })).toBe('Alice')
})
it('resolves a nested dot-path without placeholders', () => {
expect(resolveLabel('profile.name', { profile: { name: 'Alice' } })).toBe(
'Alice'
)
})
it('substitutes a single {field} placeholder', () => {
expect(resolveLabel('Name: {name}', { name: 'Alice' })).toBe('Name: Alice')
})
it('substitutes multiple placeholders', () => {
expect(
resolveLabel('{first} {last}', { first: 'Alice', last: 'Liddell' })
).toBe('Alice Liddell')
})
it('substitutes placeholders with dot-paths', () => {
expect(
resolveLabel('{profile.name} ({profile.age})', {
profile: { name: 'Alice', age: 30 }
})
).toBe('Alice (30)')
})
it('replaces missing placeholder fields with an empty string', () => {
expect(resolveLabel('{name} - {missing}', { name: 'Alice' })).toBe(
'Alice - '
)
})
it('returns an empty string when a plain path resolves to undefined', () => {
expect(resolveLabel('missing', { a: 1 })).toBe('')
})
it('coerces numeric values to strings', () => {
expect(resolveLabel('{count}', { count: 5 })).toBe('5')
})
})
describe('mapToDropdownItem', () => {
it('maps required fields to id and name', () => {
const item = mapToDropdownItem(
{ voice_id: 'v1', label: 'Roger' },
{ value_field: 'voice_id', label_field: 'label', preview_type: 'image' }
)
expect(item).toEqual({
id: 'v1',
name: 'Roger',
description: undefined,
preview_url: undefined
})
})
it('includes description when description_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', desc: 'Laid-back American male' },
{
value_field: 'id',
label_field: 'label',
description_field: 'desc',
preview_type: 'image'
}
)
expect(item.description).toBe('Laid-back American male')
})
it('includes preview_url when preview_url_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', sample: 'https://example.com/a.mp3' },
{
value_field: 'id',
label_field: 'label',
preview_url_field: 'sample',
preview_type: 'audio'
}
)
expect(item.preview_url).toBe('https://example.com/a.mp3')
})
it('resolves label_field templates with placeholders', () => {
const item = mapToDropdownItem(
{ id: 'v1', first: 'Alice', last: 'Liddell' },
{
value_field: 'id',
label_field: '{first} {last}',
preview_type: 'image'
}
)
expect(item.name).toBe('Alice Liddell')
})
it('resolves dot-path fields for nested data', () => {
const item = mapToDropdownItem(
{ task_result: { elements: [{ element_id: 'e1', name: 'Elem' }] } },
{
value_field: 'task_result.elements.0.element_id',
label_field: 'task_result.elements.0.name',
preview_type: 'image'
}
)
expect(item.id).toBe('e1')
expect(item.name).toBe('Elem')
})
it('stringifies non-string value_field', () => {
const item = mapToDropdownItem(
{ id: 42, label: 'Answer' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('42')
})
it('returns an empty string id when value_field is missing', () => {
const item = mapToDropdownItem(
{ label: 'Orphan' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('')
})
})
describe('extractItems', () => {
it('returns the full response when responseKey is undefined', () => {
expect(extractItems([1, 2, 3])).toEqual([1, 2, 3])
})
it('extracts items from a top-level key', () => {
expect(
extractItems({ voices: [{ id: 'a' }, { id: 'b' }] }, 'voices')
).toEqual([{ id: 'a' }, { id: 'b' }])
})
it('extracts items via a dot-path', () => {
expect(extractItems({ data: { items: [1, 2] } }, 'data.items')).toEqual([
1, 2
])
})
it('returns an empty array for a valid empty list', () => {
expect(extractItems([])).toEqual([])
})
it('returns null when the path does not exist', () => {
expect(extractItems({ a: 1 }, 'nonexistent')).toBeNull()
})
it('returns null when the path resolves to a non-array', () => {
expect(
extractItems({ data: { items: 'not an array' } }, 'data.items')
).toBeNull()
})
it('returns null when the full response is not an array', () => {
expect(extractItems({ not: 'array' })).toBeNull()
})
it('returns null when response is null', () => {
expect(extractItems(null)).toBeNull()
})
})
describe('buildSearchText', () => {
it('joins multiple fields with a space', () => {
expect(buildSearchText({ a: 'Hello', b: 'World' }, ['a', 'b'])).toBe(
'hello world'
)
})
it('lowercases the result', () => {
expect(buildSearchText({ name: 'ALICE' }, ['name'])).toBe('alice')
})
it('drops missing fields', () => {
expect(buildSearchText({ name: 'Alice' }, ['name', 'missing'])).toBe(
'alice'
)
})
it('supports dot-path fields', () => {
expect(
buildSearchText({ profile: { name: 'Alice', age: 30 } }, [
'profile.name',
'profile.age'
])
).toBe('alice 30')
})
it('returns an empty string when all fields are missing', () => {
expect(buildSearchText({ name: 'Alice' }, ['missing'])).toBe('')
})
})

View File

@@ -0,0 +1,62 @@
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
/** Traverse an object by dot-path, treating numeric segments as array indices */
export function getByPath(obj: unknown, path: string): unknown {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc == null) return undefined
const idx = Number(key)
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
return (acc as Record<string, unknown>)[key]
}, obj)
}
/** Resolve a label — either dot-path or template with {field.path} placeholders */
export function resolveLabel(template: string, item: unknown): string {
if (!template.includes('{')) {
return String(getByPath(item, template) ?? '')
}
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
String(getByPath(item, path) ?? '')
)
}
/** Map a raw API object to a FormDropdownItem using the item_schema */
export function mapToDropdownItem(
raw: unknown,
schema: RemoteItemSchema
): FormDropdownItem {
return {
id: String(getByPath(raw, schema.value_field) ?? ''),
name: resolveLabel(schema.label_field, raw),
description: schema.description_field
? resolveLabel(schema.description_field, raw)
: undefined,
preview_url: schema.preview_url_field
? String(getByPath(raw, schema.preview_url_field) ?? '')
: undefined
}
}
/**
* Extract items array from a full API response using `responseKey`.
* Returns `null` when the resolved value isn't an array (path missing,
* wrong shape, etc.) so callers can distinguish a malformed response
* from a legitimate empty list.
*/
export function extractItems(
response: unknown,
responseKey?: string
): unknown[] | null {
const data = responseKey ? getByPath(response, responseKey) : response
return Array.isArray(data) ? data : null
}
/** Build search text for an item from the specified search fields */
export function buildSearchText(raw: unknown, searchFields: string[]): string {
return searchFields
.map((field) => String(getByPath(raw, field) ?? ''))
.filter(Boolean)
.join(' ')
.toLowerCase()
}

View File

@@ -0,0 +1,249 @@
import { AxiosError, AxiosHeaders } from 'axios'
import { describe, expect, it } from 'vitest'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
import {
buildCacheKey,
getBackoff,
isRetriableError,
summarizeError,
summarizePayload
} from '@/renderer/extensions/vueNodes/widgets/utils/richComboHelpers'
const baseConfig: RemoteComboConfig = {
route: '/voices',
item_schema: {
value_field: 'id',
label_field: 'name',
preview_type: 'image'
}
}
function parseKey(key: string): URLSearchParams {
return new URL(key).searchParams
}
describe('buildCacheKey', () => {
it('encodes the route and response_key', () => {
const params = parseKey(
buildCacheKey(
{
...baseConfig,
route: '/voices',
response_key: 'data.items'
},
'fb:user-a'
)
)
expect(params.get('route')).toBe('/voices')
expect(params.get('responseKey')).toBe('data.items')
})
it('partitions by authScope', () => {
const a = buildCacheKey(baseConfig, 'ws:team-a')
const b = buildCacheKey(baseConfig, 'ws:team-b')
expect(a).not.toBe(b)
expect(parseKey(a).get('u')).toBe('ws:team-a')
expect(parseKey(b).get('u')).toBe('ws:team-b')
})
it('treats workspace, firebase, and api-key scopes as distinct buckets', () => {
const ws = buildCacheKey(baseConfig, 'ws:abc')
const fb = buildCacheKey(baseConfig, 'fb:abc')
const apikey = buildCacheKey(baseConfig, 'apikey')
expect(new Set([ws, fb, apikey]).size).toBe(3)
})
it('falls back to "anon" when authScope is missing', () => {
expect(parseKey(buildCacheKey(baseConfig, null)).get('u')).toBe('anon')
expect(parseKey(buildCacheKey(baseConfig, undefined)).get('u')).toBe('anon')
})
it('treats missing optional fields as empty so the key stays stable', () => {
const params = parseKey(buildCacheKey(baseConfig, 'fb:user-a'))
expect(params.get('responseKey')).toBe('')
})
})
describe('getBackoff', () => {
it('grows exponentially from 1s', () => {
expect(getBackoff(1)).toBe(2000)
expect(getBackoff(2)).toBe(4000)
expect(getBackoff(3)).toBe(8000)
expect(getBackoff(4)).toBe(16000)
})
it('caps at 16s for higher attempt counts', () => {
expect(getBackoff(5)).toBe(16000)
expect(getBackoff(10)).toBe(16000)
expect(getBackoff(100)).toBe(16000)
})
})
describe('isRetriableError', () => {
function axiosErrorWithStatus(status: number): AxiosError {
return new AxiosError(
`HTTP ${status}`,
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
}
it('retries non-axios errors (e.g. unexpected throws)', () => {
expect(isRetriableError(new Error('boom'))).toBe(true)
expect(isRetriableError('string error')).toBe(true)
expect(isRetriableError(undefined)).toBe(true)
})
it('retries axios errors with no response (network failures)', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(isRetriableError(err)).toBe(true)
})
it('retries 5xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(500))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(502))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(503))).toBe(true)
})
it('retries 408 (request timeout) and 429 (too many requests)', () => {
expect(isRetriableError(axiosErrorWithStatus(408))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(429))).toBe(true)
})
it('does not retry other 4xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(400))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(401))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(403))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(404))).toBe(false)
})
})
describe('summarizeError', () => {
it('extracts message, code and status from an axios error', () => {
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status: 500,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
expect(summarizeError(err)).toEqual({
message: 'Request failed',
code: 'ERR_BAD_RESPONSE',
status: 500
})
})
it('does not include axios config, headers, request or response data', () => {
const authedConfig = {
url: '/voices',
method: 'get',
headers: new AxiosHeaders({ Authorization: 'Bearer SECRET-TOKEN-123' })
}
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
authedConfig,
undefined,
{
status: 500,
statusText: '',
headers: { 'set-cookie': ['session=PRIVATE'] },
config: authedConfig,
data: { user_email: 'private@example.com' }
}
)
const summary = summarizeError(err)
expect(JSON.stringify(summary)).not.toContain('SECRET-TOKEN-123')
expect(JSON.stringify(summary)).not.toContain('PRIVATE')
expect(JSON.stringify(summary)).not.toContain('private@example.com')
expect(summary).not.toHaveProperty('config')
expect(summary).not.toHaveProperty('request')
expect(summary).not.toHaveProperty('response')
})
it('reports an axios network error with no response as undefined status', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(summarizeError(err)).toEqual({
message: 'Network Error',
code: 'ERR_NETWORK',
status: undefined
})
})
it('summarizes a plain Error using its name and message', () => {
expect(summarizeError(new TypeError('boom'))).toEqual({
message: 'boom',
name: 'TypeError'
})
})
it('coerces non-Error throwables to a message string', () => {
expect(summarizeError('oops')).toEqual({ message: 'oops' })
expect(summarizeError(42)).toEqual({ message: '42' })
expect(summarizeError(null)).toEqual({ message: 'null' })
expect(summarizeError(undefined)).toEqual({ message: 'undefined' })
})
})
describe('summarizePayload', () => {
it('reports array length without exposing values', () => {
expect(
summarizePayload([{ secret: 'a' }, { secret: 'b' }, { secret: 'c' }])
).toEqual({
type: 'array',
length: 3
})
})
it('reports object keys without exposing values', () => {
expect(
summarizePayload({ user_email: 'private@example.com', voices: ['x'] })
).toEqual({
type: 'object',
keys: ['user_email', 'voices'],
keyCount: 2
})
})
it('caps the keys sample at 10 but reports the full key count', () => {
const big: Record<string, number> = {}
for (let i = 0; i < 25; i++) big[`k${i}`] = i
const summary = summarizePayload(big) as {
type: string
keys: string[]
keyCount: number
}
expect(summary.type).toBe('object')
expect(summary.keys).toHaveLength(10)
expect(summary.keyCount).toBe(25)
})
it('distinguishes null and undefined', () => {
expect(summarizePayload(null)).toEqual({ type: 'null' })
expect(summarizePayload(undefined)).toEqual({ type: 'undefined' })
})
it('reports primitive types without their value', () => {
expect(summarizePayload('hello')).toEqual({ type: 'string' })
expect(summarizePayload(123)).toEqual({ type: 'number' })
expect(summarizePayload(true)).toEqual({ type: 'boolean' })
})
})

View File

@@ -0,0 +1,91 @@
import axios from 'axios'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
const BACKOFF_BASE_MS = 1000
const BACKOFF_CAP_MS = 16000
/**
* Build a stable cache key for a remote combo configuration.
*
* All remote-combo routes go through comfy-api with auth, so the cache is
* always partitioned by `authScope` — an opaque, non-secret identifier of
* the active auth context (workspace id, firebase uid, etc.). Resolving the
* scope is the caller's responsibility, which keeps this helper pure and
* trivially testable.
*/
export function buildCacheKey(
config: RemoteComboConfig,
authScope: string | null | undefined
): string {
const params = new URLSearchParams({
route: config.route,
responseKey: config.response_key ?? '',
u: authScope ?? 'anon'
})
return `https://cache.comfy.invalid/?${params}`
}
/**
* Exponential backoff in milliseconds, capped at 16s. `count` is the
* number of failed attempts so far (1-indexed for the first retry).
*/
export function getBackoff(count: number): number {
return Math.min(BACKOFF_BASE_MS * Math.pow(2, count), BACKOFF_CAP_MS)
}
/**
* Distinguish transient errors (worth retrying) from permanent ones.
* 401/403/404 etc. won't fix themselves — retrying wastes time.
* Network-level failures (no response) are treated as retriable.
*/
export function isRetriableError(err: unknown): boolean {
if (!axios.isAxiosError(err)) return true
const status = err.response?.status
if (status == null) return true
if (status >= 500) return true
return status === 408 || status === 429
}
/**
* Build a console-safe summary of an unknown error. Authenticated remote
* routes inject auth headers via fetchRemoteRoute, and AxiosError serializes
* its `config` (including those headers) by default — so logging the raw
* error would leak bearer tokens to devtools and any attached telemetry.
* This summary keeps only the diagnostic essentials.
*/
export function summarizeError(err: unknown): Record<string, unknown> {
if (axios.isAxiosError(err)) {
return {
message: err.message,
code: err.code,
status: err.response?.status
}
}
if (err instanceof Error) {
return { message: err.message, name: err.name }
}
return { message: String(err) }
}
const PAYLOAD_KEY_SAMPLE = 10
/**
* Build a console-safe summary of a remote response payload. Logs the
* structural shape so devs can diagnose schema mismatches without the
* actual values, which for authenticated routes may contain private data.
*/
export function summarizePayload(data: unknown): Record<string, unknown> {
if (data === null) return { type: 'null' }
if (data === undefined) return { type: 'undefined' }
if (Array.isArray(data)) return { type: 'array', length: data.length }
if (typeof data === 'object') {
const keys = Object.keys(data as Record<string, unknown>)
return {
type: 'object',
keys: keys.slice(0, PAYLOAD_KEY_SAMPLE),
keyCount: keys.length
}
}
return { type: typeof data }
}

View File

@@ -5,6 +5,11 @@ import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
/**
* Plain remote combo config — feeds a standard combo dropdown from a remote endpoint.
* Handled by `useRemoteWidget` + `WidgetSelectDropdown`.
*/
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
@@ -15,6 +20,32 @@ const zRemoteWidgetConfig = z.object({
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zRemoteItemSchema = z.object({
value_field: z.string(),
label_field: z.string(),
preview_url_field: z.string().optional(),
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
description_field: z.string().optional(),
search_fields: z.array(z.string()).optional()
})
/**
* Rich remote combo config — feeds `RichComboWidget` with item previews, search, and filtering.
* Requires `item_schema`. Vue-nodes only. Routes are always relative paths and resolve against
* the comfy-api base URL with auth headers injected. The endpoint returns the full items array
* in a single response.
*/
const zRemoteComboConfig = z.object({
route: z.string().startsWith('/'),
item_schema: zRemoteItemSchema,
refresh_button: z.boolean().optional(),
auto_select: z.enum(['first', 'last']).optional(),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
response_key: z.string().optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
chip: z.boolean().optional()
@@ -96,6 +127,7 @@ export const zComboInputOptions = zBaseInputOptions.extend({
animated_image_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
remote_combo: zRemoteComboConfig.optional(),
/** Whether the widget is a multi-select widget. */
multi_select: zMultiSelectOption.optional()
})
@@ -352,7 +384,9 @@ export const zMatchTypeOptions = z.object({
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
export type RemoteComboConfig = z.infer<typeof zRemoteComboConfig>
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>

View File

@@ -71,4 +71,66 @@ describe('validateNodeDef', () => {
})
}
)
describe('remote_combo route validation', () => {
const buildNodeDef = (remoteCombo: object): unknown => ({
...EXAMPLE_NODE_DEF,
input: {
required: {
voice: ['COMBO', { remote_combo: remoteCombo }]
}
}
})
const baseRemoteCombo = {
item_schema: { value_field: 'id', label_field: 'name' }
}
it('accepts a relative route', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: '/voices'
})
)
).not.toBeNull()
})
it('rejects an absolute http URL', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'http://api.example.com/voices'
}),
() => {}
)
).toBeNull()
})
it('rejects an absolute https URL', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'https://api.example.com/voices'
}),
() => {}
)
).toBeNull()
})
it('rejects a route with no leading slash', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'voices'
}),
() => {}
)
).toBeNull()
})
})
})