mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-24 14:27:32 +00:00
Compare commits
18 Commits
feat/cloud
...
test-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b3bc77926 | ||
|
|
c94cedf8ee | ||
|
|
ba355b543d | ||
|
|
e9b641cfb7 | ||
|
|
56153596d9 | ||
|
|
b679bfe8f8 | ||
|
|
54979701d0 | ||
|
|
64e704c2f9 | ||
|
|
b1050e3195 | ||
|
|
ba100c4a04 | ||
|
|
cafd2de961 | ||
|
|
1f3fb90b1b | ||
|
|
27afd01297 | ||
|
|
535f857330 | ||
|
|
adb15aac40 | ||
|
|
8752f1b06d | ||
|
|
90c2c0fae0 | ||
|
|
34155bccb1 |
116
.github/actions/comment-release-links/action.yaml
vendored
Normal file
116
.github/actions/comment-release-links/action.yaml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
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
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
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'
|
||||
|
||||
@@ -57,3 +58,26 @@ jobs:
|
||||
secrets:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
comment_desktop_publish:
|
||||
name: Comment Desktop Publish Summary
|
||||
needs:
|
||||
- resolve
|
||||
- publish
|
||||
if: success()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout merge commit
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Post desktop release summary comment
|
||||
uses: ./.github/actions/comment-release-links
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
version_file: apps/desktop-ui/package.json
|
||||
|
||||
51
.github/workflows/release-draft-create.yaml
vendored
51
.github/workflows/release-draft-create.yaml
vendored
@@ -1,9 +1,10 @@
|
||||
---
|
||||
name: Release Draft Create
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ closed ]
|
||||
branches: [ main, core/* ]
|
||||
types: ['closed']
|
||||
branches: [main, core/*]
|
||||
paths:
|
||||
- 'package.json'
|
||||
|
||||
@@ -30,7 +31,9 @@ jobs:
|
||||
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
- name: Check if prerelease
|
||||
id: check_prerelease
|
||||
run: |
|
||||
@@ -71,7 +74,8 @@ 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:
|
||||
@@ -79,9 +83,14 @@ 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:
|
||||
@@ -110,7 +119,8 @@ 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
|
||||
@@ -122,3 +132,28 @@ 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
|
||||
|
||||
@@ -74,8 +74,15 @@ 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 (
|
||||
|
||||
@@ -75,8 +75,15 @@ 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 (
|
||||
|
||||
206
apps/desktop-ui/src/components/common/LanguageSelector.vue
Normal file
206
apps/desktop-ui/src/components/common/LanguageSelector.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<Select
|
||||
:id="dropdownId"
|
||||
v-model="selectedLocale"
|
||||
:options="localeOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:disabled="isSwitching"
|
||||
:pt="dropdownPt"
|
||||
:size="props.size"
|
||||
class="language-selector"
|
||||
@change="onLocaleChange"
|
||||
>
|
||||
<template #value="{ value }">
|
||||
<span :class="valueClass">
|
||||
<i class="pi pi-language" :class="iconClass" />
|
||||
<span>{{ displayLabel(value as SupportedLocale) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<span :class="optionClass">
|
||||
<i class="pi pi-language" :class="iconClass" />
|
||||
<span class="leading-none">{{ option.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Select from 'primevue/select'
|
||||
import type { SelectChangeEvent } from 'primevue/select'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { i18n, loadLocale, st } from '@/i18n'
|
||||
|
||||
type VariantKey = 'dark' | 'light'
|
||||
type SizeKey = 'small' | 'large'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: VariantKey
|
||||
size?: SizeKey
|
||||
}>(),
|
||||
{
|
||||
variant: 'dark',
|
||||
size: 'small'
|
||||
}
|
||||
)
|
||||
|
||||
const dropdownId = `language-select-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
const LOCALES = [
|
||||
['en', 'English'],
|
||||
['zh', '中文'],
|
||||
['zh-TW', '繁體中文'],
|
||||
['ru', 'Русский'],
|
||||
['ja', '日本語'],
|
||||
['ko', '한국어'],
|
||||
['fr', 'Français'],
|
||||
['es', 'Español'],
|
||||
['ar', 'عربي'],
|
||||
['tr', 'Türkçe']
|
||||
] as const satisfies ReadonlyArray<[string, string]>
|
||||
|
||||
type SupportedLocale = (typeof LOCALES)[number][0]
|
||||
|
||||
const SIZE_PRESETS = {
|
||||
large: {
|
||||
wrapper: 'px-3 py-1 min-w-[7rem]',
|
||||
gap: 'gap-2',
|
||||
valueText: 'text-xs',
|
||||
optionText: 'text-sm',
|
||||
icon: 'text-sm'
|
||||
},
|
||||
small: {
|
||||
wrapper: 'px-2 py-0.5 min-w-[5rem]',
|
||||
gap: 'gap-1',
|
||||
valueText: 'text-[0.65rem]',
|
||||
optionText: 'text-xs',
|
||||
icon: 'text-xs'
|
||||
}
|
||||
} as const satisfies Record<SizeKey, Record<string, string>>
|
||||
|
||||
const VARIANT_PRESETS = {
|
||||
light: {
|
||||
root: 'bg-white/80 border border-neutral-200 text-neutral-700 rounded-full shadow-sm backdrop-blur hover:border-neutral-400 transition-colors focus-visible:ring-offset-2 focus-visible:ring-offset-white',
|
||||
trigger: 'text-neutral-500 hover:text-neutral-700',
|
||||
item: 'text-neutral-700 bg-transparent hover:bg-neutral-100 focus-visible:outline-none',
|
||||
valueText: 'text-neutral-600',
|
||||
optionText: 'text-neutral-600',
|
||||
icon: 'text-neutral-500'
|
||||
},
|
||||
dark: {
|
||||
root: 'bg-neutral-900/70 border border-neutral-700 text-neutral-200 rounded-full shadow-sm backdrop-blur hover:border-neutral-500 transition-colors focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900',
|
||||
trigger: 'text-neutral-400 hover:text-neutral-200',
|
||||
item: 'text-neutral-200 bg-transparent hover:bg-neutral-800/80 focus-visible:outline-none',
|
||||
valueText: 'text-neutral-100',
|
||||
optionText: 'text-neutral-100',
|
||||
icon: 'text-neutral-300'
|
||||
}
|
||||
} as const satisfies Record<VariantKey, Record<string, string>>
|
||||
|
||||
const selectedLocale = ref<string>(i18n.global.locale.value)
|
||||
const isSwitching = ref(false)
|
||||
|
||||
const sizePreset = computed(() => SIZE_PRESETS[props.size as SizeKey])
|
||||
const variantPreset = computed(
|
||||
() => VARIANT_PRESETS[props.variant as VariantKey]
|
||||
)
|
||||
|
||||
const dropdownPt = computed(() => ({
|
||||
root: {
|
||||
class: `${variantPreset.value.root} ${sizePreset.value.wrapper}`
|
||||
},
|
||||
trigger: {
|
||||
class: variantPreset.value.trigger
|
||||
},
|
||||
item: {
|
||||
class: `${variantPreset.value.item} ${sizePreset.value.optionText}`
|
||||
}
|
||||
}))
|
||||
|
||||
const valueClass = computed(() =>
|
||||
[
|
||||
'flex items-center font-medium uppercase tracking-wide leading-tight',
|
||||
sizePreset.value.gap,
|
||||
sizePreset.value.valueText,
|
||||
variantPreset.value.valueText
|
||||
].join(' ')
|
||||
)
|
||||
|
||||
const optionClass = computed(() =>
|
||||
[
|
||||
'flex items-center leading-tight',
|
||||
sizePreset.value.gap,
|
||||
variantPreset.value.optionText,
|
||||
sizePreset.value.optionText
|
||||
].join(' ')
|
||||
)
|
||||
|
||||
const iconClass = computed(() =>
|
||||
[sizePreset.value.icon, variantPreset.value.icon].join(' ')
|
||||
)
|
||||
|
||||
const localeOptions = computed(() =>
|
||||
LOCALES.map(([value, fallback]) => ({
|
||||
value,
|
||||
label: st(`settings.Comfy_Locale.options.${value}`, fallback)
|
||||
}))
|
||||
)
|
||||
|
||||
const labelLookup = computed(() =>
|
||||
localeOptions.value.reduce<Record<string, string>>((acc, option) => {
|
||||
acc[option.value] = option.label
|
||||
return acc
|
||||
}, {})
|
||||
)
|
||||
|
||||
function displayLabel(locale?: SupportedLocale) {
|
||||
if (!locale) {
|
||||
return st('settings.Comfy_Locale.name', 'Language')
|
||||
}
|
||||
|
||||
return labelLookup.value[locale] ?? locale
|
||||
}
|
||||
|
||||
watch(
|
||||
() => i18n.global.locale.value,
|
||||
(newLocale) => {
|
||||
if (newLocale !== selectedLocale.value) {
|
||||
selectedLocale.value = newLocale
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function onLocaleChange(event: SelectChangeEvent) {
|
||||
const nextLocale = event.value as SupportedLocale | undefined
|
||||
|
||||
if (!nextLocale || nextLocale === i18n.global.locale.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isSwitching.value = true
|
||||
try {
|
||||
await loadLocale(nextLocale)
|
||||
i18n.global.locale.value = nextLocale
|
||||
} catch (error) {
|
||||
console.error(`Failed to change locale to "${nextLocale}"`, error)
|
||||
selectedLocale.value = i18n.global.locale.value
|
||||
} finally {
|
||||
isSwitching.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.p-dropdown-panel .p-dropdown-item) {
|
||||
@apply transition-colors;
|
||||
}
|
||||
|
||||
:deep(.p-dropdown) {
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-yellow/60 focus-visible:ring-offset-2;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<BaseViewTemplate dark hide-language-selector>
|
||||
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
|
||||
<div
|
||||
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark>
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<div class="grid grid-rows-2 gap-8">
|
||||
<div class="grid gap-8">
|
||||
<!-- Top container: Logo -->
|
||||
<div class="flex items-end justify-center">
|
||||
<img
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<div
|
||||
class="font-sans w-screen h-screen flex flex-col"
|
||||
class="font-sans w-screen h-screen flex flex-col relative"
|
||||
: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()"
|
||||
@@ -20,14 +23,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
import LanguageSelector from '@/components/common/LanguageSelector.vue'
|
||||
|
||||
import { electronAPI, isElectron, isNativeWindow } from '../../utils/envUtil'
|
||||
|
||||
const { dark = false } = defineProps<{
|
||||
const { dark = false, hideLanguageSelector = 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'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.32.2",
|
||||
"version": "1.32.4",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
26
src/App.vue
26
src/App.vue
@@ -16,6 +16,10 @@ 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'
|
||||
|
||||
@@ -23,6 +27,8 @@ 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
|
||||
@@ -48,6 +54,26 @@ 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()
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<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"
|
||||
@@ -24,6 +25,7 @@ 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'
|
||||
|
||||
@@ -103,7 +103,7 @@ if (isComponentWidget(props.widget)) {
|
||||
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
|
||||
|
||||
const {
|
||||
// configs
|
||||
// config
|
||||
sceneConfig,
|
||||
modelConfig,
|
||||
cameraConfig,
|
||||
|
||||
29
src/components/topbar/ActionBarButtons.vue
Normal file
29
src/components/topbar/ActionBarButtons.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<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>
|
||||
@@ -5,6 +5,7 @@ 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'
|
||||
@@ -13,6 +14,7 @@ export const useBrowserTabTitle = () => {
|
||||
const executionStore = useExecutionStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
const executionText = computed(() =>
|
||||
executionStore.isIdle
|
||||
@@ -24,11 +26,27 @@ 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(() =>
|
||||
workflowStore.activeWorkflow?.isModified ||
|
||||
!workflowStore.activeWorkflow?.isPersisted
|
||||
? ' *'
|
||||
: ''
|
||||
shouldShowUnsavedIndicator.value ? ' *' : ''
|
||||
)
|
||||
const workflowNameText = computed(() => {
|
||||
const workflowName = workflowStore.activeWorkflow?.filename
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
|
||||
@@ -20,7 +21,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 { SUPPORT_URL } from '@/platform/support/config'
|
||||
import { buildSupportUrl } from '@/platform/support/config'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { ExecutionTriggerSource } from '@/platform/telemetry/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
@@ -840,7 +841,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
label: 'Contact Support',
|
||||
versionAdded: '1.17.8',
|
||||
function: () => {
|
||||
window.open(SUPPORT_URL, '_blank')
|
||||
const { userEmail, resolvedUserInfo } = useCurrentUser()
|
||||
const supportUrl = buildSupportUrl({
|
||||
userEmail: userEmail.value,
|
||||
userId: resolvedUserInfo.value?.id
|
||||
})
|
||||
window.open(supportUrl, '_blank')
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
23
src/extensions/core/cloudFeedbackTopbarButton.ts
Normal file
23
src/extensions/core/cloudFeedbackTopbarButton.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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
|
||||
})
|
||||
@@ -29,6 +29,7 @@ if (isCloud) {
|
||||
await import('./cloudRemoteConfig')
|
||||
await import('./cloudBadges')
|
||||
await import('./cloudSessionCookie')
|
||||
await import('./cloudFeedbackTopbarButton')
|
||||
|
||||
if (window.__CONFIG__?.subscription_required) {
|
||||
await import('./cloudSubscription')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { app } from '../../scripts/app'
|
||||
|
||||
const saveNodeTypes = new Set([
|
||||
'SaveImage',
|
||||
'SaveVideo',
|
||||
'SaveAnimatedWEBP',
|
||||
'SaveWEBM',
|
||||
'SaveAudio',
|
||||
|
||||
@@ -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 '@/utils/typeGuardUtil'
|
||||
import { isPrimitiveNode } from '@/renderer/utils/nodeTypeGuards'
|
||||
|
||||
const replacePropertyName = 'Run widget replace on values'
|
||||
export class PrimitiveNode extends LGraphNode {
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"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",
|
||||
@@ -611,8 +613,17 @@
|
||||
"nodes": "Nodes",
|
||||
"models": "Models",
|
||||
"workflows": "Workflows",
|
||||
"templates": "Templates"
|
||||
"templates": "Templates",
|
||||
"console": "Console",
|
||||
"menu": "Menu",
|
||||
"assets": "Assets",
|
||||
"imported": "Imported",
|
||||
"generated": "Generated"
|
||||
},
|
||||
"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",
|
||||
@@ -1772,7 +1783,10 @@
|
||||
"exportSettings": "Export Settings",
|
||||
"modelSettings": "Model Settings"
|
||||
},
|
||||
"openIn3DViewer": "Open in 3D Viewer"
|
||||
"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..."
|
||||
},
|
||||
"toastMessages": {
|
||||
"nothingToQueue": "Nothing to queue",
|
||||
@@ -2268,7 +2282,9 @@
|
||||
}
|
||||
},
|
||||
"actionbar": {
|
||||
"dockToTop": "Dock to top"
|
||||
"dockToTop": "Dock to top",
|
||||
"feedback": "Feedback",
|
||||
"feedbackTooltip": "Feedback"
|
||||
},
|
||||
"desktopDialogs": {
|
||||
"": {
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
/**
|
||||
* Zendesk ticket form field ID for the distribution tag.
|
||||
* This field is used to categorize support requests by their source (cloud vs OSS).
|
||||
* Zendesk ticket form field IDs.
|
||||
*/
|
||||
const DISTRIBUTION_FIELD_ID = 'tf_42243568391700'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Support URLs for the ComfyUI platform.
|
||||
* The URL varies based on whether the application is running in Cloud or OSS distribution.
|
||||
* 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.
|
||||
*
|
||||
* - Cloud: Includes 'ccloud' tag for identifying cloud-based support requests
|
||||
* - OSS: Includes 'oss' tag for identifying open-source support requests
|
||||
* @param params - User information to pre-fill in the support form
|
||||
* @returns Complete Zendesk support URL with query parameters
|
||||
*/
|
||||
const TAG = isCloud ? 'ccloud' : 'oss'
|
||||
export const SUPPORT_URL = `https://support.comfy.org/hc/en-us/requests/new?${DISTRIBUTION_FIELD_ID}=${TAG}`
|
||||
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()}`
|
||||
}
|
||||
|
||||
@@ -20,9 +20,28 @@ export function useWorkflowPersistence() {
|
||||
const persistCurrentWorkflow = () => {
|
||||
if (!workflowPersistenceEnabled.value) return
|
||||
const workflow = JSON.stringify(comfyApp.graph.serialize())
|
||||
localStorage.setItem('workflow', workflow)
|
||||
if (api.clientId) {
|
||||
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,10 +107,14 @@ describe('WidgetSelectButton Button Selection', () => {
|
||||
const selectedButton = buttons[1] // 'banana'
|
||||
const unselectedButton = buttons[0] // 'apple'
|
||||
|
||||
expect(selectedButton.classes()).toContain('bg-white')
|
||||
expect(selectedButton.classes()).toContain('text-neutral-900')
|
||||
expect(unselectedButton.classes()).not.toContain('bg-white')
|
||||
expect(unselectedButton.classes()).not.toContain('text-neutral-900')
|
||||
expect(selectedButton.classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(selectedButton.classes()).toContain('text-primary')
|
||||
expect(unselectedButton.classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(unselectedButton.classes()).toContain('text-secondary')
|
||||
})
|
||||
|
||||
it('handles no selection gracefully', () => {
|
||||
@@ -120,8 +124,10 @@ describe('WidgetSelectButton Button Selection', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.classes()).not.toContain('bg-white')
|
||||
expect(button.classes()).not.toContain('text-neutral-900')
|
||||
expect(button.classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(button.classes()).toContain('text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,13 +140,19 @@ describe('WidgetSelectButton Button Selection', () => {
|
||||
|
||||
// Initially 'first' is selected
|
||||
let buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
|
||||
// Update to 'second'
|
||||
await wrapper.setProps({ modelValue: 'second' })
|
||||
buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).not.toContain('bg-white')
|
||||
expect(buttons[1].classes()).toContain('bg-white')
|
||||
expect(buttons[0].classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(buttons[1].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -222,7 +234,9 @@ describe('WidgetSelectButton Button Selection', () => {
|
||||
expect(buttons[2].text()).toBe('3')
|
||||
|
||||
// The selected button should be the one with '2'
|
||||
expect(buttons[1].classes()).toContain('bg-white')
|
||||
expect(buttons[1].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles object options with label and value', () => {
|
||||
@@ -240,7 +254,9 @@ describe('WidgetSelectButton Button Selection', () => {
|
||||
expect(buttons[2].text()).toBe('Third Option')
|
||||
|
||||
// 'second' should be selected
|
||||
expect(buttons[1].classes()).toContain('bg-white')
|
||||
expect(buttons[1].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits correct values for object options', async () => {
|
||||
@@ -277,7 +293,9 @@ describe('WidgetSelectButton Button Selection', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // Empty string is selected
|
||||
})
|
||||
|
||||
it('handles null/undefined in options', () => {
|
||||
@@ -292,7 +310,9 @@ describe('WidgetSelectButton Button Selection', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles very long option text', () => {
|
||||
@@ -313,7 +333,9 @@ describe('WidgetSelectButton Button Selection', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(20)
|
||||
expect(buttons[4].classes()).toContain('bg-white') // option5 is at index 4
|
||||
expect(buttons[4].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // option5 is at index 4
|
||||
})
|
||||
|
||||
it('handles duplicate options', () => {
|
||||
@@ -324,8 +346,12 @@ describe('WidgetSelectButton Button Selection', () => {
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
// Both 'duplicate' buttons should be highlighted (due to value matching)
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
expect(buttons[2].classes()).toContain('bg-white')
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(buttons[2].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -354,7 +380,9 @@ describe('WidgetSelectButton Button Selection', () => {
|
||||
const buttons = wrapper.findAll('button')
|
||||
const unselectedButton = buttons[1] // 'option2'
|
||||
|
||||
expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50')
|
||||
expect(unselectedButton.classes()).toContain(
|
||||
'hover:bg-interface-menu-component-surface-hovered'
|
||||
)
|
||||
expect(unselectedButton.classes()).toContain('cursor-pointer')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,17 +24,14 @@
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="Play/Pause"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
|
||||
@click="togglePlayPause"
|
||||
>
|
||||
<i
|
||||
v-if="!isPlaying"
|
||||
class="icon-[lucide--play] size-4 text-smoke-600 dark-theme:text-smoke-800"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="icon-[lucide--pause] size-4 text-smoke-600 dark-theme:text-smoke-800"
|
||||
class="text-secondary icon-[lucide--play] size-4"
|
||||
/>
|
||||
<i v-else class="text-secondary icon-[lucide--pause] size-4" />
|
||||
</div>
|
||||
|
||||
<!-- Time Display -->
|
||||
@@ -44,11 +41,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div
|
||||
class="relative h-0.5 flex-1 rounded-full bg-smoke-300 dark-theme:bg-ash-800"
|
||||
>
|
||||
<div class="relative h-0.5 flex-1 rounded-full bg-interface-stroke">
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full rounded-full bg-smoke-600 transition-all dark-theme:bg-white/50"
|
||||
class="absolute top-0 left-0 h-full rounded-full bg-button-icon transition-all"
|
||||
:style="{ width: `${progressPercentage}%` }"
|
||||
/>
|
||||
<input
|
||||
@@ -70,21 +65,18 @@
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="Volume"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<i
|
||||
v-if="showVolumeTwo"
|
||||
class="icon-[lucide--volume-2] size-4 text-smoke-600 dark-theme:text-smoke-800"
|
||||
class="text-secondary icon-[lucide--volume-2] size-4"
|
||||
/>
|
||||
<i
|
||||
v-else-if="showVolumeOne"
|
||||
class="icon-[lucide--volume-1] size-4 text-smoke-600 dark-theme:text-smoke-800"
|
||||
/>
|
||||
<i
|
||||
v-else
|
||||
class="icon-[lucide--volume-x] size-4 text-smoke-600 dark-theme:text-smoke-800"
|
||||
class="text-secondary icon-[lucide--volume-1] size-4"
|
||||
/>
|
||||
<i v-else class="text-secondary icon-[lucide--volume-x] size-4" />
|
||||
</div>
|
||||
|
||||
<!-- Options Button -->
|
||||
@@ -94,12 +86,10 @@
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
aria-label="More Options"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-black/10 dark-theme:hover:bg-white/10"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded hover:bg-interface-menu-component-surface-hovered"
|
||||
@click="toggleOptionsMenu"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--more-vertical] size-4 text-smoke-600 dark-theme:text-smoke-800"
|
||||
/>
|
||||
<i class="text-secondary icon-[lucide--more-vertical] size-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -124,10 +124,16 @@ describe('FormSelectButton Core Component', () => {
|
||||
const wrapper = mountComponent('option2', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[1].classes()).toContain('bg-white')
|
||||
expect(buttons[1].classes()).toContain('text-neutral-900')
|
||||
expect(buttons[0].classes()).not.toContain('bg-white')
|
||||
expect(buttons[2].classes()).not.toContain('bg-white')
|
||||
expect(buttons[1].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(buttons[1].classes()).toContain('text-primary')
|
||||
expect(buttons[0].classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(buttons[2].classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -159,8 +165,10 @@ describe('FormSelectButton Core Component', () => {
|
||||
const wrapper = mountComponent('200', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[1].classes()).toContain('bg-white')
|
||||
expect(buttons[1].classes()).toContain('text-neutral-900')
|
||||
expect(buttons[1].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(buttons[1].classes()).toContain('text-primary')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -201,9 +209,15 @@ describe('FormSelectButton Core Component', () => {
|
||||
const wrapper = mountComponent('md', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[1].classes()).toContain('bg-white') // Medium
|
||||
expect(buttons[0].classes()).not.toContain('bg-white')
|
||||
expect(buttons[2].classes()).not.toContain('bg-white')
|
||||
expect(buttons[1].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // Medium
|
||||
expect(buttons[0].classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(buttons[2].classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles objects without value field', () => {
|
||||
@@ -216,7 +230,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('First')
|
||||
expect(buttons[1].text()).toBe('Second')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles objects without label field', () => {
|
||||
@@ -253,8 +269,12 @@ describe('FormSelectButton Core Component', () => {
|
||||
const wrapper = mountComponent('first_id', options, { optionValue: 'id' })
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
expect(buttons[1].classes()).not.toContain('bg-white')
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(buttons[1].classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits custom optionValue when clicked', async () => {
|
||||
@@ -301,7 +321,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.classes()).not.toContain('hover:bg-zinc-200/50')
|
||||
expect(button.classes()).not.toContain(
|
||||
'hover:bg-interface-menu-component-surface-hovered'
|
||||
)
|
||||
expect(button.classes()).not.toContain('cursor-pointer')
|
||||
})
|
||||
})
|
||||
@@ -311,7 +333,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
const wrapper = mountComponent('option1', options, { disabled: true })
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).not.toContain('bg-white') // Selected styling disabled
|
||||
expect(buttons[0].classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // Selected styling disabled
|
||||
expect(buttons[0].classes()).toContain('opacity-50')
|
||||
expect(buttons[0].classes()).toContain('text-secondary')
|
||||
})
|
||||
@@ -324,7 +348,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.classes()).not.toContain('bg-white')
|
||||
expect(button.classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -334,7 +360,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
buttons.forEach((button) => {
|
||||
expect(button.classes()).not.toContain('bg-white')
|
||||
expect(button.classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -343,8 +371,12 @@ describe('FormSelectButton Core Component', () => {
|
||||
const wrapper = mountComponent('', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).toContain('bg-white') // Empty string is selected
|
||||
expect(buttons[1].classes()).not.toContain('bg-white')
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // Empty string is selected
|
||||
expect(buttons[1].classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('compares values as strings', () => {
|
||||
@@ -352,7 +384,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
const wrapper = mountComponent('1', options)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).toContain('bg-white') // '1' matches number 1 as string
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // '1' matches number 1 as string
|
||||
})
|
||||
})
|
||||
|
||||
@@ -362,8 +396,10 @@ describe('FormSelectButton Core Component', () => {
|
||||
const wrapper = mountComponent('option1', options)
|
||||
|
||||
const selectedButton = wrapper.findAll('button')[0]
|
||||
expect(selectedButton.classes()).toContain('bg-white')
|
||||
expect(selectedButton.classes()).toContain('text-neutral-900')
|
||||
expect(selectedButton.classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(selectedButton.classes()).toContain('text-primary')
|
||||
})
|
||||
|
||||
it('applies unselected styling to inactive options', () => {
|
||||
@@ -380,7 +416,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
const wrapper = mountComponent('option1', options, { disabled: false })
|
||||
|
||||
const unselectedButton = wrapper.findAll('button')[1]
|
||||
expect(unselectedButton.classes()).toContain('hover:bg-zinc-200/50')
|
||||
expect(unselectedButton.classes()).toContain(
|
||||
'hover:bg-interface-menu-component-surface-hovered'
|
||||
)
|
||||
expect(unselectedButton.classes()).toContain('cursor-pointer')
|
||||
})
|
||||
})
|
||||
@@ -403,7 +441,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('@#$%^&*()')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles unicode characters in options', () => {
|
||||
@@ -412,7 +452,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].text()).toBe('🎨 Art')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles duplicate option values', () => {
|
||||
@@ -420,9 +462,15 @@ describe('FormSelectButton Core Component', () => {
|
||||
const wrapper = mountComponent('duplicate', duplicateOptions)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons[0].classes()).toContain('bg-white')
|
||||
expect(buttons[2].classes()).toContain('bg-white') // Both duplicates selected
|
||||
expect(buttons[1].classes()).not.toContain('bg-white')
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
expect(buttons[2].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // Both duplicates selected
|
||||
expect(buttons[1].classes()).not.toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles mixed type options safely', () => {
|
||||
@@ -436,7 +484,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[1].classes()).toContain('bg-white') // Number 123 as string
|
||||
expect(buttons[1].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // Number 123 as string
|
||||
})
|
||||
|
||||
it('handles objects with missing properties gracefully', () => {
|
||||
@@ -450,7 +500,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(4)
|
||||
expect(buttons[2].classes()).toContain('bg-white')
|
||||
expect(buttons[2].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
)
|
||||
})
|
||||
|
||||
it('handles large number of options', () => {
|
||||
@@ -462,7 +514,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(50)
|
||||
expect(buttons[24].classes()).toContain('bg-white') // Option 25 at index 24
|
||||
expect(buttons[24].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // Option 25 at index 24
|
||||
})
|
||||
|
||||
it('fallback to index when all object properties are missing', () => {
|
||||
@@ -474,7 +528,9 @@ describe('FormSelectButton Core Component', () => {
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].classes()).toContain('bg-white') // Falls back to index 0
|
||||
expect(buttons[0].classes()).toContain(
|
||||
'bg-interface-menu-component-surface-selected'
|
||||
) // Falls back to index 0
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
'bg-transparent border-none',
|
||||
'text-center text-xs font-normal',
|
||||
{
|
||||
'bg-white': isSelected(option) && !disabled,
|
||||
'hover:bg-zinc-200/50': !isSelected(option) && !disabled,
|
||||
'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
|
||||
},
|
||||
isSelected(option) && !disabled
|
||||
? 'text-neutral-900'
|
||||
: 'text-secondary'
|
||||
isSelected(option) && !disabled ? 'text-primary' : 'text-secondary'
|
||||
)
|
||||
"
|
||||
:disabled="disabled"
|
||||
|
||||
@@ -11,7 +11,7 @@ const filterSelected = defineModel<OptionId>('filterSelected')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4 flex gap-1 px-4 text-zinc-400">
|
||||
<div class="text-secondary mb-4 flex gap-1 px-4">
|
||||
<div
|
||||
v-for="option in filterOptions"
|
||||
:key="option.id"
|
||||
@@ -19,10 +19,10 @@ const filterSelected = defineModel<OptionId>('filterSelected')
|
||||
cn(
|
||||
'px-4 py-2 rounded-md inline-flex justify-center items-center cursor-pointer select-none',
|
||||
'transition-all duration-150',
|
||||
'hover:text-base-foreground hover:bg-zinc-500/10',
|
||||
'hover:text-base-foreground hover:bg-interface-menu-component-surface-hovered',
|
||||
'active:scale-95',
|
||||
filterSelected === option.id
|
||||
? '!bg-zinc-500/20 text-base-foreground'
|
||||
? '!bg-interface-menu-component-surface-selected text-base-foreground'
|
||||
: 'bg-transparent'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -61,9 +61,9 @@ function handleVideoLoad(event: Event) {
|
||||
'transition-all duration-150',
|
||||
{
|
||||
'flex-col text-center': layout === 'grid',
|
||||
'flex-row text-left max-h-16 bg-zinc-500/20 rounded-lg hover:scale-102 active:scale-98':
|
||||
'flex-row text-left max-h-16 bg-interface-menu-component-surface-hovered rounded-lg hover:scale-102 active:scale-98':
|
||||
layout === 'list',
|
||||
'flex-row text-left hover:bg-zinc-500/20 rounded-lg':
|
||||
'flex-row text-left hover:bg-interface-menu-component-surface-hovered rounded-lg':
|
||||
layout === 'list-small',
|
||||
// selection
|
||||
'ring-2 ring-blue-500': layout === 'list' && selected
|
||||
@@ -78,7 +78,7 @@ function handleVideoLoad(event: Event) {
|
||||
:class="
|
||||
cn(
|
||||
'relative',
|
||||
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-zinc-300/10',
|
||||
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-interface-stroke',
|
||||
'transition-all duration-150',
|
||||
{
|
||||
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
|
||||
@@ -144,7 +144,7 @@ function handleVideoLoad(event: Event) {
|
||||
{{ label ?? name }}
|
||||
</span>
|
||||
<!-- Meta Data -->
|
||||
<span class="block text-xs text-slate-400">{{
|
||||
<span class="text-secondary block text-xs">{{
|
||||
metadata || actualDimensions
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
6
src/renderer/utils/nodeTypeGuards.ts
Normal file
6
src/renderer/utils/nodeTypeGuards.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
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'
|
||||
@@ -78,7 +78,7 @@ import {
|
||||
findLegacyRerouteNodes,
|
||||
noNativeReroutes
|
||||
} from '@/utils/migration/migrateReroute'
|
||||
import { getSelectedModelsMetadata } from '@/utils/modelMetadataUtil'
|
||||
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
|
||||
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
|
||||
|
||||
import { type ComfyApi, PromptExecutionError, api } from './api'
|
||||
|
||||
@@ -11,12 +11,17 @@ 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 } = useErrorHandling()
|
||||
const {
|
||||
wrapWithErrorHandling,
|
||||
wrapWithErrorHandlingAsync,
|
||||
toastErrorHandler
|
||||
} = useErrorHandling()
|
||||
|
||||
/**
|
||||
* Loads all extensions from the API into the window in parallel
|
||||
@@ -77,22 +82,55 @@ 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 extension.onAuthUserResolved?.(user, app)
|
||||
void handleUserResolved(user)
|
||||
})
|
||||
}
|
||||
|
||||
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 extension.onAuthTokenRefreshed?.()
|
||||
void handleTokenRefreshed()
|
||||
})
|
||||
}
|
||||
|
||||
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 extension.onAuthUserLogout?.()
|
||||
void handleUserLogout()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
isVideoNode,
|
||||
migrateWidgetsValues
|
||||
} from '@/utils/litegraphUtil'
|
||||
import { getOrderedInputSpecs } from '@/utils/nodeDefOrderingUtil'
|
||||
import { getOrderedInputSpecs } from '@/workbench/utils/nodeDefOrderingUtil'
|
||||
|
||||
import { useExtensionService } from './extensionService'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
|
||||
import { extractCustomNodeName } from '@/utils/nodeHelpUtil'
|
||||
import { extractCustomNodeName } from '@/workbench/utils/nodeHelpUtil'
|
||||
|
||||
class NodeHelpService {
|
||||
async fetchNodeHelp(node: ComfyNodeDefImpl, locale: string): Promise<string> {
|
||||
|
||||
18
src/stores/actionBarButtonStore.ts
Normal file
18
src/stores/actionBarButtonStore.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -64,6 +64,11 @@ 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}`
|
||||
|
||||
@@ -95,6 +100,9 @@ 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
|
||||
@@ -104,6 +112,11 @@ 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++
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 '@/utils/nodeHelpUtil'
|
||||
import { getNodeHelpBaseUrl } from '@/workbench/utils/nodeHelpUtil'
|
||||
|
||||
export const useNodeHelpStore = defineStore('nodeHelp', () => {
|
||||
const currentHelpNode = ref<ComfyNodeDefImpl | null>(null)
|
||||
|
||||
@@ -57,6 +57,32 @@ 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.
|
||||
@@ -102,6 +128,10 @@ 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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { PrimitiveNode } from '@/extensions/core/widgetInputs'
|
||||
import type {
|
||||
INodeSlot,
|
||||
LGraph,
|
||||
@@ -6,12 +5,6 @@ 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.
|
||||
|
||||
@@ -330,7 +330,7 @@ const onGraphReady = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 30-second heartbeat interval
|
||||
// 5-minute 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 })
|
||||
}, 30000)
|
||||
}, 60000 * 5)
|
||||
|
||||
// Send initial heartbeat
|
||||
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { effectScope, nextTick, reactive } from 'vue'
|
||||
import type { EffectScope } from 'vue'
|
||||
|
||||
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
|
||||
|
||||
@@ -38,6 +39,14 @@ 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
|
||||
@@ -51,14 +60,17 @@ 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', () => {
|
||||
useBrowserTabTitle()
|
||||
const scope: EffectScope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
expect(document.title).toBe('ComfyUI')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('sets workflow name as title when workflow exists and menu enabled', async () => {
|
||||
@@ -68,9 +80,11 @@ describe('useBrowserTabTitle', () => {
|
||||
isModified: false,
|
||||
isPersisted: true
|
||||
}
|
||||
useBrowserTabTitle()
|
||||
const scope: EffectScope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('myFlow - ComfyUI')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('adds asterisk for unsaved workflow', async () => {
|
||||
@@ -80,9 +94,44 @@ describe('useBrowserTabTitle', () => {
|
||||
isModified: true,
|
||||
isPersisted: true
|
||||
}
|
||||
useBrowserTabTitle()
|
||||
const scope: EffectScope = effectScope()
|
||||
scope.run(() => 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
|
||||
@@ -94,17 +143,21 @@ describe('useBrowserTabTitle', () => {
|
||||
isModified: false,
|
||||
isPersisted: true
|
||||
}
|
||||
useBrowserTabTitle()
|
||||
const scope: EffectScope = effectScope()
|
||||
scope.run(() => 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
|
||||
useBrowserTabTitle()
|
||||
const scope: EffectScope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[30%]ComfyUI')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('shows node execution title when executing a node using nodeProgressStates', async () => {
|
||||
@@ -122,9 +175,11 @@ describe('useBrowserTabTitle', () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
useBrowserTabTitle()
|
||||
const scope: EffectScope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[40%][50%] Foo')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('shows multiple nodes running when multiple nodes are executing', async () => {
|
||||
@@ -140,8 +195,10 @@ describe('useBrowserTabTitle', () => {
|
||||
},
|
||||
'2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' }
|
||||
}
|
||||
useBrowserTabTitle()
|
||||
const scope: EffectScope = effectScope()
|
||||
scope.run(() => useBrowserTabTitle())
|
||||
await nextTick()
|
||||
expect(document.title).toBe('[40%][2 nodes running]')
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 '@/utils/nodeDefOrderingUtil'
|
||||
import { sortWidgetValuesByInputOrder } from '@/workbench/utils/nodeDefOrderingUtil'
|
||||
|
||||
describe('LGraphNode widget ordering', () => {
|
||||
let node: LGraphNode
|
||||
|
||||
@@ -83,6 +83,7 @@ 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 */
|
||||
@@ -143,6 +144,55 @@ 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)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
getOrderedInputSpecs,
|
||||
sortWidgetValuesByInputOrder
|
||||
} from '@/utils/nodeDefOrderingUtil'
|
||||
} from '@/workbench/utils/nodeDefOrderingUtil'
|
||||
|
||||
describe('nodeDefOrderingUtil', () => {
|
||||
describe('getOrderedInputSpecs', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getSelectedModelsMetadata } from '@/utils/modelMetadataUtil'
|
||||
import { getSelectedModelsMetadata } from '@/workbench/utils/modelMetadataUtil'
|
||||
|
||||
describe('modelMetadataUtil', () => {
|
||||
describe('filterModelsByCurrentSelection', () => {
|
||||
|
||||
Reference in New Issue
Block a user