Compare commits
3 Commits
v1.32.3
...
zendesk-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee8f166a9 | ||
|
|
267e7d57b1 | ||
|
|
4c5ae9cb8f |
116
.github/actions/comment-release-links/action.yaml
vendored
@@ -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
|
||||
@@ -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
|
||||
|
||||
51
.github/workflows/release-draft-create.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 78 KiB |
14
global.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
26
src/App.vue
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1507,6 +1507,7 @@
|
||||
"Video": "فيديو",
|
||||
"Video API": "واجهة برمجة تطبيقات الفيديو"
|
||||
},
|
||||
"licensesSelected": "{count} تراخيص",
|
||||
"loading": "جارٍ تحميل القوالب...",
|
||||
"loadingMore": "تحميل المزيد من القوالب...",
|
||||
"modelFilter": "مرشح النماذج",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1504,6 +1504,7 @@
|
||||
"Video": "ビデオ",
|
||||
"Video API": "動画API"
|
||||
},
|
||||
"licensesSelected": "{count}件のライセンス",
|
||||
"loading": "テンプレートを読み込み中...",
|
||||
"loadingMore": "さらにテンプレートを読み込み中...",
|
||||
"modelFilter": "モデルフィルター",
|
||||
|
||||
@@ -1504,6 +1504,7 @@
|
||||
"Video": "비디오",
|
||||
"Video API": "비디오 API"
|
||||
},
|
||||
"licensesSelected": "{count}개 라이선스",
|
||||
"loading": "템플릿 불러오는 중...",
|
||||
"loadingMore": "템플릿 더 불러오는 중...",
|
||||
"modelFilter": "모델 필터",
|
||||
|
||||
@@ -1504,6 +1504,7 @@
|
||||
"Video": "Видео",
|
||||
"Video API": "Video API"
|
||||
},
|
||||
"licensesSelected": "{count} лицензий",
|
||||
"loading": "Загрузка шаблонов...",
|
||||
"loadingMore": "Загрузка дополнительных шаблонов...",
|
||||
"modelFilter": "Фильтр моделей",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1504,6 +1504,7 @@
|
||||
"Video": "影片",
|
||||
"Video API": "影片 API"
|
||||
},
|
||||
"licensesSelected": "{count} 個授權",
|
||||
"loading": "正在載入範本...",
|
||||
"loadingMore": "載入更多範本...",
|
||||
"modelFilter": "模型篩選",
|
||||
|
||||
@@ -1507,6 +1507,7 @@
|
||||
"Video": "视频生成",
|
||||
"Video API": "视频 API"
|
||||
},
|
||||
"licensesSelected": "已选 {count} 个许可类型",
|
||||
"loading": "正在加载模板...",
|
||||
"loadingMore": "正在加载更多模板...",
|
||||
"modelFilter": "模型筛选",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
display="chip"
|
||||
:pt="{
|
||||
option: 'text-xs',
|
||||
dropdownIcon: 'text-component-node-foreground-secondary'
|
||||
dropdownIcon: 'text-button-icon'
|
||||
}"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
|
||||
@@ -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 = {
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||