Compare commits

..

15 Commits

Author SHA1 Message Date
christian-byrne
c1f30b96f9 Add key function param in node tree build 2025-01-14 18:19:15 -07:00
Gremlation
c13190cd07 Fix execution_interrupted (#2244) 2025-01-14 11:46:42 -05:00
filtered
00f031e382 [Refactor] Remove old workarounds (#2242) 2025-01-14 10:52:34 -05:00
filtered
04153caaf5 Fix prettier output in CI does not match IDE (#2243) 2025-01-14 10:51:32 -05:00
Chenlei Hu
210bfdeb7d 1.7.10 (#2241) 2025-01-13 20:25:36 -05:00
bymyself
ce0726d85e Restore all open workflows on load (#2238) 2025-01-13 20:24:40 -05:00
bymyself
dd69f9dc30 [Style] Update workflow template cards style (#2239) 2025-01-13 20:21:03 -05:00
Chenlei Hu
3f261f0e53 [Desktop] Add user journey events to the metrics collection list (UI) (#2237)
Co-authored-by: github-actions <github-actions@github.com>
2025-01-12 17:16:16 -05:00
Chenlei Hu
3b2cc23f65 [Desktop] Mark window style setting as experimental (#2236) 2025-01-12 16:14:51 -05:00
bymyself
c50a86b258 [CI] Fix vite config condition (#2235) 2025-01-12 16:09:46 -05:00
Chenlei Hu
1a8c2bba42 1.7.9 (#2234) 2025-01-12 13:43:05 -05:00
Benjamin Lu
fc09951b3e [Style] Visual improvements to WorkflowTabs (#2232)
Co-authored-by: Benjamin Lu <templu1107@proton.me>
2025-01-12 13:41:55 -05:00
Terry Jia
76d5f39607 [3d] use threejs native viewHelper (#2230) 2025-01-12 13:23:23 -05:00
bymyself
9d3bc0f173 Add optional report feature to error dialog (#2229)
Co-authored-by: github-actions <github-actions@github.com>
2025-01-12 13:23:02 -05:00
bymyself
d9b350e159 Add bookmark option in workflow tab context menu (#2231)
Co-authored-by: github-actions <github-actions@github.com>
2025-01-12 13:22:22 -05:00
59 changed files with 1765 additions and 688 deletions

View File

@@ -25,6 +25,8 @@ jobs:
id: current_version
run: echo ::set-output name=version::$(node -p "require('./package.json').version")
- name: Build project
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: |
npm ci
npm run build

View File

@@ -6,5 +6,13 @@
"printWidth": 80,
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}
"importOrderSortSpecifiers": true,
"overrides": [
{
"files": "*.{js,cjs,mjs,ts,cts,mts,tsx,vue}",
"options": {
"plugins": ["@trivago/prettier-plugin-sort-imports"]
}
}
]
}

View File

@@ -44,6 +44,18 @@ test.describe('Execution error', () => {
const executionError = comfyPage.page.locator('.comfy-error-report')
await expect(executionError).toBeVisible()
})
test('Can display Issue Report form', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('execution_error')
await comfyPage.queueButton.click()
await comfyPage.nextFrame()
await comfyPage.page.getByLabel('Help Fix This').click()
const issueReportForm = comfyPage.page.getByText(
'Submit Error Report (Optional)'
)
await expect(issueReportForm).toBeVisible()
})
})
test.describe('Missing models warning', () => {

View File

@@ -9,6 +9,12 @@ export class Topbar {
.allInnerTexts()
}
async getActiveTabName(): Promise<string> {
return this.page
.locator('.workflow-tabs .p-togglebutton-checked')
.innerText()
}
async openSubmenuMobile() {
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
}

View File

@@ -615,6 +615,67 @@ test.describe('Load workflow', () => {
'single_ksampler_modified.png'
)
})
test.describe('Restore all open workflows on reload', () => {
let workflowA: string
let workflowB: string
const generateUniqueFilename = (extension = '') =>
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}`
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
workflowA = generateUniqueFilename()
await comfyPage.menu.topbar.saveWorkflow(workflowA)
workflowB = generateUniqueFilename()
await comfyPage.menu.topbar.triggerTopbarCommand(['Workflow', 'New'])
await comfyPage.menu.topbar.saveWorkflow(workflowB)
// Wait for localStorage to persist the workflow paths before reloading
await comfyPage.page.waitForFunction(
() => !!window.localStorage.getItem('Comfy.OpenWorkflowsPaths')
)
await comfyPage.setup({ clearStorage: false })
})
test('Restores topbar workflow tabs after reload', async ({
comfyPage
}) => {
await comfyPage.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
const tabs = await comfyPage.menu.topbar.getTabNames()
const activeWorkflowName = await comfyPage.menu.topbar.getActiveTabName()
expect(tabs).toEqual(expect.arrayContaining([workflowA, workflowB]))
expect(tabs.indexOf(workflowA)).toBeLessThan(tabs.indexOf(workflowB))
expect(activeWorkflowName).toEqual(workflowB)
})
test('Restores sidebar workflows after reload', async ({ comfyPage }) => {
await comfyPage.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await comfyPage.menu.workflowsTab.open()
const openWorkflows =
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
const activeWorkflowName =
await comfyPage.menu.workflowsTab.getActiveWorkflowName()
const workflowPathA = `${workflowA}.json`
const workflowPathB = `${workflowB}.json`
expect(openWorkflows).toEqual(
expect.arrayContaining([workflowPathA, workflowPathB])
)
expect(openWorkflows.indexOf(workflowPathA)).toBeLessThan(
openWorkflows.indexOf(workflowPathB)
)
expect(activeWorkflowName).toEqual(workflowPathB)
})
})
})
test.describe('Load duplicate workflow', () => {

2
global.d.ts vendored
View File

@@ -1 +1,3 @@
declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string

95
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.7.8",
"version": "1.7.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.7.8",
"version": "1.7.10",
"license": "GPL-3.0-only",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.7",
"@comfyorg/litegraph": "^0.8.60",
"@primevue/themes": "^4.0.5",
"@sentry/vue": "^8.48.0",
"@tiptap/core": "^2.10.4",
"@tiptap/extension-link": "^2.10.4",
"@tiptap/extension-table": "^2.10.4",
@@ -4462,6 +4463,96 @@
"string-argv": "~0.3.1"
}
},
"node_modules/@sentry-internal/browser-utils": {
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.48.0.tgz",
"integrity": "sha512-pLtu0Fa1Ou0v3M1OEO1MB1EONJVmXEGtoTwFRCO1RPQI2ulmkG6BikINClFG5IBpoYKZ33WkEXuM6U5xh+pdZg==",
"dependencies": {
"@sentry/core": "8.48.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.48.0.tgz",
"integrity": "sha512-6PwcJNHVPg0EfZxmN+XxVOClfQpv7MBAweV8t9i5l7VFr8sM/7wPNSeU/cG7iK19Ug9ZEkBpzMOe3G4GXJ5bpw==",
"dependencies": {
"@sentry/core": "8.48.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.48.0.tgz",
"integrity": "sha512-csILVupc5RkrsTrncuUTGmlB56FQSFjXPYWG8I8yBTGlXEJ+o8oTuF6+55R4vbw3EIzBveXWi4kEBbnQlXW/eg==",
"dependencies": {
"@sentry-internal/browser-utils": "8.48.0",
"@sentry/core": "8.48.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.48.0.tgz",
"integrity": "sha512-LdivLfBXXB9us1aAc6XaL7/L2Ob4vi3C/fEOXElehg3qHjX6q6pewiv5wBvVXGX1NfZTRvu+X11k6TZoxKsezw==",
"dependencies": {
"@sentry-internal/replay": "8.48.0",
"@sentry/core": "8.48.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry/browser": {
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.48.0.tgz",
"integrity": "sha512-fuuVULB5/1vI8NoIwXwR3xwhJJqk+y4RdSdajExGF7nnUDBpwUJyXsmYJnOkBO+oLeEs58xaCpotCKiPUNnE3g==",
"dependencies": {
"@sentry-internal/browser-utils": "8.48.0",
"@sentry-internal/feedback": "8.48.0",
"@sentry-internal/replay": "8.48.0",
"@sentry-internal/replay-canvas": "8.48.0",
"@sentry/core": "8.48.0"
},
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry/core": {
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.48.0.tgz",
"integrity": "sha512-VGwYgTfLpvJ5LRO5A+qWo1gpo6SfqaGXL9TOzVgBucAdpzbrYHpZ87sEarDVq/4275uk1b0S293/mfsskFczyw==",
"engines": {
"node": ">=14.18"
}
},
"node_modules/@sentry/vue": {
"version": "8.48.0",
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-8.48.0.tgz",
"integrity": "sha512-hqm9X7hz1vMQQB1HBYezrDBQihZk6e/MxWIG1wMJoClcBnD1Sh7y+D36UwaQlR4Gr/Ftiz+Bb0DxuAYHoUS4ow==",
"dependencies": {
"@sentry/browser": "8.48.0",
"@sentry/core": "8.48.0"
},
"engines": {
"node": ">=14.18"
},
"peerDependencies": {
"pinia": "2.x",
"vue": "2.x || 3.x"
},
"peerDependenciesMeta": {
"pinia": {
"optional": true
}
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.7.8",
"version": "1.7.10",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -17,8 +17,8 @@
"update-litegraph": "node scripts/update-litegraph.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "vue-tsc --noEmit && tsc --noEmit && tsc-strict",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --plugin @trivago/prettier-plugin-sort-imports",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --plugin @trivago/prettier-plugin-sort-imports",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"test:jest": "jest --config jest.config.ts",
"test:generate": "npx tsx tests-ui/setup",
"test:browser": "npx playwright test",
@@ -86,6 +86,7 @@
"@comfyorg/comfyui-electron-types": "^0.4.7",
"@comfyorg/litegraph": "^0.8.60",
"@primevue/themes": "^4.0.5",
"@sentry/vue": "^8.48.0",
"@tiptap/core": "^2.10.4",
"@tiptap/extension-link": "^2.10.4",
"@tiptap/extension-table": "^2.10.4",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -45,9 +45,6 @@ onMounted(() => {
if (isElectron()) {
document.addEventListener('contextmenu', showContextMenu)
// Enable CSS selectors
document.documentElement.dataset['platform'] = 'electron'
}
})
</script>

View File

@@ -765,6 +765,7 @@ audio.comfy-audio.empty-audio-widget {
padding: var(--comfy-tree-explorer-item-padding) !important;
}
/* [Desktop] Electron window specific styles */
.app-drag {
app-region: drag;
}
@@ -772,3 +773,8 @@ audio.comfy-audio.empty-audio-widget {
.no-drag {
app-region: no-drag;
}
.window-actions-spacer {
width: calc(100vw - env(titlebar-area-width, 100vw));
}
/* End of [Desktop] Electron window specific styles */

View File

@@ -1,18 +1,22 @@
<template>
<Button
<div
v-show="workspaceState.focusMode"
class="comfy-menu-hamburger"
class="comfy-menu-hamburger no-drag"
:style="positionCSS"
icon="pi pi-bars"
severity="secondary"
text
size="large"
v-tooltip="{ value: $t('menu.showMenu'), showDelay: 300 }"
:aria-label="$t('menu.showMenu')"
aria-live="assertive"
@click="exitFocusMode"
@contextmenu="showNativeMenu"
/>
>
<Button
icon="pi pi-bars"
severity="secondary"
text
size="large"
v-tooltip="{ value: $t('menu.showMenu'), showDelay: 300 }"
:aria-label="$t('menu.showMenu')"
aria-live="assertive"
@click="exitFocusMode"
@contextmenu="showNativeMenu"
/>
<div v-show="menuSetting !== 'Bottom'" class="window-actions-spacer" />
</div>
</template>
<script setup lang="ts">
@@ -46,15 +50,13 @@ const positionCSS = computed<CSSProperties>(() =>
// 'Bottom' menuSetting shows the hamburger button in the bottom right corner
// 'Disabled', 'Top' menuSetting shows the hamburger button in the top right corner
menuSetting.value === 'Bottom'
? { bottom: '0', right: '0' }
: { top: '0', right: 'calc(100% - env(titlebar-area-width, 100%))' }
? { bottom: '0px', right: '0px' }
: { top: '0px', right: '0px' }
)
</script>
<style scoped>
.comfy-menu-hamburger {
pointer-events: auto;
position: fixed;
z-index: 9999;
@apply pointer-events-auto fixed z-[9999] flex flex-row;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div :class="['flex flex-wrap', $attrs.class]">
<div
v-for="checkbox in checkboxes"
:key="checkbox.value"
class="flex items-center gap-2"
>
<Checkbox
v-model="internalSelection"
:inputId="checkbox.value"
:value="checkbox.value"
/>
<label :for="checkbox.value" class="ml-2">{{ checkbox.label }}</label>
</div>
</div>
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { computed } from 'vue'
interface CheckboxItem {
label: string
value: string
}
const props = defineProps<{
checkboxes: CheckboxItem[]
modelValue: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const internalSelection = computed({
get: () => props.modelValue,
set: (value: string[]) => emit('update:modelValue', value)
})
</script>

View File

@@ -5,12 +5,20 @@
:message="props.error.exception_message"
/>
<div class="comfy-error-report">
<Button
v-show="!reportOpen"
:label="$t('g.showReport')"
@click="showReport"
text
/>
<div class="flex gap-2 justify-center">
<Button
v-show="!reportOpen"
text
:label="$t('g.showReport')"
@click="showReport"
/>
<Button
v-show="!sendReportOpen"
text
:label="$t('issueReport.helpFix')"
@click="showSendReport"
/>
</div>
<template v-if="reportOpen">
<Divider />
<ScrollPanel style="width: 100%; height: 400px; max-width: 80vw">
@@ -18,9 +26,12 @@
</ScrollPanel>
<Divider />
</template>
<ReportIssuePanel
v-if="sendReportOpen"
error-type="graphExecutionError"
:extra-fields="[stackTraceField]"
/>
<div class="action-container">
<ReportIssueButton v-if="showSendError" :error="props.error" />
<FindIssueButton
:errorMessage="props.error.exception_message"
:repoOwner="repoOwner"
@@ -41,16 +52,18 @@ import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import { useToast } from 'primevue/usetoast'
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
import ReportIssueButton from '@/components/dialog/content/error/ReportIssueButton.vue'
import { useCopyToClipboard } from '@/hooks/clipboardHooks'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { ExecutionErrorWsMessage, SystemStats } from '@/types/apiTypes'
import { isElectron } from '@/utils/envUtil'
import type { ReportField } from '@/types/issueReportTypes'
import ReportIssuePanel from './error/ReportIssuePanel.vue'
const props = defineProps<{
error: ExecutionErrorWsMessage
@@ -63,9 +76,24 @@ const reportOpen = ref(false)
const showReport = () => {
reportOpen.value = true
}
const showSendError = isElectron()
const sendReportOpen = ref(false)
const showSendReport = () => {
sendReportOpen.value = true
}
const toast = useToast()
const { t } = useI18n()
const stackTraceField = computed<ReportField>(() => {
return {
label: t('issueReport.stackTrace'),
value: 'StackTrace',
optIn: true,
data: {
nodeType: props.error.node_type,
stackTrace: props.error.traceback?.join('\n')
}
}
})
onMounted(async () => {
try {

View File

@@ -1,52 +0,0 @@
<template>
<Button
@click="reportIssue"
:label="$t('g.reportIssue')"
:severity="submitted ? 'success' : 'secondary'"
:icon="icon"
:disabled="submitted"
v-tooltip="$t('g.reportIssueTooltip')"
>
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ExecutionErrorWsMessage } from '@/types/apiTypes'
import { electronAPI } from '@/utils/envUtil'
const { error } = defineProps<{
error: ExecutionErrorWsMessage
}>()
const { t } = useI18n()
const toast = useToast()
const submitting = ref(false)
const submitted = ref(false)
const icon = computed(
() => `pi ${submitting.value ? 'pi-spin pi-spinner' : 'pi-send'}`
)
const reportIssue = async () => {
if (submitting.value) return
submitting.value = true
try {
await electronAPI().sendErrorToSentry(error.exception_message, {
stackTrace: error.traceback?.join('\n'),
nodeType: error.node_type
})
submitted.value = true
toast.add({
severity: 'success',
summary: t('g.reportSent'),
life: 3000
})
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,197 @@
<template>
<Panel>
<template #header>
<div class="flex items-center gap-2">
<span class="font-bold">{{ $t('issueReport.submitErrorReport') }}</span>
</div>
</template>
<template #footer>
<div class="flex justify-end">
<Button
v-tooltip="$t('g.reportIssueTooltip')"
:label="submitted ? $t('g.reportSent') : $t('g.reportIssue')"
:severity="isButtonDisabled ? 'secondary' : 'primary'"
:icon="icon"
:disabled="isButtonDisabled"
@click="reportIssue"
/>
</div>
</template>
<div class="p-4 mt-4 border border-round surface-border shadow-1">
<CheckboxGroup
v-model="selection"
class="gap-4 mb-4"
:checkboxes="reportCheckboxes"
/>
<div class="mb-4">
<InputText
v-model="contactInfo"
class="w-full"
:placeholder="$t('issueReport.provideEmail')"
:maxlength="CONTACT_MAX_LEN"
/>
<CheckboxGroup
v-model="contactPrefs"
class="gap-3 mt-2"
:checkboxes="contactCheckboxes"
/>
</div>
<div class="mb-4">
<Textarea
v-model="details"
class="w-full"
rows="4"
:maxlength="DETAILS_MAX_LEN"
:placeholder="$t('issueReport.provideAdditionalDetails')"
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
</div>
</div>
</Panel>
</template>
<script setup lang="ts">
import type { CaptureContext, User } from '@sentry/core'
import { captureMessage } from '@sentry/core'
import cloneDeep from 'lodash/cloneDeep'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Panel from 'primevue/panel'
import Textarea from 'primevue/textarea'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import CheckboxGroup from '@/components/common/CheckboxGroup.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { DefaultField, ReportField } from '@/types/issueReportTypes'
const ISSUE_NAME = 'User reported issue'
const DETAILS_MAX_LEN = 5_000
const CONTACT_MAX_LEN = 320
const props = defineProps<{
errorType: string
defaultFields?: DefaultField[]
extraFields?: ReportField[]
}>()
const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
props
const { t } = useI18n()
const toast = useToast()
const selection = ref<string[]>([])
const contactPrefs = ref<string[]>([])
const contactInfo = ref('')
const details = ref('')
const submitting = ref(false)
const submitted = ref(false)
const followUp = computed(() => contactPrefs.value.includes('FollowUp'))
const notifyResolve = computed(() => contactPrefs.value.includes('Resolution'))
const icon = computed(() => {
if (submitting.value) return 'pi pi-spin pi-spinner'
if (submitted.value) return 'pi pi-check'
return 'pi pi-send'
})
const isFormEmpty = computed(() => !selection.value.length && !details.value)
const isButtonDisabled = computed(
() => submitted.value || submitting.value || isFormEmpty.value
)
const contactCheckboxes = [
{ label: t('issueReport.contactFollowUp'), value: 'FollowUp' },
{ label: t('issueReport.notifyResolve'), value: 'Resolution' }
]
const defaultReportCheckboxes = [
{ label: t('g.workflow'), value: 'Workflow' },
{ label: t('g.logs'), value: 'Logs' },
{ label: t('issueReport.systemStats'), value: 'SystemStats' },
{ label: t('g.settings'), value: 'Settings' }
]
const reportCheckboxes = computed(() => [
...(props.extraFields?.map(({ label, value }) => ({ label, value })) ?? []),
...defaultReportCheckboxes.filter(({ value }) =>
defaultFields.includes(value as DefaultField)
)
])
const getUserInfo = (): User => ({ email: contactInfo.value })
const getLogs = async () =>
selection.value.includes('Logs') ? api.getLogs() : null
const getSystemStats = async () =>
selection.value.includes('SystemStats') ? api.getSystemStats() : null
const getSettings = async () =>
selection.value.includes('Settings') ? api.getSettings() : null
const getWorkflow = () =>
selection.value.includes('Workflow')
? cloneDeep(app.graph.asSerialisable())
: null
const createDefaultFields = async () => {
const [settings, systemStats, logs, workflow] = await Promise.all([
getSettings(),
getSystemStats(),
getLogs(),
getWorkflow()
])
return { settings, systemStats, logs, workflow }
}
const createExtraFields = (): Record<string, unknown> | undefined => {
if (!props.extraFields) return undefined
return props.extraFields
.filter((field) => !field.optIn || selection.value.includes(field.value))
.reduce((acc, field) => ({ ...acc, ...cloneDeep(field.data) }), {})
}
const createFeedback = () => {
return {
details: details.value,
contactPreferences: {
followUp: followUp.value,
notifyOnResolution: notifyResolve.value
}
}
}
const createCaptureContext = async (): Promise<CaptureContext> => {
return {
user: getUserInfo(),
level: 'error',
tags: {
errorType: props.errorType
},
extra: {
...createFeedback(),
...(await createDefaultFields()),
...createExtraFields()
}
}
}
const reportIssue = async () => {
if (isButtonDisabled.value) return
submitting.value = true
try {
captureMessage(ISSUE_NAME, await createCaptureContext())
submitted.value = true
toast.add({
severity: 'success',
summary: t('g.reportSent'),
life: 3000
})
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,230 @@
// @ts-strict-ignore
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Panel from 'primevue/panel'
import Textarea from 'primevue/textarea'
import Tooltip from 'primevue/tooltip'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import CheckboxGroup from '@/components/common/CheckboxGroup.vue'
import enMesages from '@/locales/en/main.json'
import { DefaultField, ReportField } from '@/types/issueReportTypes'
import ReportIssuePanel from '../ReportIssuePanel.vue'
type ReportIssuePanelProps = {
errorType: string
defaultFields?: DefaultField[]
extraFields?: ReportField[]
}
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: enMesages
}
})
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: vi.fn()
}))
}))
vi.mock('@/scripts/api', () => ({
api: {
getLogs: vi.fn().mockResolvedValue('mock logs'),
getSystemStats: vi.fn().mockResolvedValue('mock stats'),
getSettings: vi.fn().mockResolvedValue('mock settings')
}
}))
vi.mock('@/scripts/app', () => ({
app: {
graph: {
asSerialisable: vi.fn().mockReturnValue({})
}
}
}))
vi.mock('@sentry/core', () => ({
captureMessage: vi.fn()
}))
describe('ReportIssuePanel', () => {
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props: ReportIssuePanelProps, options = {}): any => {
return mount(ReportIssuePanel, {
global: {
plugins: [PrimeVue, createTestingPinia(), i18n],
directives: { tooltip: Tooltip },
components: { InputText, Button, Panel, Textarea, CheckboxGroup }
},
props,
...options
})
}
it('renders the panel with all required components', () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
expect(wrapper.find('.p-panel').exists()).toBe(true)
expect(wrapper.findAllComponents(CheckboxGroup).length).toBe(2)
expect(wrapper.findComponent(InputText).exists()).toBe(true)
expect(wrapper.findComponent(Textarea).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
it('updates selection when checkboxes are selected', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
await checkboxes?.setValue(['Workflow', 'Logs'])
expect(wrapper.vm.selection).toEqual(['Workflow', 'Logs'])
})
it('updates contactInfo when input is changed', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const input = wrapper.findComponent(InputText)
await input.setValue('test@example.com')
expect(wrapper.vm.contactInfo).toBe('test@example.com')
})
it('updates additional details when textarea is changed', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const textarea = wrapper.findComponent(Textarea)
await textarea.setValue('This is a test detail.')
expect(wrapper.vm.details).toBe('This is a test detail.')
})
it('updates contactPrefs when preferences are selected', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const preferences = wrapper.findAllComponents(CheckboxGroup).at(1)
await preferences?.setValue(['FollowUp'])
expect(wrapper.vm.contactPrefs).toEqual(['FollowUp'])
})
it('does not allow submission if the form is empty', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
await wrapper.vm.reportIssue()
expect(wrapper.vm.submitted).toBe(false)
})
it('renders with overridden default fields', () => {
const wrapper = mountComponent({
errorType: 'Test Error',
defaultFields: ['Settings']
})
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
expect(checkboxes?.props('checkboxes')).toEqual([
{ label: 'Settings', value: 'Settings' }
])
})
it('renders additional fields when extraFields prop is provided', () => {
const extraFields = [
{ label: 'Custom Field', value: 'CustomField', optIn: true, data: {} }
]
const wrapper = mountComponent({ errorType: 'Test Error', extraFields })
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
expect(checkboxes?.props('checkboxes')).toContainEqual({
label: 'Custom Field',
value: 'CustomField'
})
})
it('does not submit unchecked fields', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const textarea = wrapper.findComponent(Textarea)
await textarea.setValue('Report with only text but no fields selected')
await wrapper.vm.reportIssue()
const { captureMessage } = (await import('@sentry/core')) as any
const captureContext = captureMessage.mock.calls[0][1]
expect(captureContext.extra.logs).toBeNull()
expect(captureContext.extra.systemStats).toBeNull()
expect(captureContext.extra.settings).toBeNull()
expect(captureContext.extra.workflow).toBeNull()
})
it.each([
{
checkbox: 'Logs',
apiMethod: 'getLogs',
expectedKey: 'logs',
mockValue: 'mock logs'
},
{
checkbox: 'SystemStats',
apiMethod: 'getSystemStats',
expectedKey: 'systemStats',
mockValue: 'mock stats'
},
{
checkbox: 'Settings',
apiMethod: 'getSettings',
expectedKey: 'settings',
mockValue: 'mock settings'
}
])(
'submits (%s) when the (%s) checkbox is selected',
async ({ checkbox, apiMethod, expectedKey, mockValue }) => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const { api } = (await import('@/scripts/api')) as any
vi.spyOn(api, apiMethod).mockResolvedValue(mockValue)
const { captureMessage } = await import('@sentry/core')
// Select the checkbox
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
await checkboxes?.vm.$emit('update:modelValue', [checkbox])
await wrapper.vm.reportIssue()
expect(api[apiMethod]).toHaveBeenCalled()
// Verify the message includes the associated data
expect(captureMessage).toHaveBeenCalledWith(
'User reported issue',
expect.objectContaining({
extra: expect.objectContaining({ [expectedKey]: mockValue })
})
)
}
)
it('submits workflow when the Workflow checkbox is selected', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const { app } = (await import('@/scripts/app')) as any
const { captureMessage } = await import('@sentry/core')
const mockWorkflow = { nodes: [], edges: [] }
vi.spyOn(app.graph, 'asSerialisable').mockReturnValue(mockWorkflow)
// Select the "Workflow" checkbox
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
await checkboxes?.vm.$emit('update:modelValue', ['Workflow'])
await wrapper.vm.reportIssue()
expect(app.graph.asSerialisable).toHaveBeenCalled()
// Verify the message includes the workflow
expect(captureMessage).toHaveBeenCalledWith(
'User reported issue',
expect.objectContaining({
extra: expect.objectContaining({ workflow: mockWorkflow })
})
)
})
})

View File

@@ -57,7 +57,7 @@ import { usePragmaticDroppable } from '@/hooks/dndHooks'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { setStorageValue } from '@/scripts/utils'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useLitegraphService } from '@/services/litegraphService'
@@ -95,6 +95,29 @@ const canvasMenuEnabled = computed(() =>
)
const tooltipEnabled = computed(() => settingStore.get('Comfy.EnableTooltips'))
const storedWorkflows = JSON.parse(
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
)
const storedActiveIndex = JSON.parse(
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
)
const openWorkflows = computed(() => workspaceStore?.workflow?.openWorkflows)
const activeWorkflow = computed(() => workspaceStore?.workflow?.activeWorkflow)
const restoreState = computed<{ paths: string[]; activeIndex: number }>(() => {
if (!openWorkflows.value || !activeWorkflow.value) {
return { paths: [], activeIndex: -1 }
}
const paths = openWorkflows.value
.filter((workflow) => workflow?.isPersisted && !workflow.isModified)
.map((workflow) => workflow.path)
const activeIndex = openWorkflows.value.findIndex(
(workflow) => workflow.path === activeWorkflow.value?.path
)
return { paths, activeIndex }
})
watchEffect(() => {
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
if (canvasStore.canvas) {
@@ -364,6 +387,18 @@ onMounted(async () => {
'Comfy.CustomColorPalettes'
)
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
if (isRestorable)
workflowStore.openWorkflowsInBackground({
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
watch(restoreState, ({ paths, activeIndex }) => {
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(activeIndex))
})
// Start watching for locale change after the initial value is loaded.
watch(
() => settingStore.get('Comfy.Locale'),

View File

@@ -59,10 +59,27 @@
</h4>
<ul class="list-disc pl-6 space-y-1">
<li>
{{ $t('install.settings.dataCollectionDialog.errorReports') }}
{{
$t('install.settings.dataCollectionDialog.collect.errorReports')
}}
</li>
<li>
{{ $t('install.settings.dataCollectionDialog.systemInfo') }}
{{ $t('install.settings.dataCollectionDialog.collect.systemInfo') }}
</li>
<li>
{{
$t(
'install.settings.dataCollectionDialog.collect.userJourneyEvents'
)
}}
<span
class="pi pi-info-circle text-neutral-400"
v-tooltip="
$t(
'install.settings.dataCollectionDialog.collect.userJourneyTooltip'
)
"
/>
</li>
</ul>
@@ -72,21 +89,29 @@
<ul class="list-disc pl-6 space-y-1">
<li>
{{
$t('install.settings.dataCollectionDialog.personalInformation')
}}
</li>
<li>
{{ $t('install.settings.dataCollectionDialog.workflowContents') }}
</li>
<li>
{{
$t('install.settings.dataCollectionDialog.fileSystemInformation')
$t(
'install.settings.dataCollectionDialog.doNotCollect.personalInformation'
)
}}
</li>
<li>
{{
$t(
'install.settings.dataCollectionDialog.customNodeConfigurations'
'install.settings.dataCollectionDialog.doNotCollect.workflowContents'
)
}}
</li>
<li>
{{
$t(
'install.settings.dataCollectionDialog.doNotCollect.fileSystemInformation'
)
}}
</li>
<li>
{{
$t(
'install.settings.dataCollectionDialog.doNotCollect.customNodeConfigurations'
)
}}
</li>

View File

@@ -130,12 +130,13 @@
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import Tag from 'primevue/tag'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { TorchDeviceType, electronAPI } from '@/utils/envUtil'
import { electronAPI } from '@/utils/envUtil'
const { t } = useI18n()

View File

@@ -20,6 +20,14 @@
@click="alphabeticalSort = !alphabeticalSort"
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortOrder')"
/>
<Button
class="grouping-button"
:icon="groupBySource ? 'pi pi-list' : 'pi pi-list-check'"
text
severity="secondary"
@click="groupBySource = !groupBySource"
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.groupingType')"
/>
</template>
<template #header>
<SearchBox
@@ -66,7 +74,7 @@ import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import type { TreeNode } from 'primevue/treenode'
import { Ref, computed, nextTick, ref } from 'vue'
import { Ref, computed, nextTick, ref, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { SearchFilter } from '@/components/common/SearchFilterChip.vue'
@@ -101,6 +109,21 @@ const nodeBookmarkTreeExplorerRef = ref<InstanceType<
> | null>(null)
const searchFilter = ref(null)
const alphabeticalSort = ref(false)
const groupBySource = ref(false)
const createSourceKey = (nodeDef: ComfyNodeDefImpl) => {
const sourcePath = nodeDef.python_module.split('.')
const pathWithoutCategory = nodeDef.nodePath.split('/').slice(1)
return [...sourcePath, ...pathWithoutCategory]
}
watch(groupBySource, (newValue) => {
if (newValue) {
nodeDefStore.setKeyFunction(createSourceKey)
} else {
nodeDefStore.setKeyFunction(null)
}
})
const searchQuery = ref<string>('')
@@ -194,4 +217,9 @@ const onRemoveFilter = (filterAndValue) => {
}
handleSearch(searchQuery.value)
}
// This can be added if the persistent state is not desirable:
// onBeforeUnmount(() => {
// nodeDefStore.setKeyFunction(null)
// })
</script>

View File

@@ -3,7 +3,7 @@
<template #header>
<div class="flex items-center justify-center">
<div
class="relative overflow-hidden rounded-lg cursor-pointer w-64 h-64"
class="relative overflow-hidden rounded-t-lg cursor-pointer w-64 h-64"
>
<img
v-if="!imageError"
@@ -13,7 +13,7 @@
: `api/workflow_templates/${props.moduleName}/${props.workflowName}.jpg`
"
@error="imageError = true"
class="w-64 h-64 rounded-lg object-cover thumbnail"
class="w-64 h-64 rounded-t-lg object-cover thumbnail"
/>
<div v-else class="w-64 h-64 content-center text-center">
<i class="pi pi-file" style="font-size: 4rem"></i>

View File

@@ -24,7 +24,7 @@
:key="selectedTab.moduleName"
>
<template #item="slotProps">
<div @click="loadWorkflow(slotProps.data)">
<div @click="loadWorkflow(slotProps.data)" class="p-2">
<TemplateWorkflowCard
:moduleName="selectedTab.moduleName"
:workflowName="slotProps.data"

View File

@@ -3,13 +3,12 @@
<div
ref="topMenuRef"
class="comfyui-menu flex items-center"
v-show="betaMenuEnabled && !workspaceState.focusMode"
v-show="showTopMenu"
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
>
<h1 class="comfyui-logo mx-2">ComfyUI</h1>
<h1 class="comfyui-logo mx-2 app-drag">ComfyUI</h1>
<CommandMenubar />
<Divider layout="vertical" class="mx-2" />
<div class="flex-grow min-w-0">
<div class="flex-grow min-w-0 app-drag h-full">
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
</div>
<div class="comfyui-menu-right" ref="menuRight"></div>
@@ -25,14 +24,23 @@
@click="workspaceState.focusMode = true"
@contextmenu="showNativeMenu"
/>
<div
v-show="menuSetting !== 'Bottom'"
class="window-actions-spacer flex-shrink-0"
/>
</div>
</teleport>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow && !showTopMenu"
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
/>
</template>
<script setup lang="ts">
import { useEventBus, useResizeObserver } from '@vueuse/core'
import { useEventBus } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import { computed, onMounted, provide, ref } from 'vue'
import Actionbar from '@/components/actionbar/ComfyActionbar.vue'
@@ -49,14 +57,20 @@ const settingStore = useSettingStore()
const workflowTabsPosition = computed(() =>
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
)
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const menuSetting = computed(() => settingStore.get('Comfy.UseNewMenu'))
const betaMenuEnabled = computed(() => menuSetting.value !== 'Disabled')
const teleportTarget = computed(() =>
settingStore.get('Comfy.UseNewMenu') === 'Top'
? '.comfyui-body-top'
: '.comfyui-body-bottom'
)
const isNativeWindow = computed(
() =>
isElectron() && settingStore.get('Comfy-Desktop.WindowStyle') === 'custom'
)
const showTopMenu = computed(
() => betaMenuEnabled.value && !workspaceState.focusMode
)
const menuRight = ref<HTMLDivElement | null>(null)
// Menu-right holds legacy topbar elements attached by custom scripts
@@ -78,20 +92,13 @@ eventBus.on((event: string, payload: any) => {
}
})
/** Height of titlebar on desktop. */
if (isElectron()) {
let desktopHeight = 0
useResizeObserver(topMenuRef, (entries) => {
if (settingStore.get('Comfy.UseNewMenu') !== 'Top') return
const { height } = entries[0].contentRect
if (desktopHeight === height) return
electronAPI().changeTheme({ height })
desktopHeight = height
})
}
onMounted(() => {
if (isElectron()) {
electronAPI().changeTheme({
height: topMenuRef.value.getBoundingClientRect().height
})
}
})
</script>
<style scoped>
@@ -127,30 +134,3 @@ if (isElectron()) {
cursor: default;
}
</style>
<style lang="postcss">
/* Desktop: Custom window styling */
:root[data-platform='electron'] {
.comfyui-logo {
@apply flex items-center gap-2 my-1 mx-1.5;
&::before {
@apply w-7 h-7 bg-[url('/assets/images/Comfy_Logo_x256.png')] bg-no-repeat bg-contain content-[''];
}
}
.comfyui-body-top {
.comfyui-menu {
app-region: drag;
padding-right: calc(100% - env(titlebar-area-width, 0));
}
}
button,
.p-menubar,
.comfyui-menu-right > *,
.actionbar {
app-region: no-drag;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="workflow-tabs-container flex flex-row w-full">
<div class="workflow-tabs-container flex flex-row max-w-full h-full">
<ScrollPanel
class="overflow-hidden"
class="overflow-hidden no-drag"
:pt:content="{
class: 'p-0 w-full',
onwheel: handleWheel
@@ -28,7 +28,7 @@
</ScrollPanel>
<Button
v-tooltip="{ value: $t('sideToolbar.newBlankWorkflow'), showDelay: 300 }"
class="new-blank-workflow-button flex-shrink-0"
class="new-blank-workflow-button flex-shrink-0 no-drag"
icon="pi pi-plus"
text
severity="secondary"
@@ -50,7 +50,7 @@ import { useI18n } from 'vue-i18n'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { ComfyWorkflow } from '@/stores/workflowStore'
import { ComfyWorkflow, useWorkflowBookmarkStore } from '@/stores/workflowStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -67,6 +67,7 @@ const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const workflowBookmarkStore = useWorkflowBookmarkStore()
const rightClickedTab = ref<WorkflowOption>(null)
const menu = ref()
@@ -154,6 +155,13 @@ const contextMenuItems = computed(() => {
...options.value.slice(0, index)
]),
disabled: options.value.length <= 1
},
{
label: workflowBookmarkStore.isBookmarked(tab.workflow.path)
? t('tabMenu.removeFromBookmarks')
: t('tabMenu.addToBookmarks'),
command: () => workflowBookmarkStore.toggleBookmarked(tab.workflow.path),
disabled: tab.workflow.isTemporary
}
]
})
@@ -170,21 +178,31 @@ const handleWheel = (event: WheelEvent) => {
</script>
<style scoped>
:deep(.p-togglebutton) {
@apply p-0 bg-transparent rounded-none flex-shrink-0 relative border-0 border-r border-solid;
border-right-color: var(--border-color);
}
:deep(.p-togglebutton::before) {
@apply hidden;
}
:deep(.p-togglebutton) {
@apply p-0 bg-transparent rounded-none flex-shrink-0 relative;
:deep(.p-togglebutton:first-child) {
@apply border-l border-solid;
border-left-color: var(--border-color);
}
:deep(.p-togglebutton:not(:first-child)) {
@apply border-l-0;
}
:deep(.p-togglebutton.p-togglebutton-checked) {
border-bottom-width: 1px;
@apply border-b border-solid h-full;
border-bottom-color: var(--p-button-text-primary-color);
}
:deep(.p-togglebutton:not(.p-togglebutton-checked)) {
opacity: 0.75;
@apply opacity-75;
}
:deep(.p-togglebutton-checked) .close-button,
@@ -200,9 +218,17 @@ const handleWheel = (event: WheelEvent) => {
@apply invisible;
}
:deep(.p-scrollpanel-content) {
@apply h-full;
}
/* Scrollbar half opacity to avoid blocking the active tab bottom border */
:deep(.p-scrollpanel:hover .p-scrollpanel-bar),
:deep(.p-scrollpanel:active .p-scrollpanel-bar) {
opacity: 0.5;
@apply opacity-50;
}
:deep(.p-selectbutton) {
@apply rounded-none h-full;
}
</style>

View File

@@ -386,8 +386,6 @@ export const CORE_SETTINGS: SettingParams[] = [
category: ['Comfy', 'Menu', 'UseNewMenu'],
defaultValue: 'Top',
name: 'Use new menu',
tooltip:
'(Desktop, Windows only): When using custom window style, only Top is supported',
type: 'combo',
options: ['Disabled', 'Top', 'Bottom'],
migrateDeprecatedValue: (value: string) => {

View File

@@ -1,7 +1,6 @@
import { t } from '@/i18n'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useSettingStore } from '@/stores/settingStore'
import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
;(async () => {
@@ -31,7 +30,7 @@ import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
{
id: 'Comfy-Desktop.SendStatistics',
category: ['Comfy-Desktop', 'General', 'Send Statistics'],
name: 'Send anonymous crash reports',
name: 'Send anonymous usage metrics',
type: 'boolean',
defaultValue: true,
onChange: onChangeRestartApp
@@ -40,22 +39,18 @@ import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
id: 'Comfy-Desktop.WindowStyle',
category: ['Comfy-Desktop', 'General', 'Window Style'],
name: 'Window Style',
tooltip: "Custom: Replace the system title bar with ComfyUI's Top menu",
tooltip: 'Choose custom option to hide the system title bar',
type: 'combo',
experimental: true,
defaultValue: 'default',
options: ['default', 'custom'],
onChange: (
newValue: 'default' | 'custom',
oldValue?: 'default' | 'custom'
oldValue: 'default' | 'custom'
) => {
if (!oldValue) return
// Custom window mode requires the Top menu.
if (newValue === 'custom') {
useSettingStore().set('Comfy.UseNewMenu', 'Top')
}
electronAPI.Config.setWindowStyle(newValue)
onChangeRestartApp(newValue, oldValue)
}
}
],
@@ -194,21 +189,4 @@ import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
}
]
})
// TODO: Replace monkey patch with API or replace UX.
// If the user changes frontend menu type, ensure custom window style is disabled.
const menuSetting = useSettingStore().settingsById['Comfy.UseNewMenu']
if (menuSetting) {
const { onChange } = menuSetting
menuSetting.onChange = (
newValue: 'Disabled' | 'Top' | 'Bottom',
oldValue?: 'Disabled' | 'Top' | 'Bottom'
) => {
const style = useSettingStore().get('Comfy-Desktop.WindowStyle')
if (oldValue === 'Top' && style === 'custom') {
useSettingStore().set('Comfy-Desktop.WindowStyle', 'default')
}
return onChange?.(newValue, oldValue)
}
}
})()

View File

@@ -2,6 +2,7 @@
import { IWidget } from '@comfyorg/litegraph'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
@@ -115,8 +116,7 @@ class Load3d {
stlLoader: STLLoader
currentModel: THREE.Object3D | null = null
originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null = null
node: any
private animationFrameId: number | null = null
animationFrameId: number | null = null
gridHelper: THREE.GridHelper
lights: THREE.Light[] = []
clock: THREE.Clock
@@ -131,6 +131,10 @@ class Load3d {
currentUpDirection: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z' =
'original'
originalRotation: THREE.Euler | null = null
viewHelper: ViewHelper
viewHelperContainer: HTMLDivElement
cameraSwitcherContainer: HTMLDivElement
gridSwitcherContainer: HTMLDivElement
constructor(container: Element | HTMLElement) {
this.scene = new THREE.Scene()
@@ -157,6 +161,7 @@ class Load3d {
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
this.renderer.setSize(300, 300)
this.renderer.setClearColor(0x282828)
this.renderer.autoClear = false
const rendererDomElement: HTMLCanvasElement = this.renderer.domElement
@@ -203,13 +208,143 @@ class Load3d {
this.standardMaterial = this.createSTLMaterial()
this.animate()
this.createViewHelper(container)
this.createGridSwitcher(container)
this.createCameraSwitcher(container)
this.handleResize()
this.startAnimation()
}
createViewHelper(container: Element | HTMLElement) {
this.viewHelperContainer = document.createElement('div')
this.viewHelperContainer.style.position = 'absolute'
this.viewHelperContainer.style.bottom = '0'
this.viewHelperContainer.style.left = '0'
this.viewHelperContainer.style.width = '128px'
this.viewHelperContainer.style.height = '128px'
this.viewHelperContainer.addEventListener('pointerup', (event) => {
event.stopPropagation()
this.viewHelper.handleClick(event)
})
this.viewHelperContainer.addEventListener('pointerdown', (event) => {
event.stopPropagation()
})
container.appendChild(this.viewHelperContainer)
this.viewHelper = new ViewHelper(
this.activeCamera,
this.viewHelperContainer
)
this.viewHelper.center = this.controls.target
}
createGridSwitcher(container: Element | HTMLElement) {
this.gridSwitcherContainer = document.createElement('div')
this.gridSwitcherContainer.style.position = 'absolute'
this.gridSwitcherContainer.style.top = '28px' // 修改这里,让按钮在相机按钮下方
this.gridSwitcherContainer.style.left = '3px' // 与相机按钮左对齐
this.gridSwitcherContainer.style.width = '20px'
this.gridSwitcherContainer.style.height = '20px'
this.gridSwitcherContainer.style.cursor = 'pointer'
this.gridSwitcherContainer.style.alignItems = 'center'
this.gridSwitcherContainer.style.justifyContent = 'center'
this.gridSwitcherContainer.style.transition = 'background-color 0.2s'
const gridIcon = document.createElement('div')
gridIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M3 3h18v18H3z"/>
<path d="M3 9h18"/>
<path d="M3 15h18"/>
<path d="M9 3v18"/>
<path d="M15 3v18"/>
</svg>
`
const updateButtonState = () => {
if (this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor =
'rgba(255, 255, 255, 0.2)'
} else {
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
}
}
updateButtonState()
this.gridSwitcherContainer.addEventListener('mouseenter', () => {
if (!this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
}
})
this.gridSwitcherContainer.addEventListener('mouseleave', () => {
if (!this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
}
})
this.gridSwitcherContainer.title = 'Toggle Grid'
this.gridSwitcherContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleGrid(!this.gridHelper.visible)
updateButtonState()
})
this.gridSwitcherContainer.appendChild(gridIcon)
container.appendChild(this.gridSwitcherContainer)
}
createCameraSwitcher(container: Element | HTMLElement) {
this.cameraSwitcherContainer = document.createElement('div')
this.cameraSwitcherContainer.style.position = 'absolute'
this.cameraSwitcherContainer.style.top = '3px'
this.cameraSwitcherContainer.style.left = '3px'
this.cameraSwitcherContainer.style.width = '20px'
this.cameraSwitcherContainer.style.height = '20px'
this.cameraSwitcherContainer.style.cursor = 'pointer'
this.cameraSwitcherContainer.style.alignItems = 'center'
this.cameraSwitcherContainer.style.justifyContent = 'center'
const cameraIcon = document.createElement('div')
cameraIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M18 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Z"/>
<path d="m12 12 4-2.4"/>
<circle cx="12" cy="12" r="3"/>
</svg>
`
this.cameraSwitcherContainer.addEventListener('mouseenter', () => {
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.cameraSwitcherContainer.addEventListener('mouseleave', () => {
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.cameraSwitcherContainer.title =
'Switch Camera (Perspective/Orthographic)'
this.cameraSwitcherContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleCamera()
})
this.cameraSwitcherContainer.appendChild(cameraIcon)
container.appendChild(this.cameraSwitcherContainer)
}
setFOV(fov: number) {
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.fov = fov
@@ -465,6 +600,13 @@ class Load3d {
this.controls.target.copy(target)
this.controls.update()
this.viewHelper.dispose()
this.viewHelper = new ViewHelper(
this.activeCamera,
this.viewHelperContainer
)
this.viewHelper.center = this.controls.target
this.handleResize()
}
@@ -501,8 +643,16 @@ class Load3d {
startAnimation() {
const animate = () => {
this.animationFrameId = requestAnimationFrame(animate)
const delta = this.clock.getDelta()
if (this.viewHelper.animating) {
this.viewHelper.update(delta)
}
this.renderer.clear()
this.controls.update()
this.renderer.render(this.scene, this.activeCamera)
this.viewHelper.render(this.renderer)
}
animate()
}
@@ -588,6 +738,7 @@ class Load3d {
}
this.controls.dispose()
this.viewHelper.dispose()
this.renderer.dispose()
this.renderer.domElement.remove()
this.scene.clear()
@@ -818,10 +969,12 @@ class Load3d {
this.orthographicCamera.updateProjectionMatrix()
}
this.renderer.clear()
this.renderer.render(this.scene, this.activeCamera)
const sceneData = this.renderer.domElement.toDataURL('image/png')
this.renderer.setClearColor(0x000000, 0)
this.renderer.clear()
this.renderer.render(this.scene, this.activeCamera)
const maskData = this.renderer.domElement.toDataURL('image/png')
@@ -846,44 +999,6 @@ class Load3d {
})
}
setViewPosition(position: 'front' | 'top' | 'right' | 'isometric') {
if (!this.currentModel) {
return
}
const box = new THREE.Box3()
let center = new THREE.Vector3()
let size = new THREE.Vector3()
if (this.currentModel) {
box.setFromObject(this.currentModel)
box.getCenter(center)
box.getSize(size)
}
const maxDim = Math.max(size.x, size.y, size.z)
const distance = maxDim * 2
switch (position) {
case 'front':
this.activeCamera.position.set(0, 0, distance)
break
case 'top':
this.activeCamera.position.set(0, distance, 0)
break
case 'right':
this.activeCamera.position.set(distance, 0, 0)
break
case 'isometric':
this.activeCamera.position.set(distance, distance, distance)
break
}
this.activeCamera.lookAt(center)
this.controls.target.copy(center)
this.controls.update()
}
setBackgroundColor(color: string) {
this.renderer.setClearColor(new THREE.Color(color))
this.renderer.render(this.scene, this.activeCamera)
@@ -1020,16 +1135,28 @@ class Load3dAnimation extends Load3d {
})
}
animate = () => {
requestAnimationFrame(this.animate)
if (this.currentAnimation && this.isAnimationPlaying) {
startAnimation() {
const animate = () => {
this.animationFrameId = requestAnimationFrame(animate)
const delta = this.clock.getDelta()
this.currentAnimation.update(delta)
}
this.controls.update()
this.renderer.render(this.scene, this.activeCamera)
if (this.currentAnimation && this.isAnimationPlaying) {
this.currentAnimation.update(delta)
}
this.controls.update()
this.renderer.clear()
this.renderer.render(this.scene, this.activeCamera)
if (this.viewHelper.animating) {
this.viewHelper.update(delta)
}
this.viewHelper.render(this.renderer)
}
animate()
}
}
@@ -1076,9 +1203,6 @@ function configureLoad3D(
load3d: Load3d,
loadFolder: 'input' | 'output',
modelWidget: IWidget,
showGrid: IWidget,
cameraType: IWidget,
view: IWidget,
material: IWidget,
bgColor: IWidget,
lightIntensity: IWidget,
@@ -1138,22 +1262,6 @@ function configureLoad3D(
modelWidget.callback = onModelWidgetUpdate
load3d.toggleGrid(showGrid.value as boolean)
showGrid.callback = (value: boolean) => {
load3d.toggleGrid(value)
}
load3d.toggleCamera(cameraType.value as 'perspective' | 'orthographic')
cameraType.callback = (value: 'perspective' | 'orthographic') => {
load3d.toggleCamera(value)
}
view.callback = (value: 'front' | 'top' | 'right' | 'isometric') => {
load3d.setViewPosition(value)
}
material.callback = (value: 'original' | 'normal' | 'wireframe') => {
load3d.setMaterialMode(value)
}
@@ -1312,14 +1420,6 @@ app.registerExtension({
(w: IWidget) => w.name === 'model_file'
)
const showGrid = node.widgets.find((w: IWidget) => w.name === 'show_grid')
const cameraType = node.widgets.find(
(w: IWidget) => w.name === 'camera_type'
)
const view = node.widgets.find((w: IWidget) => w.name === 'view')
const material = node.widgets.find((w: IWidget) => w.name === 'material')
const bgColor = node.widgets.find((w: IWidget) => w.name === 'bg_color')
@@ -1353,9 +1453,6 @@ app.registerExtension({
load3d,
'input',
modelWidget,
showGrid,
cameraType,
view,
material,
bgColor,
lightIntensity,
@@ -1569,14 +1666,6 @@ app.registerExtension({
(w: IWidget) => w.name === 'model_file'
)
const showGrid = node.widgets.find((w: IWidget) => w.name === 'show_grid')
const cameraType = node.widgets.find(
(w: IWidget) => w.name === 'camera_type'
)
const view = node.widgets.find((w: IWidget) => w.name === 'view')
const material = node.widgets.find((w: IWidget) => w.name === 'material')
const bgColor = node.widgets.find((w: IWidget) => w.name === 'bg_color')
@@ -1621,9 +1710,6 @@ app.registerExtension({
load3d,
'input',
modelWidget,
showGrid,
cameraType,
view,
material,
bgColor,
lightIntensity,
@@ -1652,6 +1738,8 @@ app.registerExtension({
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = JSON.stringify(load3d.getCameraState())
load3d.toggleAnimation(false)
const { scene: imageData, mask: maskData } = await load3d.captureScene(
w.value,
h.value
@@ -1758,14 +1846,6 @@ app.registerExtension({
(w: IWidget) => w.name === 'model_file'
)
const showGrid = node.widgets.find((w: IWidget) => w.name === 'show_grid')
const cameraType = node.widgets.find(
(w: IWidget) => w.name === 'camera_type'
)
const view = node.widgets.find((w: IWidget) => w.name === 'view')
const material = node.widgets.find((w: IWidget) => w.name === 'material')
const bgColor = node.widgets.find((w: IWidget) => w.name === 'bg_color')
@@ -1801,9 +1881,6 @@ app.registerExtension({
load3d,
'output',
modelWidget,
showGrid,
cameraType,
view,
material,
bgColor,
lightIntensity,

View File

@@ -69,7 +69,18 @@
"command": "Command",
"keybinding": "Keybinding",
"upload": "Upload",
"export": "Export"
"export": "Export",
"workflow": "Workflow"
},
"issueReport": {
"submitErrorReport": "Submit Error Report (Optional)",
"provideEmail": "Give us your email (Optional)",
"provideAdditionalDetails": "Provide additional details (optional)",
"stackTrace": "Stack Trace",
"systemStats": "System Stats",
"contactFollowUp": "Contact me for follow up",
"notifyResolve": "Notify me when resolved",
"helpFix": "Help Fix This"
},
"color": {
"default": "Default",
@@ -170,21 +181,26 @@
},
"settings": {
"autoUpdate": "Automatic Updates",
"allowMetrics": "Crash Reports",
"autoUpdateDescription": "Automatically download and install updates when they become available. You'll always be notified before updates are installed.",
"allowMetricsDescription": "Help improve ComfyUI by sending anonymous crash reports. No personal information or workflow content will be collected. This can be disabled at any time in the settings menu.",
"allowMetrics": "Usage Metrics",
"autoUpdateDescription": "Automatically download updates when they become available. You will be notified before updates are installed.",
"allowMetricsDescription": "Help improve ComfyUI by sending anonymous usage metrics. No personal information or workflow content will be collected.",
"learnMoreAboutData": "Learn more about data collection",
"dataCollectionDialog": {
"title": "About Data Collection",
"whatWeCollect": "What we collect:",
"whatWeDoNotCollect": "What we don't collect:",
"errorReports": "Error message and stack trace",
"systemInfo": "Hardware, OS type, and app version",
"personalInformation": "Personal information",
"workflowContent": "Workflow content",
"fileSystemInformation": "File system information",
"workflowContents": "Workflow contents",
"customNodeConfigurations": "Custom node configurations"
"collect": {
"errorReports": "Error message and stack trace",
"systemInfo": "Hardware, OS type, and app version",
"userJourneyEvents": "User journey events",
"userJourneyTooltip": "User journey events are used to track the user's journey through the app installation process. The event collection ends on the first successful ComfyUI workflow run."
},
"doNotCollect": {
"personalInformation": "Personal information",
"fileSystemInformation": "File system information",
"workflowContents": "Workflow contents",
"customNodeConfigurations": "Custom node configurations"
}
}
},
"customNodes": "Custom Nodes",
@@ -218,7 +234,8 @@
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",
"nodeLibraryTab": {
"sortOrder": "Sort Order"
"sortOrder": "Sort Order",
"groupingType": "Grouping Type"
},
"modelLibrary": "Model Library",
"downloads": "Downloads",
@@ -279,7 +296,9 @@
"closeTab": "Close Tab",
"closeTabsToLeft": "Close Tabs to Left",
"closeTabsToRight": "Close Tabs to Right",
"closeOtherTabs": "Close Other Tabs"
"closeOtherTabs": "Close Other Tabs",
"addToBookmarks": "Add to Bookmarks",
"removeFromBookmarks": "Remove from Bookmarks"
},
"templateWorkflows": {
"title": "Get Started with a Template",

View File

@@ -3,11 +3,11 @@
"name": "Automatically check for updates"
},
"Comfy-Desktop_SendStatistics": {
"name": "Send anonymous crash reports"
"name": "Send anonymous usage metrics"
},
"Comfy-Desktop_WindowStyle": {
"name": "Window Style",
"tooltip": "Custom: Replace the system title bar with ComfyUI's Top menu",
"tooltip": "Choose custom option to hide the system title bar",
"options": {
"default": "default",
"custom": "custom"
@@ -261,7 +261,6 @@
},
"Comfy_UseNewMenu": {
"name": "Use new menu",
"tooltip": "(Desktop, Windows only): When using custom window style, only Top is supported",
"options": {
"Disabled": "Disabled",
"Top": "Top",

View File

@@ -132,7 +132,8 @@
"systemInfo": "Informations système",
"terminal": "Terminal",
"upload": "Téléverser",
"videoFailedToLoad": "Échec du chargement de la vidéo"
"videoFailedToLoad": "Échec du chargement de la vidéo",
"workflow": "Flux de travail"
},
"graphCanvasMenu": {
"fitView": "Adapter la vue",
@@ -205,27 +206,42 @@
"pathValidationFailed": "Échec de la validation du chemin",
"selectItemsToMigrate": "Sélectionnez les éléments à migrer",
"settings": {
"allowMetrics": "Rapports de plantage",
"allowMetricsDescription": "Aidez à améliorer ComfyUI en envoyant des rapports de plantage anonymes. Aucune information personnelle ou contenu de flux de travail ne sera collecté. Cela peut être désactivé à tout moment dans le menu des paramètres.",
"allowMetrics": "Métriques d'utilisation",
"allowMetricsDescription": "Aidez à améliorer ComfyUI en envoyant des métriques d'utilisation anonymes. Aucune information personnelle ou contenu de flux de travail ne sera collecté.",
"autoUpdate": "Mises à jour automatiques",
"autoUpdateDescription": "Téléchargez et installez automatiquement les mises à jour lorsqu'elles deviennent disponibles. Vous serez toujours informé avant l'installation des mises à jour.",
"dataCollectionDialog": {
"customNodeConfigurations": "Configurations de nœuds personnalisés",
"errorReports": "Message d'erreur et trace de la pile",
"fileSystemInformation": "Informations sur le système de fichiers",
"personalInformation": "Informations personnelles",
"systemInfo": "Matériel, type d'OS et version de l'application",
"collect": {
"errorReports": "Message d'erreur et trace de la pile",
"systemInfo": "Matériel, type de système d'exploitation et version de l'application",
"userJourneyEvents": "Événements du parcours utilisateur",
"userJourneyTooltip": "Les événements du parcours utilisateur sont utilisés pour suivre le parcours de l'utilisateur lors du processus d'installation de l'application. La collecte d'événements se termine lors de la première exécution réussie du flux de travail ComfyUI."
},
"doNotCollect": {
"customNodeConfigurations": "Configurations de nœud personnalisées",
"fileSystemInformation": "Informations sur le système de fichiers",
"personalInformation": "Informations personnelles",
"workflowContents": "Contenus du flux de travail"
},
"title": "À propos de la collecte de données",
"whatWeCollect": "Ce que nous collectons :",
"whatWeDoNotCollect": "Ce que nous ne collectons pas :",
"workflowContent": "Contenu du flux de travail",
"workflowContents": "Contenus du flux de travail"
"whatWeDoNotCollect": "Ce que nous ne collectons pas :"
},
"learnMoreAboutData": "En savoir plus sur la collecte de données"
},
"systemLocations": "Emplacements système",
"unhandledError": "Erreur inconnue"
},
"issueReport": {
"contactFollowUp": "Contactez-moi pour un suivi",
"helpFix": "Aidez à résoudre cela",
"notifyResolve": "Prévenez-moi lorsque résolu",
"provideAdditionalDetails": "Fournir des détails supplémentaires (facultatif)",
"provideEmail": "Donnez-nous votre email (Facultatif)",
"stackTrace": "Trace de la pile",
"submitErrorReport": "Soumettre un rapport d'erreur (Facultatif)",
"systemStats": "Statistiques du système"
},
"menu": {
"autoQueue": "File d'attente automatique",
"batchCount": "Nombre de lots",
@@ -623,11 +639,13 @@
"workflows": "Flux de travail"
},
"tabMenu": {
"addToBookmarks": "Ajouter aux Favoris",
"closeOtherTabs": "Fermer les autres onglets",
"closeTab": "Fermer l'onglet",
"closeTabsToLeft": "Fermer les onglets à gauche",
"closeTabsToRight": "Fermer les onglets à droite",
"duplicateTab": "Dupliquer l'onglet"
"duplicateTab": "Dupliquer l'onglet",
"removeFromBookmarks": "Retirer des Favoris"
},
"templateWorkflows": {
"template": {

View File

@@ -3,7 +3,7 @@
"name": "Vérifier automatiquement les mises à jour"
},
"Comfy-Desktop_SendStatistics": {
"name": "Envoyer des rapports de plantage anonymes"
"name": "Envoyer des métriques d'utilisation anonymes"
},
"Comfy-Desktop_WindowStyle": {
"name": "Style de fenêtre",
@@ -265,8 +265,7 @@
"Bottom": "Bas",
"Disabled": "Désactivé",
"Top": "Haut"
},
"tooltip": "(Bureau, uniquement Windows): Lors de l'utilisation d'un style de fenêtre personnalisé, seul Top est pris en charge"
}
},
"Comfy_Validation_NodeDefs": {
"name": "Valider les définitions de nœuds (lent)",

View File

@@ -132,7 +132,8 @@
"systemInfo": "システム情報",
"terminal": "ターミナル",
"upload": "アップロード",
"videoFailedToLoad": "ビデオの読み込みに失敗しました"
"videoFailedToLoad": "ビデオの読み込みに失敗しました",
"workflow": "ワークフロー"
},
"graphCanvasMenu": {
"fitView": "ビューに合わせる",
@@ -205,27 +206,42 @@
"pathValidationFailed": "パスの検証に失敗しました",
"selectItemsToMigrate": "移行する項目を選択",
"settings": {
"allowMetrics": "クラッシュレポート",
"allowMetricsDescription": "ComfyUI改善に協力してください。匿名のクラッシュレポートを送信します。個人情報やワークフロー内容は収集されません。この設定はいつでも無効にできます。",
"allowMetrics": "使用状況のメトリクス",
"allowMetricsDescription": "匿名の使用状況メトリクスを送信してComfyUI改善します。個人情報やワークフロー内容は収集されません。",
"autoUpdate": "自動更新",
"autoUpdateDescription": "更新が利用可能になると、自動的にダウンロードおよびインストールを行います。インストール前に通知が表示されます。",
"dataCollectionDialog": {
"customNodeConfigurations": "カスタムノード設定",
"errorReports": "エラーメッセージとスタックトレース",
"fileSystemInformation": "ファイルシステム情報",
"personalInformation": "個人情報",
"systemInfo": "ハードウェア、OSの種類、アプリのバージョン",
"collect": {
"errorReports": "エラーメッセージとスタックトレース",
"systemInfo": "ハードウェア、OSタイプ、アプリバージョン",
"userJourneyEvents": "ユーザージャーニーイベント",
"userJourneyTooltip": "ユーザージャーニーイベントは、アプリのインストールプロセスを通じてユーザーの旅を追跡するために使用されます。イベントの収集は、最初の成功したComfyUIワークフローの実行で終了します。"
},
"doNotCollect": {
"customNodeConfigurations": "カスタムノードの設定",
"fileSystemInformation": "ファイルシステム情報",
"personalInformation": "個人情報",
"workflowContents": "ワークフローの内容"
},
"title": "データ収集について",
"whatWeCollect": "収集内容:",
"whatWeDoNotCollect": "収集しない内容:",
"workflowContent": "ワークフロー内容",
"workflowContents": "ワークフロー内容"
"whatWeDoNotCollect": "収集しない内容:"
},
"learnMoreAboutData": "データ収集の詳細を見る"
},
"systemLocations": "システムの場所",
"unhandledError": "未知のエラー"
},
"issueReport": {
"contactFollowUp": "フォローアップのために私に連絡する",
"helpFix": "これを修正するのを助ける",
"notifyResolve": "解決したときに通知する",
"provideAdditionalDetails": "追加の詳細を提供する(オプション)",
"provideEmail": "あなたのメールアドレスを教えてください(オプション)",
"stackTrace": "スタックトレース",
"submitErrorReport": "エラーレポートを提出する(オプション)",
"systemStats": "システム統計"
},
"menu": {
"autoQueue": "自動キュー",
"batchCount": "バッチ数",
@@ -623,11 +639,13 @@
"workflows": "ワークフロー"
},
"tabMenu": {
"addToBookmarks": "ブックマークに追加",
"closeOtherTabs": "他のタブを閉じる",
"closeTab": "タブを閉じる",
"closeTabsToLeft": "左のタブを閉じる",
"closeTabsToRight": "右のタブを閉じる",
"duplicateTab": "タブを複製"
"duplicateTab": "タブを複製",
"removeFromBookmarks": "ブックマークから削除"
},
"templateWorkflows": {
"template": {

View File

@@ -3,7 +3,7 @@
"name": "自動的に更新を確認する"
},
"Comfy-Desktop_SendStatistics": {
"name": "匿名のクラッシュレポートを送信する"
"name": "匿名の使用統計を送信する"
},
"Comfy-Desktop_WindowStyle": {
"name": "ウィンドウスタイル",
@@ -265,8 +265,7 @@
"Bottom": "下",
"Disabled": "無効",
"Top": "上"
},
"tooltip": "(デスクトップ、Windowsのみ): カスタムウィンドウスタイルを使用する場合、Topのみがサポートされます"
}
},
"Comfy_Validation_NodeDefs": {
"name": "ノード定義を検証(遅い)",

View File

@@ -132,7 +132,8 @@
"systemInfo": "시스템 정보",
"terminal": "터미널",
"upload": "업로드",
"videoFailedToLoad": "비디오를 로드하지 못했습니다."
"videoFailedToLoad": "비디오를 로드하지 못했습니다.",
"workflow": "워크플로우"
},
"graphCanvasMenu": {
"fitView": "보기 맞춤",
@@ -205,27 +206,42 @@
"pathValidationFailed": "경로 유효성 검사 실패",
"selectItemsToMigrate": "마이그레이션 항목 선택",
"settings": {
"allowMetrics": "충돌 보고서",
"allowMetricsDescription": "익명의 충돌 보고서를 보내 ComfyUI 개선 도움을 줍니다. 개인 정보나 워크플로 내용은 수집되지 않습니다. 이는 설정 메뉴에서 언제든지 비활성화할 수 있습니다.",
"allowMetrics": "사용 통계",
"allowMetricsDescription": "익명의 사용 통계를 보내 ComfyUI 개선하는 데 도움을 줍니다. 개인 정보나 워크플로 내용은 수집되지 않습니다.",
"autoUpdate": "자동 업데이트",
"autoUpdateDescription": "업데이트가 가능해지면 자동으로 다운로드하고 설치합니다. 업데이트가 설치되기 전에 항상 알림을 받습니다.",
"dataCollectionDialog": {
"customNodeConfigurations": "사용자 정의 노드 설정",
"errorReports": "오류 메시지 및 스택 추적",
"fileSystemInformation": "파일 시스템 정보",
"personalInformation": "개인 정보",
"systemInfo": "하드웨어, OS 유형 및 앱 버전",
"collect": {
"errorReports": "오류 메시지 및 스택 추적",
"systemInfo": "하드웨어, OS 유형, 앱 버전",
"userJourneyEvents": "사용자 여정 이벤트",
"userJourneyTooltip": "사용자 여정 이벤트는 앱 설치 과정을 통한 사용자의 여정을 추적하는 데 사용됩니다. 이벤트 수집은 첫 번째 성공적인 ComfyUI 워크플로우 실행에서 종료됩니다."
},
"doNotCollect": {
"customNodeConfigurations": "사용자 정의 노드 구성",
"fileSystemInformation": "파일 시스템 정보",
"personalInformation": "개인 정보",
"workflowContents": "워크플로우 내용"
},
"title": "데이터 수집 안내",
"whatWeCollect": "수집하는 정보:",
"whatWeDoNotCollect": "수집하지 않는 정보:",
"workflowContent": "워크플로 내용",
"workflowContents": "워크플로 내용"
"whatWeDoNotCollect": "수집하지 않는 정보:"
},
"learnMoreAboutData": "데이터 수집에 대해 더 알아보기"
},
"systemLocations": "시스템 위치",
"unhandledError": "알 수 없는 오류"
},
"issueReport": {
"contactFollowUp": "추적 조사를 위해 연락해 주세요",
"helpFix": "이 문제 해결에 도움을 주세요",
"notifyResolve": "해결되었을 때 알려주세요",
"provideAdditionalDetails": "추가 세부 사항 제공 (선택 사항)",
"provideEmail": "이메일을 알려주세요 (선택 사항)",
"stackTrace": "스택 추적",
"submitErrorReport": "오류 보고서 제출 (선택 사항)",
"systemStats": "시스템 통계"
},
"menu": {
"autoQueue": "자동 실행 큐",
"batchCount": "배치 수",
@@ -623,11 +639,13 @@
"workflows": "워크플로"
},
"tabMenu": {
"addToBookmarks": "북마크에 추가",
"closeOtherTabs": "다른 탭 닫기",
"closeTab": "탭 닫기",
"closeTabsToLeft": "왼쪽 탭 닫기",
"closeTabsToRight": "오른쪽 탭 닫기",
"duplicateTab": "탭 복제"
"duplicateTab": "탭 복제",
"removeFromBookmarks": "북마크에서 제거"
},
"templateWorkflows": {
"template": {

View File

@@ -3,7 +3,7 @@
"name": "자동 업데이트 확인"
},
"Comfy-Desktop_SendStatistics": {
"name": "익명으로 충돌 보고서 전송"
"name": "익명 사용 통계 보내기"
},
"Comfy-Desktop_WindowStyle": {
"name": "창 스타일",
@@ -265,8 +265,7 @@
"Bottom": "하단",
"Disabled": "비활성화",
"Top": "상단"
},
"tooltip": "(데스크톱, 윈도우 전용): 사용자 정의 창 스타일을 사용할 때는 Top만 지원됩니다"
}
},
"Comfy_Validation_NodeDefs": {
"name": "노드 정의 유효성 검사 (느림)",

View File

@@ -132,7 +132,8 @@
"systemInfo": "Информация о системе",
"terminal": "Терминал",
"upload": "Загрузить",
"videoFailedToLoad": "Не удалось загрузить видео"
"videoFailedToLoad": "Не удалось загрузить видео",
"workflow": "Рабочий процесс"
},
"graphCanvasMenu": {
"fitView": "Подгонять под выделенные",
@@ -205,27 +206,42 @@
"pathValidationFailed": "Не удалось проверить путь",
"selectItemsToMigrate": "Выберите элементы для миграции",
"settings": {
"allowMetrics": "Отчеты о сбоях",
"allowMetricsDescription": "Помогите улучшить ComfyUI, отправляя анонимные отчеты о сбоях. Личная информация или содержимое рабочего процесса не будут собираться. Это можно отключить в любое время в меню настроек.",
"allowMetrics": "Метрики использования",
"allowMetricsDescription": "Помогите улучшить ComfyUI, отправляя анонимные метрики использования. Личная информация или содержание рабочего процесса не будут собираться.",
"autoUpdate": "Автоматические обновления",
"autoUpdateDescription": "Автоматически загружать и устанавливать обновления, когда они становятся доступными. Вы всегда будете уведомлены перед установкой обновлений.",
"dataCollectionDialog": {
"customNodeConfigurations": "Конфигурации пользовательских узлов",
"errorReports": "Сообщения об ошибках и трассировка стека",
"fileSystemInformation": "Информация о файловой системе",
"personalInformation": "Личная информация",
"systemInfo": "Аппаратное обеспечение, тип ОС и версия приложения",
"collect": {
"errorReports": "Сообщение об ошибке и трассировка стека",
"systemInfo": "Аппаратное обеспечение, тип ОС и версия приложения",
"userJourneyEvents": "События пользовательского пути",
"userJourneyTooltip": "События пользовательского пути используются для отслеживания пути пользователя в процессе установки приложения. Сбор событий заканчивается после первого успешного запуска рабочего процесса ComfyUI."
},
"doNotCollect": {
"customNodeConfigurations": "Пользовательские конфигурации узлов",
"fileSystemInformation": "Информация о файловой системе",
"personalInformation": "Личная информация",
"workflowContents": "Содержание рабочего процесса"
},
"title": "О сборе данных",
"whatWeCollect": "Что мы собираем:",
"whatWeDoNotCollect": "Что мы не собираем:",
"workflowContent": "Содержимое рабочего процесса",
"workflowContents": "Содержимое рабочего процесса"
"whatWeDoNotCollect": "Что мы не собираем:"
},
"learnMoreAboutData": "Узнать больше о сборе данных"
},
"systemLocations": "Системные места",
"unhandledError": "Неизвестная ошибка"
},
"issueReport": {
"contactFollowUp": "Свяжитесь со мной для уточнения",
"helpFix": "Помочь исправить это",
"notifyResolve": "Уведомить меня, когда проблема будет решена",
"provideAdditionalDetails": "Предоставьте дополнительные сведения (необязательно)",
"provideEmail": "Укажите вашу электронную почту (необязательно)",
"stackTrace": "Трассировка стека",
"submitErrorReport": "Отправить отчет об ошибке (необязательно)",
"systemStats": "Статистика системы"
},
"menu": {
"autoQueue": "Автоочередь",
"batchCount": "Количество пакетов",
@@ -623,11 +639,13 @@
"workflows": "Рабочие процессы"
},
"tabMenu": {
"addToBookmarks": "Добавить в закладки",
"closeOtherTabs": "Закрыть другие вкладки",
"closeTab": "Закрыть вкладку",
"closeTabsToLeft": "Закрыть вкладки слева",
"closeTabsToRight": "Закрыть вкладки справа",
"duplicateTab": "Дублировать вкладку"
"duplicateTab": "Дублировать вкладку",
"removeFromBookmarks": "Удалить из закладок"
},
"templateWorkflows": {
"template": {

View File

@@ -3,7 +3,7 @@
"name": "Автоматически проверять обновления"
},
"Comfy-Desktop_SendStatistics": {
"name": "Отправлять анонимные отчеты о сбоях"
"name": "Отправлять анонимную статистику использования"
},
"Comfy-Desktop_WindowStyle": {
"name": "Стиль окна",
@@ -265,8 +265,7 @@
"Bottom": "Внизу",
"Disabled": "Отключено",
"Top": "Вверху"
},
"tooltip": "(Рабочий стол, только для Windows): При использовании пользовательского стиля окна поддерживается только верхняя часть"
}
},
"Comfy_Validation_NodeDefs": {
"name": "Проверка определений узлов (медленно)",

View File

@@ -132,7 +132,8 @@
"systemInfo": "系统信息",
"terminal": "终端",
"upload": "上传",
"videoFailedToLoad": "视频加载失败"
"videoFailedToLoad": "视频加载失败",
"workflow": "工作流"
},
"graphCanvasMenu": {
"fitView": "适应视图",
@@ -205,27 +206,42 @@
"pathValidationFailed": "路径验证失败",
"selectItemsToMigrate": "选择要迁移的项目",
"settings": {
"allowMetrics": "崩溃报告",
"allowMetricsDescription": "发送匿名崩溃报告帮助改ComfyUI。报告不会收集任何个人信息或工作流内容。您可以随时在设置菜单中禁用此功能。",
"allowMetrics": "使用情况指标",
"allowMetricsDescription": "通过发送匿名使用情况指标来帮助改ComfyUI。不会收集任何个人信息或工作流内容。",
"autoUpdate": "自动更新",
"autoUpdateDescription": "更新可用时自动更新。您将在安装更新之前收到通知。",
"dataCollectionDialog": {
"customNodeConfigurations": "自定义节点配置",
"errorReports": "错误信息和堆栈跟踪",
"fileSystemInformation": "文件系统信息",
"personalInformation": "个人信息",
"systemInfo": "硬件、操作系统类型和应用版本",
"collect": {
"errorReports": "错误报告和堆栈跟踪",
"systemInfo": "硬件,操作系统类型和应用版本",
"userJourneyEvents": "用户旅程事件",
"userJourneyTooltip": "用户旅程事件用于跟踪用户通过应用安装过程的旅程。事件收集在第一次成功运行ComfyUI工作流后结束。"
},
"doNotCollect": {
"customNodeConfigurations": "自定义节点配置",
"fileSystemInformation": "文件系统信息",
"personalInformation": "个人信息",
"workflowContents": "工作流内容"
},
"title": "关于数据收集",
"whatWeCollect": "我们收集的内容:",
"whatWeDoNotCollect": "我们不收集的内容:",
"workflowContent": "工作流内容",
"workflowContents": "工作流内容"
"whatWeDoNotCollect": "我们不收集的内容:"
},
"learnMoreAboutData": "了解更多关于数据收集的信息"
},
"systemLocations": "系统位置",
"unhandledError": "未知错误"
},
"issueReport": {
"contactFollowUp": "跟进联系我",
"helpFix": "帮助修复这个",
"notifyResolve": "解决时通知我",
"provideAdditionalDetails": "提供额外的详细信息(可选)",
"provideEmail": "提供您的电子邮件(可选)",
"stackTrace": "堆栈跟踪",
"submitErrorReport": "提交错误报告(可选)",
"systemStats": "系统状态"
},
"menu": {
"autoQueue": "自动执行",
"batchCount": "批次数量",
@@ -623,11 +639,13 @@
"workflows": "工作流"
},
"tabMenu": {
"addToBookmarks": "添加到书签",
"closeOtherTabs": "关闭其他标签",
"closeTab": "关闭标签",
"closeTabsToLeft": "关闭左侧标签",
"closeTabsToRight": "关闭右侧标签",
"duplicateTab": "复制标签"
"duplicateTab": "复制标签",
"removeFromBookmarks": "从书签中移除"
},
"templateWorkflows": {
"template": {

View File

@@ -3,7 +3,7 @@
"name": "自动检查更新"
},
"Comfy-Desktop_SendStatistics": {
"name": "发送匿名崩溃报告"
"name": "发送匿名使用情况统计"
},
"Comfy-Desktop_WindowStyle": {
"name": "窗口样式",
@@ -265,8 +265,7 @@
"Bottom": "底部",
"Disabled": "禁用",
"Top": "顶部"
},
"tooltip": "(仅限桌面Windows): 使用自定义窗口样式时,只支持顶部"
}
},
"Comfy_Validation_NodeDefs": {
"name": "校验节点定义(慢)",

View File

@@ -2,6 +2,7 @@
import '@comfyorg/litegraph/style.css'
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -24,6 +25,17 @@ const ComfyUIPreset = definePreset(Aura, {
const app = createApp(App)
const pinia = createPinia()
Sentry.init({
app,
dsn: __SENTRY_DSN__,
enabled: __SENTRY_ENABLED__,
release: __COMFYUI_FRONTEND_VERSION__,
integrations: [],
autoSessionTracking: false,
defaultIntegrations: false,
normalizeDepth: 8,
tracesSampleRate: 0
})
app.directive('tooltip', Tooltip)
app
.use(router)

View File

@@ -7,6 +7,7 @@ import type {
ExecutingWsMessage,
ExecutionCachedWsMessage,
ExecutionErrorWsMessage,
ExecutionInterruptedWsMessage,
ExecutionStartWsMessage,
ExecutionSuccessWsMessage,
ExtensionsResponse,
@@ -59,6 +60,7 @@ interface BackendApiCalls {
execution_start: ExecutionStartWsMessage
execution_success: ExecutionSuccessWsMessage
execution_error: ExecutionErrorWsMessage
execution_interrupted: ExecutionInterruptedWsMessage
execution_cached: ExecutionCachedWsMessage
logs: LogsWsMessage
/** Mr Blob Preview, I presume? */
@@ -355,6 +357,7 @@ export class ComfyApi extends EventTarget {
break
case 'execution_start':
case 'execution_error':
case 'execution_interrupted':
case 'execution_cached':
case 'execution_success':
case 'progress':

View File

@@ -411,223 +411,231 @@ export class ComfyUI {
}
})
this.menuContainer = $el('div.comfy-menu', { parent: containerElement }, [
$el(
'div.drag-handle.comfy-menu-header',
{
style: {
overflow: 'hidden',
position: 'relative',
width: '100%',
cursor: 'default'
}
},
[
$el('span.drag-handle'),
$el('span.comfy-menu-queue-size', { $: (q) => (this.queueSize = q) }),
$el('div.comfy-menu-actions', [
$el('button.comfy-settings-btn', {
textContent: '⚙️',
onclick: () => {
useDialogService().showSettingsDialog()
}
this.menuContainer = $el(
'div.comfy-menu.no-drag',
{ parent: containerElement },
[
$el(
'div.drag-handle.comfy-menu-header',
{
style: {
overflow: 'hidden',
position: 'relative',
width: '100%',
cursor: 'default'
}
},
[
$el('span.drag-handle'),
$el('span.comfy-menu-queue-size', {
$: (q) => (this.queueSize = q)
}),
$el('button.comfy-close-menu-btn', {
textContent: '\u00d7',
onclick: () => {
useWorkspaceStore().focusMode = true
$el('div.comfy-menu-actions', [
$el('button.comfy-settings-btn', {
textContent: '⚙️',
onclick: () => {
useDialogService().showSettingsDialog()
}
}),
$el('button.comfy-close-menu-btn', {
textContent: '\u00d7',
onclick: () => {
useWorkspaceStore().focusMode = true
}
})
])
]
),
$el('button.comfy-queue-btn', {
id: 'queue-button',
textContent: 'Queue Prompt',
onclick: () => app.queuePrompt(0, this.batchCount)
}),
$el('div', {}, [
$el('label', { innerHTML: 'Extra options' }, [
$el('input', {
type: 'checkbox',
onchange: (i) => {
document.getElementById('extraOptions').style.display = i
.srcElement.checked
? 'block'
: 'none'
this.batchCount = i.srcElement.checked
? Number.parseInt(
(
document.getElementById(
'batchCountInputRange'
) as HTMLInputElement
).value
)
: 1
;(
document.getElementById(
'autoQueueCheckbox'
) as HTMLInputElement
).checked = false
this.autoQueueEnabled = false
}
})
])
]
),
$el('button.comfy-queue-btn', {
id: 'queue-button',
textContent: 'Queue Prompt',
onclick: () => app.queuePrompt(0, this.batchCount)
}),
$el('div', {}, [
$el('label', { innerHTML: 'Extra options' }, [
$el('input', {
type: 'checkbox',
onchange: (i) => {
document.getElementById('extraOptions').style.display = i
.srcElement.checked
? 'block'
: 'none'
this.batchCount = i.srcElement.checked
? Number.parseInt(
(
document.getElementById(
'batchCountInputRange'
) as HTMLInputElement
).value
)
: 1
;(
document.getElementById('autoQueueCheckbox') as HTMLInputElement
).checked = false
this.autoQueueEnabled = false
}
})
])
]),
$el(
'div',
{ id: 'extraOptions', style: { width: '100%', display: 'none' } },
[
$el('div', [
$el('label', { innerHTML: 'Batch count' }),
$el('input', {
id: 'batchCountInputNumber',
type: 'number',
value: this.batchCount,
min: '1',
style: { width: '35%', marginLeft: '0.4em' },
oninput: (i) => {
this.batchCount = i.target.value
/* Even though an <input> element with a type of range logically represents a number (since
]),
$el(
'div',
{ id: 'extraOptions', style: { width: '100%', display: 'none' } },
[
$el('div', [
$el('label', { innerHTML: 'Batch count' }),
$el('input', {
id: 'batchCountInputNumber',
type: 'number',
value: this.batchCount,
min: '1',
style: { width: '35%', marginLeft: '0.4em' },
oninput: (i) => {
this.batchCount = i.target.value
/* Even though an <input> element with a type of range logically represents a number (since
it's used for numeric input), the value it holds is still treated as a string in HTML and
JavaScript. This behavior is consistent across all <input> elements regardless of their type
(like text, number, or range), where the .value property is always a string. */
;(
document.getElementById(
'batchCountInputRange'
) as HTMLInputElement
).value = this.batchCount.toString()
}
}),
$el('input', {
id: 'batchCountInputRange',
type: 'range',
min: '1',
max: '100',
value: this.batchCount,
oninput: (i) => {
this.batchCount = i.srcElement.value
// Note
;(
document.getElementById(
'batchCountInputNumber'
) as HTMLInputElement
).value = i.srcElement.value
}
})
]),
$el('div', [
$el('label', {
for: 'autoQueueCheckbox',
innerHTML: 'Auto Queue'
}),
$el('input', {
id: 'autoQueueCheckbox',
type: 'checkbox',
checked: false,
title: 'Automatically queue prompt when the queue size hits 0',
onchange: (e) => {
this.autoQueueEnabled = e.target.checked
autoQueueModeEl.style.display = this.autoQueueEnabled
? ''
: 'none'
}
}),
autoQueueModeEl
])
]
),
$el('div.comfy-menu-btns', [
;(
document.getElementById(
'batchCountInputRange'
) as HTMLInputElement
).value = this.batchCount.toString()
}
}),
$el('input', {
id: 'batchCountInputRange',
type: 'range',
min: '1',
max: '100',
value: this.batchCount,
oninput: (i) => {
this.batchCount = i.srcElement.value
// Note
;(
document.getElementById(
'batchCountInputNumber'
) as HTMLInputElement
).value = i.srcElement.value
}
})
]),
$el('div', [
$el('label', {
for: 'autoQueueCheckbox',
innerHTML: 'Auto Queue'
}),
$el('input', {
id: 'autoQueueCheckbox',
type: 'checkbox',
checked: false,
title: 'Automatically queue prompt when the queue size hits 0',
onchange: (e) => {
this.autoQueueEnabled = e.target.checked
autoQueueModeEl.style.display = this.autoQueueEnabled
? ''
: 'none'
}
}),
autoQueueModeEl
])
]
),
$el('div.comfy-menu-btns', [
$el('button', {
id: 'queue-front-button',
textContent: 'Queue Front',
onclick: () => app.queuePrompt(-1, this.batchCount)
}),
$el('button', {
$: (b) => (this.queue.button = b as HTMLButtonElement),
id: 'comfy-view-queue-button',
textContent: 'View Queue',
onclick: () => {
this.history.hide()
this.queue.toggle()
}
}),
$el('button', {
$: (b) => (this.history.button = b as HTMLButtonElement),
id: 'comfy-view-history-button',
textContent: 'View History',
onclick: () => {
this.queue.hide()
this.history.toggle()
}
})
]),
this.queue.element,
this.history.element,
$el('button', {
id: 'queue-front-button',
textContent: 'Queue Front',
onclick: () => app.queuePrompt(-1, this.batchCount)
}),
$el('button', {
$: (b) => (this.queue.button = b as HTMLButtonElement),
id: 'comfy-view-queue-button',
textContent: 'View Queue',
id: 'comfy-save-button',
textContent: 'Save',
onclick: () => {
this.history.hide()
this.queue.toggle()
useCommandStore().execute('Comfy.ExportWorkflow')
}
}),
$el('button', {
$: (b) => (this.history.button = b as HTMLButtonElement),
id: 'comfy-view-history-button',
textContent: 'View History',
id: 'comfy-dev-save-api-button',
textContent: 'Save (API Format)',
style: { width: '100%', display: 'none' },
onclick: () => {
this.queue.hide()
this.history.toggle()
useCommandStore().execute('Comfy.ExportWorkflowAPI')
}
}),
$el('button', {
id: 'comfy-load-button',
textContent: 'Load',
onclick: () => fileInput.click()
}),
$el('button', {
id: 'comfy-refresh-button',
textContent: 'Refresh',
onclick: () => app.refreshComboInNodes()
}),
$el('button', {
id: 'comfy-clipspace-button',
textContent: 'Clipspace',
onclick: () => app.openClipspace()
}),
$el('button', {
id: 'comfy-clear-button',
textContent: 'Clear',
onclick: () => {
if (
!useSettingStore().get('Comfy.ConfirmClear') ||
confirm('Clear workflow?')
) {
app.clean()
app.graph.clear()
app.resetView()
api.dispatchCustomEvent('graphCleared')
}
}
}),
$el('button', {
id: 'comfy-load-default-button',
textContent: 'Load Default',
onclick: async () => {
if (
!useSettingStore().get('Comfy.ConfirmClear') ||
confirm('Load default workflow?')
) {
app.resetView()
await app.loadGraphData()
}
}
}),
$el('button', {
id: 'comfy-reset-view-button',
textContent: 'Reset View',
onclick: async () => {
app.resetView()
}
})
]),
this.queue.element,
this.history.element,
$el('button', {
id: 'comfy-save-button',
textContent: 'Save',
onclick: () => {
useCommandStore().execute('Comfy.ExportWorkflow')
}
}),
$el('button', {
id: 'comfy-dev-save-api-button',
textContent: 'Save (API Format)',
style: { width: '100%', display: 'none' },
onclick: () => {
useCommandStore().execute('Comfy.ExportWorkflowAPI')
}
}),
$el('button', {
id: 'comfy-load-button',
textContent: 'Load',
onclick: () => fileInput.click()
}),
$el('button', {
id: 'comfy-refresh-button',
textContent: 'Refresh',
onclick: () => app.refreshComboInNodes()
}),
$el('button', {
id: 'comfy-clipspace-button',
textContent: 'Clipspace',
onclick: () => app.openClipspace()
}),
$el('button', {
id: 'comfy-clear-button',
textContent: 'Clear',
onclick: () => {
if (
!useSettingStore().get('Comfy.ConfirmClear') ||
confirm('Clear workflow?')
) {
app.clean()
app.graph.clear()
app.resetView()
api.dispatchCustomEvent('graphCleared')
}
}
}),
$el('button', {
id: 'comfy-load-default-button',
textContent: 'Load Default',
onclick: async () => {
if (
!useSettingStore().get('Comfy.ConfirmClear') ||
confirm('Load default workflow?')
) {
app.resetView()
await app.loadGraphData()
}
}
}),
$el('button', {
id: 'comfy-reset-view-button',
textContent: 'Reset View',
onclick: async () => {
app.resetView()
}
})
]) as HTMLDivElement
]
) as HTMLDivElement
// Hide by default on construction so it does not interfere with other views.
this.menuContainer.style.display = 'none'

View File

@@ -176,10 +176,6 @@ export const useWorkflowService = () => {
workflow: ComfyWorkflow,
options: { warnIfUnsaved: boolean } = { warnIfUnsaved: true }
): Promise<boolean> => {
if (!workflow.isLoaded) {
return true
}
if (workflow.isModified && options.warnIfUnsaved) {
const confirmed = await dialogService.confirm({
title: t('sideToolbar.workflowTab.dirtyCloseTitle'),

View File

@@ -305,9 +305,13 @@ export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDef> = {
}
}
export function buildNodeDefTree(nodeDefs: ComfyNodeDefImpl[]): TreeNode {
return buildTree(nodeDefs, (nodeDef: ComfyNodeDefImpl) =>
export function buildNodeDefTree(
nodeDefs: ComfyNodeDefImpl[],
keyFunction: (nodeDef: ComfyNodeDefImpl) => string[] = (nodeDef) =>
nodeDef.nodePath.split('/')
): TreeNode {
return buildTree(nodeDefs, (nodeDef: ComfyNodeDefImpl) =>
keyFunction(nodeDef)
)
}
@@ -326,12 +330,16 @@ export function createDummyFolderNodeDef(folderPath: string): ComfyNodeDefImpl {
} as ComfyNodeDef)
}
const getCategoryKeys = (nodeDef: ComfyNodeDefImpl) =>
nodeDef.nodePath.split('/')
export const useNodeDefStore = defineStore('nodeDef', () => {
const nodeDefsByName = ref<Record<string, ComfyNodeDefImpl>>({})
const nodeDefsByDisplayName = ref<Record<string, ComfyNodeDefImpl>>({})
const showDeprecated = ref(false)
const showExperimental = ref(false)
const keyFunction = ref(getCategoryKeys)
const nodeDefs = computed(() => Object.values(nodeDefsByName.value))
const nodeDataTypes = computed(() => {
const types = new Set<string>()
@@ -355,7 +363,13 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
const nodeSearchService = computed(
() => new NodeSearchService(visibleNodeDefs.value)
)
const nodeTree = computed(() => buildNodeDefTree(visibleNodeDefs.value))
const nodeTree = computed(() =>
buildNodeDefTree(visibleNodeDefs.value, keyFunction.value)
)
function setKeyFunction(callback: (nodeDef: ComfyNodeDefImpl) => string[]) {
keyFunction.value = callback ?? getCategoryKeys
}
function updateNodeDefs(nodeDefs: ComfyNodeDef[]) {
const newNodeDefsByName: Record<string, ComfyNodeDefImpl> = {}
@@ -398,7 +412,8 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
updateNodeDefs,
addNodeDef,
fromLGraphNode
fromLGraphNode,
setKeyFunction
}
})

View File

@@ -1,3 +1,4 @@
import _ from 'lodash'
import { defineStore } from 'pinia'
import { computed, markRaw, ref } from 'vue'
@@ -130,6 +131,10 @@ export interface WorkflowStore {
openWorkflows: LoadedComfyWorkflow[]
openedWorkflowIndexShift: (shift: number) => LoadedComfyWorkflow | null
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
openWorkflowsInBackground: (paths: {
left?: string[]
right?: string[]
}) => void
isOpen: (workflow: ComfyWorkflow) => boolean
isBusy: boolean
closeWorkflow: (workflow: ComfyWorkflow) => Promise<void>
@@ -213,6 +218,36 @@ export const useWorkflowStore = defineStore('workflow', () => {
const isOpen = (workflow: ComfyWorkflow) =>
openWorkflowPathSet.value.has(workflow.path)
/**
* Add paths to the list of open workflow paths without loading the files
* or changing the active workflow.
*
* @param paths - The workflows to open, specified as:
* - `left`: Workflows to be added to the left.
* - `right`: Workflows to be added to the right.
*
* Invalid paths (non-strings or paths not found in `workflowLookup.value`)
* will be ignored. Duplicate paths are automatically removed.
*/
const openWorkflowsInBackground = (paths: {
left?: string[]
right?: string[]
}) => {
const { left = [], right = [] } = paths
if (!left.length && !right.length) return
const isValidPath = (
path: unknown
): path is keyof typeof workflowLookup.value =>
typeof path === 'string' && path in workflowLookup.value
openWorkflowPaths.value = _.union(
left,
openWorkflowPaths.value,
right
).filter(isValidPath)
}
/**
* Set the workflow as the active workflow.
* @param workflow The workflow to open.
@@ -389,6 +424,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
openWorkflows,
openedWorkflowIndexShift,
openWorkflow,
openWorkflowsInBackground,
isOpen,
isBusy,
closeWorkflow,

View File

@@ -0,0 +1,24 @@
export type DefaultField = 'Workflow' | 'Logs' | 'SystemStats' | 'Settings'
export interface ReportField {
/**
* The label of the field, shown next to the checkbox if the field is opt-in.
*/
label: string
/**
* A unique identifier for the field, used internally as the key for this field's value.
*/
value: string
/**
* The data associated with this field, sent as part of the report.
*/
data: Record<string, unknown>
/**
* Indicates whether the field requires explicit opt-in from the user
* before its data is included in the report.
*/
optIn: boolean
}

View File

@@ -1,17 +1,16 @@
import { ElectronAPI } from '@comfyorg/comfyui-electron-types'
export type InstallOptions = Parameters<ElectronAPI['installComfyUI']>[0]
export type TorchDeviceType = InstallOptions['device']
import {
ElectronAPI,
ElectronContextMenuOptions
} from '@comfyorg/comfyui-electron-types'
export function isElectron() {
return 'electronAPI' in window && window['electronAPI'] !== undefined
return 'electronAPI' in window && window.electronAPI !== undefined
}
export function electronAPI() {
return (window as any)['electronAPI'] as ElectronAPI
return (window as any).electronAPI as ElectronAPI
}
type NativeContextOptions = Parameters<ElectronAPI['showContextMenu']>[0]
export function showNativeMenu(options?: NativeContextOptions) {
export function showNativeMenu(options?: ElectronContextMenuOptions) {
electronAPI()?.showContextMenu(options)
}

View File

@@ -1,7 +1,7 @@
<template>
<BaseViewTemplate class="app-drag">
<BaseViewTemplate>
<div
class="no-drag max-w-screen-sm flex flex-col gap-8 p-8 bg-[url('/assets/images/Git-Logo-White.svg')] bg-no-repeat bg-right-top bg-origin-padding"
class="max-w-screen-sm flex flex-col gap-8 p-8 bg-[url('/assets/images/Git-Logo-White.svg')] bg-no-repeat bg-right-top bg-origin-padding"
>
<!-- Header -->
<h1 class="mt-24 text-4xl font-bold text-red-500">

View File

@@ -66,23 +66,11 @@ watch(
document.body.classList.add(DARK_THEME_CLASS)
}
// Native window control theme
if (isElectron()) {
const cssVars = newTheme.colors.comfy_base
// Allow OS to set matching hover colour
const color = setZeroAlpha(cssVars['comfy-menu-bg'])
const symbolColor = cssVars['input-text']
electronAPI().changeTheme({ color, symbolColor })
}
function setZeroAlpha(color: string) {
if (!color.startsWith('#')) return color
if (color.length === 4) {
const [_, r, g, b] = color
return `#${r}${r}${g}${g}${b}${b}00`
}
return `#${color.substring(1, 7)}00`
electronAPI().changeTheme({
color: 'rgba(0, 0, 0, 0)',
symbolColor: newTheme.colors.comfy_base['input-text']
})
}
},
{ immediate: true }

View File

@@ -1,10 +1,10 @@
<template>
<BaseViewTemplate dark class="app-drag">
<BaseViewTemplate dark>
<!-- h-full to make sure the stepper does not layout shift between steps
as for each step the stepper height is different. Inherit the center element
placement from BaseViewTemplate would cause layout shift. -->
<Stepper
class="no-drag h-full p-8 2xl:p-16"
class="h-full p-8 2xl:p-16"
value="0"
@update:value="setHighestStep"
>
@@ -103,6 +103,10 @@
</template>
<script setup lang="ts">
import type {
InstallOptions,
TorchDeviceType
} from '@comfyorg/comfyui-electron-types'
import Button from 'primevue/button'
import Step from 'primevue/step'
import StepList from 'primevue/steplist'
@@ -116,11 +120,7 @@ import DesktopSettingsConfiguration from '@/components/install/DesktopSettingsCo
import GpuPicker from '@/components/install/GpuPicker.vue'
import InstallLocationPicker from '@/components/install/InstallLocationPicker.vue'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import {
type InstallOptions,
type TorchDeviceType,
electronAPI
} from '@/utils/envUtil'
import { electronAPI } from '@/utils/envUtil'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const device = ref<TorchDeviceType>(null)

View File

@@ -1,8 +1,8 @@
<template>
<BaseViewTemplate dark class="app-drag">
<BaseViewTemplate dark>
<!-- Installation Path Section -->
<div
class="no-drag comfy-installer grow flex flex-col gap-4 text-neutral-300 max-w-110"
class="comfy-installer grow flex flex-col gap-4 text-neutral-300 max-w-110"
>
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.manualConfiguration.title') }}

View File

@@ -1,6 +1,6 @@
<template>
<BaseViewTemplate class="app-drag">
<div class="no-drag sad-container">
<BaseViewTemplate>
<div class="sad-container">
<!-- Right side image -->
<img
class="sad-girl"

View File

@@ -1,47 +1,45 @@
<template>
<BaseViewTemplate dark class="app-drag flex-col">
<h2 class="text-2xl font-bold">
{{ t(`serverStart.process.${status}`) }}
<span v-if="status === ProgressStatus.ERROR">
v{{ electronVersion }}
</span>
</h2>
<div
v-if="status === ProgressStatus.ERROR"
class="no-drag flex flex-col items-center gap-4"
>
<div class="flex items-center my-4 gap-2">
<BaseViewTemplate dark class="flex-col">
<div class="flex flex-col w-full h-full items-center">
<h2 class="text-2xl font-bold">
{{ t(`serverStart.process.${status}`) }}
<span v-if="status === ProgressStatus.ERROR">
v{{ electronVersion }}
</span>
</h2>
<div
v-if="status === ProgressStatus.ERROR"
class="flex flex-col items-center gap-4"
>
<div class="flex items-center my-4 gap-2">
<Button
icon="pi pi-flag"
severity="secondary"
:label="t('serverStart.reportIssue')"
@click="reportIssue"
/>
<Button
icon="pi pi-file"
severity="secondary"
:label="t('serverStart.openLogs')"
@click="openLogs"
/>
<Button
icon="pi pi-refresh"
:label="t('serverStart.reinstall')"
@click="reinstall"
/>
</div>
<Button
icon="pi pi-flag"
v-if="!terminalVisible"
icon="pi pi-search"
severity="secondary"
:label="t('serverStart.reportIssue')"
@click="reportIssue"
/>
<Button
icon="pi pi-file"
severity="secondary"
:label="t('serverStart.openLogs')"
@click="openLogs"
/>
<Button
icon="pi pi-refresh"
:label="t('serverStart.reinstall')"
@click="reinstall"
:label="t('serverStart.showTerminal')"
@click="terminalVisible = true"
/>
</div>
<Button
v-if="!terminalVisible"
icon="pi pi-search"
severity="secondary"
:label="t('serverStart.showTerminal')"
@click="terminalVisible = true"
/>
<BaseTerminal v-show="terminalVisible" @created="terminalCreated" />
</div>
<BaseTerminal
v-show="terminalVisible"
class="no-drag"
@created="terminalCreated"
/>
</BaseViewTemplate>
</template>

View File

@@ -1,8 +1,8 @@
<template>
<BaseViewTemplate dark class="app-drag">
<BaseViewTemplate dark>
<main
id="comfy-user-selection"
class="no-drag min-w-84 relative rounded-lg bg-[var(--comfy-menu-bg)] p-5 px-10 shadow-lg"
class="min-w-84 relative rounded-lg bg-[var(--comfy-menu-bg)] p-5 px-10 shadow-lg"
>
<h1 class="my-2.5 mb-7 font-normal">ComfyUI</h1>
<div class="flex w-full flex-col items-center">

View File

@@ -1,6 +1,6 @@
<template>
<BaseViewTemplate dark class="app-drag">
<div class="no-drag flex flex-col items-center justify-center gap-8 p-8">
<BaseViewTemplate dark>
<div class="flex flex-col items-center justify-center gap-8 p-8">
<!-- Header -->
<h1 class="animated-gradient-text text-glow select-none">
{{ $t('welcome.title') }}

View File

@@ -1,18 +1,28 @@
<template>
<div
class="font-sans w-screen h-screen flex items-center justify-center pointer-events-auto overflow-auto"
class="font-sans w-screen h-screen flex flex-col pointer-events-auto"
:class="[
props.dark
? 'text-neutral-300 bg-neutral-900 dark-theme'
: 'text-neutral-900 bg-neutral-300'
]"
>
<slot></slot>
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow"
ref="topMenuRef"
class="app-drag w-full h-[var(--comfy-topbar-height)]"
/>
<div
class="flex-grow w-full flex items-center justify-center overflow-auto"
>
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { nextTick, onMounted, ref } from 'vue'
import { electronAPI, isElectron } from '@/utils/envUtil'
@@ -26,18 +36,28 @@ const props = withDefaults(
)
const darkTheme = {
color: 'rgba(23, 23, 23, 0)',
color: 'rgba(0, 0, 0, 0)',
symbolColor: '#d4d4d4'
}
const lightTheme = {
color: 'rgba(212, 212, 212, 0)',
color: 'rgba(0, 0, 0, 0)',
symbolColor: '#171717'
}
onMounted(() => {
const topMenuRef = ref<HTMLDivElement | null>(null)
const isNativeWindow = ref(false)
onMounted(async () => {
if (isElectron()) {
electronAPI().changeTheme(props.dark ? darkTheme : lightTheme)
const windowStyle = await electronAPI().Config.getWindowStyle()
isNativeWindow.value = windowStyle === 'custom'
await nextTick()
electronAPI().changeTheme({
...(props.dark ? darkTheme : lightTheme),
height: topMenuRef.value.getBoundingClientRect().height
})
}
})
</script>

View File

@@ -3,6 +3,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { api } from '@/scripts/api'
import { defaultGraph, defaultGraphJSON } from '@/scripts/defaultGraph'
import {
ComfyWorkflow,
LoadedComfyWorkflow,
useWorkflowBookmarkStore,
useWorkflowStore
@@ -161,6 +162,97 @@ describe('useWorkflowStore', () => {
})
})
describe('openWorkflowsInBackground', () => {
let workflowA: ComfyWorkflow
let workflowB: ComfyWorkflow
let workflowC: ComfyWorkflow
const openWorkflowPaths = () =>
store.openWorkflows.filter((w) => store.isOpen(w)).map((w) => w.path)
beforeEach(async () => {
await syncRemoteWorkflows(['a.json', 'b.json', 'c.json'])
workflowA = store.getWorkflowByPath('workflows/a.json')!
workflowB = store.getWorkflowByPath('workflows/b.json')!
workflowC = store.getWorkflowByPath('workflows/c.json')!
;(api.getUserData as jest.Mock).mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
})
})
it('should open workflows adjacent to the active workflow', async () => {
await store.openWorkflow(workflowA)
store.openWorkflowsInBackground({
left: [workflowB.path],
right: [workflowC.path]
})
expect(openWorkflowPaths()).toEqual([
workflowB.path,
workflowA.path,
workflowC.path
])
})
it('should not change the active workflow', async () => {
await store.openWorkflow(workflowA)
store.openWorkflowsInBackground({
left: [workflowC.path],
right: [workflowB.path]
})
expect(store.activeWorkflow).not.toBeUndefined()
expect(store.activeWorkflow!.path).toBe(workflowA.path)
})
it('should open workflows when none are active', async () => {
expect(store.openWorkflows.length).toBe(0)
store.openWorkflowsInBackground({
left: [workflowA.path],
right: [workflowB.path]
})
expect(openWorkflowPaths()).toEqual([workflowA.path, workflowB.path])
})
it('should not open duplicate workflows', async () => {
store.openWorkflowsInBackground({
left: [workflowA.path, workflowB.path, workflowA.path],
right: [workflowB.path, workflowA.path, workflowB.path]
})
expect(openWorkflowPaths()).toEqual([workflowA.path, workflowB.path])
})
it('should not open workflow that is already open', async () => {
await store.openWorkflow(workflowA)
store.openWorkflowsInBackground({
left: [workflowA.path]
})
expect(openWorkflowPaths()).toEqual([workflowA.path])
expect(store.activeWorkflow?.path).toBe(workflowA.path)
})
it('should ignore invalid or deleted workflow paths', async () => {
await store.openWorkflow(workflowA)
store.openWorkflowsInBackground({
left: ['workflows/invalid::$-path.json'],
right: ['workflows/deleted-since-last-session.json']
})
expect(openWorkflowPaths()).toEqual([workflowA.path])
expect(store.activeWorkflow?.path).toBe(workflowA.path)
})
it('should do nothing when given an empty argument', async () => {
await store.openWorkflow(workflowA)
store.openWorkflowsInBackground({})
expect(openWorkflowPaths()).toEqual([workflowA.path])
store.openWorkflowsInBackground({ left: [], right: [] })
expect(openWorkflowPaths()).toEqual([workflowA.path])
expect(store.activeWorkflow?.path).toBe(workflowA.path)
})
})
describe('renameWorkflow', () => {
it('should rename workflow and update bookmarks', async () => {
const workflow = store.createTemporary('dir/test.json')

View File

@@ -178,7 +178,11 @@ export default defineConfig({
define: {
__COMFYUI_FRONTEND_VERSION__: JSON.stringify(
process.env.npm_package_version
)
),
__SENTRY_ENABLED__: JSON.stringify(
!(process.env.NODE_ENV === 'development' || !process.env.SENTRY_DSN)
),
__SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN || '')
},
resolve: {