diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 96c2d2065..701e14891 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -9,6 +9,8 @@ import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces' import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { LayoutSource } from '@/renderer/core/layout/types' +import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' +import { useNodeDefStore } from '@/stores/nodeDefStore' import type { WidgetValue } from '@/types/simplifiedWidget' import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph' @@ -20,6 +22,7 @@ export interface SafeWidgetData { label?: string options?: Record callback?: ((value: unknown) => void) | undefined + spec?: InputSpec } export interface VueNodeData { @@ -53,6 +56,7 @@ export interface GraphNodeManager { export function useGraphNodeManager(graph: LGraph): GraphNodeManager { // Get layout mutations composable const { createNode, deleteNode, setSource } = useLayoutMutations() + const nodeDefStore = useNodeDefStore() // Safe reactive data extracted from LiteGraph nodes const vueNodeData = reactive(new Map()) @@ -82,6 +86,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { ) { value = widget.options.values[0] } + const spec = nodeDefStore.getInputSpecForWidget(node, widget.name) return { name: widget.name, @@ -89,15 +94,14 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { value: value, label: widget.label, options: widget.options ? { ...widget.options } : undefined, - callback: widget.callback + callback: widget.callback, + spec } } catch (error) { return { name: widget.name || 'unknown', type: widget.type || 'text', - value: undefined, // Already a valid WidgetValue - options: undefined, - callback: undefined + value: undefined } } }) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index a07c2d1ea..d5bb80f89 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1872,7 +1872,15 @@ "copyTooltip": "Copy message to clipboard" }, "widgets": { - "selectModel": "Select model" + "selectModel": "Select model", + "uploadSelect": { + "placeholder": "Select...", + "placeholderImage": "Select image...", + "placeholderAudio": "Select audio...", + "placeholderVideo": "Select video...", + "placeholderModel": "Select model...", + "placeholderUnknown": "Select media..." + } }, "nodeHelpPage": { "inputs": "Inputs", diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue index c27cf8976..59f4fe3bf 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue @@ -137,7 +137,8 @@ const processedWidgets = computed((): ProcessedWidget[] => { value: widget.value, label: widget.label, options: widget.options, - callback: widget.callback + callback: widget.callback, + spec: widget.spec } const updateHandler = (value: unknown) => { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts index 28f950360..ca1556355 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts @@ -4,9 +4,12 @@ import Select from 'primevue/select' import type { SelectProps } from 'primevue/select' import { describe, expect, it } from 'vitest' +import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { SimplifiedWidget } from '@/types/simplifiedWidget' import WidgetSelect from './WidgetSelect.vue' +import WidgetSelectDefault from './WidgetSelectDefault.vue' +import WidgetSelectDropdown from './WidgetSelectDropdown.vue' describe('WidgetSelect Value Binding', () => { const createMockWidget = ( @@ -14,7 +17,8 @@ describe('WidgetSelect Value Binding', () => { options: Partial< SelectProps & { values?: string[]; return_index?: boolean } > = {}, - callback?: (value: string | number | undefined) => void + callback?: (value: string | number | undefined) => void, + spec?: ComboInputSpec ): SimplifiedWidget => ({ name: 'test_select', type: 'combo', @@ -23,7 +27,8 @@ describe('WidgetSelect Value Binding', () => { values: ['option1', 'option2', 'option3'], ...options }, - callback + callback, + spec }) const mountComponent = ( @@ -184,4 +189,44 @@ describe('WidgetSelect Value Binding', () => { expect(emitted![0]).toContain('100') }) }) + + describe('Spec-aware rendering', () => { + it('uses dropdown variant when combo spec enables image uploads', () => { + const spec: ComboInputSpec = { + type: 'COMBO', + name: 'test_select', + image_upload: true + } + const widget = createMockWidget('option1', {}, undefined, spec) + const wrapper = mountComponent(widget, 'option1') + + expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(true) + expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(false) + }) + + it('uses dropdown variant for audio uploads', () => { + const spec: ComboInputSpec = { + type: 'COMBO', + name: 'test_select', + audio_upload: true + } + const widget = createMockWidget('clip.wav', {}, undefined, spec) + const wrapper = mountComponent(widget, 'clip.wav') + const dropdown = wrapper.findComponent(WidgetSelectDropdown) + + expect(dropdown.exists()).toBe(true) + expect(dropdown.props('assetKind')).toBe('audio') + expect(dropdown.props('allowUpload')).toBe(false) + }) + + it('keeps default select when no spec or media hints are present', () => { + const widget = createMockWidget('plain', { + values: ['plain', 'text'] + }) + const wrapper = mountComponent(widget, 'plain') + + expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(true) + expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(false) + }) + }) }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue index 01cb75c18..34a48b3da 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue @@ -1,33 +1,32 @@ diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue new file mode 100644 index 000000000..01cb75c18 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue new file mode 100644 index 000000000..59abbe749 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -0,0 +1,233 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue new file mode 100644 index 000000000..31a6bbc34 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue @@ -0,0 +1,233 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue new file mode 100644 index 000000000..be3dfaca5 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownInput.vue @@ -0,0 +1,99 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue new file mode 100644 index 000000000..1ce307ccf --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenu.vue @@ -0,0 +1,96 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.vue new file mode 100644 index 000000000..b9dbc359f --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuActions.vue @@ -0,0 +1,174 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuFilter.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuFilter.vue new file mode 100644 index 000000000..19a9bb907 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuFilter.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuItem.vue b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuItem.vue new file mode 100644 index 000000000..10cd324c2 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdownMenuItem.vue @@ -0,0 +1,124 @@ + + + diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/shared.ts b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/shared.ts new file mode 100644 index 000000000..1c8efeac7 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/shared.ts @@ -0,0 +1,28 @@ +import type { DropdownItem, SortOption } from './types' + +export async function defaultSearcher(query: string, items: DropdownItem[]) { + if (query.trim() === '') return items + const words = query.trim().toLowerCase().split(' ') + return items.filter((item) => { + const name = item.name.toLowerCase() + return words.every((word) => name.includes(word)) + }) +} + +export function getDefaultSortOptions(): SortOption[] { + return [ + { + name: 'Default', + id: 'default', + sorter: ({ items }) => items.slice() + }, + { + name: 'A-Z', + id: 'a-z', + sorter: ({ items }) => + items.slice().sort((a, b) => { + return a.name.localeCompare(b.name) + }) + } + ] +} diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/types.ts b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/types.ts new file mode 100644 index 000000000..154b765cf --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/components/form/dropdown/types.ts @@ -0,0 +1,21 @@ +export type OptionId = string | number | symbol +export type SelectedKey = OptionId + +export interface DropdownItem { + id: SelectedKey + imageSrc: string + name: string + metadata: string +} +export interface SortOption { + id: OptionId + name: string + sorter: (ctx: { items: readonly DropdownItem[] }) => DropdownItem[] +} + +export interface FilterOption { + id: OptionId + name: string +} + +export type LayoutMode = 'list' | 'grid' | 'list-small' diff --git a/src/schemas/nodeDefSchema.ts b/src/schemas/nodeDefSchema.ts index 24adf85d4..75890d609 100644 --- a/src/schemas/nodeDefSchema.ts +++ b/src/schemas/nodeDefSchema.ts @@ -77,6 +77,7 @@ export const zComboInputOptions = zBaseInputOptions.extend({ image_folder: resultItemType.optional(), allow_batch: z.boolean().optional(), video_upload: z.boolean().optional(), + audio_upload: z.boolean().optional(), animated_image_upload: z.boolean().optional(), options: z.array(zComboOption).optional(), remote: zRemoteWidgetConfig.optional(), diff --git a/src/stores/nodeDefStore.ts b/src/stores/nodeDefStore.ts index 4f65647a7..3f4047bb6 100644 --- a/src/stores/nodeDefStore.ts +++ b/src/stores/nodeDefStore.ts @@ -352,6 +352,16 @@ export const useNodeDefStore = defineStore('nodeDef', () => { return nodeDef } + function getInputSpecForWidget( + node: LGraphNode, + widgetName: string + ): InputSpecV2 | undefined { + const nodeDef = fromLGraphNode(node) + if (!nodeDef) return undefined + + return nodeDef.inputs[widgetName] + } + /** * Registers a node definition filter. * @param filter - The filter to register @@ -424,6 +434,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => { updateNodeDefs, addNodeDef, fromLGraphNode, + getInputSpecForWidget, registerNodeDefFilter, unregisterNodeDefFilter } diff --git a/src/types/simplifiedWidget.ts b/src/types/simplifiedWidget.ts index 7dbc5f270..6dccd0124 100644 --- a/src/types/simplifiedWidget.ts +++ b/src/types/simplifiedWidget.ts @@ -2,6 +2,7 @@ * Simplified widget interface for Vue-based node rendering * Removes all DOM manipulation and positioning concerns */ +import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2' /** Valid types for widget values */ export type WidgetValue = @@ -36,6 +37,9 @@ export interface SimplifiedWidget< /** Callback fired when value changes */ callback?: (value: T) => void + /** Optional input specification backing this widget */ + spec?: InputSpecV2 + /** Optional serialization method for custom value handling */ serializeValue?: () => any diff --git a/src/types/widgetTypes.ts b/src/types/widgetTypes.ts index 06ef1e3e0..ed6cb20c9 100644 --- a/src/types/widgetTypes.ts +++ b/src/types/widgetTypes.ts @@ -1,3 +1,5 @@ import type { InjectionKey } from 'vue' +export type AssetKind = 'image' | 'video' | 'audio' | 'model' | 'unknown' + export const OnCloseKey: InjectionKey<() => void> = Symbol()