Merge branch 'main' into feat/survey-eligibility

This commit is contained in:
Christian Byrne
2026-01-23 18:48:21 -08:00
committed by GitHub
248 changed files with 15168 additions and 7260 deletions

View File

@@ -9,6 +9,7 @@
</template>
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import { useEventListener } from '@vueuse/core'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
@@ -16,10 +17,6 @@ import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { t } from '@/i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
@@ -27,8 +24,6 @@ import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const conflictDetection = useConflictDetection()
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
const handleKey = (e: KeyboardEvent) => {
workspaceStore.shiftDown = e.shiftKey
@@ -54,23 +49,15 @@ onMounted(() => {
document.addEventListener('contextmenu', showContextMenu)
}
// Handle Vite preload errors (e.g., when assets are deleted after deployment)
window.addEventListener('vite:preloadError', async (_event) => {
// Auto-reload if app is not ready or there are no unsaved changes
if (!app.vueAppReady || !workflowStore.activeWorkflow?.isModified) {
window.location.reload()
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
// eslint-disable-next-line no-undef
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }
})
} else {
// Show confirmation dialog if there are unsaved changes
await dialogService
.confirm({
title: t('g.vitePreloadErrorTitle'),
message: t('g.vitePreloadErrorMessage')
})
.then((confirmed) => {
if (confirmed) {
window.location.reload()
}
})
console.error('[vite:preloadError]', event.payload)
}
})

View File

@@ -14,6 +14,7 @@ interface IdleDeadline {
interface IDisposable {
dispose(): void
}
type GlobalWindow = typeof globalThis
/**
* Internal implementation function that handles the actual scheduling logic.
@@ -21,7 +22,7 @@ interface IDisposable {
* or fall back to setTimeout-based implementation.
*/
let _runWhenIdle: (
targetWindow: any,
targetWindow: GlobalWindow,
callback: (idle: IdleDeadline) => void,
timeout?: number
) => IDisposable
@@ -37,7 +38,7 @@ export let runWhenGlobalIdle: (
// Self-invoking function to set up the idle callback implementation
;(function () {
const safeGlobal: any = globalThis
const safeGlobal: GlobalWindow = globalThis as GlobalWindow
if (
typeof safeGlobal.requestIdleCallback !== 'function' ||

View File

@@ -1,12 +1,21 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import type { MenuItem } from 'primevue/menuitem'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { computed, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import TopMenuSection from '@/components/TopMenuSection.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isElectron } from '@/utils/envUtil'
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
@@ -27,7 +36,7 @@ vi.mock('@/stores/firebaseAuthStore', () => ({
}))
}))
function createWrapper() {
function createWrapper(pinia = createTestingPinia({ createSpy: vi.fn })) {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -36,7 +45,9 @@ function createWrapper() {
sideToolbar: {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue'
expandCollapsedQueue: 'Expand collapsed queue',
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
}
}
@@ -45,12 +56,17 @@ function createWrapper() {
return mount(TopMenuSection, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
plugins: [pinia, i18n],
stubs: {
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
CurrentUserButton: true,
LoginButton: true
LoginButton: true,
ContextMenu: {
name: 'ContextMenu',
props: ['model'],
template: '<div />'
}
},
directives: {
tooltip: () => {}
@@ -59,6 +75,19 @@ function createWrapper() {
})
}
function createJob(id: string, status: JobStatus): JobListItem {
return {
id,
status,
create_time: 0,
priority: 0
}
}
function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(createJob(id, status))
}
describe('TopMenuSection', () => {
beforeEach(() => {
vi.resetAllMocks()
@@ -100,4 +129,104 @@ describe('TopMenuSection', () => {
})
})
})
it('shows the active jobs label with the current count', async () => {
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
queueStore.runningTasks = [
createTask('running-1', 'in_progress'),
createTask('running-2', 'in_progress')
]
await nextTick()
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
expect(queueButton.text()).toContain('3 active')
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
await nextTick()
expect(wrapper.find('[data-testid="queue-overlay-toggle"]').exists()).toBe(
true
)
expect(
wrapper.findComponent({ name: 'QueueProgressOverlay' }).exists()
).toBe(false)
})
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? false : undefined
)
const wrapper = createWrapper(pinia)
const commandStore = useCommandStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(commandStore.execute).toHaveBeenCalledWith(
'Comfy.Queue.ToggleOverlay'
)
})
it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
})
it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Queue.QPOV2' ? true : undefined
)
const wrapper = createWrapper(pinia)
const sidebarTabStore = useSidebarTabStore(pinia)
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
await toggleButton.trigger('click')
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
})
it('disables the clear queue context menu item when no queued jobs exist', () => {
const wrapper = createWrapper()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.label).toBe('Clear queue')
expect(model[0]?.disabled).toBe(true)
})
it('enables the clear queue context menu item when queued jobs exist', async () => {
const wrapper = createWrapper()
const queueStore = useQueueStore()
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
await nextTick()
const menu = wrapper.findComponent({ name: 'ContextMenu' })
const model = menu.props('model') as MenuItem[]
expect(model[0]?.disabled).toBe(false)
})
})

View File

