Compare commits

..

11 Commits

Author SHA1 Message Date
bymyself
cb0861edf5 [style] apply review feedback: simplify conditional logic
Simplify conditional class logic in FormSelectButton.vue per review suggestion:
- Combine cursor classes into single ternary
- Combine background classes into nested ternary with !disabled guard
2025-11-06 13:05:27 -07:00
bymyself
6e2b20b5bf Merge main into vue-node/style/improvements
Update semantic token usage to match current design system conventions:
- Use bg-component-node-widget-background (main's naming)
- Use text-base-foreground for primary text (main's convention)
- Use text-secondary for icons and secondary text (semantic token)
- Use bg-interface-stroke and bg-button-icon (semantic tokens)
- Use hover:bg-interface-menu-component-surface-hovered
- Use bg-interface-menu-component-surface-selected

This preserves the PR's intent of migrating to semantic tokens while
adopting the evolved token names from main.
2025-11-05 13:38:17 -07:00
bymyself
44f485eeca revert config changes 2025-10-28 12:53:37 -07:00
bymyself
032f5f2ecf revert multiselect changes 2025-10-27 20:13:37 -07:00
bymyself
df5cd4ce04 [style] ignore temporarily disabled TailwindCSS ESLint dependencies in knip
Due to TailwindCSS v4 compatibility issues with eslint-plugin-tailwindcss v4.0.0-beta.0,
we temporarily disabled the plugin in eslint.config.ts. This causes knip to detect
these dependencies as unused, so we add them to ignoreDependencies until the
compatibility issue is resolved.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 08:31:20 -07:00
bymyself
883463cb54 [fix] temporarily disable TailwindCSS ESLint plugin and revert MultiSelect.vue
- Disable eslint-plugin-tailwindcss due to TailwindCSS v4 incompatibility
- Revert MultiSelect.vue changes as they were not part of vueNodes scope

The eslint-plugin-tailwindcss v4.0.0-beta.0 is incompatible with TailwindCSS v4.1.12
as it tries to load v3 file structure that no longer exists.
2025-10-27 08:31:20 -07:00
bymyself
b43c0a53bb [style] restore MultiSelect.vue semantic token changes
Restore the semantic token migration for MultiSelect.vue:
- text-neutral-400 dark-theme:text-zinc-500 → text-secondary

This completes the semantic token migration across all components.
2025-10-27 00:01:58 -07:00
bymyself
eddd8ba7fd [test] update FormSelectButton tests for semantic tokens
Update test expectations to use semantic color tokens instead of hard-coded classes:
- bg-white → bg-interface-menu-component-surface-selected (25 instances)
- text-neutral-900 → text-primary (3 instances)
- hover:bg-zinc-200/50 → hover:bg-interface-menu-component-surface-hovered (2 instances)
2025-10-26 23:05:47 -07:00
bymyself
4e1d49a097 [test] update WidgetSelectButton tests for semantic tokens
Update test expectations to use semantic color tokens instead of hard-coded classes:
- bg-white → bg-interface-menu-component-surface-selected
- text-neutral-900 → text-primary/text-secondary
- hover:bg-zinc-200/50 → hover:bg-interface-menu-component-surface-hovered
2025-10-26 23:05:47 -07:00
bymyself
20015ccd81 [style] complete semantic token migration for vueNodes widgets
Complete the semantic token migration by updating final hard-coded colors
in widget components to use design system tokens:

- FormSelectButton.vue: use text-primary/text-secondary consistently
- FormDropdownMenuFilter.vue: use text-secondary for default text
- FormDropdownMenuItem.vue: use text-secondary for metadata
- AudioPreviewPlayer.vue: use text-secondary for icons
- Remove hard-coded zinc, neutral, and other color values from vueNodes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 22:13:29 -07:00
bymyself
cd15b659ae [style] complete semantic token migration for remaining components
Complete the semantic token migration by updating final hard-coded colors
in widget components to use design system tokens:

- MultiSelect.vue: text-neutral-400 → text-secondary
- Ensure consistent text-primary/text-secondary usage across all widgets
- Remove hard-coded zinc, neutral, and other color values

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 22:13:29 -07:00
84 changed files with 184 additions and 2278 deletions

View File

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

View File

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

View File

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

View File

@@ -74,15 +74,8 @@ const config: StorybookConfig = {
'@': process.cwd() + '/src'
}
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main
minifyIdentifiers: false,
keepNames: true
},
build: {
rollupOptions: {
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
treeshake: false,
onwarn: (warning, warn) => {
// Suppress specific warnings
if (

View File

@@ -75,15 +75,8 @@ const config: StorybookConfig = {
'@frontend-locales': process.cwd() + '/../../src/locales'
}
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main
minifyIdentifiers: false,
keepNames: true
},
build: {
rollupOptions: {
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
treeshake: false,
onwarn: (warning, warn) => {
// Suppress specific warnings
if (

View File

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

View File

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

View File

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

View File

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

14
global.d.ts vendored
View File

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

View File

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

View File

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

View File

@@ -7,7 +7,6 @@
<div
class="actionbar-container pointer-events-auto flex h-12 items-center rounded-lg border border-[var(--interface-stroke)] px-2 shadow-interface"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
@@ -25,7 +24,6 @@ import { onMounted, ref } from 'vue'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'

View File

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

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
<template>
<div class="flex h-full shrink-0 items-center gap-1">
<Button
v-for="(button, index) in actionBarButtonStore.buttons"
:key="index"
v-tooltip.bottom="button.tooltip"
:label="button.label"
:aria-label="button.tooltip || button.label"
:class="button.class"
text
rounded
severity="secondary"
class="h-7"
@click="button.onClick"
>
<template #icon>
<i :class="button.icon" />
</template>
</Button>
</div>
</template>
<script lang="ts" setup>
import Button from 'primevue/button'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
const actionBarButtonStore = useActionBarButtonStore()
</script>

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
@@ -21,7 +20,7 @@ import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBro
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { SUPPORT_URL } 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 +840,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
const { userEmail, resolvedUserInfo } = useCurrentUser()
const supportUrl = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(supportUrl, '_blank')
window.open(SUPPORT_URL, '_blank')
}
},
{

View File

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

View File

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

View File

@@ -1,23 +0,0 @@
import { t } from '@/i18n'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
// Zendesk feedback URL - update this with the actual URL
const ZENDESK_FEEDBACK_URL =
'https://support.comfy.org/hc/en-us/requests/new?ticket_form_id=43066738713236'
const buttons: ActionBarButton[] = [
{
icon: 'icon-[lucide--message-circle-question-mark]',
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
window.open(ZENDESK_FEEDBACK_URL, '_blank', 'noopener,noreferrer')
}
}
]
useExtensionService().registerExtension({
name: 'Comfy.Cloud.FeedbackButton',
actionBarButtons: buttons
})

View File

@@ -29,7 +29,6 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
await import('./cloudFeedbackTopbarButton')
if (window.__CONFIG__?.subscription_required) {
await import('./cloudSubscription')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,43 +1,17 @@
import { isCloud } from '@/platform/distribution/types'
/**
* Zendesk ticket form field IDs.
* Zendesk ticket form field ID for the distribution tag.
* This field is used to categorize support requests by their source (cloud vs OSS).
*/
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'
const DISTRIBUTION_FIELD_ID = 'tf_42243568391700'
/**
* 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.
* Support URLs for the ComfyUI platform.
* The URL varies based on whether the application is running in Cloud or OSS distribution.
*
* @param params - User information to pre-fill in the support form
* @returns Complete Zendesk support URL with query parameters
* - Cloud: Includes 'ccloud' tag for identifying cloud-based support requests
* - OSS: Includes 'oss' tag for identifying open-source support requests
*/
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()}`
}
const TAG = isCloud ? 'ccloud' : 'oss'
export const SUPPORT_URL = `https://support.comfy.org/hc/en-us/requests/new?${DISTRIBUTION_FIELD_ID}=${TAG}`

View File

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

View File

@@ -20,28 +20,9 @@ export function useWorkflowPersistence() {
const persistCurrentWorkflow = () => {
if (!workflowPersistenceEnabled.value) return
const workflow = JSON.stringify(comfyApp.graph.serialize())
try {
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
}
} catch (error) {
// Only log our own keys and aggregate stats
const ourKeys = Object.keys(sessionStorage).filter(
(key) => key.startsWith('workflow:') || key === 'workflow'
)
console.error('QuotaExceededError details:', {
workflowSizeKB: Math.round(workflow.length / 1024),
totalStorageItems: Object.keys(sessionStorage).length,
ourWorkflowKeys: ourKeys.length,
ourWorkflowSizes: ourKeys.map((key) => ({
key,
sizeKB: Math.round(sessionStorage[key].length / 1024)
})),
error: error instanceof Error ? error.message : String(error)
})
throw error
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
}
}

View File

@@ -46,7 +46,7 @@
@contextmenu="handleContextMenu"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
@drop="handleDrop"
>
<div class="flex flex-col justify-center items-center relative">
<template v-if="isCollapsed">
@@ -136,7 +136,7 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { useMouseInElement, whenever } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, inject, onErrorCaptured, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -486,10 +486,13 @@ const nodeMedia = computed(() => {
const nodeContainerRef = ref<HTMLDivElement>()
// Track mouse position relative to node container for drag and drop
const { isOutside } = useMouseInElement(nodeContainerRef)
// Drag and drop support
const isDraggingOver = ref(false)
function handleDragOver(event: DragEvent) {
const handleDragOver = (event: DragEvent) => {
const node = lgraphNode.value
if (!node || !node.onDragOver) {
isDraggingOver.value = false
@@ -501,11 +504,16 @@ function handleDragOver(event: DragEvent) {
isDraggingOver.value = canDrop
}
function handleDragLeave() {
isDraggingOver.value = false
const handleDragLeave = () => {
if (isOutside.value) {
isDraggingOver.value = false
}
}
async function handleDrop(event: DragEvent) {
const handleDrop = async (event: DragEvent) => {
event.preventDefault()
event.stopPropagation()
isDraggingOver.value = false
const node = lgraphNode.value

View File

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

View File

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

View File

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

View File

@@ -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(
@@ -64,45 +59,14 @@ const combinedProps = computed(() => ({
...transformCompatProps.value
}))
const getAssetData = () => {
if (props.isAssetMode && props.nodeType) {
return useAssetWidgetData(toRef(() => props.nodeType))
}
return null
}
const assetData = getAssetData()
const filterSelected = ref('all')
const filterOptions = computed<FilterOption[]>(() => {
if (props.isAssetMode) {
const categoryName = assetData?.category.value ?? 'All'
return [{ id: 'all', name: capitalize(categoryName) }]
}
return [
{ id: 'all', name: 'All' },
{ id: 'inputs', name: 'Inputs' },
{ id: 'outputs', name: 'Outputs' }
]
})
const filterOptions = ref<FilterOption[]>([
{ id: 'all', name: 'All' },
{ id: 'inputs', name: 'Inputs' },
{ id: 'outputs', name: 'Outputs' }
])
const selectedSet = ref<Set<SelectedKey>>(new Set())
/**
* Transforms a value using getOptionLabel if available.
* Falls back to the original value if getOptionLabel is not provided or throws an error.
*/
function getDisplayLabel(value: string): string {
const getOptionLabel = props.widget.options?.getOptionLabel
if (!getOptionLabel) return value
try {
return getOptionLabel(value)
} catch (e) {
console.error('Failed to map value:', e)
return value
}
}
const inputItems = computed<DropdownItem[]>(() => {
const values = props.widget.options?.values || []
@@ -114,7 +78,6 @@ const inputItems = computed<DropdownItem[]>(() => {
id: `input-${index}`,
mediaSrc: getMediaUrl(value, 'input'),
name: value,
label: getDisplayLabel(value),
metadata: ''
}))
})
@@ -145,22 +108,14 @@ const outputItems = computed<DropdownItem[]>(() => {
id: `output-${index}`,
mediaSrc: getMediaUrl(output.replace(' [output]', ''), 'output'),
name: output,
label: getDisplayLabel(output),
metadata: ''
}))
})
const allItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode && assetData) {
return assetData.dropdownItems.value
}
return [...inputItems.value, ...outputItems.value]
})
const dropdownItems = computed<DropdownItem[]>(() => {
if (props.isAssetMode) {
return allItems.value
}
switch (filterSelected.value) {
case 'inputs':
return inputItems.value
@@ -195,10 +150,7 @@ const mediaPlaceholder = computed(() => {
return t('widgets.uploadSelect.placeholder')
})
const uploadable = computed(() => {
if (props.isAssetMode) return false
return props.allowUpload === true
})
const uploadable = computed(() => props.allowUpload === true)
const acceptTypes = computed(() => {
// Be permissive with accept types because backend uses libraries
@@ -215,8 +167,6 @@ const acceptTypes = computed(() => {
}
})
const layoutMode = ref<LayoutMode>(props.defaultLayoutMode ?? 'grid')
watch(
localValue,
(currentValue) => {
@@ -344,7 +294,6 @@ function getMediaUrl(
<FormDropdown
v-model:selected="selectedSet"
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
:items="dropdownItems"
:placeholder="mediaPlaceholder"
:multiple="false"

View File

@@ -15,14 +15,11 @@
'flex-1 h-6 px-5 py-[5px] rounded flex justify-center items-center gap-1 transition-all duration-150 ease-in-out',
'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,
'opacity-50 cursor-not-allowed': disabled,
'cursor-pointer': !disabled
},
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
!disabled &&
(isSelected(option)
? 'bg-interface-menu-component-surface-selected'
: 'hover:bg-interface-menu-component-surface-hovered'),
isSelected(option) && !disabled ? 'text-primary' : 'text-secondary'
)
"

View File

@@ -77,7 +77,7 @@ const theButtonStyle = computed(() =>
{{ props.placeholder }}
</span>
<span v-else class="line-clamp-1 min-w-0 break-all">
{{ selectedItems.map((item) => item.label ?? item.name).join(', ') }}
{{ selectedItems.map((item) => (item as any)?.name).join(', ') }}
</span>
</span>
<i class="icon-[lucide--chevron-down]" :class="chevronClass" />

View File

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

View File

@@ -12,7 +12,6 @@ interface Props {
selected: boolean
mediaSrc: string
name: string
label?: string
metadata?: string
layout?: LayoutMode
}
@@ -124,24 +123,23 @@ 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">{{

View File

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

View File

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

View File

@@ -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 }

View File

@@ -1,6 +0,0 @@
import type { PrimitiveNode } from '@/extensions/core/widgetInputs'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
export const isPrimitiveNode = (
node: LGraphNode
): node is PrimitiveNode & LGraphNode => node.type === 'PrimitiveNode'

View File

@@ -78,7 +78,7 @@ import {
findLegacyRerouteNodes,
noNativeReroutes
} from '@/utils/migration/migrateReroute'
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import { getSelectedModelsMetadata } from '@/utils/modelMetadataUtil'
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'

View File

@@ -1,9 +1,9 @@
import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { ref, watch } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
@@ -24,7 +24,7 @@ type CustomerEventsResponseQuery =
export type AuditLog = components['schemas']['AuditLog']
const customerApiClient = axios.create({
baseURL: getComfyApiBaseUrl(),
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
@@ -35,13 +35,6 @@ export const useCustomerEventsService = () => {
const error = ref<string | null>(null)
const { d } = useI18n()
watch(
() => getComfyApiBaseUrl(),
(url) => {
customerApiClient.defaults.baseURL = url
}
)
const handleRequestError = (
err: unknown,
context: string,

View File

@@ -11,17 +11,12 @@ import { useMenuItemStore } from '@/stores/menuItemStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { ComfyExtension } from '@/types/comfy'
import type { AuthUserInfo } from '@/types/authTypes'
export const useExtensionService = () => {
const extensionStore = useExtensionStore()
const settingStore = useSettingStore()
const keybindingStore = useKeybindingStore()
const {
wrapWithErrorHandling,
wrapWithErrorHandlingAsync,
toastErrorHandler
} = useErrorHandling()
const { wrapWithErrorHandling } = useErrorHandling()
/**
* Loads all extensions from the API into the window in parallel
@@ -82,55 +77,22 @@ export const useExtensionService = () => {
if (extension.onAuthUserResolved) {
const { onUserResolved } = useCurrentUser()
const handleUserResolved = wrapWithErrorHandlingAsync(
(user: AuthUserInfo) => extension.onAuthUserResolved?.(user, app),
(error) => {
console.error('[Extension Auth Hook Error]', {
extension: extension.name,
hook: 'onAuthUserResolved',
error
})
toastErrorHandler(error)
}
)
onUserResolved((user) => {
void handleUserResolved(user)
void extension.onAuthUserResolved?.(user, app)
})
}
if (extension.onAuthTokenRefreshed) {
const { onTokenRefreshed } = useCurrentUser()
const handleTokenRefreshed = wrapWithErrorHandlingAsync(
() => extension.onAuthTokenRefreshed?.(),
(error) => {
console.error('[Extension Auth Hook Error]', {
extension: extension.name,
hook: 'onAuthTokenRefreshed',
error
})
toastErrorHandler(error)
}
)
onTokenRefreshed(() => {
void handleTokenRefreshed()
void extension.onAuthTokenRefreshed?.()
})
}
if (extension.onAuthUserLogout) {
const { onUserLogout } = useCurrentUser()
const handleUserLogout = wrapWithErrorHandlingAsync(
() => extension.onAuthUserLogout?.(),
(error) => {
console.error('[Extension Auth Hook Error]', {
extension: extension.name,
hook: 'onAuthUserLogout',
error
})
toastErrorHandler(error)
}
)
onUserLogout(() => {
void handleUserLogout()
void extension.onAuthUserLogout?.()
})
}
}

View File

@@ -55,7 +55,7 @@ import {
isVideoNode,
migrateWidgetsValues
} from '@/utils/litegraphUtil'
import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
import { getOrderedInputSpecs } from '@/utils/nodeDefOrderingUtil'
import { useExtensionService } from './extensionService'

View File

@@ -1,7 +1,7 @@
import { api } from '@/scripts/api'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import { extractCustomNodeName } from '@/workbench/utils/nodeHelpUtil'
import { extractCustomNodeName } from '@/utils/nodeHelpUtil'
class NodeHelpService {
async fetchNodeHelp(node: ComfyNodeDefImpl, locale: string): Promise<string> {

View File

@@ -1,18 +0,0 @@
import { defineStore } from 'pinia'
import { computed } from 'vue'
import type { ActionBarButton } from '@/types/comfy'
import { useExtensionStore } from './extensionStore'
export const useActionBarButtonStore = defineStore('actionBarButton', () => {
const extensionStore = useExtensionStore()
const buttons = computed<ActionBarButton[]>(() =>
extensionStore.extensions.flatMap((e) => e.actionBarButtons ?? [])
)
return {
buttons
}
})

View File

@@ -1,6 +1,6 @@
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, shallowReactive } from 'vue'
import { computed } from 'vue'
import {
mapInputFileToAssetItem,
@@ -9,7 +9,6 @@ import {
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import type { HistoryTaskItem } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { TaskItemImpl } from './queueStore'
@@ -48,7 +47,7 @@ async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
/**
* Convert history task items to asset items
*/
function mapHistoryToAssets(historyItems: HistoryTaskItem[]): AssetItem[] {
function mapHistoryToAssets(historyItems: any[]): AssetItem[] {
const assetItems: AssetItem[] = []
for (const item of historyItems) {
@@ -88,13 +87,9 @@ function mapHistoryToAssets(historyItems: HistoryTaskItem[]): AssetItem[] {
export const useAssetsStore = defineStore('assets', () => {
const maxHistoryItems = 200
const getFetchInputFiles = () => {
if (isCloud) {
return fetchInputFilesFromCloud
}
return fetchInputFilesFromAPI
}
const fetchInputFiles = getFetchInputFiles()
const fetchInputFiles = isCloud
? fetchInputFilesFromCloud
: fetchInputFilesFromAPI
const {
state: inputAssets,
@@ -134,6 +129,7 @@ export const useAssetsStore = defineStore('assets', () => {
const inputAssetsByFilename = computed(() => {
const map = new Map<string, AssetItem>()
for (const asset of inputAssets.value) {
// Use asset_hash as the key (hash-based filename)
if (asset.asset_hash) {
map.set(asset.asset_hash, asset)
}
@@ -150,96 +146,6 @@ export const useAssetsStore = defineStore('assets', () => {
return inputAssetsByFilename.value.get(filename)?.name ?? filename
}
/**
* Model assets cached by node type (e.g., 'CheckpointLoaderSimple', 'LoraLoader')
* Used by multiple loader nodes to avoid duplicate fetches
* Cloud-only feature - empty Maps in desktop builds
*/
const getModelState = () => {
if (isCloud) {
const modelAssetsByNodeType = shallowReactive(
new Map<string, AssetItem[]>()
)
const modelLoadingByNodeType = shallowReactive(new Map<string, boolean>())
const modelErrorByNodeType = shallowReactive(
new Map<string, Error | null>()
)
const stateByNodeType = shallowReactive(
new Map<string, ReturnType<typeof useAsyncState<AssetItem[]>>>()
)
/**
* Fetch and cache model assets for a specific node type
* Uses VueUse's useAsyncState for automatic loading/error tracking
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
* @returns Promise resolving to the fetched assets
*/
async function updateModelsForNodeType(
nodeType: string
): Promise<AssetItem[]> {
if (!stateByNodeType.has(nodeType)) {
stateByNodeType.set(
nodeType,
useAsyncState(
() => assetService.getAssetsForNodeType(nodeType),
[],
{
immediate: false,
resetOnExecute: false,
onError: (err) => {
console.error(
`Error fetching model assets for ${nodeType}:`,
err
)
}
}
)
)
}
const state = stateByNodeType.get(nodeType)!
modelLoadingByNodeType.set(nodeType, true)
modelErrorByNodeType.set(nodeType, null)
try {
await state.execute()
const assets = state.state.value
modelAssetsByNodeType.set(nodeType, assets)
modelErrorByNodeType.set(
nodeType,
state.error.value instanceof Error ? state.error.value : null
)
return assets
} finally {
modelLoadingByNodeType.set(nodeType, state.isLoading.value)
}
}
return {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType
}
}
return {
modelAssetsByNodeType: shallowReactive(new Map<string, AssetItem[]>()),
modelLoadingByNodeType: shallowReactive(new Map<string, boolean>()),
modelErrorByNodeType: shallowReactive(new Map<string, Error | null>()),
updateModelsForNodeType: async () => []
}
}
const {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType
} = getModelState()
return {
// States
inputAssets,
@@ -255,12 +161,6 @@ export const useAssetsStore = defineStore('assets', () => {
// Input mapping helpers
inputAssetsByFilename,
getInputName,
// Model assets
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
updateModelsForNodeType
getInputName
}
})

View File

@@ -21,7 +21,7 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useFirebaseAuth } from 'vuefire'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -64,13 +64,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// Token refresh trigger - increments when token is refreshed
const tokenRefreshTrigger = ref(0)
/**
* The user ID for which the initial ID token has been observed.
* When a token changes for the same user, that is a refresh.
*/
const lastTokenUserId = ref<string | null>(null)
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
// Providers
const googleProvider = new GoogleAuthProvider()
@@ -100,9 +93,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
onAuthStateChanged(auth, (user) => {
currentUser.value = user
isInitialized.value = true
if (user === null) {
lastTokenUserId.value = null
}
// Reset balance when auth state changes
balance.value = null
@@ -112,11 +102,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// Listen for token refresh events
onIdTokenChanged(auth, (user) => {
if (user && isCloud) {
// Skip initial token change
if (lastTokenUserId.value !== user.uid) {
lastTokenUserId.value = user.uid
return
}
tokenRefreshTrigger.value++
}
})
@@ -178,7 +163,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
)
}
const response = await fetch(buildApiUrl('/customers/balance'), {
const response = await fetch(`${COMFY_API_BASE_URL}/customers/balance`, {
headers: {
...authHeader,
'Content-Type': 'application/json'
@@ -214,7 +199,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const createCustomerRes = await fetch(buildApiUrl('/customers'), {
const createCustomerRes = await fetch(`${COMFY_API_BASE_URL}/customers`, {
method: 'POST',
headers: {
...authHeader,
@@ -382,7 +367,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
customerCreated.value = true
}
const response = await fetch(buildApiUrl('/customers/credit'), {
const response = await fetch(`${COMFY_API_BASE_URL}/customers/credit`, {
method: 'POST',
headers: {
...authHeader,
@@ -416,7 +401,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(buildApiUrl('/customers/billing'), {
const response = await fetch(`${COMFY_API_BASE_URL}/customers/billing`, {
method: 'POST',
headers: {
...authHeader,

View File

@@ -5,7 +5,7 @@ import { i18n } from '@/i18n'
import { nodeHelpService } from '@/services/nodeHelpService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import { getNodeHelpBaseUrl } from '@/workbench/utils/nodeHelpUtil'
import { getNodeHelpBaseUrl } from '@/utils/nodeHelpUtil'
export const useNodeHelpStore = defineStore('nodeHelp', () => {
const currentHelpNode = ref<ComfyNodeDefImpl | null>(null)

View File

@@ -57,32 +57,6 @@ export interface TopbarBadge {
tooltip?: string
}
/*
* Action bar button definition: add buttons to the action bar
*/
export interface ActionBarButton {
/**
* Icon class to display (e.g., "icon-[lucide--message-circle-question-mark]")
*/
icon: string
/**
* Optional label text to display next to the icon
*/
label?: string
/**
* Optional tooltip text to show on hover
*/
tooltip?: string
/**
* Optional CSS classes to apply to the button
*/
class?: string
/**
* Click handler for the button
*/
onClick: () => void
}
export type MissingNodeType =
| string
// Primarily used by group nodes.
@@ -128,10 +102,6 @@ export interface ComfyExtension {
* Badges to add to the top bar
*/
topbarBadges?: TopbarBadge[]
/**
* Buttons to add to the action bar
*/
actionBarButtons?: ActionBarButton[]
/**
* Allows any initialisation, e.g. loading resources. Called after the canvas is created but before nodes are added
* @param app The ComfyUI app instance

View File

@@ -1,3 +1,4 @@
import type { PrimitiveNode } from '@/extensions/core/widgetInputs'
import type {
INodeSlot,
LGraph,
@@ -5,6 +6,12 @@ import type {
Subgraph
} from '@/lib/litegraph/src/litegraph'
export function isPrimitiveNode(
node: LGraphNode
): node is PrimitiveNode & LGraphNode {
return node.type === 'PrimitiveNode'
}
/**
* Check if an error is an AbortError triggered by `AbortController#abort`
* when cancelling a request.

View File

@@ -330,7 +330,7 @@ const onGraphReady = () => {
}
}
// 5-minute heartbeat interval
// 30-second heartbeat interval
tabCountInterval = window.setInterval(() => {
const now = Date.now()
@@ -347,7 +347,7 @@ const onGraphReady = () => {
// Track tab count (include current tab)
const tabCount = activeTabs.size + 1
telemetry.trackTabCount({ tab_count: tabCount })
}, 60000 * 5)
}, 30000)
// Send initial heartbeat
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })

View File

@@ -1,123 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
describe('NodeWidgets', () => {
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
name: 'test_widget',
type: 'combo',
value: 'test_value',
options: {
values: ['option1', 'option2']
},
callback: undefined,
spec: undefined,
label: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
const createMockNodeData = (
nodeType: string = 'TestNode',
widgets: SafeWidgetData[] = []
): VueNodeData => ({
id: '1',
type: nodeType,
widgets,
title: 'Test Node',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
})
const mountComponent = (nodeData?: VueNodeData) => {
return mount(NodeWidgets, {
props: {
nodeData
},
global: {
plugins: [createTestingPinia()],
stubs: {
// Stub InputSlot to avoid complex slot registration dependencies
InputSlot: true
},
mocks: {
$t: (key: string) => key
}
}
})
}
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('CheckpointLoaderSimple', [widget])
const wrapper = mountComponent(nodeData)
// Find the dynamically rendered widget component
const widgetComponent = wrapper.find('.lg-node-widget')
expect(widgetComponent.exists()).toBe(true)
// Verify node-type prop is passed
const component = widgetComponent.findComponent({ name: 'WidgetSelect' })
if (component.exists()) {
expect(component.props('nodeType')).toBe('CheckpointLoaderSimple')
}
})
it('passes empty string when nodeData is undefined', () => {
const wrapper = mountComponent(undefined)
// No widgets should be rendered
const widgetComponents = wrapper.findAll('.lg-node-widget')
expect(widgetComponents).toHaveLength(0)
})
it('passes empty string when nodeData.type is undefined', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('', [widget])
const wrapper = mountComponent(nodeData)
const widgetComponent = wrapper.find('.lg-node-widget')
if (widgetComponent.exists()) {
const component = widgetComponent.findComponent({
name: 'WidgetSelect'
})
if (component.exists()) {
expect(component.props('nodeType')).toBe('')
}
}
})
it.for(['CheckpointLoaderSimple', 'LoraLoader', 'VAELoader', 'KSampler'])(
'passes correct node type: %s',
(nodeType) => {
const widget = createMockWidget()
const nodeData = createMockNodeData(nodeType, [widget])
const wrapper = mountComponent(nodeData)
const widgetComponent = wrapper.find('.lg-node-widget')
expect(widgetComponent.exists()).toBe(true)
const component = widgetComponent.findComponent({
name: 'WidgetSelect'
})
if (component.exists()) {
expect(component.props('nodeType')).toBe(nodeType)
}
}
)
})
})

View File

@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { effectScope, nextTick, reactive } from 'vue'
import type { EffectScope } from 'vue'
import { nextTick, reactive } from 'vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
@@ -39,14 +38,6 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStore
}))
// Mock the workspace store
const workspaceStore = reactive({
shiftDown: false
})
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => workspaceStore
}))
describe('useBrowserTabTitle', () => {
beforeEach(() => {
// reset execution store
@@ -60,17 +51,14 @@ describe('useBrowserTabTitle', () => {
// reset setting and workflow stores
;(settingStore.get as any).mockReturnValue('Enabled')
workflowStore.activeWorkflow = null
workspaceStore.shiftDown = false
// reset document title
document.title = ''
})
it('sets default title when idle and no workflow', () => {
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
useBrowserTabTitle()
expect(document.title).toBe('ComfyUI')
scope.stop()
})
it('sets workflow name as title when workflow exists and menu enabled', async () => {
@@ -80,11 +68,9 @@ describe('useBrowserTabTitle', () => {
isModified: false,
isPersisted: true
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
scope.stop()
})
it('adds asterisk for unsaved workflow', async () => {
@@ -94,44 +80,9 @@ describe('useBrowserTabTitle', () => {
isModified: true,
isPersisted: true
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('*myFlow - ComfyUI')
scope.stop()
})
it('hides asterisk when autosave is enabled', async () => {
;(settingStore.get as any).mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.AutoSave') return 'after delay'
if (key === 'Comfy.UseNewMenu') return 'Enabled'
return 'Enabled'
})
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: true,
isPersisted: true
}
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
})
it('hides asterisk while Shift key is held', async () => {
;(settingStore.get as any).mockImplementation((key: string) => {
if (key === 'Comfy.Workflow.AutoSave') return 'off'
if (key === 'Comfy.UseNewMenu') return 'Enabled'
return 'Enabled'
})
workspaceStore.shiftDown = true
workflowStore.activeWorkflow = {
filename: 'myFlow',
isModified: true,
isPersisted: true
}
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('myFlow - ComfyUI')
})
// Fails when run together with other tests. Suspect to be caused by leaked
@@ -143,21 +94,17 @@ describe('useBrowserTabTitle', () => {
isModified: false,
isPersisted: true
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('ComfyUI')
scope.stop()
})
it('shows execution progress when not idle without workflow', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.3
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('[30%]ComfyUI')
scope.stop()
})
it('shows node execution title when executing a node using nodeProgressStates', async () => {
@@ -175,11 +122,9 @@ describe('useBrowserTabTitle', () => {
}
}
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('[40%][50%] Foo')
scope.stop()
})
it('shows multiple nodes running when multiple nodes are executing', async () => {
@@ -195,10 +140,8 @@ describe('useBrowserTabTitle', () => {
},
'2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' }
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('[40%][2 nodes running]')
scope.stop()
})
})

View File

@@ -1,42 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
modelAssetsByNodeType: new Map(),
modelLoadingByNodeType: new Map(),
modelErrorByNodeType: new Map(),
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType
})
}))
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
it('returns empty/default values without calling stores', () => {
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, dropdownItems, isLoading, error } =
useAssetWidgetData(nodeType)
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(dropdownItems.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(mockGetCategoryForNodeType).not.toHaveBeenCalled()
})
})

View File

@@ -1,245 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
const mockModelAssetsByNodeType = new Map<string, AssetItem[]>()
const mockModelLoadingByNodeType = new Map<string, boolean>()
const mockModelErrorByNodeType = new Map<string, Error | null>()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
modelAssetsByNodeType: mockModelAssetsByNodeType,
modelLoadingByNodeType: mockModelLoadingByNodeType,
modelErrorByNodeType: mockModelErrorByNodeType,
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType
})
}))
describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelAssetsByNodeType.clear()
mockModelLoadingByNodeType.clear()
mockModelErrorByNodeType.clear()
mockGetCategoryForNodeType.mockReturnValue(undefined)
mockUpdateModelsForNodeType.mockImplementation(
async (): Promise<AssetItem[]> => {
return []
}
)
})
const createMockAsset = (
id: string,
name: string,
filename: string,
previewUrl?: string
): AssetItem => ({
id,
name,
size: 1024,
tags: ['models', 'checkpoints'],
created_at: '2025-01-01T00:00:00Z',
preview_url: previewUrl,
user_metadata: {
filename
}
})
it('fetches assets and transforms to dropdown items', async () => {
const mockAssets: AssetItem[] = [
createMockAsset(
'asset-1',
'Beautiful Model',
'models/beautiful_model.safetensors',
'/api/preview/asset-1'
),
createMockAsset('asset-2', 'Model B', 'model_b.safetensors', '/preview/2')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, dropdownItems, isLoading } =
useAssetWidgetData(nodeType)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
expect(dropdownItems.value).toHaveLength(2)
const item = dropdownItems.value[0]
expect(item.id).toBe('asset-1')
expect(item.name).toBe('models/beautiful_model.safetensors')
expect(item.label).toBe('Beautiful Model')
expect(item.mediaSrc).toBe('/api/preview/asset-1')
})
it('handles API errors gracefully', async () => {
const mockError = new Error('Network error')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelErrorByNodeType.set(_nodeType, mockError)
mockModelAssetsByNodeType.set(_nodeType, [])
mockModelLoadingByNodeType.set(_nodeType, false)
return []
}
)
const nodeType = ref('CheckpointLoaderSimple')
const { assets, error, isLoading } = useAssetWidgetData(nodeType)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(error.value).toBe(mockError)
expect(assets.value).toEqual([])
})
it('returns empty for unknown node type', async () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, [])
mockModelLoadingByNodeType.set(_nodeType, false)
return []
}
)
const nodeType = ref('UnknownNodeType')
const { category, assets } = useAssetWidgetData(nodeType)
await nextTick()
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
})
describe('MaybeRefOrGetter parameter support', () => {
it('accepts plain string value', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const { category, assets, isLoading } = useAssetWidgetData(
'CheckpointLoaderSimple'
)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
})
it('accepts getter function', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('loras')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const nodeType = ref('LoraLoader')
const { category, assets, isLoading } = useAssetWidgetData(
() => nodeType.value
)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith('LoraLoader')
expect(category.value).toBe('loras')
expect(assets.value).toEqual(mockAssets)
})
it('accepts ref (backward compatibility)', async () => {
const mockAssets: AssetItem[] = [
createMockAsset('asset-1', 'Model A', 'model_a.safetensors')
]
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
return mockAssets
}
)
const nodeTypeRef = ref('CheckpointLoaderSimple')
const { category, assets, isLoading } = useAssetWidgetData(nodeTypeRef)
await nextTick()
await vi.waitFor(() => !isLoading.value)
expect(mockUpdateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(category.value).toBe('checkpoints')
expect(assets.value).toEqual(mockAssets)
})
it('handles undefined node type gracefully', async () => {
const { category, assets, dropdownItems, isLoading, error } =
useAssetWidgetData(undefined)
await nextTick()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(dropdownItems.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})
})
})

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import { sortWidgetValuesByInputOrder } from '@/workbench/utils/nodeDefOrderingUtil'
import { sortWidgetValuesByInputOrder } from '@/utils/nodeDefOrderingUtil'
describe('LGraphNode widget ordering', () => {
let node: LGraphNode

View File

@@ -283,7 +283,7 @@ describe('useSubscription', () => {
handleViewUsageHistory()
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://stagingplatform.comfy.org/profile/usage',
'https://platform.comfy.org/profile/usage',
'_blank'
)

View File

@@ -1,89 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { flushPromises, mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
// Mock modules
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetBrowserEligible: vi.fn(() => true)
}
}))
const mockSettingStoreGet = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: mockSettingStoreGet
}))
}))
// Import after mocks are defined
import { assetService } from '@/platform/assets/services/assetService'
const mockAssetServiceEligible = vi.mocked(assetService.isAssetBrowserEligible)
describe('WidgetSelect asset mode', () => {
const createWidget = (): SimplifiedWidget<string | number | undefined> => ({
name: 'ckpt_name',
type: 'combo',
value: undefined,
options: {
values: []
}
})
beforeEach(() => {
vi.clearAllMocks()
mockAssetServiceEligible.mockReturnValue(true)
mockSettingStoreGet.mockReturnValue(true) // Default to true for UseAssetAPI
})
// Helper to mount with common setup
const mountWidget = () => {
return mount(WidgetSelect, {
props: {
widget: createWidget(),
modelValue: undefined,
nodeType: 'CheckpointLoaderSimple'
},
global: {
plugins: [PrimeVue, createTestingPinia()]
}
})
}
it('uses dropdown when isCloud && UseAssetAPI && isEligible', async () => {
const wrapper = mountWidget()
await flushPromises()
expect(
wrapper.findComponent({ name: 'WidgetSelectDropdown' }).exists()
).toBe(true)
})
it('uses default widget when UseAssetAPI setting is false', () => {
mockSettingStoreGet.mockReturnValue(false)
const wrapper = mountWidget()
expect(
wrapper.findComponent({ name: 'WidgetSelectDefault' }).exists()
).toBe(true)
})
it('uses default widget when node is not eligible', () => {
mockAssetServiceEligible.mockReturnValue(false)
const wrapper = mountWidget()
expect(
wrapper.findComponent({ name: 'WidgetSelectDefault' }).exists()
).toBe(true)
})
})

View File

@@ -1,174 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import type { ComponentPublicInstance } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { DropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
interface WidgetSelectDropdownInstance extends ComponentPublicInstance {
inputItems: DropdownItem[]
outputItems: DropdownItem[]
updateSelectedItems: (selectedSet: Set<string>) => void
}
describe('WidgetSelectDropdown custom label mapping', () => {
const createMockWidget = (
value: string = 'img_001.png',
options: {
values?: string[]
getOptionLabel?: (value: string | null) => string
} = {},
spec?: ComboInputSpec
): SimplifiedWidget<string | number | undefined> => ({
name: 'test_image_select',
type: 'combo',
value,
options: {
values: ['img_001.png', 'photo_abc.jpg', 'hash789.png'],
...options
},
spec
})
const mountComponent = (
widget: SimplifiedWidget<string | number | undefined>,
modelValue: string | number | undefined,
assetKind: 'image' | 'video' | 'audio' = 'image'
): VueWrapper<WidgetSelectDropdownInstance> => {
return mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind,
allowUpload: true,
uploadFolder: 'input'
},
global: {
plugins: [PrimeVue, createTestingPinia()]
}
}) as unknown as VueWrapper<WidgetSelectDropdownInstance>
}
describe('when custom labels are not provided', () => {
it('uses values as labels when no mapping provided', () => {
const widget = createMockWidget('img_001.png')
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(3)
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('hash789.png')
})
})
describe('when custom labels are provided via getOptionLabel', () => {
it('displays custom labels while preserving original values', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (!value) return 'No file'
const mapping: Record<string, string> = {
'img_001.png': 'Vacation Photo',
'photo_abc.jpg': 'Family Portrait',
'hash789.png': 'Sunset Beach'
}
return mapping[value] || value
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems).toHaveLength(3)
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Vacation Photo')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('Family Portrait')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Sunset Beach')
expect(getOptionLabel).toHaveBeenCalledWith('img_001.png')
expect(getOptionLabel).toHaveBeenCalledWith('photo_abc.jpg')
expect(getOptionLabel).toHaveBeenCalledWith('hash789.png')
})
it('emits original values when items with custom labels are selected', async () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (!value) return 'No file'
return `Custom: ${value}`
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
// Simulate selecting an item
const selectedSet = new Set(['input-1']) // index 1 = photo_abc.jpg
wrapper.vm.updateSelectedItems(selectedSet)
// Should emit the original value, not the custom label
expect(wrapper.emitted('update:modelValue')).toBeDefined()
expect(wrapper.emitted('update:modelValue')![0]).toEqual([
'photo_abc.jpg'
])
})
it('falls back to original value when label mapping fails', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (value === 'photo_abc.jpg') {
throw new Error('Mapping failed')
}
return `Labeled: ${value}`
})
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const inputItems = wrapper.vm.inputItems
expect(inputItems[0].name).toBe('img_001.png')
expect(inputItems[0].label).toBe('Labeled: img_001.png')
expect(inputItems[1].name).toBe('photo_abc.jpg')
expect(inputItems[1].label).toBe('photo_abc.jpg')
expect(inputItems[2].name).toBe('hash789.png')
expect(inputItems[2].label).toBe('Labeled: hash789.png')
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
})
})
describe('output items with custom label mapping', () => {
it('applies custom label mapping to output items from queue history', () => {
const getOptionLabel = vi.fn((value: string | null) => {
if (!value) return 'No file'
return `Output: ${value}`
})
const widget = createMockWidget('img_001.png', {
getOptionLabel
})
const wrapper = mountComponent(widget, 'img_001.png')
const outputItems = wrapper.vm.outputItems
expect(outputItems).toBeDefined()
expect(Array.isArray(outputItems)).toBe(true)
})
})
})

View File

@@ -1,23 +1,8 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
// Mock isCloud to be true for these tests
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
// Mock assetService
const mockGetAssetsForNodeType = vi.hoisted(() => vi.fn())
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetsForNodeType: mockGetAssetsForNodeType
}
}))
const HASH_FILENAME =
'72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
@@ -39,7 +24,6 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
describe('assetsStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('input asset mapping helpers', () => {
@@ -170,56 +154,4 @@ describe('assetsStore', () => {
expect(store.inputAssetsByFilename.size).toBe(0)
})
})
describe('model assets caching', () => {
beforeEach(() => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
})
it('should cache assets by node type', async () => {
const store = useAssetsStore()
const mockAssets: AssetItem[] = [
createMockAssetItem({ id: '1', name: 'model_a.safetensors' }),
createMockAssetItem({ id: '2', name: 'model_b.safetensors' })
]
mockGetAssetsForNodeType.mockResolvedValue(mockAssets)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(mockGetAssetsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(store.modelAssetsByNodeType.get('CheckpointLoaderSimple')).toEqual(
mockAssets
)
})
it('should track loading state', async () => {
const store = useAssetsStore()
mockGetAssetsForNodeType.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([]), 100))
)
const promise = store.updateModelsForNodeType('LoraLoader')
expect(store.modelLoadingByNodeType.get('LoraLoader')).toBe(true)
await promise
expect(store.modelLoadingByNodeType.get('LoraLoader')).toBe(false)
})
it('should handle errors gracefully', async () => {
const store = useAssetsStore()
const mockError = new Error('Network error')
mockGetAssetsForNodeType.mockRejectedValue(mockError)
await store.updateModelsForNodeType('VAELoader')
expect(store.modelErrorByNodeType.get('VAELoader')).toBe(mockError)
expect(store.modelAssetsByNodeType.get('VAELoader')).toEqual([])
expect(store.modelLoadingByNodeType.get('VAELoader')).toBe(false)
})
})
})

View File

@@ -83,7 +83,6 @@ vi.mock('@/services/dialogService')
describe('useFirebaseAuthStore', () => {
let store: ReturnType<typeof useFirebaseAuthStore>
let authStateCallback: (user: any) => void
let idTokenCallback: (user: any) => void
const mockAuth = {
/* mock Auth object */
@@ -144,55 +143,6 @@ describe('useFirebaseAuthStore', () => {
mockUser.getIdToken.mockResolvedValue('mock-id-token')
})
describe('token refresh events', () => {
beforeEach(async () => {
vi.resetModules()
vi.doMock('@/platform/distribution/types', () => ({
isCloud: true,
isDesktop: true
}))
vi.mocked(firebaseAuth.onIdTokenChanged).mockImplementation(
(_auth, callback) => {
idTokenCallback = callback as (user: any) => void
return vi.fn()
}
)
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(mockAuth as any)
setActivePinia(createPinia())
const storeModule = await import('@/stores/firebaseAuthStore')
store = storeModule.useFirebaseAuthStore()
})
it("should not increment tokenRefreshTrigger on the user's first ID token event", () => {
idTokenCallback?.(mockUser)
expect(store.tokenRefreshTrigger).toBe(0)
})
it('should increment tokenRefreshTrigger on subsequent ID token events for the same user', () => {
idTokenCallback?.(mockUser)
idTokenCallback?.(mockUser)
expect(store.tokenRefreshTrigger).toBe(1)
})
it('should not increment when ID token event is for a different user UID', () => {
const otherUser = { uid: 'other-user-id' }
idTokenCallback?.(mockUser)
idTokenCallback?.(otherUser)
expect(store.tokenRefreshTrigger).toBe(0)
})
it('should increment after switching to a new UID and receiving a second event for that UID', () => {
const otherUser = { uid: 'other-user-id' }
idTokenCallback?.(mockUser)
idTokenCallback?.(otherUser)
idTokenCallback?.(otherUser)
expect(store.tokenRefreshTrigger).toBe(1)
})
})
it('should initialize with the current user', () => {
expect(store.currentUser).toEqual(mockUser)
expect(store.isAuthenticated).toBe(true)

View File

@@ -5,7 +5,7 @@ import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import {
getOrderedInputSpecs,
sortWidgetValuesByInputOrder
} from '@/workbench/utils/nodeDefOrderingUtil'
} from '@/utils/nodeDefOrderingUtil'
describe('nodeDefOrderingUtil', () => {
describe('getOrderedInputSpecs', () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
import { getSelectedModelsMetadata } from '@/utils/modelMetadataUtil'
describe('modelMetadataUtil', () => {
describe('filterModelsByCurrentSelection', () => {

View File

@@ -6,27 +6,8 @@ declare global {
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
severity?: 'info' | 'warning' | 'error'
badge?: string
}
server_health_alert?: string
}
}
}
@@ -43,17 +24,7 @@ globalThis.__DISTRIBUTION__ = 'localhost'
// Define runtime config for tests
window.__CONFIG__ = {
subscription_required: true,
mixpanel_token: 'test-token',
comfy_api_base_url: 'https://stagingapi.comfy.org',
comfy_platform_base_url: 'https://stagingplatform.comfy.org',
firebase_config: {
apiKey: 'test',
authDomain: 'test.firebaseapp.com',
projectId: 'test',
storageBucket: 'test.appspot.com',
messagingSenderId: '123',
appId: '123'
}
mixpanel_token: 'test-token'
}
// Mock Worker for extendable-media-recorder