mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-08 15:29:52 +00:00
Compare commits
2 Commits
test/cov-L
...
dev/remote
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71cf071361 | ||
|
|
8527e16f74 |
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, provide, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { FilterOption } from '@/platform/assets/types/filterTypes'
|
||||||
|
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||||
|
|
||||||
|
import FormDropdown from './form/dropdown/FormDropdown.vue'
|
||||||
|
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
|
||||||
|
import { AssetKindKey } from './form/dropdown/types'
|
||||||
|
import {
|
||||||
|
buildSearchText,
|
||||||
|
extractFilterValues,
|
||||||
|
getByPath,
|
||||||
|
mapToDropdownItem
|
||||||
|
} from '../utils/resolveItemSchema'
|
||||||
|
import { fetchRemoteRoute } from '../utils/resolveRemoteRoute'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue?: string
|
||||||
|
widget: SimplifiedWidget<string | undefined>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const comboSpec = computed(() => {
|
||||||
|
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
|
||||||
|
return props.widget.spec
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
const remoteConfig = computed(() => comboSpec.value?.remote!)
|
||||||
|
const itemSchema = computed(() => remoteConfig.value?.item_schema!)
|
||||||
|
|
||||||
|
const rawItems = ref<unknown[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchItems() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetchRemoteRoute(remoteConfig.value.route, {
|
||||||
|
params: remoteConfig.value.query_params,
|
||||||
|
timeout: remoteConfig.value.timeout ?? 30000,
|
||||||
|
useComfyApi: remoteConfig.value.use_comfy_api
|
||||||
|
})
|
||||||
|
const data = remoteConfig.value.response_key
|
||||||
|
? res.data[remoteConfig.value.response_key]
|
||||||
|
: res.data
|
||||||
|
rawItems.value = Array.isArray(data) ? data : []
|
||||||
|
} catch (err) {
|
||||||
|
console.error('RichComboWidget: fetch error', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void fetchItems()
|
||||||
|
})
|
||||||
|
|
||||||
|
const assetKind = computed(() => {
|
||||||
|
const pt = itemSchema.value.preview_type ?? 'image'
|
||||||
|
return pt as 'image' | 'video' | 'audio'
|
||||||
|
})
|
||||||
|
|
||||||
|
provide(AssetKindKey, assetKind)
|
||||||
|
|
||||||
|
const items = computed<FormDropdownItem[]>(() =>
|
||||||
|
rawItems.value.map((raw) => mapToDropdownItem(raw, itemSchema.value))
|
||||||
|
)
|
||||||
|
|
||||||
|
const searchIndex = computed(() => {
|
||||||
|
const schema = itemSchema.value
|
||||||
|
const fields = schema.search_fields ?? [schema.label_field]
|
||||||
|
const index = new Map<string, string>()
|
||||||
|
for (const raw of rawItems.value) {
|
||||||
|
const id = String(getByPath(raw, schema.value_field) ?? '')
|
||||||
|
index.set(id, buildSearchText(raw, fields))
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterOptions = computed<FilterOption[]>(() => {
|
||||||
|
const schema = itemSchema.value
|
||||||
|
if (!schema.filter_field) return []
|
||||||
|
const values = extractFilterValues(rawItems.value, schema.filter_field)
|
||||||
|
return [
|
||||||
|
{ name: 'All', value: 'all' },
|
||||||
|
...values.map((v) => ({ name: v, value: v }))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterSelected = ref('all')
|
||||||
|
const layoutMode = ref<LayoutMode>('list')
|
||||||
|
const selectedSet = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const filteredItems = computed<FormDropdownItem[]>(() => {
|
||||||
|
const schema = itemSchema.value
|
||||||
|
if (filterSelected.value === 'all' || !schema.filter_field) {
|
||||||
|
return items.value
|
||||||
|
}
|
||||||
|
const filterField = schema.filter_field
|
||||||
|
return rawItems.value
|
||||||
|
.filter(
|
||||||
|
(raw) =>
|
||||||
|
String(getByPath(raw, filterField) ?? '') === filterSelected.value
|
||||||
|
)
|
||||||
|
.map((raw) => mapToDropdownItem(raw, schema))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function searcher(
|
||||||
|
query: string,
|
||||||
|
searchItems: FormDropdownItem[],
|
||||||
|
_onCleanup: (cleanupFn: () => void) => void
|
||||||
|
): Promise<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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.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() {
|
||||||
|
void fetchItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelection(selected: Set<string>) {
|
||||||
|
const id = selected.values().next().value
|
||||||
|
if (id) {
|
||||||
|
emit('update:modelValue', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full items-center gap-1">
|
||||||
|
<FormDropdown
|
||||||
|
v-model:selected="selectedSet"
|
||||||
|
v-model:filter-selected="filterSelected"
|
||||||
|
v-model:layout-mode="layoutMode"
|
||||||
|
:items="filteredItems"
|
||||||
|
:placeholder="loading ? 'Loading...' : t('widgets.uploadSelect.placeholder')"
|
||||||
|
:multiple="false"
|
||||||
|
:filter-options="[]"
|
||||||
|
:show-sort="false"
|
||||||
|
:show-layout-switcher="false"
|
||||||
|
:searcher="searcher"
|
||||||
|
class="flex-1"
|
||||||
|
@update:selected="handleSelection"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="remoteConfig?.refresh_button !== false"
|
||||||
|
class="flex size-7 shrink-0 items-center justify-center rounded text-secondary hover:bg-component-node-widget-background-hovered"
|
||||||
|
title="Refresh"
|
||||||
|
@pointerdown.stop
|
||||||
|
@click.stop="handleRefresh"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="[
|
||||||
|
'icon-[lucide--refresh-cw] size-3.5',
|
||||||
|
loading && 'animate-spin'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<RichComboWidget
|
||||||
|
v-if="hasItemSchema"
|
||||||
|
v-model="modelValue"
|
||||||
|
:widget
|
||||||
|
/>
|
||||||
<WidgetSelectDropdown
|
<WidgetSelectDropdown
|
||||||
v-if="isDropdownUIWidget"
|
v-else-if="isDropdownUIWidget"
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
:widget
|
:widget
|
||||||
:node-type="widget.nodeType ?? nodeType"
|
:node-type="widget.nodeType ?? nodeType"
|
||||||
@@ -24,6 +29,7 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { assetService } from '@/platform/assets/services/assetService'
|
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 WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
|
||||||
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
|
||||||
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
|
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
|
||||||
@@ -53,6 +59,10 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
|
|||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasItemSchema = computed(
|
||||||
|
() => !!comboSpec.value?.remote?.item_schema
|
||||||
|
)
|
||||||
|
|
||||||
const specDescriptor = computed<{
|
const specDescriptor = computed<{
|
||||||
kind: AssetKind
|
kind: AssetKind
|
||||||
allowUpload: boolean
|
allowUpload: boolean
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ interface Props {
|
|||||||
accept?: string
|
accept?: string
|
||||||
filterOptions?: FilterOption[]
|
filterOptions?: FilterOption[]
|
||||||
sortOptions?: SortOption[]
|
sortOptions?: SortOption[]
|
||||||
|
showSort?: boolean
|
||||||
|
showLayoutSwitcher?: boolean
|
||||||
showOwnershipFilter?: boolean
|
showOwnershipFilter?: boolean
|
||||||
ownershipOptions?: OwnershipFilterOption[]
|
ownershipOptions?: OwnershipFilterOption[]
|
||||||
showBaseModelFilter?: boolean
|
showBaseModelFilter?: boolean
|
||||||
@@ -61,6 +63,8 @@ const {
|
|||||||
accept,
|
accept,
|
||||||
filterOptions = [],
|
filterOptions = [],
|
||||||
sortOptions = getDefaultSortOptions(),
|
sortOptions = getDefaultSortOptions(),
|
||||||
|
showSort = true,
|
||||||
|
showLayoutSwitcher = true,
|
||||||
showOwnershipFilter,
|
showOwnershipFilter,
|
||||||
ownershipOptions,
|
ownershipOptions,
|
||||||
showBaseModelFilter,
|
showBaseModelFilter,
|
||||||
@@ -232,6 +236,8 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
|||||||
v-model:base-model-selected="baseModelSelected"
|
v-model:base-model-selected="baseModelSelected"
|
||||||
:filter-options
|
:filter-options
|
||||||
:sort-options
|
:sort-options
|
||||||
|
:show-sort
|
||||||
|
:show-layout-switcher="showLayoutSwitcher"
|
||||||
:show-ownership-filter
|
:show-ownership-filter
|
||||||
:ownership-options
|
:ownership-options
|
||||||
:show-base-model-filter
|
:show-base-model-filter
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ interface Props {
|
|||||||
isSelected: (item: FormDropdownItem, index: number) => boolean
|
isSelected: (item: FormDropdownItem, index: number) => boolean
|
||||||
filterOptions: FilterOption[]
|
filterOptions: FilterOption[]
|
||||||
sortOptions: SortOption[]
|
sortOptions: SortOption[]
|
||||||
|
showSort?: boolean
|
||||||
|
showLayoutSwitcher?: boolean
|
||||||
showOwnershipFilter?: boolean
|
showOwnershipFilter?: boolean
|
||||||
ownershipOptions?: OwnershipFilterOption[]
|
ownershipOptions?: OwnershipFilterOption[]
|
||||||
showBaseModelFilter?: boolean
|
showBaseModelFilter?: boolean
|
||||||
@@ -31,6 +33,8 @@ const {
|
|||||||
isSelected,
|
isSelected,
|
||||||
filterOptions,
|
filterOptions,
|
||||||
sortOptions,
|
sortOptions,
|
||||||
|
showSort = true,
|
||||||
|
showLayoutSwitcher = true,
|
||||||
showOwnershipFilter,
|
showOwnershipFilter,
|
||||||
ownershipOptions,
|
ownershipOptions,
|
||||||
showBaseModelFilter,
|
showBaseModelFilter,
|
||||||
@@ -112,6 +116,8 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
|||||||
v-model:ownership-selected="ownershipSelected"
|
v-model:ownership-selected="ownershipSelected"
|
||||||
v-model:base-model-selected="baseModelSelected"
|
v-model:base-model-selected="baseModelSelected"
|
||||||
:sort-options
|
:sort-options
|
||||||
|
:show-sort
|
||||||
|
:show-layout-switcher="showLayoutSwitcher"
|
||||||
:show-ownership-filter
|
:show-ownership-filter
|
||||||
:ownership-options
|
:ownership-options
|
||||||
:show-base-model-filter
|
:show-base-model-filter
|
||||||
@@ -145,6 +151,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
|||||||
:preview-url="item.preview_url ?? ''"
|
:preview-url="item.preview_url ?? ''"
|
||||||
:name="item.name"
|
:name="item.name"
|
||||||
:label="item.label"
|
:label="item.label"
|
||||||
|
:description="item.description"
|
||||||
:layout="layoutMode"
|
:layout="layoutMode"
|
||||||
@click="emit('item-click', item, index)"
|
@click="emit('item-click', item, index)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,8 +18,13 @@ import type { LayoutMode, SortOption } from './types'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const overlayProps = useTransformCompatOverlayProps()
|
const overlayProps = useTransformCompatOverlayProps()
|
||||||
|
|
||||||
defineProps<{
|
const {
|
||||||
|
showSort = true,
|
||||||
|
showLayoutSwitcher = true
|
||||||
|
} = defineProps<{
|
||||||
sortOptions: SortOption[]
|
sortOptions: SortOption[]
|
||||||
|
showSort?: boolean
|
||||||
|
showLayoutSwitcher?: boolean
|
||||||
showOwnershipFilter?: boolean
|
showOwnershipFilter?: boolean
|
||||||
ownershipOptions?: OwnershipFilterOption[]
|
ownershipOptions?: OwnershipFilterOption[]
|
||||||
showBaseModelFilter?: boolean
|
showBaseModelFilter?: boolean
|
||||||
@@ -114,6 +119,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
v-if="showSort"
|
||||||
ref="sortTriggerRef"
|
ref="sortTriggerRef"
|
||||||
variant="textonly"
|
variant="textonly"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -132,6 +138,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
|||||||
<i class="icon-[lucide--arrow-up-down] size-4" />
|
<i class="icon-[lucide--arrow-up-down] size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Popover
|
<Popover
|
||||||
|
v-if="showSort"
|
||||||
ref="sortPopoverRef"
|
ref="sortPopoverRef"
|
||||||
:dismissable="true"
|
:dismissable="true"
|
||||||
:close-on-escape="true"
|
:close-on-escape="true"
|
||||||
@@ -309,6 +316,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
|||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
v-if="showLayoutSwitcher"
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
actionButtonStyle,
|
actionButtonStyle,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface Props {
|
|||||||
previewUrl: string
|
previewUrl: string
|
||||||
name: string
|
name: string
|
||||||
label?: string
|
label?: string
|
||||||
|
description?: string
|
||||||
layout?: LayoutMode
|
layout?: LayoutMode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,11 +28,31 @@ const actualDimensions = ref<string | null>(null)
|
|||||||
const assetKind = inject(AssetKindKey)
|
const assetKind = inject(AssetKindKey)
|
||||||
|
|
||||||
const isVideo = computed(() => assetKind?.value === 'video')
|
const isVideo = computed(() => assetKind?.value === 'video')
|
||||||
|
const isAudio = computed(() => assetKind?.value === 'audio')
|
||||||
|
|
||||||
|
const audioRef = ref<HTMLAudioElement | null>(null)
|
||||||
|
const isPlayingAudio = ref(false)
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
emit('click', props.index)
|
emit('click', props.index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAudioPreview(event: Event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!audioRef.value) return
|
||||||
|
if (isPlayingAudio.value) {
|
||||||
|
audioRef.value.pause()
|
||||||
|
isPlayingAudio.value = false
|
||||||
|
} else {
|
||||||
|
void audioRef.value.play()
|
||||||
|
isPlayingAudio.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAudioEnded() {
|
||||||
|
isPlayingAudio.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function handleImageLoad(event: Event) {
|
function handleImageLoad(event: Event) {
|
||||||
emit('mediaLoad', event)
|
emit('mediaLoad', event)
|
||||||
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
if (!event.target || !(event.target instanceof HTMLImageElement)) return
|
||||||
@@ -107,6 +128,25 @@ function handleVideoLoad(event: Event) {
|
|||||||
muted
|
muted
|
||||||
@loadeddata="handleVideoLoad"
|
@loadeddata="handleVideoLoad"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-else-if="previewUrl && isAudio"
|
||||||
|
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-tr from-violet-500 via-purple-500 to-fuchsia-400"
|
||||||
|
@click.stop="toggleAudioPreview"
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
ref="audioRef"
|
||||||
|
:src="previewUrl"
|
||||||
|
preload="none"
|
||||||
|
@ended="handleAudioEnded"
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
isPlayingAudio
|
||||||
|
? 'icon-[lucide--pause] size-5 text-white'
|
||||||
|
: 'icon-[lucide--play] size-5 text-white'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<img
|
<img
|
||||||
v-else-if="previewUrl"
|
v-else-if="previewUrl"
|
||||||
:src="previewUrl"
|
:src="previewUrl"
|
||||||
@@ -144,6 +184,13 @@ function handleVideoLoad(event: Event) {
|
|||||||
>
|
>
|
||||||
{{ label ?? name }}
|
{{ label ?? name }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Description -->
|
||||||
|
<span
|
||||||
|
v-if="description && layout !== 'grid'"
|
||||||
|
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</span>
|
||||||
<!-- Meta Data -->
|
<!-- Meta Data -->
|
||||||
<span v-if="actualDimensions" class="text-secondary block text-xs">
|
<span v-if="actualDimensions" class="text-secondary block text-xs">
|
||||||
{{ actualDimensions }}
|
{{ actualDimensions }}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export interface FormDropdownItem {
|
|||||||
name: string
|
name: string
|
||||||
/** Original/alternate label (e.g., original filename) */
|
/** Original/alternate label (e.g., original filename) */
|
||||||
label?: string
|
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
|
preview_url?: string
|
||||||
/** Whether the item is immutable (public model) - used for ownership filtering */
|
/** Whether the item is immutable (public model) - used for ownership filtering */
|
||||||
is_immutable?: boolean
|
is_immutable?: boolean
|
||||||
|
|||||||
@@ -214,7 +214,9 @@ const addComboWidget = (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (inputSpec.remote) {
|
if (inputSpec.remote && !inputSpec.remote.item_schema) {
|
||||||
|
// Skip useRemoteWidget when item_schema is present —
|
||||||
|
// RichComboWidget handles its own data fetching and rendering.
|
||||||
if (!isComboWidget(widget)) {
|
if (!isComboWidget(widget)) {
|
||||||
throw new Error(`Expected combo widget but received ${widget.type}`)
|
throw new Error(`Expected combo widget but received ${widget.type}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import axios from 'axios'
|
|||||||
|
|
||||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||||
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
|
||||||
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import {
|
||||||
|
getRemoteAuthHeaders,
|
||||||
|
resolveRoute
|
||||||
|
} from '../utils/resolveRemoteRoute'
|
||||||
|
|
||||||
const MAX_RETRIES = 5
|
const MAX_RETRIES = 5
|
||||||
const TIMEOUT = 4096
|
const TIMEOUT = 4096
|
||||||
@@ -21,17 +23,6 @@ interface CacheEntry<T> {
|
|||||||
failed?: boolean
|
failed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAuthHeaders() {
|
|
||||||
if (isCloud) {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const authHeader = await authStore.getAuthHeader()
|
|
||||||
return {
|
|
||||||
...(authHeader && { headers: authHeader })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataCache = new Map<string, CacheEntry<unknown>>()
|
const dataCache = new Map<string, CacheEntry<unknown>>()
|
||||||
|
|
||||||
const createCacheKey = (config: RemoteWidgetConfig): string => {
|
const createCacheKey = (config: RemoteWidgetConfig): string => {
|
||||||
@@ -73,9 +64,10 @@ const fetchData = async (
|
|||||||
) => {
|
) => {
|
||||||
const { route, response_key, query_params, timeout = TIMEOUT } = config
|
const { route, response_key, query_params, timeout = TIMEOUT } = config
|
||||||
|
|
||||||
const authHeaders = await getAuthHeaders()
|
const url = resolveRoute(route, config.use_comfy_api)
|
||||||
|
const authHeaders = await getRemoteAuthHeaders(config.use_comfy_api)
|
||||||
|
|
||||||
const res = await axios.get(route, {
|
const res = await axios.get(url, {
|
||||||
params: query_params,
|
params: query_params,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
timeout,
|
timeout,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
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 full API response using response_key */
|
||||||
|
export function extractItems(
|
||||||
|
response: unknown,
|
||||||
|
responseKey?: string
|
||||||
|
): unknown[] {
|
||||||
|
const data = responseKey ? getByPath(response, responseKey) : response
|
||||||
|
return Array.isArray(data) ? data : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract unique filter values from items */
|
||||||
|
export function extractFilterValues(
|
||||||
|
items: unknown[],
|
||||||
|
filterField: string
|
||||||
|
): string[] {
|
||||||
|
const values = new Set<string>()
|
||||||
|
for (const item of items) {
|
||||||
|
const value = getByPath(item, filterField)
|
||||||
|
if (value != null) values.add(String(value))
|
||||||
|
}
|
||||||
|
return Array.from(values).sort()
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a RemoteOptions route to a full URL.
|
||||||
|
* - useComfyApi=true → prepend getComfyApiBaseUrl()
|
||||||
|
* - Otherwise → use as-is
|
||||||
|
*/
|
||||||
|
export function resolveRoute(
|
||||||
|
route: string,
|
||||||
|
useComfyApi?: boolean
|
||||||
|
): string {
|
||||||
|
if (useComfyApi) {
|
||||||
|
return getComfyApiBaseUrl() + route
|
||||||
|
}
|
||||||
|
return route
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get auth headers for a remote request.
|
||||||
|
* - useComfyApi=true → inject auth headers (comfy-api requires it)
|
||||||
|
* - Otherwise → no auth headers injected
|
||||||
|
*/
|
||||||
|
export async function getRemoteAuthHeaders(
|
||||||
|
useComfyApi?: boolean
|
||||||
|
): Promise<Record<string, any>> {
|
||||||
|
if (useComfyApi) {
|
||||||
|
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
|
||||||
|
useComfyApi?: boolean
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const { useComfyApi, ...requestOptions } = options
|
||||||
|
const url = resolveRoute(route, useComfyApi)
|
||||||
|
const authHeaders = await getRemoteAuthHeaders(useComfyApi)
|
||||||
|
return axios.get(url, { ...requestOptions, ...authHeaders })
|
||||||
|
}
|
||||||
@@ -5,6 +5,16 @@ import { resultItemType } from '@/schemas/apiSchema'
|
|||||||
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
|
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
|
||||||
|
|
||||||
const zComboOption = z.union([z.string(), z.number()])
|
const zComboOption = z.union([z.string(), z.number()])
|
||||||
|
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(),
|
||||||
|
filter_field: z.string().optional()
|
||||||
|
})
|
||||||
|
|
||||||
const zRemoteWidgetConfig = z.object({
|
const zRemoteWidgetConfig = z.object({
|
||||||
route: z.string().url().or(z.string().startsWith('/')),
|
route: z.string().url().or(z.string().startsWith('/')),
|
||||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||||
@@ -13,7 +23,9 @@ const zRemoteWidgetConfig = z.object({
|
|||||||
refresh_button: z.boolean().optional(),
|
refresh_button: z.boolean().optional(),
|
||||||
control_after_refresh: z.enum(['first', 'last']).optional(),
|
control_after_refresh: z.enum(['first', 'last']).optional(),
|
||||||
timeout: z.number().gte(0).optional(),
|
timeout: z.number().gte(0).optional(),
|
||||||
max_retries: z.number().gte(0).optional()
|
max_retries: z.number().gte(0).optional(),
|
||||||
|
item_schema: zRemoteItemSchema.optional(),
|
||||||
|
use_comfy_api: z.boolean().optional()
|
||||||
})
|
})
|
||||||
const zMultiSelectOption = z.object({
|
const zMultiSelectOption = z.object({
|
||||||
placeholder: z.string().optional(),
|
placeholder: z.string().optional(),
|
||||||
@@ -354,6 +366,7 @@ export const zMatchTypeOptions = z.object({
|
|||||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
||||||
|
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
|
||||||
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
||||||
|
|
||||||
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
|
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
|
||||||
|
|||||||
Reference in New Issue
Block a user