@@ -44,21 +44,31 @@
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="icon"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'assets'
: isQueueProgressOverlayEnabled
? isQueueOverlayExpanded
: undefined
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<i class="icon-[lucide--history] size-4" />
<span
v-if="queuedCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground"
>
{{ queuedCount }}
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
@@ -77,6 +87,7 @@
</div>
</div>
<QueueProgressOverlay
v-if="isQueueProgressOverlayEnabled"
v-model:expanded="isQueueOverlayExpanded"
:menu-hovered="isTopMenuHovered"
/>
@@ -86,6 +97,8 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -103,8 +116,10 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
@@ -117,27 +132,56 @@ const rightSidePanelStore = useRightSidePanelStore()
const managerState = useManagerState()
const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const { t, n } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const queueUIStore = useQueueUIStore()
const sidebarTabStore = useSidebarTabStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
const isTopMenuHovered = ref(false)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobsShort',
{ count: n(count) },
count
)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isQueueProgressOverlayEnabled = computed(
() => !isQueuePanelV2Enabled.value
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.viewJobHistory'))
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.customNodesManager'))
)
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
const queueContextMenuItems = computed<MenuItem[]>(() => [
{
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
icon: 'icon-[lucide--list-x] text-destructive-background',
class: '*:text-destructive-background',
disabled: queueStore.pendingTasks.length === 0,
command: () => {
void handleClearQueue()
}
}
])
// Use either release red dot or conflict red dot
const shouldShowRedDot = computed((): boolean => {
@@ -161,9 +205,26 @@ onMounted(() => {
})
const toggleQueueOverlay = () => {
if (isQueuePanelV2Enabled.value) {
sidebarTabStore.toggleSidebarTab('assets')
return
}
commandStore.execute('Comfy.Queue.ToggleOverlay')
}
const showQueueContextMenu = (event: MouseEvent) => {
queueContextMenu.value?.show(event)
}
const handleClearQueue = async () => {
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByPromptIds(pendingPromptIds)
}
const openCustomNodeManager = async () => {
try {
await managerState.openManager({

View File

@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import type { Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -151,8 +152,8 @@ describe('BaseTerminal', () => {
// Trigger the selection change callback that was registered during mount
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
// Access the mock calls - TypeScript can't infer the mock structure dynamically
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
.calls[0][0]
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
const selectionCallback = mockCalls[0][0] as () => void
selectionCallback()
await nextTick()

View File

@@ -51,7 +51,7 @@ describe('EditableText', () => {
isEditing: true
})
await wrapper.findComponent(InputText).setValue('New Text')
await wrapper.findComponent(InputText).trigger('keyup.enter')
await wrapper.findComponent(InputText).trigger('keydown.enter')
// Blur event should have been triggered
expect(wrapper.findComponent(InputText).element).not.toBe(
document.activeElement
@@ -79,7 +79,7 @@ describe('EditableText', () => {
await wrapper.findComponent(InputText).setValue('Modified Text')
// Press escape
await wrapper.findComponent(InputText).trigger('keyup.escape')
await wrapper.findComponent(InputText).trigger('keydown.escape')
// Should emit cancel event
expect(wrapper.emitted('cancel')).toBeTruthy()
@@ -103,7 +103,7 @@ describe('EditableText', () => {
await wrapper.findComponent(InputText).setValue('Modified Text')
// Press escape (which triggers blur internally)
await wrapper.findComponent(InputText).trigger('keyup.escape')
await wrapper.findComponent(InputText).trigger('keydown.escape')
// Manually trigger blur to simulate the blur that happens after escape
await wrapper.findComponent(InputText).trigger('blur')
@@ -120,7 +120,7 @@ describe('EditableText', () => {
isEditing: true
})
await enterWrapper.findComponent(InputText).setValue('Saved Text')
await enterWrapper.findComponent(InputText).trigger('keyup.enter')
await enterWrapper.findComponent(InputText).trigger('keydown.enter')
// Trigger blur that happens after enter
await enterWrapper.findComponent(InputText).trigger('blur')
expect(enterWrapper.emitted('edit')).toBeTruthy()
@@ -133,7 +133,7 @@ describe('EditableText', () => {
isEditing: true
})
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
await escapeWrapper.findComponent(InputText).trigger('keyup.escape')
await escapeWrapper.findComponent(InputText).trigger('keydown.escape')
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
expect(escapeWrapper.emitted('edit')).toBeFalsy()
})

View File

@@ -3,7 +3,7 @@
<span v-if="!isEditing">
{{ modelValue }}
</span>
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
<InputText
v-else
ref="inputRef"
@@ -18,8 +18,8 @@
...inputAttrs
}
}"
@keyup.enter.capture.stop="blurInputElement"
@keyup.escape.stop="cancelEditing"
@keydown.enter.capture.stop="blurInputElement"
@keydown.escape.capture.stop="cancelEditing"
@click.stop
@contextmenu.stop
@pointerdown.stop.capture

View File

@@ -7,6 +7,7 @@ import { createApp } from 'vue'
import type { SettingOption } from '@/platform/settings/types'
import FormRadioGroup from './FormRadioGroup.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('FormRadioGroup', () => {
beforeAll(() => {
@@ -14,7 +15,8 @@ describe('FormRadioGroup', () => {
app.use(PrimeVue)
})
const mountComponent = (props: any, options = {}) => {
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
return mount(FormRadioGroup, {
global: {
plugins: [PrimeVue],
@@ -92,9 +94,9 @@ describe('FormRadioGroup', () => {
it('handles custom object with optionLabel and optionValue', () => {
const options = [
{ name: 'First Option', id: 1 },
{ name: 'Second Option', id: 2 },
{ name: 'Third Option', id: 3 }
{ name: 'First Option', id: '1' },
{ name: 'Second Option', id: '2' },
{ name: 'Third Option', id: '3' }
]
const wrapper = mountComponent({
@@ -108,9 +110,9 @@ describe('FormRadioGroup', () => {
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(3)
expect(radioButtons[0].props('value')).toBe(1)
expect(radioButtons[1].props('value')).toBe(2)
expect(radioButtons[2].props('value')).toBe(3)
expect(radioButtons[0].props('value')).toBe('1')
expect(radioButtons[1].props('value')).toBe('2')
expect(radioButtons[2].props('value')).toBe('3')
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('First Option')
@@ -167,10 +169,7 @@ describe('FormRadioGroup', () => {
})
it('handles object with missing properties gracefully', () => {
const options = [
{ label: 'Option 1', val: 'opt1' },
{ text: 'Option 2', value: 'opt2' }
]
const options = [{ label: 'Option 1', val: 'opt1' }]
const wrapper = mountComponent({
modelValue: 'opt1',
@@ -179,11 +178,10 @@ describe('FormRadioGroup', () => {
})
const radioButtons = wrapper.findAllComponents(RadioButton)
expect(radioButtons).toHaveLength(2)
expect(radioButtons).toHaveLength(1)
const labels = wrapper.findAll('label')
expect(labels[0].text()).toBe('Unknown')
expect(labels[1].text()).toBe('Option 2')
})
})

View File

@@ -28,7 +28,7 @@ import type { SettingOption } from '@/platform/settings/types'
const props = defineProps<{
modelValue: any
options: (SettingOption | string)[]
options?: (string | SettingOption | Record<string, string>)[]
optionLabel?: string
optionValue?: string
id?: string

View File

@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StatusBadge from './StatusBadge.vue'
const meta = {
title: 'Common/StatusBadge',
component: StatusBadge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'Status',
severity: 'default'
}
} satisfies Meta<typeof StatusBadge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Failed: Story = {
args: {
label: 'Failed',
severity: 'danger'
}
}
export const Finished: Story = {
args: {
label: 'Finished',
severity: 'contrast'
}
}
export const Dot: Story = {
args: {
label: undefined,
variant: 'dot',
severity: 'danger'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeverities: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-2">
<StatusBadge label="Default" severity="default" />
<StatusBadge label="Secondary" severity="secondary" />
<StatusBadge label="Warn" severity="warn" />
<StatusBadge label="Danger" severity="danger" />
<StatusBadge label="Contrast" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<StatusBadge label="Label" variant="label" />
<span class="text-xs text-muted">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge variant="dot" severity="danger" />
<span class="text-xs text-muted">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge label="5" variant="circle" />
<span class="text-xs text-muted">circle</span>
</div>
</div>
`
})
}

View File

@@ -1,30 +1,27 @@
<script setup lang="ts">
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
import { statusBadgeVariants } from './statusBadge.variants'
import type { StatusBadgeVariants } from './statusBadge.variants'
const { label, severity = 'default' } = defineProps<{
label: string
severity?: Severity
const {
label,
severity = 'default',
variant
} = defineProps<{
label?: string | number
severity?: StatusBadgeVariants['severity']
variant?: StatusBadgeVariants['variant']
}>()
function badgeClasses(sev: Severity): string {
const baseClasses =
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
switch (sev) {
case 'danger':
return `${baseClasses} bg-destructive-background text-white`
case 'contrast':
return `${baseClasses} bg-base-foreground text-base-background`
case 'warn':
return `${baseClasses} bg-warning-background text-base-background`
case 'secondary':
return `${baseClasses} bg-secondary-background text-base-foreground`
default:
return `${baseClasses} bg-primary-background text-base-foreground`
}
}
</script>
<template>
<span :class="badgeClasses(severity)">{{ label }}</span>
<span
:class="
statusBadgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
})
"
>
{{ label }}
</span>
</template>

View File

@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import UrlInput from './UrlInput.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
describe('UrlInput', () => {
beforeEach(() => {
@@ -14,7 +15,13 @@ describe('UrlInput', () => {
app.use(PrimeVue)
})
const mountComponent = (props: any, options = {}) => {
const mountComponent = (
props: ComponentProps<typeof UrlInput> & {
placeholder?: string
disabled?: boolean
},
options = {}
) => {
return mount(UrlInput, {
global: {
plugins: [PrimeVue],
@@ -169,25 +176,25 @@ describe('UrlInput', () => {
await input.setValue(' https://leading-space.com')
await input.trigger('input')
await nextTick()
expect(wrapper.vm.internalValue).toBe('https://leading-space.com')
expect(input.element.value).toBe('https://leading-space.com')
// Test trailing whitespace
await input.setValue('https://trailing-space.com ')
await input.trigger('input')
await nextTick()
expect(wrapper.vm.internalValue).toBe('https://trailing-space.com')
expect(input.element.value).toBe('https://trailing-space.com')
// Test both leading and trailing whitespace
await input.setValue(' https://both-spaces.com ')
await input.trigger('input')
await nextTick()
expect(wrapper.vm.internalValue).toBe('https://both-spaces.com')
expect(input.element.value).toBe('https://both-spaces.com')
// Test whitespace in the middle of the URL
await input.setValue('https:// middle-space.com')
await input.trigger('input')
await nextTick()
expect(wrapper.vm.internalValue).toBe('https://middle-space.com')
expect(input.element.value).toBe('https://middle-space.com')
})
it('trims whitespace when value set externally', async () => {
@@ -196,15 +203,17 @@ describe('UrlInput', () => {
placeholder: 'Enter URL'
})
const input = wrapper.find('input')
// Check initial value is trimmed
expect(wrapper.vm.internalValue).toBe('https://initial-value.com')
expect(input.element.value).toBe('https://initial-value.com')
// Update props with whitespace
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
await nextTick()
// Check updated value is trimmed
expect(wrapper.vm.internalValue).toBe('https://updated-value.com')
expect(input.element.value).toBe('https://updated-value.com')
})
})
})

View File

@@ -1,3 +1,5 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { mount } from '@vue/test-utils'
import Avatar from 'primevue/avatar'
import PrimeVue from 'primevue/config'
@@ -27,7 +29,7 @@ describe('UserAvatar', () => {
app.use(PrimeVue)
})
const mountComponent = (props: any = {}) => {
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
return mount(UserAvatar, {
global: {
plugins: [PrimeVue, i18n],

View File

@@ -1,16 +1,20 @@
<template>
<div ref="container" class="scroll-container">
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
<div :style="gridStyle">
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
<div
ref="container"
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
>
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">
<div
v-for="item in renderedItems"
:key="item.key"
class="transition-[width] duration-150 ease-out"
data-virtual-grid-item
>
<slot name="item" :item="item" />
</div>
</div>
<div
:style="{
height: `${((items.length - state.end) / cols) * itemHeight}px`
}"
/>
<div :style="bottomSpacerStyle" />
</div>
</template>
@@ -28,19 +32,22 @@ type GridState = {
const {
items,
gridStyle,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
defaultItemWidth = 200
defaultItemWidth = 200,
maxColumns = Infinity
} = defineProps<{
items: (T & { key: string })[]
gridStyle: Partial<CSSProperties>
gridStyle: CSSProperties
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemHeight?: number
defaultItemWidth?: number
maxColumns?: number
}>()
const emit = defineEmits<{
@@ -59,7 +66,18 @@ const { y: scrollY } = useScroll(container, {
eventListenerOptions: { passive: true }
})
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
const cols = computed(() =>
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
)
const mergedGridStyle = computed<CSSProperties>(() => {
if (maxColumns === Infinity) return gridStyle
return {
...gridStyle,
gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))`
}
})
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
const isValidGrid = computed(() => height.value && width.value && items?.length)
@@ -83,6 +101,16 @@ const renderedItems = computed(() =>
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
)
function rowsToHeight(rows: number): string {
return `${(rows / cols.value) * itemHeight.value}px`
}
const topSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(state.value.start)
}))
const bottomSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(items.length - state.value.end)
}))
whenever(
() => state.value.isNearEnd,
() => {
@@ -109,15 +137,6 @@ const onResize = debounce(updateItemSize, resizeDebounce)
watch([width, height], onResize, { flush: 'post' })
whenever(() => items, updateItemSize, { flush: 'post' })
onBeforeUnmount(() => {
onResize.cancel() // Clear pending debounced calls
onResize.cancel()
})
</script>
<style scoped>
.scroll-container {
height: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--dialog-surface) transparent;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div
class="flex size-6 items-center justify-center rounded-md text-base font-semibold text-white"
:style="{
background: gradient,
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
}"
>
{{ letter }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { workspaceName } = defineProps<{
workspaceName: string
}>()
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
const gradient = computed(() => {
const seed = letter.value.charCodeAt(0)
function mulberry32(a: number) {
return function () {
let t = (a += 0x6d2b79f5)
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const rand = mulberry32(seed)
const hue1 = Math.floor(rand() * 360)
const hue2 = (hue1 + 40 + Math.floor(rand() * 80)) % 360
const sat = 65 + Math.floor(rand() * 20)
const light = 55 + Math.floor(rand() * 15)
return `linear-gradient(135deg, hsl(${hue1}, ${sat}%, ${light}%), hsl(${hue2}, ${sat}%, ${light}%))`
})
</script>

View File

@@ -0,0 +1,26 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const statusBadgeVariants = cva({
base: 'inline-flex items-center justify-center rounded-full',
variants: {
severity: {
default: 'bg-primary-background text-base-foreground',
secondary: 'bg-secondary-background text-base-foreground',
warn: 'bg-warning-background text-base-background',
danger: 'bg-destructive-background text-white',
contrast: 'bg-base-foreground text-base-background'
},
variant: {
label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
dot: 'size-2',
circle: 'size-3.5 text-xxxs font-semibold'
}
},
defaultVariants: {
severity: 'default',
variant: 'label'
}
})
export type StatusBadgeVariants = VariantProps<typeof statusBadgeVariants>

View File

@@ -4,7 +4,12 @@
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
:class="[
'global-dialog',
item.key === 'global-settings' && teamWorkspacesEnabled
? 'settings-dialog-workspace'
: ''
]"
v-bind="item.dialogComponentProps"
:pt="item.dialogComponentProps.pt"
:aria-labelledby="item.key"
@@ -38,7 +43,15 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { computed } from 'vue'
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const dialogStore = useDialogStore()
</script>
@@ -55,4 +68,27 @@ const dialogStore = useDialogStore()
@apply p-2 2xl:p-[var(--p-dialog-content-padding)];
@apply pt-0;
}
/* Workspace mode: wider settings dialog */
.settings-dialog-workspace {
width: 100%;
max-width: 1440px;
}
.settings-dialog-workspace .p-dialog-content {
width: 100%;
}
.manager-dialog {
height: 80vh;
max-width: 1724px;
max-height: 1026px;
}
@media (min-width: 3000px) {
.manager-dialog {
max-width: 2200px;
max-height: 1320px;
}
}
</style>

View File

@@ -18,7 +18,7 @@ vi.mock('@/utils/formatUtil', () => ({
}))
describe('SettingItem', () => {
const mountComponent = (props: any, options = {}): any => {
const mountComponent = (props: Record<string, unknown>, options = {}) => {
return mount(SettingItem, {
global: {
plugins: [PrimeVue, i18n, createPinia()],
@@ -32,6 +32,7 @@ describe('SettingItem', () => {
'i-material-symbols:experiment-outline': true
}
},
// @ts-expect-error - Test utility accepts flexible props for testing edge cases
props,
...options
})
@@ -48,8 +49,9 @@ describe('SettingItem', () => {
}
})
// Get the options property of the FormItem
const options = wrapper.vm.formItem.options
// Check the FormItem component's item prop for the options
const formItem = wrapper.findComponent({ name: 'FormItem' })
const options = formItem.props('item').options
expect(options).toEqual([
{ text: 'Correctly Translated', value: 'Correctly Translated' }
])
@@ -67,7 +69,8 @@ describe('SettingItem', () => {
})
// Should not throw an error and tooltip should be preserved as-is
expect(wrapper.vm.formItem.tooltip).toBe(
const formItem = wrapper.findComponent({ name: 'FormItem' })
expect(formItem.props('item').tooltip).toBe(
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
)
})

View File

@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { AuditLog } from '@/services/customerEventsService'
import { EventType } from '@/services/customerEventsService'
import UsageLogsTable from './UsageLogsTable.vue'
@@ -19,7 +20,7 @@ import UsageLogsTable from './UsageLogsTable.vue'
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
loading: boolean
error: string | null
events: any[]
events: Partial<AuditLog>[]
pagination: {
page: number
limit: number

View File

@@ -0,0 +1,11 @@
<template>
<TabPanel value="Workspace" class="h-full">
<WorkspacePanelContent />
</TabPanel>
</template>
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import WorkspacePanelContent from '@/components/dialog/content/setting/WorkspacePanelContent.vue'
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div class="flex h-full w-full flex-col">
<div class="pb-8 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 !text-3xl"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl text-base-foreground">
{{ workspaceName }}
</h1>
</div>
<Tabs :value="activeTab" @update:value="setActiveTab">
<div class="flex w-full items-center">
<TabList class="w-full">
<Tab value="plan">{{ $t('workspacePanel.tabs.planCredits') }}</Tab>
</TabList>
<template v-if="permissions.canAccessWorkspaceMenu">
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="menu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="menu" :model="menuItems" :popup="true">
<template #item="{ item }">
<div
v-tooltip="
item.disabled && deleteTooltip
? { value: deleteTooltip, showDelay: 0 }
: null
"
:class="[
'flex items-center gap-2 px-3 py-2',
item.class,
item.disabled ? 'pointer-events-auto' : ''
]"
@click="
item.command?.({
originalEvent: $event,
item
})
"
>
<i :class="item.icon" />
<span>{{ item.label }}</span>
</div>
</template>
</Menu>
</template>
</div>
<TabPanels>
<TabPanel value="plan">
<SubscriptionPanelContent />
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import Tab from 'primevue/tab'
import TabList from 'primevue/tablist'
import TabPanel from 'primevue/tabpanel'
import TabPanels from 'primevue/tabpanels'
import Tabs from 'primevue/tabs'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import Button from '@/components/ui/button/Button.vue'
import SubscriptionPanelContent from '@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const { t } = useI18n()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showEditWorkspaceDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, isWorkspaceSubscribed } = storeToRefs(workspaceStore)
const { activeTab, setActiveTab, permissions, uiConfig } = useWorkspaceUI()
const menu = ref<InstanceType<typeof Menu> | null>(null)
function handleLeaveWorkspace() {
showLeaveWorkspaceDialog()
}
function handleDeleteWorkspace() {
showDeleteWorkspaceDialog()
}
function handleEditWorkspace() {
showEditWorkspaceDialog()
}
// Disable delete when workspace has an active subscription (to prevent accidental deletion)
// Use workspace's own subscription status, not the global isActiveSubscription
const isDeleteDisabled = computed(
() =>
uiConfig.value.workspaceMenuAction === 'delete' &&
isWorkspaceSubscribed.value
)
const deleteTooltip = computed(() => {
if (!isDeleteDisabled.value) return null
const tooltipKey = uiConfig.value.workspaceMenuDisabledTooltip
return tooltipKey ? t(tooltipKey) : null
})
const menuItems = computed(() => {
const items = []
// Add edit option for owners
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: handleEditWorkspace
})
}
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
items.push({
label: t('workspacePanel.menu.deleteWorkspace'),
icon: 'pi pi-trash',
class: isDeleteDisabled.value
? 'text-danger/50 cursor-not-allowed'
: 'text-danger',
disabled: isDeleteDisabled.value,
command: isDeleteDisabled.value ? undefined : handleDeleteWorkspace
})
} else if (action === 'leave') {
items.push({
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
command: handleLeaveWorkspace
})
}
return items
})
onMounted(() => {
setActiveTab(defaultTab)
})
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="flex items-center gap-2">
<WorkspaceProfilePic
class="size-6 text-xs"
:workspace-name="workspaceName"
/>
<span>{{ workspaceName }}</span>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
</script>

View File

@@ -1,3 +1,5 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
@@ -63,7 +65,7 @@ describe('ApiKeyForm', () => {
mockLoading.mockReset()
})
const mountComponent = (props: any = {}) => {
const mountComponent = (props: ComponentProps<typeof ApiKeyForm> = {}) => {
return mount(ApiKeyForm, {
global: {
plugins: [PrimeVue, createPinia(), i18n],

View File

@@ -112,8 +112,10 @@ describe('SignInForm', () => {
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
@@ -138,7 +140,10 @@ describe('SignInForm', () => {
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
@@ -161,7 +166,10 @@ describe('SignInForm', () => {
describe('Form Submission', () => {
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Call onSubmit directly with valid data
component.onSubmit({
@@ -181,7 +189,10 @@ describe('SignInForm', () => {
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
@@ -254,12 +265,17 @@ describe('SignInForm', () => {
describe('Focus Behavior', () => {
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
@@ -273,12 +289,17 @@ describe('SignInForm', () => {
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
const component = wrapper.vm as typeof wrapper.vm & {
handleForgotPassword: (email: string, valid: boolean) => void
onSubmit: (data: { valid: boolean; values: unknown }) => void
}
// Mock getElementById
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
const mockElement: Partial<HTMLElement> = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(
mockElement as HTMLElement
)
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)

View File

@@ -0,0 +1,113 @@
<template>
<div
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.createWorkspaceDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.createWorkspaceDialog.message') }}
</p>
<div class="flex flex-col gap-2">
<label class="text-sm text-base-foreground">
{{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
</label>
<input
v-model="workspaceName"
type="text"
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
:placeholder="
$t('workspacePanel.createWorkspaceDialog.namePlaceholder')
"
@keydown.enter="isValidName && onCreate()"
/>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="!isValidName"
@click="onCreate"
>
{{ $t('workspacePanel.createWorkspaceDialog.create') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
onConfirm?: (name: string) => void | Promise<void>
}>()
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const workspaceName = ref('')
const isValidName = computed(() => {
const name = workspaceName.value.trim()
// Allow alphanumeric, spaces, hyphens, underscores (safe characters)
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})
function onCancel() {
dialogStore.closeDialog({ key: 'create-workspace' })
}
async function onCreate() {
if (!isValidName.value) return
loading.value = true
try {
const name = workspaceName.value.trim()
// Call optional callback if provided
await onConfirm?.(name)
dialogStore.closeDialog({ key: 'create-workspace' })
// Create workspace and switch to it (triggers reload internally)
await workspaceStore.createWorkspace(name)
} catch (error) {
console.error('[CreateWorkspaceDialog] Failed to create workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.deleteDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{
workspaceName
? $t('workspacePanel.deleteDialog.messageWithName', {
name: workspaceName
})
: $t('workspacePanel.deleteDialog.message')
}}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onDelete">
{{ $t('g.delete') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { workspaceId, workspaceName } = defineProps<{
workspaceId?: string
workspaceName?: string
}>()
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'delete-workspace' })
}
async function onDelete() {
loading.value = true
try {
// Delete workspace (uses workspaceId if provided, otherwise current workspace)
await workspaceStore.deleteWorkspace(workspaceId)
dialogStore.closeDialog({ key: 'delete-workspace' })
window.location.reload()
} catch (error) {
console.error('[DeleteWorkspaceDialog] Failed to delete workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,104 @@
<template>
<div
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.editWorkspaceDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="flex flex-col gap-4 px-4 py-4">
<div class="flex flex-col gap-2">
<label class="text-sm text-base-foreground">
{{ $t('workspacePanel.editWorkspaceDialog.nameLabel') }}
</label>
<input
v-model="newWorkspaceName"
type="text"
class="w-full rounded-lg border border-border-default bg-transparent px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-secondary-foreground"
@keydown.enter="isValidName && onSave()"
/>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button
variant="primary"
size="lg"
:loading
:disabled="!isValidName"
@click="onSave"
>
{{ $t('workspacePanel.editWorkspaceDialog.save') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
const newWorkspaceName = ref(workspaceStore.workspaceName)
const isValidName = computed(() => {
const name = newWorkspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
})
function onCancel() {
dialogStore.closeDialog({ key: 'edit-workspace' })
}
async function onSave() {
if (!isValidName.value) return
loading.value = true
try {
await workspaceStore.updateWorkspaceName(newWorkspaceName.value.trim())
dialogStore.closeDialog({ key: 'edit-workspace' })
toast.add({
severity: 'success',
summary: t('workspacePanel.toast.workspaceUpdated.title'),
detail: t('workspacePanel.toast.workspaceUpdated.message'),
life: 5000
})
} catch (error) {
console.error('[EditWorkspaceDialog] Failed to update workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToUpdateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div
class="flex w-full max-w-[360px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<!-- Header -->
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.leaveDialog.title') }}
</h2>
<button
class="cursor-pointer rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-secondary-foreground"
:aria-label="$t('g.close')"
@click="onCancel"
>
<i class="pi pi-times size-4" />
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.leaveDialog.message') }}
</p>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-4 px-4 py-4">
<Button variant="muted-textonly" @click="onCancel">
{{ $t('g.cancel') }}
</Button>
<Button variant="destructive" size="lg" :loading @click="onLeave">
{{ $t('workspacePanel.leaveDialog.leave') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
const toast = useToast()
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const loading = ref(false)
function onCancel() {
dialogStore.closeDialog({ key: 'leave-workspace' })
}
async function onLeave() {
loading.value = true
try {
// leaveWorkspace() handles switching to personal workspace internally and reloads
await workspaceStore.leaveWorkspace()
dialogStore.closeDialog({ key: 'leave-workspace' })
window.location.reload()
} catch (error) {
console.error('[LeaveWorkspaceDialog] Failed to leave workspace:', error)
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToLeaveWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false
}
}
</script>

View File

@@ -200,7 +200,13 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
if (item.state === 'running' || item.state === 'initialization') {
// Running/initializing jobs: interrupt execution
await api.interrupt(promptId)
// Cloud backend uses deleteItem, local uses interrupt
if (isCloud) {
await api.deleteItem('queue', promptId)
} else {
await api.interrupt(promptId)
}
executionStore.clearInitializationByPromptId(promptId)
await queueStore.update()
} else if (item.state === 'pending') {
// Pending jobs: remove from queue
@@ -268,7 +274,15 @@ const inspectJobAsset = wrapWithErrorHandlingAsync(
)
const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => {
// Capture pending promptIds before clearing
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
// Clear initialization state for removed prompts
executionStore.clearInitializationByPromptIds(pendingPromptIds)
})
const interruptAll = wrapWithErrorHandlingAsync(async () => {
@@ -284,10 +298,14 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
// on cloud to ensure we cancel the workflow the user clicked.
if (isCloud) {
await Promise.all(promptIds.map((id) => api.deleteItem('queue', id)))
executionStore.clearInitializationByPromptIds(promptIds)
await queueStore.update()
return
}
await Promise.all(promptIds.map((id) => api.interrupt(id)))
executionStore.clearInitializationByPromptIds(promptIds)
await queueStore.update()
})
const showClearHistoryDialog = () => {

View File

@@ -5,25 +5,32 @@ import { cn } from '@/utils/tailwindUtil'
import TransitionCollapse from './TransitionCollapse.vue'
const props = defineProps<{
const {
disabled,
label,
enableEmptyState,
tooltip,
class: className
} = defineProps<{
disabled?: boolean
label?: string
enableEmptyState?: boolean
tooltip?: string
class?: string
}>()
const isCollapse = defineModel<boolean>('collapse', { default: false })
const isExpanded = computed(() => !isCollapse.value && !props.disabled)
const isExpanded = computed(() => !isCollapse.value && !disabled)
const tooltipConfig = computed(() => {
if (!props.tooltip) return undefined
return { value: props.tooltip, showDelay: 1000 }
if (!tooltip) return undefined
return { value: tooltip, showDelay: 1000 }
})
</script>
<template>
<div class="flex flex-col bg-comfy-menu-bg">
<div :class="cn('flex flex-col bg-comfy-menu-bg', className)">
<div
class="sticky top-0 z-10 flex items-center justify-between backdrop-blur-xl bg-inherit"
>

View File

@@ -8,7 +8,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgetsAndNodes } from '../shared'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared'
import SectionWidgets from './SectionWidgets.vue'
@@ -24,18 +24,7 @@ const nodes = computed((): LGraphNode[] => {
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.value.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
.map((widget) => ({ node, widget }))
return {
widgets: shownWidgets,
node
}
})
})
const { widgetsSectionDataList } = computedSectionDataList(nodes)
const searchedWidgetsSectionDataList = shallowRef<NodeWidgetsListList>(
widgetsSectionDataList.value

View File

@@ -7,7 +7,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgetsAndNodes } from '../shared'
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
import type { NodeWidgetsListList } from '../shared'
import SectionWidgets from './SectionWidgets.vue'
@@ -21,15 +21,26 @@ const { t } = useI18n()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return nodes.map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter((w) => !(w.options?.canvasOnly || w.options?.hidden))
.map((widget) => ({ node, widget }))
const { widgetsSectionDataList, includesAdvanced } = computedSectionDataList(
() => nodes
)
return { widgets: shownWidgets, node }
})
const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
if (includesAdvanced.value) {
return []
}
return nodes
.map((node) => {
const { widgets = [] } = node
const advancedWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
)
.map((widget) => ({ node, widget }))
return { widgets: advancedWidgets, node }
})
.filter(({ widgets }) => widgets.length > 0)
})
const isMultipleNodesSelected = computed(
@@ -56,6 +67,12 @@ const label = computed(() => {
: t('rightSidePanel.inputsNone')
: undefined // SectionWidgets display node titles by default
})
const advancedLabel = computed(() => {
return !mustShowNodeTitle && !isMultipleNodesSelected.value
? t('rightSidePanel.advancedInputs')
: undefined // SectionWidgets display node titles by default
})
</script>
<template>
@@ -93,4 +110,16 @@ const label = computed(() => {
class="border-b border-interface-stroke"
/>
</TransitionGroup>
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
<SectionWidgets
v-for="{ widgets, node } in advancedWidgetsSectionDataList"
:key="`advanced-${node.id}`"
:collapse="true"
:node
:label="advancedLabel"
:widgets
:show-locate-button="isMultipleNodesSelected"
class="border-b border-interface-stroke"
/>
</template>
</template>

View File

@@ -43,7 +43,7 @@ const favoritedWidgetsStore = useFavoritedWidgetsStore()
const isEditing = ref(false)
const widgetComponent = computed(() => {
const component = getComponent(widget.type, widget.name)
const component = getComponent(widget.type)
return component || WidgetLegacy
})

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -23,7 +23,11 @@ const settingStore = useSettingStore()
const dialogService = useDialogService()
// NODES settings
const showAdvancedParameters = ref(false) // Placeholder for future implementation
const showAdvancedParameters = computed({
get: () => settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets'),
set: (value) =>
settingStore.set('Comfy.Node.AlwaysShowAdvancedWidgets', value)
})
const showToolbox = computed({
get: () => settingStore.get('Comfy.Canvas.SelectionToolbox'),

View File

@@ -6,6 +6,7 @@ import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
export const GetNodeParentGroupKey: InjectionKey<
(node: LGraphNode) => LGraphGroup | null
@@ -203,3 +204,33 @@ function repeatItems<T>(items: T[]): T[] {
}
return result
}
export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
const settingStore = useSettingStore()
const includesAdvanced = computed(() =>
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const widgetsSectionDataList = computed((): NodeWidgetsListList => {
return toValue(nodes).map((node) => {
const { widgets = [] } = node
const shownWidgets = widgets
.filter(
(w) =>
!(
w.options?.canvasOnly ||
w.options?.hidden ||
(w.options?.advanced && !includesAdvanced.value)
)
)
.map((widget) => ({ node, widget }))
return { widgets: shownWidgets, node }
})
})
return {
widgetsSectionDataList,
includesAdvanced
}
}

View File

@@ -219,7 +219,7 @@ const extraMenuItems = computed(() => [
{
key: 'settings',
label: t('g.settings'),
icon: 'mdi mdi-cog-outline',
icon: 'icon-[lucide--settings]',
command: () => {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_settings_menu_opened'
@@ -230,7 +230,7 @@ const extraMenuItems = computed(() => [
{
key: 'manage-extensions',
label: t('menu.manageExtensions'),
icon: 'mdi mdi-puzzle-outline',
icon: 'icon-[lucide--puzzle]',
command: showManageExtensions
}
])

View File

@@ -5,6 +5,11 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
const canvasStore = useCanvasStore()
function toggleLinearMode() {
useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source: 'button' }
})
}
</script>
<template>
<div class="p-1 bg-secondary-background rounded-lg w-10">
@@ -12,7 +17,7 @@ const canvasStore = useCanvasStore()
size="icon"
:title="t('linearMode.linearMode')"
:variant="canvasStore.linearMode ? 'inverted' : 'secondary'"
@click="useCommandStore().execute('Comfy.ToggleLinear')"
@click="toggleLinearMode"
>
<i class="icon-[lucide--panels-top-left]" />
</Button>
@@ -20,7 +25,7 @@ const canvasStore = useCanvasStore()
size="icon"
:title="t('linearMode.graphMode')"
:variant="canvasStore.linearMode ? 'secondary' : 'inverted'"
@click="useCommandStore().execute('Comfy.ToggleLinear')"
@click="toggleLinearMode"
>
<i class="icon-[comfy--workflow]" />
</Button>

View File

@@ -5,6 +5,7 @@
:label="$t('menu.help')"
:tooltip="$t('sideToolbar.helpCenter')"
:icon-badge="shouldShowRedDot ? '' : ''"
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
:is-small="isSmall"
@click="toggleHelpCenter"
/>
@@ -21,24 +22,3 @@ defineProps<{
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
</script>
<style scoped>
:deep(.p-badge) {
background: #ff3b30;
color: #ff3b30;
min-width: 8px;
height: 8px;
padding: 0;
border-radius: 9999px;
font-size: 0;
margin-top: 4px;
margin-right: 4px;
border: none;
outline: none;
box-shadow: none;
}
:deep(.p-badge.p-badge-dot) {
width: 8px !important;
}
</style>

View File

@@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import OverlayBadge from 'primevue/overlaybadge'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
@@ -33,8 +32,7 @@ describe('SidebarIcon', () => {
return mount(SidebarIcon, {
global: {
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip },
components: { OverlayBadge }
directives: { tooltip: Tooltip }
},
props: { ...exampleProps, ...props },
...options
@@ -54,9 +52,9 @@ describe('SidebarIcon', () => {
it('creates badge when iconBadge prop is set', () => {
const badge = '2'
const wrapper = mountSidebarIcon({ iconBadge: badge })
const badgeEl = wrapper.findComponent(OverlayBadge)
const badgeEl = wrapper.find('.sidebar-icon-badge')
expect(badgeEl.exists()).toBe(true)
expect(badgeEl.find('.p-badge').text()).toEqual(badge)
expect(badgeEl.text()).toEqual(badge)
})
it('shows tooltip on hover', async () => {

View File

@@ -17,22 +17,28 @@
>
<div class="side-bar-button-content">
<slot name="icon">
<OverlayBadge v-if="shouldShowBadge" :value="overlayValue">
<div class="sidebar-icon-wrapper relative">
<i
v-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component :is="icon" v-else class="side-bar-button-icon" />
</OverlayBadge>
<i
v-else-if="typeof icon === 'string'"
:class="icon + ' side-bar-button-icon'"
/>
<component
:is="icon"
v-else-if="typeof icon === 'object'"
class="side-bar-button-icon"
/>
<component
:is="icon"
v-else-if="typeof icon === 'object'"
class="side-bar-button-icon"
/>
<span
v-if="shouldShowBadge"
:class="
cn(
'sidebar-icon-badge absolute min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground',
badgeClass || '-top-1 -right-1'
)
"
>
{{ overlayValue }}
</span>
</div>
</slot>
<span v-if="label && !isSmall" class="side-bar-button-label">{{
t(label)
@@ -42,7 +48,6 @@
</template>
<script setup lang="ts">
import OverlayBadge from 'primevue/overlaybadge'
import { computed } from 'vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -57,6 +62,7 @@ const {
tooltip = '',
tooltipSuffix = '',
iconBadge = '',
badgeClass = '',
label = '',
isSmall = false
} = defineProps<{
@@ -65,6 +71,7 @@ const {
tooltip?: string
tooltipSuffix?: string
iconBadge?: string | (() => string | null)
badgeClass?: string
label?: string
isSmall?: boolean
}>()

View File

@@ -0,0 +1,113 @@
<template>
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
<ActiveMediaAssetCard
v-for="job in activeJobItems"
:key="job.id"
:job="job"
/>
</div>
<!-- Assets Header -->
<div
v-if="assets.length"
:class="cn('px-2 2xl:px-4', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
<!-- Assets Grid -->
<VirtualGrid
class="flex-1"
:items="assetItems"
:grid-style="gridStyle"
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<MediaAssetCard
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
@click="emit('select-asset', item.asset)"
@context-menu="emit('context-menu', $event, item.asset)"
@zoom="emit('zoom', item.asset)"
@output-count-click="emit('output-count-click', item.asset)"
/>
</template>
</VirtualGrid>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import ActiveMediaAssetCard from '@/platform/assets/components/ActiveMediaAssetCard.vue'
import { useJobList } from '@/composables/queue/useJobList'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isActiveJobState } from '@/utils/queueUtil'
import { cn } from '@/utils/tailwindUtil'
const {
assets,
isSelected,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number
}>()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
(e: 'zoom', asset: AssetItem): void
(e: 'output-count-click', asset: AssetItem): void
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
type AssetGridItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
)
const assetItems = computed<AssetGridItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0 0.5rem',
gap: '0.5rem'
}
</script>

View File

@@ -2,7 +2,7 @@
<div class="flex h-full flex-col">
<div
v-if="activeJobItems.length"
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2"
class="flex max-h-[50%] scrollbar-custom flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
@@ -44,9 +44,15 @@
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
class="flex items-center p-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
{{ t('sideToolbar.generatedAssetsHeader') }}
{{
t(
assetType === 'input'
? 'sideToolbar.importedAssetsHeader'
: 'sideToolbar.generatedAssetsHeader'
)
}}
</div>
</div>
@@ -108,7 +114,7 @@ import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import type { JobState } from '@/types/queue'
import { isActiveJobState } from '@/utils/queueUtil'
import {
formatDuration,
formatSize,
@@ -118,9 +124,14 @@ import {
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const { assets, isSelected } = defineProps<{
const {
assets,
isSelected,
assetType = 'output'
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
assetType?: 'input' | 'output'
}>()
const emit = defineEmits<{
@@ -161,12 +172,6 @@ const listGridStyle = {
gap: '0.5rem'
}
function isActiveJobState(state: JobState): boolean {
return (
state === 'pending' || state === 'initialization' || state === 'running'
)
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
}

View File

@@ -100,34 +100,24 @@
v-if="isListView"
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<VirtualGrid
<AssetsSidebarGridView
v-else
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
padding: '0 0.5rem',
gap: '0.5rem'
}"
:assets="displayAssets"
:is-selected="isSelected"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
>
<template #item="{ item }">
<MediaAssetCard
:asset="item"
:selected="isSelected(item.id)"
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
@click="handleAssetSelect(item)"
@context-menu="handleAssetContextMenu"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
/>
</template>
</VirtualGrid>
@zoom="handleZoomClick"
@output-count-click="enterFolderView"
/>
</div>
</template>
<template #footer>
@@ -212,6 +202,7 @@
<script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
@@ -219,15 +210,14 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
@@ -243,6 +233,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -256,6 +247,8 @@ interface JobOutputItem {
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const { activeJobsCount } = storeToRefs(queueStore)
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
@@ -301,9 +294,6 @@ const formattedExecutionTime = computed(() => {
})
const queuedCount = computed(() => queueStore.pendingTasks.length)
const activeJobsCount = computed(
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
@@ -404,14 +394,14 @@ const showLoadingState = computed(
() =>
loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
activeJobsCount.value === 0
)
const showEmptyState = computed(
() =>
!loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
activeJobsCount.value === 0
)
watch(displayAssets, (newAssets) => {
@@ -453,14 +443,6 @@ const galleryItems = computed(() => {
})
})
// Add key property for VirtualGrid
const mediaAssetsWithKey = computed(() => {
return displayAssets.value.map((asset) => ({
...asset,
key: asset.id
}))
})
const refreshAssets = async () => {
await currentAssets.value.fetchMediaList()
if (error.value) {
@@ -510,7 +492,13 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
}
const handleClearQueue = async () => {
const pendingPromptIds = queueStore.pendingTasks
.map((task) => task.promptId)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
await commandStore.execute('Comfy.ClearPendingTasks')
executionStore.clearInitializationByPromptIds(pendingPromptIds)
}
const handleBulkAddToWorkflow = async (assets: AssetItem[]) => {

View File

@@ -1,4 +1,4 @@
<!-- A button that shows current authenticated user's avatar -->
<!-- A button that shows workspace icon (Cloud) or user avatar -->
<template>
<div>
<Button
@@ -16,7 +16,16 @@
)
"
>
<UserAvatar :photo-url="photoURL" :class="compact && 'size-full'" />
<WorkspaceProfilePic
v-if="showWorkspaceIcon"
:workspace-name="workspaceName"
:class="compact && 'size-full'"
/>
<UserAvatar
v-else
:photo-url="photoURL"
:class="compact && 'size-full'"
/>
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-3 px-1" />
</div>
@@ -27,38 +36,65 @@
:show-arrow="false"
:pt="{
root: {
class: 'rounded-lg'
class: 'rounded-lg w-80'
}
}"
>
<CurrentUserPopover @close="closePopover" />
<!-- Workspace mode: workspace-aware popover -->
<CurrentUserPopoverWorkspace
v-if="teamWorkspacesEnabled"
@close="closePopover"
/>
<!-- Legacy mode: original popover -->
<CurrentUserPopover v-else @close="closePopover" />
</Popover>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { computed, defineAsyncComponent, ref } from 'vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
import CurrentUserPopover from './CurrentUserPopover.vue'
const CurrentUserPopoverWorkspace = defineAsyncComponent(
() => import('./CurrentUserPopoverWorkspace.vue')
)
const { showArrow = true, compact = false } = defineProps<{
showArrow?: boolean
compact?: boolean
}>()
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(() => flags.teamWorkspacesEnabled)
const { isLoggedIn, userPhotoUrl } = useCurrentUser()
const popover = ref<InstanceType<typeof Popover> | null>(null)
const photoURL = computed<string | undefined>(
() => userPhotoUrl.value ?? undefined
)
const showWorkspaceIcon = computed(() => isCloud && teamWorkspacesEnabled.value)
const workspaceName = computed(() => {
if (!showWorkspaceIcon.value) return ''
const { workspaceName } = storeToRefs(useTeamWorkspaceStore())
return workspaceName.value
})
const popover = ref<InstanceType<typeof Popover> | null>(null)
const closePopover = () => {
popover.value?.hide()
}

View File

@@ -0,0 +1,337 @@
<!-- A popover that shows current user information and actions -->
<template>
<div
class="current-user-popover w-80 -m-3 p-2 rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<!-- User Info Section -->
<div class="flex flex-col items-center px-0 py-3 mb-4">
<UserAvatar
class="mb-1"
:photo-url="userPhotoUrl"
:pt:icon:class="{
'text-2xl!': !userPhotoUrl
}"
size="large"
/>
<!-- User Details -->
<h3 class="my-0 mb-1 truncate text-base font-bold text-base-foreground">
{{ userDisplayName || $t('g.user') }}
</h3>
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
</div>
<!-- Workspace Selector -->
<div
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-secondary-background-hover"
@click="toggleWorkspaceSwitcher"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<WorkspaceProfilePic
class="size-6 shrink-0 text-xs"
:workspace-name="workspaceName"
/>
<span class="truncate text-sm text-base-foreground">{{
workspaceName
}}</span>
<div
v-if="workspaceTierName"
class="shrink-0 rounded bg-secondary-background-hover px-1.5 py-0.5 text-xs"
>
{{ workspaceTierName }}
</div>
<span v-else class="shrink-0 text-xs text-muted-foreground">
{{ $t('workspaceSwitcher.subscribe') }}
</span>
</div>
<i class="pi pi-chevron-down shrink-0 text-sm text-muted-foreground" />
</div>
<Popover
ref="workspaceSwitcherPopover"
append-to="body"
:pt="{
content: {
class: 'p-0'
}
}"
>
<WorkspaceSwitcherPopover
@select="workspaceSwitcherPopover?.hide()"
@create="handleCreateWorkspace"
/>
</Popover>
<!-- Credits Section (PERSONAL and OWNER only) -->
<template v-if="showCreditsSection">
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
height="1.25rem"
class="w-full"
/>
<span v-else class="text-base font-semibold text-base-foreground">{{
displayedCredits
}}</span>
<i
v-tooltip="{ value: $t('credits.unified.tooltip'), showDelay: 300 }"
class="icon-[lucide--circle-help] mr-auto cursor-help text-base text-muted-foreground"
/>
<!-- Subscribed: Show Add Credits button -->
<Button
v-if="isActiveSubscription && isWorkspaceSubscribed"
variant="secondary"
size="sm"
class="text-base-foreground"
data-testid="add-credits-button"
@click="handleTopUp"
>
{{ $t('subscription.addCredits') }}
</Button>
<!-- Unsubscribed: Show Subscribe button (disabled until billing is ready) -->
<SubscribeButton
v-else
disabled
:fluid="false"
:label="$t('workspaceSwitcher.subscribe')"
size="sm"
variant="gradient"
/>
</div>
<Divider class="mx-0 my-2" />
</template>
<!-- Plans & Pricing (PERSONAL and OWNER only) -->
<div
v-if="showPlansAndPricing"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="plans-pricing-menu-item"
@click="handleOpenPlansAndPricing"
>
<i class="icon-[lucide--receipt-text] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.plansAndPricing')
}}</span>
<span
v-if="canUpgrade"
class="rounded-full bg-base-foreground px-1.5 py-0.5 text-xs font-bold text-base-background"
>
{{ $t('subscription.upgrade') }}
</span>
</div>
<!-- Manage Plan (PERSONAL and OWNER, only if subscribed) -->
<div
v-if="showManagePlan"
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="manage-plan-menu-item"
@click="handleOpenPlanAndCreditsSettings"
>
<i class="icon-[lucide--file-text] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.managePlan')
}}</span>
</div>
<!-- Partner Nodes Pricing (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="partner-nodes-menu-item"
@click="handleOpenPartnerNodesInfo"
>
<i class="icon-[lucide--tag] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('subscription.partnerNodesCredits')
}}</span>
</div>
<Divider class="mx-0 my-2" />
<!-- Workspace Settings (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="workspace-settings-menu-item"
@click="handleOpenWorkspaceSettings"
>
<i class="icon-[lucide--users] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('userSettings.workspaceSettings')
}}</span>
</div>
<!-- Account Settings (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="user-settings-menu-item"
@click="handleOpenUserSettings"
>
<i class="icon-[lucide--settings-2] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('userSettings.accountSettings')
}}</span>
</div>
<Divider class="mx-0 my-2" />
<!-- Logout (always shown) -->
<div
class="flex cursor-pointer items-center gap-2 px-4 py-2 hover:bg-secondary-background-hover"
data-testid="logout-menu-item"
@click="handleLogout"
>
<i class="icon-[lucide--log-out] text-sm text-muted-foreground" />
<span class="flex-1 text-sm text-base-foreground">{{
$t('auth.signOut.signOut')
}}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import WorkspaceSwitcherPopover from '@/components/topbar/WorkspaceSwitcherPopover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
const workspaceStore = useTeamWorkspaceStore()
const {
workspaceName,
isInPersonalWorkspace: isPersonalWorkspace,
isWorkspaceSubscribed,
subscriptionPlan
} = storeToRefs(workspaceStore)
const { workspaceRole } = useWorkspaceUI()
const workspaceSwitcherPopover = ref<InstanceType<typeof Popover> | null>(null)
const emit = defineEmits<{
close: []
}>()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const { isActiveSubscription } = useSubscription()
const { totalCredits, isLoadingBalance } = useSubscriptionCredits()
const subscriptionDialog = useSubscriptionDialog()
const { t } = useI18n()
const displayedCredits = computed(() =>
isWorkspaceSubscribed.value ? totalCredits.value : '0'
)
// Workspace subscription tier name (not user tier)
const workspaceTierName = computed(() => {
if (!isWorkspaceSubscribed.value) return null
if (!subscriptionPlan.value) return null
// Convert plan to display name
if (subscriptionPlan.value === 'PRO_MONTHLY')
return t('subscription.tiers.pro.name')
if (subscriptionPlan.value === 'PRO_YEARLY')
return t('subscription.tierNameYearly', {
name: t('subscription.tiers.pro.name')
})
return null
})
const canUpgrade = computed(() => {
// PRO is currently the only/highest tier, so no upgrades available
// This will need updating when additional tiers are added
return false
})
const showPlansAndPricing = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
)
const showManagePlan = computed(
() => showPlansAndPricing.value && isActiveSubscription.value
)
const showCreditsSection = computed(
() => isPersonalWorkspace.value || workspaceRole.value === 'owner'
)
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
emit('close')
}
const handleOpenWorkspaceSettings = () => {
dialogService.showSettingsDialog('workspace')
emit('close')
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.show()
emit('close')
}
const handleOpenPlanAndCreditsSettings = () => {
if (isCloud) {
dialogService.showSettingsDialog('workspace')
} else {
dialogService.showSettingsDialog('credits')
}
emit('close')
}
const handleTopUp = () => {
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
emit('close')
}
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
'_blank'
)
emit('close')
}
const handleLogout = async () => {
await handleSignOut()
emit('close')
}
const handleCreateWorkspace = () => {
workspaceSwitcherPopover.value?.hide()
dialogService.showCreateWorkspaceDialog()
emit('close')
}
const toggleWorkspaceSwitcher = (event: MouseEvent) => {
workspaceSwitcherPopover.value?.toggle(event)
}
onMounted(() => {
void authActions.fetchBalance()
})
</script>

View File

@@ -0,0 +1,166 @@
<template>
<div class="flex w-80 flex-col overflow-hidden rounded-lg">
<div class="flex flex-col overflow-y-auto">
<!-- Loading state -->
<div v-if="isFetchingWorkspaces" class="flex flex-col gap-2 p-2">
<div
v-for="i in 2"
:key="i"
class="flex h-[54px] animate-pulse items-center gap-2 rounded px-2 py-4"
>
<div class="size-8 rounded-full bg-secondary-background" />
<div class="flex flex-1 flex-col gap-1">
<div class="h-4 w-24 rounded bg-secondary-background" />
<div class="h-3 w-16 rounded bg-secondary-background" />
</div>
</div>
</div>
<!-- Workspace list -->
<template v-else>
<template v-for="workspace in availableWorkspaces" :key="workspace.id">
<div class="border-b border-border-default p-2">
<div
:class="
cn(
'group flex h-[54px] w-full items-center gap-2 rounded px-2 py-4',
'hover:bg-secondary-background-hover',
isCurrentWorkspace(workspace) && 'bg-secondary-background'
)
"
>
<button
class="flex flex-1 cursor-pointer items-center gap-2 border-none bg-transparent p-0"
@click="handleSelectWorkspace(workspace)"
>
<WorkspaceProfilePic
class="size-8 text-sm"
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
<span class="text-sm text-base-foreground">
{{ workspace.name }}
</span>
<span
v-if="workspace.type !== 'personal'"
class="text-sm text-muted-foreground"
>
{{ getRoleLabel(workspace.role) }}
</span>
</div>
<i
v-if="isCurrentWorkspace(workspace)"
class="pi pi-check text-sm text-base-foreground"
/>
</button>
</div>
</div>
</template>
</template>
<!-- <Divider class="mx-0 my-0" /> -->
<!-- Create workspace button -->
<div class="px-2 py-2">
<div
:class="
cn(
'flex h-12 w-full items-center gap-2 rounded px-2 py-2',
canCreateWorkspace
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'cursor-default'
)
"
@click="canCreateWorkspace && handleCreateWorkspace()"
>
<div
:class="
cn(
'flex size-8 items-center justify-center rounded-full bg-secondary-background',
!canCreateWorkspace && 'opacity-50'
)
"
>
<i class="pi pi-plus text-sm text-muted-foreground" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<span
v-if="canCreateWorkspace"
class="text-sm text-muted-foreground"
>
{{ $t('workspaceSwitcher.createWorkspace') }}
</span>
<span v-else class="text-sm text-muted-foreground">
{{ $t('workspaceSwitcher.maxWorkspacesReached') }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/components/common/WorkspaceProfilePic.vue'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import type {
WorkspaceRole,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
interface AvailableWorkspace {
id: string
name: string
type: WorkspaceType
role: WorkspaceRole
}
const emit = defineEmits<{
select: [workspace: AvailableWorkspace]
create: []
}>()
const { t } = useI18n()
const { switchWithConfirmation } = useWorkspaceSwitch()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceId, workspaces, canCreateWorkspace, isFetchingWorkspaces } =
storeToRefs(workspaceStore)
const availableWorkspaces = computed<AvailableWorkspace[]>(() =>
workspaces.value.map((w) => ({
id: w.id,
name: w.name,
type: w.type,
role: w.role
}))
)
function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
return workspace.id === workspaceId.value
}
function getRoleLabel(role: AvailableWorkspace['role']): string {
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
if (role === 'member') return t('workspaceSwitcher.roleMember')
return ''
}
async function handleSelectWorkspace(workspace: AvailableWorkspace) {
const success = await switchWithConfirmation(workspace.id)
if (success) {
emit('select', workspace)
}
}
function handleCreateWorkspace() {
emit('create')
}
</script>

View File

@@ -0,0 +1,19 @@
# UI Component Guidelines
## Adding New Components
```bash
pnpm dlx shadcn-vue@latest add <component-name> --yes
```
After adding, create `ComponentName.stories.ts` with Default, Disabled, and variant stories.
## Reka UI Wrapper Components
- Use reactive props destructuring with rest: `const { class: className, ...restProps } = defineProps<Props>()`
- Use `useForwardProps(restProps)` for prop forwarding, or `computed()` if adding defaults
- Import siblings directly (`./Component.vue`), not from barrel (`'.'`)
- Use `cn()` for class merging with `className`
- Use Iconify icons: `<i class="icon-[lucide--check]" />`
- Use design tokens: `bg-secondary-background`, `text-muted-foreground`, `border-border-default`
- Tailwind 4 CSS variables use parentheses: `h-(--my-var)` not `h-[--my-var]`

View File

@@ -26,7 +26,8 @@ export const buttonVariants = cva({
md: 'h-8 rounded-lg p-2 text-xs',
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
icon: 'size-8',
'icon-sm': 'size-5 p-0'
'icon-sm': 'size-5 p-0',
unset: ''
}
},

View File

@@ -0,0 +1,261 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Select from './Select.vue'
import SelectContent from './SelectContent.vue'
import SelectGroup from './SelectGroup.vue'
import SelectItem from './SelectItem.vue'
import SelectLabel from './SelectLabel.vue'
import SelectSeparator from './SelectSeparator.vue'
import SelectTrigger from './SelectTrigger.vue'
import SelectValue from './SelectValue.vue'
const meta = {
title: 'Components/Select',
component: Select,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'text',
description: 'Selected value'
},
disabled: {
control: 'boolean',
description: 'When true, disables the select'
},
'onUpdate:modelValue': { action: 'update:modelValue' }
}
} satisfies Meta<typeof Select>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref(args.modelValue || '')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
<SelectItem value="grape">Grape</SelectItem>
<SelectItem value="orange">Orange</SelectItem>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const WithPlaceholder: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Choose an option..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const Disabled: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('apple')
return { value, args }
},
template: `
<Select v-model="value" disabled>
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="cherry">Cherry</SelectItem>
</SelectContent>
</Select>
`
})
}
export const WithGroups: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select a model type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Checkpoints</SelectLabel>
<SelectItem value="sd15">SD 1.5</SelectItem>
<SelectItem value="sdxl">SDXL</SelectItem>
<SelectItem value="flux">Flux</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>LoRAs</SelectLabel>
<SelectItem value="lora-style">Style LoRA</SelectItem>
<SelectItem value="lora-character">Character LoRA</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Other</SelectLabel>
<SelectItem value="vae">VAE</SelectItem>
<SelectItem value="embedding">Embedding</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<div class="mt-4 text-sm text-muted-foreground">
Selected: {{ value || 'None' }}
</div>
`
}),
args: {
disabled: false
}
}
export const Scrollable: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
const items = Array.from({ length: 20 }, (_, i) => ({
value: `item-${i + 1}`,
label: `Option ${i + 1}`
}))
return { value, items, args }
},
template: `
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-56">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="item in items"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</SelectItem>
</SelectContent>
</Select>
`
}),
args: {
disabled: false
}
}
export const CustomWidth: Story = {
render: (args) => ({
components: {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
},
setup() {
const value = ref('')
return { value, args }
},
template: `
<div class="space-y-4">
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-32">
<SelectValue placeholder="Small" />
</SelectTrigger>
<SelectContent>
<SelectItem value="a">A</SelectItem>
<SelectItem value="b">B</SelectItem>
<SelectItem value="c">C</SelectItem>
</SelectContent>
</Select>
<Select v-model="value" :disabled="args.disabled">
<SelectTrigger class="w-full">
<SelectValue placeholder="Full width select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
<SelectItem value="option3">Option 3</SelectItem>
</SelectContent>
</Select>
</div>
`
}),
args: {
disabled: false
}
}

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
// eslint-disable-next-line vue/no-unused-properties
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<SelectRoot v-bind="forwarded">
<slot />
</SelectRoot>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import SelectScrollDownButton from './SelectScrollDownButton.vue'
import SelectScrollUpButton from './SelectScrollUpButton.vue'
defineOptions({
inheritAttrs: false
})
const {
position = 'popper',
class: className,
...restProps
} = defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = computed(() => ({
position,
...restProps
}))
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectPortal>
<SelectContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'relative z-3000 max-h-96 min-w-32 overflow-hidden',
'mt-2 rounded-lg p-2',
'bg-base-background text-base-foreground',
'border border-solid border-border-default',
'shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'scrollbar-custom flex flex-col gap-0',
position === 'popper' &&
'h-(--reka-select-trigger-height) w-full min-w-(--reka-select-trigger-width)'
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import { SelectGroup } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectGroupProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectGroup :class="cn('w-full', className)" v-bind="restProps">
<slot />
</SelectGroup>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import { SelectItem, SelectItemIndicator, SelectItemText } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectItemProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectItem
v-bind="restProps"
:class="
cn(
'relative flex w-full cursor-pointer select-none items-center justify-between',
'gap-3 rounded px-2 py-3 text-sm outline-none',
'hover:bg-secondary-background-hover',
'focus:bg-secondary-background-hover',
'data-[state=checked]:bg-secondary-background-selected',
'data-[state=checked]:hover:bg-secondary-background-selected',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)
"
>
<SelectItemText class="truncate">
<slot />
</SelectItemText>
<SelectItemIndicator class="flex shrink-0 items-center justify-center">
<i class="icon-[lucide--check] text-base-foreground" aria-hidden="true" />
</SelectItemIndicator>
</SelectItem>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import { SelectLabel } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectLabelProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectLabel
v-bind="restProps"
:class="
cn(
'px-3 py-2 text-xs uppercase tracking-wide text-muted-foreground',
className
)
"
>
<slot />
</SelectLabel>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import { SelectScrollDownButton } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectScrollDownButton
v-bind="restProps"
:class="
cn(
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
className
)
"
>
<slot>
<i class="icon-[lucide--chevron-down]" />
</slot>
</SelectScrollDownButton>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import { SelectScrollUpButton } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectScrollUpButton
v-bind="restProps"
:class="
cn(
'flex cursor-default items-center justify-center py-1 text-muted-foreground',
className
)
"
>
<slot>
<i class="icon-[lucide--chevron-up]" />
</slot>
</SelectScrollUpButton>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import { SelectSeparator } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectSeparatorProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectSeparator
v-bind="restProps"
:class="cn('-mx-1 my-1 h-px bg-border-default', className)"
/>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import { SelectIcon, SelectTrigger } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className, ...restProps } = defineProps<
SelectTriggerProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<SelectTrigger
v-bind="restProps"
:class="
cn(
'flex h-10 w-full cursor-pointer select-none items-center justify-between',
'rounded-lg px-4 py-2 text-sm',
'bg-secondary-background text-base-foreground',
'border-[2.5px] border-solid border-transparent',
'transition-all duration-200 ease-in-out',
'focus:border-node-component-border focus:outline-none',
'data-[placeholder]:text-muted-foreground',
'disabled:cursor-not-allowed disabled:opacity-60',
'[&>span]:truncate',
className
)
"
>
<slot />
<SelectIcon as-child>
<i class="icon-[lucide--chevron-down] shrink-0 text-muted-foreground" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from 'reka-ui'
const props = defineProps<SelectValueProps>()
</script>
<template>
<SelectValue v-bind="props">
<slot />
</SelectValue>
</template>

View File

@@ -71,7 +71,7 @@ onClickOutside(rootEl, () => {
<i
v-if="!disabled && !isEditing"
aria-hidden="true"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground transition-opacity opacity-0 group-hover:opacity-100"
/>
</TagsInputRoot>
</template>

View File

@@ -23,6 +23,11 @@ const showInput = computed(() => isEditing.value || isEmpty)
const { forwardRef, currentElement } = useForwardExpose()
const registerFocus = inject(tagsInputFocusKey, undefined)
function handleEscape() {
currentElement.value?.blur()
isEditing.value = false
}
onMounted(() => {
registerFocus?.(() => currentElement.value?.focus())
})
@@ -44,5 +49,6 @@ onUnmounted(() => {
className
)
"
@keydown.escape.stop="handleEscape"
/>
</template>

View File

@@ -1,100 +1,128 @@
<template>
<div class="base-widget-layout rounded-2xl overflow-hidden relative">
<Button
v-show="!isRightPanelOpen && hasRightPanel"
size="lg"
:class="
cn('absolute top-4 right-18 z-10', 'transition-opacity duration-200', {
'opacity-0 pointer-events-none': isRightPanelOpen || !hasRightPanel
})
"
@click="toggleRightPanel"
<div
class="base-widget-layout rounded-2xl overflow-hidden relative"
@keydown.esc.capture="handleEscape"
>
<div
class="grid h-full w-full transition-[grid-template-columns] duration-300 ease-out"
:style="gridStyle"
>
<i class="icon-[lucide--panel-right]" />
</Button>
<Button
size="lg"
class="absolute top-4 right-6 z-10 transition-opacity duration-200 w-10"
@click="closeDialog"
>
<i class="pi pi-times" />
</Button>
<div class="flex h-full w-full">
<Transition name="slide-panel">
<nav
v-if="$slots.leftPanel && showLeftPanel"
:class="[
PANEL_SIZES.width,
PANEL_SIZES.minWidth,
PANEL_SIZES.maxWidth
]"
>
<slot name="leftPanel"></slot>
</nav>
</Transition>
<div class="flex-1 flex bg-base-background">
<div class="flex h-full w-full flex-col">
<header
v-if="$slots.header"
class="w-full h-18 px-6 flex items-center justify-between gap-2"
>
<div class="flex flex-1 shrink-0 gap-2">
<Button v-if="!notMobile" size="icon" @click="toggleLeftPanel">
<i
:class="
cn(
showLeftPanel
? 'icon-[lucide--panel-left]'
: 'icon-[lucide--panel-left-close]'
)
"
/>
</Button>
<slot name="header"></slot>
</div>
<slot name="header-right-area"></slot>
<div
:class="
cn(
'flex justify-end gap-2 w-0',
hasRightPanel && !isRightPanelOpen ? 'min-w-22' : 'min-w-10'
)
"
>
<Button
v-if="isRightPanelOpen && hasRightPanel"
size="lg"
@click="toggleRightPanel"
>
<i class="icon-[lucide--panel-right-close]" />
</Button>
</div>
</header>
<main class="flex min-h-0 flex-1 flex-col">
<!-- Fallback title bar when no leftPanel is provided -->
<slot name="contentFilter"></slot>
<h2
v-if="!$slots.leftPanel"
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
>
{{ contentTitle }}
</h2>
<div
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<slot name="content"></slot>
</div>
</main>
<nav
class="h-full overflow-hidden"
:inert="!showLeftPanel"
:aria-hidden="!showLeftPanel"
>
<div v-if="hasLeftPanel" class="h-full min-w-40 max-w-56">
<slot name="leftPanel" />
</div>
<aside
v-if="hasRightPanel && isRightPanelOpen"
class="w-1/4 min-w-40 max-w-80 pt-16 pb-8"
</nav>
<div class="flex flex-col bg-base-background overflow-hidden">
<header
v-if="$slots.header"
class="w-full h-18 px-6 flex items-center justify-between gap-2"
>
<slot name="rightPanel"></slot>
</aside>
<div class="flex flex-1 shrink-0 gap-2">
<Button
v-if="!notMobile"
size="icon"
:aria-label="
showLeftPanel ? t('g.hideLeftPanel') : t('g.showLeftPanel')
"
@click="toggleLeftPanel"
>
<i
:class="
cn(
showLeftPanel
? 'icon-[lucide--panel-left]'
: 'icon-[lucide--panel-left-close]'
)
"
/>
</Button>
<slot name="header" />
</div>
<slot name="header-right-area" />
<template v-if="!isRightPanelOpen">
<Button
v-if="hasRightPanel"
size="lg"
class="w-10 p-0"
:aria-label="t('g.showRightPanel')"
@click="toggleRightPanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<Button
size="lg"
class="w-10"
:aria-label="t('g.closeDialog')"
@click="closeDialog"
>
<i class="pi pi-times" />
</Button>
</template>
</header>
<main class="flex min-h-0 flex-1 flex-col">
<slot name="contentFilter" />
<h2
v-if="!hasLeftPanel"
class="text-xxl m-0 px-6 pt-2 pb-6 capitalize"
>
{{ contentTitle }}
</h2>
<div
class="min-h-0 flex-1 px-6 pt-0 pb-10 overflow-y-auto scrollbar-custom"
>
<slot name="content" />
</div>
</main>
</div>
<aside
v-if="hasRightPanel"
class="overflow-hidden"
:inert="!isRightPanelOpen"
:aria-hidden="!isRightPanelOpen"
>
<div
class="min-w-72 w-72 flex flex-col bg-modal-panel-background h-full"
>
<header
data-component-id="RightPanelHeader"
class="flex h-18 shrink-0 items-center gap-2 px-6"
>
<h2 v-if="rightPanelTitle" class="flex-1 text-base font-semibold">
{{ rightPanelTitle }}
</h2>
<div v-else class="flex-1">
<slot name="rightPanelHeaderTitle" />
</div>
<slot name="rightPanelHeaderActions" />
<Button
size="lg"
class="w-10 p-0"
:aria-label="t('g.hideRightPanel')"
@click="toggleRightPanel"
>
<i class="icon-[lucide--panel-right-close] size-4" />
</Button>
<Button
size="lg"
class="w-10 p-0"
:aria-label="t('g.closeDialog')"
@click="closeDialog"
>
<i class="pi pi-times" />
</Button>
</header>
<div class="min-h-0 flex-1 overflow-y-auto">
<slot name="rightPanel" />
</div>
</div>
</aside>
</div>
</div>
</template>
@@ -102,27 +130,29 @@
<script setup lang="ts">
import { useBreakpoints } from '@vueuse/core'
import { computed, inject, ref, useSlots, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { OnCloseKey } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
const { contentTitle } = defineProps<{
const { t } = useI18n()
const { contentTitle, rightPanelTitle } = defineProps<{
contentTitle: string
rightPanelTitle?: string
}>()
const isRightPanelOpen = defineModel<boolean>('rightPanelOpen', {
default: false
})
const BREAKPOINTS = { md: 880 }
const PANEL_SIZES = {
width: 'w-1/3',
minWidth: 'min-w-40',
maxWidth: 'max-w-56'
}
const slots = useSlots()
const hasLeftPanel = computed(() => !!slots.leftPanel)
const hasRightPanel = computed(() => !!slots.rightPanel)
const BREAKPOINTS = { md: 880 }
const closeDialog = inject(OnCloseKey, () => {})
const breakpoints = useBreakpoints(BREAKPOINTS)
@@ -131,8 +161,6 @@ const notMobile = breakpoints.greater('md')
const isLeftPanelOpen = ref<boolean>(true)
const mobileMenuOpen = ref<boolean>(false)
const hasRightPanel = computed(() => !!slots.rightPanel)
watch(notMobile, (isDesktop) => {
if (!isDesktop) {
mobileMenuOpen.value = false
@@ -146,6 +174,12 @@ const showLeftPanel = computed(() => {
return shouldShow
})
const gridStyle = computed(() => ({
gridTemplateColumns: hasRightPanel.value
? `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr ${isRightPanelOpen.value ? '18rem' : '0rem'}`
: `${hasLeftPanel.value && showLeftPanel.value ? '14rem' : '0rem'} 1fr`
}))
const toggleLeftPanel = () => {
if (notMobile.value) {
isLeftPanelOpen.value = !isLeftPanelOpen.value
@@ -157,6 +191,23 @@ const toggleLeftPanel = () => {
const toggleRightPanel = () => {
isRightPanelOpen.value = !isRightPanelOpen.value
}
function handleEscape(event: KeyboardEvent) {
const target = event.target
if (!(target instanceof HTMLElement)) return
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target.isContentEditable
) {
return
}
if (isRightPanelOpen.value) {
event.stopPropagation()
isRightPanelOpen.value = false
}
}
</script>
<style scoped>
.base-widget-layout {
@@ -171,28 +222,4 @@ const toggleRightPanel = () => {
max-width: 1724px;
}
}
/* Fade transition for buttons */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Slide transition for left panel */
.slide-panel-enter-active,
.slide-panel-leave-active {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
backface-visibility: hidden;
}
.slide-panel-enter-from,
.slide-panel-leave-to {
transform: translateX(-100%);
}
</style>

View File

@@ -5,7 +5,7 @@
disabled: !isOverflowing,
pt: { text: { class: 'whitespace-nowrap' } }
}"
class="flex cursor-pointer items-start gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
class="flex cursor-pointer items-center-safe gap-2 rounded-md px-4 py-3 text-sm transition-colors text-base-foreground"
:class="
active
? 'bg-interface-menu-component-surface-selected'
@@ -15,25 +15,32 @@
@mouseenter="checkOverflow"
@click="onClick"
>
<div v-if="icon" class="pt-0.5">
<NavIcon :icon="icon" />
</div>
<NavIcon v-if="icon" :icon="icon" />
<i v-else class="text-neutral icon-[lucide--folder] text-xs shrink-0" />
<span ref="textRef" class="min-w-0 truncate">
<slot></slot>
<slot />
</span>
<StatusBadge
v-if="badge !== undefined"
:label="String(badge)"
severity="contrast"
variant="circle"
class="ml-auto"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import type { NavItemData } from '@/types/navTypes'
import NavIcon from './NavIcon.vue'
const { icon, active, onClick } = defineProps<{
const { icon, badge, active, onClick } = defineProps<{
icon: NavItemData['icon']
badge?: NavItemData['badge']
active?: boolean
onClick: () => void
}>()

View File

@@ -22,6 +22,7 @@
v-for="subItem in item.items"
:key="subItem.id"
:icon="subItem.icon"
:badge="subItem.badge"
:active="activeItem === subItem.id"
@click="activeItem = subItem.id"
>
@@ -32,6 +33,7 @@
<div v-else class="flex flex-col gap-2">
<NavItem
:icon="item.icon"
:badge="item.badge"
:active="activeItem === item.id"
@click="activeItem = item.id"
>

View File

@@ -77,6 +77,7 @@ export interface VueNodeData {
outputs?: INodeOutputSlot[]
resizable?: boolean
shape?: number
showAdvanced?: boolean
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
@@ -314,7 +315,8 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
resizable: node.resizable,
shape: node.shape
shape: node.shape,
showAdvanced: node.showAdvanced
}
}
@@ -398,6 +400,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
vueNodeData.set(id, extractVueNodeData(node))
const initializeVueNodeLayout = () => {
// Check if the node was removed mid-sequence
if (!nodeRefs.has(id)) return
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
@@ -427,7 +432,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
requestAnimationFrame(initializeVueNodeLayout)
}
// Call original callback if provided
@@ -565,6 +570,13 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
? propertyEvent.newValue
: undefined
})
break
case 'showAdvanced':
vueNodeData.set(nodeId, {
...currentData,
showAdvanced: Boolean(propertyEvent.newValue)
})
break
}
}
},

View File

@@ -73,6 +73,14 @@ export const useNodeBadge = () => {
onMounted(() => {
const nodePricing = useNodePricing()
watch(
() => nodePricing.pricingRevision.value,
() => {
if (!showApiPricingBadge.value) return
app.canvas?.setDirty(true, true)
}
)
extensionStore.registerExtension({
name: 'Comfy.NodeBadge',
nodeCreated(node: LGraphNode) {
@@ -111,17 +119,16 @@ export const useNodeBadge = () => {
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
// Get the pricing function to determine if this node has dynamic pricing
// JSONata rules are dynamic if they depend on any widgets/inputs/input_groups
const pricingConfig = nodePricing.getNodePricingConfig(node)
const hasDynamicPricing =
typeof pricingConfig?.displayPrice === 'function'
let creditsBadge
const createBadge = () => {
const price = nodePricing.getNodeDisplayPrice(node)
return priceBadge.getCreditsBadge(price)
}
!!pricingConfig &&
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
// Keep the existing widget-watch wiring ONLY to trigger redraws on widget change.
// (We no longer rely on it to hold the current badge value.)
if (hasDynamicPricing) {
// For dynamic pricing nodes, use computed that watches widget changes
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
@@ -133,13 +140,63 @@ export const useNodeBadge = () => {
triggerCanvasRedraw: true
})
creditsBadge = computedWithWidgetWatch(createBadge)
} else {
// For static pricing nodes, use regular computed
creditsBadge = computed(createBadge)
// Ensure watchers are installed; ignore the returned value.
// (This call is what registers the widget listeners in most implementations.)
computedWithWidgetWatch(() => 0)
// Hook into connection changes to trigger price recalculation
// This handles both connect and disconnect in VueNodes mode
const relevantInputs = pricingConfig?.depends_on?.inputs ?? []
const inputGroupPrefixes =
pricingConfig?.depends_on?.input_groups ?? []
const hasRelevantInputs =
relevantInputs.length > 0 || inputGroupPrefixes.length > 0
if (hasRelevantInputs) {
const originalOnConnectionsChange = node.onConnectionsChange
node.onConnectionsChange = function (
type,
slotIndex,
isConnected,
link,
ioSlot
) {
originalOnConnectionsChange?.call(
this,
type,
slotIndex,
isConnected,
link,
ioSlot
)
// Only trigger if this input affects pricing
const inputName = ioSlot?.name
if (!inputName) return
const isRelevantInput =
relevantInputs.includes(inputName) ||
inputGroupPrefixes.some((prefix) =>
inputName.startsWith(prefix + '.')
)
if (isRelevantInput) {
nodePricing.triggerPriceRecalculation(node)
}
}
}
}
node.badges.push(() => creditsBadge.value)
let lastLabel = nodePricing.getNodeDisplayPrice(node)
let lastBadge = priceBadge.getCreditsBadge(lastLabel)
const creditsBadgeGetter: () => LGraphBadge = () => {
const label = nodePricing.getNodeDisplayPrice(node)
if (label !== lastLabel) {
lastLabel = label
lastBadge = priceBadge.getCreditsBadge(label)
}
return lastBadge
}
node.badges.push(creditsBadgeGetter)
}
},
init() {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -305,24 +305,40 @@ describe('useJobList', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('sorts all tasks by priority descending', async () => {
it('sorts all tasks by create time', async () => {
queueStoreMock.pendingTasks = [
createTask({ promptId: 'p', queueIndex: 1, mockState: 'pending' })
createTask({
promptId: 'p',
queueIndex: 1,
mockState: 'pending',
createTime: 3000
})
]
queueStoreMock.runningTasks = [
createTask({ promptId: 'r', queueIndex: 5, mockState: 'running' })
createTask({
promptId: 'r',
queueIndex: 5,
mockState: 'running',
createTime: 2000
})
]
queueStoreMock.historyTasks = [
createTask({ promptId: 'h', queueIndex: 3, mockState: 'completed' })
createTask({
promptId: 'h',
queueIndex: 3,
mockState: 'completed',
createTime: 1000,
executionEndTimestamp: 5000
})
]
const { allTasksSorted } = initComposable()
await flush()
expect(allTasksSorted.value.map((task) => task.promptId)).toEqual([
'p',
'r',
'h',
'p'
'h'
])
})

View File

@@ -1,3 +1,4 @@
import { orderBy } from 'es-toolkit/array'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -197,13 +198,15 @@ export function useJobList() {
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref<JobSortMode>('mostRecent')
const mostRecentTimestamp = (task: TaskItemImpl) => task.createTime ?? 0
const allTasksSorted = computed<TaskItemImpl[]>(() => {
const all = [
...queueStore.pendingTasks,
...queueStore.runningTasks,
...queueStore.historyTasks
]
return all.sort((a, b) => b.queueIndex - a.queueIndex)
return orderBy(all, [mostRecentTimestamp], ['desc'])
})
const tasksWithJobState = computed<TaskWithState[]>(() =>

View File

@@ -5,6 +5,10 @@ import type { Ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const downloadFileMock = vi.fn()
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: (...args: any[]) => downloadFileMock(...args)
@@ -55,7 +59,8 @@ const workflowStoreMock = {
createTemporary: vi.fn()
}
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStoreMock
useWorkflowStore: () => workflowStoreMock,
ComfyWorkflow: class {}
}))
const interruptMock = vi.fn()
@@ -104,6 +109,13 @@ vi.mock('@/stores/queueStore', () => ({
useQueueStore: () => queueStoreMock
}))
const executionStoreMock = {
clearInitializationByPromptId: vi.fn()
}
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStoreMock
}))
const getJobWorkflowMock = vi.fn()
vi.mock('@/services/jobOutputCache', () => ({
getJobWorkflow: (...args: any[]) => getJobWorkflowMock(...args)

View File

@@ -6,6 +6,7 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { st, t } from '@/i18n'
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -15,6 +16,7 @@ import { downloadBlob } from '@/scripts/utils'
import { useDialogService } from '@/services/dialogService'
import { getJobWorkflow } from '@/services/jobOutputCache'
import { useLitegraphService } from '@/services/litegraphService'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
@@ -44,6 +46,7 @@ export function useJobMenu(
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
const { copyToClipboard } = useCopyToClipboard()
const litegraphService = useLitegraphService()
const nodeDefStore = useNodeDefStore()
@@ -72,10 +75,15 @@ export function useJobMenu(
const target = resolveItem(item)
if (!target) return
if (target.state === 'running' || target.state === 'initialization') {
await api.interrupt(target.id)
if (isCloud) {
await api.deleteItem('queue', target.id)
} else {
await api.interrupt(target.id)
}
} else if (target.state === 'pending') {
await api.deleteItem('queue', target.id)
}
executionStore.clearInitializationByPromptId(target.id)
await queueStore.update()
}

View File

@@ -1,6 +1,7 @@
import { markRaw } from 'vue'
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
import { useQueueStore } from '@/stores/queueStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useAssetsSidebarTab = (): SidebarTabExtension => {
@@ -11,6 +12,12 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
tooltip: 'sideToolbar.assets',
label: 'sideToolbar.labels.assets',
component: markRaw(AssetsSidebarTab),
type: 'vue'
type: 'vue',
iconBadge: () => {
const queueStore = useQueueStore()
return queueStore.pendingTasks.length > 0
? queueStore.pendingTasks.length.toString()
: null
}
}
}

View File

@@ -123,8 +123,7 @@ export const useContextMenuTranslation = () => {
}
// for capture translation text of input and widget
const extraInfo = (options.extra ||
options.parentMenu?.options?.extra) as
const extraInfo = (options.extra || options.parentMenu?.options?.extra) as
| { inputs?: INodeInputSlot[]; widgets?: IWidget[] }
| undefined
// widgets and inputs

View File

@@ -1235,8 +1235,11 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.ToggleLinear',
icon: 'pi pi-database',
label: 'Toggle Simple Mode',
function: () => {
function: (metadata?: Record<string, unknown>) => {
const source =
typeof metadata?.source === 'string' ? metadata.source : 'keybind'
const newMode = !canvasStore.linearMode
if (newMode) useTelemetry()?.trackEnterLinear({ source })
app.rootGraph.extra.linearMode = newMode
workflowStore.activeWorkflow?.changeTracker?.checkState()
canvasStore.linearMode = newMode

View File

@@ -1,5 +1,6 @@
import { computed, reactive, readonly } from 'vue'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
@@ -95,6 +96,8 @@ export function useFeatureFlags() {
)
},
get teamWorkspacesEnabled() {
if (!isCloud) return false
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)

View File

@@ -1,4 +1,4 @@
import { refDebounced, watchDebounced } from '@vueuse/core'
import { refThrottled, watchDebounced } from '@vueuse/core'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
import { computed, ref, watch } from 'vue'
@@ -84,7 +84,7 @@ export function useTemplateFiltering(
return ['ComfyUI', 'External or Remote API']
})
const debouncedSearchQuery = refDebounced(searchQuery, 50)
const debouncedSearchQuery = refThrottled(searchQuery, 50)
const filteredBySearch = computed(() => {
if (!debouncedSearchQuery.value.trim()) {

View File

@@ -175,4 +175,32 @@ describe('Autogrow', () => {
await nextTick()
expect(node.inputs.length).toBe(5)
})
test('Can deserialize a complex node', async () => {
const graph = new LGraph()
const node = testNode()
graph.add(node)
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'a' })
addAutogrow(node, { min: 1, input: inputsSpec, prefix: 'b' })
addNodeInput(node, { name: 'aa', isOptional: false, type: 'IMAGE' })
connectInput(node, 0, graph)
connectInput(node, 1, graph)
connectInput(node, 3, graph)
connectInput(node, 4, graph)
const serialized = graph.serialize()
graph.clear()
graph.configure(serialized)
const newNode = graph.nodes[0]!
expect(newNode.inputs.map((i) => i.name)).toStrictEqual([
'0.a0',
'0.a1',
'0.a2',
'1.b0',
'1.b1',
'1.b2',
'aa'
])
})
})

View File

@@ -1,4 +1,5 @@
import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type {
@@ -342,7 +343,9 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
//ensure outputs get updated
const index = node.inputs.length - 1
requestAnimationFrame(() => {
const input = node.inputs.at(index)!
const input = node.inputs[index]
if (!input) return
node.inputs[index] = shallowReactive(input)
node.onConnectionsChange?.(
LiteGraph.INPUT,
index,
@@ -385,20 +388,32 @@ function addAutogrowGroup(
...autogrowOrdinalToName(ordinal, input.name, groupName, node)
}))
const newInputs = namedSpecs
.filter(
(namedSpec) => !node.inputs.some((inp) => inp.name === namedSpec.name)
)
.map((namedSpec) => {
addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget))
ensureWidgetForInput(node, input)
return input
})
const newInputs = namedSpecs.map((namedSpec) => {
addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget))
ensureWidgetForInput(node, input)
return input
})
for (const newInput of newInputs) {
for (const existingInput of remove(
node.inputs,
(inp) => inp.name === newInput.name
)) {
//NOTE: link.target_slot is updated on spliceInputs call
newInput.link ??= existingInput.link
}
}
const targetName = autogrowOrdinalToName(
ordinal - 1,
inputSpecs.at(-1)!.name,
groupName,
node
).name
const lastIndex = node.inputs.findLastIndex((inp) =>
inp.name.startsWith(groupName)
inp.name.startsWith(targetName)
)
const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1
spliceInputs(node, insertionIndex, 0, ...newInputs)
@@ -427,13 +442,14 @@ function autogrowInputConnected(index: number, node: AutogrowNode) {
const input = node.inputs[index]
const groupName = input.name.slice(0, input.name.lastIndexOf('.'))
const lastInput = node.inputs.findLast((inp) =>
inp.name.startsWith(groupName)
inp.name.startsWith(groupName + '.')
)
const ordinal = resolveAutogrowOrdinal(input.name, groupName, node)
if (
!lastInput ||
ordinal == undefined ||
ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node)
(ordinal !== resolveAutogrowOrdinal(lastInput.name, groupName, node) &&
!app.configuringGraph)
)
return
addAutogrowGroup(ordinal + 1, groupName, node)
@@ -453,6 +469,7 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
inp.name.lastIndexOf('.') === groupName.length
)
const stride = inputSpecs.length
if (stride + index >= node.inputs.length) return
if (groupInputs.length % stride !== 0) {
console.error('Failed to group multi-input autogrow inputs')
return
@@ -473,10 +490,24 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
const curIndex = node.inputs.findIndex((inp) => inp === curInput)
if (curIndex === -1) throw new Error('missing input')
link.target_slot = curIndex
node.onConnectionsChange?.(
LiteGraph.INPUT,
curIndex,
true,
link,
curInput
)
}
const lastInput = groupInputs.at(column - stride)
if (!lastInput) continue
lastInput.link = null
node.onConnectionsChange?.(
LiteGraph.INPUT,
node.inputs.length + column - stride,
false,
null,
lastInput
)
}
const removalChecks = groupInputs.slice((min - 1) * stride)
let i
@@ -564,5 +595,6 @@ function applyAutogrow(node: LGraphNode, inputSpecV2: InputSpecV2) {
prefix,
inputSpecs: inputsV2
}
for (let i = 0; i < min; i++) addAutogrowGroup(i, inputSpecV2.name, node)
for (let i = 0; i === 0 || i < min; i++)
addAutogrowGroup(i, inputSpecV2.name, node)
}

View File

@@ -1,10 +1,17 @@
import { t } from '@/i18n'
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
// Zendesk feedback URL - update this with the actual URL
const ZENDESK_FEEDBACK_URL =
'https://support.comfy.org/hc/en-us/requests/new?ticket_form_id=43066738713236'
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
const distribution = getDistribution()
const params = new URLSearchParams({
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
[ZENDESK_FIELDS.DISTRIBUTION]: distribution
})
const feedbackUrl = `${ZENDESK_BASE_URL}?${params.toString()}`
const buttons: ActionBarButton[] = [
{
@@ -12,7 +19,7 @@ const buttons: ActionBarButton[] = [
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
window.open(ZENDESK_FEEDBACK_URL, '_blank', 'noopener,noreferrer')
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
}
}
]

View File

@@ -2,7 +2,6 @@ import { watchDebounced } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { loadRemoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { useExtensionService } from '@/services/extensionService'
@@ -26,7 +25,7 @@ useExtensionService().registerExtension({
{ debounce: 256, immediate: true }
)
// Poll for config updates every 10 minutes
setInterval(() => void loadRemoteConfig(), 600_000)
// Poll for config updates every 10 minutes (with auth)
setInterval(() => void refreshRemoteConfig(), 600_000)
}
})

View File

@@ -98,6 +98,19 @@ function onNodeCreated(this: LGraphNode) {
}
})
}
const widgets = this.widgets!
widgets.push({
name: 'index',
type: 'hidden',
get value() {
return widgets.slice(2).findIndex((w) => w.value === comboWidget.value)
},
set value(_) {},
draw: () => undefined,
computeSize: () => [0, -4],
options: { hidden: true },
y: 0
})
addOption(this)
}

View File

@@ -1,4 +1,4 @@
import { isCloud } from '@/platform/distribution/types'
import { isCloud, isNightly } from '@/platform/distribution/types'
import './clipspace'
import './contextMenuFilter'
@@ -32,9 +32,18 @@ if (isCloud) {
await import('./cloudRemoteConfig')
await import('./cloudBadges')
await import('./cloudSessionCookie')
await import('./cloudFeedbackTopbarButton')
if (window.__CONFIG__?.subscription_required) {
await import('./cloudSubscription')
}
}
// Feedback button for cloud and nightly builds
if (isCloud || isNightly) {
await import('./cloudFeedbackTopbarButton')
}
// Nightly-only extensions
if (isNightly && !isCloud) {
await import('./nightlyBadges')
}

View File

@@ -0,0 +1,17 @@
import { t } from '@/i18n'
import { useExtensionService } from '@/services/extensionService'
import type { TopbarBadge } from '@/types/comfy'
const badges: TopbarBadge[] = [
{
text: t('nightly.badge.label'),
label: t('g.nightly'),
variant: 'warning',
tooltip: t('nightly.badge.tooltip')
}
]
useExtensionService().registerExtension({
name: 'Comfy.Nightly.Badges',
topbarBadges: badges
})

View File

@@ -75,7 +75,9 @@ useExtensionService().registerExtension({
for (const previewWidget of previewWidgets) {
const text = message.text ?? ''
previewWidget.value = Array.isArray(text) ? (text[0] ?? '') : text
previewWidget.value = Array.isArray(text)
? (text?.join('\n\n') ?? '')
: text
}
}
}

View File

@@ -1,6 +1,5 @@
import { MediaRecorder as ExtendableMediaRecorder } from 'extendable-media-recorder'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodePaste } from '@/composables/node/useNodePaste'
@@ -25,6 +24,17 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
import { app } from '../../scripts/app'
function updateUIWidget(
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
url: string = ''
) {
audioUIWidget.element.src = url
audioUIWidget.value = url
audioUIWidget.callback?.(url)
if (url) audioUIWidget.element.classList.remove('empty-audio-widget')
else audioUIWidget.element.classList.add('empty-audio-widget')
}
async function uploadFile(
audioWidget: IStringWidget,
audioUIWidget: DOMWidget<HTMLAudioElement, string>,
@@ -55,10 +65,10 @@ async function uploadFile(
}
if (updateNode) {
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(path))
updateUIWidget(
audioUIWidget,
api.apiURL(getResourceURL(...splitFilePath(path)))
)
audioWidget.value = path
// Manually trigger the callback to update VueNodes
audioWidget.callback?.(path)
@@ -118,26 +128,18 @@ app.registerExtension({
const audios = output.audio
if (!audios?.length) return
const audio = audios[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(
audio.subfolder ?? '',
audio.filename ?? '',
audio.type
)
const resourceUrl = getResourceURL(
audio.subfolder ?? '',
audio.filename ?? '',
audio.type
)
audioUIWidget.element.classList.remove('empty-audio-widget')
updateUIWidget(audioUIWidget, api.apiURL(resourceUrl))
}
}
audioUIWidget.onRemove = useChainCallback(
audioUIWidget.onRemove,
() => {
if (!audioUIWidget.element) return
audioUIWidget.element.pause()
audioUIWidget.element.src = ''
audioUIWidget.element.remove()
}
)
let value = ''
audioUIWidget.options.getValue = () => value
audioUIWidget.options.setValue = (v) => (value = v)
return { widget: audioUIWidget }
}
@@ -156,10 +158,12 @@ app.registerExtension({
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
const audio = output.audio[0]
audioUIWidget.element.src = api.apiURL(
getResourceURL(audio.subfolder ?? '', audio.filename ?? '', audio.type)
const resourceUrl = getResourceURL(
audio.subfolder ?? '',
audio.filename ?? '',
audio.type
)
audioUIWidget.element.classList.remove('empty-audio-widget')
updateUIWidget(audioUIWidget, api.apiURL(resourceUrl))
}
}
})
@@ -183,18 +187,18 @@ app.registerExtension({
const audioUIWidget = node.widgets.find(
(w) => w.name === 'audioUI'
) as unknown as DOMWidget<HTMLAudioElement, string>
audioUIWidget.options.canvasOnly = true
const onAudioWidgetUpdate = () => {
if (typeof audioWidget.value !== 'string') return
audioUIWidget.element.src = api.apiURL(
getResourceURL(...splitFilePath(audioWidget.value))
updateUIWidget(
audioUIWidget,
api.apiURL(
getResourceURL(...splitFilePath(audioWidget.value ?? ''))
)
)
}
// Initially load default audio file to audioUIWidget.
if (audioWidget.value) {
onAudioWidgetUpdate()
}
onAudioWidgetUpdate()
audioWidget.callback = onAudioWidgetUpdate
// Load saved audio file widget values if restoring from workflow
@@ -202,9 +206,7 @@ app.registerExtension({
node.onGraphConfigured = function () {
// @ts-expect-error fixme ts strict error
onGraphConfigured?.apply(this, arguments)
if (audioWidget.value) {
onAudioWidgetUpdate()
}
onAudioWidgetUpdate()
}
const handleUpload = async (files: File[]) => {
@@ -328,7 +330,7 @@ app.registerExtension({
URL.revokeObjectURL(audioUIWidget.element.src)
}
audioUIWidget.element.src = URL.createObjectURL(audioBlob)
updateUIWidget(audioUIWidget, URL.createObjectURL(audioBlob))
isRecording = false

View File

@@ -13,7 +13,9 @@ const DEFAULT_TRACKED_PROPERTIES: string[] = [
'flags.pinned',
'mode',
'color',
'bgcolor'
'bgcolor',
'shape',
'showAdvanced'
]
/**
* Manages node properties with optional change tracking and instrumentation.

View File

@@ -30,7 +30,7 @@ export interface IDrawOptions {
highlight?: boolean
}
const ROTATION_OFFSET = -Math.PI / 2
const ROTATION_OFFSET = -Math.PI
/** Shared base class for {@link LGraphNode} input and output slots. */
export abstract class NodeSlot extends SlotBase implements INodeSlot {

View File

@@ -24,6 +24,7 @@
"assets": "الأصول",
"baseModels": "النماذج الأساسية",
"browseAssets": "تصفح الأصول",
"byType": "حسب النوع",
"checkpoints": "نقاط التحقق",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "مثال:",
@@ -45,6 +46,10 @@
"failed": "فشل التنزيل",
"inProgress": "جاري تنزيل {assetName}..."
},
"emptyImported": {
"canImport": "لا توجد نماذج مستوردة بعد. انقر على \"استيراد نموذج\" لإضافة نموذجك الخاص.",
"restricted": "النماذج الشخصية متاحة فقط لمستوى Creator وما فوق."
},
"errorFileTooLarge": "الملف يتجاوز الحد الأقصى المسموح به للحجم",
"errorFormatNotAllowed": "يسمح فقط بصيغة SafeTensor",
"errorModelTypeNotSupported": "نوع النموذج هذا غير مدعوم",
@@ -61,6 +66,7 @@
"finish": "إنهاء",
"genericLinkPlaceholder": "الصق الرابط هنا",
"importAnother": "استيراد آخر",
"imported": "مستوردة",
"jobId": "معرّف المهمة",
"loadingModels": "جارٍ تحميل {type}...",
"maxFileSize": "الحد الأقصى لحجم الملف: {size}",
@@ -70,6 +76,30 @@
"threeDModelPlaceholder": "نموذج ثلاثي الأبعاد"
},
"modelAssociatedWithLink": "النموذج المرتبط بالرابط الذي قدمته:",
"modelInfo": {
"addBaseModel": "أضف نموذجًا أساسيًا...",
"addTag": "أضف وسمًا...",
"additionalTags": "وسوم إضافية",
"baseModelUnknown": "النموذج الأساسي غير معروف",
"basicInfo": "معلومات أساسية",
"compatibleBaseModels": "نماذج أساسية متوافقة",
"description": "الوصف",
"descriptionNotSet": "لم يتم تعيين وصف",
"descriptionPlaceholder": "أضف وصفًا لهذا النموذج...",
"displayName": "اسم العرض",
"editDisplayName": "تعديل اسم العرض",
"fileName": "اسم الملف",
"modelDescription": "وصف النموذج",
"modelTagging": "تصنيف النموذج",
"modelType": "نوع النموذج",
"noAdditionalTags": "لا توجد وسوم إضافية",
"selectModelPrompt": "اختر نموذجًا لعرض معلوماته",
"selectModelType": "اختر نوع النموذج...",
"source": "المصدر",
"title": "معلومات النموذج",
"triggerPhrases": "عبارات التفعيل",
"viewOnSource": "عرض على {source}"
},
"modelName": "اسم النموذج",
"modelNamePlaceholder": "أدخل اسمًا لهذا النموذج",
"modelTypeSelectorLabel": "ما نوع هذا النموذج؟",
@@ -684,6 +714,7 @@
"clearAll": "مسح الكل",
"clearFilters": "مسح الفلاتر",
"close": "إغلاق",
"closeDialog": "إغلاق الحوار",
"color": "اللون",
"comfy": "Comfy",
"comfyOrgLogoAlt": "شعار ComfyOrg",
@@ -700,6 +731,7 @@
"control_before_generate": "التحكم قبل التوليد",
"copied": "تم النسخ",
"copy": "نسخ",
"copyAll": "نسخ الكل",
"copyJobId": "نسخ معرف المهمة",
"copyToClipboard": "نسخ إلى الحافظة",
"copyURL": "نسخ الرابط",
@@ -762,6 +794,8 @@
"goToNode": "الانتقال إلى العقدة",
"graphNavigation": "التنقل في الرسم البياني",
"halfSpeed": "0.5x",
"hideLeftPanel": "إخفاء اللوحة اليسرى",
"hideRightPanel": "إخفاء اللوحة اليمنى",
"icon": "أيقونة",
"imageFailedToLoad": "فشل تحميل الصورة",
"imagePreview": "معاينة الصورة - استخدم مفاتيح الأسهم للتنقل بين الصور",
@@ -803,6 +837,7 @@
"name": "الاسم",
"newFolder": "مجلد جديد",
"next": "التالي",
"nightly": "NIGHTLY",
"no": "لا",
"noAudioRecorded": "لم يتم تسجيل أي صوت",
"noItems": "لا توجد عناصر",
@@ -817,6 +852,7 @@
"nodeSlotsError": "خطأ في فتحات العقدة",
"nodeWidgetsError": "خطأ في عناصر واجهة العقدة",
"nodes": "العُقَد",
"nodesCount": "{count} عقدة | {count} عقدة | {count} عقدة",
"nodesRunning": "العُقَد قيد التشغيل",
"none": "لا شيء",
"nothingToCopy": "لا يوجد ما يمكن نسخه",
@@ -891,7 +927,9 @@
"selectedFile": "الملف المحدد",
"setAsBackground": "تعيين كخلفية",
"settings": "الإعدادات",
"showLeftPanel": "إظهار اللوحة اليسرى",
"showReport": "عرض التقرير",
"showRightPanel": "إظهار اللوحة اليمنى",
"singleSelectDropdown": "قائمة منسدلة اختيار واحد",
"sort": "فرز",
"source": "المصدر",
@@ -914,6 +952,7 @@
"updating": "جارٍ التحديث",
"upload": "رفع",
"usageHint": "تلميح الاستخدام",
"use": "استخدم",
"user": "المستخدم",
"versionMismatchWarning": "تحذير توافق الإصدارات",
"versionMismatchWarningMessage": "{warning}: {detail} زر https://docs.comfy.org/installation/update_comfyui#common-update-issues للحصول على تعليمات التحديث.",
@@ -1617,11 +1656,18 @@
"title": "سير العمل هذا يحتوي على عقد مفقودة"
}
},
"nightly": {
"badge": {
"label": "إصدار معاينة",
"tooltip": "أنت تستخدم إصدارًا ليليًا من ComfyUI. يرجى استخدام زر الملاحظات لمشاركة آرائك حول هذه الميزات."
}
},
"nodeCategories": {
"": "",
"3d": "ثلاثي الأبعاد",
"3d_models": "نماذج ثلاثية الأبعاد",
"BFL": "BFL",
"Bria": "Bria",
"ByteDance": "بايت دانس",
"Gemini": "جيميني",
"Ideogram": "إيديوغرام",
@@ -1643,6 +1689,7 @@
"Veo": "Veo",
"Vidu": "فيدو",
"Wan": "وان",
"WaveSpeed": "WaveSpeed",
"_for_testing": "_للاختبار",
"advanced": "متقدم",
"animation": "الرسوم المتحركة",
@@ -2084,6 +2131,7 @@
"NodeLibrary": "مكتبة العقد",
"Nodes 2_0": "Nodes 2.0",
"Notification Preferences": "تفضيلات الإشعارات",
"Other": "أخرى",
"PLY": "PLY",
"PlanCredits": "الخطة والاعتمادات",
"Pointer": "المؤشر",
@@ -2103,7 +2151,8 @@
"Vue Nodes": "عقد Vue",
"VueNodes": "عقد Vue",
"Window": "النافذة",
"Workflow": "سير العمل"
"Workflow": "سير العمل",
"Workspace": "مساحة العمل"
},
"shape": {
"CARD": "بطاقة",
@@ -2129,12 +2178,14 @@
"viewControls": "عناصر تحكم العرض"
},
"sideToolbar": {
"activeJobStatus": "المهمة النشطة: {status}",
"assets": "الأصول",
"backToAssets": "العودة إلى جميع الأصول",
"browseTemplates": "تصفح القوالب المثال",
"downloads": "التنزيلات",
"generatedAssetsHeader": "الأصول المُولدة",
"helpCenter": "مركز المساعدة",
"importedAssetsHeader": "الأصول المستوردة",
"labels": {
"assets": "الأصول",
"console": "وحدة التحكم",
@@ -2179,6 +2230,7 @@
"queue": "قائمة الانتظار",
"queueProgressOverlay": {
"activeJobs": "{count} مهمة نشطة | {count} مهام نشطة",
"activeJobsShort": "{count} نشط | {count} نشط",
"activeJobsSuffix": "مهام نشطة",
"cancelJobTooltip": "إلغاء المهمة",
"clearHistory": "مسح سجل قائمة الانتظار",
@@ -2267,9 +2319,11 @@
"beta": "نسخة تجريبية",
"billedMonthly": "يتم الفوترة شهريًا",
"billedYearly": "{total} يتم الفوترة سنويًا",
"cancelSubscription": "إلغاء الاشتراك",
"changeTo": "تغيير إلى {plan}",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "شعار Comfy Cloud",
"contactOwnerToSubscribe": "يرجى التواصل مع مالك مساحة العمل للاشتراك",
"contactUs": "تواصل معنا",
"creditsRemainingThisMonth": "الرصيد المتبقي لهذا الشهر",
"creditsRemainingThisYear": "الرصيد المتبقي لهذا العام",
@@ -2282,6 +2336,7 @@
"haveQuestions": "هل لديك أسئلة أو ترغب في معرفة المزيد عن المؤسسات؟",
"invoiceHistory": "سجل الفواتير",
"learnMore": "معرفة المزيد",
"managePayment": "إدارة الدفع",
"managePlan": "إدارة الخطة",
"manageSubscription": "إدارة الاشتراك",
"maxDuration": {
@@ -2317,6 +2372,7 @@
"subscribeToComfyCloud": "الاشتراك في Comfy Cloud",
"subscribeToRun": "اشتراك",
"subscribeToRunFull": "الاشتراك للتشغيل",
"subscriptionRequiredMessage": "الاشتراك مطلوب للأعضاء لتشغيل سير العمل على السحابة",
"tierNameYearly": "{name} سنوي",
"tiers": {
"creator": {
@@ -2348,6 +2404,7 @@
"viewMoreDetails": "عرض المزيد من التفاصيل",
"viewMoreDetailsPlans": "عرض المزيد من التفاصيل حول الخطط والأسعار",
"viewUsageHistory": "عرض سجل الاستخدام",
"workspaceNotSubscribed": "هذه مساحة العمل ليست مشتركة",
"yearly": "سنوي",
"yearlyCreditsLabel": "إجمالي الرصيد السنوي",
"yearlyDiscount": "خصم 20%",
@@ -2497,7 +2554,8 @@
"notSet": "غير محدد",
"provider": "مزود تسجيل الدخول",
"title": "إعدادات المستخدم",
"updatePassword": "تحديث كلمة المرور"
"updatePassword": "تحديث كلمة المرور",
"workspaceSettings": "إعدادات مساحة العمل"
},
"validation": {
"descriptionRequired": "الوصف مطلوب",
@@ -2602,6 +2660,57 @@
"workspaceNotFound": "لم يتم العثور على مساحة العمل"
}
},
"workspacePanel": {
"createWorkspaceDialog": {
"create": "إنشاء",
"message": "تتيح مساحات العمل للأعضاء مشاركة رصيد واحد. ستصبح المالك بعد الإنشاء.",
"nameLabel": "اسم مساحة العمل*",
"namePlaceholder": "أدخل اسم مساحة العمل",
"title": "إنشاء مساحة عمل جديدة"
},
"deleteDialog": {
"message": "سيتم فقدان أي أرصدة غير مستخدمة أو أصول غير محفوظة. لا يمكن التراجع عن هذا الإجراء.",
"messageWithName": "حذف \"{name}\"؟ سيتم فقدان أي أرصدة غير مستخدمة أو أصول غير محفوظة. لا يمكن التراجع عن هذا الإجراء.",
"title": "حذف هذه المساحة؟"
},
"editWorkspaceDialog": {
"nameLabel": "اسم مساحة العمل",
"save": "حفظ",
"title": "تعديل تفاصيل مساحة العمل"
},
"leaveDialog": {
"leave": "مغادرة",
"message": "لن تتمكن من الانضمام مرة أخرى إلا إذا تواصلت مع مالك مساحة العمل.",
"title": "مغادرة هذه المساحة؟"
},
"menu": {
"deleteWorkspace": "حذف مساحة العمل",
"deleteWorkspaceDisabledTooltip": "يرجى إلغاء الاشتراك النشط لمساحة العمل أولاً",
"editWorkspace": "تعديل تفاصيل مساحة العمل",
"leaveWorkspace": "مغادرة مساحة العمل"
},
"tabs": {
"planCredits": "الخطة والأرصدة"
},
"toast": {
"failedToCreateWorkspace": "فشل في إنشاء مساحة العمل",
"failedToDeleteWorkspace": "فشل في حذف مساحة العمل",
"failedToLeaveWorkspace": "فشل في مغادرة مساحة العمل",
"failedToUpdateWorkspace": "فشل في تحديث مساحة العمل",
"workspaceUpdated": {
"message": "تم حفظ تفاصيل مساحة العمل.",
"title": "تم تحديث مساحة العمل"
}
}
},
"workspaceSwitcher": {
"createWorkspace": "إنشاء مساحة عمل جديدة",
"maxWorkspacesReached": "يمكنك امتلاك ١٠ مساحات عمل فقط. احذف واحدة لإنشاء مساحة جديدة.",
"roleMember": "عضو",
"roleOwner": "المالك",
"subscribe": "اشترك",
"switchWorkspace": "تبديل مساحة العمل"
},
"zoomControls": {
"hideMinimap": "إخفاء الخريطة المصغرة",
"label": "عناصر التحكم في التكبير",

View File

@@ -328,6 +328,68 @@
}
}
},
"BriaImageEditNode": {
"description": "حرر الصور باستخدام أحدث نموذج من Bria",
"display_name": "تحرير صورة Bria",
"inputs": {
"control_after_generate": {
"name": "التحكم بعد التوليد"
},
"guidance_scale": {
"name": "مقياس التوجيه",
"tooltip": "القيمة الأعلى تجعل الصورة تتبع التوجيه بشكل أدق."
},
"image": {
"name": "الصورة"
},
"mask": {
"name": "القناع",
"tooltip": "إذا لم يتم تحديده، سيتم تطبيق التحرير على الصورة بالكامل."
},
"model": {
"name": "النموذج"
},
"moderation": {
"name": "الإشراف",
"tooltip": "إعدادات الإشراف"
},
"moderation_prompt_content_moderation": {
"name": "إشراف محتوى التوجيه"
},
"moderation_visual_input_moderation": {
"name": "إشراف الإدخال البصري"
},
"moderation_visual_output_moderation": {
"name": "إشراف الإخراج البصري"
},
"negative_prompt": {
"name": "توجيه سلبي"
},
"prompt": {
"name": "التوجيه",
"tooltip": "تعليمات لتحرير الصورة"
},
"seed": {
"name": "البذرة"
},
"steps": {
"name": "الخطوات"
},
"structured_prompt": {
"name": "توجيه منظم",
"tooltip": "سلسلة نصية تحتوي على توجيه التحرير المنظم بصيغة JSON. استخدم هذا بدلاً من التوجيه المعتاد للتحكم الدقيق والبرمجي."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "توجيه منظم",
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "إنشاء فيديو باستخدام المطالبة النصية والإطار الأول والأخير.",
"display_name": "تحويل الإطار الأول-الأخير من ByteDance إلى فيديو",
@@ -2031,7 +2093,9 @@
"choice": {
"name": "اختيار"
},
"option0": {
"index": {
},
"option1": {
}
},
"outputs": {
@@ -11209,19 +11273,28 @@
}
},
"ResizeImageMaskNode": {
"description": "تغيير حجم صورة أو قناع باستخدام طرق تحجيم مختلفة.",
"display_name": "تغيير حجم الصورة/القناع",
"inputs": {
"input": {
"name": "الإدخال"
},
"resize_type": {
"name": "نوع تغيير الحجم"
"name": "نوع تغيير الحجم",
"tooltip": "اختر طريقة تغيير الحجم: حسب الأبعاد الدقيقة، عامل التحجيم، مطابقة صورة أخرى، إلخ."
},
"resize_type_multiplier": {
"name": "المضاعف"
"resize_type_crop": {
"name": "قص"
},
"resize_type_height": {
"name": "الارتفاع"
},
"resize_type_width": {
"name": "العرض"
},
"scale_method": {
"name": "طريقة التحجيم"
"name": "طريقة التحجيم",
"tooltip": "خوارزمية الاستيفاء. 'area' هي الأفضل لتصغير الحجم، و'lanczos' لتكبير الحجم، و'nearest-exact' لفن البكسل."
}
},
"outputs": {
@@ -13450,6 +13523,40 @@
}
}
},
"TextEncodeZImageOmni": {
"display_name": "TextEncodeZImageOmni",
"inputs": {
"auto_resize_images": {
"name": "تغيير حجم الصور تلقائياً"
},
"clip": {
"name": "clip"
},
"image1": {
"name": "الصورة ١"
},
"image2": {
"name": "الصورة ٢"
},
"image3": {
"name": "الصورة ٣"
},
"image_encoder": {
"name": "مُرمّز الصورة"
},
"prompt": {
"name": "التوجيه"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TextToLowercase": {
"display_name": "تحويل النص إلى أحرف صغيرة",
"inputs": {
@@ -15621,6 +15728,79 @@
}
}
},
"WanInfiniteTalkToVideo": {
"display_name": "WanInfiniteTalkToVideo",
"inputs": {
"audio_encoder_output_1": {
"name": "مخرجات ترميز الصوت ١"
},
"audio_scale": {
"name": "مقياس الصوت"
},
"clip_vision_output": {
"name": "مخرجات clip للرؤية"
},
"height": {
"name": "الارتفاع"
},
"length": {
"name": "الطول"
},
"mode": {
"name": "الوضع"
},
"model": {
"name": "النموذج"
},
"model_patch": {
"name": "تصحيح النموذج"
},
"motion_frame_count": {
"name": "عدد إطارات الحركة",
"tooltip": "عدد الإطارات السابقة المستخدمة كسياق للحركة."
},
"negative": {
"name": "سلبي"
},
"positive": {
"name": "إيجابي"
},
"previous_frames": {
"name": "الإطارات السابقة"
},
"start_image": {
"name": "صورة البداية"
},
"vae": {
"name": "vae"
},
"width": {
"name": "العرض"
}
},
"outputs": {
"0": {
"name": "النموذج",
"tooltip": null
},
"1": {
"name": "إيجابي",
"tooltip": null
},
"2": {
"name": "سلبي",
"tooltip": null
},
"3": {
"name": "الفضاء الكامن",
"tooltip": null
},
"4": {
"name": "قص الصورة",
"tooltip": null
}
}
},
"WanMoveConcatTrack": {
"display_name": "WanMoveConcatTrack",
"inputs": {
@@ -16137,6 +16317,43 @@
}
}
},
"WavespeedFlashVSRNode": {
"description": "مُرقّي فيديو سريع وعالي الجودة يعزز الدقة ويعيد الوضوح للمقاطع منخفضة الدقة أو الضبابية.",
"display_name": "ترقية فيديو FlashVSR",
"inputs": {
"target_resolution": {
"name": "الدقة المستهدفة"
},
"video": {
"name": "الفيديو"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WavespeedImageUpscaleNode": {
"description": "عزز دقة وجودة الصورة، وارفع الصور إلى دقة 4K أو 8K للحصول على نتائج حادة ومفصلة.",
"display_name": "ترقية صورة WaveSpeed",
"inputs": {
"image": {
"name": "الصورة"
},
"model": {
"name": "النموذج"
},
"target_resolution": {
"name": "الدقة المستهدفة"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WebcamCapture": {
"display_name": "التقاط كاميرا ويب",
"inputs": {

View File

@@ -43,8 +43,6 @@
"comfy": "Comfy",
"refresh": "Refresh",
"refreshNode": "Refresh Node",
"vitePreloadErrorTitle": "New Version Available",
"vitePreloadErrorMessage": "A new version of the app has been released. Would you like to reload?\nIf not, some parts of the app might not work as expected.\nFeel free to decline and save your progress before reloading.",
"terminal": "Terminal",
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
@@ -88,6 +86,7 @@
"reportIssueTooltip": "Submit the error report to Comfy Org",
"reportSent": "Report Submitted",
"copyToClipboard": "Copy to Clipboard",
"copyAll": "Copy All",
"openNewIssue": "Open New Issue",
"showReport": "Show Report",
"imageFailedToLoad": "Image failed to load",
@@ -100,6 +99,11 @@
"no": "No",
"cancel": "Cancel",
"close": "Close",
"closeDialog": "Close dialog",
"showLeftPanel": "Show left panel",
"hideLeftPanel": "Hide left panel",
"showRightPanel": "Show right panel",
"hideRightPanel": "Hide right panel",
"or": "or",
"pressKeysForNewBinding": "Press keys for new binding",
"defaultBanner": "default banner",
@@ -158,6 +162,7 @@
"choose_file_to_upload": "choose file to upload",
"capture": "capture",
"nodes": "Nodes",
"nodesCount": "{count} nodes | {count} node | {count} nodes",
"community": "Community",
"all": "All",
"versionMismatchWarning": "Version Compatibility Warning",
@@ -178,6 +183,7 @@
"source": "Source",
"filter": "Filter",
"apply": "Apply",
"use": "Use",
"enabled": "Enabled",
"installed": "Installed",
"restart": "Restart",
@@ -269,6 +275,7 @@
"1x": "1x",
"2x": "2x",
"beta": "BETA",
"nightly": "NIGHTLY",
"profile": "Profile",
"noItems": "No items"
},
@@ -702,6 +709,8 @@
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"generatedAssetsHeader": "Generated assets",
"importedAssetsHeader": "Imported assets",
"activeJobStatus": "Active job: {status}",
"noFilesFoundMessage": "Upload files or generate content to see them here",
"browseTemplates": "Browse example templates",
"openWorkflow": "Open workflow in local file system",
@@ -751,6 +760,7 @@
"sortJobs": "Sort jobs",
"sortBy": "Sort by",
"activeJobs": "{count} active job | {count} active jobs",
"activeJobsShort": "{count} active | {count} active",
"activeJobsSuffix": "active jobs",
"jobQueue": "Job Queue",
"expandCollapsedQueue": "Expand job queue",
@@ -1275,7 +1285,9 @@
"VueNodes": "Nodes 2.0",
"Nodes 2_0": "Nodes 2.0",
"Execution": "Execution",
"PLY": "PLY"
"PLY": "PLY",
"Workspace": "Workspace",
"Other": "Other"
},
"serverConfigItems": {
"listen": {
@@ -1426,6 +1438,7 @@
"latent": "latent",
"mask": "mask",
"api node": "api node",
"Bria": "Bria",
"video": "video",
"ByteDance": "ByteDance",
"preprocessors": "preprocessors",
@@ -1506,6 +1519,7 @@
"": "",
"camera": "camera",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"zimage": "zimage"
},
"dataTypes": {
@@ -1997,6 +2011,8 @@
"renewsDate": "Renews {date}",
"expiresDate": "Expires {date}",
"manageSubscription": "Manage subscription",
"managePayment": "Manage Payment",
"cancelSubscription": "Cancel Subscription",
"partnerNodesBalance": "\"Partner Nodes\" Credit Balance",
"partnerNodesDescription": "For running commercial/proprietary models",
"totalCredits": "Total credits",
@@ -2051,6 +2067,9 @@
"subscribeToRunFull": "Subscribe to Run",
"subscribeNow": "Subscribe Now",
"subscribeToComfyCloud": "Subscribe to Comfy Cloud",
"workspaceNotSubscribed": "This workspace is not on a subscription",
"subscriptionRequiredMessage": "A subscription is required for members to run workflows on Cloud",
"contactOwnerToSubscribe": "Contact the workspace owner to subscribe",
"description": "Choose the best plan for you",
"haveQuestions": "Have questions or wondering about enterprise?",
"contactUs": "Contact us",
@@ -2086,12 +2105,64 @@
"userSettings": {
"title": "My Account Settings",
"accountSettings": "Account settings",
"workspaceSettings": "Workspace settings",
"name": "Name",
"email": "Email",
"provider": "Sign-in Provider",
"notSet": "Not set",
"updatePassword": "Update Password"
},
"workspacePanel": {
"tabs": {
"planCredits": "Plan & Credits"
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
"nameLabel": "Workspace name",
"save": "Save"
},
"leaveDialog": {
"title": "Leave this workspace?",
"message": "You won't be able to join again unless you contact the workspace owner.",
"leave": "Leave"
},
"deleteDialog": {
"title": "Delete this workspace?",
"message": "Any unused credits or unsaved assets will be lost. This action cannot be undone.",
"messageWithName": "Delete \"{name}\"? Any unused credits or unsaved assets will be lost. This action cannot be undone."
},
"createWorkspaceDialog": {
"title": "Create a new workspace",
"message": "Workspaces let members share a single credits pool. You'll become the owner after creating this.",
"nameLabel": "Workspace name*",
"namePlaceholder": "Enter workspace name",
"create": "Create"
},
"toast": {
"workspaceUpdated": {
"title": "Workspace updated",
"message": "Workspace details have been saved."
},
"failedToUpdateWorkspace": "Failed to update workspace",
"failedToCreateWorkspace": "Failed to create workspace",
"failedToDeleteWorkspace": "Failed to delete workspace",
"failedToLeaveWorkspace": "Failed to leave workspace"
}
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
"subscribe": "Subscribe",
"roleOwner": "Owner",
"roleMember": "Member",
"createWorkspace": "Create new workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one."
},
"selectionToolbox": {
"executeButton": {
"tooltip": "Execute to selected output nodes (Highlighted with orange border)",
@@ -2306,6 +2377,12 @@
"assetBrowser": {
"allCategory": "All {category}",
"allModels": "All Models",
"byType": "By type",
"emptyImported": {
"canImport": "No imported models yet. Click \"Import Model\" to add your own.",
"restricted": "Personal models are only available at Creator tier and above."
},
"imported": "Imported",
"assetCollection": "Asset collection",
"assets": "Assets",
"baseModels": "Base models",
@@ -2396,6 +2473,30 @@
"assetCard": "{name} - {type} asset",
"loadingAsset": "Loading asset"
},
"modelInfo": {
"title": "Model Info",
"selectModelPrompt": "Select a model to see its information",
"basicInfo": "Basic Info",
"displayName": "Display Name",
"editDisplayName": "Edit display name",
"fileName": "File Name",
"source": "Source",
"viewOnSource": "View on {source}",
"modelTagging": "Model Tagging",
"modelType": "Model Type",
"selectModelType": "Select model type...",
"compatibleBaseModels": "Compatible Base Models",
"addBaseModel": "Add base model...",
"baseModelUnknown": "Base model unknown",
"additionalTags": "Additional Tags",
"addTag": "Add tag...",
"noAdditionalTags": "No additional tags",
"modelDescription": "Model Description",
"triggerPhrases": "Trigger Phrases",
"description": "Description",
"descriptionNotSet": "No description set",
"descriptionPlaceholder": "Add a description for this model..."
},
"media": {
"threeDModelPlaceholder": "3D Model",
"audioPlaceholder": "Audio"
@@ -2582,7 +2683,8 @@
"noneSearchDesc": "No items match your search",
"nodesNoneDesc": "NO NODES",
"fallbackGroupTitle": "Group",
"fallbackNodeTitle": "Node"
"fallbackNodeTitle": "Node",
"hideAdvancedInputsButton": "Hide advanced inputs"
},
"help": {
"recentReleases": "Recent releases",
@@ -2618,5 +2720,11 @@
"workspaceNotFound": "Workspace not found",
"tokenExchangeFailed": "Failed to authenticate with workspace: {error}"
}
},
"nightly": {
"badge": {
"label": "Preview Version",
"tooltip": "You are using a nightly version of ComfyUI. Please use the feedback button to share your thoughts about these features."
}
}
}

View File

@@ -328,6 +328,68 @@
}
}
},
"BriaImageEditNode": {
"display_name": "Bria FIBO Image Edit",
"description": "Edit images using Bria latest model",
"inputs": {
"model": {
"name": "model"
},
"image": {
"name": "image"
},
"prompt": {
"name": "prompt",
"tooltip": "Instruction to edit image"
},
"negative_prompt": {
"name": "negative_prompt"
},
"structured_prompt": {
"name": "structured_prompt",
"tooltip": "A string containing the structured edit prompt in JSON format. Use this instead of usual prompt for precise, programmatic control."
},
"seed": {
"name": "seed"
},
"guidance_scale": {
"name": "guidance_scale",
"tooltip": "Higher value makes the image follow the prompt more closely."
},
"steps": {
"name": "steps"
},
"moderation": {
"name": "moderation",
"tooltip": "Moderation settings"
},
"mask": {
"name": "mask",
"tooltip": "If omitted, the edit applies to the entire image."
},
"control_after_generate": {
"name": "control after generate"
},
"moderation_prompt_content_moderation": {
"name": "prompt_content_moderation"
},
"moderation_visual_input_moderation": {
"name": "visual_input_moderation"
},
"moderation_visual_output_moderation": {
"name": "visual_output_moderation"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "structured_prompt",
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"display_name": "ByteDance First-Last-Frame to Video",
"description": "Generate video using prompt and first and last frames.",
@@ -2033,7 +2095,8 @@
"choice": {
"name": "choice"
},
"option0": {}
"index": {},
"option1": {}
},
"outputs": {
"0": {
@@ -9795,8 +9858,8 @@
}
},
"OpenAIGPTImage1": {
"display_name": "OpenAI GPT Image 1",
"description": "Generates images synchronously via OpenAI's GPT Image 1 endpoint.",
"display_name": "OpenAI GPT Image 1.5",
"description": "Generates images synchronously via OpenAI's GPT Image endpoint.",
"inputs": {
"prompt": {
"name": "prompt",
@@ -11218,18 +11281,27 @@
},
"ResizeImageMaskNode": {
"display_name": "Resize Image/Mask",
"description": "Resize an image or mask using various scaling methods.",
"inputs": {
"input": {
"name": "input"
},
"resize_type": {
"name": "resize_type"
"name": "resize_type",
"tooltip": "Select how to resize: by exact dimensions, scale factor, matching another image, etc."
},
"scale_method": {
"name": "scale_method"
"name": "scale_method",
"tooltip": "Interpolation algorithm. 'area' is best for downscaling, 'lanczos' for upscaling, 'nearest-exact' for pixel art."
},
"resize_type_multiplier": {
"name": "multiplier"
"resize_type_crop": {
"name": "crop"
},
"resize_type_height": {
"name": "height"
},
"resize_type_width": {
"name": "width"
}
},
"outputs": {
@@ -13469,6 +13541,40 @@
}
}
},
"TextEncodeZImageOmni": {
"display_name": "TextEncodeZImageOmni",
"inputs": {
"clip": {
"name": "clip"
},
"prompt": {
"name": "prompt"
},
"auto_resize_images": {
"name": "auto_resize_images"
},
"image_encoder": {
"name": "image_encoder"
},
"vae": {
"name": "vae"
},
"image1": {
"name": "image1"
},
"image2": {
"name": "image2"
},
"image3": {
"name": "image3"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TextToLowercase": {
"display_name": "Text to Lowercase",
"inputs": {
@@ -15683,6 +15789,79 @@
}
}
},
"WanInfiniteTalkToVideo": {
"display_name": "WanInfiniteTalkToVideo",
"inputs": {
"mode": {
"name": "mode"
},
"model": {
"name": "model"
},
"model_patch": {
"name": "model_patch"
},
"positive": {
"name": "positive"
},
"negative": {
"name": "negative"
},
"vae": {
"name": "vae"
},
"width": {
"name": "width"
},
"height": {
"name": "height"
},
"length": {
"name": "length"
},
"audio_encoder_output_1": {
"name": "audio_encoder_output_1"
},
"motion_frame_count": {
"name": "motion_frame_count",
"tooltip": "Number of previous frames to use as motion context."
},
"audio_scale": {
"name": "audio_scale"
},
"clip_vision_output": {
"name": "clip_vision_output"
},
"start_image": {
"name": "start_image"
},
"previous_frames": {
"name": "previous_frames"
}
},
"outputs": {
"0": {
"name": "model",
"tooltip": null
},
"1": {
"name": "positive",
"tooltip": null
},
"2": {
"name": "negative",
"tooltip": null
},
"3": {
"name": "latent",
"tooltip": null
},
"4": {
"name": "trim_image",
"tooltip": null
}
}
},
"WanMoveConcatTrack": {
"display_name": "WanMoveConcatTrack",
"inputs": {
@@ -16199,6 +16378,43 @@
}
}
},
"WavespeedFlashVSRNode": {
"display_name": "FlashVSR Video Upscale",
"description": "Fast, high-quality video upscaler that boosts resolution and restores clarity for low-resolution or blurry footage.",
"inputs": {
"video": {
"name": "video"
},
"target_resolution": {
"name": "target_resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WavespeedImageUpscaleNode": {
"display_name": "WaveSpeed Image Upscale",
"description": "Boost image resolution and quality, upscaling photos to 4K or 8K for sharp, detailed results.",
"inputs": {
"model": {
"name": "model"
},
"image": {
"name": "image"
},
"target_resolution": {
"name": "target_resolution"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WebcamCapture": {
"display_name": "Webcam Capture",
"inputs": {

View File

@@ -24,6 +24,7 @@
"assets": "Recursos",
"baseModels": "Modelos base",
"browseAssets": "Explorar recursos",
"byType": "Por tipo",
"checkpoints": "Checkpoints",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "Ejemplo:",
@@ -45,6 +46,10 @@
"failed": "La descarga falló",
"inProgress": "Descargando {assetName}..."
},
"emptyImported": {
"canImport": "Aún no hay modelos importados. Haz clic en \"Importar modelo\" para añadir el tuyo.",
"restricted": "Los modelos personales solo están disponibles en el nivel Creador o superior."
},
"errorFileTooLarge": "El archivo excede el tamaño máximo permitido",
"errorFormatNotAllowed": "Solo se permite el formato SafeTensor",
"errorModelTypeNotSupported": "Este tipo de modelo no es compatible",
@@ -61,6 +66,7 @@
"finish": "Finalizar",
"genericLinkPlaceholder": "Pega el enlace aquí",
"importAnother": "Importar otro",
"imported": "Importado",
"jobId": "ID de tarea",
"loadingModels": "Cargando {type}...",
"maxFileSize": "Tamaño máximo de archivo: {size}",
@@ -70,6 +76,30 @@
"threeDModelPlaceholder": "Modelo 3D"
},
"modelAssociatedWithLink": "El modelo asociado con el enlace que proporcionaste:",
"modelInfo": {
"addBaseModel": "Agregar modelo base...",
"addTag": "Agregar etiqueta...",
"additionalTags": "Etiquetas adicionales",
"baseModelUnknown": "Modelo base desconocido",
"basicInfo": "Información básica",
"compatibleBaseModels": "Modelos base compatibles",
"description": "Descripción",
"descriptionNotSet": "Sin descripción",
"descriptionPlaceholder": "Agrega una descripción para este modelo...",
"displayName": "Nombre para mostrar",
"editDisplayName": "Editar nombre para mostrar",
"fileName": "Nombre de archivo",
"modelDescription": "Descripción del modelo",
"modelTagging": "Etiquetado del modelo",
"modelType": "Tipo de modelo",
"noAdditionalTags": "Sin etiquetas adicionales",
"selectModelPrompt": "Selecciona un modelo para ver su información",
"selectModelType": "Selecciona el tipo de modelo...",
"source": "Fuente",
"title": "Información del modelo",
"triggerPhrases": "Frases de activación",
"viewOnSource": "Ver en {source}"
},
"modelName": "Nombre del modelo",
"modelNamePlaceholder": "Introduce un nombre para este modelo",
"modelTypeSelectorLabel": "¿Qué tipo de modelo es este?",
@@ -684,6 +714,7 @@
"clearAll": "Borrar todo",
"clearFilters": "Borrar filtros",
"close": "Cerrar",
"closeDialog": "Cerrar diálogo",
"color": "Color",
"comfy": "Comfy",
"comfyOrgLogoAlt": "Logo de ComfyOrg",
@@ -700,6 +731,7 @@
"control_before_generate": "control antes de generar",
"copied": "Copiado",
"copy": "Copiar",
"copyAll": "Copiar todo",
"copyJobId": "Copiar ID de trabajo",
"copyToClipboard": "Copiar al portapapeles",
"copyURL": "Copiar URL",
@@ -762,6 +794,8 @@
"goToNode": "Ir al nodo",
"graphNavigation": "Navegación de gráficos",
"halfSpeed": "0.5x",
"hideLeftPanel": "Ocultar panel izquierdo",
"hideRightPanel": "Ocultar panel derecho",
"icon": "Icono",
"imageFailedToLoad": "Falló la carga de la imagen",
"imagePreview": "Vista previa de imagen - Usa las teclas de flecha para navegar entre imágenes",
@@ -803,6 +837,7 @@
"name": "Nombre",
"newFolder": "Nueva carpeta",
"next": "Siguiente",
"nightly": "NIGHTLY",
"no": "No",
"noAudioRecorded": "No se grabó audio",
"noItems": "Sin elementos",
@@ -817,6 +852,7 @@
"nodeSlotsError": "Error de Ranuras del Nodo",
"nodeWidgetsError": "Error de Widgets del Nodo",
"nodes": "Nodos",
"nodesCount": "{count} nodos | {count} nodo | {count} nodos",
"nodesRunning": "nodos en ejecución",
"none": "Ninguno",
"nothingToCopy": "Nada para copiar",
@@ -891,7 +927,9 @@
"selectedFile": "Archivo seleccionado",
"setAsBackground": "Establecer como fondo",
"settings": "Configuraciones",
"showLeftPanel": "Mostrar panel izquierdo",
"showReport": "Mostrar informe",
"showRightPanel": "Mostrar panel derecho",
"singleSelectDropdown": "Menú desplegable de selección única",
"sort": "Ordenar",
"source": "Fuente",
@@ -914,6 +952,7 @@
"updating": "Actualizando",
"upload": "Subir",
"usageHint": "Sugerencia de uso",
"use": "Usar",
"user": "Usuario",
"versionMismatchWarning": "Advertencia de compatibilidad de versión",
"versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.",
@@ -1617,11 +1656,18 @@
"title": "Este flujo de trabajo tiene nodos faltantes"
}
},
"nightly": {
"badge": {
"label": "Versión preliminar",
"tooltip": "Estás usando una versión nightly de ComfyUI. Por favor, utiliza el botón de comentarios para compartir tus opiniones sobre estas funciones."
}
},
"nodeCategories": {
"": "",
"3d": "3d",
"3d_models": "modelos_3d",
"BFL": "BFL",
"Bria": "Bria",
"ByteDance": "ByteDance",
"Gemini": "Gemini",
"Ideogram": "Ideogram",
@@ -1643,6 +1689,7 @@
"Veo": "Veo",
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"_for_testing": "_para_pruebas",
"advanced": "avanzado",
"animation": "animación",
@@ -2084,6 +2131,7 @@
"NodeLibrary": "Biblioteca de Nodos",
"Nodes 2_0": "Nodes 2.0",
"Notification Preferences": "Preferencias de notificación",
"Other": "Otros",
"PLY": "PLY",
"PlanCredits": "Plan y créditos",
"Pointer": "Puntero",
@@ -2103,7 +2151,8 @@
"Vue Nodes": "Nodos Vue",
"VueNodes": "Nodos Vue",
"Window": "Ventana",
"Workflow": "Flujo de Trabajo"
"Workflow": "Flujo de Trabajo",
"Workspace": "Espacio de trabajo"
},
"shape": {
"CARD": "Card",
@@ -2129,12 +2178,14 @@
"viewControls": "Controles de vista"
},
"sideToolbar": {
"activeJobStatus": "Trabajo activo: {status}",
"assets": "Recursos",
"backToAssets": "Volver a todos los recursos",
"browseTemplates": "Explorar plantillas de ejemplo",
"downloads": "Descargas",
"generatedAssetsHeader": "Recursos generados",
"helpCenter": "Centro de ayuda",
"importedAssetsHeader": "Recursos importados",
"labels": {
"assets": "Recursos",
"console": "Consola",
@@ -2179,6 +2230,7 @@
"queue": "Cola",
"queueProgressOverlay": {
"activeJobs": "{count} trabajo activo | {count} trabajos activos",
"activeJobsShort": "{count} activo(s) | {count} activo(s)",
"activeJobsSuffix": "trabajos activos",
"cancelJobTooltip": "Cancelar trabajo",
"clearHistory": "Limpiar historial de la cola de trabajos",
@@ -2267,9 +2319,11 @@
"beta": "BETA",
"billedMonthly": "Facturado mensualmente",
"billedYearly": "{total} facturado anualmente",
"cancelSubscription": "Cancelar suscripción",
"changeTo": "Cambiar a {plan}",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Logo de Comfy Cloud",
"contactOwnerToSubscribe": "Contacta al propietario del espacio de trabajo para suscribirte",
"contactUs": "Contáctanos",
"creditsRemainingThisMonth": "Créditos restantes este mes",
"creditsRemainingThisYear": "Créditos restantes este año",
@@ -2282,6 +2336,7 @@
"haveQuestions": "¿Tienes preguntas o buscas soluciones empresariales?",
"invoiceHistory": "Historial de facturas",
"learnMore": "Más información",
"managePayment": "Gestionar pago",
"managePlan": "Gestionar plan",
"manageSubscription": "Gestionar suscripción",
"maxDuration": {
@@ -2317,6 +2372,7 @@
"subscribeToComfyCloud": "Suscribirse a Comfy Cloud",
"subscribeToRun": "Suscribirse",
"subscribeToRunFull": "Suscribirse a Ejecutar",
"subscriptionRequiredMessage": "Se requiere una suscripción para que los miembros ejecuten flujos de trabajo en la nube",
"tierNameYearly": "{name} Anual",
"tiers": {
"creator": {
@@ -2348,6 +2404,7 @@
"viewMoreDetails": "Ver más detalles",
"viewMoreDetailsPlans": "Ver más detalles sobre planes y precios",
"viewUsageHistory": "Ver historial de uso",
"workspaceNotSubscribed": "Este espacio de trabajo no tiene una suscripción",
"yearly": "Anual",
"yearlyCreditsLabel": "Total de créditos anuales",
"yearlyDiscount": "20% DESCUENTO",
@@ -2497,7 +2554,8 @@
"notSet": "No establecido",
"provider": "Método de inicio de sesión",
"title": "Configuración de usuario",
"updatePassword": "Actualizar contraseña"
"updatePassword": "Actualizar contraseña",
"workspaceSettings": "Configuración del espacio de trabajo"
},
"validation": {
"descriptionRequired": "Descripción es requerida",
@@ -2602,6 +2660,57 @@
"workspaceNotFound": "Espacio de trabajo no encontrado"
}
},
"workspacePanel": {
"createWorkspaceDialog": {
"create": "Crear",
"message": "Los espacios de trabajo permiten a los miembros compartir un único fondo de créditos. Te convertirás en el propietario después de crearlo.",
"nameLabel": "Nombre del espacio de trabajo*",
"namePlaceholder": "Introduce el nombre del espacio de trabajo",
"title": "Crear un nuevo espacio de trabajo"
},
"deleteDialog": {
"message": "Cualquier crédito no utilizado o recurso no guardado se perderá. Esta acción no se puede deshacer.",
"messageWithName": "¿Eliminar \"{name}\"? Cualquier crédito no utilizado o recurso no guardado se perderá. Esta acción no se puede deshacer.",
"title": "¿Eliminar este espacio de trabajo?"
},
"editWorkspaceDialog": {
"nameLabel": "Nombre del espacio de trabajo",
"save": "Guardar",
"title": "Editar detalles del espacio de trabajo"
},
"leaveDialog": {
"leave": "Abandonar",
"message": "No podrás unirte de nuevo a menos que contactes al propietario del espacio de trabajo.",
"title": "¿Abandonar este espacio de trabajo?"
},
"menu": {
"deleteWorkspace": "Eliminar espacio de trabajo",
"deleteWorkspaceDisabledTooltip": "Primero cancela la suscripción activa de tu espacio de trabajo",
"editWorkspace": "Editar detalles del espacio de trabajo",
"leaveWorkspace": "Abandonar espacio de trabajo"
},
"tabs": {
"planCredits": "Plan y créditos"
},
"toast": {
"failedToCreateWorkspace": "No se pudo crear el espacio de trabajo",
"failedToDeleteWorkspace": "No se pudo eliminar el espacio de trabajo",
"failedToLeaveWorkspace": "No se pudo abandonar el espacio de trabajo",
"failedToUpdateWorkspace": "No se pudo actualizar el espacio de trabajo",
"workspaceUpdated": {
"message": "Los detalles del espacio de trabajo se han guardado.",
"title": "Espacio de trabajo actualizado"
}
}
},
"workspaceSwitcher": {
"createWorkspace": "Crear nuevo espacio de trabajo",
"maxWorkspacesReached": "Solo puedes ser propietario de 10 espacios de trabajo. Elimina uno para crear uno nuevo.",
"roleMember": "Miembro",
"roleOwner": "Propietario",
"subscribe": "Suscribirse",
"switchWorkspace": "Cambiar espacio de trabajo"
},
"zoomControls": {
"hideMinimap": "Ocultar minimapa",
"label": "Controles de zoom",

View File

@@ -328,6 +328,68 @@
}
}
},
"BriaImageEditNode": {
"description": "Edita imágenes usando el modelo más reciente de Bria",
"display_name": "Edición de Imagen Bria",
"inputs": {
"control_after_generate": {
"name": "control después de generar"
},
"guidance_scale": {
"name": "escala_de_guía",
"tooltip": "Un valor más alto hace que la imagen siga la instrucción más de cerca."
},
"image": {
"name": "imagen"
},
"mask": {
"name": "máscara",
"tooltip": "Si se omite, la edición se aplica a toda la imagen."
},
"model": {
"name": "modelo"
},
"moderation": {
"name": "moderación",
"tooltip": "Configuración de moderación"
},
"moderation_prompt_content_moderation": {
"name": "moderación_de_contenido_de_instrucción"
},
"moderation_visual_input_moderation": {
"name": "moderación_visual_de_entrada"
},
"moderation_visual_output_moderation": {
"name": "moderación_visual_de_salida"
},
"negative_prompt": {
"name": "instrucción_negativa"
},
"prompt": {
"name": "instrucción",
"tooltip": "Instrucción para editar la imagen"
},
"seed": {
"name": "semilla"
},
"steps": {
"name": "pasos"
},
"structured_prompt": {
"name": "instrucción_estructurada",
"tooltip": "Una cadena que contiene la instrucción de edición estructurada en formato JSON. Usa esto en lugar de la instrucción habitual para un control preciso y programático."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "instrucción_estructurada",
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "Generar video usando prompt y primer y último fotograma.",
"display_name": "ByteDance Primer-Último-Fotograma a Video",
@@ -2031,7 +2093,9 @@
"choice": {
"name": "elección"
},
"option0": {
"index": {
},
"option1": {
}
},
"outputs": {
@@ -11209,19 +11273,28 @@
}
},
"ResizeImageMaskNode": {
"description": "Redimensiona una imagen o máscara utilizando varios métodos de escalado.",
"display_name": "Redimensionar Imagen/Máscara",
"inputs": {
"input": {
"name": "input"
},
"resize_type": {
"name": "resize_type"
"name": "resize_type",
"tooltip": "Selecciona cómo redimensionar: por dimensiones exactas, factor de escala, igualando otra imagen, etc."
},
"resize_type_multiplier": {
"name": "multiplier"
"resize_type_crop": {
"name": "recortar"
},
"resize_type_height": {
"name": "altura"
},
"resize_type_width": {
"name": "anchura"
},
"scale_method": {
"name": "scale_method"
"name": "scale_method",
"tooltip": "Algoritmo de interpolación. 'area' es mejor para reducir tamaño, 'lanczos' para aumentar tamaño, 'nearest-exact' para pixel art."
}
},
"outputs": {
@@ -13450,6 +13523,40 @@
}
}
},
"TextEncodeZImageOmni": {
"display_name": "TextEncodeZImageOmni",
"inputs": {
"auto_resize_images": {
"name": "auto_redimensionar_imágenes"
},
"clip": {
"name": "clip"
},
"image1": {
"name": "imagen1"
},
"image2": {
"name": "imagen2"
},
"image3": {
"name": "imagen3"
},
"image_encoder": {
"name": "codificador_de_imagen"
},
"prompt": {
"name": "instrucción"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TextToLowercase": {
"display_name": "Convertir texto a minúsculas",
"inputs": {
@@ -15621,6 +15728,79 @@
}
}
},
"WanInfiniteTalkToVideo": {
"display_name": "WanInfiniteTalkToVideo",
"inputs": {
"audio_encoder_output_1": {
"name": "salida codificador de audio 1"
},
"audio_scale": {
"name": "escala de audio"
},
"clip_vision_output": {
"name": "salida de clip visión"
},
"height": {
"name": "alto"
},
"length": {
"name": "longitud"
},
"mode": {
"name": "modo"
},
"model": {
"name": "modelo"
},
"model_patch": {
"name": "parche de modelo"
},
"motion_frame_count": {
"name": "número de fotogramas de movimiento",
"tooltip": "Número de fotogramas anteriores a usar como contexto de movimiento."
},
"negative": {
"name": "negativo"
},
"positive": {
"name": "positivo"
},
"previous_frames": {
"name": "fotogramas anteriores"
},
"start_image": {
"name": "imagen inicial"
},
"vae": {
"name": "vae"
},
"width": {
"name": "ancho"
}
},
"outputs": {
"0": {
"name": "modelo",
"tooltip": null
},
"1": {
"name": "positivo",
"tooltip": null
},
"2": {
"name": "negativo",
"tooltip": null
},
"3": {
"name": "latente",
"tooltip": null
},
"4": {
"name": "imagen recortada",
"tooltip": null
}
}
},
"WanMoveConcatTrack": {
"display_name": "WanMoveConcatTrack",
"inputs": {
@@ -16137,6 +16317,43 @@
}
}
},
"WavespeedFlashVSRNode": {
"description": "Escalador de video rápido y de alta calidad que aumenta la resolución y restaura la claridad de videos de baja resolución o borrosos.",
"display_name": "FlashVSR Escalado de Video",
"inputs": {
"target_resolution": {
"name": "resolución_objetivo"
},
"video": {
"name": "video"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WavespeedImageUpscaleNode": {
"description": "Aumenta la resolución y calidad de la imagen, escalando fotos a 4K u 8K para obtener resultados nítidos y detallados.",
"display_name": "WaveSpeed Escalado de Imagen",
"inputs": {
"image": {
"name": "imagen"
},
"model": {
"name": "modelo"
},
"target_resolution": {
"name": "resolución_objetivo"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WebcamCapture": {
"display_name": "Captura de Webcam",
"inputs": {

View File

@@ -24,6 +24,7 @@
"assets": "دارایی‌ها",
"baseModels": "مدل‌های پایه",
"browseAssets": "مرور دارایی‌ها",
"byType": "بر اساس نوع",
"checkpoints": "چک‌پوینت‌ها",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "مثال:",
@@ -45,6 +46,10 @@
"failed": "دانلود ناموفق بود",
"inProgress": "در حال دانلود {assetName}..."
},
"emptyImported": {
"canImport": "هنوز مدلی وارد نشده است. برای افزودن مدل خود، روی «وارد کردن مدل» کلیک کنید.",
"restricted": "مدل‌های شخصی فقط برای سطح Creator و بالاتر در دسترس هستند."
},
"errorFileTooLarge": "فایل از حداکثر اندازه مجاز بزرگ‌تر است",
"errorFormatNotAllowed": "فقط فرمت SafeTensor مجاز است",
"errorModelTypeNotSupported": "این نوع مدل پشتیبانی نمی‌شود",
@@ -61,6 +66,7 @@
"finish": "پایان",
"genericLinkPlaceholder": "لینک را اینجا وارد کنید",
"importAnother": "وارد کردن مورد دیگر",
"imported": "وارد شده",
"jobId": "شناسه کار: {jobId}",
"loadingModels": "در حال بارگذاری {type}...",
"maxFileSize": "حداکثر اندازه فایل: {size}",
@@ -70,6 +76,30 @@
"threeDModelPlaceholder": "مدل سه‌بعدی"
},
"modelAssociatedWithLink": "مدل مرتبط با لینکی که وارد کردید:",
"modelInfo": {
"addBaseModel": "افزودن مدل پایه...",
"addTag": "افزودن برچسب...",
"additionalTags": "برچسب‌های اضافی",
"baseModelUnknown": "مدل پایه نامشخص",
"basicInfo": "اطلاعات پایه",
"compatibleBaseModels": "مدل‌های پایه سازگار",
"description": "توضیحات",
"descriptionNotSet": "توضیحی تنظیم نشده است",
"descriptionPlaceholder": "یک توضیح برای این مدل اضافه کنید...",
"displayName": "نام نمایشی",
"editDisplayName": "ویرایش نام نمایشی",
"fileName": "نام فایل",
"modelDescription": "توضیحات مدل",
"modelTagging": "برچسب‌گذاری مدل",
"modelType": "نوع مدل",
"noAdditionalTags": "برچسب اضافی وجود ندارد",
"selectModelPrompt": "یک مدل را برای مشاهده اطلاعات آن انتخاب کنید",
"selectModelType": "انتخاب نوع مدل...",
"source": "منبع",
"title": "اطلاعات مدل",
"triggerPhrases": "عبارات فعال‌ساز",
"viewOnSource": "مشاهده در {source}"
},
"modelName": "نام مدل",
"modelNamePlaceholder": "یک نام برای این مدل وارد کنید",
"modelTypeSelectorLabel": "نوع مدل چیست؟",
@@ -684,6 +714,7 @@
"clearAll": "پاک‌سازی همه",
"clearFilters": "پاک‌سازی فیلترها",
"close": "بستن",
"closeDialog": "بستن پنجره",
"color": "رنگ",
"comfy": "Comfy",
"comfyOrgLogoAlt": "لوگوی ComfyOrg",
@@ -700,6 +731,7 @@
"control_before_generate": "کنترل پیش از تولید",
"copied": "کپی شد",
"copy": "کپی",
"copyAll": "کپی همه",
"copyJobId": "کپی شناسه وظیفه",
"copyToClipboard": "کپی در کلیپ‌بورد",
"copyURL": "کپی آدرس",
@@ -762,6 +794,8 @@
"goToNode": "رفتن به node",
"graphNavigation": "ناوبری گراف",
"halfSpeed": "۰.۵x",
"hideLeftPanel": "پنهان کردن پنل چپ",
"hideRightPanel": "پنهان کردن پنل راست",
"icon": "آیکون",
"imageFailedToLoad": "بارگذاری تصویر ناموفق بود",
"imagePreview": "پیش‌نمایش تصویر - برای جابجایی بین تصاویر از کلیدهای جهت‌دار استفاده کنید",
@@ -803,6 +837,7 @@
"name": "نام",
"newFolder": "پوشه جدید",
"next": "بعدی",
"nightly": "نسخه شبانه",
"no": "خیر",
"noAudioRecorded": "هیچ صدایی ضبط نشد",
"noItems": "هیچ موردی وجود ندارد",
@@ -817,6 +852,7 @@
"nodeSlotsError": "خطا در slotهای node",
"nodeWidgetsError": "خطا در ابزارک‌های node",
"nodes": "nodeها",
"nodesCount": "{count} نود | {count} نود | {count} نود",
"nodesRunning": "nodeها در حال اجرا هستند",
"none": "هیچ‌کدام",
"nothingToCopy": "موردی برای کپی وجود ندارد",
@@ -891,7 +927,9 @@
"selectedFile": "فایل انتخاب‌شده",
"setAsBackground": "تنظیم به عنوان پس‌زمینه",
"settings": "تنظیمات",
"showLeftPanel": "نمایش پنل چپ",
"showReport": "نمایش گزارش",
"showRightPanel": "نمایش پنل راست",
"singleSelectDropdown": "لیست کشویی تک‌انتخابی",
"sort": "مرتب‌سازی",
"source": "منبع",
@@ -914,6 +952,7 @@
"updating": "در حال به‌روزرسانی {id}",
"upload": "بارگذاری",
"usageHint": "راهنمای استفاده",
"use": "استفاده",
"user": "کاربر",
"versionMismatchWarning": "هشدار ناسازگاری نسخه",
"versionMismatchWarningMessage": "{warning}: {detail} برای راهنمای به‌روزرسانی به https://docs.comfy.org/installation/update_comfyui#common-update-issues مراجعه کنید.",
@@ -1617,11 +1656,18 @@
"title": "این workflow دارای nodeهای مفقود است"
}
},
"nightly": {
"badge": {
"label": "نسخه پیش‌نمایش",
"tooltip": "شما در حال استفاده از نسخه شبانه ComfyUI هستید. لطفاً با استفاده از دکمه بازخورد، نظرات خود را درباره این قابلیت‌ها به اشتراک بگذارید."
}
},
"nodeCategories": {
"": "",
"3d": "سه‌بعدی",
"3d_models": "مدل‌های سه‌بعدی",
"BFL": "BFL",
"Bria": "Bria",
"ByteDance": "ByteDance",
"Gemini": "Gemini",
"Ideogram": "Ideogram",
@@ -1643,6 +1689,7 @@
"Veo": "Veo",
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"_for_testing": "_for_testing",
"advanced": "پیشرفته",
"animation": "انیمیشن",
@@ -2084,6 +2131,7 @@
"NodeLibrary": "کتابخانه Node",
"Nodes 2_0": "Nodes 2.0",
"Notification Preferences": "تنظیمات اعلان",
"Other": "سایر",
"PLY": "PLY",
"PlanCredits": "طرح و اعتبارات",
"Pointer": "اشاره‌گر",
@@ -2103,7 +2151,8 @@
"Vue Nodes": "Nodes 2.0",
"VueNodes": "Nodes 2.0",
"Window": "پنجره",
"Workflow": "Workflow"
"Workflow": "Workflow",
"Workspace": "محیط کاری"
},
"shape": {
"CARD": "کارت",
@@ -2129,12 +2178,14 @@
"viewControls": "کنترل‌های نمایش"
},
"sideToolbar": {
"activeJobStatus": "وضعیت کار فعال: {status}",
"assets": "دارایی‌ها",
"backToAssets": "بازگشت به همه دارایی‌ها",
"browseTemplates": "مرور قالب‌های نمونه",
"downloads": "دانلودها",
"generatedAssetsHeader": "دارایی‌های تولیدشده",
"helpCenter": "مرکز راهنما",
"importedAssetsHeader": "دارایی‌های واردشده",
"labels": {
"assets": "دارایی‌ها",
"console": "کنسول",
@@ -2190,6 +2241,7 @@
"queue": "صف",
"queueProgressOverlay": {
"activeJobs": "{count} کار فعال",
"activeJobsShort": "{count} فعال | {count} فعال",
"activeJobsSuffix": "کار فعال",
"cancelJobTooltip": "لغو کار",
"clearHistory": "پاک‌سازی تاریخچه صف کار",
@@ -2278,9 +2330,11 @@
"beta": "بتا",
"billedMonthly": "صورتحساب ماهانه",
"billedYearly": "{total} صورتحساب سالانه",
"cancelSubscription": "لغو اشتراک",
"changeTo": "تغییر به {plan}",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "لوگوی Comfy Cloud",
"contactOwnerToSubscribe": "برای فعال‌سازی اشتراک با مالک محیط کاری تماس بگیرید",
"contactUs": "تماس با ما",
"creditsRemainingThisMonth": "شامل شده (شارژ مجدد {date})",
"creditsRemainingThisYear": "شامل شده (شارژ مجدد {date})",
@@ -2293,6 +2347,7 @@
"haveQuestions": "سوالی دارید یا به دنبال راهکار سازمانی هستید؟",
"invoiceHistory": "تاریخچه فاکتورها",
"learnMore": "اطلاعات بیشتر",
"managePayment": "مدیریت پرداخت",
"managePlan": "مدیریت طرح",
"manageSubscription": "مدیریت اشتراک",
"maxDuration": {
@@ -2328,6 +2383,7 @@
"subscribeToComfyCloud": "اشتراک در Comfy Cloud",
"subscribeToRun": "اشتراک",
"subscribeToRunFull": "اشتراک برای اجرا",
"subscriptionRequiredMessage": "برای اجرای workflowها در Cloud، اشتراک لازم است.",
"tierNameYearly": "{name} سالانه",
"tiers": {
"creator": {
@@ -2359,6 +2415,7 @@
"viewMoreDetails": "مشاهده جزئیات بیشتر",
"viewMoreDetailsPlans": "مشاهده جزئیات بیشتر درباره طرح‌ها و قیمت‌ها",
"viewUsageHistory": "مشاهده تاریخچه استفاده",
"workspaceNotSubscribed": "این محیط کاری اشتراک فعال ندارد",
"yearly": "سالانه",
"yearlyCreditsLabel": "کل اعتبار سالانه",
"yearlyDiscount": "٪۲۰ تخفیف",
@@ -2508,7 +2565,8 @@
"notSet": "تنظیم نشده",
"provider": "ارائه‌دهنده ورود",
"title": "تنظیمات حساب کاربری من",
"updatePassword": "به‌روزرسانی گذرواژه"
"updatePassword": "به‌روزرسانی گذرواژه",
"workspaceSettings": "تنظیمات محیط کاری"
},
"validation": {
"descriptionRequired": "توضیحات الزامی است",
@@ -2613,6 +2671,57 @@
"workspaceNotFound": "فضای کاری پیدا نشد."
}
},
"workspacePanel": {
"createWorkspaceDialog": {
"create": "ایجاد",
"message": "محیط‌های کاری به اعضا اجازه می‌دهند از یک اعتبار مشترک استفاده کنند. پس از ایجاد، شما مالک خواهید بود.",
"nameLabel": "نام محیط کاری*",
"namePlaceholder": "نام محیط کاری را وارد کنید",
"title": "ایجاد محیط کاری جدید"
},
"deleteDialog": {
"message": "هرگونه اعتبار یا دارایی ذخیره‌نشده از بین خواهد رفت. این عملیات قابل بازگشت نیست.",
"messageWithName": "حذف «{name}»؟ هرگونه اعتبار یا دارایی ذخیره‌نشده از بین خواهد رفت. این عملیات قابل بازگشت نیست.",
"title": "حذف این محیط کاری؟"
},
"editWorkspaceDialog": {
"nameLabel": "نام محیط کاری",
"save": "ذخیره",
"title": "ویرایش جزئیات محیط کاری"
},
"leaveDialog": {
"leave": "خروج",
"message": "تا زمانی که با مالک محیط کاری تماس نگیرید، امکان پیوستن مجدد نخواهید داشت.",
"title": "خروج از این محیط کاری؟"
},
"menu": {
"deleteWorkspace": "حذف محیط کاری",
"deleteWorkspaceDisabledTooltip": "ابتدا اشتراک فعال محیط کاری خود را لغو کنید",
"editWorkspace": "ویرایش جزئیات محیط کاری",
"leaveWorkspace": "خروج از محیط کاری"
},
"tabs": {
"planCredits": "پلن و اعتبارها"
},
"toast": {
"failedToCreateWorkspace": "ایجاد محیط کاری ناموفق بود",
"failedToDeleteWorkspace": "حذف محیط کاری ناموفق بود",
"failedToLeaveWorkspace": "خروج از محیط کاری ناموفق بود",
"failedToUpdateWorkspace": "به‌روزرسانی محیط کاری ناموفق بود",
"workspaceUpdated": {
"message": "جزئیات محیط کاری ذخیره شد.",
"title": "محیط کاری به‌روزرسانی شد"
}
}
},
"workspaceSwitcher": {
"createWorkspace": "ایجاد محیط کاری جدید",
"maxWorkspacesReached": "شما فقط می‌توانید مالک ۱۰ محیط کاری باشید. برای ایجاد محیط کاری جدید، یکی را حذف کنید.",
"roleMember": "عضو",
"roleOwner": "مالک",
"subscribe": "اشتراک",
"switchWorkspace": "تغییر محیط کاری"
},
"zoomControls": {
"hideMinimap": "مخفی‌سازی نقشه کوچک",
"label": "کنترل‌های بزرگ‌نمایی",

View File

@@ -328,6 +328,68 @@
}
}
},
"BriaImageEditNode": {
"description": "ویرایش تصاویر با استفاده از جدیدترین مدل Bria",
"display_name": "ویرایش تصویر Bria",
"inputs": {
"control_after_generate": {
"name": "کنترل پس از تولید"
},
"guidance_scale": {
"name": "مقیاس راهنما",
"tooltip": "مقدار بالاتر باعث می‌شود تصویر بیشتر از پرامپت پیروی کند."
},
"image": {
"name": "تصویر"
},
"mask": {
"name": "ماسک",
"tooltip": "در صورت عدم انتخاب، ویرایش بر کل تصویر اعمال می‌شود."
},
"model": {
"name": "مدل"
},
"moderation": {
"name": "تنظیمات نظارت",
"tooltip": "تنظیمات نظارت"
},
"moderation_prompt_content_moderation": {
"name": "نظارت بر محتوای پرامپت"
},
"moderation_visual_input_moderation": {
"name": "نظارت بر ورودی تصویری"
},
"moderation_visual_output_moderation": {
"name": "نظارت بر خروجی تصویری"
},
"negative_prompt": {
"name": "پرامپت منفی"
},
"prompt": {
"name": "پرامپت",
"tooltip": "دستورالعمل برای ویرایش تصویر"
},
"seed": {
"name": "بذر"
},
"steps": {
"name": "گام‌ها"
},
"structured_prompt": {
"name": "پرامپت ساختاریافته",
"tooltip": "یک رشته شامل پرامپت ویرایش ساختاریافته در قالب JSON. برای کنترل دقیق و برنامه‌نویسی شده، به جای پرامپت معمولی از این گزینه استفاده کنید."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "پرامپت ساختاریافته",
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "تولید ویدیو با استفاده از پرامپت و اولین و آخرین فریم.",
"display_name": "تبدیل اولین و آخرین فریم به ویدیو ByteDance",
@@ -2033,7 +2095,9 @@
"choice": {
"name": "انتخاب"
},
"option0": {
"index": {
},
"option1": {
}
},
"outputs": {
@@ -11225,19 +11289,28 @@
}
},
"ResizeImageMaskNode": {
"description": "تغییر اندازه تصویر یا mask با استفاده از روش‌های مختلف مقیاس‌گذاری.",
"display_name": "تغییر اندازه تصویر/ماسک",
"inputs": {
"input": {
"name": "ورودی"
},
"resize_type": {
"name": "نوع تغییر اندازه"
"name": "نوع تغییر اندازه",
"tooltip": "نحوه تغییر اندازه را انتخاب کنید: بر اساس ابعاد دقیق، ضریب مقیاس، تطبیق با تصویر دیگر و غیره."
},
"resize_type_multiplier": {
"name": "ضریب"
"resize_type_crop": {
"name": رش"
},
"resize_type_height": {
"name": "ارتفاع"
},
"resize_type_width": {
"name": "عرض"
},
"scale_method": {
"name": "روش مقیاس‌دهی"
"name": "روش مقیاس‌دهی",
"tooltip": "الگوریتم درون‌یابی. 'area' برای کاهش اندازه بهترین است، 'lanczos' برای افزایش اندازه، و 'nearest-exact' برای هنر پیکسلی مناسب است."
}
},
"outputs": {
@@ -13477,6 +13550,40 @@
}
}
},
"TextEncodeZImageOmni": {
"display_name": "TextEncodeZImageOmni",
"inputs": {
"auto_resize_images": {
"name": "تغییر اندازه خودکار تصاویر"
},
"clip": {
"name": "clip"
},
"image1": {
"name": "تصویر ۱"
},
"image2": {
"name": "تصویر ۲"
},
"image3": {
"name": "تصویر ۳"
},
"image_encoder": {
"name": "رمزگذار تصویر"
},
"prompt": {
"name": "پرامپت"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TextToLowercase": {
"display_name": "تبدیل متن به حروف کوچک",
"inputs": {
@@ -15652,6 +15759,79 @@
}
}
},
"WanInfiniteTalkToVideo": {
"display_name": "WanInfiniteTalkToVideo",
"inputs": {
"audio_encoder_output_1": {
"name": "خروجی رمزگذار صوتی ۱"
},
"audio_scale": {
"name": "مقیاس صوتی"
},
"clip_vision_output": {
"name": "خروجی بینایی clip"
},
"height": {
"name": "ارتفاع"
},
"length": {
"name": "طول"
},
"mode": {
"name": "حالت"
},
"model": {
"name": "مدل"
},
"model_patch": {
"name": "patch مدل"
},
"motion_frame_count": {
"name": "تعداد فریم‌های حرکتی",
"tooltip": "تعداد فریم‌های قبلی که به عنوان زمینه حرکت استفاده می‌شود."
},
"negative": {
"name": "منفی"
},
"positive": {
"name": "مثبت"
},
"previous_frames": {
"name": "فریم‌های قبلی"
},
"start_image": {
"name": "تصویر شروع"
},
"vae": {
"name": "vae"
},
"width": {
"name": "عرض"
}
},
"outputs": {
"0": {
"name": "مدل",
"tooltip": null
},
"1": {
"name": "مثبت",
"tooltip": null
},
"2": {
"name": "منفی",
"tooltip": null
},
"3": {
"name": "latent",
"tooltip": null
},
"4": {
"name": "تصویر برش‌خورده",
"tooltip": null
}
}
},
"WanMoveConcatTrack": {
"display_name": "WanMoveConcatTrack",
"inputs": {
@@ -16168,6 +16348,43 @@
}
}
},
"WavespeedFlashVSRNode": {
"description": "افزایش‌دهنده سریع و با کیفیت ویدیو که وضوح را افزایش داده و شفافیت را برای ویدیوهای کم‌کیفیت یا تار بازمی‌گرداند.",
"display_name": "افزایش کیفیت ویدیو FlashVSR",
"inputs": {
"target_resolution": {
"name": "وضوح هدف"
},
"video": {
"name": "ویدیو"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WavespeedImageUpscaleNode": {
"description": "افزایش وضوح و کیفیت تصویر، ارتقاء عکس‌ها به ۴K یا ۸K برای نتایج شفاف و با جزئیات.",
"display_name": "افزایش کیفیت تصویر WaveSpeed",
"inputs": {
"image": {
"name": "تصویر"
},
"model": {
"name": "مدل"
},
"target_resolution": {
"name": "وضوح هدف"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WebcamCapture": {
"display_name": "دریافت از وب‌کم",
"inputs": {

View File

@@ -24,6 +24,7 @@
"assets": "Ressources",
"baseModels": "Modèles de base",
"browseAssets": "Parcourir les ressources",
"byType": "Par type",
"checkpoints": "Checkpoints",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "Exemple :",
@@ -45,6 +46,10 @@
"failed": "Échec du téléchargement",
"inProgress": "Téléchargement de {assetName}..."
},
"emptyImported": {
"canImport": "Aucun modèle importé pour le moment. Cliquez sur « Importer un modèle » pour ajouter le vôtre.",
"restricted": "Les modèles personnels sont disponibles uniquement à partir du niveau Creator."
},
"errorFileTooLarge": "Le fichier dépasse la taille maximale autorisée",
"errorFormatNotAllowed": "Seul le format SafeTensor est autorisé",
"errorModelTypeNotSupported": "Ce type de modèle n'est pas pris en charge",
@@ -61,6 +66,7 @@
"finish": "Terminer",
"genericLinkPlaceholder": "Collez le lien ici",
"importAnother": "Importer un autre",
"imported": "Importé",
"jobId": "ID de tâche",
"loadingModels": "Chargement de {type}...",
"maxFileSize": "Taille maximale du fichier : {size}",
@@ -70,6 +76,30 @@
"threeDModelPlaceholder": "Modèle 3D"
},
"modelAssociatedWithLink": "Le modèle associé au lien que vous avez fourni :",
"modelInfo": {
"addBaseModel": "Ajouter un modèle de base...",
"addTag": "Ajouter un tag...",
"additionalTags": "Tags supplémentaires",
"baseModelUnknown": "Modèle de base inconnu",
"basicInfo": "Informations de base",
"compatibleBaseModels": "Modèles de base compatibles",
"description": "Description",
"descriptionNotSet": "Aucune description définie",
"descriptionPlaceholder": "Ajoutez une description pour ce modèle...",
"displayName": "Nom d'affichage",
"editDisplayName": "Modifier le nom affiché",
"fileName": "Nom du fichier",
"modelDescription": "Description du modèle",
"modelTagging": "Étiquetage du modèle",
"modelType": "Type de modèle",
"noAdditionalTags": "Aucun tag supplémentaire",
"selectModelPrompt": "Sélectionnez un modèle pour voir ses informations",
"selectModelType": "Sélectionner le type de modèle...",
"source": "Source",
"title": "Infos du modèle",
"triggerPhrases": "Phrases déclencheuses",
"viewOnSource": "Voir sur {source}"
},
"modelName": "Nom du modèle",
"modelNamePlaceholder": "Entrez un nom pour ce modèle",
"modelTypeSelectorLabel": "Quel type de modèle est-ce ?",
@@ -684,6 +714,7 @@
"clearAll": "Tout effacer",
"clearFilters": "Effacer les filtres",
"close": "Fermer",
"closeDialog": "Fermer la boîte de dialogue",
"color": "Couleur",
"comfy": "Comfy",
"comfyOrgLogoAlt": "Logo ComfyOrg",
@@ -700,6 +731,7 @@
"control_before_generate": "contrôle avant génération",
"copied": "Copié",
"copy": "Copier",
"copyAll": "Tout copier",
"copyJobId": "Copier l'ID du travail",
"copyToClipboard": "Copier dans le presse-papiers",
"copyURL": "Copier lURL",
@@ -762,6 +794,8 @@
"goToNode": "Aller au nœud",
"graphNavigation": "Navigation dans le graphe",
"halfSpeed": "0.5x",
"hideLeftPanel": "Masquer le panneau de gauche",
"hideRightPanel": "Masquer le panneau de droite",
"icon": "Icône",
"imageFailedToLoad": "Échec du chargement de l'image",
"imagePreview": "Aperçu de l'image - Utilisez les flèches pour naviguer entre les images",
@@ -803,6 +837,7 @@
"name": "Nom",
"newFolder": "Nouveau dossier",
"next": "Suivant",
"nightly": "NIGHTLY",
"no": "Non",
"noAudioRecorded": "Aucun audio enregistré",
"noItems": "Aucun élément",
@@ -817,6 +852,7 @@
"nodeSlotsError": "Erreur d'emplacements du nœud",
"nodeWidgetsError": "Erreur de widgets du nœud",
"nodes": "Nœuds",
"nodesCount": "{count} nœuds | {count} nœud | {count} nœuds",
"nodesRunning": "nœuds en cours dexécution",
"none": "Aucun",
"nothingToCopy": "Rien à copier",
@@ -891,7 +927,9 @@
"selectedFile": "Fichier sélectionné",
"setAsBackground": "Définir comme arrière-plan",
"settings": "Paramètres",
"showLeftPanel": "Afficher le panneau de gauche",
"showReport": "Afficher le rapport",
"showRightPanel": "Afficher le panneau de droite",
"singleSelectDropdown": "Menu déroulant à sélection unique",
"sort": "Trier",
"source": "Source",
@@ -914,6 +952,7 @@
"updating": "Mise à jour",
"upload": "Téléverser",
"usageHint": "Conseil d'utilisation",
"use": "Utiliser",
"user": "Utilisateur",
"versionMismatchWarning": "Avertissement de compatibilité de version",
"versionMismatchWarningMessage": "{warning} : {detail} Consultez https://docs.comfy.org/installation/update_comfyui#common-update-issues pour les instructions de mise à jour.",
@@ -1617,11 +1656,18 @@
"title": "Ce flux de travail a des nœuds manquants"
}
},
"nightly": {
"badge": {
"label": "Version de prévisualisation",
"tooltip": "Vous utilisez une version nightly de ComfyUI. Veuillez utiliser le bouton de retour pour partager vos impressions sur ces fonctionnalités."
}
},
"nodeCategories": {
"": "",
"3d": "3d",
"3d_models": "modèles_3d",
"BFL": "BFL",
"Bria": "Bria",
"ByteDance": "ByteDance",
"Gemini": "Gemini",
"Ideogram": "Ideogram",
@@ -1643,6 +1689,7 @@
"Veo": "Veo",
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"_for_testing": "_pour_test",
"advanced": "avancé",
"animation": "animation",
@@ -2084,6 +2131,7 @@
"NodeLibrary": "Bibliothèque de Nœuds",
"Nodes 2_0": "Nodes 2.0",
"Notification Preferences": "Préférences de notification",
"Other": "Autre",
"PLY": "PLY",
"PlanCredits": "Forfait et crédits",
"Pointer": "Pointeur",
@@ -2103,7 +2151,8 @@
"Vue Nodes": "Nœuds Vue",
"VueNodes": "Nœuds Vue",
"Window": "Fenêtre",
"Workflow": "Flux de Travail"
"Workflow": "Flux de Travail",
"Workspace": "Espace de travail"
},
"shape": {
"CARD": "Carte",
@@ -2129,12 +2178,14 @@
"viewControls": "Contrôles d'affichage"
},
"sideToolbar": {
"activeJobStatus": "Tâche active : {status}",
"assets": "Ressources",
"backToAssets": "Retour à toutes les ressources",
"browseTemplates": "Parcourir les modèles d'exemple",
"downloads": "Téléchargements",
"generatedAssetsHeader": "Ressources générées",
"helpCenter": "Centre d'aide",
"importedAssetsHeader": "Ressources importées",
"labels": {
"assets": "Ressources",
"console": "Console",
@@ -2179,6 +2230,7 @@
"queue": "File d'attente",
"queueProgressOverlay": {
"activeJobs": "{count} travail actif | {count} travaux actifs",
"activeJobsShort": "{count} actif(s) | {count} actif(s)",
"activeJobsSuffix": "travaux actifs",
"cancelJobTooltip": "Annuler le travail",
"clearHistory": "Effacer lhistorique de la file dattente",
@@ -2267,9 +2319,11 @@
"beta": "BÊTA",
"billedMonthly": "Facturé mensuellement",
"billedYearly": "{total} facturé annuellement",
"cancelSubscription": "Annuler labonnement",
"changeTo": "Changer pour {plan}",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Logo Comfy Cloud",
"contactOwnerToSubscribe": "Contactez le propriétaire de lespace de travail pour vous abonner",
"contactUs": "Contactez-nous",
"creditsRemainingThisMonth": "Crédits restants ce mois-ci",
"creditsRemainingThisYear": "Crédits restants cette année",
@@ -2282,6 +2336,7 @@
"haveQuestions": "Des questions ou besoin d'une offre entreprise ?",
"invoiceHistory": "Historique des factures",
"learnMore": "En savoir plus",
"managePayment": "Gérer le paiement",
"managePlan": "Gérer le forfait",
"manageSubscription": "Gérer l'abonnement",
"maxDuration": {
@@ -2317,6 +2372,7 @@
"subscribeToComfyCloud": "S'abonner à Comfy Cloud",
"subscribeToRun": "S'abonner",
"subscribeToRunFull": "S'abonner pour exécuter",
"subscriptionRequiredMessage": "Un abonnement est requis pour que les membres puissent exécuter des workflows sur le Cloud",
"tierNameYearly": "{name} Annuel",
"tiers": {
"creator": {
@@ -2348,6 +2404,7 @@
"viewMoreDetails": "Voir plus de détails",
"viewMoreDetailsPlans": "Voir plus de détails sur les forfaits et tarifs",
"viewUsageHistory": "Voir l'historique d'utilisation",
"workspaceNotSubscribed": "Cet espace de travail na pas dabonnement",
"yearly": "Annuel",
"yearlyCreditsLabel": "Crédits annuels totaux",
"yearlyDiscount": "20% DE RÉDUCTION",
@@ -2497,7 +2554,8 @@
"notSet": "Non défini",
"provider": "Méthode de connexion",
"title": "Paramètres utilisateur",
"updatePassword": "Mettre à jour le mot de passe"
"updatePassword": "Mettre à jour le mot de passe",
"workspaceSettings": "Paramètres de lespace de travail"
},
"validation": {
"descriptionRequired": "La description est requise",
@@ -2602,6 +2660,57 @@
"workspaceNotFound": "Espace de travail introuvable"
}
},
"workspacePanel": {
"createWorkspaceDialog": {
"create": "Créer",
"message": "Les espaces de travail permettent aux membres de partager un même pool de crédits. Vous deviendrez le propriétaire après la création.",
"nameLabel": "Nom de lespace de travail*",
"namePlaceholder": "Saisissez le nom de lespace de travail",
"title": "Créer un nouvel espace de travail"
},
"deleteDialog": {
"message": "Tout crédit inutilisé ou ressource non enregistrée sera perdu. Cette action est irréversible.",
"messageWithName": "Supprimer « {name} » ? Tout crédit inutilisé ou ressource non enregistrée sera perdu. Cette action est irréversible.",
"title": "Supprimer cet espace de travail ?"
},
"editWorkspaceDialog": {
"nameLabel": "Nom de lespace de travail",
"save": "Enregistrer",
"title": "Modifier les détails de lespace de travail"
},
"leaveDialog": {
"leave": "Quitter",
"message": "Vous ne pourrez pas le rejoindre à nouveau sans contacter le propriétaire de lespace de travail.",
"title": "Quitter cet espace de travail ?"
},
"menu": {
"deleteWorkspace": "Supprimer lespace de travail",
"deleteWorkspaceDisabledTooltip": "Annulez dabord labonnement actif de votre espace de travail",
"editWorkspace": "Modifier les détails de lespace de travail",
"leaveWorkspace": "Quitter lespace de travail"
},
"tabs": {
"planCredits": "Forfait & Crédits"
},
"toast": {
"failedToCreateWorkspace": "Échec de la création de lespace de travail",
"failedToDeleteWorkspace": "Échec de la suppression de lespace de travail",
"failedToLeaveWorkspace": "Échec de la sortie de lespace de travail",
"failedToUpdateWorkspace": "Échec de la mise à jour de lespace de travail",
"workspaceUpdated": {
"message": "Les détails de lespace de travail ont été enregistrés.",
"title": "Espace de travail mis à jour"
}
}
},
"workspaceSwitcher": {
"createWorkspace": "Créer un nouvel espace de travail",
"maxWorkspacesReached": "Vous ne pouvez posséder que 10 espaces de travail. Supprimez-en un pour en créer un nouveau.",
"roleMember": "Membre",
"roleOwner": "Propriétaire",
"subscribe": "Sabonner",
"switchWorkspace": "Changer despace de travail"
},
"zoomControls": {
"hideMinimap": "Masquer la mini-carte",
"label": "Contrôles de zoom",

View File

@@ -328,6 +328,68 @@
}
}
},
"BriaImageEditNode": {
"description": "Modifiez des images en utilisant le dernier modèle Bria",
"display_name": "Bria Image Edit",
"inputs": {
"control_after_generate": {
"name": "contrôle après génération"
},
"guidance_scale": {
"name": "échelle de guidage",
"tooltip": "Une valeur plus élevée fait suivre l'image à l'invite de façon plus précise."
},
"image": {
"name": "image"
},
"mask": {
"name": "masque",
"tooltip": "Si omis, la modification s'applique à l'image entière."
},
"model": {
"name": "modèle"
},
"moderation": {
"name": "modération",
"tooltip": "Paramètres de modération"
},
"moderation_prompt_content_moderation": {
"name": "modération_du_contenu_de_l'invite"
},
"moderation_visual_input_moderation": {
"name": "modération_de_l'entrée_visuelle"
},
"moderation_visual_output_moderation": {
"name": "modération_de_la_sortie_visuelle"
},
"negative_prompt": {
"name": "invite négative"
},
"prompt": {
"name": "invite",
"tooltip": "Instruction pour modifier l'image"
},
"seed": {
"name": "graine"
},
"steps": {
"name": "étapes"
},
"structured_prompt": {
"name": "invite structurée",
"tooltip": "Une chaîne contenant l'invite d'édition structurée au format JSON. Utilisez ceci à la place de l'invite habituelle pour un contrôle précis et programmatique."
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "invite structurée",
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "Générer une vidéo en utilisant l'invite et les première et dernière images.",
"display_name": "ByteDance Première-Dernière Image vers Vidéo",
@@ -2031,7 +2093,9 @@
"choice": {
"name": "choix"
},
"option0": {
"index": {
},
"option1": {
}
},
"outputs": {
@@ -11209,19 +11273,28 @@
}
},
"ResizeImageMaskNode": {
"description": "Redimensionner une image ou un mask en utilisant différentes méthodes de mise à l'échelle.",
"display_name": "Redimensionner image/masque",
"inputs": {
"input": {
"name": "input"
},
"resize_type": {
"name": "resize_type"
"name": "resize_type",
"tooltip": "Sélectionnez la méthode de redimensionnement : par dimensions exactes, facteur d'échelle, correspondance avec une autre image, etc."
},
"resize_type_multiplier": {
"name": "multiplier"
"resize_type_crop": {
"name": "rogner"
},
"resize_type_height": {
"name": "hauteur"
},
"resize_type_width": {
"name": "largeur"
},
"scale_method": {
"name": "scale_method"
"name": "scale_method",
"tooltip": "Algorithme d'interpolation. 'area' est optimal pour la réduction, 'lanczos' pour l'agrandissement, 'nearest-exact' pour le pixel art."
}
},
"outputs": {
@@ -13450,6 +13523,40 @@
}
}
},
"TextEncodeZImageOmni": {
"display_name": "TextEncodeZImageOmni",
"inputs": {
"auto_resize_images": {
"name": "redimensionnement automatique des images"
},
"clip": {
"name": "clip"
},
"image1": {
"name": "image1"
},
"image2": {
"name": "image2"
},
"image3": {
"name": "image3"
},
"image_encoder": {
"name": "encodeur d'image"
},
"prompt": {
"name": "invite"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TextToLowercase": {
"display_name": "Texte en minuscules",
"inputs": {
@@ -15621,6 +15728,79 @@
}
}
},
"WanInfiniteTalkToVideo": {
"display_name": "WanInfiniteTalkToVideo",
"inputs": {
"audio_encoder_output_1": {
"name": "sortie encodeur audio 1"
},
"audio_scale": {
"name": "échelle audio"
},
"clip_vision_output": {
"name": "sortie vision clip"
},
"height": {
"name": "hauteur"
},
"length": {
"name": "longueur"
},
"mode": {
"name": "mode"
},
"model": {
"name": "modèle"
},
"model_patch": {
"name": "correctif du modèle"
},
"motion_frame_count": {
"name": "nombre dimages de mouvement",
"tooltip": "Nombre dimages précédentes à utiliser comme contexte de mouvement."
},
"negative": {
"name": "négatif"
},
"positive": {
"name": "positif"
},
"previous_frames": {
"name": "images précédentes"
},
"start_image": {
"name": "image de départ"
},
"vae": {
"name": "vae"
},
"width": {
"name": "largeur"
}
},
"outputs": {
"0": {
"name": "modèle",
"tooltip": null
},
"1": {
"name": "positif",
"tooltip": null
},
"2": {
"name": "négatif",
"tooltip": null
},
"3": {
"name": "latent",
"tooltip": null
},
"4": {
"name": "image rognée",
"tooltip": null
}
}
},
"WanMoveConcatTrack": {
"display_name": "WanMoveConcatTrack",
"inputs": {
@@ -16137,6 +16317,43 @@
}
}
},
"WavespeedFlashVSRNode": {
"description": "Upscaler vidéo rapide et de haute qualité qui augmente la résolution et restaure la clarté des séquences basse résolution ou floues.",
"display_name": "FlashVSR Upscale Vidéo",
"inputs": {
"target_resolution": {
"name": "résolution cible"
},
"video": {
"name": "vidéo"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WavespeedImageUpscaleNode": {
"description": "Augmentez la résolution et la qualité de l'image, en upscalant les photos en 4K ou 8K pour des résultats nets et détaillés.",
"display_name": "WaveSpeed Upscale Image",
"inputs": {
"image": {
"name": "image"
},
"model": {
"name": "modèle"
},
"target_resolution": {
"name": "résolution cible"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WebcamCapture": {
"display_name": "Capture Webcam",
"inputs": {

View File

@@ -24,6 +24,7 @@
"assets": "アセット",
"baseModels": "ベースモデル",
"browseAssets": "アセットを閲覧",
"byType": "タイプ別",
"checkpoints": "チェックポイント",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "例:",
@@ -45,6 +46,10 @@
"failed": "ダウンロードに失敗しました",
"inProgress": "{assetName}をダウンロード中..."
},
"emptyImported": {
"canImport": "まだインポートされたモデルはありません。「モデルをインポート」をクリックして追加してください。",
"restricted": "パーソナルモデルはCreator以上のプランでのみ利用可能です。"
},
"errorFileTooLarge": "ファイルが許可された最大サイズを超えています",
"errorFormatNotAllowed": "SafeTensor形式のみ許可されています",
"errorModelTypeNotSupported": "このモデルタイプはサポートされていません",
@@ -61,6 +66,7 @@
"finish": "完了",
"genericLinkPlaceholder": "ここにリンクを貼り付けてください",
"importAnother": "別のファイルをインポート",
"imported": "インポート済み",
"jobId": "ジョブID",
"loadingModels": "{type}を読み込み中...",
"maxFileSize": "最大ファイルサイズ:{size}",
@@ -70,6 +76,30 @@
"threeDModelPlaceholder": "3Dモデル"
},
"modelAssociatedWithLink": "ご提供いただいたリンクに関連付けられているモデル:",
"modelInfo": {
"addBaseModel": "ベースモデルを追加...",
"addTag": "タグを追加...",
"additionalTags": "追加タグ",
"baseModelUnknown": "ベースモデル不明",
"basicInfo": "基本情報",
"compatibleBaseModels": "互換性のあるベースモデル",
"description": "説明",
"descriptionNotSet": "説明が設定されていません",
"descriptionPlaceholder": "このモデルの説明を追加...",
"displayName": "表示名",
"editDisplayName": "表示名を編集",
"fileName": "ファイル名",
"modelDescription": "モデル説明",
"modelTagging": "モデルタグ付け",
"modelType": "モデルタイプ",
"noAdditionalTags": "追加タグなし",
"selectModelPrompt": "モデルを選択して情報を表示してください",
"selectModelType": "モデルタイプを選択...",
"source": "ソース",
"title": "モデル情報",
"triggerPhrases": "トリガーフレーズ",
"viewOnSource": "{source} で表示"
},
"modelName": "モデル名",
"modelNamePlaceholder": "このモデルの名前を入力してください",
"modelTypeSelectorLabel": "モデルの種類は何ですか?",
@@ -684,6 +714,7 @@
"clearAll": "すべてクリア",
"clearFilters": "フィルターをクリア",
"close": "閉じる",
"closeDialog": "ダイアログを閉じる",
"color": "色",
"comfy": "Comfy",
"comfyOrgLogoAlt": "ComfyOrgロゴ",
@@ -700,6 +731,7 @@
"control_before_generate": "生成前の制御",
"copied": "コピーしました",
"copy": "コピー",
"copyAll": "すべてコピー",
"copyJobId": "ジョブIDをコピー",
"copyToClipboard": "クリップボードにコピー",
"copyURL": "URLをコピー",
@@ -762,6 +794,8 @@
"goToNode": "ノードに移動",
"graphNavigation": "グラフナビゲーション",
"halfSpeed": "0.5倍速",
"hideLeftPanel": "左パネルを非表示",
"hideRightPanel": "右パネルを非表示",
"icon": "アイコン",
"imageFailedToLoad": "画像の読み込みに失敗しました",
"imagePreview": "画像プレビュー - 矢印キーで画像を切り替え",
@@ -803,6 +837,7 @@
"name": "名前",
"newFolder": "新しいフォルダー",
"next": "次へ",
"nightly": "NIGHTLY",
"no": "いいえ",
"noAudioRecorded": "音声が録音されていません",
"noItems": "項目がありません",
@@ -817,6 +852,7 @@
"nodeSlotsError": "ノードスロットエラー",
"nodeWidgetsError": "ノードウィジェットエラー",
"nodes": "ノード",
"nodesCount": "{count} ノード | {count} ノード | {count} ノード",
"nodesRunning": "ノードが実行中",
"none": "なし",
"nothingToCopy": "コピーするものがありません",
@@ -891,7 +927,9 @@
"selectedFile": "選択されたファイル",
"setAsBackground": "背景として設定",
"settings": "設定",
"showLeftPanel": "左パネルを表示",
"showReport": "レポートを表示",
"showRightPanel": "右パネルを表示",
"singleSelectDropdown": "単一選択ドロップダウン",
"sort": "並び替え",
"source": "ソース",
@@ -914,6 +952,7 @@
"updating": "更新中",
"upload": "アップロード",
"usageHint": "使用ヒント",
"use": "使用",
"user": "ユーザー",
"versionMismatchWarning": "バージョン互換性の警告",
"versionMismatchWarningMessage": "{warning}: {detail} 更新手順については https://docs.comfy.org/installation/update_comfyui#common-update-issues をご覧ください。",
@@ -1617,11 +1656,18 @@
"title": "このワークフローには不足しているノードがあります"
}
},
"nightly": {
"badge": {
"label": "プレビュー版",
"tooltip": "現在、ComfyUI のナイトリーバージョンを使用しています。これらの機能についてご意見があれば、フィードバックボタンからお知らせください。"
}
},
"nodeCategories": {
"": "",
"3d": "3d",
"3d_models": "3Dモデル",
"BFL": "BFL",
"Bria": "Bria",
"ByteDance": "ByteDance",
"Gemini": "Gemini",
"Ideogram": "Ideogram",
@@ -1643,6 +1689,7 @@
"Veo": "Veo",
"Vidu": "Vidu",
"Wan": "Wan",
"WaveSpeed": "WaveSpeed",
"_for_testing": "_テスト用",
"advanced": "高度な機能",
"animation": "アニメーション",
@@ -2084,6 +2131,7 @@
"NodeLibrary": "ノードライブラリ",
"Nodes 2_0": "Nodes 2.0",
"Notification Preferences": "通知設定",
"Other": "その他",
"PLY": "PLY",
"PlanCredits": "プランとクレジット",
"Pointer": "ポインタ",
@@ -2103,7 +2151,8 @@
"Vue Nodes": "Vueード",
"VueNodes": "Vueード",
"Window": "ウィンドウ",
"Workflow": "ワークフロー"
"Workflow": "ワークフロー",
"Workspace": "ワークスペース"
},
"shape": {
"CARD": "カード",
@@ -2129,12 +2178,14 @@
"viewControls": "ビューコントロール"
},
"sideToolbar": {
"activeJobStatus": "アクティブジョブ: {status}",
"assets": "アセット",
"backToAssets": "すべてのアセットに戻る",
"browseTemplates": "サンプルテンプレートを表示",
"downloads": "ダウンロード",
"generatedAssetsHeader": "生成されたアセット",
"helpCenter": "ヘルプセンター",
"importedAssetsHeader": "インポート済みアセット",
"labels": {
"assets": "アセット",
"console": "コンソール",
@@ -2179,6 +2230,7 @@
"queue": "キュー",
"queueProgressOverlay": {
"activeJobs": "{count}件のアクティブジョブ",
"activeJobsShort": "{count} 件のアクティブ | {count} 件のアクティブ",
"activeJobsSuffix": "アクティブジョブ",
"cancelJobTooltip": "ジョブをキャンセル",
"clearHistory": "ジョブキュー履歴をクリア",
@@ -2267,9 +2319,11 @@
"beta": "ベータ版",
"billedMonthly": "毎月請求",
"billedYearly": "{total} 年間請求",
"cancelSubscription": "サブスクリプションをキャンセル",
"changeTo": "{plan}に変更",
"comfyCloud": "Comfy Cloud",
"comfyCloudLogo": "Comfy Cloud ロゴ",
"contactOwnerToSubscribe": "サブスクリプションのためにワークスペースのオーナーに連絡してください",
"contactUs": "お問い合わせ",
"creditsRemainingThisMonth": "今月残りのクレジット",
"creditsRemainingThisYear": "今年残りのクレジット",
@@ -2282,6 +2336,7 @@
"haveQuestions": "ご質問やエンタープライズについてのお問い合わせはこちら",
"invoiceHistory": "請求履歴",
"learnMore": "詳細を見る",
"managePayment": "支払いを管理",
"managePlan": "プランを管理",
"manageSubscription": "サブスクリプションを管理",
"maxDuration": {
@@ -2317,6 +2372,7 @@
"subscribeToComfyCloud": "Comfy Cloudを購読",
"subscribeToRun": "購読する",
"subscribeToRunFull": "実行を購読",
"subscriptionRequiredMessage": "クラウドでワークフローを実行するにはメンバーにサブスクリプションが必要です",
"tierNameYearly": "{name} 年間",
"tiers": {
"creator": {
@@ -2348,6 +2404,7 @@
"viewMoreDetails": "詳細を表示",
"viewMoreDetailsPlans": "プランと価格の詳細を見る",
"viewUsageHistory": "利用履歴を表示",
"workspaceNotSubscribed": "このワークスペースはサブスクリプションに加入していません",
"yearly": "年額",
"yearlyCreditsLabel": "年間合計クレジット",
"yearlyDiscount": "20%割引",
@@ -2497,7 +2554,8 @@
"notSet": "未設定",
"provider": "サインイン方法",
"title": "ユーザー設定",
"updatePassword": "パスワードを更新"
"updatePassword": "パスワードを更新",
"workspaceSettings": "ワークスペース設定"
},
"validation": {
"descriptionRequired": "説明が必要です",
@@ -2602,6 +2660,57 @@
"workspaceNotFound": "ワークスペースが見つかりません"
}
},
"workspacePanel": {
"createWorkspaceDialog": {
"create": "作成",
"message": "ワークスペースはメンバーでクレジットプールを共有できます。作成後、あなたがオーナーになります。",
"nameLabel": "ワークスペース名*",
"namePlaceholder": "ワークスペース名を入力",
"title": "新しいワークスペースを作成"
},
"deleteDialog": {
"message": "未使用のクレジットや保存されていないアセットは失われます。この操作は元に戻せません。",
"messageWithName": "「{name}」を削除しますか?未使用のクレジットや保存されていないアセットは失われます。この操作は元に戻せません。",
"title": "このワークスペースを削除しますか?"
},
"editWorkspaceDialog": {
"nameLabel": "ワークスペース名",
"save": "保存",
"title": "ワークスペースの詳細を編集"
},
"leaveDialog": {
"leave": "退出",
"message": "ワークスペースのオーナーに連絡しない限り、再参加できません。",
"title": "このワークスペースを退出しますか?"
},
"menu": {
"deleteWorkspace": "ワークスペースを削除",
"deleteWorkspaceDisabledTooltip": "まずワークスペースの有効なサブスクリプションをキャンセルしてください",
"editWorkspace": "ワークスペースの詳細を編集",
"leaveWorkspace": "ワークスペースを退出"
},
"tabs": {
"planCredits": "プランとクレジット"
},
"toast": {
"failedToCreateWorkspace": "ワークスペースの作成に失敗しました",
"failedToDeleteWorkspace": "ワークスペースの削除に失敗しました",
"failedToLeaveWorkspace": "ワークスペースの退出に失敗しました",
"failedToUpdateWorkspace": "ワークスペースの更新に失敗しました",
"workspaceUpdated": {
"message": "ワークスペースの詳細が保存されました。",
"title": "ワークスペースが更新されました"
}
}
},
"workspaceSwitcher": {
"createWorkspace": "新しいワークスペースを作成",
"maxWorkspacesReached": "所有できるワークスペースは10個までです。新しく作成するには1つ削除してください。",
"roleMember": "メンバー",
"roleOwner": "オーナー",
"subscribe": "サブスクライブ",
"switchWorkspace": "ワークスペースを切り替え"
},
"zoomControls": {
"hideMinimap": "ミニマップを非表示",
"label": "ズームコントロール",

View File

@@ -328,6 +328,68 @@
}
}
},
"BriaImageEditNode": {
"description": "Briaの最新モデルを使って画像を編集します",
"display_name": "Bria画像編集",
"inputs": {
"control_after_generate": {
"name": "生成後コントロール"
},
"guidance_scale": {
"name": "ガイダンススケール",
"tooltip": "値が高いほどプロンプトに忠実な画像になります。"
},
"image": {
"name": "画像"
},
"mask": {
"name": "マスク",
"tooltip": "省略した場合、編集は画像全体に適用されます。"
},
"model": {
"name": "model"
},
"moderation": {
"name": "モデレーション",
"tooltip": "モデレーション設定"
},
"moderation_prompt_content_moderation": {
"name": "プロンプト内容モデレーション"
},
"moderation_visual_input_moderation": {
"name": "入力画像モデレーション"
},
"moderation_visual_output_moderation": {
"name": "出力画像モデレーション"
},
"negative_prompt": {
"name": "ネガティブプロンプト"
},
"prompt": {
"name": "プロンプト",
"tooltip": "画像編集の指示"
},
"seed": {
"name": "シード"
},
"steps": {
"name": "ステップ数"
},
"structured_prompt": {
"name": "構造化プロンプト",
"tooltip": "JSON形式の構造化編集プロンプトを含む文字列。より正確でプログラム的な制御が必要な場合は通常のプロンプトの代わりに使用してください。"
}
},
"outputs": {
"0": {
"tooltip": null
},
"1": {
"name": "構造化プロンプト",
"tooltip": null
}
}
},
"ByteDanceFirstLastFrameNode": {
"description": "プロンプトと最初・最後のフレームを使用して動画を生成します。",
"display_name": "ByteDance 最初-最後フレームから動画生成",
@@ -2031,7 +2093,9 @@
"choice": {
"name": "選択"
},
"option0": {
"index": {
},
"option1": {
}
},
"outputs": {
@@ -11209,19 +11273,28 @@
}
},
"ResizeImageMaskNode": {
"description": "さまざまなスケーリング方法で画像またはマスクをリサイズします。",
"display_name": "画像/マスクのリサイズ",
"inputs": {
"input": {
"name": "input"
},
"resize_type": {
"name": "resize_type"
"name": "resize_type",
"tooltip": "リサイズ方法を選択します:正確な寸法、スケール係数、他の画像に合わせるなど。"
},
"resize_type_multiplier": {
"name": "multiplier"
"resize_type_crop": {
"name": "切り抜き"
},
"resize_type_height": {
"name": "高さ"
},
"resize_type_width": {
"name": "幅"
},
"scale_method": {
"name": "scale_method"
"name": "scale_method",
"tooltip": "補間アルゴリズム。「area」は縮小に最適、「lanczos」は拡大に最適、「nearest-exact」はドット絵に最適です。"
}
},
"outputs": {
@@ -13450,6 +13523,40 @@
}
}
},
"TextEncodeZImageOmni": {
"display_name": "TextEncodeZImageOmni",
"inputs": {
"auto_resize_images": {
"name": "画像自動リサイズ"
},
"clip": {
"name": "clip"
},
"image1": {
"name": "画像1"
},
"image2": {
"name": "画像2"
},
"image3": {
"name": "画像3"
},
"image_encoder": {
"name": "画像エンコーダ"
},
"prompt": {
"name": "プロンプト"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"TextToLowercase": {
"display_name": "テキストを小文字に変換",
"inputs": {
@@ -15621,6 +15728,79 @@
}
}
},
"WanInfiniteTalkToVideo": {
"display_name": "WanInfiniteTalkToVideo",
"inputs": {
"audio_encoder_output_1": {
"name": "オーディオエンコーダ出力1"
},
"audio_scale": {
"name": "オーディオスケール"
},
"clip_vision_output": {
"name": "clipビジョン出力"
},
"height": {
"name": "高さ"
},
"length": {
"name": "長さ"
},
"mode": {
"name": "モード"
},
"model": {
"name": "モデル"
},
"model_patch": {
"name": "モデルパッチ"
},
"motion_frame_count": {
"name": "モーションフレーム数",
"tooltip": "動きのコンテキストとして使用する前のフレーム数。"
},
"negative": {
"name": "ネガティブ"
},
"positive": {
"name": "ポジティブ"
},
"previous_frames": {
"name": "前のフレーム"
},
"start_image": {
"name": "開始画像"
},
"vae": {
"name": "vae"
},
"width": {
"name": "幅"
}
},
"outputs": {
"0": {
"name": "モデル",
"tooltip": null
},
"1": {
"name": "ポジティブ",
"tooltip": null
},
"2": {
"name": "ネガティブ",
"tooltip": null
},
"3": {
"name": "潜在",
"tooltip": null
},
"4": {
"name": "トリム画像",
"tooltip": null
}
}
},
"WanMoveConcatTrack": {
"display_name": "WanMoveConcatTrack",
"inputs": {
@@ -16137,6 +16317,43 @@
}
}
},
"WavespeedFlashVSRNode": {
"description": "低解像度やぼやけた映像の解像度を向上させ、鮮明さを復元する高速・高品質なビデオアップスケーラーです。",
"display_name": "FlashVSRビデオ高解像度化",
"inputs": {
"target_resolution": {
"name": "目標解像度"
},
"video": {
"name": "ビデオ"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WavespeedImageUpscaleNode": {
"description": "画像の解像度と品質を向上させ、写真を4Kや8Kにアップスケールしてシャープで詳細な結果を得られます。",
"display_name": "WaveSpeed画像高解像度化",
"inputs": {
"image": {
"name": "画像"
},
"model": {
"name": "model"
},
"target_resolution": {
"name": "目標解像度"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"WebcamCapture": {
"display_name": "ウェブカメラキャプチャ",
"inputs": {

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