Compare commits

..

3 Commits

Author SHA1 Message Date
bymyself
3ee8f166a9 fix: remove unused config file
The SUPPORT_URL logic was moved inline in useCoreCommands.ts
to support user email/ID prefilling. This removes the now-unused
config file to fix the knip check.
2025-11-05 21:11:05 -07:00
Alexander Brown
267e7d57b1 Merge branch 'main' into zendesk-prefill 2025-11-04 19:39:50 -08:00
Marwan Mostafa
4c5ae9cb8f feat: pre-fill user info in Zendesk support link
Add user email and ID as URL parameters when opening the Contact Support
link to improve support experience. Only includes user data when logged in.
2025-11-05 02:13:38 +02:00
131 changed files with 434 additions and 4264 deletions

View File

@@ -1,116 +0,0 @@
name: Post Release Summary Comment
description: Post or update a PR comment summarizing release links with diff, derived versions, and optional extras.
author: ComfyUI Frontend Team
inputs:
issue-number:
description: Optional PR number override (defaults to the current pull request)
default: ''
version_file:
description: Path to the JSON file containing the current version (relative to repo root)
required: true
outputs:
prev_version:
description: Previous version derived from the parent commit
value: ${{ steps.build.outputs.prev_version }}
runs:
using: composite
steps:
- name: Build comment body
id: build
shell: bash
run: |
set -euo pipefail
VERSION_FILE="${{ inputs.version_file }}"
REPO="${{ github.repository }}"
if [[ -z "$VERSION_FILE" ]]; then
echo '::error::version_file input is required' >&2
exit 1
fi
PREV_JSON=$(git show HEAD^1:"$VERSION_FILE" 2>/dev/null || true)
if [[ -z "$PREV_JSON" ]]; then
echo "::error::Unable to read $VERSION_FILE from parent commit" >&2
exit 1
fi
PREV_VERSION=$(printf '%s' "$PREV_JSON" | node -pe "const data = JSON.parse(require('fs').readFileSync(0, 'utf8')); if (!data.version) { process.exit(1); } data.version")
if [[ -z "$PREV_VERSION" ]]; then
echo "::error::Unable to determine previous version from $VERSION_FILE" >&2
exit 1
fi
NEW_VERSION=$(node -pe "const fs=require('fs');const data=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));if(!data.version){process.exit(1);}data.version" "$VERSION_FILE")
if [[ -z "$NEW_VERSION" ]]; then
echo "::error::Unable to determine current version from $VERSION_FILE" >&2
exit 1
fi
MARKER='release-summary'
MESSAGE='Publish jobs finished successfully:'
LINKS_VALUE=''
case "$VERSION_FILE" in
package.json)
LINKS_VALUE=$'PyPI|https://pypi.org/project/comfyui-frontend-package/{{version}}/\n''npm types|https://npm.im/@comfyorg/comfyui-frontend-types@{{version}}'
;;
apps/desktop-ui/package.json)
MARKER='desktop-release-summary'
LINKS_VALUE='npm desktop UI|https://npm.im/@comfyorg/desktop-ui@{{version}}'
;;
esac
DIFF_PREFIX='v'
DIFF_LABEL='Diff'
DIFF_URL="https://github.com/${REPO}/compare/${DIFF_PREFIX}${PREV_VERSION}...${DIFF_PREFIX}${NEW_VERSION}"
COMMENT_FILE=$(mktemp)
{
printf '<!--%s:%s%s-->\n' "$MARKER" "$DIFF_PREFIX" "$NEW_VERSION"
printf '%s\n\n' "$MESSAGE"
printf '- %s: [%s%s...%s%s](%s)\n' "$DIFF_LABEL" "$DIFF_PREFIX" "$PREV_VERSION" "$DIFF_PREFIX" "$NEW_VERSION" "$DIFF_URL"
while IFS= read -r RAW_LINE; do
LINE=$(printf '%s' "$RAW_LINE" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[[ -z "$LINE" ]] && continue
if [[ "$LINE" != *"|"* ]]; then
echo "::warning::Skipping malformed link entry: $LINE" >&2
continue
fi
LABEL=${LINE%%|*}
URL_TEMPLATE=${LINE#*|}
URL=${URL_TEMPLATE//\{\{version\}\}/$NEW_VERSION}
URL=${URL_TEMPLATE//\{\{prev_version\}\}/$PREV_VERSION}
printf '- %s: %s\n' "$LABEL" "$URL"
done <<< "$LINKS_VALUE"
printf '\n'
} > "$COMMENT_FILE"
{
echo "body<<'EOF'"
cat "$COMMENT_FILE"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
echo "prev_version=$PREV_VERSION" >> "$GITHUB_OUTPUT"
echo "marker_search=<!--$MARKER:" >> "$GITHUB_OUTPUT"
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
- name: Find existing comment
id: find
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: ${{ steps.build.outputs.marker_search }}
- name: Post or update comment
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
with:
issue-number: ${{ inputs.issue-number || github.event.pull_request.number }}
comment-id: ${{ steps.find.outputs.comment-id }}
body: ${{ steps.build.outputs.body }}
edit-mode: replace

View File

@@ -1,10 +1,9 @@
---
name: Publish Desktop UI on PR Merge
on:
pull_request:
types: ['closed']
branches: [main, core/*]
types: [ closed ]
branches: [ main, core/* ]
paths:
- 'apps/desktop-ui/package.json'
@@ -58,26 +57,3 @@ jobs:
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
comment_desktop_publish:
name: Comment Desktop Publish Summary
needs:
- resolve
- publish
if: success()
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2
- name: Post desktop release summary comment
uses: ./.github/actions/comment-release-links
with:
issue-number: ${{ github.event.pull_request.number }}
version_file: apps/desktop-ui/package.json

View File

@@ -1,10 +1,9 @@
---
name: Release Draft Create
on:
pull_request:
types: ['closed']
branches: [main, core/*]
types: [ closed ]
branches: [ main, core/* ]
paths:
- 'package.json'
@@ -31,9 +30,7 @@ jobs:
- name: Get current version
id: current_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Check if prerelease
id: check_prerelease
run: |
@@ -74,8 +71,7 @@ jobs:
name: dist-files
- name: Create release
id: create_release
uses: >-
softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -83,14 +79,9 @@ jobs:
dist.zip
tag_name: v${{ needs.build.outputs.version }}
target_commitish: ${{ github.event.pull_request.base.ref }}
make_latest: >-
${{ github.event.pull_request.base.ref == 'main' &&
needs.build.outputs.is_prerelease == 'false' }}
draft: >-
${{ github.event.pull_request.base.ref != 'main' ||
needs.build.outputs.is_prerelease == 'true' }}
prerelease: >-
${{ needs.build.outputs.is_prerelease == 'true' }}
make_latest: ${{ github.event.pull_request.base.ref == 'main' && needs.build.outputs.is_prerelease == 'false' }}
draft: ${{ github.event.pull_request.base.ref != 'main' || needs.build.outputs.is_prerelease == 'true' }}
prerelease: ${{ needs.build.outputs.is_prerelease == 'true' }}
generate_release_notes: true
publish_pypi:
@@ -119,8 +110,7 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
- name: Publish pypi package
uses: >-
pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist
@@ -132,28 +122,3 @@ jobs:
version: ${{ needs.build.outputs.version }}
ref: ${{ github.event.pull_request.merge_commit_sha }}
secrets: inherit
comment_release_summary:
name: Comment Release Summary
needs:
- draft_release
- publish_pypi
- publish_types
if: success()
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout merge commit
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
fetch-depth: 2
- name: Post release summary comment
uses: ./.github/actions/comment-release-links
with:
issue-number: ${{ github.event.pull_request.number }}
version_file: package.json

View File

@@ -1,206 +0,0 @@
<template>
<Select
:id="dropdownId"
v-model="selectedLocale"
:options="localeOptions"
option-label="label"
option-value="value"
:disabled="isSwitching"
:pt="dropdownPt"
:size="props.size"
class="language-selector"
@change="onLocaleChange"
>
<template #value="{ value }">
<span :class="valueClass">
<i class="pi pi-language" :class="iconClass" />
<span>{{ displayLabel(value as SupportedLocale) }}</span>
</span>
</template>
<template #option="{ option }">
<span :class="optionClass">
<i class="pi pi-language" :class="iconClass" />
<span class="leading-none">{{ option.label }}</span>
</span>
</template>
</Select>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import type { SelectChangeEvent } from 'primevue/select'
import { computed, ref, watch } from 'vue'
import { i18n, loadLocale, st } from '@/i18n'
type VariantKey = 'dark' | 'light'
type SizeKey = 'small' | 'large'
const props = withDefaults(
defineProps<{
variant?: VariantKey
size?: SizeKey
}>(),
{
variant: 'dark',
size: 'small'
}
)
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
const LOCALES = [
['en', 'English'],
['zh', '中文'],
['zh-TW', '繁體中文'],
['ru', 'Русский'],
['ja', '日本語'],
['ko', '한국어'],
['fr', 'Français'],
['es', 'Español'],
['ar', 'عربي'],
['tr', 'Türkçe']
] as const satisfies ReadonlyArray<[string, string]>
type SupportedLocale = (typeof LOCALES)[number][0]
const SIZE_PRESETS = {
large: {
wrapper: 'px-3 py-1 min-w-[7rem]',
gap: 'gap-2',
valueText: 'text-xs',
optionText: 'text-sm',
icon: 'text-sm'
},
small: {
wrapper: 'px-2 py-0.5 min-w-[5rem]',
gap: 'gap-1',
valueText: 'text-[0.65rem]',
optionText: 'text-xs',
icon: 'text-xs'
}
} as const satisfies Record<SizeKey, Record<string, string>>
const VARIANT_PRESETS = {
light: {
root: 'bg-white/80 border border-neutral-200 text-neutral-700 rounded-full shadow-sm backdrop-blur hover:border-neutral-400 transition-colors focus-visible:ring-offset-2 focus-visible:ring-offset-white',
trigger: 'text-neutral-500 hover:text-neutral-700',
item: 'text-neutral-700 bg-transparent hover:bg-neutral-100 focus-visible:outline-none',
valueText: 'text-neutral-600',
optionText: 'text-neutral-600',
icon: 'text-neutral-500'
},
dark: {
root: 'bg-neutral-900/70 border border-neutral-700 text-neutral-200 rounded-full shadow-sm backdrop-blur hover:border-neutral-500 transition-colors focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900',
trigger: 'text-neutral-400 hover:text-neutral-200',
item: 'text-neutral-200 bg-transparent hover:bg-neutral-800/80 focus-visible:outline-none',
valueText: 'text-neutral-100',
optionText: 'text-neutral-100',
icon: 'text-neutral-300'
}
} as const satisfies Record<VariantKey, Record<string, string>>
const selectedLocale = ref<string>(i18n.global.locale.value)
const isSwitching = ref(false)
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
const variantPreset = computed(
() => VARIANT_PRESETS[props.variant as VariantKey]
)
const dropdownPt = computed(() => ({
root: {
class: `${variantPreset.value.root} ${sizePreset.value.wrapper}`
},
trigger: {
class: variantPreset.value.trigger
},
item: {
class: `${variantPreset.value.item} ${sizePreset.value.optionText}`
}
}))
const valueClass = computed(() =>
[
'flex items-center font-medium uppercase tracking-wide leading-tight',
sizePreset.value.gap,
sizePreset.value.valueText,
variantPreset.value.valueText
].join(' ')
)
const optionClass = computed(() =>
[
'flex items-center leading-tight',
sizePreset.value.gap,
variantPreset.value.optionText,
sizePreset.value.optionText
].join(' ')
)
const iconClass = computed(() =>
[sizePreset.value.icon, variantPreset.value.icon].join(' ')
)
const localeOptions = computed(() =>
LOCALES.map(([value, fallback]) => ({
value,
label: st(`settings.Comfy_Locale.options.${value}`, fallback)
}))
)
const labelLookup = computed(() =>
localeOptions.value.reduce<Record<string, string>>((acc, option) => {
acc[option.value] = option.label
return acc
}, {})
)
function displayLabel(locale?: SupportedLocale) {
if (!locale) {
return st('settings.Comfy_Locale.name', 'Language')
}
return labelLookup.value[locale] ?? locale
}
watch(
() => i18n.global.locale.value,
(newLocale) => {
if (newLocale !== selectedLocale.value) {
selectedLocale.value = newLocale
}
}
)
async function onLocaleChange(event: SelectChangeEvent) {
const nextLocale = event.value as SupportedLocale | undefined
if (!nextLocale || nextLocale === i18n.global.locale.value) {
return
}
isSwitching.value = true
try {
await loadLocale(nextLocale)
i18n.global.locale.value = nextLocale
} catch (error) {
console.error(`Failed to change locale to "${nextLocale}"`, error)
selectedLocale.value = i18n.global.locale.value
} finally {
isSwitching.value = false
}
}
</script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.p-dropdown-panel .p-dropdown-item) {
@apply transition-colors;
}
:deep(.p-dropdown) {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<BaseViewTemplate dark hide-language-selector>
<BaseViewTemplate dark>
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
<div
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"

View File

@@ -1,7 +1,7 @@
<template>
<BaseViewTemplate dark>
<div class="flex items-center justify-center min-h-screen">
<div class="grid gap-8">
<div class="grid grid-rows-2 gap-8">
<!-- Top container: Logo -->
<div class="flex items-end justify-center">
<img

View File

@@ -1,15 +1,12 @@
<template>
<div
class="font-sans w-screen h-screen flex flex-col relative"
class="font-sans w-screen h-screen flex flex-col"
:class="[
dark
? 'text-neutral-300 bg-neutral-900 dark-theme'
: 'text-neutral-900 bg-neutral-300'
]"
>
<div v-if="showLanguageSelector" class="absolute top-6 right-6 z-10">
<LanguageSelector :variant="variant" />
</div>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow()"
@@ -23,20 +20,14 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import LanguageSelector from '@/components/common/LanguageSelector.vue'
import { nextTick, onMounted, ref } from 'vue'
import { electronAPI, isElectron, isNativeWindow } from '../../utils/envUtil'
const { dark = false, hideLanguageSelector = false } = defineProps<{
const { dark = false } = defineProps<{
dark?: boolean
hideLanguageSelector?: boolean
}>()
const variant = computed(() => (dark ? 'dark' : 'light'))
const showLanguageSelector = computed(() => !hideLanguageSelector)
const darkTheme = {
color: 'rgba(0, 0, 0, 0)',
symbolColor: '#d4d4d4'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 78 KiB

14
global.d.ts vendored
View File

@@ -8,21 +8,7 @@ declare const __USE_PROD_CONFIG__: boolean
interface Window {
__CONFIG__: {
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number
comfy_api_base_url?: string
comfy_platform_base_url?: string
firebase_config?: {
apiKey: string
authDomain: string
databaseURL?: string
projectId: string
storageBucket: string
messagingSenderId: string
appId: string
measurementId?: string
}
server_health_alert?: {
message: string
tooltip?: string

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.32.3",
"version": "1.32.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -16,7 +16,6 @@
"size:collect": "node scripts/size-collect.js",
"size:report": "node scripts/size-report.js",
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' nx serve",
"dev:desktop": "nx dev @comfyorg/desktop-ui",
"dev:electron": "nx serve --config vite.electron.config.mts",
"dev": "nx serve",

View File

@@ -73,7 +73,6 @@
--color-jade-400: #47e469;
--color-jade-600: #00cd72;
--color-graphite-400: #9C9EAB;
--color-gold-400: #fcbf64;
--color-gold-500: #fdab34;
@@ -228,7 +227,7 @@
--brand-yellow: var(--color-electric-400);
--brand-blue: var(--color-sapphire-700);
--secondary-background: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-200);
--secondary-background-hover: var(--color-smoke-400);
--secondary-background-selected: var(--color-smoke-600);
--base-background: var(--color-white);
--primary-background: var(--color-azure-400);
@@ -243,17 +242,6 @@
--muted-background: var(--color-smoke-700);
--accent-background: var(--color-smoke-800);
/* Component/Node tokens from design system light */
--component-node-background: var(--color-white);
--component-node-border: var(--color-border-default);
--component-node-foreground: var(--base-foreground);
--component-node-foreground-secondary: var(--color-muted-foreground);
--component-node-widget-background: var(--secondary-background);
--component-node-widget-background-hovered: var(--secondary-background-hover);
--component-node-widget-background-selected: var(--secondary-background-selected);
--component-node-widget-background-disabled: var(--color-alpha-ash-500-20);
--component-node-widget-background-highlighted: var(--color-ash-500);
/* Default UI element color palette variables */
--palette-contrast-mix-color: #fff;
--palette-interface-panel-surface: var(--comfy-menu-bg);
@@ -313,7 +301,7 @@
--node-component-surface-highlight: var(--color-slate-100);
--node-component-surface-hovered: var(--color-charcoal-600);
--node-component-surface-selected: var(--color-charcoal-200);
--node-component-surface: var(--color-charcoal-600);
--node-component-surface: var(--color-charcoal-800);
--node-component-tooltip: var(--color-white);
--node-component-tooltip-border: var(--color-slate-300);
--node-component-tooltip-surface: var(--color-charcoal-800);
@@ -351,17 +339,6 @@
--border-subtle: var(--color-charcoal-300);
--muted-background: var(--color-charcoal-100);
--accent-background: var(--color-charcoal-100);
/* Component/Node tokens from design dark system */
--component-node-background: var(--color-charcoal-600);
--component-node-border: var(--color-charcoal-100);
--component-node-foreground: var(--base-foreground);
--component-node-foreground-secondary: var(--color-muted-foreground);
--component-node-widget-background: var(--secondary-background-hover);
--component-node-widget-background-hovered: var(--secondary-background-selected);
--component-node-widget-background-selected: var(--color-charcoal-100);
--component-node-widget-background-disabled: var(--color-alpha-charcoal-600-30);
--component-node-widget-background-highlighted: var(--color-graphite-400);
}
@theme inline {
@@ -384,14 +361,6 @@
--interface-menu-keybind-surface-default
);
--color-interface-panel-surface: var(--interface-panel-surface);
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
--color-interface-panel-selected-surface: var(
--interface-panel-selected-surface
);
--color-interface-button-hover-surface: var(
--interface-button-hover-surface
);
--color-comfy-menu-bg: var(--comfy-menu-bg);
--color-interface-stroke: var(--interface-stroke);
--color-nav-background: var(--nav-background);
--color-node-border: var(--node-border);
@@ -437,17 +406,6 @@
--color-text-primary: var(--text-primary);
--color-input-surface: var(--input-surface);
/* Component/Node design tokens */
--color-component-node-background: var(--component-node-background);
--color-component-node-border: var(--component-node-border);
--color-component-node-foreground: var(--component-node-foreground);
--color-component-node-foreground-secondary: var(--component-node-foreground-secondary);
--color-component-node-widget-background: var(--component-node-widget-background);
--color-component-node-widget-background-hovered: var(--component-node-widget-background-hovered);
--color-component-node-widget-background-selected: var(--component-node-widget-background-selected);
--color-component-node-widget-background-disabled: var(--component-node-widget-background-disabled);
--color-component-node-widget-background-highlighted: var(--component-node-widget-background-highlighted);
/* Semantic tokens */
--color-base-foreground: var(--base-foreground);
--color-muted-foreground: var(--muted-foreground);

View File

@@ -16,10 +16,6 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { t } from '@/i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
@@ -27,8 +23,6 @@ import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
@@ -54,26 +48,6 @@ onMounted(() => {
document.addEventListener('contextmenu', showContextMenu)
}
// Handle Vite preload errors (e.g., when assets are deleted after deployment)
window.addEventListener('vite:preloadError', async (_event) => {
// Auto-reload if app is not ready or there are no unsaved changes
if (!app.vueAppReady || !workflowStore.activeWorkflow?.isModified) {
window.location.reload()
} else {
// Show confirmation dialog if there are unsaved changes
await dialogService
.confirm({
title: t('g.vitePreloadErrorTitle'),
message: t('g.vitePreloadErrorMessage')
})
.then((confirmed) => {
if (confirmed) {
window.location.reload()
}
})
}
})
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()

View File

@@ -22,7 +22,7 @@
},
"litegraph_base": {
"BACKGROUND_IMAGE": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII=",
"CLEAR_BACKGROUND_COLOR": "#141414",
"CLEAR_BACKGROUND_COLOR": "#222",
"NODE_TITLE_COLOR": "#999",
"NODE_SELECTED_TITLE_COLOR": "#FFF",
"NODE_TEXT_SIZE": 14,
@@ -52,7 +52,7 @@
"comfy_base": {
"fg-color": "#fff",
"bg-color": "#202020",
"comfy-menu-bg": "#171718",
"comfy-menu-bg": "#11141a",
"comfy-menu-secondary-bg": "#303030",
"comfy-input-bg": "#222",
"input-text": "#ddd",

View File

@@ -1,11 +1,11 @@
<template>
<div v-if="!workspaceStore.focusMode" class="ml-1 flex gap-x-0.5 pt-1">
<div v-if="!workspaceStore.focusMode" class="ml-2 flex pt-1">
<div class="min-w-0 flex-1">
<SubgraphBreadcrumb />
</div>
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
class="actionbar-container pointer-events-auto mx-1 flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
>
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div

View File

@@ -1,6 +1,6 @@
<template>
<Avatar
class="bg-interface-panel-selected-surface"
class="bg-gray-200 dark-theme:bg-[var(--interface-panel-selected-surface)]"
:image="photoUrl ?? undefined"
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
:pt:icon:class="{ 'size-4': !hasAvatar }"

View File

@@ -96,7 +96,7 @@
<small class="text-center text-muted">
{{ t('auth.apiKey.helpText') }}
<a
:href="`${comfyPlatformBaseUrl}/login`"
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
target="_blank"
class="cursor-pointer text-blue-500"
>
@@ -145,15 +145,11 @@
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
import { isInChina } from '@/utils/networkUtil'
@@ -172,13 +168,6 @@ const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
const ssoAllowed = isHostWhitelisted(normalizeHost(window.location.hostname))
const comfyPlatformBaseUrl = computed(() =>
configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',
getComfyPlatformBaseUrl()
)
)
const toggleState = () => {
isSignIn.value = !isSignIn.value

View File

@@ -9,7 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import ApiKeyForm from './ApiKeyForm.vue'
@@ -111,7 +111,7 @@ describe('ApiKeyForm', () => {
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
`${getComfyPlatformBaseUrl()}/login`
`${COMFY_PLATFORM_BASE_URL}/login`
)
})
})

View File

@@ -48,7 +48,7 @@
<small class="text-muted">
{{ t('auth.apiKey.helpText') }}
<a
:href="`${comfyPlatformBaseUrl}/login`"
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
target="_blank"
class="cursor-pointer text-blue-500"
>
@@ -88,11 +88,7 @@ import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import { apiKeySchema } from '@/schemas/signInSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -100,13 +96,6 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const loading = computed(() => authStore.loading)
const comfyPlatformBaseUrl = computed(() =>
configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',
getComfyPlatformBaseUrl()
)
)
const { t } = useI18n()

View File

@@ -2,14 +2,14 @@
<Button
ref="buttonRef"
severity="secondary"
class="group h-8 rounded-none! bg-comfy-menu-bg p-0 transition-none! hover:rounded-lg! hover:bg-interface-button-hover-surface!"
class="group h-8 rounded-none! bg-interface-panel-surface p-0 transition-none! hover:rounded-lg! hover:bg-button-hover-surface!"
:style="buttonStyles"
@click="toggle"
>
<template #default>
<div class="flex items-center gap-1 pr-0.5">
<div
class="rounded-lg bg-interface-panel-selected-surface p-2 group-hover:bg-interface-button-hover-surface"
class="rounded-lg bg-button-active-surface p-2 group-hover:bg-button-hover-surface"
>
<i :class="currentModeIcon" class="block h-4 w-4" />
</div>
@@ -114,7 +114,7 @@ const popoverPt = computed(() => ({
content: {
class: [
'mb-2 text-text-primary',
'shadow-lg border border-interface-stroke',
'shadow-lg border border-node-border',
'bg-nav-background',
'rounded-lg',
'p-2 px-3',

View File

@@ -10,7 +10,7 @@
></div>
<ButtonGroup
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-interface-stroke bg-comfy-menu-bg p-2"
class="absolute right-0 bottom-0 z-[1200] flex-row gap-1 border-[1px] border-[var(--interface-stroke)] bg-interface-panel-surface p-2"
:style="{
...stringifiedMinimapStyles.buttonGroupStyles
}"
@@ -28,7 +28,7 @@
icon="pi pi-expand"
:aria-label="fitViewTooltip"
:style="stringifiedMinimapStyles.buttonStyles"
class="h-8 w-8 bg-comfy-menu-bg p-0 hover:bg-interface-button-hover-surface!"
class="h-8 w-8 bg-interface-panel-surface p-0 hover:bg-button-hover-surface!"
@click="() => commandStore.execute('Comfy.Canvas.FitView')"
>
<template #icon>
@@ -166,18 +166,18 @@ const minimapCommandText = computed(() =>
// Computed properties for button classes and states
const zoomButtonClass = computed(() => [
'bg-comfy-menu-bg',
isModalVisible.value ? 'not-active:bg-interface-panel-selected-surface!' : '',
'hover:bg-interface-button-hover-surface!',
'bg-interface-panel-surface',
isModalVisible.value ? 'not-active:bg-button-active-surface!' : '',
'hover:bg-button-hover-surface!',
'p-0',
'h-8',
'w-15'
])
const minimapButtonClass = computed(() => ({
'bg-comfy-menu-bg': true,
'hover:bg-interface-button-hover-surface!': true,
'not-active:bg-interface-panel-selected-surface!': settingStore.get(
'bg-interface-panel-surface': true,
'hover:bg-button-hover-surface!': true,
'not-active:bg-button-active-surface!': settingStore.get(
'Comfy.Minimap.Visible'
),
'p-0': true,
@@ -209,9 +209,9 @@ const linkVisibilityAriaLabel = computed(() =>
: t('graphCanvasMenu.hideLinks')
)
const linkVisibleClass = computed(() => [
'bg-comfy-menu-bg',
linkHidden.value ? 'not-active:bg-interface-panel-selected-surface!' : '',
'hover:bg-interface-button-hover-surface!',
'bg-interface-panel-surface',
linkHidden.value ? 'not-active:bg-button-active-surface!' : '',
'hover:bg-button-hover-surface!',
'p-0',
'w-8',
'h-8'

View File

@@ -4,7 +4,7 @@
class="absolute right-0 bottom-[62px] z-1300 flex w-[250px] justify-center border-0! bg-inherit!"
>
<div
class="w-4/5 rounded-lg border border-interface-stroke bg-interface-panel-surface p-2 text-text-primary shadow-lg select-none"
class="w-4/5 rounded-lg border border-node-border bg-interface-panel-surface p-2 text-text-primary shadow-lg select-none"
:style="filteredMinimapStyles"
@click.stop
>

View File

@@ -10,7 +10,7 @@
@click="popover?.toggle($event)"
>
<div
class="flex items-center gap-1 rounded-full hover:bg-interface-button-hover-surface"
class="flex items-center gap-1 rounded-full hover:bg-[var(--interface-button-hover-surface)]"
>
<UserAvatar :photo-url="photoURL" />

View File

@@ -4,7 +4,7 @@
outlined
rounded
severity="secondary"
class="size-8 border-black/50 bg-transparent text-black hover:bg-interface-panel-hover-surface dark-theme:border-white/50 dark-theme:text-white"
class="size-8 border-black/50 bg-transparent text-black hover:bg-[var(--interface-panel-hover-surface)] dark-theme:border-white/50 dark-theme:text-white"
@click="handleSignIn()"
@mouseenter="showPopover"
@mouseleave="hidePopover"

View File

@@ -269,13 +269,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const updatedWidgets = currentData.widgets.map((w) =>
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
)
// Create a completely new object to ensure Vue reactivity triggers
const updatedData = {
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets
}
vueNodeData.set(nodeId, updatedData)
})
} catch (error) {
// Ignore widget update errors to prevent cascade failures
}

View File

@@ -2,17 +2,16 @@
* Composable for managing widget value synchronization between Vue and LiteGraph
* Provides consistent pattern for immediate UI updates and LiteGraph callbacks
*/
import { computed, toValue, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import type { MaybeRefOrGetter } from '@vueuse/core'
interface UseWidgetValueOptions<T extends WidgetValue = WidgetValue, U = T> {
/** The widget configuration from LiteGraph */
widget: SimplifiedWidget<T>
/** The current value from parent component (can be a value or a getter function) */
modelValue: MaybeRefOrGetter<T>
/** The current value from parent component */
modelValue: T
/** Default value if modelValue is null/undefined */
defaultValue: T
/** Emit function from component setup */
@@ -47,21 +46,8 @@ export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
emit,
transform
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
// Ref for immediate UI feedback before value flows back through modelValue
const newProcessedValue = ref<T | null>(null)
// Computed that prefers the immediately processed value, then falls back to modelValue
const localValue = computed<T>(
() => newProcessedValue.value ?? toValue(modelValue) ?? defaultValue
)
// Clear newProcessedValue when modelValue updates (allowing external changes to flow through)
watch(
() => toValue(modelValue),
() => {
newProcessedValue.value = null
}
)
// Local value for immediate UI updates
const localValue = ref<T>(modelValue ?? defaultValue)
// Handle user changes
const onChange = (newValue: U) => {
@@ -85,13 +71,21 @@ export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
}
}
// Set for immediate UI feedback
newProcessedValue.value = processedValue
// 1. Update local state for immediate UI feedback
localValue.value = processedValue
// Emit to parent component
// 2. Emit to parent component
emit('update:modelValue', processedValue)
}
// Watch for external updates from LiteGraph
watch(
() => modelValue,
(newValue) => {
localValue.value = newValue ?? defaultValue
}
)
return {
localValue: localValue as Ref<T>,
onChange
@@ -103,7 +97,7 @@ export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
*/
export function useStringWidgetValue(
widget: SimplifiedWidget<string>,
modelValue: string | (() => string),
modelValue: string,
emit: (event: 'update:modelValue', value: string) => void
) {
return useWidgetValue({
@@ -120,7 +114,7 @@ export function useStringWidgetValue(
*/
export function useNumberWidgetValue(
widget: SimplifiedWidget<number>,
modelValue: number | (() => number),
modelValue: number,
emit: (event: 'update:modelValue', value: number) => void
) {
return useWidgetValue({
@@ -143,7 +137,7 @@ export function useNumberWidgetValue(
*/
export function useBooleanWidgetValue(
widget: SimplifiedWidget<boolean>,
modelValue: boolean | (() => boolean),
modelValue: boolean,
emit: (event: 'update:modelValue', value: boolean) => void
) {
return useWidgetValue({

View File

@@ -168,7 +168,6 @@ export const useNodeVideo = (node: LGraphNode, callback?: () => void) => {
const hasWidget = node.widgets?.some((w) => w.name === VIDEO_WIDGET_NAME)
if (!hasWidget) {
const widget = node.addDOMWidget(VIDEO_WIDGET_NAME, 'video', container, {
canvasOnly: true,
hideOnZoom: false
})
widget.serialize = false

View File

@@ -5,7 +5,6 @@ import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const DEFAULT_TITLE = 'ComfyUI'
const TITLE_SUFFIX = ' - ComfyUI'
@@ -14,7 +13,6 @@ export const useBrowserTabTitle = () => {
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const workspaceStore = useWorkspaceStore()
const executionText = computed(() =>
executionStore.isIdle
@@ -26,27 +24,11 @@ export const useBrowserTabTitle = () => {
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const isAutoSaveEnabled = computed(
() => settingStore.get('Comfy.Workflow.AutoSave') === 'after delay'
)
const isActiveWorkflowModified = computed(
() => !!workflowStore.activeWorkflow?.isModified
)
const isActiveWorkflowPersisted = computed(
() => !!workflowStore.activeWorkflow?.isPersisted
)
const shouldShowUnsavedIndicator = computed(() => {
if (workspaceStore.shiftDown) return false
if (isAutoSaveEnabled.value) return false
if (!isActiveWorkflowPersisted.value) return true
if (isActiveWorkflowModified.value) return true
return false
})
const isUnsavedText = computed(() =>
shouldShowUnsavedIndicator.value ? ' *' : ''
workflowStore.activeWorkflow?.isModified ||
!workflowStore.activeWorkflow?.isPersisted
? ' *'
: ''
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.filename

View File

@@ -19,9 +19,9 @@ import {
import type { Point } from '@/lib/litegraph/src/litegraph'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { isCloud } from '@/platform/distribution/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { ExecutionTriggerSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -841,12 +841,26 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
// OSS link for all non-cloud versions (portable, desktop, localhost)
// Cloud link only for cloud distribution
const { userEmail, resolvedUserInfo } = useCurrentUser()
const supportUrl = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
const params = new URLSearchParams({
tf_42243568391700: isCloud ? 'ccloud' : 'oss'
})
window.open(supportUrl, '_blank')
// Add user email and ID if available
if (userEmail.value) {
params.append('tf_anonymous_requester_email', userEmail.value)
params.append('tf_40029135130388', userEmail.value)
}
if (resolvedUserInfo.value?.id) {
params.append('tf_42515251051412', resolvedUserInfo.value.id)
}
const baseUrl = 'https://support.comfy.org/hc/en-us/requests/new'
const zendeskUrl = `${baseUrl}?${params.toString()}`
window.open(zendeskUrl, '_blank')
}
},
{

View File

@@ -1,43 +1,7 @@
import { isCloud } from '@/platform/distribution/types'
import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
export const COMFY_API_BASE_URL = __USE_PROD_CONFIG__
? 'https://api.comfy.org'
: 'https://stagingapi.comfy.org'
const PROD_API_BASE_URL = 'https://api.comfy.org'
const STAGING_API_BASE_URL = 'https://stagingapi.comfy.org'
const PROD_PLATFORM_BASE_URL = 'https://platform.comfy.org'
const STAGING_PLATFORM_BASE_URL = 'https://stagingplatform.comfy.org'
const BUILD_TIME_API_BASE_URL = __USE_PROD_CONFIG__
? PROD_API_BASE_URL
: STAGING_API_BASE_URL
const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
? PROD_PLATFORM_BASE_URL
: STAGING_PLATFORM_BASE_URL
export function getComfyApiBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_API_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_api_base_url',
BUILD_TIME_API_BASE_URL
)
}
export function getComfyPlatformBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_PLATFORM_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',
BUILD_TIME_PLATFORM_BASE_URL
)
}
export const COMFY_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
? 'https://platform.comfy.org'
: 'https://stagingplatform.comfy.org'

View File

@@ -1,8 +1,5 @@
import type { FirebaseOptions } from 'firebase/app'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const DEV_CONFIG: FirebaseOptions = {
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
authDomain: 'dreamboothy-dev.firebaseapp.com',
@@ -25,18 +22,7 @@ const PROD_CONFIG: FirebaseOptions = {
measurementId: 'G-3ZBD3MBTG4'
}
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
/**
* Returns the Firebase configuration for the current environment.
* - Cloud builds use runtime configuration delivered via feature flags
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
*/
export function getFirebaseConfig(): FirebaseOptions {
if (!isCloud) {
return BUILD_TIME_CONFIG
}
const runtimeConfig = remoteConfig.value.firebase_config
return runtimeConfig ?? BUILD_TIME_CONFIG
}
// To test with prod config while using dev server, set USE_PROD_CONFIG=true in .env
export const FIREBASE_CONFIG: FirebaseOptions = __USE_PROD_CONFIG__
? PROD_CONFIG
: DEV_CONFIG

View File

@@ -58,9 +58,6 @@ async function uploadFile(
getResourceURL(...splitFilePath(path))
)
audioWidget.value = path
// Manually trigger the callback to update VueNodes
audioWidget.callback?.(path)
}
} else {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)

View File

@@ -18,7 +18,7 @@ import { ComfyWidgets, addValueControlWidgets } from '@/scripts/widgets'
import { CONFIG, GET_CONFIG } from '@/services/litegraphService'
import { mergeInputSpec } from '@/utils/nodeDefUtil'
import { applyTextReplacements } from '@/utils/searchAndReplace'
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
import { isPrimitiveNode } from '@/utils/typeGuardUtil'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {

View File

@@ -29,8 +29,6 @@ export interface IWidgetOptions<TValues = unknown[]> {
canvasOnly?: boolean
values?: TValues
/** Optional function to format values for display (e.g., hash → human-readable name) */
getOptionLabel?: (value?: string | null) => string
callback?: IWidget['callback']
}

View File

@@ -34,18 +34,6 @@ export class ComboWidget
override get _displayValue() {
if (this.computedDisabled) return ''
if (this.options.getOptionLabel) {
try {
return this.options.getOptionLabel(
this.value ? String(this.value) : null
)
} catch (e) {
console.error('Failed to map value:', e)
return this.value ? String(this.value) : ''
}
}
const { values: rawValues } = this.options
if (rawValues) {
const values = typeof rawValues === 'function' ? rawValues() : rawValues
@@ -143,31 +131,7 @@ export class ComboWidget
const values = this.getValues(node)
const values_list = toArray(values)
// Use addItem to solve duplicate filename issues
if (this.options.getOptionLabel) {
const menuOptions = {
scale: Math.max(1, canvas.ds.scale),
event: e,
className: 'dark',
callback: (value: string) => {
this.setValue(value, { e, node, canvas })
}
}
const menu = new LiteGraph.ContextMenu([], menuOptions)
for (const value of values_list) {
try {
const label = this.options.getOptionLabel(String(value))
menu.addItem(label, value, menuOptions)
} catch (err) {
console.error('Failed to map value:', err)
menu.addItem(String(value), value, menuOptions)
}
}
return
}
// Show dropdown menu when user clicks on widget label
// Handle center click - show dropdown menu
const text_values = values != values_list ? Object.values(values) : values
new LiteGraph.ContextMenu(text_values, {
scale: Math.max(1, canvas.ds.scale),

View File

@@ -1507,6 +1507,7 @@
"Video": "فيديو",
"Video API": "واجهة برمجة تطبيقات الفيديو"
},
"licensesSelected": "{count} تراخيص",
"loading": "جارٍ تحميل القوالب...",
"loadingMore": "تحميل المزيد من القوالب...",
"modelFilter": "مرشح النماذج",

View File

@@ -40,8 +40,6 @@
"comfy": "Comfy",
"refresh": "Refresh",
"refreshNode": "Refresh Node",
"vitePreloadErrorTitle": "New Version Available",
"vitePreloadErrorMessage": "A new version of the app has been released. Would you like to reload?\nIf not, some parts of the app might not work as expected.\nFeel free to decline and save your progress before reloading.",
"terminal": "Terminal",
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
@@ -613,17 +611,8 @@
"nodes": "Nodes",
"models": "Models",
"workflows": "Workflows",
"templates": "Templates",
"console": "Console",
"menu": "Menu",
"assets": "Assets",
"imported": "Imported",
"generated": "Generated"
"templates": "Templates"
},
"noFilesFound": "No files found",
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"noFilesFoundMessage": "Upload files or generate content to see them here",
"browseTemplates": "Browse example templates",
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",
@@ -1783,10 +1772,7 @@
"exportSettings": "Export Settings",
"modelSettings": "Model Settings"
},
"openIn3DViewer": "Open in 3D Viewer",
"dropToLoad": "Drop 3D model to load",
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)",
"uploadingModel": "Uploading 3D model..."
"openIn3DViewer": "Open in 3D Viewer"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",

View File

@@ -2908,11 +2908,6 @@
"strength": {
"name": "strength"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"HyperTile": {
@@ -4645,7 +4640,10 @@
},
"height": {
"name": "height"
}
},
"clear": {},
"upload 3d model": {},
"upload extra resources": {}
},
"outputs": {
"0": {
@@ -7881,11 +7879,6 @@
"name": "instructions",
"tooltip": "Instructions for the model on how to generate the response"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIChatNode": {
@@ -7898,7 +7891,7 @@
},
"persist_context": {
"name": "persist_context",
"tooltip": "This parameter is deprecated and has no effect."
"tooltip": "Persist chat context between calls (multi-turn conversation)"
},
"model": {
"name": "model",
@@ -7916,11 +7909,6 @@
"name": "advanced_options",
"tooltip": "Optional configuration for the model. Accepts inputs from the OpenAI Chat Advanced Options node."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIDalle2": {
@@ -7954,11 +7942,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIDalle3": {
@@ -7988,11 +7971,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIGPTImage1": {
@@ -8034,11 +8012,6 @@
"control_after_generate": {
"name": "control after generate"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIInputFiles": {
@@ -8053,11 +8026,6 @@
"name": "OPENAI_INPUT_FILES",
"tooltip": "An optional additional file(s) to batch together with the file loaded from this node. Allows chaining of input files so that a single message can include multiple input files."
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"OpenAIVideoSora2": {
@@ -8837,6 +8805,9 @@
},
"camera_info": {
"name": "camera_info"
},
"image": {
"name": "image"
}
}
},

View File

@@ -1504,6 +1504,7 @@
"Video": "Video",
"Video API": "API de Video"
},
"licensesSelected": "{count} licencias",
"loading": "Cargando plantillas...",
"loadingMore": "Cargando más plantillas...",
"modelFilter": "Filtro de modelo",

View File

@@ -1504,6 +1504,7 @@
"Video": "Vidéo",
"Video API": "API vidéo"
},
"licensesSelected": "{count} Licences",
"loading": "Chargement des modèles...",
"loadingMore": "Chargement de plus de modèles...",
"modelFilter": "Filtre de modèle",

View File

@@ -1504,6 +1504,7 @@
"Video": "ビデオ",
"Video API": "動画API"
},
"licensesSelected": "{count}件のライセンス",
"loading": "テンプレートを読み込み中...",
"loadingMore": "さらにテンプレートを読み込み中...",
"modelFilter": "モデルフィルター",

View File

@@ -1504,6 +1504,7 @@
"Video": "비디오",
"Video API": "비디오 API"
},
"licensesSelected": "{count}개 라이선스",
"loading": "템플릿 불러오는 중...",
"loadingMore": "템플릿 더 불러오는 중...",
"modelFilter": "모델 필터",

View File

@@ -1504,6 +1504,7 @@
"Video": "Видео",
"Video API": "Video API"
},
"licensesSelected": "{count} лицензий",
"loading": "Загрузка шаблонов...",
"loadingMore": "Загрузка дополнительных шаблонов...",
"modelFilter": "Фильтр моделей",

View File

@@ -1502,6 +1502,7 @@
"Video": "Video",
"Video API": "Video API"
},
"licensesSelected": "{count} Lisans",
"loading": "Şablonlar yükleniyor...",
"loadingMore": "Daha fazla şablon yükleniyor...",
"modelFilter": "Model Filtresi",

View File

@@ -1504,6 +1504,7 @@
"Video": "影片",
"Video API": "影片 API"
},
"licensesSelected": "{count} 個授權",
"loading": "正在載入範本...",
"loadingMore": "載入更多範本...",
"modelFilter": "模型篩選",

View File

@@ -1507,6 +1507,7 @@
"Video": "视频生成",
"Video API": "视频 API"
},
"licensesSelected": "已选 {count} 个许可类型",
"loading": "正在加载模板...",
"loadingMore": "正在加载更多模板...",
"modelFilter": "模型筛选",

View File

@@ -11,7 +11,7 @@ import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { getFirebaseConfig } from '@/config/firebase'
import { FIREBASE_CONFIG } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
@@ -40,7 +40,7 @@ const ComfyUIPreset = definePreset(Aura, {
}
})
const firebaseApp = initializeApp(getFirebaseConfig())
const firebaseApp = initializeApp(FIREBASE_CONFIG)
const app = createApp(App)
const pinia = createPinia()

View File

@@ -194,31 +194,20 @@ function createAssetService() {
/**
* Gets assets filtered by a specific tag
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param tag - The tag to filter by (e.g., 'models')
* @param includePublic - Whether to include public assets (default: true)
* @param options - Pagination options
* @param options.limit - Maximum number of assets to return (default: 500)
* @param options.offset - Number of assets to skip (default: 0)
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
*/
async function getAssetsByTag(
tag: string,
includePublic: boolean = true,
{
limit = DEFAULT_LIMIT,
offset = 0
}: { limit?: number; offset?: number } = {}
includePublic: boolean = true
): Promise<AssetItem[]> {
const queryParams = new URLSearchParams({
include_tags: tag,
limit: limit.toString(),
limit: DEFAULT_LIMIT.toString(),
include_public: includePublic ? 'true' : 'false'
})
if (offset > 0) {
queryParams.set('offset', offset.toString())
}
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?${queryParams.toString()}`,
`assets for tag ${tag}`

View File

@@ -4,7 +4,7 @@ import { createSharedComposable } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
@@ -74,8 +74,6 @@ function useSubscriptionInternal() {
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
)
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
reportError
@@ -116,7 +114,7 @@ function useSubscriptionInternal() {
}
const handleViewUsageHistory = () => {
window.open(`${getComfyPlatformBaseUrl()}/profile/usage`, '_blank')
window.open('https://platform.comfy.org/profile/usage', '_blank')
}
const handleLearnMore = () => {
@@ -138,7 +136,7 @@ function useSubscriptionInternal() {
}
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-status'),
`${COMFY_API_BASE_URL}/customers/cloud-subscription-status`,
{
headers: {
...authHeader,
@@ -183,7 +181,7 @@ function useSubscriptionInternal() {
}
const response = await fetch(
buildApiUrl('/customers/cloud-subscription-checkout'),
`${COMFY_API_BASE_URL}/customers/cloud-subscription-checkout`,
{
method: 'POST',
headers: {

View File

@@ -21,15 +21,6 @@ import type { RemoteConfig } from './types'
*/
export const remoteConfig = ref<RemoteConfig>({})
export function configValueOrDefault<K extends keyof RemoteConfig>(
remoteConfig: RemoteConfig,
key: K,
defaultValue: NonNullable<RemoteConfig[K]>
): NonNullable<RemoteConfig[K]> {
const configValue = remoteConfig[key]
return configValue || defaultValue
}
/**
* Loads remote configuration from the backend /api/features endpoint
* and updates the reactive remoteConfig ref

View File

@@ -8,17 +8,6 @@ type ServerHealthAlert = {
badge?: string
}
type FirebaseRuntimeConfig = {
apiKey: string
authDomain: string
databaseURL?: string
projectId: string
storageBucket: string
messagingSenderId: string
appId: string
measurementId?: string
}
/**
* Remote configuration type
* Configuration fetched from the server at runtime
@@ -27,8 +16,4 @@ export type RemoteConfig = {
mixpanel_token?: string
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number
comfy_api_base_url?: string
comfy_platform_base_url?: string
firebase_config?: FirebaseRuntimeConfig
}

View File

@@ -1,43 +0,0 @@
import { isCloud } from '@/platform/distribution/types'
/**
* Zendesk ticket form field IDs.
*/
const ZENDESK_FIELDS = {
/** Distribution tag (cloud vs OSS) */
DISTRIBUTION: 'tf_42243568391700',
/** User email (anonymous requester) */
ANONYMOUS_EMAIL: 'tf_anonymous_requester_email',
/** User email (authenticated) */
EMAIL: 'tf_40029135130388',
/** User ID */
USER_ID: 'tf_42515251051412'
} as const
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
/**
* Builds the support URL with optional user information for pre-filling.
* Users without login information will still get a valid support URL without pre-fill.
*
* @param params - User information to pre-fill in the support form
* @returns Complete Zendesk support URL with query parameters
*/
export function buildSupportUrl(params?: {
userEmail?: string | null
userId?: string | null
}): string {
const searchParams = new URLSearchParams({
[ZENDESK_FIELDS.DISTRIBUTION]: isCloud ? 'ccloud' : 'oss'
})
if (params?.userEmail) {
searchParams.append(ZENDESK_FIELDS.ANONYMOUS_EMAIL, params.userEmail)
searchParams.append(ZENDESK_FIELDS.EMAIL, params.userEmail)
}
if (params?.userId) {
searchParams.append(ZENDESK_FIELDS.USER_ID, params.userId)
}
return `${SUPPORT_BASE_URL}?${searchParams.toString()}`
}

View File

@@ -1,11 +1,18 @@
import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { ref, watch } from 'vue'
import { ref } from 'vue'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const releaseApiClient = axios.create({
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
})
// Use generated types from OpenAPI spec
export type ReleaseNote = components['schemas']['ReleaseNote']
type GetReleasesParams = operations['getReleaseNotes']['parameters']['query']
@@ -13,25 +20,11 @@ type GetReleasesParams = operations['getReleaseNotes']['parameters']['query']
// Use generated error response type
type ErrorResponse = components['schemas']['ErrorResponse']
const releaseApiClient = axios.create({
baseURL: getComfyApiBaseUrl(),
headers: {
'Content-Type': 'application/json'
}
})
// Release service for fetching release notes
export const useReleaseService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
watch(
() => getComfyApiBaseUrl(),
(url) => {
releaseApiClient.defaults.baseURL = url
}
)
// No transformation needed - API response matches the generated type
// Handle API errors with context

View File

@@ -17,11 +17,11 @@
<div
ref="containerRef"
class="litegraph-minimap relative border border-interface-stroke bg-comfy-menu-bg shadow-interface"
class="litegraph-minimap relative border border-[var(--interface-stroke)] bg-interface-panel-surface shadow-interface"
:style="containerStyles"
>
<Button
class="absolute top-1 left-1 z-10 hover:bg-interface-button-hover-surface!"
class="absolute top-1 left-1 z-10 hover:bg-button-hover-surface!"
size="small"
text
severity="secondary"
@@ -32,7 +32,7 @@
</template>
</Button>
<Button
class="absolute top-1 right-1 z-10 hover:bg-interface-button-hover-surface!"
class="absolute top-1 right-1 z-10 hover:bg-button-hover-surface!"
size="small"
text
severity="secondary"

View File

@@ -1,6 +1,6 @@
<template>
<div
class="minimap-panel mr-2 flex flex-col gap-2 bg-comfy-menu-bg p-3 text-sm shadow-interface"
class="minimap-panel mr-2 flex flex-col gap-2 bg-interface-panel-surface p-3 text-sm shadow-interface"
:style="panelStyles"
>
<div class="flex items-center gap-2">

View File

@@ -8,10 +8,10 @@
:data-node-id="nodeData.id"
:class="
cn(
'bg-component-node-background lg-node absolute',
'bg-node-component-surface lg-node absolute',
'h-min w-min contain-style contain-layout min-h-(--node-height) min-w-(--node-width)',
'rounded-2xl touch-none flex flex-col',
'border-1 border-solid border-component-node-border',
'border-1 border-solid border-node-component-border',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-node-component-ring',
@@ -23,8 +23,7 @@
bypassed,
'before:rounded-2xl before:pointer-events-none before:absolute before:inset-0':
muted,
'will-change-transform': isDragging,
'ring-4 ring-primary-500 bg-primary-500/10': isDraggingOver
'will-change-transform': isDragging
},
shouldHandleNodePointerEvents
@@ -37,16 +36,13 @@
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex,
opacity: nodeOpacity,
'--component-node-background': nodeBodyBackgroundColor
'--node-component-surface': nodeBodyBackgroundColor
},
dragStyle
]"
v-bind="pointerHandlers"
@wheel="handleWheel"
@contextmenu="handleContextMenu"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
>
<div class="flex flex-col justify-center items-center relative">
<template v-if="isCollapsed">
@@ -387,7 +383,7 @@ const hasCustomContent = computed(() => {
})
// Computed classes and conditions for better reusability
const separatorClasses = 'bg-component-node-border h-px mx-0 w-full lod-toggle'
const separatorClasses = 'bg-node-component-border h-px mx-0 w-full lod-toggle'
const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
@@ -485,35 +481,4 @@ const nodeMedia = computed(() => {
})
const nodeContainerRef = ref<HTMLDivElement>()
// Drag and drop support
const isDraggingOver = ref(false)
function handleDragOver(event: DragEvent) {
const node = lgraphNode.value
if (!node || !node.onDragOver) {
isDraggingOver.value = false
return
}
// Call the litegraph node's onDragOver callback to check if files are valid
const canDrop = node.onDragOver(event)
isDraggingOver.value = canDrop
}
function handleDragLeave() {
isDraggingOver.value = false
}
async function handleDrop(event: DragEvent) {
isDraggingOver.value = false
const node = lgraphNode.value
if (!node || !node.onDragDrop) {
return
}
// Forward the drop event to the litegraph node's onDragDrop callback
await node.onDragDrop(event)
}
</script>

View File

@@ -7,7 +7,7 @@
:class="
cn(
'lg-node-header p-4 rounded-t-2xl w-full min-w-50',
'text-node-component-header',
'bg-node-component-header-surface text-node-component-header',
collapsed && 'rounded-2xl'
)
"

View File

@@ -50,7 +50,6 @@
:widget="widget.simplified"
:model-value="widget.value"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
class="flex-1"
@update:model-value="widget.updateHandler"
/>
@@ -163,9 +162,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
// Update the widget value directly
widget.value = value as WidgetValue
// Skip callback for asset widgets - their callback opens the modal,
// but Vue asset mode handles selection through the dropdown
if (widget.callback && widget.type !== 'asset') {
if (widget.callback) {
widget.callback(value)
}
}

View File

@@ -26,7 +26,7 @@
size="small"
:pt="{
option: 'text-xs',
dropdownIcon: 'text-component-node-foreground-secondary'
dropdownIcon: 'text-button-icon'
}"
/>
<Button
@@ -97,7 +97,7 @@
size="small"
:pt="{
option: 'text-xs',
dropdownIcon: 'text-component-node-foreground-secondary'
dropdownIcon: 'text-button-icon'
}"
/>
<Button

View File

@@ -103,14 +103,10 @@ const inputNumberPt = useNumberWidgetButtonPt({
@update:model-value="onChange"
>
<template #incrementicon>
<span
class="pi pi-plus text-sm text-component-node-foreground-secondary"
/>
<span class="pi pi-plus text-sm text-button-icon" />
</template>
<template #decrementicon>
<span
class="pi pi-minus text-sm text-component-node-foreground-secondary"
/>
<span class="pi pi-minus text-sm text-button-icon" />
</template>
</InputNumber>
</div>
@@ -120,7 +116,7 @@ const inputNumberPt = useNumberWidgetButtonPt({
<style scoped>
:deep(.p-inputnumber-input) {
background-color: transparent;
border: 1px solid var(--component-node-border);
border: 1px solid var(--node-stroke);
border-top: transparent;
border-bottom: transparent;
height: 1.625rem;

View File

@@ -10,7 +10,7 @@
display="chip"
:pt="{
option: 'text-xs',
dropdownIcon: 'text-component-node-foreground-secondary'
dropdownIcon: 'text-button-icon'
}"
@update:model-value="onChange"
/>

View File

@@ -3,47 +3,16 @@ import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Select from 'primevue/select'
import type { SelectProps } from 'primevue/select'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
// Mock state for distribution and settings
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
const mockIsAssetBrowserEligible = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockDistributionState.isCloud
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: mockSettingStoreGet
}))
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetBrowserEligible: mockIsAssetBrowserEligible
}
}))
import WidgetSelect from './WidgetSelect.vue'
import WidgetSelectDefault from './WidgetSelectDefault.vue'
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
describe('WidgetSelect Value Binding', () => {
beforeEach(() => {
// Reset all mocks before each test
mockDistributionState.isCloud = false
mockSettingStoreGet.mockReturnValue(false)
mockIsAssetBrowserEligible.mockReturnValue(false)
vi.clearAllMocks()
})
const createMockWidget = (
value: string = 'option1',
options: Partial<
@@ -212,92 +181,6 @@ describe('WidgetSelect Value Binding', () => {
})
})
describe('node-type prop passing', () => {
it('passes node-type prop to WidgetSelectDropdown', () => {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'test_select',
image_upload: true
}
const widget = createMockWidget('option1', {}, undefined, spec)
const wrapper = mount(WidgetSelect, {
props: {
widget,
modelValue: 'option1',
nodeType: 'CheckpointLoaderSimple'
},
global: {
plugins: [PrimeVue, createTestingPinia()],
components: { Select }
}
})
const dropdown = wrapper.findComponent(WidgetSelectDropdown)
expect(dropdown.exists()).toBe(true)
expect(dropdown.props('nodeType')).toBe('CheckpointLoaderSimple')
})
it('does not pass node-type prop to WidgetSelectDefault', () => {
const widget = createMockWidget('option1')
const wrapper = mount(WidgetSelect, {
props: {
widget,
modelValue: 'option1',
nodeType: 'KSampler'
},
global: {
plugins: [PrimeVue, createTestingPinia()],
components: { Select }
}
})
const defaultSelect = wrapper.findComponent(WidgetSelectDefault)
expect(defaultSelect.exists()).toBe(true)
})
})
describe('Asset mode detection', () => {
it('enables asset mode when all conditions are met', () => {
mockDistributionState.isCloud = true
mockSettingStoreGet.mockReturnValue(true)
mockIsAssetBrowserEligible.mockReturnValue(true)
const widget = createMockWidget('test.safetensors')
const wrapper = mount(WidgetSelect, {
props: {
widget,
modelValue: 'test.safetensors',
nodeType: 'CheckpointLoaderSimple'
},
global: {
plugins: [PrimeVue, createTestingPinia()],
components: { Select }
}
})
expect(wrapper.findComponent(WidgetSelectDropdown).exists()).toBe(true)
})
it('disables asset mode when conditions are not met', () => {
mockDistributionState.isCloud = false
const widget = createMockWidget('test.safetensors')
const wrapper = mount(WidgetSelect, {
props: {
widget,
modelValue: 'test.safetensors',
nodeType: 'CheckpointLoaderSimple'
},
global: {
plugins: [PrimeVue, createTestingPinia()],
components: { Select }
}
})
expect(wrapper.findComponent(WidgetSelectDefault).exists()).toBe(true)
})
})
describe('Spec-aware rendering', () => {
it('uses dropdown variant when combo spec enables image uploads', () => {
const spec: ComboInputSpec = {

View File

@@ -5,14 +5,11 @@
:asset-kind="assetKind"
:allow-upload="allowUpload"
:upload-folder="uploadFolder"
:is-asset-mode="isAssetMode"
:default-layout-mode="defaultLayoutMode"
@update:model-value="handleUpdateModelValue"
/>
<WidgetSelectDefault
v-else
:widget="widget"
:model-value="modelValue"
v-bind="props"
@update:model-value="handleUpdateModelValue"
/>
</template>
@@ -20,22 +17,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import type { LayoutMode } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { ResultItemType } from '@/schemas/apiSchema'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import type { AssetKind } from '@/types/widgetTypes'
import WidgetSelectDefault from './WidgetSelectDefault.vue'
import WidgetSelectDropdown from './WidgetSelectDropdown.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
nodeType?: string
}>()
const emit = defineEmits<{
@@ -97,30 +90,10 @@ const specDescriptor = computed<{
}
})
const isAssetMode = computed(() => {
if (isCloud) {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
props.nodeType,
props.widget.name
)
return isUsingAssetAPI && isEligible
}
return false
})
const assetKind = computed(() => specDescriptor.value.kind)
const isDropdownUIWidget = computed(
() => isAssetMode.value || assetKind.value !== 'unknown'
)
const isDropdownUIWidget = computed(() => assetKind.value !== 'unknown')
const allowUpload = computed(() => specDescriptor.value.allowUpload)
const uploadFolder = computed<ResultItemType>(() => {
return specDescriptor.value.folder ?? 'input'
})
const defaultLayoutMode = computed<LayoutMode>(() => {
return isAssetMode.value ? 'list' : 'grid'
})
</script>

View File

@@ -107,14 +107,10 @@ describe('WidgetSelectButton Button Selection', () => {
const selectedButton = buttons[1] // 'banana'
const unselectedButton = buttons[0] // 'apple'
expect(selectedButton.classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(selectedButton.classes()).toContain('text-primary')
expect(unselectedButton.classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(unselectedButton.classes()).toContain('text-secondary')
expect(selectedButton.classes()).toContain('bg-white')
expect(selectedButton.classes()).toContain('text-neutral-900')
expect(unselectedButton.classes()).not.toContain('bg-white')
expect(unselectedButton.classes()).not.toContain('text-neutral-900')
})
it('handles no selection gracefully', () => {
@@ -124,10 +120,8 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(button.classes()).toContain('text-secondary')
expect(button.classes()).not.toContain('bg-white')
expect(button.classes()).not.toContain('text-neutral-900')
})
})
@@ -140,19 +134,13 @@ describe('WidgetSelectButton Button Selection', () => {
// Initially 'first' is selected
let buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[0].classes()).toContain('bg-white')
// Update to 'second'
await wrapper.setProps({ modelValue: 'second' })
buttons = wrapper.findAll('button')
expect(buttons[0].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[0].classes()).not.toContain('bg-white')
expect(buttons[1].classes()).toContain('bg-white')
})
})
@@ -234,9 +222,7 @@ describe('WidgetSelectButton Button Selection', () => {
expect(buttons[2].text()).toBe('3')
// The selected button should be the one with '2'
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).toContain('bg-white')
})
it('handles object options with label and value', () => {
@@ -254,9 +240,7 @@ describe('WidgetSelectButton Button Selection', () => {
expect(buttons[2].text()).toBe('Third Option')
// 'second' should be selected
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).toContain('bg-white')
})
it('emits correct values for object options', async () => {
@@ -293,9 +277,7 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Empty string is selected
expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected
})
it('handles null/undefined in options', () => {
@@ -310,9 +292,7 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[0].classes()).toContain('bg-white')
})
it('handles very long option text', () => {
@@ -333,9 +313,7 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(20)
expect(buttons[4].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // option5 is at index 4
expect(buttons[4].classes()).toContain('bg-white') // option5 is at index 4
})
it('handles duplicate options', () => {
@@ -346,12 +324,8 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
// Both 'duplicate' buttons should be highlighted (due to value matching)
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[2].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[2].classes()).toContain('bg-white')
})
})
@@ -380,9 +354,7 @@ describe('WidgetSelectButton Button Selection', () => {
const buttons = wrapper.findAll('button')
const unselectedButton = buttons[1] // 'option2'
expect(unselectedButton.classes()).toContain(
'hover:bg-interface-menu-component-surface-hovered'
)
expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50')
expect(unselectedButton.classes()).toContain('cursor-pointer')
})
})

View File

@@ -10,7 +10,7 @@
size="small"
:pt="{
option: 'text-xs',
dropdownIcon: 'text-component-node-foreground-secondary'
dropdownIcon: 'text-button-icon'
}"
data-capture-wheel="true"
@update:model-value="onChange"

View File

@@ -1,21 +1,10 @@
<script setup lang="ts">
import { capitalize } from 'es-toolkit'
import { computed, provide, ref, toRef, watch } from 'vue'
import { computed, provide, ref, watch } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
import { AssetKindKey } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type {
DropdownItem,
FilterOption,
LayoutMode,
SelectedKey
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -27,15 +16,21 @@ import {
filterWidgetProps
} from '@/utils/widgetPropFilter'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import { AssetKindKey } from './form/dropdown/types'
import type {
DropdownItem,
FilterOption,
SelectedKey
} from './form/dropdown/types'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
nodeType?: string
assetKind?: AssetKind
allowUpload?: boolean
uploadFolder?: ResultItemType
isAssetMode?: boolean
defaultLayoutMode?: LayoutMode
}>()
provide(
@@ -49,7 +44,7 @@ const emit = defineEmits<{
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: () => props.modelValue,
modelValue: props.modelValue,
defaultValue: props.widget.options?.values?.[0] || '',
emit
})
@@ -64,45 +59,14 @@ const combinedProps = computed(() => ({
...transformCompatProps.value
}))
const getAssetData = () => {
if (props.isAssetMode && props.nodeType) {
return useAssetWidgetData(toRef(() => props.nodeType))
}
return null
}
const assetData = getAssetData()
const filterSelected = ref('all')
const filterOptions = computed<FilterOption[]>(() => {
if (props.isAssetMode) {
const categoryName = assetData?.category.value ?? 'All'
return [{ id: 'all', name: capitalize(categoryName) }]
}
return [
{ id: 'all', name: 'All' },
{ id: 'inputs', name: 'Inputs' },
{ id: 'outputs', name: 'Outputs' }
]
})
const filterOptions = ref<FilterOption[]>([
{ id: 'all', name: 'All' },
{ id: 'inputs', name: 'Inputs' },
{ id: 'outputs', name: 'Outputs' }
])
const selectedSet = ref<Set<SelectedKey>>(new Set())
/**
* Transforms a value using getOptionLabel if available.
* Falls back to the original value if getOptionLabel is not provided or throws an error.
*/
function getDisplayLabel(value: string): string {
const getOptionLabel = props.widget.options?.getOptionLabel
if (!getOptionLabel) return value
try {
return getOptionLabel(value)
} catch (e) {
console.error('Failed to map value:', e)
return value
}
}
const inputItems = computed<DropdownItem[]>(() => {
const values = props.widget.options?.values || []
@@ -114,7 +78,6 @@ const inputItems = computed<DropdownItem[]>(() => {
id: `input-${index}`,
mediaSrc: getMediaUrl(value, 'input'),
name: value,
label: getDisplayLabel(value),
metadata: ''
}))
})
@@ -145,22 +108,14 @@ const outputItems = computed<DropdownItem[]>(() => {
id: `output-${index}`,
mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'),
name: output,
label: getDisplayLabel(output),
metadata: ''
}))
})
const allItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
return assetData.dropdownItems.value
}
return [...inputItems.value, ...outputItems.value]
})
const dropdownItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode) {
return allItems.value
}
switch (filterSelected.value) {
case 'inputs':
return inputItems.value
@@ -195,10 +150,7 @@ const mediaPlaceholder = computed(() => {
return t('widgets.uploadSelect.placeholder')
})
const uploadable = computed(() => {
if (props.isAssetMode) return false
return props.allowUpload === true
})
const uploadable = computed(() => props.allowUpload === true)
const acceptTypes = computed(() => {
// Be permissive with accept types because backend uses libraries
@@ -215,8 +167,6 @@ const acceptTypes = computed(() => {
}
})
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
watch(
localValue,
(currentValue) => {
@@ -344,7 +294,6 @@ function getMediaUrl(
<FormDropdown
v-model:selected="selectedSet"
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
:items="dropdownItems"
:placeholder="mediaPlaceholder"
:multiple="false"

View File

@@ -7,7 +7,7 @@
:aria-label="widget.name"
size="small"
:pt="{
dropdownIcon: 'text-component-node-foreground-secondary'
dropdownIcon: 'text-button-icon'
}"
@update:model-value="onChange"
/>

View File

@@ -4,7 +4,7 @@
v-if="!hidden"
:class="
cn(
'bg-component-node-widget-background box-border flex gap-4 items-center justify-start relative rounded-lg w-full h-16 px-4 py-0',
'bg-zinc-500/10 dark-theme:bg-charcoal-600 box-border flex gap-4 items-center justify-start relative rounded-lg w-full h-16 px-4 py-0',
{ hidden: hideWhenEmpty && !hasAudio }
)
"
@@ -24,14 +24,17 @@
role="button"
:tabindex="0"
aria-label="Play/Pause"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
@click="togglePlayPause"
>
<i
v-if="!isPlaying"
class="text-secondary icon-[lucide--play] size-4"
class="icon-[lucide--play] size-4 text-smoke-600 dark-theme:text-smoke-800"
/>
<i
v-else
class="icon-[lucide--pause] size-4 text-smoke-600 dark-theme:text-smoke-800"
/>
<i v-else class="text-secondary icon-[lucide--pause] size-4" />
</div>
<!-- Time Display -->
@@ -41,9 +44,11 @@
</div>
<!-- Progress Bar -->
<div class="relative h-0.5 flex-1 rounded-full bg-interface-stroke">
<div
class="relative h-0.5 flex-1 rounded-full bg-smoke-300 dark-theme:bg-ash-800"
>
<div
class="absolute top-0 left-0 h-full rounded-full bg-button-icon transition-all"
class="absolute top-0 left-0 h-full rounded-full bg-smoke-600 transition-all dark-theme:bg-white/50"
:style="{ width: `${progressPercentage}%` }"
/>
<input
@@ -65,18 +70,21 @@
role="button"
:tabindex="0"
aria-label="Volume"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
@click="toggleMute"
>
<i
v-if="showVolumeTwo"
class="text-secondary icon-[lucide--volume-2] size-4"
class="icon-[lucide--volume-2] size-4 text-smoke-600 dark-theme:text-smoke-800"
/>
<i
v-else-if="showVolumeOne"
class="text-secondary icon-[lucide--volume-1] size-4"
class="icon-[lucide--volume-1] size-4 text-smoke-600 dark-theme:text-smoke-800"
/>
<i
v-else
class="icon-[lucide--volume-x] size-4 text-smoke-600 dark-theme:text-smoke-800"
/>
<i v-else class="text-secondary icon-[lucide--volume-x] size-4" />
</div>
<!-- Options Button -->
@@ -86,10 +94,12 @@
role="button"
:tabindex="0"
aria-label="More Options"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
@click="toggleOptionsMenu"
>
<i class="text-secondary icon-[lucide--more-vertical] size-4" />
<i
class="icon-[lucide--more-vertical] size-4 text-smoke-600 dark-theme:text-smoke-800"
/>
</div>
</div>

View File

@@ -124,16 +124,10 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('option2', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).toContain('text-primary')
expect(buttons[0].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[2].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).toContain('bg-white')
expect(buttons[1].classes()).toContain('text-neutral-900')
expect(buttons[0].classes()).not.toContain('bg-white')
expect(buttons[2].classes()).not.toContain('bg-white')
})
})
@@ -165,10 +159,8 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('200', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).toContain('text-primary')
expect(buttons[1].classes()).toContain('bg-white')
expect(buttons[1].classes()).toContain('text-neutral-900')
})
})
@@ -209,15 +201,9 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('md', options)
const buttons = wrapper.findAll('button')
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Medium
expect(buttons[0].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[2].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).toContain('bg-white') // Medium
expect(buttons[0].classes()).not.toContain('bg-white')
expect(buttons[2].classes()).not.toContain('bg-white')
})
it('handles objects without value field', () => {
@@ -230,9 +216,7 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('First')
expect(buttons[1].text()).toBe('Second')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[0].classes()).toContain('bg-white')
})
it('handles objects without label field', () => {
@@ -269,12 +253,8 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('first_id', options, { optionValue: 'id' })
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[1].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[1].classes()).not.toContain('bg-white')
})
it('emits custom optionValue when clicked', async () => {
@@ -321,9 +301,7 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain(
'hover:bg-interface-menu-component-surface-hovered'
)
expect(button.classes()).not.toContain('hover:bg-zinc-200/50')
expect(button.classes()).not.toContain('cursor-pointer')
})
})
@@ -333,9 +311,7 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('option1', options, { disabled: true })
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
) // Selected styling disabled
expect(buttons[0].classes()).not.toContain('bg-white') // Selected styling disabled
expect(buttons[0].classes()).toContain('opacity-50')
expect(buttons[0].classes()).toContain('text-secondary')
})
@@ -348,9 +324,7 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(button.classes()).not.toContain('bg-white')
})
})
@@ -360,9 +334,7 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
buttons.forEach((button) => {
expect(button.classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(button.classes()).not.toContain('bg-white')
})
})
@@ -371,12 +343,8 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Empty string is selected
expect(buttons[1].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected
expect(buttons[1].classes()).not.toContain('bg-white')
})
it('compares values as strings', () => {
@@ -384,9 +352,7 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('1', options)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // '1' matches number 1 as string
expect(buttons[0].classes()).toContain('bg-white') // '1' matches number 1 as string
})
})
@@ -396,10 +362,8 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('option1', options)
const selectedButton = wrapper.findAll('button')[0]
expect(selectedButton.classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(selectedButton.classes()).toContain('text-primary')
expect(selectedButton.classes()).toContain('bg-white')
expect(selectedButton.classes()).toContain('text-neutral-900')
})
it('applies unselected styling to inactive options', () => {
@@ -416,9 +380,7 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('option1', options, { disabled: false })
const unselectedButton = wrapper.findAll('button')[1]
expect(unselectedButton.classes()).toContain(
'hover:bg-interface-menu-component-surface-hovered'
)
expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50')
expect(unselectedButton.classes()).toContain('cursor-pointer')
})
})
@@ -441,9 +403,7 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('@#$%^&*()')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[0].classes()).toContain('bg-white')
})
it('handles unicode characters in options', () => {
@@ -452,9 +412,7 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons[0].text()).toBe('🎨 Art')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[0].classes()).toContain('bg-white')
})
it('handles duplicate option values', () => {
@@ -462,15 +420,9 @@ describe('FormSelectButton Core Component', () => {
const wrapper = mountComponent('duplicate', duplicateOptions)
const buttons = wrapper.findAll('button')
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[2].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Both duplicates selected
expect(buttons[1].classes()).not.toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[0].classes()).toContain('bg-white')
expect(buttons[2].classes()).toContain('bg-white') // Both duplicates selected
expect(buttons[1].classes()).not.toContain('bg-white')
})
it('handles mixed type options safely', () => {
@@ -484,9 +436,7 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[1].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Number 123 as string
expect(buttons[1].classes()).toContain('bg-white') // Number 123 as string
})
it('handles objects with missing properties gracefully', () => {
@@ -500,9 +450,7 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(4)
expect(buttons[2].classes()).toContain(
'bg-interface-menu-component-surface-selected'
)
expect(buttons[2].classes()).toContain('bg-white')
})
it('handles large number of options', () => {
@@ -514,9 +462,7 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(50)
expect(buttons[24].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Option 25 at index 24
expect(buttons[24].classes()).toContain('bg-white') // Option 25 at index 24
})
it('fallback to index when all object properties are missing', () => {
@@ -528,9 +474,7 @@ describe('FormSelectButton Core Component', () => {
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(2)
expect(buttons[0].classes()).toContain(
'bg-interface-menu-component-surface-selected'
) // Falls back to index 0
expect(buttons[0].classes()).toContain('bg-white') // Falls back to index 0
})
})

View File

@@ -16,14 +16,14 @@
'bg-transparent border-none',
'text-center text-xs font-normal',
{
'bg-interface-menu-component-surface-selected':
isSelected(option) && !disabled,
'hover:bg-interface-menu-component-surface-hovered':
!isSelected(option) && !disabled,
'bg-white': isSelected(option) && !disabled,
'hover:bg-zinc-200/50': !isSelected(option) && !disabled,
'opacity-50 cursor-not-allowed': disabled,
'cursor-pointer': !disabled
},
isSelected(option) && !disabled ? 'text-primary' : 'text-secondary'
isSelected(option) && !disabled
? 'text-neutral-900'
: 'text-secondary'
)
"
:disabled="disabled"

View File

@@ -34,7 +34,7 @@ const selectedItems = computed(() => {
const chevronClass = computed(() =>
cn(
'mr-2 size-4 transition-transform duration-200 flex-shrink-0 text-component-node-foreground-secondary',
'mr-2 size-4 transition-transform duration-200 flex-shrink-0 text-button-icon',
{
'rotate-180': props.isOpen
}
@@ -42,15 +42,12 @@ const chevronClass = computed(() =>
)
const theButtonStyle = computed(() =>
cn(
'border-0 bg-component-node-widget-background outline-none text-text-secondary',
{
'hover:bg-component-node-widget-background-hovered cursor-pointer':
!props.disabled,
'cursor-not-allowed': props.disabled,
'text-text-primary': selectedItems.value.length > 0
}
)
cn('bg-transparent border-0 outline-none text-text-secondary', {
'hover:bg-node-component-widget-input-surface/30 cursor-pointer':
!props.disabled,
'cursor-not-allowed': props.disabled,
'text-text-primary': selectedItems.value.length > 0
})
)
</script>
@@ -77,7 +74,7 @@ const theButtonStyle = computed(() =>
{{ props.placeholder }}
</span>
<span v-else class="line-clamp-1 min-w-0 break-all">
{{ selectedItems.map((item) => item.label ?? item.name).join(', ') }}
{{ selectedItems.map((item) => (item as any)?.name).join(', ') }}
</span>
</span>
<i class="icon-[lucide--chevron-down]" :class="chevronClass" />

View File

@@ -86,7 +86,6 @@ const searchQuery = defineModel<string>('searchQuery')
:selected="isSelected(item, index)"
:media-src="item.mediaSrc"
:name="item.name"
:label="item.label"
:metadata="item.metadata"
:layout="layoutMode"
@click="emit('item-click', item, index)"

View File

@@ -11,7 +11,7 @@ const filterSelected = defineModel<OptionId>('filterSelected')
</script>
<template>
<div class="text-secondary mb-4 flex gap-1 px-4">
<div class="mb-4 flex gap-1 px-4 text-zinc-400">
<div
v-for="option in filterOptions"
:key="option.id"
@@ -19,10 +19,10 @@ const filterSelected = defineModel<OptionId>('filterSelected')
cn(
'px-4 py-2 rounded-md inline-flex justify-center items-center cursor-pointer select-none',
'transition-all duration-150',
'hover:text-base-foreground hover:bg-interface-menu-component-surface-hovered',
'hover:text-base-foreground hover:bg-zinc-500/10',
'active:scale-95',
filterSelected === option.id
? '!bg-interface-menu-component-surface-selected text-base-foreground'
? '!bg-zinc-500/20 text-base-foreground'
: 'bg-transparent'
)
"

View File

@@ -12,7 +12,6 @@ interface Props {
selected: boolean
mediaSrc: string
name: string
label?: string
metadata?: string
layout?: LayoutMode
}
@@ -61,9 +60,9 @@ function handleVideoLoad(event: Event) {
'transition-all duration-150',
{
'flex-col text-center': layout === 'grid',
'flex-row text-left max-h-16 bg-interface-menu-component-surface-hovered rounded-lg hover:scale-102 active:scale-98':
'flex-row text-left max-h-16 bg-zinc-500/20 rounded-lg hover:scale-102 active:scale-98':
layout === 'list',
'flex-row text-left hover:bg-interface-menu-component-surface-hovered rounded-lg':
'flex-row text-left hover:bg-zinc-500/20 rounded-lg':
layout === 'list-small',
// selection
'ring-2 ring-blue-500': layout === 'list' && selected
@@ -78,7 +77,7 @@ function handleVideoLoad(event: Event) {
:class="
cn(
'relative',
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-interface-stroke',
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-zinc-300/10',
'transition-all duration-150',
{
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
@@ -124,27 +123,26 @@ function handleVideoLoad(event: Event) {
:class="
cn('flex gap-1', {
'flex-col': layout === 'grid',
'flex-col px-4 py-1 w-full justify-center min-w-0': layout === 'list',
'flex-col px-4 py-1 w-full justify-center': layout === 'list',
'flex-row p-2 items-center justify-between w-full':
layout === 'list-small'
})
"
>
<span
v-tooltip="layout === 'grid' ? (label ?? name) : undefined"
:class="
cn(
'block text-[15px] line-clamp-2 break-words overflow-hidden',
'block text-[15px] line-clamp-2 wrap-break-word',
'transition-colors duration-150',
// selection
!!selected && 'text-blue-500'
)
"
>
{{ label ?? name }}
{{ name }}
</span>
<!-- Meta Data -->
<span class="text-secondary block text-xs">{{
<span class="block text-xs text-slate-400">{{
metadata || actualDimensions
}}</span>
</div>

View File

@@ -9,7 +9,6 @@ export interface DropdownItem {
id: SelectedKey
mediaSrc: string // URL for image, video, or other media
name: string
label?: string
metadata: string
}
export interface SortOption {

View File

@@ -2,13 +2,13 @@ import { cn } from '@/utils/tailwindUtil'
export const WidgetInputBaseClass = cn([
// Background
'not-disabled:bg-component-node-widget-background',
'not-disabled:text-component-node-foreground',
'not-disabled:bg-node-component-widget-input-surface',
'not-disabled:text-node-component-widget-input',
// Outline
'border-none',
'outline outline-offset-[-1px] outline-component-node-border',
'outline outline-offset-[-1px] outline-node-stroke',
// Rounded
'rounded-lg',
// Hover
'hover:bg-component-node-widget-background-hovered'
'hover:bg-node-component-surface-hovered'
])

View File

@@ -1,99 +0,0 @@
import { computed, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { DropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
/**
* Composable for fetching and transforming asset data for Vue node widgets.
* Provides reactive asset data based on node type with automatic category detection.
* Uses store-based caching to avoid duplicate fetches across multiple instances.
*
* Cloud-only composable - returns empty data when not in cloud environment.
*
* @param nodeType - ComfyUI node type (ref, getter, or plain value). Can be undefined.
* Accepts: ref('CheckpointLoaderSimple'), () => 'CheckpointLoaderSimple', or 'CheckpointLoaderSimple'
* @returns Reactive data including category, assets, dropdown items, loading state, and errors
*/
export function useAssetWidgetData(
nodeType: MaybeRefOrGetter<string | undefined>
) {
if (isCloud) {
const assetsStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const category = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? modelToNodeStore.getCategoryForNodeType(resolvedType)
: undefined
})
const assets = computed<AssetItem[]>(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? (assetsStore.modelAssetsByNodeType.get(resolvedType) ?? [])
: []
})
const isLoading = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? (assetsStore.modelLoadingByNodeType.get(resolvedType) ?? false)
: false
})
const error = computed<Error | null>(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? (assetsStore.modelErrorByNodeType.get(resolvedType) ?? null)
: null
})
const dropdownItems = computed<DropdownItem[]>(() => {
return assets.value.map((asset) => ({
id: asset.id,
name:
(asset.user_metadata?.filename as string | undefined) ?? asset.name,
label: asset.name,
mediaSrc: asset.preview_url ?? '',
metadata: ''
}))
})
watch(
() => toValue(nodeType),
async (currentNodeType) => {
if (!currentNodeType) {
return
}
const hasData = assetsStore.modelAssetsByNodeType.has(currentNodeType)
if (!hasData) {
await assetsStore.updateModelsForNodeType(currentNodeType)
}
},
{ immediate: true }
)
return {
category,
assets,
dropdownItems,
isLoading,
error
}
}
return {
category: computed(() => undefined),
assets: computed(() => []),
dropdownItems: computed(() => []),
isLoading: computed(() => false),
error: computed(() => null)
}
}

View File

@@ -11,7 +11,6 @@ import {
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -23,8 +22,6 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { addValueControlWidgets } from '@/scripts/widgets'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useAssetsStore } from '@/stores/assetsStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import { useRemoteWidget } from './useRemoteWidget'
@@ -35,20 +32,6 @@ const getDefaultValue = (inputSpec: ComboInputSpec) => {
return undefined
}
// Map node types to expected media types
const NODE_MEDIA_TYPE_MAP: Record<string, 'image' | 'video' | 'audio'> = {
LoadImage: 'image',
LoadVideo: 'video',
LoadAudio: 'audio'
}
// Map node types to placeholder i18n keys
const NODE_PLACEHOLDER_MAP: Record<string, string> = {
LoadImage: 'widgets.uploadSelect.placeholderImage',
LoadVideo: 'widgets.uploadSelect.placeholderVideo',
LoadAudio: 'widgets.uploadSelect.placeholderAudio'
}
const addMultiSelectWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
@@ -72,168 +55,87 @@ const addMultiSelectWidget = (
return widget
}
const createAssetBrowserWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec,
defaultValue: string | undefined
): IBaseWidget => {
const currentValue = defaultValue
const displayLabel = currentValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()
const widget = node.addWidget(
'asset',
inputSpec.name,
displayLabel,
async function (this: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
this.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
)
return widget
}
const createInputMappingWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec,
defaultValue: string | undefined
): IBaseWidget => {
const assetsStore = useAssetsStore()
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue ?? '',
() => {},
{
values: [],
getOptionLabel: (value?: string | null) => {
if (!value) {
const placeholderKey =
NODE_PLACEHOLDER_MAP[node.comfyClass ?? ''] ??
'widgets.uploadSelect.placeholder'
return t(placeholderKey)
}
return assetsStore.getInputName(value)
}
}
)
if (assetsStore.inputAssets.length === 0 && !assetsStore.inputLoading) {
void assetsStore.updateInputs().then(() => {
// edge for users using nodes with 0 prior inputs
// force canvas refresh the first time they add an asset
// so they see filenames instead of hashes.
node.setDirtyCanvas(true, false)
})
}
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
if (prop !== 'values') {
return target[prop as keyof typeof target]
}
return assetsStore.inputAssets
.filter(
(asset) =>
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.map((asset) => asset.asset_hash)
.filter((hash): hash is string => !!hash)
}
})
if (inputSpec.control_after_generate) {
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
}
const addComboWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
): IBaseWidget => {
const defaultValue = getDefaultValue(inputSpec)
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
node.comfyClass,
inputSpec.name
)
if (isCloud) {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
node.comfyClass,
inputSpec.name
if (isUsingAssetAPI && isEligible) {
const currentValue = getDefaultValue(inputSpec)
const displayLabel = currentValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()
const widget = node.addWidget(
'asset',
inputSpec.name,
displayLabel,
async function (this: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
this.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
)
if (isUsingAssetAPI && isEligible) {
return createAssetBrowserWidget(node, inputSpec, defaultValue)
}
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
return createInputMappingWidget(node, inputSpec, defaultValue)
}
return widget
}
// Standard combo widget
// Create normal combo widget
const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? []
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
() => {},
{
values: inputSpec.options ?? []
values: comboOptions
}
)
@@ -241,7 +143,6 @@ const addComboWidget = (
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
@@ -265,7 +166,6 @@ const addComboWidget = (
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}
widget.linkedWidgets = addValueControlWidgets(
node,
widget,

View File

@@ -78,13 +78,9 @@ export const useImageUploadWidget = () => {
folder,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
// Create a NEW array to ensure Vue reactivity detects the change
const newValue = allow_batch ? [...output] : output[0]
// @ts-expect-error litegraph combo value type does not support arrays yet
fileComboWidget.value = newValue
fileComboWidget.callback?.(newValue)
fileComboWidget.value = output
fileComboWidget.callback?.(output)
}
})

View File

@@ -62,10 +62,7 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
essential: true
}
],
[
'combo',
{ component: WidgetSelect, aliases: ['COMBO', 'asset'], essential: true }
],
['combo', { component: WidgetSelect, aliases: ['COMBO'], essential: true }],
[
'color',
{ component: WidgetColorPicker, aliases: ['COLOR'], essential: false }

Some files were not shown because too many files have changed in this diff Show More