Merge remote-tracking branch 'origin/main' into fix/remove-queue-overlay-mini

This commit is contained in:
Benjamin Lu
2026-01-22 14:16:17 -08:00
134 changed files with 9175 additions and 6248 deletions

View File

@@ -1,12 +1,17 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
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 { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { isElectron } from '@/utils/envUtil'
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
@@ -36,7 +41,8 @@ function createWrapper() {
sideToolbar: {
queueProgressOverlay: {
viewJobHistory: 'View job history',
expandCollapsedQueue: 'Expand collapsed queue'
expandCollapsedQueue: 'Expand collapsed queue',
activeJobsShort: '{count} active | {count} active'
}
}
}
@@ -59,6 +65,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 +119,19 @@ 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')
})
})

View File

@@ -39,19 +39,17 @@
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
type="destructive"
size="icon"
size="md"
:aria-pressed="isQueueOverlayExpanded"
:aria-label="
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
"
class="px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
>
<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">
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
</span>
</Button>
<CurrentUserButton
@@ -109,17 +107,25 @@ 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 queueUIStore = useQueueUIStore()
const { activeJobsCount } = storeToRefs(queueStore)
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const releaseStore = useReleaseStore()
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
useConflictAcknowledgment()
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'
)

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

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

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

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

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

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

@@ -0,0 +1,109 @@
<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"
>
<ActiveJobCard 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 ActiveJobCard from '@/components/sidebar/tabs/assets/ActiveJobCard.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"
@@ -114,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,
@@ -172,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

@@ -105,30 +105,19 @@
@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>
@@ -213,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'
@@ -220,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'
@@ -258,6 +247,7 @@ interface JobOutputItem {
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const { activeJobsCount } = storeToRefs(queueStore)
const executionStore = useExecutionStore()
const settingStore = useSettingStore()
@@ -304,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(
@@ -407,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) => {
@@ -456,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) {

View File

@@ -0,0 +1,111 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ActiveJobCard from './ActiveJobCard.vue'
import type { JobListItem } from '@/composables/queue/useJobList'
vi.mock('@/composables/useProgressBarBackground', () => ({
useProgressBarBackground: () => ({
progressBarPrimaryClass: 'bg-blue-500',
hasProgressPercent: (val: number | undefined) => typeof val === 'number',
progressPercentStyle: (val: number) => ({ width: `${val}%` })
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
sideToolbar: {
activeJobStatus: 'Active job: {status}'
}
}
}
})
const createJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'test-job-1',
title: 'Running...',
meta: 'Step 5/10',
state: 'running',
progressTotalPercent: 50,
progressCurrentPercent: 75,
...overrides
})
const mountComponent = (job: JobListItem) =>
mount(ActiveJobCard, {
props: { job },
global: {
plugins: [i18n]
}
})
describe('ActiveJobCard', () => {
it('displays percentage and progress bar when job is running', () => {
const wrapper = mountComponent(
createJob({ state: 'running', progressTotalPercent: 65 })
)
expect(wrapper.text()).toContain('65%')
const progressBar = wrapper.find('.bg-blue-500')
expect(progressBar.exists()).toBe(true)
expect(progressBar.attributes('style')).toContain('width: 65%')
})
it('displays status text when job is pending', () => {
const wrapper = mountComponent(
createJob({
state: 'pending',
title: 'In queue...',
progressTotalPercent: undefined
})
)
expect(wrapper.text()).toContain('In queue...')
const progressBar = wrapper.find('.bg-blue-500')
expect(progressBar.exists()).toBe(false)
})
it('shows spinner for pending state', () => {
const wrapper = mountComponent(createJob({ state: 'pending' }))
const spinner = wrapper.find('.icon-\\[lucide--loader-circle\\]')
expect(spinner.exists()).toBe(true)
expect(spinner.classes()).toContain('animate-spin')
})
it('shows error icon for failed state', () => {
const wrapper = mountComponent(
createJob({ state: 'failed', title: 'Failed' })
)
const errorIcon = wrapper.find('.icon-\\[lucide--circle-alert\\]')
expect(errorIcon.exists()).toBe(true)
expect(wrapper.text()).toContain('Failed')
})
it('shows preview image when running with iconImageUrl', () => {
const wrapper = mountComponent(
createJob({
state: 'running',
iconImageUrl: 'https://example.com/preview.jpg'
})
)
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
expect(img.attributes('src')).toBe('https://example.com/preview.jpg')
})
it('has proper accessibility attributes', () => {
const wrapper = mountComponent(createJob({ title: 'Generating...' }))
const container = wrapper.find('[role="status"]')
expect(container.exists()).toBe(true)
expect(container.attributes('aria-label')).toBe('Active job: Generating...')
})
})

View File

@@ -0,0 +1,85 @@
<template>
<div
role="status"
:aria-label="t('sideToolbar.activeJobStatus', { status: statusText })"
class="flex flex-col gap-2 p-2 rounded-lg"
>
<!-- Thumbnail -->
<div class="relative aspect-square overflow-hidden rounded-lg">
<!-- Running state with preview image -->
<img
v-if="isRunning && job.iconImageUrl"
:src="job.iconImageUrl"
:alt="statusText"
class="size-full object-cover"
/>
<!-- Placeholder for queued/failed states or running without preview -->
<div
v-else
class="absolute inset-0 flex items-center justify-center bg-modal-card-placeholder-background"
>
<!-- Spinner for queued/initialization states -->
<i
v-if="isQueued"
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
/>
<!-- Error icon for failed state -->
<i
v-else-if="isFailed"
class="icon-[lucide--circle-alert] size-8 text-red-500"
/>
<!-- Spinner for running without preview -->
<i
v-else
class="icon-[lucide--loader-circle] size-8 animate-spin text-muted-foreground"
/>
</div>
</div>
<!-- Footer: Progress bar or status text -->
<div class="flex gap-1.5 items-center h-5">
<!-- Running state: percentage + progress bar -->
<template v-if="isRunning && hasProgressPercent(progressPercent)">
<span class="shrink-0 text-sm text-muted-foreground">
{{ Math.round(progressPercent ?? 0) }}%
</span>
<div class="flex-1 relative h-1 rounded-sm bg-secondary-background">
<div
:class="progressBarPrimaryClass"
:style="progressPercentStyle(progressPercent)"
/>
</div>
</template>
<!-- Non-running states: status text only -->
<template v-else>
<div class="w-full truncate text-center text-sm text-muted-foreground">
{{ statusText }}
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
const { job } = defineProps<{ job: JobListItem }>()
const { t } = useI18n()
const { progressBarPrimaryClass, hasProgressPercent, progressPercentStyle } =
useProgressBarBackground()
const statusText = computed(() => job.title)
const progressPercent = computed(() => job.progressTotalPercent)
const isQueued = computed(
() => job.state === 'pending' || job.state === 'initialization'
)
const isRunning = computed(() => job.state === 'running')
const isFailed = computed(() => job.state === 'failed')
</script>

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

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

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

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

@@ -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'
@@ -38,3 +38,8 @@ if (isCloud) {
await import('./cloudSubscription')
}
}
// 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

@@ -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,29 @@
"threeDModelPlaceholder": "نموذج ثلاثي الأبعاد"
},
"modelAssociatedWithLink": "النموذج المرتبط بالرابط الذي قدمته:",
"modelInfo": {
"addBaseModel": "أضف نموذجًا أساسيًا...",
"addTag": "أضف وسمًا...",
"additionalTags": "وسوم إضافية",
"baseModelUnknown": "النموذج الأساسي غير معروف",
"basicInfo": "معلومات أساسية",
"compatibleBaseModels": "نماذج أساسية متوافقة",
"description": "الوصف",
"descriptionNotSet": "لم يتم تعيين وصف",
"descriptionPlaceholder": "أضف وصفًا لهذا النموذج...",
"displayName": "اسم العرض",
"fileName": "اسم الملف",
"modelDescription": "وصف النموذج",
"modelTagging": "تصنيف النموذج",
"modelType": "نوع النموذج",
"noAdditionalTags": "لا توجد وسوم إضافية",
"selectModelPrompt": "اختر نموذجًا لعرض معلوماته",
"selectModelType": "اختر نوع النموذج...",
"source": "المصدر",
"title": "معلومات النموذج",
"triggerPhrases": "عبارات التفعيل",
"viewOnSource": "عرض على {source}"
},
"modelName": "اسم النموذج",
"modelNamePlaceholder": "أدخل اسمًا لهذا النموذج",
"modelTypeSelectorLabel": "ما نوع هذا النموذج؟",
@@ -684,6 +713,7 @@
"clearAll": "مسح الكل",
"clearFilters": "مسح الفلاتر",
"close": "إغلاق",
"closeDialog": "إغلاق الحوار",
"color": "اللون",
"comfy": "Comfy",
"comfyOrgLogoAlt": "شعار ComfyOrg",
@@ -762,6 +792,8 @@
"goToNode": "الانتقال إلى العقدة",
"graphNavigation": "التنقل في الرسم البياني",
"halfSpeed": "0.5x",
"hideLeftPanel": "إخفاء اللوحة اليسرى",
"hideRightPanel": "إخفاء اللوحة اليمنى",
"icon": "أيقونة",
"imageFailedToLoad": "فشل تحميل الصورة",
"imagePreview": "معاينة الصورة - استخدم مفاتيح الأسهم للتنقل بين الصور",
@@ -803,6 +835,7 @@
"name": "الاسم",
"newFolder": "مجلد جديد",
"next": "التالي",
"nightly": "NIGHTLY",
"no": "لا",
"noAudioRecorded": "لم يتم تسجيل أي صوت",
"noItems": "لا توجد عناصر",
@@ -892,7 +925,9 @@
"selectedFile": "الملف المحدد",
"setAsBackground": "تعيين كخلفية",
"settings": "الإعدادات",
"showLeftPanel": "إظهار اللوحة اليسرى",
"showReport": "عرض التقرير",
"showRightPanel": "إظهار اللوحة اليمنى",
"singleSelectDropdown": "قائمة منسدلة اختيار واحد",
"sort": "فرز",
"source": "المصدر",
@@ -915,6 +950,7 @@
"updating": "جارٍ التحديث",
"upload": "رفع",
"usageHint": "تلميح الاستخدام",
"use": "استخدم",
"user": "المستخدم",
"versionMismatchWarning": "تحذير توافق الإصدارات",
"versionMismatchWarningMessage": "{warning}: {detail} زر https://docs.comfy.org/installation/update_comfyui#common-update-issues للحصول على تعليمات التحديث.",
@@ -1618,6 +1654,12 @@
"title": "سير العمل هذا يحتوي على عقد مفقودة"
}
},
"nightly": {
"badge": {
"label": "إصدار معاينة",
"tooltip": "أنت تستخدم إصدارًا ليليًا من ComfyUI. يرجى استخدام زر الملاحظات لمشاركة آرائك حول هذه الميزات."
}
},
"nodeCategories": {
"": "",
"3d": "ثلاثي الأبعاد",
@@ -2132,12 +2174,14 @@
"viewControls": "عناصر تحكم العرض"
},
"sideToolbar": {
"activeJobStatus": "المهمة النشطة: {status}",
"assets": "الأصول",
"backToAssets": "العودة إلى جميع الأصول",
"browseTemplates": "تصفح القوالب المثال",
"downloads": "التنزيلات",
"generatedAssetsHeader": "الأصول المُولدة",
"helpCenter": "مركز المساعدة",
"importedAssetsHeader": "الأصول المستوردة",
"labels": {
"assets": "الأصول",
"console": "وحدة التحكم",
@@ -2182,6 +2226,7 @@
"queue": "قائمة الانتظار",
"queueProgressOverlay": {
"activeJobs": "{count} مهمة نشطة | {count} مهام نشطة",
"activeJobsShort": "{count} نشط | {count} نشط",
"activeJobsSuffix": "مهام نشطة",
"cancelJobTooltip": "إلغاء المهمة",
"clearHistory": "مسح سجل قائمة الانتظار",

View File

@@ -100,6 +100,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",
@@ -179,6 +184,7 @@
"source": "Source",
"filter": "Filter",
"apply": "Apply",
"use": "Use",
"enabled": "Enabled",
"installed": "Installed",
"restart": "Restart",
@@ -270,6 +276,7 @@
"1x": "1x",
"2x": "2x",
"beta": "BETA",
"nightly": "NIGHTLY",
"profile": "Profile",
"noItems": "No items"
},
@@ -704,6 +711,7 @@
"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",
@@ -753,6 +761,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",
@@ -1277,7 +1286,10 @@
"VueNodes": "Nodes 2.0",
"Nodes 2_0": "Nodes 2.0",
"Execution": "Execution",
"PLY": "PLY"
"PLY": "PLY",
"Workspace": "Workspace",
"General": "General",
"Other": "Other"
},
"serverConfigItems": {
"listen": {
@@ -2001,6 +2013,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",
@@ -2055,6 +2069,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",
@@ -2090,12 +2107,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)",
@@ -2310,6 +2379,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",
@@ -2400,6 +2475,29 @@
"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",
"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"
@@ -2622,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

@@ -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,29 @@
"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",
"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 +713,7 @@
"clearAll": "Borrar todo",
"clearFilters": "Borrar filtros",
"close": "Cerrar",
"closeDialog": "Cerrar diálogo",
"color": "Color",
"comfy": "Comfy",
"comfyOrgLogoAlt": "Logo de ComfyOrg",
@@ -762,6 +792,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 +835,7 @@
"name": "Nombre",
"newFolder": "Nueva carpeta",
"next": "Siguiente",
"nightly": "NIGHTLY",
"no": "No",
"noAudioRecorded": "No se grabó audio",
"noItems": "Sin elementos",
@@ -892,7 +925,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",
@@ -915,6 +950,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.",
@@ -1618,6 +1654,12 @@
"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",
@@ -2132,12 +2174,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",
@@ -2182,6 +2226,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",

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,29 @@
"threeDModelPlaceholder": "مدل سه‌بعدی"
},
"modelAssociatedWithLink": "مدل مرتبط با لینکی که وارد کردید:",
"modelInfo": {
"addBaseModel": "افزودن مدل پایه...",
"addTag": "افزودن برچسب...",
"additionalTags": "برچسب‌های اضافی",
"baseModelUnknown": "مدل پایه نامشخص",
"basicInfo": "اطلاعات پایه",
"compatibleBaseModels": "مدل‌های پایه سازگار",
"description": "توضیحات",
"descriptionNotSet": "توضیحی تنظیم نشده است",
"descriptionPlaceholder": "یک توضیح برای این مدل اضافه کنید...",
"displayName": "نام نمایشی",
"fileName": "نام فایل",
"modelDescription": "توضیحات مدل",
"modelTagging": "برچسب‌گذاری مدل",
"modelType": "نوع مدل",
"noAdditionalTags": "برچسب اضافی وجود ندارد",
"selectModelPrompt": "یک مدل را برای مشاهده اطلاعات آن انتخاب کنید",
"selectModelType": "انتخاب نوع مدل...",
"source": "منبع",
"title": "اطلاعات مدل",
"triggerPhrases": "عبارات فعال‌ساز",
"viewOnSource": "مشاهده در {source}"
},
"modelName": "نام مدل",
"modelNamePlaceholder": "یک نام برای این مدل وارد کنید",
"modelTypeSelectorLabel": "نوع مدل چیست؟",
@@ -684,6 +713,7 @@
"clearAll": "پاک‌سازی همه",
"clearFilters": "پاک‌سازی فیلترها",
"close": "بستن",
"closeDialog": "بستن پنجره",
"color": "رنگ",
"comfy": "Comfy",
"comfyOrgLogoAlt": "لوگوی ComfyOrg",
@@ -762,6 +792,8 @@
"goToNode": "رفتن به node",
"graphNavigation": "ناوبری گراف",
"halfSpeed": "۰.۵x",
"hideLeftPanel": "پنهان کردن پنل چپ",
"hideRightPanel": "پنهان کردن پنل راست",
"icon": "آیکون",
"imageFailedToLoad": "بارگذاری تصویر ناموفق بود",
"imagePreview": "پیش‌نمایش تصویر - برای جابجایی بین تصاویر از کلیدهای جهت‌دار استفاده کنید",
@@ -803,6 +835,7 @@
"name": "نام",
"newFolder": "پوشه جدید",
"next": "بعدی",
"nightly": "نسخه شبانه",
"no": "خیر",
"noAudioRecorded": "هیچ صدایی ضبط نشد",
"noItems": "هیچ موردی وجود ندارد",
@@ -892,7 +925,9 @@
"selectedFile": "فایل انتخاب‌شده",
"setAsBackground": "تنظیم به عنوان پس‌زمینه",
"settings": "تنظیمات",
"showLeftPanel": "نمایش پنل چپ",
"showReport": "نمایش گزارش",
"showRightPanel": "نمایش پنل راست",
"singleSelectDropdown": "لیست کشویی تک‌انتخابی",
"sort": "مرتب‌سازی",
"source": "منبع",
@@ -915,6 +950,7 @@
"updating": "در حال به‌روزرسانی {id}",
"upload": "بارگذاری",
"usageHint": "راهنمای استفاده",
"use": "استفاده",
"user": "کاربر",
"versionMismatchWarning": "هشدار ناسازگاری نسخه",
"versionMismatchWarningMessage": "{warning}: {detail} برای راهنمای به‌روزرسانی به https://docs.comfy.org/installation/update_comfyui#common-update-issues مراجعه کنید.",
@@ -1618,6 +1654,12 @@
"title": "این workflow دارای nodeهای مفقود است"
}
},
"nightly": {
"badge": {
"label": "نسخه پیش‌نمایش",
"tooltip": "شما در حال استفاده از نسخه شبانه ComfyUI هستید. لطفاً با استفاده از دکمه بازخورد، نظرات خود را درباره این قابلیت‌ها به اشتراک بگذارید."
}
},
"nodeCategories": {
"": "",
"3d": "سه‌بعدی",
@@ -2132,12 +2174,14 @@
"viewControls": "کنترل‌های نمایش"
},
"sideToolbar": {
"activeJobStatus": "وضعیت کار فعال: {status}",
"assets": "دارایی‌ها",
"backToAssets": "بازگشت به همه دارایی‌ها",
"browseTemplates": "مرور قالب‌های نمونه",
"downloads": "دانلودها",
"generatedAssetsHeader": "دارایی‌های تولیدشده",
"helpCenter": "مرکز راهنما",
"importedAssetsHeader": "دارایی‌های واردشده",
"labels": {
"assets": "دارایی‌ها",
"console": "کنسول",
@@ -2193,6 +2237,7 @@
"queue": "صف",
"queueProgressOverlay": {
"activeJobs": "{count} کار فعال",
"activeJobsShort": "{count} فعال | {count} فعال",
"activeJobsSuffix": "کار فعال",
"cancelJobTooltip": "لغو کار",
"clearHistory": "پاک‌سازی تاریخچه صف کار",

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,29 @@
"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",
"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 +713,7 @@
"clearAll": "Tout effacer",
"clearFilters": "Effacer les filtres",
"close": "Fermer",
"closeDialog": "Fermer la boîte de dialogue",
"color": "Couleur",
"comfy": "Comfy",
"comfyOrgLogoAlt": "Logo ComfyOrg",
@@ -762,6 +792,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 +835,7 @@
"name": "Nom",
"newFolder": "Nouveau dossier",
"next": "Suivant",
"nightly": "NIGHTLY",
"no": "Non",
"noAudioRecorded": "Aucun audio enregistré",
"noItems": "Aucun élément",
@@ -892,7 +925,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",
@@ -915,6 +950,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.",
@@ -1618,6 +1654,12 @@
"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",
@@ -2132,12 +2174,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",
@@ -2182,6 +2226,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",

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,29 @@
"threeDModelPlaceholder": "3Dモデル"
},
"modelAssociatedWithLink": "ご提供いただいたリンクに関連付けられているモデル:",
"modelInfo": {
"addBaseModel": "ベースモデルを追加...",
"addTag": "タグを追加...",
"additionalTags": "追加タグ",
"baseModelUnknown": "ベースモデル不明",
"basicInfo": "基本情報",
"compatibleBaseModels": "互換性のあるベースモデル",
"description": "説明",
"descriptionNotSet": "説明が設定されていません",
"descriptionPlaceholder": "このモデルの説明を追加...",
"displayName": "表示名",
"fileName": "ファイル名",
"modelDescription": "モデル説明",
"modelTagging": "モデルタグ付け",
"modelType": "モデルタイプ",
"noAdditionalTags": "追加タグなし",
"selectModelPrompt": "モデルを選択して情報を表示してください",
"selectModelType": "モデルタイプを選択...",
"source": "ソース",
"title": "モデル情報",
"triggerPhrases": "トリガーフレーズ",
"viewOnSource": "{source} で表示"
},
"modelName": "モデル名",
"modelNamePlaceholder": "このモデルの名前を入力してください",
"modelTypeSelectorLabel": "モデルの種類は何ですか?",
@@ -684,6 +713,7 @@
"clearAll": "すべてクリア",
"clearFilters": "フィルターをクリア",
"close": "閉じる",
"closeDialog": "ダイアログを閉じる",
"color": "色",
"comfy": "Comfy",
"comfyOrgLogoAlt": "ComfyOrgロゴ",
@@ -762,6 +792,8 @@
"goToNode": "ノードに移動",
"graphNavigation": "グラフナビゲーション",
"halfSpeed": "0.5倍速",
"hideLeftPanel": "左パネルを非表示",
"hideRightPanel": "右パネルを非表示",
"icon": "アイコン",
"imageFailedToLoad": "画像の読み込みに失敗しました",
"imagePreview": "画像プレビュー - 矢印キーで画像を切り替え",
@@ -803,6 +835,7 @@
"name": "名前",
"newFolder": "新しいフォルダー",
"next": "次へ",
"nightly": "NIGHTLY",
"no": "いいえ",
"noAudioRecorded": "音声が録音されていません",
"noItems": "項目がありません",
@@ -892,7 +925,9 @@
"selectedFile": "選択されたファイル",
"setAsBackground": "背景として設定",
"settings": "設定",
"showLeftPanel": "左パネルを表示",
"showReport": "レポートを表示",
"showRightPanel": "右パネルを表示",
"singleSelectDropdown": "単一選択ドロップダウン",
"sort": "並び替え",
"source": "ソース",
@@ -915,6 +950,7 @@
"updating": "更新中",
"upload": "アップロード",
"usageHint": "使用ヒント",
"use": "使用",
"user": "ユーザー",
"versionMismatchWarning": "バージョン互換性の警告",
"versionMismatchWarningMessage": "{warning}: {detail} 更新手順については https://docs.comfy.org/installation/update_comfyui#common-update-issues をご覧ください。",
@@ -1618,6 +1654,12 @@
"title": "このワークフローには不足しているノードがあります"
}
},
"nightly": {
"badge": {
"label": "プレビュー版",
"tooltip": "現在、ComfyUI のナイトリーバージョンを使用しています。これらの機能についてご意見があれば、フィードバックボタンからお知らせください。"
}
},
"nodeCategories": {
"": "",
"3d": "3d",
@@ -2132,12 +2174,14 @@
"viewControls": "ビューコントロール"
},
"sideToolbar": {
"activeJobStatus": "アクティブジョブ: {status}",
"assets": "アセット",
"backToAssets": "すべてのアセットに戻る",
"browseTemplates": "サンプルテンプレートを表示",
"downloads": "ダウンロード",
"generatedAssetsHeader": "生成されたアセット",
"helpCenter": "ヘルプセンター",
"importedAssetsHeader": "インポート済みアセット",
"labels": {
"assets": "アセット",
"console": "コンソール",
@@ -2182,6 +2226,7 @@
"queue": "キュー",
"queueProgressOverlay": {
"activeJobs": "{count}件のアクティブジョブ",
"activeJobsShort": "{count} 件のアクティブ | {count} 件のアクティブ",
"activeJobsSuffix": "アクティブジョブ",
"cancelJobTooltip": "ジョブをキャンセル",
"clearHistory": "ジョブキュー履歴をクリア",

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,29 @@
"threeDModelPlaceholder": "3D 모델"
},
"modelAssociatedWithLink": "제공하신 링크와 연결된 모델:",
"modelInfo": {
"addBaseModel": "베이스 모델 추가...",
"addTag": "태그 추가...",
"additionalTags": "추가 태그",
"baseModelUnknown": "베이스 모델 알 수 없음",
"basicInfo": "기본 정보",
"compatibleBaseModels": "호환 가능한 베이스 모델",
"description": "설명",
"descriptionNotSet": "설정된 설명 없음",
"descriptionPlaceholder": "이 모델에 대한 설명을 추가하세요...",
"displayName": "표시 이름",
"fileName": "파일 이름",
"modelDescription": "모델 설명",
"modelTagging": "모델 태깅",
"modelType": "모델 유형",
"noAdditionalTags": "추가 태그 없음",
"selectModelPrompt": "모델을 선택하여 정보를 확인하세요",
"selectModelType": "모델 유형 선택...",
"source": "소스",
"title": "모델 정보",
"triggerPhrases": "트리거 문구",
"viewOnSource": "{source}에서 보기"
},
"modelName": "모델 이름",
"modelNamePlaceholder": "이 모델의 이름을 입력하세요",
"modelTypeSelectorLabel": "모델 유형은 무엇인가요?",
@@ -684,6 +713,7 @@
"clearAll": "모두 지우기",
"clearFilters": "필터 지우기",
"close": "닫기",
"closeDialog": "대화 상자 닫기",
"color": "색상",
"comfy": "Comfy",
"comfyOrgLogoAlt": "ComfyOrg 로고",
@@ -762,6 +792,8 @@
"goToNode": "노드로 이동",
"graphNavigation": "그래프 탐색",
"halfSpeed": "0.5배속",
"hideLeftPanel": "왼쪽 패널 숨기기",
"hideRightPanel": "오른쪽 패널 숨기기",
"icon": "아이콘",
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
"imagePreview": "이미지 미리보기 - 화살표 키를 사용하여 이미지 간 이동",
@@ -803,6 +835,7 @@
"name": "이름",
"newFolder": "새 폴더",
"next": "다음",
"nightly": "NIGHTLY",
"no": "아니오",
"noAudioRecorded": "녹음된 오디오가 없습니다",
"noItems": "항목 없음",
@@ -892,7 +925,9 @@
"selectedFile": "선택된 파일",
"setAsBackground": "배경으로 설정",
"settings": "설정",
"showLeftPanel": "왼쪽 패널 표시",
"showReport": "보고서 보기",
"showRightPanel": "오른쪽 패널 표시",
"singleSelectDropdown": "단일 선택 드롭다운",
"sort": "정렬",
"source": "소스",
@@ -915,6 +950,7 @@
"updating": "업데이트 중",
"upload": "업로드",
"usageHint": "사용 힌트",
"use": "사용",
"user": "사용자",
"versionMismatchWarning": "버전 호환성 경고",
"versionMismatchWarningMessage": "{warning}: {detail} 업데이트 지침은 https://docs.comfy.org/installation/update_comfyui#common-update-issues 를 방문하세요.",
@@ -1618,6 +1654,12 @@
"title": "이 워크플로우에 누락된 노드가 있습니다"
}
},
"nightly": {
"badge": {
"label": "미리보기 버전",
"tooltip": "현재 ComfyUI의 나이트리 버전을 사용 중입니다. 이 기능들에 대한 의견을 피드백 버튼을 통해 공유해 주세요."
}
},
"nodeCategories": {
"": "",
"3d": "3d",
@@ -2132,12 +2174,14 @@
"viewControls": "보기 컨트롤"
},
"sideToolbar": {
"activeJobStatus": "진행 중인 작업: {status}",
"assets": "에셋",
"backToAssets": "모든 에셋으로 돌아가기",
"browseTemplates": "예제 템플릿 탐색",
"downloads": "다운로드",
"generatedAssetsHeader": "생성된 에셋",
"helpCenter": "도움말 센터",
"importedAssetsHeader": "가져온 에셋",
"labels": {
"assets": "에셋",
"console": "콘솔",
@@ -2182,6 +2226,7 @@
"queue": "실행 대기열",
"queueProgressOverlay": {
"activeJobs": "{count}개의 활성 작업",
"activeJobsShort": "{count}개 활성",
"activeJobsSuffix": "활성 작업",
"cancelJobTooltip": "작업 취소",
"clearHistory": "작업 대기열 기록 삭제",

View File

@@ -24,6 +24,7 @@
"assets": "Ativos",
"baseModels": "Modelos base",
"browseAssets": "Explorar Ativos",
"byType": "Por tipo",
"checkpoints": "Checkpoints",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "Exemplo:",
@@ -45,6 +46,10 @@
"failed": "Falha no download",
"inProgress": "Baixando {assetName}..."
},
"emptyImported": {
"canImport": "Nenhum modelo importado ainda. Clique em \"Importar Modelo\" para adicionar o seu.",
"restricted": "Modelos pessoais estão disponíveis apenas no nível Creator ou superior."
},
"errorFileTooLarge": "O arquivo excede o limite máximo de tamanho permitido",
"errorFormatNotAllowed": "Apenas o formato SafeTensor é permitido",
"errorModelTypeNotSupported": "Este tipo de modelo não é suportado",
@@ -61,6 +66,7 @@
"finish": "Concluir",
"genericLinkPlaceholder": "Cole o link aqui",
"importAnother": "Importar outro",
"imported": "Importado",
"jobId": "ID do trabalho",
"loadingModels": "Carregando {type}...",
"maxFileSize": "Tamanho máximo do arquivo: {size}",
@@ -70,6 +76,29 @@
"threeDModelPlaceholder": "Modelo 3D"
},
"modelAssociatedWithLink": "O modelo associado ao link fornecido:",
"modelInfo": {
"addBaseModel": "Adicionar modelo base...",
"addTag": "Adicionar tag...",
"additionalTags": "Tags Adicionais",
"baseModelUnknown": "Modelo base desconhecido",
"basicInfo": "Informações Básicas",
"compatibleBaseModels": "Modelos Base Compatíveis",
"description": "Descrição",
"descriptionNotSet": "Nenhuma descrição definida",
"descriptionPlaceholder": "Adicione uma descrição para este modelo...",
"displayName": "Nome de Exibição",
"fileName": "Nome do Arquivo",
"modelDescription": "Descrição do Modelo",
"modelTagging": "Tagueamento do Modelo",
"modelType": "Tipo de Modelo",
"noAdditionalTags": "Sem tags adicionais",
"selectModelPrompt": "Selecione um modelo para ver suas informações",
"selectModelType": "Selecione o tipo de modelo...",
"source": "Fonte",
"title": "Informações do Modelo",
"triggerPhrases": "Frases de Ativação",
"viewOnSource": "Ver em {source}"
},
"modelName": "Nome do modelo",
"modelNamePlaceholder": "Digite um nome para este modelo",
"modelTypeSelectorLabel": "Qual o tipo deste modelo?",
@@ -684,6 +713,7 @@
"clearAll": "Limpar tudo",
"clearFilters": "Limpar filtros",
"close": "Fechar",
"closeDialog": "Fechar diálogo",
"color": "Cor",
"comfy": "Comfy",
"comfyOrgLogoAlt": "Logo do ComfyOrg",
@@ -762,6 +792,8 @@
"goToNode": "Ir para o nó",
"graphNavigation": "Navegação no grafo",
"halfSpeed": "0,5x",
"hideLeftPanel": "Ocultar painel esquerdo",
"hideRightPanel": "Ocultar painel direito",
"icon": "Ícone",
"imageFailedToLoad": "Falha ao carregar imagem",
"imagePreview": "Pré-visualização da imagem - Use as setas para navegar entre as imagens",
@@ -803,6 +835,7 @@
"name": "Nome",
"newFolder": "Nova pasta",
"next": "Próximo",
"nightly": "NIGHTLY",
"no": "Não",
"noAudioRecorded": "Nenhum áudio gravado",
"noItems": "Nenhum item",
@@ -892,7 +925,9 @@
"selectedFile": "Arquivo selecionado",
"setAsBackground": "Definir como plano de fundo",
"settings": "Configurações",
"showLeftPanel": "Mostrar painel esquerdo",
"showReport": "Mostrar relatório",
"showRightPanel": "Mostrar painel direito",
"singleSelectDropdown": "Menu suspenso de seleção única",
"sort": "Ordenar",
"source": "Fonte",
@@ -915,6 +950,7 @@
"updating": "Atualizando {id}",
"upload": "Enviar",
"usageHint": "Dica de uso",
"use": "Usar",
"user": "Usuário",
"versionMismatchWarning": "Aviso de compatibilidade de versão",
"versionMismatchWarningMessage": "{warning}: {detail} Visite https://docs.comfy.org/installation/update_comfyui#common-update-issues para instruções de atualização.",
@@ -1618,6 +1654,12 @@
"title": "Este fluxo de trabalho possui nós ausentes"
}
},
"nightly": {
"badge": {
"label": "Versão de Prévia",
"tooltip": "Você está usando uma versão nightly do ComfyUI. Por favor, use o botão de feedback para compartilhar suas opiniões sobre esses recursos."
}
},
"nodeCategories": {
"": "",
"3d": "3d",
@@ -2132,12 +2174,14 @@
"viewControls": "Controles de Visualização"
},
"sideToolbar": {
"activeJobStatus": "Tarefa ativa: {status}",
"assets": "Ativos",
"backToAssets": "Voltar para todos os ativos",
"browseTemplates": "Explorar modelos de exemplo",
"downloads": "Downloads",
"generatedAssetsHeader": "Ativos gerados",
"helpCenter": "Central de Ajuda",
"importedAssetsHeader": "Ativos importados",
"labels": {
"assets": "Ativos",
"console": "Console",
@@ -2193,6 +2237,7 @@
"queue": "Fila",
"queueProgressOverlay": {
"activeJobs": "{count} trabalho ativo | {count} trabalhos ativos",
"activeJobsShort": "{count} ativo(s) | {count} ativo(s)",
"activeJobsSuffix": "trabalhos ativos",
"cancelJobTooltip": "Cancelar trabalho",
"clearHistory": "Limpar histórico da fila de trabalhos",

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,29 @@
"threeDModelPlaceholder": "3D-модель"
},
"modelAssociatedWithLink": "Модель, связанная с предоставленной вами ссылкой:",
"modelInfo": {
"addBaseModel": "Добавить базовую модель...",
"addTag": "Добавить тег...",
"additionalTags": "Дополнительные теги",
"baseModelUnknown": "Базовая модель неизвестна",
"basicInfo": "Основная информация",
"compatibleBaseModels": "Совместимые базовые модели",
"description": "Описание",
"descriptionNotSet": "Описание не задано",
"descriptionPlaceholder": "Добавьте описание для этой модели...",
"displayName": "Отображаемое имя",
"fileName": "Имя файла",
"modelDescription": "Описание модели",
"modelTagging": "Теги модели",
"modelType": "Тип модели",
"noAdditionalTags": "Нет дополнительных тегов",
"selectModelPrompt": "Выберите модель, чтобы увидеть её информацию",
"selectModelType": "Выберите тип модели...",
"source": "Источник",
"title": "Информация о модели",
"triggerPhrases": "Триггерные фразы",
"viewOnSource": "Посмотреть на {source}"
},
"modelName": "Имя модели",
"modelNamePlaceholder": "Введите имя для этой модели",
"modelTypeSelectorLabel": "Какой это тип модели?",
@@ -684,6 +713,7 @@
"clearAll": "Очистить всё",
"clearFilters": "Сбросить фильтры",
"close": "Закрыть",
"closeDialog": "Закрыть диалог",
"color": "Цвет",
"comfy": "Comfy",
"comfyOrgLogoAlt": "Логотип ComfyOrg",
@@ -762,6 +792,8 @@
"goToNode": "Перейти к ноде",
"graphNavigation": "Навигация по графу",
"halfSpeed": "0.5x",
"hideLeftPanel": "Скрыть левую панель",
"hideRightPanel": "Скрыть правую панель",
"icon": "Иконка",
"imageFailedToLoad": "Не удалось загрузить изображение",
"imagePreview": "Предварительный просмотр изображения - Используйте клавиши со стрелками для навигации между изображениями",
@@ -803,6 +835,7 @@
"name": "Имя",
"newFolder": "Новая папка",
"next": "Далее",
"nightly": "NIGHTLY",
"no": "Нет",
"noAudioRecorded": "Аудио не записано",
"noItems": "Нет элементов",
@@ -892,7 +925,9 @@
"selectedFile": "Выбранный файл",
"setAsBackground": "Установить как фон",
"settings": "Настройки",
"showLeftPanel": "Показать левую панель",
"showReport": "Показать отчёт",
"showRightPanel": "Показать правую панель",
"singleSelectDropdown": "Выпадающий список единичного выбора",
"sort": "Сортировать",
"source": "Источник",
@@ -915,6 +950,7 @@
"updating": "Обновление",
"upload": "Загрузить",
"usageHint": "Подсказка по использованию",
"use": "Использовать",
"user": "Пользователь",
"versionMismatchWarning": "Предупреждение о несовместимости версий",
"versionMismatchWarningMessage": "{warning}: {detail} Посетите https://docs.comfy.org/installation/update_comfyui#common-update-issues для инструкций по обновлению.",
@@ -1618,6 +1654,12 @@
"title": "В этом рабочем процессе отсутствуют узлы"
}
},
"nightly": {
"badge": {
"label": "Предварительная версия",
"tooltip": "Вы используете ночную версию ComfyUI. Пожалуйста, используйте кнопку обратной связи, чтобы поделиться своим мнением об этих функциях."
}
},
"nodeCategories": {
"": "",
"3d": "3d",
@@ -2132,12 +2174,14 @@
"viewControls": "Управление видом"
},
"sideToolbar": {
"activeJobStatus": "Активная задача: {status}",
"assets": "Ассеты",
"backToAssets": "Назад ко всем ассетам",
"browseTemplates": "Просмотреть примеры шаблонов",
"downloads": "Загрузки",
"generatedAssetsHeader": "Сгенерированные ресурсы",
"helpCenter": "Центр поддержки",
"importedAssetsHeader": "Импортированные ресурсы",
"labels": {
"assets": "Ассеты",
"console": "Консоль",
@@ -2182,6 +2226,7 @@
"queue": "Очередь",
"queueProgressOverlay": {
"activeJobs": "{count} активное задание | {count} активных задания | {count} активных заданий",
"activeJobsShort": "{count} активно | {count} активно",
"activeJobsSuffix": "активных заданий",
"cancelJobTooltip": "Отменить задание",
"clearHistory": "Очистить историю очереди заданий",

View File

@@ -24,6 +24,7 @@
"assets": "Varlıklar",
"baseModels": "Temel modeller",
"browseAssets": "Varlıklara Göz At",
"byType": "Türe göre",
"checkpoints": "Kontrol noktaları",
"civitaiLinkExample": "{example} {link}",
"civitaiLinkExampleStrong": "Örnek:",
@@ -45,6 +46,10 @@
"failed": "İndirme başarısız oldu",
"inProgress": "{assetName} indiriliyor..."
},
"emptyImported": {
"canImport": "Henüz içe aktarılmış model yok. Kendi modelinizi eklemek için \"Model İçe Aktar\"a tıklayın.",
"restricted": "Kişisel modeller yalnızca Creator ve üzeri seviyelerde kullanılabilir."
},
"errorFileTooLarge": "Dosya izin verilen maksimum boyut sınırınııyor",
"errorFormatNotAllowed": "Yalnızca SafeTensor formatı destekleniyor",
"errorModelTypeNotSupported": "Bu model türü desteklenmiyor",
@@ -61,6 +66,7 @@
"finish": "Bitir",
"genericLinkPlaceholder": "Bağlantıyı buraya yapıştırın",
"importAnother": "Başka Birini İçe Aktar",
"imported": "İçe aktarıldı",
"jobId": "İş ID",
"loadingModels": "{type} yükleniyor...",
"maxFileSize": "Maksimum dosya boyutu: {size}",
@@ -70,6 +76,29 @@
"threeDModelPlaceholder": "3D Model"
},
"modelAssociatedWithLink": "Sağladığınız bağlantı ile ilişkili model:",
"modelInfo": {
"addBaseModel": "Taban model ekle...",
"addTag": "Etiket ekle...",
"additionalTags": "Ek Etiketler",
"baseModelUnknown": "Taban model bilinmiyor",
"basicInfo": "Temel Bilgiler",
"compatibleBaseModels": "Uyumlu Taban Modelleri",
"description": "Açıklama",
"descriptionNotSet": "Açıklama ayarlanmadı",
"descriptionPlaceholder": "Bu model için bir açıklama ekleyin...",
"displayName": "Görünen Ad",
"fileName": "Dosya Adı",
"modelDescription": "Model Açıklaması",
"modelTagging": "Model Etiketleme",
"modelType": "Model Türü",
"noAdditionalTags": "Ek etiket yok",
"selectModelPrompt": "Bilgilerini görmek için bir model seçin",
"selectModelType": "Model türü seç...",
"source": "Kaynak",
"title": "Model Bilgisi",
"triggerPhrases": "Tetikleyici İfadeler",
"viewOnSource": "{source} üzerinde görüntüle"
},
"modelName": "Model Adı",
"modelNamePlaceholder": "Bu model için bir ad girin",
"modelTypeSelectorLabel": "Bu hangi model türü?",
@@ -684,6 +713,7 @@
"clearAll": "Tümünü temizle",
"clearFilters": "Filtreleri Temizle",
"close": "Kapat",
"closeDialog": "Diyaloğu kapat",
"color": "Renk",
"comfy": "Comfy",
"comfyOrgLogoAlt": "ComfyOrg Logosu",
@@ -762,6 +792,8 @@
"goToNode": "Düğüme Git",
"graphNavigation": "Grafik gezintisi",
"halfSpeed": "0.5x",
"hideLeftPanel": "Sol paneli gizle",
"hideRightPanel": "Sağ paneli gizle",
"icon": "Simge",
"imageFailedToLoad": "Görsel yüklenemedi",
"imagePreview": "Görüntü önizlemesi - Görüntüler arasında gezinmek için ok tuşlarını kullanın",
@@ -803,6 +835,7 @@
"name": "Ad",
"newFolder": "Yeni Klasör",
"next": "İleri",
"nightly": "NIGHTLY",
"no": "Hayır",
"noAudioRecorded": "Ses kaydedilmedi",
"noItems": "Öğe yok",
@@ -892,7 +925,9 @@
"selectedFile": "Seçilen dosya",
"setAsBackground": "Arka Plan Olarak Ayarla",
"settings": "Ayarlar",
"showLeftPanel": "Sol paneli göster",
"showReport": "Raporu Göster",
"showRightPanel": "Sağ paneli göster",
"singleSelectDropdown": "Tekli seçim açılır menüsü",
"sort": "Sırala",
"source": "Kaynak",
@@ -915,6 +950,7 @@
"updating": "{id} güncelleniyor",
"upload": "Yükle",
"usageHint": "Kullanım ipucu",
"use": "Kullan",
"user": "Kullanıcı",
"versionMismatchWarning": "Sürüm Uyumluluk Uyarısı",
"versionMismatchWarningMessage": "{warning}: {detail} Güncelleme talimatları için https://docs.comfy.org/installation/update_comfyui#common-update-issues adresini ziyaret edin.",
@@ -1618,6 +1654,12 @@
"title": "Bu iş akışında eksik düğümler var"
}
},
"nightly": {
"badge": {
"label": "Önizleme Sürümü",
"tooltip": "ComfyUI'nin nightly sürümünü kullanıyorsunuz. Lütfen bu özelliklerle ilgili görüşlerinizi paylaşmak için geri bildirim butonunu kullanın."
}
},
"nodeCategories": {
"": "",
"3d": "3d",
@@ -2132,12 +2174,14 @@
"viewControls": "Görünüm Kontrolleri"
},
"sideToolbar": {
"activeJobStatus": "Aktif iş: {status}",
"assets": "Varlıklar",
"backToAssets": "Tüm varlıklara dön",
"browseTemplates": "Örnek şablonlara göz atın",
"downloads": "İndirmeler",
"generatedAssetsHeader": "Oluşturulan varlıklar",
"helpCenter": "Yardım Merkezi",
"importedAssetsHeader": "İçe aktarılan varlıklar",
"labels": {
"assets": "Varlıklar",
"console": "Konsol",
@@ -2182,6 +2226,7 @@
"queue": "Kuyruk",
"queueProgressOverlay": {
"activeJobs": "{count} aktif iş | {count} aktif iş",
"activeJobsShort": "{count} aktif | {count} aktif",
"activeJobsSuffix": "aktif iş",
"cancelJobTooltip": "İşi iptal et",
"clearHistory": "İş kuyruğu geçmişini temizle",

View File

@@ -24,6 +24,7 @@
"assets": "資產",
"baseModels": "基礎模型",
"browseAssets": "瀏覽資產",
"byType": "依類型",
"checkpoints": "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,29 @@
"threeDModelPlaceholder": "3D 模型"
},
"modelAssociatedWithLink": "您提供的連結所對應的模型:",
"modelInfo": {
"addBaseModel": "新增基礎模型...",
"addTag": "新增標籤...",
"additionalTags": "其他標籤",
"baseModelUnknown": "基礎模型未知",
"basicInfo": "基本資訊",
"compatibleBaseModels": "相容基礎模型",
"description": "描述",
"descriptionNotSet": "尚未設定描述",
"descriptionPlaceholder": "為此模型新增描述...",
"displayName": "顯示名稱",
"fileName": "檔案名稱",
"modelDescription": "模型描述",
"modelTagging": "模型標籤",
"modelType": "模型類型",
"noAdditionalTags": "沒有其他標籤",
"selectModelPrompt": "選擇模型以查看其資訊",
"selectModelType": "選擇模型類型...",
"source": "來源",
"title": "模型資訊",
"triggerPhrases": "觸發詞",
"viewOnSource": "在 {source} 上檢視"
},
"modelName": "模型名稱",
"modelNamePlaceholder": "請輸入此模型的名稱",
"modelTypeSelectorLabel": "這是什麼類型的模型?",
@@ -684,6 +713,7 @@
"clearAll": "全部清除",
"clearFilters": "清除篩選",
"close": "關閉",
"closeDialog": "關閉對話框",
"color": "顏色",
"comfy": "Comfy",
"comfyOrgLogoAlt": "ComfyOrg 標誌",
@@ -762,6 +792,8 @@
"goToNode": "前往節點",
"graphNavigation": "圖形導覽",
"halfSpeed": "0.5倍速",
"hideLeftPanel": "隱藏左側面板",
"hideRightPanel": "隱藏右側面板",
"icon": "圖示",
"imageFailedToLoad": "無法載入圖片",
"imagePreview": "圖片預覽 - 使用方向鍵在圖片間導航",
@@ -803,6 +835,7 @@
"name": "名稱",
"newFolder": "新資料夾",
"next": "下一步",
"nightly": "NIGHTLY",
"no": "否",
"noAudioRecorded": "沒有錄製到音訊",
"noItems": "沒有項目",
@@ -892,7 +925,9 @@
"selectedFile": "已選取的檔案",
"setAsBackground": "設為背景",
"settings": "設定",
"showLeftPanel": "顯示左側面板",
"showReport": "顯示報告",
"showRightPanel": "顯示右側面板",
"singleSelectDropdown": "單選下拉式選單",
"sort": "排序",
"source": "來源",
@@ -915,6 +950,7 @@
"updating": "更新中",
"upload": "上傳",
"usageHint": "使用提示",
"use": "使用",
"user": "使用者",
"versionMismatchWarning": "版本相容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。",
@@ -1618,6 +1654,12 @@
"title": "此工作流程有缺少的節點"
}
},
"nightly": {
"badge": {
"label": "預覽版本",
"tooltip": "您正在使用 ComfyUI 的夜間版本。請使用反饋按鈕分享您對這些功能的看法。"
}
},
"nodeCategories": {
"": "",
"3d": "3D",
@@ -2132,12 +2174,14 @@
"viewControls": "檢視控制"
},
"sideToolbar": {
"activeJobStatus": "進行中作業:{status}",
"assets": "資源",
"backToAssets": "返回所有資源",
"browseTemplates": "瀏覽範例模板",
"downloads": "下載",
"generatedAssetsHeader": "已產生資產",
"helpCenter": "說明中心",
"importedAssetsHeader": "已匯入資產",
"labels": {
"assets": "資源",
"console": "控制台",
@@ -2182,6 +2226,7 @@
"queue": "佇列",
"queueProgressOverlay": {
"activeJobs": "{count} 個執行中作業",
"activeJobsShort": "{count} 個進行中",
"activeJobsSuffix": "執行中作業",
"cancelJobTooltip": "取消作業",
"clearHistory": "清除作業佇列歷史",

View File

@@ -24,6 +24,7 @@
"assets": "资产",
"baseModels": "基础模型",
"browseAssets": "浏览资产",
"byType": "按类型",
"checkpoints": "模型",
"civitaiLinkExample": "<strong>案例:</strong> <a href=\"https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor\" target=\"_blank\" class=\"text-muted-foreground\">https://civitai.com/api/download/models/833921?type=Model&format=SafeTensor</a>",
"civitaiLinkExampleStrong": "案例:",
@@ -45,6 +46,10 @@
"failed": "下载失败",
"inProgress": "正在下载 {assetName}..."
},
"emptyImported": {
"canImport": "尚未导入模型。点击“导入模型”添加您的模型。",
"restricted": "个人模型仅限创作者及以上等级使用。"
},
"errorFileTooLarge": "允许执行文件的文件大小限制",
"errorFormatNotAllowed": "仅允许 SafeTensor 格式",
"errorModelTypeNotSupported": "不支持该类型的模型",
@@ -61,6 +66,7 @@
"finish": "完成",
"genericLinkPlaceholder": "粘贴链接到这",
"importAnother": "导入其他",
"imported": "已导入",
"jobId": "任务ID",
"loadingModels": "正在加载{type}...",
"maxFileSize": "最大文件大小:{size}",
@@ -70,6 +76,29 @@
"threeDModelPlaceholder": "3D 模型"
},
"modelAssociatedWithLink": "您提供的链接的模型:",
"modelInfo": {
"addBaseModel": "添加基础模型...",
"addTag": "添加标签...",
"additionalTags": "附加标签",
"baseModelUnknown": "基础模型未知",
"basicInfo": "基本信息",
"compatibleBaseModels": "兼容基础模型",
"description": "描述",
"descriptionNotSet": "未设置描述",
"descriptionPlaceholder": "为此模型添加描述...",
"displayName": "显示名称",
"fileName": "文件名",
"modelDescription": "模型描述",
"modelTagging": "模型标签",
"modelType": "模型类型",
"noAdditionalTags": "无附加标签",
"selectModelPrompt": "选择一个模型以查看其信息",
"selectModelType": "选择模型类型...",
"source": "来源",
"title": "模型信息",
"triggerPhrases": "触发短语",
"viewOnSource": "在 {source} 上查看"
},
"modelName": "模型名",
"modelNamePlaceholder": "输入该模型的名称",
"modelTypeSelectorLabel": "这是什么类型的模型?",
@@ -684,6 +713,7 @@
"clearAll": "全部清除",
"clearFilters": "清除筛选",
"close": "关闭",
"closeDialog": "关闭对话框",
"color": "颜色",
"comfy": "舒适",
"comfyOrgLogoAlt": "ComfyOrg 徽标",
@@ -762,6 +792,8 @@
"goToNode": "转到节点",
"graphNavigation": "图形导航",
"halfSpeed": "0.5倍",
"hideLeftPanel": "隐藏左侧面板",
"hideRightPanel": "隐藏右侧面板",
"icon": "图标",
"imageFailedToLoad": "图像加载失败",
"imagePreview": "图片预览 - 使用方向键切换图片",
@@ -803,6 +835,7 @@
"name": "名称",
"newFolder": "新文件夹",
"next": "下一个",
"nightly": "NIGHTLY",
"no": "否",
"noAudioRecorded": "未录制音频",
"noItems": "无项目",
@@ -892,7 +925,9 @@
"selectedFile": "已选文件",
"setAsBackground": "设为背景",
"settings": "设置",
"showLeftPanel": "显示左侧面板",
"showReport": "显示报告",
"showRightPanel": "显示右侧面板",
"singleSelectDropdown": "单选下拉框",
"sort": "排序",
"source": "来源",
@@ -915,6 +950,7 @@
"updating": "更新中",
"upload": "上传",
"usageHint": "使用提示",
"use": "使用",
"user": "用户",
"versionMismatchWarning": "版本兼容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 请参阅 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新说明。",
@@ -1618,6 +1654,12 @@
"title": "该工作流含有缺失节点"
}
},
"nightly": {
"badge": {
"label": "预览版",
"tooltip": "您正在使用 ComfyUI 的夜间版本。请使用反馈按钮分享您对这些功能的看法。"
}
},
"nodeCategories": {
"": "",
"3d": "3d",
@@ -2132,12 +2174,14 @@
"viewControls": "视图控制"
},
"sideToolbar": {
"activeJobStatus": "当前任务:{status}",
"assets": "资产",
"backToAssets": "返回所有资产",
"browseTemplates": "浏览示例模板",
"downloads": "下载",
"generatedAssetsHeader": "生成的资源",
"helpCenter": "帮助中心",
"importedAssetsHeader": "已导入资源",
"labels": {
"assets": "资产",
"console": "控制台",
@@ -2193,6 +2237,7 @@
"queue": "队列",
"queueProgressOverlay": {
"activeJobs": "{count} 个活跃任务",
"activeJobsShort": "{count} 个活动任务 | {count} 个活动任务",
"activeJobsSuffix": "活跃任务",
"cancelJobTooltip": "取消任务",
"clearHistory": "清除任务记录",

View File

@@ -6,6 +6,9 @@ import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vu
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetsStore } from '@/stores/assetsStore'
const mockAssetsByKey = vi.hoisted(() => new Map<string, AssetItem[]>())
const mockLoadingByKey = vi.hoisted(() => new Map<string, boolean>())
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, string>) =>
params ? `${key}:${JSON.stringify(params)}` : key,
@@ -13,13 +16,20 @@ vi.mock('@/i18n', () => ({
}))
vi.mock('@/stores/assetsStore', () => {
const store = {
modelAssetsByNodeType: new Map<string, AssetItem[]>(),
modelLoadingByNodeType: new Map<string, boolean>(),
updateModelsForNodeType: vi.fn(),
updateModelsForTag: vi.fn()
const getAssets = vi.fn((key: string) => mockAssetsByKey.get(key) ?? [])
const isModelLoading = vi.fn(
(key: string) => mockLoadingByKey.get(key) ?? false
)
const updateModelsForNodeType = vi.fn()
const updateModelsForTag = vi.fn()
return {
useAssetsStore: () => ({
getAssets,
isModelLoading,
updateModelsForNodeType,
updateModelsForTag
})
}
return { useAssetsStore: () => store }
})
vi.mock('@/stores/modelToNodeStore', () => ({
@@ -183,12 +193,10 @@ describe('AssetBrowserModal', () => {
})
}
const mockStore = useAssetsStore()
beforeEach(() => {
vi.resetAllMocks()
mockStore.modelAssetsByNodeType.clear()
mockStore.modelLoadingByNodeType.clear()
mockAssetsByKey.clear()
mockLoadingByKey.clear()
})
describe('Integration with useAssetBrowser', () => {
@@ -197,7 +205,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('asset1', 'Model A', 'checkpoints'),
createTestAsset('asset2', 'Model B', 'loras')
]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
@@ -214,7 +222,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('c1', 'model.safetensors', 'checkpoints'),
createTestAsset('l1', 'lora.pt', 'loras')
]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
@@ -231,17 +239,18 @@ describe('AssetBrowserModal', () => {
describe('Data fetching', () => {
it('triggers store refresh for node type on mount', async () => {
const store = useAssetsStore()
createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
expect(mockStore.updateModelsForNodeType).toHaveBeenCalledWith(
expect(store.updateModelsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
})
it('displays cached assets immediately from store', async () => {
const assets = [createTestAsset('asset1', 'Cached Model', 'checkpoints')]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
@@ -253,15 +262,16 @@ describe('AssetBrowserModal', () => {
})
it('triggers store refresh for asset type (tag) on mount', async () => {
const store = useAssetsStore()
createWrapper({ assetType: 'models' })
await flushPromises()
expect(mockStore.updateModelsForTag).toHaveBeenCalledWith('models')
expect(store.updateModelsForTag).toHaveBeenCalledWith('models')
})
it('uses tag: prefix for cache key when assetType is provided', async () => {
const assets = [createTestAsset('asset1', 'Tagged Model', 'models')]
mockStore.modelAssetsByNodeType.set('tag:models', assets)
mockAssetsByKey.set('tag:models', assets)
const wrapper = createWrapper({ assetType: 'models' })
await flushPromises()
@@ -277,7 +287,7 @@ describe('AssetBrowserModal', () => {
describe('Asset Selection', () => {
it('emits asset-select event when asset is selected', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
@@ -290,7 +300,7 @@ describe('AssetBrowserModal', () => {
it('executes onSelect callback when provided', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
const onSelect = vi.fn()
const wrapper = createWrapper({
@@ -333,7 +343,7 @@ describe('AssetBrowserModal', () => {
createTestAsset('asset1', 'Model A', 'checkpoints'),
createTestAsset('asset2', 'Model B', 'loras')
]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({
nodeType: 'CheckpointLoaderSimple',
@@ -366,7 +376,7 @@ describe('AssetBrowserModal', () => {
it('passes computed contentTitle to BaseModalLayout when no title prop', async () => {
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets)
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()

View File

@@ -1,18 +1,20 @@
<template>
<BaseModalLayout
v-model:right-panel-open="isRightPanelOpen"
data-component-id="AssetBrowserModal"
class="size-full max-h-full max-w-full min-w-0"
:content-title="displayTitle"
:right-panel-title="$t('assetBrowser.modelInfo.title')"
@close="handleClose"
>
<template v-if="shouldShowLeftPanel" #leftPanel>
<LeftSidePanel
v-model="selectedCategory"
v-model="selectedNavItem"
data-component-id="AssetBrowserModal-LeftSidePanel"
:nav-items="availableCategories"
:nav-items
>
<template #header-icon>
<div class="icon-[lucide--folder] size-4" />
<div class="icon-[comfy--ai-model] size-4" />
</template>
<template #header-title>
<span class="capitalize">{{ displayTitle }}</span>
@@ -21,7 +23,10 @@
</template>
<template #header>
<div class="flex w-full items-center justify-between gap-2">
<div
class="flex w-full items-center justify-between gap-2"
@click.self="focusedAsset = null"
>
<SearchBox
v-model="searchQuery"
:autofocus="true"
@@ -47,8 +52,8 @@
<template #contentFilter>
<AssetFilterBar
:assets="categoryFilteredAssets"
:all-assets="fetchedAssets"
@filter-change="updateFilters"
@click.self="focusedAsset = null"
/>
</template>
@@ -56,16 +61,31 @@
<AssetGrid
:assets="filteredAssets"
:loading="isLoading"
:focused-asset-id="focusedAsset?.id"
:empty-message
@asset-focus="handleAssetFocus"
@asset-select="handleAssetSelectAndEmit"
@asset-deleted="refreshAssets"
@asset-show-info="handleShowInfo"
@click="focusedAsset = null"
/>
</template>
<template #rightPanel>
<ModelInfoPanel v-if="focusedAsset" :asset="focusedAsset" :cache-key />
<div
v-else
class="flex h-full items-center justify-center break-words p-6 text-center text-muted"
>
{{ $t('assetBrowser.modelInfo.selectModelPrompt') }}
</div>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, provide } from 'vue'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
@@ -74,8 +94,10 @@ import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import ModelInfoPanel from '@/platform/assets/components/modelInfo/ModelInfoPanel.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
@@ -112,44 +134,47 @@ const cacheKey = computed(() => {
})
// Read directly from store cache - reactive to any store updates
const fetchedAssets = computed(
() => assetStore.modelAssetsByNodeType.get(cacheKey.value) ?? []
)
const fetchedAssets = computed(() => assetStore.getAssets(cacheKey.value))
const isStoreLoading = computed(
() => assetStore.modelLoadingByNodeType.get(cacheKey.value) ?? false
)
const isStoreLoading = computed(() => assetStore.isModelLoading(cacheKey.value))
// Only show loading spinner when loading AND no cached data
const isLoading = computed(
() => isStoreLoading.value && fetchedAssets.value.length === 0
)
async function refreshAssets(): Promise<AssetItem[]> {
async function refreshAssets(): Promise<void> {
if (props.nodeType) {
return await assetStore.updateModelsForNodeType(props.nodeType)
await assetStore.updateModelsForNodeType(props.nodeType)
} else if (props.assetType) {
await assetStore.updateModelsForTag(props.assetType)
}
if (props.assetType) {
return await assetStore.updateModelsForTag(props.assetType)
}
return []
}
// Trigger background refresh on mount
void refreshAssets()
// Eagerly fetch model types so they're available when ModelInfoPanel loads
const { fetchModelTypes } = useModelTypes()
void fetchModelTypes()
const { isUploadButtonEnabled, showUploadDialog } =
useModelUpload(refreshAssets)
const {
searchQuery,
selectedNavItem,
selectedCategory,
availableCategories,
navItems,
categoryFilteredAssets,
filteredAssets,
isImportedSelected,
updateFilters
} = useAssetBrowser(fetchedAssets)
const focusedAsset = ref<AssetDisplayItem | null>(null)
const isRightPanelOpen = ref(false)
const primaryCategoryTag = computed(() => {
const assets = fetchedAssets.value ?? []
const tagFromAssets = assets
@@ -186,15 +211,30 @@ const shouldShowLeftPanel = computed(() => {
return props.showLeftPanel ?? true
})
const emptyMessage = computed(() => {
if (!isImportedSelected.value) return undefined
return isUploadButtonEnabled.value
? t('assetBrowser.emptyImported.canImport')
: t('assetBrowser.emptyImported.restricted')
})
function handleClose() {
props.onClose?.()
emit('close')
}
function handleAssetFocus(asset: AssetDisplayItem) {
focusedAsset.value = asset
}
function handleShowInfo(asset: AssetDisplayItem) {
focusedAsset.value = asset
isRightPanelOpen.value = true
}
function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
emit('asset-select', asset)
// onSelect callback is provided by dialog composable layer
// It handles the appropriate transformation (filename extraction or full asset)
props.onSelect?.(asset)
}
</script>

View File

@@ -9,30 +9,28 @@
cn(
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
interactive &&
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4'
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4',
focused && 'bg-secondary-background outline-solid'
)
"
@click.stop="interactive && $emit('focus', asset)"
@focus="interactive && $emit('focus', asset)"
@keydown.enter.self="interactive && $emit('select', asset)"
>
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
<div
v-if="isLoading || error"
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<img
v-else
:src="asset.preview_url"
:alt="displayName"
class="size-full object-cover cursor-pointer"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<AssetBadgeGroup :badges="asset.badges" />
<IconGroup
v-if="showAssetOptions"
:class="
cn(
'absolute top-2 right-2 invisible group-hover:visible',
@@ -40,18 +38,21 @@
)
"
>
<MoreButton ref="dropdown-menu-button" size="sm">
<Button
v-tooltip.bottom="$t('assetBrowser.modelInfo.title')"
:aria-label="$t('assetBrowser.modelInfo.title')"
variant="secondary"
size="sm"
@click.stop="$emit('showInfo', asset)"
>
<i class="icon-[lucide--info]" />
</Button>
<MoreButton
v-if="showAssetOptions"
ref="dropdown-menu-button"
size="sm"
>
<template #default>
<Button
v-if="flags.assetRenameEnabled"
variant="secondary"
size="md"
class="justify-start"
@click="startAssetRename"
>
<i class="icon-[lucide--pencil]" />
<span>{{ $t('g.rename') }}</span>
</Button>
<Button
v-if="flags.assetDeletionEnabled"
variant="secondary"
@@ -72,43 +73,59 @@
v-tooltip.top="{ value: displayName, showDelay: tooltipDelay }"
:class="
cn(
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
'm-0 text-sm font-semibold line-clamp-2 wrap-anywhere',
'text-base-foreground'
)
"
>
<EditableText
:model-value="displayName"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'asset-name-input' }"
@edit="assetRename"
@cancel="assetRename()"
/>
{{ displayName }}
</h3>
<p
:id="descId"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
'm-0 text-sm line-clamp-2 [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
)
"
>
{{ asset.description }}
</p>
<div class="flex gap-4 text-xs text-muted-foreground mt-auto">
<span v-if="asset.stats.stars" class="flex items-center gap-1">
<i class="icon-[lucide--star] size-3" />
{{ asset.stats.stars }}
</span>
<span v-if="asset.stats.downloadCount" class="flex items-center gap-1">
<i class="icon-[lucide--download] size-3" />
{{ asset.stats.downloadCount }}
</span>
<span v-if="asset.stats.formattedDate" class="flex items-center gap-1">
<i class="icon-[lucide--clock] size-3" />
{{ asset.stats.formattedDate }}
</span>
<div class="flex items-center justify-between gap-2 mt-auto">
<div class="flex gap-3 text-xs text-muted-foreground">
<span v-if="asset.stats.stars" class="flex items-center gap-1">
<i class="icon-[lucide--star] size-3" />
{{ asset.stats.stars }}
</span>
<span
v-if="asset.stats.downloadCount"
class="flex items-center gap-1"
>
<i class="icon-[lucide--download] size-3" />
{{ asset.stats.downloadCount }}
</span>
<span
v-if="asset.stats.formattedDate"
class="flex items-center gap-1"
>
<i class="icon-[lucide--clock] size-3" />
{{ asset.stats.formattedDate }}
</span>
</div>
<Button
v-if="interactive"
variant="secondary"
size="lg"
class="shrink-0 relative"
@click.stop="handleSelect"
>
{{ $t('g.use') }}
<StatusBadge
v-if="isNewlyImported"
severity="contrast"
class="absolute -top-0.5 -right-0.5"
/>
</Button>
</div>
</div>
</div>
@@ -121,33 +138,37 @@ import { useI18n } from 'vue-i18n'
import IconGroup from '@/components/button/IconGroup.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
const { asset, interactive } = defineProps<{
const { asset, interactive, focused } = defineProps<{
asset: AssetDisplayItem
interactive?: boolean
focused?: boolean
}>()
const emit = defineEmits<{
focus: [asset: AssetDisplayItem]
select: [asset: AssetDisplayItem]
deleted: [asset: AssetDisplayItem]
showInfo: [asset: AssetDisplayItem]
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const { closeDialog } = useDialogStore()
const { flags } = useFeatureFlags()
const toastStore = useToastStore()
const { isDownloadedThisSession, acknowledgeAsset } = useAssetDownloadStore()
const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
'dropdown-menu-button'
@@ -156,10 +177,9 @@ const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
const titleId = useId()
const descId = useId()
const isEditing = ref(false)
const newNameRef = ref<string>()
const displayName = computed(() => getAssetDisplayName(asset))
const displayName = computed(() => newNameRef.value ?? asset.name)
const isNewlyImported = computed(() => isDownloadedThisSession(asset.id))
const showAssetOptions = computed(
() =>
@@ -176,6 +196,11 @@ const { isLoading, error } = useImage({
alt: asset.name
})
function handleSelect() {
acknowledgeAsset(asset.id)
emit('select', asset)
}
function confirmDeletion() {
dropdownMenuButton.value?.hide()
const assetName = toValue(displayName)
@@ -225,32 +250,4 @@ function confirmDeletion() {
}
})
}
function startAssetRename() {
dropdownMenuButton.value?.hide()
isEditing.value = true
}
async function assetRename(newName?: string) {
isEditing.value = false
if (newName) {
// Optimistic update
newNameRef.value = newName
try {
const result = await assetService.updateAsset(asset.id, {
name: newName
})
// Update with the actual name once the server responds
newNameRef.value = result.name
} catch (err: unknown) {
console.error(err)
toastStore.add({
severity: 'error',
summary: t('assetBrowser.rename.failed'),
life: 10_000
})
newNameRef.value = undefined
}
}
}
</script>

View File

@@ -10,11 +10,15 @@ import {
createAssetWithoutBaseModel
} from '@/platform/assets/fixtures/ui-mock-assets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { createI18n } from 'vue-i18n'
// Mock @/i18n directly since component imports { t } from '@/i18n'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {}
}
})
// Mock components with minimal functionality for business logic testing
vi.mock('@/components/input/MultiSelect.vue', () => ({
@@ -66,9 +70,7 @@ function mountAssetFilterBar(props = {}) {
return mount(AssetFilterBar, {
props,
global: {
mocks: {
$t: (key: string) => key
}
plugins: [i18n]
}
})
}
@@ -86,10 +88,6 @@ function findBaseModelsFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
return wrapper.findComponent('[data-component-id="asset-filter-base-models"]')
}
function findOwnershipFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
return wrapper.findComponent('[data-component-id="asset-filter-ownership"]')
}
function findSortFilter(wrapper: ReturnType<typeof mountAssetFilterBar>) {
return wrapper.findComponent('[data-component-id="asset-filter-sort"]')
}
@@ -268,90 +266,5 @@ describe('AssetFilterBar', () => {
expect(fileFormatSelect.exists()).toBe(false)
expect(baseModelSelect.exists()).toBe(false)
})
it('hides ownership filter when no mutable assets', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', true) // immutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(false)
})
it('shows ownership filter when mutable assets exist', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
})
it('shows ownership filter when mixed assets exist', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', true), // immutable
createAssetWithSpecificExtension('ckpt', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
})
it('shows ownership filter with allAssets when provided', () => {
const assets = [
createAssetWithSpecificExtension('safetensors', true) // immutable
]
const allAssets = [
createAssetWithSpecificExtension('safetensors', true), // immutable
createAssetWithSpecificExtension('ckpt', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets, allAssets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
})
})
describe('Ownership Filter', () => {
it('emits ownership filter changes', async () => {
const assets = [
createAssetWithSpecificExtension('safetensors', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets })
const ownershipSelect = findOwnershipFilter(wrapper)
expect(ownershipSelect.exists()).toBe(true)
const ownershipSelectElement = ownershipSelect.find('select')
ownershipSelectElement.element.value = 'my-models'
await ownershipSelectElement.trigger('change')
await nextTick()
const emitted = wrapper.emitted('filterChange')
expect(emitted).toBeTruthy()
const filterState = emitted![emitted!.length - 1][0] as FilterState
expect(filterState.ownership).toBe('my-models')
})
it('ownership filter defaults to "all"', async () => {
const assets = [
createAssetWithSpecificExtension('safetensors', false) // mutable
]
const wrapper = mountAssetFilterBar({ assets })
const sortSelect = findSortFilter(wrapper)
const sortSelectElement = sortSelect.find('select')
sortSelectElement.element.value = 'recent'
await sortSelectElement.trigger('change')
await nextTick()
const emitted = wrapper.emitted('filterChange')
const filterState = emitted![0][0] as FilterState
expect(filterState.ownership).toBe('all')
})
})
})

View File

@@ -26,16 +26,6 @@
data-component-id="asset-filter-base-models"
@update:model-value="handleFilterChange"
/>
<SingleSelect
v-if="hasMutableAssets"
v-model="ownership"
:label="$t('assetBrowser.ownership')"
:options="ownershipOptions"
class="min-w-42"
data-component-id="asset-filter-ownership"
@update:model-value="handleFilterChange"
/>
</div>
<div class="flex items-center" data-component-id="asset-filter-bar-right">
@@ -57,56 +47,41 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { SelectOption } from '@/components/input/types'
import { t } from '@/i18n'
import type { OwnershipOption } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const SORT_OPTIONS = [
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
{ name: t('assetBrowser.sortZA'), value: 'name-desc' }
] as const
const { t } = useI18n()
type SortOption = (typeof SORT_OPTIONS)[number]['value']
type SortOption = 'recent' | 'name-asc' | 'name-desc'
const sortOptions = [...SORT_OPTIONS]
const ownershipOptions = [
{ name: t('assetBrowser.ownershipAll'), value: 'all' },
{ name: t('assetBrowser.ownershipMyModels'), value: 'my-models' },
{ name: t('assetBrowser.ownershipPublicModels'), value: 'public-models' }
]
const sortOptions = computed(() => [
{ name: t('assetBrowser.sortRecent'), value: 'recent' as const },
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' as const },
{ name: t('assetBrowser.sortZA'), value: 'name-desc' as const }
])
export interface FilterState {
fileFormats: string[]
baseModels: string[]
sortBy: string
ownership: OwnershipOption
sortBy: SortOption
}
const { assets = [], allAssets = [] } = defineProps<{
const { assets = [] } = defineProps<{
assets?: AssetItem[]
allAssets?: AssetItem[]
}>()
const fileFormats = ref<SelectOption[]>([])
const baseModels = ref<SelectOption[]>([])
const sortBy = ref<SortOption>('recent')
const ownership = ref<OwnershipOption>('all')
const { availableFileFormats, availableBaseModels } =
useAssetFilterOptions(assets)
const hasMutableAssets = computed(() => {
const assetsToCheck = allAssets.length ? allAssets : assets
return assetsToCheck.some((asset) => asset.is_immutable === false)
})
const emit = defineEmits<{
filterChange: [filters: FilterState]
}>()
@@ -115,8 +90,7 @@ function handleFilterChange() {
emit('filterChange', {
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
baseModels: baseModels.value.map((option: SelectOption) => option.value),
sortBy: sortBy.value,
ownership: ownership.value
sortBy: sortBy.value
})
}
</script>

View File

@@ -19,9 +19,11 @@
>
<i class="mb-4 icon-[lucide--search] size-10" />
<h3 class="mb-2 text-lg font-medium">
{{ $t('assetBrowser.noAssetsFound') }}
{{ emptyTitle ?? $t('assetBrowser.noAssetsFound') }}
</h3>
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
<p class="text-sm">
{{ emptyMessage ?? $t('assetBrowser.tryAdjustingFilters') }}
</p>
</div>
<VirtualGrid
v-else
@@ -35,8 +37,11 @@
<AssetCard
:asset="item"
:interactive="true"
:focused="item.id === focusedAssetId"
@focus="$emit('assetFocus', $event)"
@select="$emit('assetSelect', $event)"
@deleted="$emit('assetDeleted', $event)"
@show-info="$emit('assetShowInfo', $event)"
/>
</template>
</VirtualGrid>
@@ -52,14 +57,19 @@ import VirtualGrid from '@/components/common/VirtualGrid.vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
const { assets } = defineProps<{
const { assets, focusedAssetId, emptyTitle, emptyMessage } = defineProps<{
assets: AssetDisplayItem[]
loading?: boolean
focusedAssetId?: string | null
emptyTitle?: string
emptyMessage?: string
}>()
defineEmits<{
assetFocus: [asset: AssetDisplayItem]
assetSelect: [asset: AssetDisplayItem]
assetDeleted: [asset: AssetDisplayItem]
assetShowInfo: [asset: AssetDisplayItem]
}>()
const assetsWithKey = computed(() =>
@@ -73,7 +83,7 @@ const isLg = breakpoints.greaterOrEqual('lg')
const isMd = breakpoints.greaterOrEqual('md')
const maxColumns = computed(() => {
if (is2Xl.value) return 5
if (isXl.value) return 4
if (isXl.value) return 3
if (isLg.value) return 3
if (isMd.value) return 2
return 1

View File

@@ -0,0 +1,12 @@
<template>
<div class="flex flex-col gap-1 px-4 py-2 text-sm text-muted-foreground">
<span>{{ label }}</span>
<slot />
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string
}>()
</script>

View File

@@ -0,0 +1,165 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import ModelInfoPanel from './ModelInfoPanel.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
describe('ModelInfoPanel', () => {
const createMockAsset = (
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem => ({
id: 'test-id',
name: 'test-model.safetensors',
asset_hash: 'hash123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
description: 'A test model description',
badges: [],
stats: {},
...overrides
})
const mountPanel = (asset: AssetDisplayItem) => {
return mount(ModelInfoPanel, {
props: { asset },
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n]
}
})
}
describe('Basic Info Section', () => {
it('renders basic info section', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo')
})
it('displays asset filename', () => {
const asset = createMockAsset({ name: 'my-model.safetensors' })
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain('my-model.safetensors')
})
it('displays name from user_metadata when present', () => {
const asset = createMockAsset({
user_metadata: { name: 'My Custom Model' }
})
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain('My Custom Model')
})
it('falls back to asset name when user_metadata.name not present', () => {
const asset = createMockAsset({ name: 'fallback-model.safetensors' })
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain('fallback-model.safetensors')
})
it('renders source link when source_arn is present', () => {
const asset = createMockAsset({
user_metadata: { source_arn: 'civitai:model:123:version:456' }
})
const wrapper = mountPanel(asset)
const link = wrapper.find(
'a[href="https://civitai.com/models/123?modelVersionId=456"]'
)
expect(link.exists()).toBe(true)
expect(link.attributes('target')).toBe('_blank')
})
it('displays Civitai icon for Civitai source', () => {
const asset = createMockAsset({
user_metadata: { source_arn: 'civitai:model:123:version:456' }
})
const wrapper = mountPanel(asset)
expect(
wrapper.find('img[src="/assets/images/civitai.svg"]').exists()
).toBe(true)
})
it('does not render source field when source_arn is absent', () => {
const asset = createMockAsset()
const wrapper = mountPanel(asset)
const links = wrapper.findAll('a')
expect(links).toHaveLength(0)
})
})
describe('Model Tagging Section', () => {
it('renders model tagging section', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging')
})
it('renders model type field', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelType')
})
it('renders base models field', () => {
const asset = createMockAsset({
user_metadata: { base_model: ['SDXL'] }
})
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain(
'assetBrowser.modelInfo.compatibleBaseModels'
)
})
it('renders additional tags field', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain('assetBrowser.modelInfo.additionalTags')
})
})
describe('Model Description Section', () => {
it('renders trigger phrases when present', () => {
const asset = createMockAsset({
user_metadata: { trained_words: ['trigger1', 'trigger2'] }
})
const wrapper = mountPanel(asset)
expect(wrapper.text()).toContain('trigger1')
expect(wrapper.text()).toContain('trigger2')
})
it('renders description section', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain(
'assetBrowser.modelInfo.modelDescription'
)
})
it('does not render trigger phrases field when empty', () => {
const asset = createMockAsset()
const wrapper = mountPanel(asset)
expect(wrapper.text()).not.toContain(
'assetBrowser.modelInfo.triggerPhrases'
)
})
})
describe('Accordion Structure', () => {
it('renders all three section labels', () => {
const wrapper = mountPanel(createMockAsset())
expect(wrapper.text()).toContain('assetBrowser.modelInfo.basicInfo')
expect(wrapper.text()).toContain('assetBrowser.modelInfo.modelTagging')
expect(wrapper.text()).toContain(
'assetBrowser.modelInfo.modelDescription'
)
})
})
})

View File

@@ -0,0 +1,296 @@
<template>
<div
data-component-id="ModelInfoPanel"
class="flex h-full flex-col scrollbar-custom"
>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
{{ t('assetBrowser.modelInfo.basicInfo') }}
</span>
</template>
<ModelInfoField :label="t('assetBrowser.modelInfo.displayName')">
<EditableText
:model-value="displayName"
:is-editing="isEditingDisplayName"
:class="cn('break-all', !isImmutable && 'text-base-foreground')"
@dblclick="isEditingDisplayName = !isImmutable"
@edit="handleDisplayNameEdit"
@cancel="isEditingDisplayName = false"
/>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.fileName')">
<span class="break-all">{{ asset.name }}</span>
</ModelInfoField>
<ModelInfoField
v-if="sourceUrl"
:label="t('assetBrowser.modelInfo.source')"
>
<a
:href="sourceUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-muted-foreground no-underline transition-colors hover:text-foreground"
>
<img
v-if="sourceName === 'Civitai'"
src="/assets/images/civitai.svg"
alt=""
class="size-4 shrink-0"
/>
{{ t('assetBrowser.modelInfo.viewOnSource', { source: sourceName }) }}
<i class="icon-[lucide--external-link] size-4 shrink-0" />
</a>
</ModelInfoField>
</PropertiesAccordionItem>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
{{ t('assetBrowser.modelInfo.modelTagging') }}
</span>
</template>
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
<Select v-model="selectedModelType" :disabled="isImmutable">
<SelectTrigger class="w-full">
<SelectValue
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in modelTypes"
:key="option.value"
:value="option.value"
>
{{ option.name }}
</SelectItem>
</SelectContent>
</Select>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.compatibleBaseModels')">
<TagsInput
v-slot="{ isEmpty }"
v-model="baseModels"
:disabled="isImmutable"
>
<TagsInputItem
v-for="model in baseModels"
:key="model"
:value="model"
>
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:is-empty="isEmpty"
:placeholder="
isImmutable
? t('assetBrowser.modelInfo.baseModelUnknown')
: t('assetBrowser.modelInfo.addBaseModel')
"
/>
</TagsInput>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.additionalTags')">
<TagsInput
v-slot="{ isEmpty }"
v-model="additionalTags"
:disabled="isImmutable"
>
<TagsInputItem v-for="tag in additionalTags" :key="tag" :value="tag">
<TagsInputItemText />
<TagsInputItemDelete />
</TagsInputItem>
<TagsInputInput
:is-empty="isEmpty"
:placeholder="
isImmutable
? t('assetBrowser.modelInfo.noAdditionalTags')
: t('assetBrowser.modelInfo.addTag')
"
/>
</TagsInput>
</ModelInfoField>
</PropertiesAccordionItem>
<PropertiesAccordionItem :class="accordionClass">
<template #label>
<span class="text-xs uppercase font-inter">
{{ t('assetBrowser.modelInfo.modelDescription') }}
</span>
</template>
<ModelInfoField
v-if="triggerPhrases.length > 0"
:label="t('assetBrowser.modelInfo.triggerPhrases')"
>
<div class="flex flex-wrap gap-1">
<span
v-for="phrase in triggerPhrases"
:key="phrase"
class="rounded px-2 py-0.5 text-xs"
>
{{ phrase }}
</span>
</div>
</ModelInfoField>
<ModelInfoField
v-if="description"
:label="t('assetBrowser.modelInfo.description')"
>
<p class="text-sm whitespace-pre-wrap">{{ description }}</p>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.description')">
<textarea
ref="descriptionTextarea"
v-model="userDescription"
:disabled="isImmutable"
:placeholder="
isImmutable
? t('assetBrowser.modelInfo.descriptionNotSet')
: t('assetBrowser.modelInfo.descriptionPlaceholder')
"
rows="3"
:class="
cn(
'w-full resize-y rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground outline-none transition-colors focus:bg-component-node-widget-background',
isImmutable && 'cursor-not-allowed'
)
"
@keydown.escape.stop="descriptionTextarea?.blur()"
/>
</ModelInfoField>
</PropertiesAccordionItem>
</div>
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.vue'
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import type { AssetUserMetadata } from '@/platform/assets/schemas/assetSchema'
import {
getAssetAdditionalTags,
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetsStore } from '@/stores/assetsStore'
import { cn } from '@/utils/tailwindUtil'
import ModelInfoField from './ModelInfoField.vue'
const { t } = useI18n()
const descriptionTextarea = useTemplateRef<HTMLTextAreaElement>(
'descriptionTextarea'
)
const accordionClass = cn(
'bg-modal-panel-background border-t border-border-default'
)
const { asset, cacheKey } = defineProps<{
asset: AssetDisplayItem
cacheKey?: string
}>()
const assetsStore = useAssetsStore()
const { modelTypes } = useModelTypes()
const pendingUpdates = ref<AssetUserMetadata>({})
const isEditingDisplayName = ref(false)
const isImmutable = computed(() => asset.is_immutable ?? true)
const displayName = computed(
() => pendingUpdates.value.name ?? getAssetDisplayName(asset)
)
const sourceUrl = computed(() => getAssetSourceUrl(asset))
const sourceName = computed(() =>
sourceUrl.value ? getSourceName(sourceUrl.value) : ''
)
const description = computed(() => getAssetDescription(asset))
const triggerPhrases = computed(() => getAssetTriggerPhrases(asset))
watch(
() => asset.user_metadata,
() => {
pendingUpdates.value = {}
}
)
const debouncedFlushMetadata = useDebounceFn(() => {
if (isImmutable.value) return
assetsStore.updateAssetMetadata(
asset.id,
{ ...(asset.user_metadata ?? {}), ...pendingUpdates.value },
cacheKey
)
}, 500)
function queueMetadataUpdate(updates: AssetUserMetadata) {
pendingUpdates.value = { ...pendingUpdates.value, ...updates }
debouncedFlushMetadata()
}
function handleDisplayNameEdit(newName: string) {
isEditingDisplayName.value = false
if (newName && newName !== displayName.value) {
queueMetadataUpdate({ name: newName })
}
}
const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
if (isImmutable.value) return
const currentModelType = getAssetModelType(asset)
if (currentModelType === newModelType) return
const newTags = asset.tags
.filter((tag) => tag !== currentModelType)
.concat(newModelType)
assetsStore.updateAssetTags(asset.id, newTags, cacheKey)
}, 500)
const baseModels = computed({
get: () => pendingUpdates.value.base_model ?? getAssetBaseModels(asset),
set: (value: string[]) => queueMetadataUpdate({ base_model: value })
})
const additionalTags = computed({
get: () =>
pendingUpdates.value.additional_tags ?? getAssetAdditionalTags(asset),
set: (value: string[]) => queueMetadataUpdate({ additional_tags: value })
})
const userDescription = computed({
get: () =>
pendingUpdates.value.user_description ?? getAssetUserDescription(asset),
set: (value: string) => queueMetadataUpdate({ user_description: value })
})
const selectedModelType = computed({
get: () => getAssetModelType(asset) ?? undefined,
set: (value: string | undefined) => {
if (value) debouncedSaveModelType(value)
}
})
</script>

View File

@@ -1,3 +1,4 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
@@ -8,6 +9,8 @@ vi.mock('@/i18n', () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'assetBrowser.allModels': 'All Models',
'assetBrowser.imported': 'Imported',
'assetBrowser.byType': 'By type',
'assetBrowser.assets': 'Assets',
'assetBrowser.unknown': 'unknown'
}
@@ -18,6 +21,7 @@ vi.mock('@/i18n', () => ({
describe('useAssetBrowser', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
@@ -48,7 +52,7 @@ describe('useAssetBrowser', () => {
tags: ['models', 'loras']
})
const { selectedCategory, categoryFilteredAssets } = useAssetBrowser(
const { selectedNavItem, categoryFilteredAssets } = useAssetBrowser(
ref([checkpointAsset, loraAsset])
)
@@ -56,11 +60,11 @@ describe('useAssetBrowser', () => {
expect(categoryFilteredAssets.value).toHaveLength(2)
// When category selected, should only show that category
selectedCategory.value = 'checkpoints'
selectedNavItem.value = 'checkpoints'
expect(categoryFilteredAssets.value).toHaveLength(1)
expect(categoryFilteredAssets.value[0].id).toBe('checkpoint-1')
selectedCategory.value = 'loras'
selectedNavItem.value = 'loras'
expect(categoryFilteredAssets.value).toHaveLength(1)
expect(categoryFilteredAssets.value[0].id).toBe('lora-1')
})
@@ -150,9 +154,9 @@ describe('useAssetBrowser', () => {
createApiAsset({ id: '3', tags: ['models', 'checkpoints'] })
]
const { selectedCategory, filteredAssets } = useAssetBrowser(ref(assets))
const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
selectedCategory.value = 'checkpoints'
selectedNavItem.value = 'checkpoints'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
@@ -169,9 +173,9 @@ describe('useAssetBrowser', () => {
createApiAsset({ id: '2', tags: ['models', 'loras'] })
]
const { selectedCategory, filteredAssets } = useAssetBrowser(ref(assets))
const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
selectedCategory.value = 'all'
selectedNavItem.value = 'all'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
@@ -291,8 +295,7 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'name-asc',
fileFormats: ['safetensors'],
baseModels: [],
ownership: 'all'
baseModels: []
})
await nextTick()
@@ -327,8 +330,7 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
baseModels: ['SDXL'],
ownership: 'all'
baseModels: ['SDXL']
})
await nextTick()
@@ -354,12 +356,12 @@ describe('useAssetBrowser', () => {
})
]
const { searchQuery, selectedCategory, filteredAssets } = useAssetBrowser(
const { searchQuery, selectedNavItem, filteredAssets } = useAssetBrowser(
ref(assets)
)
searchQuery.value = 'realistic'
selectedCategory.value = 'checkpoints'
selectedNavItem.value = 'checkpoints'
await nextTick()
expect(filteredAssets.value).toHaveLength(1)
@@ -380,10 +382,9 @@ describe('useAssetBrowser', () => {
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({
sortBy: 'name',
sortBy: 'name-asc',
fileFormats: [],
baseModels: [],
ownership: 'all'
baseModels: []
})
await nextTick()
@@ -407,8 +408,7 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'recent',
fileFormats: [],
baseModels: [],
ownership: 'all'
baseModels: []
})
await nextTick()
@@ -440,15 +440,14 @@ describe('useAssetBrowser', () => {
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
baseModels: [],
ownership: 'all'
baseModels: []
})
await nextTick()
expect(filteredAssets.value).toHaveLength(3)
})
it('filters by ownership - my models only', async () => {
it('filters by ownership - imported models only via nav selection', async () => {
const assets = [
createApiAsset({ name: 'my-model.safetensors', is_immutable: false }),
createApiAsset({
@@ -461,14 +460,10 @@ describe('useAssetBrowser', () => {
})
]
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
baseModels: [],
ownership: 'my-models'
})
// Selecting 'imported' nav item filters to my-models (non-immutable)
selectedNavItem.value = 'imported'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
@@ -477,7 +472,7 @@ describe('useAssetBrowser', () => {
)
})
it('filters by ownership - public models only', async () => {
it('shows all models when nav is "all"', async () => {
const assets = [
createApiAsset({ name: 'my-model.safetensors', is_immutable: false }),
createApiAsset({
@@ -490,41 +485,47 @@ describe('useAssetBrowser', () => {
})
]
const { updateFilters, filteredAssets } = useAssetBrowser(ref(assets))
const { selectedNavItem, filteredAssets } = useAssetBrowser(ref(assets))
updateFilters({
sortBy: 'name-asc',
fileFormats: [],
baseModels: [],
ownership: 'public-models'
})
// Selecting 'all' nav item shows all models
selectedNavItem.value = 'all'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
expect(filteredAssets.value.every((asset) => asset.is_immutable)).toBe(
true
)
expect(filteredAssets.value).toHaveLength(3)
})
})
describe('Dynamic Category Extraction', () => {
it('extracts categories from asset tags', () => {
it('extracts categories from asset tags into navItems', () => {
const assets = [
createApiAsset({ tags: ['models', 'checkpoints'] }),
createApiAsset({ tags: ['models', 'loras'] }),
createApiAsset({ tags: ['models', 'checkpoints'] }) // duplicate
]
const { availableCategories } = useAssetBrowser(ref(assets))
const { navItems } = useAssetBrowser(ref(assets))
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
// navItems includes quick filters plus a "By type" group
expect(navItems.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--list]' },
{
id: 'checkpoints',
label: 'Checkpoints',
icon: 'icon-[lucide--package]'
id: 'imported',
label: 'Imported',
icon: 'icon-[lucide--folder-input]',
badge: undefined
},
{ id: 'loras', label: 'Loras', icon: 'icon-[lucide--package]' }
{
title: 'By type',
collapsible: false,
items: [
{
id: 'checkpoints',
label: 'Checkpoints',
icon: 'icon-[lucide--folder]'
},
{ id: 'loras', label: 'Loras', icon: 'icon-[lucide--folder]' }
]
}
])
})
@@ -534,11 +535,21 @@ describe('useAssetBrowser', () => {
createApiAsset({ tags: ['models', 'vae'] })
]
const { availableCategories } = useAssetBrowser(ref(assets))
const { navItems } = useAssetBrowser(ref(assets))
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{ id: 'vae', label: 'Vae', icon: 'icon-[lucide--package]' }
expect(navItems.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--list]' },
{
id: 'imported',
label: 'Imported',
icon: 'icon-[lucide--folder-input]',
badge: undefined
},
{
title: 'By type',
collapsible: false,
items: [{ id: 'vae', label: 'Vae', icon: 'icon-[lucide--folder]' }]
}
])
})
@@ -548,31 +559,47 @@ describe('useAssetBrowser', () => {
createApiAsset({ tags: ['models', 'checkpoints'] })
]
const { availableCategories } = useAssetBrowser(ref(assets))
const { navItems } = useAssetBrowser(ref(assets))
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
expect(navItems.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--list]' },
{
id: 'checkpoints',
label: 'Checkpoints',
icon: 'icon-[lucide--package]'
id: 'imported',
label: 'Imported',
icon: 'icon-[lucide--folder-input]',
badge: undefined
},
{
title: 'By type',
collapsible: false,
items: [
{
id: 'checkpoints',
label: 'Checkpoints',
icon: 'icon-[lucide--folder]'
}
]
}
])
})
it('computes content title from selected category', () => {
it('computes content title from selected nav item', () => {
const assets = [createApiAsset({ tags: ['models', 'checkpoints'] })]
const { selectedCategory, contentTitle } = useAssetBrowser(ref(assets))
const { selectedNavItem, contentTitle } = useAssetBrowser(ref(assets))
// Default
expect(contentTitle.value).toBe('All Models')
// Set specific category
selectedCategory.value = 'checkpoints'
selectedNavItem.value = 'checkpoints'
expect(contentTitle.value).toBe('Checkpoints')
// Set imported
selectedNavItem.value = 'imported'
expect(contentTitle.value).toBe('Imported')
// Unknown category
selectedCategory.value = 'unknown'
selectedNavItem.value = 'unknown'
expect(contentTitle.value).toBe('Assets')
})
@@ -596,26 +623,18 @@ describe('useAssetBrowser', () => {
})
]
const { availableCategories, selectedCategory, categoryFilteredAssets } =
const { navItems, selectedNavItem, categoryFilteredAssets } =
useAssetBrowser(ref(assets))
// Should group all Chatterbox subfolders under single category
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{
id: 'Chatterbox',
label: 'Chatterbox',
icon: 'icon-[lucide--package]'
},
{
id: 'OtherFolder',
label: 'OtherFolder',
icon: 'icon-[lucide--package]'
}
// Should group all Chatterbox subfolders under single category in the type group
const typeGroup = navItems.value[2] as { items: { id: string }[] }
expect(typeGroup.items.map((i) => i.id)).toEqual([
'Chatterbox',
'OtherFolder'
])
// When selecting Chatterbox category, should include all models from its subfolders
selectedCategory.value = 'Chatterbox'
selectedNavItem.value = 'Chatterbox'
expect(categoryFilteredAssets.value).toHaveLength(3)
expect(categoryFilteredAssets.value.map((a) => a.id)).toEqual([
'asset-1',
@@ -624,7 +643,7 @@ describe('useAssetBrowser', () => {
])
// When selecting OtherFolder category, should include only its models
selectedCategory.value = 'OtherFolder'
selectedNavItem.value = 'OtherFolder'
expect(categoryFilteredAssets.value).toHaveLength(1)
expect(categoryFilteredAssets.value[0].id).toBe('asset-4')
})

View File

@@ -2,16 +2,22 @@ import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { useFuse } from '@vueuse/integrations/useFuse'
import type { UseFuseOptions } from '@vueuse/integrations/useFuse'
import { storeToRefs } from 'pinia'
import { d, t } from '@/i18n'
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModel,
getAssetDescription
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
export type OwnershipOption = 'all' | 'my-models' | 'public-models'
type OwnershipOption = 'all' | 'my-models' | 'public-models'
type NavId = 'all' | 'imported' | (string & {})
function filterByCategory(category: string) {
return (asset: AssetItem) => {
@@ -43,8 +49,8 @@ function filterByBaseModels(models: string[]) {
return (asset: AssetItem) => {
if (models.length === 0) return true
const modelSet = new Set(models)
const baseModel = getAssetBaseModel(asset)
return baseModel ? modelSet.has(baseModel) : false
const assetBaseModels = getAssetBaseModels(asset)
return assetBaseModels.some((model) => modelSet.has(model))
}
}
@@ -81,14 +87,31 @@ export function useAssetBrowser(
assetsSource: Ref<AssetItem[] | undefined> = ref<AssetItem[] | undefined>([])
) {
const assets = computed<AssetItem[]>(() => assetsSource.value ?? [])
const assetDownloadStore = useAssetDownloadStore()
const { sessionDownloadCount } = storeToRefs(assetDownloadStore)
// State
const searchQuery = ref('')
const selectedCategory = ref('all')
const selectedNavItem = ref<NavId>('all')
const filters = ref<FilterState>({
sortBy: 'recent',
fileFormats: [],
baseModels: [],
ownership: 'all'
baseModels: []
})
const selectedOwnership = computed<OwnershipOption>(() => {
if (selectedNavItem.value === 'imported') return 'my-models'
return 'all'
})
const selectedCategory = computed(() => {
if (
selectedNavItem.value === 'all' ||
selectedNavItem.value === 'imported'
) {
return 'all'
}
return selectedNavItem.value
})
// Transform API asset to display asset
@@ -112,18 +135,17 @@ export function useAssetBrowser(
badges.push({ label: badgeLabel, type: 'type' })
}
// Base model badge from metadata
const baseModel = getAssetBaseModel(asset)
if (baseModel) {
badges.push({
label: baseModel,
type: 'base'
})
// Base model badges from metadata
const baseModels = getAssetBaseModels(asset)
for (const model of baseModels) {
badges.push({ label: model, type: 'base' })
}
// Create display stats from API data
const stats = {
formattedDate: d(new Date(asset.created_at), { dateStyle: 'short' }),
formattedDate: asset.created_at
? d(new Date(asset.created_at), { dateStyle: 'short' })
: undefined,
downloadCount: undefined, // Not available in API
stars: undefined // Not available in API
}
@@ -136,39 +158,69 @@ export function useAssetBrowser(
}
}
const availableCategories = computed(() => {
const typeCategories = computed<NavItemData[]>(() => {
const categories = assets.value
.filter((asset) => asset.tags[0] === 'models')
.map((asset) => asset.tags[1])
.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0)
.map((tag) => tag.split('/')[0]) // Extract top-level folder name
.map((tag) => tag.split('/')[0])
const uniqueCategories = Array.from(new Set(categories))
return Array.from(new Set(categories))
.sort()
.map((category) => ({
id: category,
label: category.charAt(0).toUpperCase() + category.slice(1),
icon: 'icon-[lucide--package]'
icon: 'icon-[lucide--folder]'
}))
})
return [
const navItems = computed<(NavItemData | NavGroupData)[]>(() => {
const quickFilters: NavItemData[] = [
{
id: 'all',
label: t('assetBrowser.allModels'),
icon: 'icon-[lucide--folder]'
icon: 'icon-[lucide--list]'
},
...uniqueCategories
{
id: 'imported',
label: t('assetBrowser.imported'),
icon: 'icon-[lucide--folder-input]',
badge:
sessionDownloadCount.value > 0
? sessionDownloadCount.value
: undefined
}
]
if (typeCategories.value.length === 0) {
return quickFilters
}
return [
...quickFilters,
{
title: t('assetBrowser.byType'),
items: typeCategories.value,
collapsible: false
}
]
})
// Compute content title from selected category
const isImportedSelected = computed(
() => selectedNavItem.value === 'imported'
)
// Compute content title from selected nav item
const contentTitle = computed(() => {
if (selectedCategory.value === 'all') {
if (selectedNavItem.value === 'all') {
return t('assetBrowser.allModels')
}
if (selectedNavItem.value === 'imported') {
return t('assetBrowser.imported')
}
const category = availableCategories.value.find(
(cat) => cat.id === selectedCategory.value
const category = typeCategories.value.find(
(cat) => cat.id === selectedNavItem.value
)
return category?.label || t('assetBrowser.assets')
})
@@ -182,7 +234,13 @@ export function useAssetBrowser(
fuseOptions: {
keys: [
{ name: 'name', weight: 0.4 },
{ name: 'tags', weight: 0.3 }
{ name: 'tags', weight: 0.3 },
{ name: 'user_metadata.name', weight: 0.4 },
{ name: 'user_metadata.additional_tags', weight: 0.3 },
{ name: 'user_metadata.trained_words', weight: 0.3 },
{ name: 'user_metadata.user_description', weight: 0.3 },
{ name: 'metadata.name', weight: 0.4 },
{ name: 'metadata.trained_words', weight: 0.3 }
],
threshold: 0.4, // Higher threshold for typo tolerance (0.0 = exact, 1.0 = match all)
ignoreLocation: true, // Search anywhere in the string, not just at the beginning
@@ -205,22 +263,21 @@ export function useAssetBrowser(
const filtered = searchFiltered.value
.filter(filterByFileFormats(filters.value.fileFormats))
.filter(filterByBaseModels(filters.value.baseModels))
.filter(filterByOwnership(filters.value.ownership))
.filter(filterByOwnership(selectedOwnership.value))
const sortedAssets = [...filtered]
sortedAssets.sort((a, b) => {
switch (filters.value.sortBy) {
case 'name-desc':
return b.name.localeCompare(a.name)
return getAssetDisplayName(b).localeCompare(getAssetDisplayName(a))
case 'recent':
return (
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
new Date(b.created_at ?? 0).getTime() -
new Date(a.created_at ?? 0).getTime()
)
case 'popular':
return a.name.localeCompare(b.name)
case 'name-asc':
default:
return a.name.localeCompare(b.name)
return getAssetDisplayName(a).localeCompare(getAssetDisplayName(b))
}
})
@@ -234,11 +291,13 @@ export function useAssetBrowser(
return {
searchQuery,
selectedNavItem,
selectedCategory,
availableCategories,
navItems,
contentTitle,
categoryFilteredAssets,
filteredAssets,
isImportedSelected,
updateFilters
}
}

View File

@@ -4,6 +4,7 @@ import type { MaybeRefOrGetter } from 'vue'
import type { SelectOption } from '@/components/input/types'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
/**
* Composable that extracts available filter options from asset data
@@ -37,12 +38,7 @@ export function useAssetFilterOptions(assets: MaybeRefOrGetter<AssetItem[]>) {
*/
const availableBaseModels = computed<SelectOption[]>(() => {
const assetList = toValue(assets)
const models = assetList
.map((asset) => asset.user_metadata?.base_model)
.filter(
(baseModel): baseModel is string =>
baseModel !== undefined && typeof baseModel === 'string'
)
const models = assetList.flatMap((asset) => getAssetBaseModels(asset))
const uniqueModels = uniqWith(models, (a, b) => a === b)

View File

@@ -46,9 +46,10 @@ const DISALLOWED_MODEL_TYPES = ['nlf'] as const
export const useModelTypes = createSharedComposable(() => {
const {
state: modelTypes,
isReady,
isLoading,
error,
execute: fetchModelTypes
execute
} = useAsyncState(
async (): Promise<ModelTypeOption[]> => {
const response = await api.getModelFolders()
@@ -74,6 +75,11 @@ export const useModelTypes = createSharedComposable(() => {
}
)
async function fetchModelTypes() {
if (isReady.value || isLoading.value) return
await execute()
}
return {
modelTypes,
isLoading,

View File

@@ -10,10 +10,11 @@ const zAsset = z.object({
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
preview_url: z.string().optional(),
created_at: z.string(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
is_immutable: z.boolean().optional(),
last_access_time: z.string().optional(),
metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
user_metadata: z.record(z.unknown()).optional() // API allows arbitrary key-value pairs
})
@@ -90,6 +91,21 @@ export type AsyncUploadResponse = z.infer<typeof zAsyncUploadResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>
/** Payload for updating an asset via PUT /assets/:id */
export type AssetUpdatePayload = Partial<
Pick<AssetItem, 'name' | 'tags' | 'user_metadata'>
>
/** User-editable metadata fields for model assets */
const zAssetUserMetadata = z.object({
name: z.string().optional(),
base_model: z.array(z.string()).optional(),
additional_tags: z.array(z.string()).optional(),
user_description: z.string().optional()
})
export type AssetUserMetadata = z.infer<typeof zAssetUserMetadata>
// Legacy interface for backward compatibility (now aligned with Zod schema)
export interface ModelFolderInfo {
name: string

View File

@@ -160,7 +160,7 @@ describe('assetService', () => {
const result = await assetService.getAssetModels('checkpoints')
expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=models,checkpoints&limit=500'
'/assets?include_tags=models%2Ccheckpoints&limit=500'
)
expect(result).toEqual([
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
@@ -231,9 +231,9 @@ describe('assetService', () => {
)
expect(result).toEqual(testAssets)
// Verify API call includes correct category
// Verify API call includes correct category (comma is URL-encoded by URLSearchParams)
expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=models,checkpoints&limit=500'
'/assets?include_tags=models%2Ccheckpoints&limit=500'
)
})
@@ -400,7 +400,7 @@ describe('assetService', () => {
})
expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=models&limit=500&include_public=true&offset=50'
'/assets?include_tags=models&limit=500&offset=50&include_public=true'
)
expect(result).toEqual(testAssets)
})
@@ -415,7 +415,7 @@ describe('assetService', () => {
})
expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=input&limit=100&include_public=false&offset=25'
'/assets?include_tags=input&limit=100&offset=25&include_public=false'
)
expect(result).toEqual(testAssets)
})

View File

@@ -1,6 +1,7 @@
import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
import {
assetItemSchema,
assetResponseSchema,
@@ -10,6 +11,7 @@ import type {
AssetItem,
AssetMetadata,
AssetResponse,
AssetUpdatePayload,
AsyncUploadResponse,
ModelFile,
ModelFolder
@@ -17,6 +19,16 @@ import type {
import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
export interface PaginationOptions {
limit?: number
offset?: number
}
interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
includePublic?: boolean
}
/**
* Maps CivitAI validation error codes to localized error messages
*/
@@ -77,9 +89,27 @@ function createAssetService() {
* Handles API response with consistent error handling and Zod validation
*/
async function handleAssetRequest(
url: string,
options: AssetRequestOptions,
context: string
): Promise<AssetResponse> {
const {
includeTags,
limit = DEFAULT_LIMIT,
offset,
includePublic
} = options
const queryParams = new URLSearchParams({
include_tags: includeTags.join(','),
limit: limit.toString()
})
if (offset !== undefined && offset > 0) {
queryParams.set('offset', offset.toString())
}
if (includePublic !== undefined) {
queryParams.set('include_public', includePublic ? 'true' : 'false')
}
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
const res = await api.fetchApi(url)
if (!res.ok) {
throw new Error(
@@ -101,7 +131,7 @@ function createAssetService() {
*/
async function getAssetModelFolders(): Promise<ModelFolder[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}&limit=${DEFAULT_LIMIT}`,
{ includeTags: [MODELS_TAG] },
'model folders'
)
@@ -130,7 +160,7 @@ function createAssetService() {
*/
async function getAssetModels(folder: string): Promise<ModelFile[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}&limit=${DEFAULT_LIMIT}`,
{ includeTags: [MODELS_TAG, folder] },
`models for ${folder}`
)
@@ -169,9 +199,15 @@ function createAssetService() {
* and fetching all assets with that category tag
*
* @param nodeType - The ComfyUI node type (e.g., 'CheckpointLoaderSimple')
* @param options - Pagination options
* @param options.limit - Maximum number of assets to return (default: 500)
* @param options.offset - Number of assets to skip (default: 0)
* @returns Promise<AssetItem[]> - Full asset objects with preserved metadata
*/
async function getAssetsForNodeType(nodeType: string): Promise<AssetItem[]> {
async function getAssetsForNodeType(
nodeType: string,
{ limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {}
): Promise<AssetItem[]> {
if (!nodeType || typeof nodeType !== 'string') {
return []
}
@@ -186,7 +222,7 @@ function createAssetService() {
// Fetch assets for this category using same API pattern as getAssetModels
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}&limit=${DEFAULT_LIMIT}`,
{ includeTags: [MODELS_TAG, category], limit, offset },
`assets for ${nodeType}`
)
@@ -242,23 +278,10 @@ function createAssetService() {
async function getAssetsByTag(
tag: string,
includePublic: boolean = true,
{
limit = DEFAULT_LIMIT,
offset = 0
}: { limit?: number; offset?: number } = {}
{ limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {}
): Promise<AssetItem[]> {
const queryParams = new URLSearchParams({
include_tags: tag,
limit: limit.toString(),
include_public: includePublic ? 'true' : 'false'
})
if (offset > 0) {
queryParams.set('offset', offset.toString())
}
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?${queryParams.toString()}`,
{ includeTags: [tag], limit, offset, includePublic },
`assets for tag ${tag}`
)
@@ -298,7 +321,7 @@ function createAssetService() {
*/
async function updateAsset(
id: string,
newData: Partial<AssetMetadata>
newData: AssetUpdatePayload
): Promise<AssetItem> {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
method: 'PUT',

View File

@@ -2,8 +2,16 @@ import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetAdditionalTags,
getAssetBaseModel,
getAssetDescription
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
describe('assetMetadataUtils', () => {
@@ -20,20 +28,17 @@ describe('assetMetadataUtils', () => {
}
describe('getAssetDescription', () => {
it('should return string description when present', () => {
const asset = {
...mockAsset,
user_metadata: { description: 'A test model' }
}
expect(getAssetDescription(asset)).toBe('A test model')
})
it('should return null when description is not a string', () => {
const asset = {
...mockAsset,
user_metadata: { description: 123 }
}
expect(getAssetDescription(asset)).toBeNull()
it.for([
{
name: 'returns string description when present',
description: 'A test model',
expected: 'A test model'
},
{ name: 'returns null for non-string', description: 123, expected: null },
{ name: 'returns null for null', description: null, expected: null }
])('$name', ({ description, expected }) => {
const asset = { ...mockAsset, user_metadata: { description } }
expect(getAssetDescription(asset)).toBe(expected)
})
it('should return null when no metadata', () => {
@@ -42,24 +47,228 @@ describe('assetMetadataUtils', () => {
})
describe('getAssetBaseModel', () => {
it('should return string base_model when present', () => {
const asset = {
...mockAsset,
user_metadata: { base_model: 'SDXL' }
}
expect(getAssetBaseModel(asset)).toBe('SDXL')
})
it('should return null when base_model is not a string', () => {
const asset = {
...mockAsset,
user_metadata: { base_model: 123 }
}
expect(getAssetBaseModel(asset)).toBeNull()
it.for([
{
name: 'returns string base_model when present',
base_model: 'SDXL',
expected: 'SDXL'
},
{ name: 'returns null for non-string', base_model: 123, expected: null },
{ name: 'returns null for null', base_model: null, expected: null }
])('$name', ({ base_model, expected }) => {
const asset = { ...mockAsset, user_metadata: { base_model } }
expect(getAssetBaseModel(asset)).toBe(expected)
})
it('should return null when no metadata', () => {
expect(getAssetBaseModel(mockAsset)).toBeNull()
})
})
describe('getAssetDisplayName', () => {
it.for([
{
name: 'returns name from user_metadata when present',
user_metadata: { name: 'My Custom Name' },
expected: 'My Custom Name'
},
{
name: 'falls back to asset name for non-string',
user_metadata: { name: 123 },
expected: 'test-model'
},
{
name: 'falls back to asset name for undefined',
user_metadata: undefined,
expected: 'test-model'
}
])('$name', ({ user_metadata, expected }) => {
const asset = { ...mockAsset, user_metadata }
expect(getAssetDisplayName(asset)).toBe(expected)
})
})
describe('getAssetSourceUrl', () => {
it.for([
{
name: 'constructs URL from civitai format',
source_arn: 'civitai:model:123:version:456',
expected: 'https://civitai.com/models/123?modelVersionId=456'
},
{ name: 'returns null for non-string', source_arn: 123, expected: null },
{
name: 'returns null for unrecognized format',
source_arn: 'unknown:format',
expected: null
}
])('$name', ({ source_arn, expected }) => {
const asset = { ...mockAsset, user_metadata: { source_arn } }
expect(getAssetSourceUrl(asset)).toBe(expected)
})
it('should return null when no metadata', () => {
expect(getAssetSourceUrl(mockAsset)).toBeNull()
})
})
describe('getAssetTriggerPhrases', () => {
it.for([
{
name: 'returns array when array present',
trained_words: ['phrase1', 'phrase2'],
expected: ['phrase1', 'phrase2']
},
{
name: 'wraps single string in array',
trained_words: 'single phrase',
expected: ['single phrase']
},
{
name: 'filters non-string values from array',
trained_words: ['valid', 123, 'also valid', null],
expected: ['valid', 'also valid']
}
])('$name', ({ trained_words, expected }) => {
const asset = { ...mockAsset, user_metadata: { trained_words } }
expect(getAssetTriggerPhrases(asset)).toEqual(expected)
})
it('should return empty array when no metadata', () => {
expect(getAssetTriggerPhrases(mockAsset)).toEqual([])
})
})
describe('getAssetAdditionalTags', () => {
it.for([
{
name: 'returns array of tags when present',
additional_tags: ['tag1', 'tag2'],
expected: ['tag1', 'tag2']
},
{
name: 'filters non-string values from array',
additional_tags: ['valid', 123, 'also valid'],
expected: ['valid', 'also valid']
},
{
name: 'returns empty array for non-array',
additional_tags: 'not an array',
expected: []
}
])('$name', ({ additional_tags, expected }) => {
const asset = { ...mockAsset, user_metadata: { additional_tags } }
expect(getAssetAdditionalTags(asset)).toEqual(expected)
})
it('should return empty array when no metadata', () => {
expect(getAssetAdditionalTags(mockAsset)).toEqual([])
})
})
describe('getSourceName', () => {
it.for([
{
name: 'returns Civitai for civitai.com',
url: 'https://civitai.com/models/123',
expected: 'Civitai'
},
{
name: 'returns Hugging Face for huggingface.co',
url: 'https://huggingface.co/org/model',
expected: 'Hugging Face'
},
{
name: 'returns Source for unknown URLs',
url: 'https://example.com/model',
expected: 'Source'
}
])('$name', ({ url, expected }) => {
expect(getSourceName(url)).toBe(expected)
})
})
describe('getAssetBaseModels', () => {
it.for([
{
name: 'array of strings',
base_model: ['SDXL', 'SD1.5', 'Flux'],
expected: ['SDXL', 'SD1.5', 'Flux']
},
{
name: 'filters non-string entries',
base_model: ['SDXL', 123, 'SD1.5', null, undefined],
expected: ['SDXL', 'SD1.5']
},
{
name: 'single string wrapped in array',
base_model: 'SDXL',
expected: ['SDXL']
},
{
name: 'non-array/string returns empty',
base_model: 123,
expected: []
},
{ name: 'undefined returns empty', base_model: undefined, expected: [] }
])('$name', ({ base_model, expected }) => {
const asset = { ...mockAsset, user_metadata: { base_model } }
expect(getAssetBaseModels(asset)).toEqual(expected)
})
it('should return empty array when no metadata', () => {
expect(getAssetBaseModels(mockAsset)).toEqual([])
})
})
describe('getAssetModelType', () => {
it.for([
{
name: 'returns model type from tags',
tags: ['models', 'checkpoints'],
expected: 'checkpoints'
},
{
name: 'extracts last segment from path-style tags',
tags: ['models', 'models/loras'],
expected: 'loras'
},
{
name: 'returns null when only models tag',
tags: ['models'],
expected: null
},
{ name: 'returns null when tags empty', tags: [], expected: null }
])('$name', ({ tags, expected }) => {
const asset = { ...mockAsset, tags }
expect(getAssetModelType(asset)).toBe(expected)
})
})
describe('getAssetUserDescription', () => {
it.for([
{
name: 'returns description when present',
user_description: 'A custom user description',
expected: 'A custom user description'
},
{
name: 'returns empty for non-string',
user_description: 123,
expected: ''
},
{ name: 'returns empty for null', user_description: null, expected: '' },
{
name: 'returns empty for undefined',
user_description: undefined,
expected: ''
}
])('$name', ({ user_description, expected }) => {
const asset = { ...mockAsset, user_metadata: { user_description } }
expect(getAssetUserDescription(asset)).toBe(expected)
})
it('should return empty string when no metadata', () => {
expect(getAssetUserDescription(mockAsset)).toBe('')
})
})
})

View File

@@ -1,27 +1,151 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Type-safe utilities for extracting metadata from assets
* Type-safe utilities for extracting metadata from assets.
* These utilities check user_metadata first, then metadata, then fallback.
*/
/**
* Helper to get a string property from user_metadata or metadata
*/
function getStringProperty(asset: AssetItem, key: string): string | undefined {
const userValue = asset.user_metadata?.[key]
if (typeof userValue === 'string') return userValue
const metaValue = asset.metadata?.[key]
if (typeof metaValue === 'string') return metaValue
return undefined
}
/**
* Safely extracts string description from asset metadata
* Checks user_metadata first, then metadata, then returns null
* @param asset - The asset to extract description from
* @returns The description string or null if not present/not a string
*/
export function getAssetDescription(asset: AssetItem): string | null {
return typeof asset.user_metadata?.description === 'string'
? asset.user_metadata.description
: null
return getStringProperty(asset, 'description') ?? null
}
/**
* Safely extracts string base_model from asset metadata
* Checks user_metadata first, then metadata, then returns null
* @param asset - The asset to extract base_model from
* @returns The base_model string or null if not present/not a string
*/
export function getAssetBaseModel(asset: AssetItem): string | null {
return typeof asset.user_metadata?.base_model === 'string'
? asset.user_metadata.base_model
: null
return getStringProperty(asset, 'base_model') ?? null
}
/**
* Extracts base models as an array from asset metadata
* Checks user_metadata first, then metadata, then returns empty array
* @param asset - The asset to extract base models from
* @returns Array of base model strings
*/
export function getAssetBaseModels(asset: AssetItem): string[] {
const baseModel =
asset.user_metadata?.base_model ?? asset.metadata?.base_model
if (Array.isArray(baseModel)) {
return baseModel.filter((m): m is string => typeof m === 'string')
}
if (typeof baseModel === 'string' && baseModel) {
return [baseModel]
}
return []
}
/**
* Gets the display name for an asset
* Checks user_metadata.name first, then metadata.name, then asset.name
* @param asset - The asset to get display name from
* @returns The display name
*/
export function getAssetDisplayName(asset: AssetItem): string {
return getStringProperty(asset, 'name') ?? asset.name
}
/**
* Constructs source URL from asset's source_arn
* @param asset - The asset to extract source URL from
* @returns The source URL or null if not present/parseable
*/
export function getAssetSourceUrl(asset: AssetItem): string | null {
// Note: Reversed priority for backwards compatibility
const sourceArn =
asset.metadata?.source_arn ?? asset.user_metadata?.source_arn
if (typeof sourceArn !== 'string') return null
const civitaiMatch = sourceArn.match(
/^civitai:model:(\d+):version:(\d+)(?::file:\d+)?$/
)
if (civitaiMatch) {
const [, modelId, versionId] = civitaiMatch
return `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`
}
return null
}
/**
* Extracts trigger phrases from asset metadata
* Checks user_metadata first, then metadata, then returns empty array
* @param asset - The asset to extract trigger phrases from
* @returns Array of trigger phrases
*/
export function getAssetTriggerPhrases(asset: AssetItem): string[] {
const phrases =
asset.user_metadata?.trained_words ?? asset.metadata?.trained_words
if (Array.isArray(phrases)) {
return phrases.filter((p): p is string => typeof p === 'string')
}
if (typeof phrases === 'string') return [phrases]
return []
}
/**
* Extracts additional tags from asset user_metadata
* @param asset - The asset to extract tags from
* @returns Array of user-defined tags
*/
export function getAssetAdditionalTags(asset: AssetItem): string[] {
const tags = asset.user_metadata?.additional_tags
if (Array.isArray(tags)) {
return tags.filter((t): t is string => typeof t === 'string')
}
return []
}
/**
* Determines the source name from a URL
* @param url - The source URL
* @returns Human-readable source name
*/
export function getSourceName(url: string): string {
if (url.includes('civitai.com')) return 'Civitai'
if (url.includes('huggingface.co')) return 'Hugging Face'
return 'Source'
}
/**
* Extracts the model type from asset tags
* @param asset - The asset to extract model type from
* @returns The model type string or null if not present
*/
export function getAssetModelType(asset: AssetItem): string | null {
const typeTag = asset.tags?.find((tag) => tag && tag !== 'models')
if (!typeTag) return null
return typeTag.includes('/') ? (typeTag.split('/').pop() ?? null) : typeTag
}
/**
* Extracts user description from asset user_metadata
* @param asset - The asset to extract user description from
* @returns The user description string or empty string if not present
*/
export function getAssetUserDescription(asset: AssetItem): string {
return typeof asset.user_metadata?.user_description === 'string'
? asset.user_metadata.user_description
: ''
}

View File

@@ -1,5 +1,5 @@
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -19,12 +19,13 @@ export const useSessionCookie = () => {
const createSession = async (): Promise<void> => {
if (!isCloud) return
const { flags } = useFeatureFlags()
try {
const authStore = useFirebaseAuthStore()
let authHeader: Record<string, string>
if (remoteConfig.value.team_workspaces_enabled) {
if (flags.teamWorkspacesEnabled) {
const firebaseToken = await authStore.getIdToken()
if (!firebaseToken) {
console.warn(

View File

@@ -1,22 +1,22 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceSwitch } from '@/platform/auth/workspace/useWorkspaceSwitch'
import type { WorkspaceWithRole } from '@/platform/auth/workspace/workspaceTypes'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
const mockSwitchWorkspace = vi.hoisted(() => vi.fn())
const mockCurrentWorkspace = vi.hoisted(() => ({
const mockActiveWorkspace = vi.hoisted(() => ({
value: null as WorkspaceWithRole | null
}))
vi.mock('@/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
switchWorkspace: mockSwitchWorkspace
})
}))
vi.mock('pinia', () => ({
storeToRefs: () => ({
currentWorkspace: mockCurrentWorkspace
activeWorkspace: mockActiveWorkspace
})
}))
@@ -46,19 +46,16 @@ vi.mock('vue-i18n', () => ({
})
}))
const mockReload = vi.fn()
describe('useWorkspaceSwitch', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentWorkspace.value = {
mockActiveWorkspace.value = {
id: 'workspace-1',
name: 'Test Workspace',
type: 'personal',
role: 'owner'
}
mockModifiedWorkflows.length = 0
vi.stubGlobal('location', { reload: mockReload })
})
afterEach(() => {
@@ -109,7 +106,6 @@ describe('useWorkspaceSwitch', () => {
expect(result).toBe(true)
expect(mockConfirm).not.toHaveBeenCalled()
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
expect(mockReload).toHaveBeenCalled()
})
it('shows confirmation dialog when there are unsaved changes', async () => {
@@ -136,10 +132,9 @@ describe('useWorkspaceSwitch', () => {
expect(result).toBe(false)
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
expect(mockReload).not.toHaveBeenCalled()
})
it('calls switchWorkspace and reloads page after user confirms', async () => {
it('calls switchWorkspace after user confirms', async () => {
mockModifiedWorkflows.push({ isModified: true })
mockConfirm.mockResolvedValue(true)
mockSwitchWorkspace.mockResolvedValue(undefined)
@@ -149,7 +144,6 @@ describe('useWorkspaceSwitch', () => {
expect(result).toBe(true)
expect(mockSwitchWorkspace).toHaveBeenCalledWith('workspace-2')
expect(mockReload).toHaveBeenCalled()
})
it('returns false if switchWorkspace throws an error', async () => {
@@ -160,7 +154,6 @@ describe('useWorkspaceSwitch', () => {
const result = await switchWithConfirmation('workspace-2')
expect(result).toBe(false)
expect(mockReload).not.toHaveBeenCalled()
})
})
})

View File

@@ -2,13 +2,13 @@ import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
export function useWorkspaceSwitch() {
const { t } = useI18n()
const workspaceAuthStore = useWorkspaceAuthStore()
const { currentWorkspace } = storeToRefs(workspaceAuthStore)
const workspaceStore = useTeamWorkspaceStore()
const { activeWorkspace } = storeToRefs(workspaceStore)
const workflowStore = useWorkflowStore()
const dialogService = useDialogService()
@@ -17,7 +17,7 @@ export function useWorkspaceSwitch() {
}
async function switchWithConfirmation(workspaceId: string): Promise<boolean> {
if (currentWorkspace.value?.id === workspaceId) {
if (activeWorkspace.value?.id === workspaceId) {
return true
}
@@ -34,8 +34,8 @@ export function useWorkspaceSwitch() {
}
try {
await workspaceAuthStore.switchWorkspace(workspaceId)
window.location.reload()
await workspaceStore.switchWorkspace(workspaceId)
// Note: switchWorkspace triggers page reload internally
return true
} catch {
return false

View File

@@ -1,7 +1,10 @@
export const WORKSPACE_STORAGE_KEYS = {
// sessionStorage keys (cleared on browser close)
CURRENT_WORKSPACE: 'Comfy.Workspace.Current',
TOKEN: 'Comfy.Workspace.Token',
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt'
EXPIRES_AT: 'Comfy.Workspace.ExpiresAt',
// localStorage key (persists across browser sessions)
LAST_WORKSPACE_ID: 'Comfy.Workspace.LastWorkspaceId'
} as const
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000

View File

@@ -2,7 +2,7 @@
<Button
:size
:loading="isLoading"
:disabled="isPolling"
:disabled="disabled || isPolling"
variant="primary"
:style="
variant === 'gradient'
@@ -32,12 +32,14 @@ const {
size = 'lg',
fluid = true,
variant = 'default',
label
label,
disabled = false
} = defineProps<{
label?: string
size?: 'sm' | 'lg'
variant?: 'default' | 'gradient'
fluid?: boolean
disabled?: boolean
}>()
const emit = defineEmits<{

View File

@@ -17,208 +17,10 @@
</div>
</div>
<div class="grow overflow-auto">
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">{{
$t('subscription.perMonth')
}}</span>
</div>
<div
v-if="isActiveSubscription"
class="text-sm text-text-secondary"
>
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
date: formattedEndDate
})
}}
</template>
<template v-else>
{{
$t('subscription.renewsDate', {
date: formattedRenewalDate
})
}}
</template>
</div>
</div>
<Button
v-if="isActiveSubscription"
variant="secondary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="
async () => {
await authActions.accessBillingPortal()
}
"
>
{{ $t('subscription.manageSubscription') }}
</Button>
<Button
v-if="isActiveSubscription"
variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<SubscribeButton
v-else
:label="$t('subscription.subscribeNow')"
size="sm"
:fluid="false"
class="text-xs"
@subscribed="handleRefresh"
/>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
@click="handleRefresh"
>
<i class="pi pi-sync text-text-secondary text-sm" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton
v-if="isLoadingBalance"
width="8rem"
height="2rem"
/>
<div v-else class="text-2xl font-bold">
{{ totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{ includedCreditsDisplay }}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{ prepaidCredits }}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between">
<a
:href="usageHistoryUrl"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription"
variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</div>
<!-- Workspace mode: workspace-aware subscription content -->
<SubscriptionPanelContentWorkspace v-if="teamWorkspacesEnabled" />
<!-- Legacy mode: user-level subscription content -->
<SubscriptionPanelContentLegacy v-else />
<div
class="flex items-center justify-between border-t border-interface-stroke pt-3"
@@ -265,171 +67,32 @@
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import TabPanel from 'primevue/tabpanel'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { defineAsyncComponent } from 'vue'
import CloudBadge from '@/components/topbar/CloudBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import SubscriptionPanelContentLegacy from '@/platform/cloud/subscription/components/SubscriptionPanelContentLegacy.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import { cn } from '@/utils/tailwindUtil'
import { isCloud } from '@/platform/distribution/types'
const SubscriptionPanelContentWorkspace = defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/SubscriptionPanelContentWorkspace.vue')
)
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = isCloud && flags.teamWorkspacesEnabled
const { buildDocsUrl, docsPaths } = useExternalLink()
const authActions = useFirebaseAuthActions()
const { t, n } = useI18n()
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription,
handleInvoiceHistory
} = useSubscription()
const { isActiveSubscription, handleInvoiceHistory } = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const usageHistoryUrl = computed(
() => `${getComfyPlatformBaseUrl()}/profile/usage`
)
const refillsDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const date = new Date(subscriptionStatus.value.renewal_date)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t('subscription.creditsRemainingThisYear', {
date: refillsDate.value
})
: t('subscription.creditsRemainingThisMonth', {
date: refillsDate.value
})
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
key: 'maxDuration',
type: 'metric',
value: t(`subscription.maxDuration.${key}`),
label: t('subscription.maxDurationLabel')
},
{
key: 'gpu',
type: 'feature',
label: t('subscription.gpuLabel')
},
{
key: 'addCredits',
type: 'feature',
label: t('subscription.addCreditsLabel')
}
]
if (getTierFeatures(key).customLoRAs) {
benefits.push({
key: 'customLoRAs',
type: 'feature',
label: t('subscription.customLoRAsLabel')
})
}
return benefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const {
isLoadingSupport,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,
handleLearnMoreClick
} = useSubscriptionActions()
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
if (!timestampStr) return
const timestamp = parseInt(timestampStr, 10)
// Clear expired tracking (older than 5 minutes)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
// Refresh and clear tracking to prevent repeated calls
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
})
onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
})
const { isLoadingSupport, handleMessageSupport, handleLearnMoreClick } =
useSubscriptionActions()
const handleOpenPartnerNodesInfo = () => {
window.open(
@@ -438,9 +101,3 @@ const handleOpenPartnerNodesInfo = () => {
)
}
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
</style>

View File

@@ -0,0 +1,357 @@
<template>
<div class="grow overflow-auto">
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
</div>
<div
v-if="isActiveSubscription"
class="text-sm text-text-secondary"
>
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
date: formattedEndDate
})
}}
</template>
<template v-else>
{{
$t('subscription.renewsDate', {
date: formattedRenewalDate
})
}}
</template>
</div>
</div>
<Button
v-if="isActiveSubscription"
variant="secondary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="
async () => {
await authActions.accessBillingPortal()
}
"
>
{{ $t('subscription.manageSubscription') }}
</Button>
<Button
v-if="isActiveSubscription"
variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<SubscribeButton
v-if="!isActiveSubscription"
:label="$t('subscription.subscribeNow')"
size="sm"
:fluid="false"
class="text-xs"
@subscribed="handleRefresh"
/>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
@click="handleRefresh"
>
<i class="pi pi-sync text-text-secondary text-sm" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="text-2xl font-bold">
{{ totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{ includedCreditsDisplay }}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{ prepaidCredits }}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between">
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription"
variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeButton.vue'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const { t, n } = useI18n()
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription
} = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const tierKey = computed(() => {
const tier = subscriptionTier.value
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const refillsDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const date = new Date(subscriptionStatus.value.renewal_date)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t('subscription.creditsRemainingThisYear', {
date: refillsDate.value
})
: t('subscription.creditsRemainingThisMonth', {
date: refillsDate.value
})
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
key: 'maxDuration',
type: 'metric',
value: t(`subscription.maxDuration.${key}`),
label: t('subscription.maxDurationLabel')
},
{
key: 'gpu',
type: 'feature',
label: t('subscription.gpuLabel')
},
{
key: 'addCredits',
type: 'feature',
label: t('subscription.addCreditsLabel')
}
]
if (getTierFeatures(key).customLoRAs) {
benefits.push({
key: 'customLoRAs',
type: 'feature',
label: t('subscription.customLoRAsLabel')
})
}
return benefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
if (!timestampStr) return
const timestamp = parseInt(timestampStr, 10)
// Clear expired tracking (older than 5 minutes)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
// Refresh and clear tracking to prevent repeated calls
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
})
onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
})
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
</style>

View File

@@ -0,0 +1,435 @@
<template>
<div class="grow overflow-auto">
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div class="flex items-center justify-between gap-2">
<!-- OWNER Unsubscribed State -->
<template v-if="isOwnerUnsubscribed">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
</div>
<div class="text-sm text-text-secondary">
{{ $t('subscription.subscriptionRequiredMessage') }}
</div>
</div>
<Button
variant="primary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal"
@click="handleSubscribeWorkspace"
>
{{ $t('subscription.subscribeNow') }}
</Button>
</template>
<!-- MEMBER View - read-only, no subscription data yet -->
<template v-else-if="isMemberView">
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ $t('subscription.workspaceNotSubscribed') }}
</div>
<div class="text-sm text-text-secondary">
{{ $t('subscription.contactOwnerToSubscribe') }}
</div>
</div>
</template>
<!-- Normal Subscribed State (Owner with subscription) -->
<template v-else>
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-text-primary">
{{ subscriptionTierName }}
</div>
<div class="flex items-baseline gap-1 font-inter font-semibold">
<span class="text-2xl">${{ tierPrice }}</span>
<span class="text-base">{{ $t('subscription.perMonth') }}</span>
</div>
<div
v-if="isActiveSubscription"
class="text-sm text-text-secondary"
>
<template v-if="isCancelled">
{{
$t('subscription.expiresDate', {
date: formattedEndDate
})
}}
</template>
<template v-else>
{{
$t('subscription.renewsDate', {
date: formattedRenewalDate
})
}}
</template>
</div>
</div>
<template
v-if="isActiveSubscription && permissions.canManageSubscription"
>
<Button
variant="secondary"
class="ml-auto rounded-lg px-4 py-2 text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="
async () => {
await authActions.accessBillingPortal()
}
"
>
{{ $t('subscription.managePayment') }}
</Button>
<Button
variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
@click="planMenu?.toggle($event)"
>
<i class="pi pi-ellipsis-h" />
</Button>
<Menu ref="planMenu" :model="planMenuItems" :popup="true" />
</template>
</template>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-6 pt-9">
<div class="flex flex-col shrink-0">
<div class="flex flex-col gap-3">
<div
:class="
cn(
'relative flex flex-col gap-6 rounded-2xl p-5',
'bg-modal-panel-background'
)
"
>
<Button
variant="muted-textonly"
size="icon-sm"
class="absolute top-4 right-4"
:loading="isLoadingBalance"
@click="handleRefresh"
>
<i class="pi pi-sync text-text-secondary text-sm" />
</Button>
<div class="flex flex-col gap-2">
<div class="text-sm text-muted">
{{ $t('subscription.totalCredits') }}
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="text-2xl font-bold">
{{ showZeroState ? '0' : totalCredits }}
</div>
</div>
<!-- Credit Breakdown -->
<table class="text-sm text-muted">
<tbody>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="5rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0 / 0' : includedCreditsDisplay
}}</span>
</td>
<td class="align-middle" :title="creditsRemainingLabel">
{{ creditsRemainingLabel }}
</td>
</tr>
<tr>
<td class="pr-4 font-bold text-left align-middle">
<Skeleton
v-if="isLoadingBalance"
width="3rem"
height="1rem"
/>
<span v-else>{{
showZeroState ? '0' : prepaidCredits
}}</span>
</td>
<td
class="align-middle"
:title="$t('subscription.creditsYouveAdded')"
>
{{ $t('subscription.creditsYouveAdded') }}
</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between">
<a
href="https://platform.comfy.org/profile/usage"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline text-center text-muted"
>
{{ $t('subscription.viewUsageHistory') }}
</a>
<Button
v-if="isActiveSubscription && !showZeroState"
variant="secondary"
class="p-2 min-h-8 rounded-lg text-sm font-normal text-text-primary bg-interface-menu-component-surface-selected"
@click="handleAddApiCredits"
>
{{ $t('subscription.addCredits') }}
</Button>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-text-primary">
{{ $t('subscription.yourPlanIncludes') }}
</div>
<div class="flex flex-col gap-0">
<div
v-for="benefit in tierBenefits"
:key="benefit.key"
class="flex items-center gap-2 py-2"
>
<i
v-if="benefit.type === 'feature'"
class="pi pi-check text-xs text-text-primary"
/>
<span
v-else-if="benefit.type === 'metric' && benefit.value"
class="text-sm font-normal whitespace-nowrap text-text-primary"
>
{{ benefit.value }}
</span>
<span class="text-sm text-muted">
{{ benefit.label }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- View More Details - Outside main content -->
<div class="flex items-center gap-2 py-4">
<i class="pi pi-external-link text-muted"></i>
<a
href="https://www.comfy.org/cloud/pricing"
target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:opacity-80 text-muted"
>
{{ $t('subscription.viewMoreDetailsPlans') }}
</a>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Menu from 'primevue/menu'
import Skeleton from 'primevue/skeleton'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits,
getTierFeatures,
getTierPrice
} from '@/platform/cloud/subscription/constants/tierPricing'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@/utils/tailwindUtil'
const authActions = useFirebaseAuthActions()
const workspaceStore = useTeamWorkspaceStore()
const { isWorkspaceSubscribed } = storeToRefs(workspaceStore)
const { subscribeWorkspace } = workspaceStore
const { permissions, workspaceRole } = useWorkspaceUI()
const { t, n } = useI18n()
// OWNER with unsubscribed workspace - can see subscribe button
const isOwnerUnsubscribed = computed(
() => workspaceRole.value === 'owner' && !isWorkspaceSubscribed.value
)
// MEMBER view - members can't manage subscription, show read-only zero state
const isMemberView = computed(() => !permissions.value.canManageSubscription)
// Show zero state for credits (no real billing data yet)
const showZeroState = computed(
() => isOwnerUnsubscribed.value || isMemberView.value
)
// Demo: Subscribe workspace to PRO monthly plan
function handleSubscribeWorkspace() {
subscribeWorkspace('PRO_MONTHLY')
}
const {
isActiveSubscription,
isCancelled,
formattedRenewalDate,
formattedEndDate,
subscriptionTier,
subscriptionTierName,
subscriptionStatus,
isYearlySubscription
} = useSubscription()
const { show: showSubscriptionDialog } = useSubscriptionDialog()
const planMenu = ref<InstanceType<typeof Menu> | null>(null)
const planMenuItems = computed(() => [
{
label: t('subscription.cancelSubscription'),
icon: 'pi pi-times',
command: async () => {
await authActions.accessBillingPortal()
}
}
])
const tierKey = computed(() => {
const tier = subscriptionTier.value
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const tierPrice = computed(() =>
getTierPrice(tierKey.value, isYearlySubscription.value)
)
const refillsDate = computed(() => {
if (!subscriptionStatus.value?.renewal_date) return ''
const date = new Date(subscriptionStatus.value.renewal_date)
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear()).slice(-2)
return `${month}/${day}/${year}`
})
const creditsRemainingLabel = computed(() =>
isYearlySubscription.value
? t('subscription.creditsRemainingThisYear', {
date: refillsDate.value
})
: t('subscription.creditsRemainingThisMonth', {
date: refillsDate.value
})
)
const planTotalCredits = computed(() => {
const credits = getTierCredits(tierKey.value)
const total = isYearlySubscription.value ? credits * 12 : credits
return n(total)
})
const includedCreditsDisplay = computed(
() => `${monthlyBonusCredits.value} / ${planTotalCredits.value}`
)
// Tier benefits for v-for loop
type BenefitType = 'metric' | 'feature'
interface Benefit {
key: string
type: BenefitType
label: string
value?: string
}
const tierBenefits = computed((): Benefit[] => {
const key = tierKey.value
const benefits: Benefit[] = [
{
key: 'maxDuration',
type: 'metric',
value: t(`subscription.maxDuration.${key}`),
label: t('subscription.maxDurationLabel')
},
{
key: 'gpu',
type: 'feature',
label: t('subscription.gpuLabel')
},
{
key: 'addCredits',
type: 'feature',
label: t('subscription.addCreditsLabel')
}
]
if (getTierFeatures(key).customLoRAs) {
benefits.push({
key: 'customLoRAs',
type: 'feature',
label: t('subscription.customLoRAsLabel')
})
}
return benefits
})
const { totalCredits, monthlyBonusCredits, prepaidCredits, isLoadingBalance } =
useSubscriptionCredits()
const { handleAddApiCredits, handleRefresh } = useSubscriptionActions()
// Focus-based polling: refresh balance when user returns from Stripe checkout
const PENDING_TOPUP_KEY = 'pending_topup_timestamp'
const TOPUP_EXPIRY_MS = 5 * 60 * 1000 // 5 minutes
function handleWindowFocus() {
const timestampStr = localStorage.getItem(PENDING_TOPUP_KEY)
if (!timestampStr) return
const timestamp = parseInt(timestampStr, 10)
// Clear expired tracking (older than 5 minutes)
if (Date.now() - timestamp > TOPUP_EXPIRY_MS) {
localStorage.removeItem(PENDING_TOPUP_KEY)
return
}
// Refresh and clear tracking to prevent repeated calls
void handleRefresh()
localStorage.removeItem(PENDING_TOPUP_KEY)
}
onMounted(() => {
window.addEventListener('focus', handleWindowFocus)
})
onBeforeUnmount(() => {
window.removeEventListener('focus', handleWindowFocus)
})
</script>
<style scoped>
:deep(.bg-comfy-menu-secondary) {
background-color: transparent;
}
</style>

View File

@@ -64,7 +64,7 @@ vi.mock('@/services/dialogService', () => ({
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
getAuthHeader: mockGetAuthHeader
getFirebaseAuthHeader: mockGetAuthHeader
})),
FirebaseAuthStoreError: class extends Error {}
}))

View File

@@ -38,7 +38,7 @@ function useSubscriptionInternal() {
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
const { showSubscriptionRequiredDialog } = useDialogService()
const { getAuthHeader } = useFirebaseAuthStore()
const { getFirebaseAuthHeader } = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isLoggedIn } = useCurrentUser()
@@ -168,7 +168,7 @@ function useSubscriptionInternal() {
* @returns Subscription status or null if no subscription exists
*/
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
@@ -217,7 +217,7 @@ function useSubscriptionInternal() {
const initiateSubscriptionCheckout =
async (): Promise<CloudSubscriptionCheckoutResponse> => {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')

View File

@@ -1,6 +1,18 @@
<template>
<div class="settings-container">
<ScrollPanel class="settings-sidebar w-48 shrink-0 p-2 2xl:w-64">
<div
:class="
teamWorkspacesEnabled
? 'flex h-[80vh] w-full overflow-hidden'
: 'settings-container'
"
>
<ScrollPanel
:class="
teamWorkspacesEnabled
? 'w-48 shrink-0 p-2 2xl:w-64'
: 'settings-sidebar w-48 shrink-0 p-2 2xl:w-64'
"
>
<SearchBox
v-model:model-value="searchQuery"
class="settings-search-box mb-2 w-full"
@@ -20,16 +32,40 @@
(option: SettingTreeNode) =>
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
"
class="w-full border-none"
:class="
teamWorkspacesEnabled
? 'w-full border-none bg-transparent'
: 'w-full border-none'
"
>
<template #optiongroup>
<!-- Workspace mode: custom group headers -->
<template v-if="teamWorkspacesEnabled" #optiongroup="{ option }">
<h3 class="text-xs font-semibold uppercase text-muted m-0 pt-6 pb-2">
{{ option.translatedLabel ?? option.label }}
</h3>
</template>
<!-- Legacy mode: divider between groups -->
<template v-else #optiongroup>
<Divider class="my-0" />
</template>
<!-- Workspace mode: custom workspace item -->
<template v-if="teamWorkspacesEnabled" #option="{ option }">
<WorkspaceSidebarItem v-if="option.key === 'workspace'" />
<span v-else>{{ option.translatedLabel }}</span>
</template>
</Listbox>
</ScrollPanel>
<Divider layout="vertical" class="mx-1 hidden md:flex 2xl:mx-4" />
<Divider layout="horizontal" class="flex md:hidden" />
<Tabs :value="tabValue" :lazy="true" class="settings-content h-full w-full">
<Tabs
:value="tabValue"
:lazy="true"
:class="
teamWorkspacesEnabled
? 'h-full flex-1 overflow-x-auto'
: 'settings-content h-full w-full'
"
>
<TabPanels class="settings-tab-panels h-full w-full pr-0">
<PanelTemplate value="Search Results">
<SettingsPanel :setting-groups="searchResults" />
@@ -48,7 +84,7 @@
</PanelTemplate>
<Suspense v-for="panel in panels" :key="panel.node.key">
<component :is="panel.component" />
<component :is="panel.component" v-bind="panel.props" />
<template #fallback>
<div>{{ $t('g.loadingPanel', { panel: panel.node.label }) }}</div>
</template>
@@ -69,7 +105,10 @@ import { computed, watch } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
import PanelTemplate from '@/components/dialog/content/setting/PanelTemplate.vue'
import WorkspaceSidebarItem from '@/components/dialog/content/setting/WorkspaceSidebarItem.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
@@ -86,8 +125,15 @@ const { defaultPanel } = defineProps<{
| 'server-config'
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
}>()
const { flags } = useFeatureFlags()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const {
activeCategory,
defaultCategory,
@@ -162,6 +208,7 @@ watch(activeCategory, (_, oldValue) => {
</style>
<style scoped>
/* Legacy mode styles (when teamWorkspacesEnabled is false) */
.settings-container {
display: flex;
height: 70vh;
@@ -190,7 +237,7 @@ watch(activeCategory, (_, oldValue) => {
}
}
/* Hide the first group separator */
/* Hide the first group separator in legacy mode */
.settings-sidebar :deep(.p-listbox-option-group:nth-child(1)) {
display: none;
}

View File

@@ -3,19 +3,21 @@ import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
interface SettingPanelItem {
node: SettingTreeNode
component: Component
props?: Record<string, unknown>
}
export function useSettingUI(
@@ -27,15 +29,21 @@ export function useSettingUI(
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
) {
const { t } = useI18n()
const { isLoggedIn } = useCurrentUser()
const settingStore = useSettingStore()
const activeCategory = ref<SettingTreeNode | null>(null)
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription } = useSubscription()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
)
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
Object.values(settingStore.settingsById).filter(
@@ -64,6 +72,33 @@ export function useSettingUI(
() => settingRoot.value.children ?? []
)
// Core setting categories (built-in to ComfyUI) in display order
// 'Other' includes floating settings that don't have a specific category
const CORE_CATEGORIES_ORDER = [
'Comfy',
'LiteGraph',
'Appearance',
'3D',
'Mask Editor',
'Other'
]
const CORE_CATEGORIES = new Set(CORE_CATEGORIES_ORDER)
const coreSettingCategories = computed<SettingTreeNode[]>(() => {
const categories = settingCategories.value.filter((node) =>
CORE_CATEGORIES.has(node.label)
)
return categories.sort(
(a, b) =>
CORE_CATEGORIES_ORDER.indexOf(a.label) -
CORE_CATEGORIES_ORDER.indexOf(b.label)
)
})
const customNodeSettingCategories = computed<SettingTreeNode[]>(() =>
settingCategories.value.filter((node) => !CORE_CATEGORIES.has(node.label))
)
// Define panel items
const aboutPanel: SettingPanelItem = {
node: {
@@ -118,6 +153,22 @@ export function useSettingUI(
)
}
// Workspace panel: only available on cloud with team workspaces enabled
const workspacePanel: SettingPanelItem = {
node: {
key: 'workspace',
label: 'Workspace',
children: []
},
component: defineAsyncComponent(
() => import('@/components/dialog/content/setting/WorkspacePanel.vue')
)
}
const shouldShowWorkspacePanel = computed(
() => teamWorkspacesEnabled.value && isLoggedIn.value
)
const keybindingPanel: SettingPanelItem = {
node: {
key: 'keybinding',
@@ -156,13 +207,14 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
keybindingPanel,
extensionPanel,
...(isElectron() ? [serverConfigPanel] : []),
...(shouldShowPlanCreditsPanel.value && subscriptionPanel
? [subscriptionPanel]
: [])
].filter((panel) => panel.component)
].filter((panel) => panel !== null && panel.component)
)
/**
@@ -186,7 +238,47 @@ export function useSettingUI(
)
})
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Sidebar structure when team workspaces is enabled
const workspaceMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Workspace settings
translateCategory({
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])
].map(translateCategory)
}),
// General settings - Profile + all core settings + special panels
translateCategory({
key: 'general',
label: 'General',
children: [
translateCategory(userPanel.node),
...coreSettingCategories.value.map(translateCategory),
translateCategory(keybindingPanel.node),
translateCategory(extensionPanel.node),
translateCategory(aboutPanel.node),
...(isElectron() ? [translateCategory(serverConfigPanel.node)] : [])
]
}),
// Custom node settings (only shown if custom nodes have registered settings)
...(customNodeSettingCategories.value.length > 0
? [
translateCategory({
key: 'other',
label: 'Other',
children: customNodeSettingCategories.value.map(translateCategory)
})
]
: [])
])
// Sidebar structure when team workspaces is disabled (legacy)
const legacyMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Account settings - show different panels based on distribution and auth state
{
key: 'account',
@@ -223,6 +315,12 @@ export function useSettingUI(
}
])
const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() =>
teamWorkspacesEnabled.value
? workspaceMenuTreeNodes.value
: legacyMenuTreeNodes.value
)
onMounted(() => {
activeCategory.value = defaultCategory.value
})

View File

@@ -0,0 +1,335 @@
import axios from 'axios'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
export type WorkspaceType = 'personal' | 'team'
export type WorkspaceRole = 'owner' | 'member'
interface Workspace {
id: string
name: string
type: WorkspaceType
}
export interface WorkspaceWithRole extends Workspace {
role: WorkspaceRole
}
export interface Member {
id: string
name: string
email: string
joined_at: string
}
interface PaginationInfo {
offset: number
limit: number
total: number
}
interface ListMembersResponse {
members: Member[]
pagination: PaginationInfo
}
export interface ListMembersParams {
offset?: number
limit?: number
}
export interface PendingInvite {
id: string
email: string
token: string
invited_at: string
expires_at: string
}
interface ListInvitesResponse {
invites: PendingInvite[]
}
interface CreateInviteRequest {
email: string
}
interface AcceptInviteResponse {
workspace_id: string
workspace_name: string
}
interface BillingPortalRequest {
return_url: string
}
interface BillingPortalResponse {
billing_portal_url: string
}
interface CreateWorkspacePayload {
name: string
}
interface UpdateWorkspacePayload {
name: string
}
interface ListWorkspacesResponse {
workspaces: WorkspaceWithRole[]
}
class WorkspaceApiError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly code?: string
) {
super(message)
this.name = 'WorkspaceApiError'
}
}
const workspaceApiClient = axios.create({
headers: {
'Content-Type': 'application/json'
}
})
async function getAuthHeaderOrThrow() {
const authHeader = await useFirebaseAuthStore().getAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
return authHeader
}
function handleAxiosError(err: unknown): never {
if (axios.isAxiosError(err)) {
const status = err.response?.status
const message = err.response?.data?.message ?? err.message
throw new WorkspaceApiError(message, status)
}
throw err
}
export const workspaceApi = {
/**
* List all workspaces the user has access to
* GET /api/workspaces
*/
async list(): Promise<ListWorkspacesResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<ListWorkspacesResponse>(
api.apiURL('/workspaces'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Create a new workspace
* POST /api/workspaces
*/
async create(payload: CreateWorkspacePayload): Promise<WorkspaceWithRole> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<WorkspaceWithRole>(
api.apiURL('/workspaces'),
payload,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Update workspace name
* PATCH /api/workspaces/:id
*/
async update(
workspaceId: string,
payload: UpdateWorkspacePayload
): Promise<WorkspaceWithRole> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.patch<WorkspaceWithRole>(
api.apiURL(`/workspaces/${workspaceId}`),
payload,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Delete a workspace (owner only)
* DELETE /api/workspaces/:id
*/
async delete(workspaceId: string): Promise<void> {
const headers = await getAuthHeaderOrThrow()
try {
await workspaceApiClient.delete(
api.apiURL(`/workspaces/${workspaceId}`),
{
headers
}
)
} catch (err) {
handleAxiosError(err)
}
},
/**
* Leave the current workspace.
* POST /api/workspace/leave
*/
async leave(): Promise<void> {
const headers = await getAuthHeaderOrThrow()
try {
await workspaceApiClient.post(api.apiURL('/workspace/leave'), null, {
headers
})
} catch (err) {
handleAxiosError(err)
}
},
/**
* List workspace members (paginated).
* GET /api/workspace/members
*/
async listMembers(params?: ListMembersParams): Promise<ListMembersResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<ListMembersResponse>(
api.apiURL('/workspace/members'),
{ headers, params }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Remove a member from the workspace.
* DELETE /api/workspace/members/:userId
*/
async removeMember(userId: string): Promise<void> {
const headers = await getAuthHeaderOrThrow()
try {
await workspaceApiClient.delete(
api.apiURL(`/workspace/members/${userId}`),
{ headers }
)
} catch (err) {
handleAxiosError(err)
}
},
/**
* List pending invites for the workspace.
* GET /api/workspace/invites
*/
async listInvites(): Promise<ListInvitesResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.get<ListInvitesResponse>(
api.apiURL('/workspace/invites'),
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Create an invite for the workspace.
* POST /api/workspace/invites
*/
async createInvite(payload: CreateInviteRequest): Promise<PendingInvite> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<PendingInvite>(
api.apiURL('/workspace/invites'),
payload,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Revoke a pending invite.
* DELETE /api/workspace/invites/:inviteId
*/
async revokeInvite(inviteId: string): Promise<void> {
const headers = await getAuthHeaderOrThrow()
try {
await workspaceApiClient.delete(
api.apiURL(`/workspace/invites/${inviteId}`),
{ headers }
)
} catch (err) {
handleAxiosError(err)
}
},
/**
* Accept a workspace invite.
* POST /api/invites/:token/accept
*/
async acceptInvite(token: string): Promise<AcceptInviteResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<AcceptInviteResponse>(
api.apiURL(`/invites/${token}/accept`),
null,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
},
/**
* Access the billing portal for the current workspace.
* POST /api/billing/portal
*/
async accessBillingPortal(
returnUrl?: string
): Promise<BillingPortalResponse> {
const headers = await getAuthHeaderOrThrow()
try {
const response = await workspaceApiClient.post<BillingPortalResponse>(
api.apiURL('/billing/portal'),
{
return_url: returnUrl ?? window.location.href
} satisfies BillingPortalRequest,
{ headers }
)
return response.data
} catch (err) {
handleAxiosError(err)
}
}
}

View File

@@ -0,0 +1,125 @@
import { computed, ref } from 'vue'
import { createSharedComposable } from '@vueuse/core'
import type { WorkspaceRole, WorkspaceType } from '../api/workspaceApi'
import { useTeamWorkspaceStore } from '../stores/teamWorkspaceStore'
/** Permission flags for workspace actions */
interface WorkspacePermissions {
canLeaveWorkspace: boolean
canAccessWorkspaceMenu: boolean
canManageSubscription: boolean
}
/** UI configuration for workspace role */
interface WorkspaceUIConfig {
showEditWorkspaceMenuItem: boolean
workspaceMenuAction: 'leave' | 'delete' | null
workspaceMenuDisabledTooltip: string | null
}
function getPermissions(
type: WorkspaceType,
role: WorkspaceRole
): WorkspacePermissions {
if (type === 'personal') {
return {
canLeaveWorkspace: false,
canAccessWorkspaceMenu: false,
canManageSubscription: true
}
}
if (role === 'owner') {
return {
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true
}
}
// member role
return {
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false
}
}
function getUIConfig(
type: WorkspaceType,
role: WorkspaceRole
): WorkspaceUIConfig {
if (type === 'personal') {
return {
showEditWorkspaceMenuItem: false,
workspaceMenuAction: null,
workspaceMenuDisabledTooltip: null
}
}
if (role === 'owner') {
return {
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip:
'workspacePanel.menu.deleteWorkspaceDisabledTooltip'
}
}
// member role
return {
showEditWorkspaceMenuItem: false,
workspaceMenuAction: 'leave',
workspaceMenuDisabledTooltip: null
}
}
/**
* Internal implementation of UI configuration composable.
*/
function useWorkspaceUIInternal() {
const store = useTeamWorkspaceStore()
// Tab management (shared UI state)
const activeTab = ref<string>('plan')
function setActiveTab(tab: string | number) {
activeTab.value = String(tab)
}
const workspaceType = computed<WorkspaceType>(
() => store.activeWorkspace?.type ?? 'personal'
)
const workspaceRole = computed<WorkspaceRole>(
() => store.activeWorkspace?.role ?? 'owner'
)
const permissions = computed<WorkspacePermissions>(() =>
getPermissions(workspaceType.value, workspaceRole.value)
)
const uiConfig = computed<WorkspaceUIConfig>(() =>
getUIConfig(workspaceType.value, workspaceRole.value)
)
return {
// Tab management
activeTab: computed(() => activeTab.value),
setActiveTab,
// Permissions and config
permissions,
uiConfig,
workspaceType,
workspaceRole
}
}
/**
* UI configuration composable derived from workspace state.
* Controls what UI elements are visible/enabled based on role and workspace type.
* Uses createSharedComposable to ensure tab state is shared across components.
*/
export const useWorkspaceUI = createSharedComposable(useWorkspaceUIInternal)

View File

@@ -0,0 +1,917 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useTeamWorkspaceStore } from './teamWorkspaceStore'
// Mock workspaceAuthStore
const mockWorkspaceAuthStore = vi.hoisted(() => ({
currentWorkspace: null as {
id: string
name: string
type: 'personal' | 'team'
role: 'owner' | 'member'
} | null,
workspaceToken: null as string | null,
isLoading: false,
error: null as Error | null,
isAuthenticated: false,
init: vi.fn(),
destroy: vi.fn(),
initializeFromSession: vi.fn(),
switchWorkspace: vi.fn(),
refreshToken: vi.fn(),
getWorkspaceAuthHeader: vi.fn(),
clearWorkspaceContext: vi.fn()
}))
vi.mock('@/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => mockWorkspaceAuthStore
}))
// Mock workspaceApi
const mockWorkspaceApi = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
leave: vi.fn(),
listMembers: vi.fn(),
removeMember: vi.fn(),
listInvites: vi.fn(),
createInvite: vi.fn(),
revokeInvite: vi.fn(),
acceptInvite: vi.fn(),
accessBillingPortal: vi.fn()
}))
const mockWorkspaceApiError = vi.hoisted(
() =>
class WorkspaceApiError extends Error {
constructor(
message: string,
public readonly status?: number,
public readonly code?: string
) {
super(message)
this.name = 'WorkspaceApiError'
}
}
)
vi.mock('../api/workspaceApi', () => ({
workspaceApi: mockWorkspaceApi,
WorkspaceApiError: mockWorkspaceApiError
}))
// Mock localStorage
const mockLocalStorage = vi.hoisted(() => {
const store: Record<string, string> = {}
return {
getItem: vi.fn((_key: string): string | null => store[_key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value
}),
removeItem: vi.fn((key: string) => {
delete store[key]
}),
clear: vi.fn(() => {
Object.keys(store).forEach((key) => delete store[key])
})
}
})
// Mock window.location.reload
const mockReload = vi.fn()
Object.defineProperty(window, 'location', {
value: { reload: mockReload, origin: 'http://localhost' },
writable: true
})
// Test data
const mockPersonalWorkspace = {
id: 'ws-personal-123',
name: 'Personal',
type: 'personal' as const,
role: 'owner' as const
}
const mockTeamWorkspace = {
id: 'ws-team-456',
name: 'Team Alpha',
type: 'team' as const,
role: 'owner' as const
}
const mockMemberWorkspace = {
id: 'ws-team-789',
name: 'Team Beta',
type: 'team' as const,
role: 'member' as const
}
describe('useTeamWorkspaceStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.stubGlobal('localStorage', mockLocalStorage)
sessionStorage.clear()
// Reset workspaceAuthStore mock state
mockWorkspaceAuthStore.currentWorkspace = null
mockWorkspaceAuthStore.workspaceToken = null
mockWorkspaceAuthStore.isLoading = false
mockWorkspaceAuthStore.error = null
mockWorkspaceAuthStore.isAuthenticated = false
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(false)
mockWorkspaceAuthStore.switchWorkspace.mockResolvedValue(undefined)
// Default mock responses
mockWorkspaceApi.list.mockResolvedValue({
workspaces: [mockPersonalWorkspace, mockTeamWorkspace]
})
mockLocalStorage.getItem.mockReturnValue(null)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('initial state', () => {
it('has correct initial state values', () => {
const store = useTeamWorkspaceStore()
expect(store.initState).toBe('uninitialized')
expect(store.workspaces).toEqual([])
expect(store.activeWorkspaceId).toBeNull()
expect(store.error).toBeNull()
expect(store.isCreating).toBe(false)
expect(store.isDeleting).toBe(false)
expect(store.isSwitching).toBe(false)
expect(store.isFetchingWorkspaces).toBe(false)
})
it('computed properties return correct defaults', () => {
const store = useTeamWorkspaceStore()
expect(store.activeWorkspace).toBeNull()
expect(store.personalWorkspace).toBeNull()
expect(store.isInPersonalWorkspace).toBe(false)
expect(store.sharedWorkspaces).toEqual([])
expect(store.ownedWorkspacesCount).toBe(0)
expect(store.canCreateWorkspace).toBe(true)
expect(store.members).toEqual([])
expect(store.pendingInvites).toEqual([])
})
})
describe('initialize', () => {
it('fetches workspaces and sets active workspace to personal by default', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
expect(mockWorkspaceApi.list).toHaveBeenCalledTimes(1)
expect(store.initState).toBe('ready')
expect(store.workspaces).toHaveLength(2)
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
expect(mockWorkspaceAuthStore.switchWorkspace).toHaveBeenCalledWith(
mockPersonalWorkspace.id
)
})
it('restores workspace from session if valid', async () => {
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.activeWorkspaceId).toBe(mockTeamWorkspace.id)
expect(mockWorkspaceAuthStore.switchWorkspace).not.toHaveBeenCalled()
})
it('falls back to localStorage if no session', async () => {
mockLocalStorage.getItem.mockReturnValue(mockTeamWorkspace.id)
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.activeWorkspaceId).toBe(mockTeamWorkspace.id)
})
it('falls back to personal if stored workspace not in list', async () => {
mockLocalStorage.getItem.mockReturnValue('non-existent-workspace')
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
})
it('sets error state when workspaces fetch fails after retries', async () => {
vi.useFakeTimers()
mockWorkspaceApi.list.mockRejectedValue(new Error('Network error'))
const store = useTeamWorkspaceStore()
// Start initialization and catch rejections to prevent unhandled promise warning
let initError: unknown = null
const initPromise = store.initialize().catch((e: unknown) => {
initError = e
})
// Fast-forward through all retry delays (1s, 2s, 4s)
await vi.advanceTimersByTimeAsync(1000)
await vi.advanceTimersByTimeAsync(2000)
await vi.advanceTimersByTimeAsync(4000)
await initPromise
expect(initError).toBeInstanceOf(Error)
expect((initError as Error).message).toBe('Network error')
expect(store.initState).toBe('error')
expect(store.error).toBeInstanceOf(Error)
// Should have been called 4 times (initial + 3 retries)
expect(mockWorkspaceApi.list).toHaveBeenCalledTimes(4)
vi.useRealTimers()
})
it('does not reinitialize if already initialized', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
await store.initialize()
expect(mockWorkspaceApi.list).toHaveBeenCalledTimes(1)
})
it('throws when no workspaces available', async () => {
mockWorkspaceApi.list.mockResolvedValue({ workspaces: [] })
const store = useTeamWorkspaceStore()
await expect(store.initialize()).rejects.toThrow(
'No workspaces available'
)
expect(store.initState).toBe('error')
})
it('continues initialization even if token exchange fails', async () => {
mockWorkspaceAuthStore.switchWorkspace.mockRejectedValue(
new Error('Token exchange failed')
)
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.initState).toBe('ready')
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
})
})
describe('switchWorkspace', () => {
it('does nothing if switching to current workspace', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
const currentId = store.activeWorkspaceId
await store.switchWorkspace(currentId!)
expect(mockReload).not.toHaveBeenCalled()
})
it('clears context and reloads for valid workspace', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
await store.switchWorkspace(mockTeamWorkspace.id)
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
expect(mockReload).toHaveBeenCalled()
})
it('sets isSwitching flag during operation', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.isSwitching).toBe(false)
const switchPromise = store.switchWorkspace(mockTeamWorkspace.id)
expect(store.isSwitching).toBe(true)
await switchPromise
})
it('refreshes workspace list if target not found', async () => {
const newWorkspace = {
id: 'ws-new-999',
name: 'New Workspace',
type: 'team' as const,
role: 'member' as const
}
mockWorkspaceApi.list
.mockResolvedValueOnce({
workspaces: [mockPersonalWorkspace, mockTeamWorkspace]
})
.mockResolvedValueOnce({
workspaces: [mockPersonalWorkspace, mockTeamWorkspace, newWorkspace]
})
const store = useTeamWorkspaceStore()
await store.initialize()
await store.switchWorkspace(newWorkspace.id)
expect(mockWorkspaceApi.list).toHaveBeenCalledTimes(2)
expect(mockReload).toHaveBeenCalled()
})
it('throws if workspace not found after refresh', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
await expect(
store.switchWorkspace('non-existent-workspace')
).rejects.toThrow('Workspace not found or access denied')
expect(store.isSwitching).toBe(false)
})
})
describe('createWorkspace', () => {
it('creates workspace and triggers reload', async () => {
const newWorkspace = {
id: 'ws-new-created',
name: 'Created Workspace',
type: 'team' as const,
role: 'owner' as const
}
mockWorkspaceApi.create.mockResolvedValue(newWorkspace)
const store = useTeamWorkspaceStore()
await store.initialize()
const result = await store.createWorkspace('Created Workspace')
expect(mockWorkspaceApi.create).toHaveBeenCalledWith({
name: 'Created Workspace'
})
expect(result.id).toBe(newWorkspace.id)
expect(store.workspaces).toContainEqual(
expect.objectContaining({ id: newWorkspace.id })
)
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
expect(mockReload).toHaveBeenCalled()
})
it('sets isCreating flag during operation', async () => {
let resolveCreate: (value: unknown) => void
const createPromise = new Promise((resolve) => {
resolveCreate = resolve
})
mockWorkspaceApi.create.mockReturnValue(createPromise)
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.isCreating).toBe(false)
const resultPromise = store.createWorkspace('New Workspace')
expect(store.isCreating).toBe(true)
resolveCreate!({
id: 'ws-new',
name: 'New Workspace',
type: 'team',
role: 'owner'
})
await resultPromise
})
it('resets isCreating on error', async () => {
mockWorkspaceApi.create.mockRejectedValue(new Error('Creation failed'))
const store = useTeamWorkspaceStore()
await store.initialize()
await expect(store.createWorkspace('New Workspace')).rejects.toThrow(
'Creation failed'
)
expect(store.isCreating).toBe(false)
})
})
describe('deleteWorkspace', () => {
it('deletes non-active workspace without reload', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.activeWorkspaceId).toBe(mockPersonalWorkspace.id)
await store.deleteWorkspace(mockTeamWorkspace.id)
expect(mockWorkspaceApi.delete).toHaveBeenCalledWith(mockTeamWorkspace.id)
expect(store.workspaces).not.toContainEqual(
expect.objectContaining({ id: mockTeamWorkspace.id })
)
expect(mockReload).not.toHaveBeenCalled()
})
it('deletes active workspace and reloads to personal', async () => {
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.activeWorkspaceId).toBe(mockTeamWorkspace.id)
await store.deleteWorkspace()
expect(mockWorkspaceApi.delete).toHaveBeenCalledWith(mockTeamWorkspace.id)
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
expect(mockReload).toHaveBeenCalled()
})
it('throws when trying to delete personal workspace', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
await expect(
store.deleteWorkspace(mockPersonalWorkspace.id)
).rejects.toThrow('Cannot delete personal workspace')
})
it('throws when workspace not found', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
await expect(store.deleteWorkspace('non-existent')).rejects.toThrow(
'Workspace not found'
)
})
})
describe('renameWorkspace', () => {
it('updates workspace name locally', async () => {
mockWorkspaceApi.update.mockResolvedValue({
...mockTeamWorkspace,
name: 'Renamed Workspace'
})
const store = useTeamWorkspaceStore()
await store.initialize()
await store.renameWorkspace(mockTeamWorkspace.id, 'Renamed Workspace')
expect(mockWorkspaceApi.update).toHaveBeenCalledWith(
mockTeamWorkspace.id,
{ name: 'Renamed Workspace' }
)
const updated = store.workspaces.find(
(w) => w.id === mockTeamWorkspace.id
)
expect(updated?.name).toBe('Renamed Workspace')
})
})
describe('leaveWorkspace', () => {
it('leaves workspace and reloads to personal', async () => {
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockMemberWorkspace
mockWorkspaceApi.list.mockResolvedValue({
workspaces: [mockPersonalWorkspace, mockMemberWorkspace]
})
const store = useTeamWorkspaceStore()
await store.initialize()
await store.leaveWorkspace()
expect(mockWorkspaceApi.leave).toHaveBeenCalled()
expect(mockWorkspaceAuthStore.clearWorkspaceContext).toHaveBeenCalled()
expect(mockReload).toHaveBeenCalled()
})
it('throws when trying to leave personal workspace', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
await expect(store.leaveWorkspace()).rejects.toThrow(
'Cannot leave personal workspace'
)
})
})
describe('computed properties', () => {
it('activeWorkspace returns correct workspace', async () => {
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.activeWorkspace?.id).toBe(mockTeamWorkspace.id)
expect(store.activeWorkspace?.name).toBe(mockTeamWorkspace.name)
})
it('personalWorkspace returns personal workspace', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.personalWorkspace?.id).toBe(mockPersonalWorkspace.id)
expect(store.personalWorkspace?.type).toBe('personal')
})
it('isInPersonalWorkspace returns true when in personal', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.isInPersonalWorkspace).toBe(true)
})
it('isInPersonalWorkspace returns false when in team', async () => {
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.isInPersonalWorkspace).toBe(false)
})
it('sharedWorkspaces excludes personal workspace', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.sharedWorkspaces).toHaveLength(1)
expect(store.sharedWorkspaces[0].id).toBe(mockTeamWorkspace.id)
})
it('ownedWorkspacesCount counts owned workspaces', async () => {
mockWorkspaceApi.list.mockResolvedValue({
workspaces: [
mockPersonalWorkspace,
mockTeamWorkspace,
mockMemberWorkspace
]
})
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.ownedWorkspacesCount).toBe(2)
})
it('canCreateWorkspace respects limit', async () => {
const manyWorkspaces = Array.from({ length: 10 }, (_, i) => ({
id: `ws-owned-${i}`,
name: `Owned ${i}`,
type: 'team' as const,
role: 'owner' as const
}))
mockWorkspaceApi.list.mockResolvedValue({
workspaces: [mockPersonalWorkspace, ...manyWorkspaces]
})
const store = useTeamWorkspaceStore()
await store.initialize()
expect(store.ownedWorkspacesCount).toBe(11)
expect(store.canCreateWorkspace).toBe(false)
})
})
describe('member actions', () => {
it('fetchMembers updates active workspace members', async () => {
const mockMembers = [
{
id: 'user-1',
name: 'User One',
email: 'one@test.com',
joined_at: '2024-01-01T00:00:00Z'
},
{
id: 'user-2',
name: 'User Two',
email: 'two@test.com',
joined_at: '2024-01-02T00:00:00Z'
}
]
mockWorkspaceApi.listMembers.mockResolvedValue({
members: mockMembers,
pagination: { offset: 0, limit: 50, total: 2 }
})
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
const result = await store.fetchMembers()
expect(result).toHaveLength(2)
expect(store.members).toHaveLength(2)
expect(store.members[0].name).toBe('User One')
})
it('fetchMembers returns empty for personal workspace', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
const result = await store.fetchMembers()
expect(result).toEqual([])
expect(mockWorkspaceApi.listMembers).not.toHaveBeenCalled()
})
it('removeMember removes from local list', async () => {
const mockMembers = [
{
id: 'user-1',
name: 'User One',
email: 'one@test.com',
joined_at: '2024-01-01T00:00:00Z'
},
{
id: 'user-2',
name: 'User Two',
email: 'two@test.com',
joined_at: '2024-01-02T00:00:00Z'
}
]
mockWorkspaceApi.listMembers.mockResolvedValue({
members: mockMembers,
pagination: { offset: 0, limit: 50, total: 2 }
})
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
await store.fetchMembers()
expect(store.members).toHaveLength(2)
await store.removeMember('user-1')
expect(mockWorkspaceApi.removeMember).toHaveBeenCalledWith('user-1')
expect(store.members).toHaveLength(1)
expect(store.members[0].id).toBe('user-2')
})
})
describe('invite actions', () => {
it('fetchPendingInvites updates active workspace invites', async () => {
const mockInvites = [
{
id: 'inv-1',
email: 'invite@test.com',
token: 'token-abc',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
}
]
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
const result = await store.fetchPendingInvites()
expect(result).toHaveLength(1)
expect(store.pendingInvites).toHaveLength(1)
expect(store.pendingInvites[0].email).toBe('invite@test.com')
})
it('createInvite adds to local list', async () => {
const newInvite = {
id: 'inv-new',
email: 'new@test.com',
token: 'token-new',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
}
mockWorkspaceApi.createInvite.mockResolvedValue(newInvite)
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
const result = await store.createInvite('new@test.com')
expect(mockWorkspaceApi.createInvite).toHaveBeenCalledWith({
email: 'new@test.com'
})
expect(result.email).toBe('new@test.com')
expect(store.pendingInvites).toContainEqual(
expect.objectContaining({ email: 'new@test.com' })
)
})
it('revokeInvite removes from local list', async () => {
const mockInvites = [
{
id: 'inv-1',
email: 'one@test.com',
token: 'token-1',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
},
{
id: 'inv-2',
email: 'two@test.com',
token: 'token-2',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
}
]
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
await store.fetchPendingInvites()
await store.revokeInvite('inv-1')
expect(mockWorkspaceApi.revokeInvite).toHaveBeenCalledWith('inv-1')
expect(store.pendingInvites).toHaveLength(1)
expect(store.pendingInvites[0].id).toBe('inv-2')
})
it('acceptInvite refreshes workspace list', async () => {
mockWorkspaceApi.acceptInvite.mockResolvedValue({
workspace_id: 'ws-joined',
workspace_name: 'Joined Workspace'
})
const store = useTeamWorkspaceStore()
await store.initialize()
const result = await store.acceptInvite('invite-token')
expect(mockWorkspaceApi.acceptInvite).toHaveBeenCalledWith('invite-token')
expect(result.workspaceId).toBe('ws-joined')
expect(result.workspaceName).toBe('Joined Workspace')
expect(mockWorkspaceApi.list).toHaveBeenCalledTimes(2)
})
})
describe('invite link helpers', () => {
it('getInviteLink returns link for existing invite', async () => {
const mockInvites = [
{
id: 'inv-1',
email: 'test@test.com',
token: 'secret-token',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
}
]
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
await store.fetchPendingInvites()
const link = store.getInviteLink('inv-1')
expect(link).toContain('?invite=secret-token')
})
it('getInviteLink returns null for non-existent invite', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
const link = store.getInviteLink('non-existent')
expect(link).toBeNull()
})
it('createInviteLink creates invite and returns link', async () => {
const newInvite = {
id: 'inv-new',
email: 'new@test.com',
token: 'new-token',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
}
mockWorkspaceApi.createInvite.mockResolvedValue(newInvite)
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
const link = await store.createInviteLink('new@test.com')
expect(link).toContain('?invite=new-token')
})
})
describe('cleanup', () => {
it('destroy calls workspaceAuthStore.destroy', async () => {
const store = useTeamWorkspaceStore()
await store.initialize()
store.destroy()
expect(mockWorkspaceAuthStore.destroy).toHaveBeenCalled()
})
})
describe('totalMemberSlots and isInviteLimitReached', () => {
it('calculates total slots from members and invites', async () => {
const mockMembers = [
{
id: 'user-1',
name: 'User One',
email: 'one@test.com',
joined_at: '2024-01-01T00:00:00Z'
}
]
const mockInvites = [
{
id: 'inv-1',
email: 'invite@test.com',
token: 'token-1',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
},
{
id: 'inv-2',
email: 'invite2@test.com',
token: 'token-2',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
}
]
mockWorkspaceApi.listMembers.mockResolvedValue({
members: mockMembers,
pagination: { offset: 0, limit: 50, total: 1 }
})
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
await store.fetchMembers()
await store.fetchPendingInvites()
expect(store.totalMemberSlots).toBe(3)
expect(store.isInviteLimitReached).toBe(false)
})
it('isInviteLimitReached returns true at 50 slots', async () => {
const mockMembers = Array.from({ length: 48 }, (_, i) => ({
id: `user-${i}`,
name: `User ${i}`,
email: `user${i}@test.com`,
joined_at: '2024-01-01T00:00:00Z'
}))
const mockInvites = [
{
id: 'inv-1',
email: 'invite1@test.com',
token: 'token-1',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
},
{
id: 'inv-2',
email: 'invite2@test.com',
token: 'token-2',
invited_at: '2024-01-01T00:00:00Z',
expires_at: '2024-01-08T00:00:00Z'
}
]
mockWorkspaceApi.listMembers.mockResolvedValue({
members: mockMembers,
pagination: { offset: 0, limit: 50, total: 48 }
})
mockWorkspaceApi.listInvites.mockResolvedValue({ invites: mockInvites })
mockWorkspaceAuthStore.initializeFromSession.mockReturnValue(true)
mockWorkspaceAuthStore.currentWorkspace = mockTeamWorkspace
const store = useTeamWorkspaceStore()
await store.initialize()
await store.fetchMembers()
await store.fetchPendingInvites()
expect(store.totalMemberSlots).toBe(50)
expect(store.isInviteLimitReached).toBe(true)
})
})
})

View File

@@ -0,0 +1,668 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
import { useWorkspaceAuthStore } from '@/stores/workspaceAuthStore'
import type {
ListMembersParams,
Member,
PendingInvite as ApiPendingInvite,
WorkspaceWithRole
} from '../api/workspaceApi'
import { workspaceApi } from '../api/workspaceApi'
interface WorkspaceMember {
id: string
name: string
email: string
joinDate: Date
}
interface PendingInvite {
id: string
email: string
token: string
inviteDate: Date
expiryDate: Date
}
type SubscriptionPlan = 'PRO_MONTHLY' | 'PRO_YEARLY' | null
interface WorkspaceState extends WorkspaceWithRole {
isSubscribed: boolean
subscriptionPlan: SubscriptionPlan
members: WorkspaceMember[]
pendingInvites: PendingInvite[]
}
type InitState = 'uninitialized' | 'loading' | 'ready' | 'error'
function mapApiMemberToWorkspaceMember(member: Member): WorkspaceMember {
return {
id: member.id,
name: member.name,
email: member.email,
joinDate: new Date(member.joined_at)
}
}
function mapApiInviteToPendingInvite(invite: ApiPendingInvite): PendingInvite {
return {
id: invite.id,
email: invite.email,
token: invite.token,
inviteDate: new Date(invite.invited_at),
expiryDate: new Date(invite.expires_at)
}
}
function createWorkspaceState(workspace: WorkspaceWithRole): WorkspaceState {
return {
...workspace,
isSubscribed: false,
subscriptionPlan: null,
members: [],
pendingInvites: []
}
}
function getLastWorkspaceId(): string | null {
try {
return localStorage.getItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID)
} catch {
return null
}
}
function setLastWorkspaceId(workspaceId: string): void {
try {
localStorage.setItem(WORKSPACE_STORAGE_KEYS.LAST_WORKSPACE_ID, workspaceId)
} catch {
console.warn('Failed to persist last workspace ID to localStorage')
}
}
const MAX_OWNED_WORKSPACES = 10
const MAX_WORKSPACE_MEMBERS = 50
const MAX_INIT_RETRIES = 3
const BASE_RETRY_DELAY_MS = 1000
export const useTeamWorkspaceStore = defineStore('teamWorkspace', () => {
const initState = ref<InitState>('uninitialized')
const workspaces = shallowRef<WorkspaceState[]>([])
const activeWorkspaceId = ref<string | null>(null)
const error = ref<Error | null>(null)
const isCreating = ref(false)
const isDeleting = ref(false)
const isSwitching = ref(false)
const isFetchingWorkspaces = ref(false)
const activeWorkspace = computed(
() => workspaces.value.find((w) => w.id === activeWorkspaceId.value) ?? null
)
const personalWorkspace = computed(
() => workspaces.value.find((w) => w.type === 'personal') ?? null
)
const isInPersonalWorkspace = computed(
() => activeWorkspace.value?.type === 'personal'
)
const sharedWorkspaces = computed(() =>
workspaces.value.filter((w) => w.type !== 'personal')
)
const ownedWorkspacesCount = computed(
() => workspaces.value.filter((w) => w.role === 'owner').length
)
const canCreateWorkspace = computed(
() => ownedWorkspacesCount.value < MAX_OWNED_WORKSPACES
)
const members = computed<WorkspaceMember[]>(
() => activeWorkspace.value?.members ?? []
)
const pendingInvites = computed<PendingInvite[]>(
() => activeWorkspace.value?.pendingInvites ?? []
)
const totalMemberSlots = computed(
() => members.value.length + pendingInvites.value.length
)
const isInviteLimitReached = computed(
() => totalMemberSlots.value >= MAX_WORKSPACE_MEMBERS
)
const workspaceId = computed(() => activeWorkspace.value?.id ?? null)
const workspaceName = computed(() => activeWorkspace.value?.name ?? '')
const isWorkspaceSubscribed = computed(
() => activeWorkspace.value?.isSubscribed ?? false
)
const subscriptionPlan = computed(
() => activeWorkspace.value?.subscriptionPlan ?? null
)
function updateWorkspace(
workspaceId: string,
updates: Partial<WorkspaceState>
) {
const index = workspaces.value.findIndex((w) => w.id === workspaceId)
if (index === -1) return
const current = workspaces.value[index]
const updated = { ...current, ...updates }
workspaces.value = [
...workspaces.value.slice(0, index),
updated,
...workspaces.value.slice(index + 1)
]
}
function updateActiveWorkspace(updates: Partial<WorkspaceState>) {
if (!activeWorkspaceId.value) return
updateWorkspace(activeWorkspaceId.value, updates)
}
/**
* Initialize the workspace store.
* Fetches workspaces and resolves the active workspace from session/localStorage.
* Delegates token management to workspaceAuthStore.
* Retries on transient failures with exponential backoff.
* Call once on app boot.
*/
async function initialize(): Promise<void> {
if (initState.value !== 'uninitialized') return
initState.value = 'loading'
isFetchingWorkspaces.value = true
error.value = null
const workspaceAuthStore = useWorkspaceAuthStore()
for (let attempt = 0; attempt <= MAX_INIT_RETRIES; attempt++) {
try {
// 1. Try to restore workspace context from session (page refresh case)
const hasValidSession = workspaceAuthStore.initializeFromSession()
if (hasValidSession && workspaceAuthStore.currentWorkspace) {
// Valid session exists - fetch workspace list and verify access
const response = await workspaceApi.list()
workspaces.value = response.workspaces.map(createWorkspaceState)
if (workspaces.value.length === 0) {
throw new Error('No workspaces available')
}
// Verify session workspace exists in fetched list
const sessionWorkspaceId = workspaceAuthStore.currentWorkspace.id
const sessionWorkspaceExists = workspaces.value.some(
(w) => w.id === sessionWorkspaceId
)
if (sessionWorkspaceExists) {
activeWorkspaceId.value = sessionWorkspaceId
initState.value = 'ready'
isFetchingWorkspaces.value = false
return
}
// Session workspace not found (deleted/access revoked) - fallback to default
workspaceAuthStore.clearWorkspaceContext()
const personal = workspaces.value.find((w) => w.type === 'personal')
const fallbackWorkspaceId = personal?.id ?? workspaces.value[0].id
try {
await workspaceAuthStore.switchWorkspace(fallbackWorkspaceId)
} catch {
console.error(
'[teamWorkspaceStore] Token exchange failed during fallback'
)
}
activeWorkspaceId.value = fallbackWorkspaceId
setLastWorkspaceId(fallbackWorkspaceId)
initState.value = 'ready'
isFetchingWorkspaces.value = false
return
}
// 2. No valid session - fetch workspaces and pick default
const response = await workspaceApi.list()
workspaces.value = response.workspaces.map(createWorkspaceState)
if (workspaces.value.length === 0) {
throw new Error('No workspaces available')
}
// 3. Determine target workspace (priority: localStorage > personal)
let targetWorkspaceId: string | null = null
const lastId = getLastWorkspaceId()
if (lastId && workspaces.value.some((w) => w.id === lastId)) {
targetWorkspaceId = lastId
}
if (!targetWorkspaceId) {
const personal = workspaces.value.find((w) => w.type === 'personal')
targetWorkspaceId = personal?.id ?? workspaces.value[0].id
}
// 4. Exchange Firebase token for workspace token
try {
await workspaceAuthStore.switchWorkspace(targetWorkspaceId)
} catch {
// Log but don't fail initialization - API calls will fall back to Firebase token
console.error(
'[teamWorkspaceStore] Token exchange failed during init'
)
}
// 5. Set active workspace
activeWorkspaceId.value = targetWorkspaceId
setLastWorkspaceId(targetWorkspaceId)
initState.value = 'ready'
isFetchingWorkspaces.value = false
return
} catch (e) {
const isNoWorkspacesError =
e instanceof Error && e.message === 'No workspaces available'
// Don't retry on permanent errors (no workspaces available)
if (isNoWorkspacesError || attempt >= MAX_INIT_RETRIES) {
error.value = e instanceof Error ? e : new Error('Unknown error')
initState.value = 'error'
isFetchingWorkspaces.value = false
throw e
}
// Retry with exponential backoff for transient errors
const delay = BASE_RETRY_DELAY_MS * Math.pow(2, attempt)
const errorMessage = e instanceof Error ? e.message : String(e)
console.warn(
`[teamWorkspaceStore] Init failed (attempt ${attempt + 1}/${MAX_INIT_RETRIES + 1}), retrying in ${delay}ms: ${errorMessage}`
)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
isFetchingWorkspaces.value = false
}
/**
* Re-fetch workspaces from API without changing active workspace.
*/
async function refreshWorkspaces(): Promise<void> {
isFetchingWorkspaces.value = true
try {
const response = await workspaceApi.list()
workspaces.value = response.workspaces.map(createWorkspaceState)
} finally {
isFetchingWorkspaces.value = false
}
}
/**
* Switch to a different workspace.
* Clears workspace context and reloads the page.
*/
async function switchWorkspace(workspaceId: string): Promise<void> {
if (workspaceId === activeWorkspaceId.value) return
const workspaceAuthStore = useWorkspaceAuthStore()
isSwitching.value = true
try {
// Verify workspace exists in our list (user has access)
const workspace = workspaces.value.find((w) => w.id === workspaceId)
if (!workspace) {
// Workspace not in list - try refetching in case it was added
await refreshWorkspaces()
const refreshedWorkspace = workspaces.value.find(
(w) => w.id === workspaceId
)
if (!refreshedWorkspace) {
throw new Error('Workspace not found or access denied')
}
}
// Clear current workspace context and persist new workspace ID
workspaceAuthStore.clearWorkspaceContext()
setLastWorkspaceId(workspaceId)
// Reload to reinitialize with new workspace
window.location.reload()
// Code after this won't run (page reloads)
} catch (e) {
isSwitching.value = false
throw e
}
}
/**
* Create a new workspace and switch to it.
*/
async function createWorkspace(name: string): Promise<WorkspaceState> {
const workspaceAuthStore = useWorkspaceAuthStore()
isCreating.value = true
try {
const newWorkspace = await workspaceApi.create({ name })
const workspaceState = createWorkspaceState(newWorkspace)
// Add to local list
workspaces.value = [...workspaces.value, workspaceState]
// Clear context and switch to new workspace
workspaceAuthStore.clearWorkspaceContext()
setLastWorkspaceId(newWorkspace.id)
window.location.reload()
// Code after this won't run (page reloads)
return workspaceState
} catch (e) {
isCreating.value = false
throw e
}
}
/**
* Delete a workspace.
* If deleting active workspace, switches to personal.
*/
async function deleteWorkspace(workspaceId?: string): Promise<void> {
const targetId = workspaceId ?? activeWorkspaceId.value
if (!targetId) throw new Error('No workspace to delete')
const workspace = workspaces.value.find((w) => w.id === targetId)
if (!workspace) throw new Error('Workspace not found')
if (workspace.type === 'personal') {
throw new Error('Cannot delete personal workspace')
}
const workspaceAuthStore = useWorkspaceAuthStore()
isDeleting.value = true
try {
await workspaceApi.delete(targetId)
if (targetId === activeWorkspaceId.value) {
// Deleted active workspace - go to personal
const personal = personalWorkspace.value
workspaceAuthStore.clearWorkspaceContext()
if (personal) {
setLastWorkspaceId(personal.id)
}
window.location.reload()
// Code after this won't run (page reloads)
} else {
// Deleted non-active workspace - just update local list
workspaces.value = workspaces.value.filter((w) => w.id !== targetId)
isDeleting.value = false
}
} catch (e) {
isDeleting.value = false
throw e
}
}
/**
* Rename a workspace. No reload needed.
*/
async function renameWorkspace(
workspaceId: string,
newName: string
): Promise<void> {
const updated = await workspaceApi.update(workspaceId, { name: newName })
updateWorkspace(workspaceId, { name: updated.name })
}
/**
* Update workspace name (convenience for current workspace).
*/
async function updateWorkspaceName(name: string): Promise<void> {
if (!activeWorkspaceId.value) {
throw new Error('No active workspace')
}
await renameWorkspace(activeWorkspaceId.value, name)
}
/**
* Leave the current workspace.
* Switches to personal workspace after leaving.
*/
async function leaveWorkspace(): Promise<void> {
const current = activeWorkspace.value
if (!current || current.type === 'personal') {
throw new Error('Cannot leave personal workspace')
}
const workspaceAuthStore = useWorkspaceAuthStore()
await workspaceApi.leave()
// Go to personal workspace
const personal = personalWorkspace.value
workspaceAuthStore.clearWorkspaceContext()
if (personal) {
setLastWorkspaceId(personal.id)
}
window.location.reload()
// Code after this won't run (page reloads)
}
/**
* Fetch members for the current workspace.
*/
async function fetchMembers(
params?: ListMembersParams
): Promise<WorkspaceMember[]> {
if (!activeWorkspaceId.value) return []
if (activeWorkspace.value?.type === 'personal') return []
const response = await workspaceApi.listMembers(params)
const members = response.members.map(mapApiMemberToWorkspaceMember)
updateActiveWorkspace({ members })
return members
}
/**
* Remove a member from the current workspace.
*/
async function removeMember(userId: string): Promise<void> {
await workspaceApi.removeMember(userId)
const current = activeWorkspace.value
if (current) {
updateActiveWorkspace({
members: current.members.filter((m) => m.id !== userId)
})
}
}
/**
* Fetch pending invites for the current workspace.
*/
async function fetchPendingInvites(): Promise<PendingInvite[]> {
if (!activeWorkspaceId.value) return []
if (activeWorkspace.value?.type === 'personal') return []
const response = await workspaceApi.listInvites()
const invites = response.invites.map(mapApiInviteToPendingInvite)
updateActiveWorkspace({ pendingInvites: invites })
return invites
}
/**
* Create an invite for the current workspace.
*/
async function createInvite(email: string): Promise<PendingInvite> {
const response = await workspaceApi.createInvite({ email })
const invite = mapApiInviteToPendingInvite(response)
const current = activeWorkspace.value
if (current) {
updateActiveWorkspace({
pendingInvites: [...current.pendingInvites, invite]
})
}
return invite
}
/**
* Revoke a pending invite.
*/
async function revokeInvite(inviteId: string): Promise<void> {
await workspaceApi.revokeInvite(inviteId)
const current = activeWorkspace.value
if (current) {
updateActiveWorkspace({
pendingInvites: current.pendingInvites.filter((i) => i.id !== inviteId)
})
}
}
/**
* Accept a workspace invite.
* Returns workspace info so UI can offer "View Workspace" button.
*/
async function acceptInvite(
token: string
): Promise<{ workspaceId: string; workspaceName: string }> {
const response = await workspaceApi.acceptInvite(token)
// Refresh workspace list to include newly joined workspace
await refreshWorkspaces()
return {
workspaceId: response.workspace_id,
workspaceName: response.workspace_name
}
}
// ════════════════════════════════════════════════════════════
// INVITE LINK HELPERS
// ════════════════════════════════════════════════════════════
function buildInviteLink(token: string): string {
const baseUrl = window.location.origin
return `${baseUrl}?invite=${encodeURIComponent(token)}`
}
/**
* Get the invite link for a pending invite.
*/
function getInviteLink(inviteId: string): string | null {
const invite = activeWorkspace.value?.pendingInvites.find(
(i) => i.id === inviteId
)
return invite ? buildInviteLink(invite.token) : null
}
/**
* Create an invite link for a given email.
*/
async function createInviteLink(email: string): Promise<string> {
const invite = await createInvite(email)
return buildInviteLink(invite.token)
}
/**
* Copy an invite link to clipboard.
*/
async function copyInviteLink(inviteId: string): Promise<string> {
const invite = activeWorkspace.value?.pendingInvites.find(
(i) => i.id === inviteId
)
if (!invite) {
throw new Error('Invite not found')
}
const inviteLink = buildInviteLink(invite.token)
await navigator.clipboard.writeText(inviteLink)
return inviteLink
}
//TODO: when billing lands update this
function subscribeWorkspace(plan: SubscriptionPlan = 'PRO_MONTHLY') {
console.warn(plan, 'Billing endpoint has not been added yet.')
}
/**
* Clean up store resources.
* Delegates to workspaceAuthStore for token cleanup.
*/
function destroy(): void {
const workspaceAuthStore = useWorkspaceAuthStore()
workspaceAuthStore.destroy()
}
return {
// State
initState,
workspaces,
activeWorkspaceId,
error,
isCreating,
isDeleting,
isSwitching,
isFetchingWorkspaces,
// Computed
activeWorkspace,
personalWorkspace,
isInPersonalWorkspace,
sharedWorkspaces,
ownedWorkspacesCount,
canCreateWorkspace,
members,
pendingInvites,
totalMemberSlots,
isInviteLimitReached,
workspaceId,
workspaceName,
isWorkspaceSubscribed,
subscriptionPlan,
// Initialization & Cleanup
initialize,
destroy,
refreshWorkspaces,
// Workspace Actions
switchWorkspace,
createWorkspace,
deleteWorkspace,
renameWorkspace,
updateWorkspaceName,
leaveWorkspace,
// Member Actions
fetchMembers,
removeMember,
// Invite Actions
fetchPendingInvites,
createInvite,
revokeInvite,
acceptInvite,
getInviteLink,
createInviteLink,
copyInviteLink,
// Subscription
subscribeWorkspace
}
})

View File

@@ -45,7 +45,7 @@ const timeOptions = {
second: 'numeric'
} as const
function formatTime(time: string) {
function formatTime(time?: string) {
if (!time) return ''
const date = new Date(time)
return `${d(date, dateOptions)} | ${d(date, timeOptions)}`

View File

@@ -154,7 +154,7 @@ describe('NodeHeader.vue', () => {
// Edit and confirm (EditableText uses blur or enter to emit)
const input = wrapper.get('[data-testid="node-title-input"]')
await input.setValue('My Custom Sampler')
await input.trigger('keyup.enter')
await input.trigger('keydown.enter')
await input.trigger('blur')
// NodeHeader should emit update:title with trimmed value
@@ -169,7 +169,7 @@ describe('NodeHeader.vue', () => {
await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick')
const input = wrapper.get('[data-testid="node-title-input"]')
await input.setValue('Should Not Save')
await input.trigger('keyup.escape')
await input.trigger('keydown.escape')
// Should not emit update:title
expect(wrapper.emitted('update:title')).toBeFalsy()

View File

@@ -99,6 +99,7 @@ import { computed, onErrorCaptured, ref, toValue, watch } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { LGraphEventMode, RenderShape } from '@/lib/litegraph/src/litegraph'
@@ -183,9 +184,67 @@ const statusBadge = computed((): NodeBadgeProps | undefined =>
: undefined
)
const nodeBadges = computed<NodeBadgeProps[]>(() =>
[...(nodeData?.badges ?? [])].map(toValue)
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
const {
getRelevantWidgetNames,
hasDynamicPricing,
getInputGroupPrefixes,
getInputNames,
getNodeRevisionRef
} = useNodePricing()
// Cache pricing metadata (won't change during node lifetime)
const isDynamicPricing = computed(() =>
nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false
)
const relevantPricingWidgets = computed(() =>
nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : []
)
const inputGroupPrefixes = computed(() =>
nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : []
)
const relevantInputNames = computed(() =>
nodeData?.apiNode ? getInputNames(nodeData.type) : []
)
const nodeBadges = computed<NodeBadgeProps[]>(() => {
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
// This is needed even for static pricing because JSONata 2.x evaluation is async
if (nodeData?.apiNode && nodeData?.id != null) {
// Access per-node revision ref to establish dependency (each node has its own ref)
void getNodeRevisionRef(nodeData.id).value
// For dynamic pricing, also track widget values and input connections
if (isDynamicPricing.value) {
// Access only the widget values that affect pricing
const relevantNames = relevantPricingWidgets.value
if (relevantNames.length > 0) {
nodeData?.widgets?.forEach((w) => {
if (relevantNames.includes(w.name)) w.value
})
}
// Access input connections for regular inputs
const inputNames = relevantInputNames.value
if (inputNames.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (inp.name && inputNames.includes(inp.name)) {
void inp.link // Access link to create reactive dependency
}
})
}
// Access input connections for input_groups (e.g., autogrow inputs)
const groupPrefixes = inputGroupPrefixes.value
if (groupPrefixes.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
) {
void inp.link // Access link to create reactive dependency
}
})
}
}
}
return [...(nodeData?.badges ?? [])].map(toValue)
})
const isPinned = computed(() => Boolean(nodeData?.flags?.pinned))
const isApiNode = computed(() => Boolean(nodeData?.apiNode))

View File

@@ -13,18 +13,29 @@ const props = defineProps<{
multi?: boolean
}>()
const clipPath = computed(() => {
switch (props.slotData?.shape) {
case 6:
return 'url(#square)'
case 7:
return 'url(#hollow)'
default:
return undefined
}
})
const slotElRef = useTemplateRef('slot-el')
function getTypes() {
const types = computed(() => {
if (props.hasError) return ['var(--color-error)']
//TODO Support connected/disconnected colors?
if (!props.slotData) return [getSlotColor()]
if (props.slotData.type === '*') return ['']
const typesSet = new Set(
`${props.slotData.type}`.split(',').map(getSlotColor)
)
return [...typesSet].slice(0, 3)
}
const types = getTypes()
})
defineExpose({
slotElRef
@@ -52,26 +63,65 @@ const slotClass = computed(() =>
"
>
<div
v-if="types.length === 1"
v-if="types.length === 1 && slotData?.shape == undefined"
ref="slot-el"
:style="{ backgroundColor: types[0] }"
:style="{ backgroundColor: types.length === 1 ? types[0] : undefined }"
:class="slotClass"
/>
<div
<svg
v-else
ref="slot-el"
:style="{
'--type1': types[0],
'--type2': types[1],
'--type3': types[2]
}"
:class="slotClass"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<i-comfy:node-slot2
v-if="types.length === 2"
class="size-full -translate-y-1/2"
<defs>
<clipPath id="square">
<rect x="20" y="20" width="60" height="60" />
</clipPath>
<clipPath id="hollow">
<path
d="M-50 50
A100 100 0 0 1 150 50
A100 100 0 0 1 -50 50
M30 50
A20 20 0 0 0 70 50
A20 20 0 0 0 30 50"
/>
</clipPath>
</defs>
<circle
v-if="types.length === 1"
:clip-path
cx="50"
cy="50"
r="50"
:fill="types[0]"
/>
<i-comfy:node-slot3 v-else class="size-full -translate-y-1/2" />
</div>
<g v-else-if="types.length === 2" :clip-path stroke-width="4">
<path d="M0 50 A 50 50 0 0 1 100 50" :fill="types[0]" />
<path d="M100 50 A 50 50 0 0 1 0 50" :fill="types[1]" />
<path d="M0 50L100 50" stroke="var(--inner-stroke, black)" />
<path
d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2"
fill="transparent"
stroke="var(--outer-stroke, transparent)"
/>
</g>
<g v-else :clip-path stroke-width="4">
<path d="M0 50A50 50 0 0 0 75 93L50 50" :fill="types[0]" />
<path d="M75 93A50 50 0 0 0 75 7L50 50" :fill="types[1]" />
<path d="M75 7A50 50 0 0 0 0 50L50 50" :fill="types[2]" />
<path
d="M50 50L0 50M50 50L75 93M50 50L75 7"
stroke="var(--inner-stroke, black)"
/>
<path
d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2"
fill="transparent"
stroke="var(--outer-stroke, transparent)"
/>
</g>
</svg>
</div>
</template>

View File

@@ -12,9 +12,10 @@ const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
modelAssetsByNodeType: new Map(),
modelLoadingByNodeType: new Map(),
modelErrorByNodeType: new Map(),
getAssets: () => [],
isModelLoading: () => false,
getError: () => undefined,
hasAssetKey: () => false,
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))

View File

@@ -8,17 +8,19 @@ vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
const mockModelAssetsByNodeType = new Map<string, AssetItem[]>()
const mockModelLoadingByNodeType = new Map<string, boolean>()
const mockModelErrorByNodeType = new Map<string, Error | null>()
const mockAssetsByKey = new Map<string, AssetItem[]>()
const mockLoadingByKey = new Map<string, boolean>()
const mockErrorByKey = new Map<string, Error | undefined>()
const mockInitializedKeys = new Set<string>()
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
modelAssetsByNodeType: mockModelAssetsByNodeType,
modelLoadingByNodeType: mockModelLoadingByNodeType,
modelErrorByNodeType: mockModelErrorByNodeType,
getAssets: (key: string) => mockAssetsByKey.get(key) ?? [],
isModelLoading: (key: string) => mockLoadingByKey.get(key) ?? false,
getError: (key: string) => mockErrorByKey.get(key),
hasAssetKey: (key: string) => mockInitializedKeys.has(key),
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
@@ -32,9 +34,10 @@ vi.mock('@/stores/modelToNodeStore', () => ({
describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelAssetsByNodeType.clear()
mockModelLoadingByNodeType.clear()
mockModelErrorByNodeType.clear()
mockAssetsByKey.clear()
mockLoadingByKey.clear()
mockErrorByKey.clear()
mockInitializedKeys.clear()
mockGetCategoryForNodeType.mockReturnValue(undefined)
mockUpdateModelsForNodeType.mockImplementation(
@@ -76,8 +79,9 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
}
)
@@ -108,9 +112,10 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelErrorByNodeType.set(_nodeType, mockError)
mockModelAssetsByNodeType.set(_nodeType, [])
mockModelLoadingByNodeType.set(_nodeType, false)
mockInitializedKeys.add(_nodeType)
mockErrorByKey.set(_nodeType, mockError)
mockAssetsByKey.set(_nodeType, [])
mockLoadingByKey.set(_nodeType, false)
return []
}
)
@@ -130,8 +135,9 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, [])
mockModelLoadingByNodeType.set(_nodeType, false)
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, [])
mockLoadingByKey.set(_nodeType, false)
return []
}
)
@@ -154,8 +160,9 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
}
)
@@ -182,8 +189,9 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockGetCategoryForNodeType.mockReturnValue('loras')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
}
)
@@ -209,8 +217,9 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
mockUpdateModelsForNodeType.mockImplementation(
async (_nodeType: string): Promise<AssetItem[]> => {
mockModelAssetsByNodeType.set(_nodeType, mockAssets)
mockModelLoadingByNodeType.set(_nodeType, false)
mockInitializedKeys.add(_nodeType)
mockAssetsByKey.set(_nodeType, mockAssets)
mockLoadingByKey.set(_nodeType, false)
return mockAssets
}
)

View File

@@ -34,27 +34,21 @@ export function useAssetWidgetData(
const assets = computed<AssetItem[]>(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? (assetsStore.modelAssetsByNodeType.get(resolvedType) ?? [])
: []
return resolvedType ? (assetsStore.getAssets(resolvedType) ?? []) : []
})
const isLoading = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? (assetsStore.modelLoadingByNodeType.get(resolvedType) ?? false)
: false
return resolvedType ? assetsStore.isModelLoading(resolvedType) : false
})
const error = computed<Error | null>(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? (assetsStore.modelErrorByNodeType.get(resolvedType) ?? null)
: null
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
})
const dropdownItems = computed<DropdownItem[]>(() => {
return assets.value.map((asset) => ({
return (assets.value ?? []).map((asset) => ({
id: asset.id,
name:
(asset.user_metadata?.filename as string | undefined) ?? asset.name,
@@ -71,9 +65,10 @@ export function useAssetWidgetData(
return
}
const hasData = assetsStore.modelAssetsByNodeType.has(currentNodeType)
const isLoading = assetsStore.isModelLoading(currentNodeType)
const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
if (!hasData) {
if (!isLoading && !hasBeenInitialized) {
await assetsStore.updateModelsForNodeType(currentNodeType)
}
},

View File

@@ -197,6 +197,50 @@ const zComfyOutputTypesSpec = z.array(
z.union([zComfyNodeDataType, zComfyComboOutput])
)
/**
* Widget dependency with type information.
* Provides strong type enforcement for JSONata evaluation context.
*/
const zWidgetDependency = z.object({
name: z.string(),
type: z.string()
})
export type WidgetDependency = z.infer<typeof zWidgetDependency>
/**
* Schema for price badge depends_on field.
* Specifies which widgets and inputs the pricing expression depends on.
* Widgets must be specified as objects with name and type.
*/
const zPriceBadgeDepends = z.object({
widgets: z.array(zWidgetDependency).optional().default([]),
inputs: z.array(z.string()).optional().default([]),
/**
* Autogrow input group names to track.
* For each group, the count of connected inputs will be available in the
* JSONata context as `g.<groupName>`.
* Example: `input_groups: ["reference_videos"]` makes `g.reference_videos`
* available with the count of connected inputs like `reference_videos.character1`, etc.
*/
input_groups: z.array(z.string()).optional().default([])
})
/**
* Schema for price badge definition.
* Used to calculate and display pricing information for API nodes.
* The `expr` field contains a JSONata expression that returns a PricingResult.
*/
const zPriceBadge = z.object({
engine: z.literal('jsonata').optional().default('jsonata'),
depends_on: zPriceBadgeDepends
.optional()
.default({ widgets: [], inputs: [], input_groups: [] }),
expr: z.string()
})
export type PriceBadge = z.infer<typeof zPriceBadge>
export const zComfyNodeDef = z.object({
input: zComfyInputsSpec.optional(),
output: zComfyOutputTypesSpec.optional(),
@@ -224,7 +268,13 @@ export const zComfyNodeDef = z.object({
* Used to ensure consistent widget ordering regardless of JSON serialization.
* Keys are 'required', 'optional', etc., values are arrays of input names.
*/
input_order: z.record(z.array(z.string())).optional()
input_order: z.record(z.array(z.string())).optional(),
/**
* Price badge definition for API nodes.
* Contains a JSONata expression to calculate pricing based on widget values
* and input connectivity.
*/
price_badge: zPriceBadge.optional()
})
export const zAutogrowOptions = z.object({

View File

@@ -102,6 +102,7 @@ export const useDialogService = () => {
| 'user'
| 'credits'
| 'subscription'
| 'workspace'
) {
const props = panel ? { props: { defaultPanel: panel } } : undefined
@@ -519,6 +520,75 @@ export const useDialogService = () => {
show()
}
// Workspace dialogs - dynamically imported to avoid bundling when feature flag is off
const workspaceDialogPt = {
headless: true,
pt: {
header: { class: 'p-0! hidden' },
content: { class: 'p-0! m-0! rounded-2xl' },
root: { class: 'rounded-2xl' }
}
} as const
async function showDeleteWorkspaceDialog(options?: {
workspaceId?: string
workspaceName?: string
}) {
const { default: component } =
await import('@/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'delete-workspace',
component,
props: options,
dialogComponentProps: workspaceDialogPt
})
}
async function showCreateWorkspaceDialog(
onConfirm?: (name: string) => void | Promise<void>
) {
const { default: component } =
await import('@/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'create-workspace',
component,
props: { onConfirm },
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[400px] w-full' }
}
}
})
}
async function showLeaveWorkspaceDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'leave-workspace',
component,
dialogComponentProps: workspaceDialogPt
})
}
async function showEditWorkspaceDialog() {
const { default: component } =
await import('@/components/dialog/content/workspace/EditWorkspaceDialogContent.vue')
return dialogStore.showDialog({
key: 'edit-workspace',
component,
dialogComponentProps: {
...workspaceDialogPt,
pt: {
...workspaceDialogPt.pt,
root: { class: 'rounded-2xl max-w-[400px] w-full' }
}
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -536,6 +606,10 @@ export const useDialogService = () => {
confirm,
showLayoutDialog,
showImportFailedNodeDialog,
showNodeConflictDialog
showNodeConflictDialog,
showDeleteWorkspaceDialog,
showCreateWorkspaceDialog,
showLeaveWorkspaceDialog,
showEditWorkspaceDialog
}
}

View File

@@ -236,4 +236,80 @@ describe('useAssetDownloadStore', () => {
expect(store.finishedDownloads).toHaveLength(0)
})
})
describe('session download tracking', () => {
it('counts unacknowledged completed downloads with asset IDs', () => {
const store = useAssetDownloadStore()
dispatch(
createDownloadMessage({
status: 'completed',
progress: 100,
asset_id: 'asset-456'
})
)
expect(store.sessionDownloadCount).toBe(1)
})
it('does not count completed downloads without asset IDs', () => {
const store = useAssetDownloadStore()
dispatch(
createDownloadMessage({
status: 'completed',
progress: 100,
asset_id: undefined
})
)
expect(store.sessionDownloadCount).toBe(0)
})
it('does not count failed downloads', () => {
const store = useAssetDownloadStore()
dispatch(
createDownloadMessage({
status: 'failed',
asset_id: 'asset-456'
})
)
expect(store.sessionDownloadCount).toBe(0)
})
it('isDownloadedThisSession returns true for unacknowledged downloads', () => {
const store = useAssetDownloadStore()
dispatch(
createDownloadMessage({
status: 'completed',
progress: 100,
asset_id: 'asset-456'
})
)
expect(store.isDownloadedThisSession('asset-456')).toBe(true)
expect(store.isDownloadedThisSession('other-asset')).toBe(false)
})
it('acknowledgeAsset decrements session count', () => {
const store = useAssetDownloadStore()
dispatch(
createDownloadMessage({
status: 'completed',
progress: 100,
asset_id: 'asset-456'
})
)
expect(store.sessionDownloadCount).toBe(1)
store.acknowledgeAsset('asset-456')
expect(store.sessionDownloadCount).toBe(0)
expect(store.isDownloadedThisSession('asset-456')).toBe(false)
})
})
})

View File

@@ -17,6 +17,7 @@ export interface AssetDownload {
assetId?: string
error?: string
modelType?: string
acknowledged?: boolean
}
interface CompletedDownload {
@@ -59,9 +60,29 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
(d) => d.status === 'completed' || d.status === 'failed'
)
)
const unacknowledgedDownloads = computed(() =>
finishedDownloads.value.filter(
(d) => d.status === 'completed' && d.assetId && !d.acknowledged
)
)
const sessionDownloadCount = computed(
() => unacknowledgedDownloads.value.length
)
const hasActiveDownloads = computed(() => activeDownloads.value.length > 0)
const hasDownloads = computed(() => downloads.value.size > 0)
function isDownloadedThisSession(assetId: string): boolean {
return unacknowledgedDownloads.value.some((d) => d.assetId === assetId)
}
function acknowledgeAsset(assetId: string) {
for (const download of downloads.value.values()) {
if (download.assetId === assetId) {
download.acknowledged = true
}
}
}
function trackDownload(taskId: string, modelType: string, assetName: string) {
if (downloads.value.has(taskId)) return
@@ -172,7 +193,10 @@ export const useAssetDownloadStore = defineStore('assetDownload', () => {
hasDownloads,
downloadList,
lastCompletedDownload,
sessionDownloadCount,
trackDownload,
clearFinishedDownloads
clearFinishedDownloads,
isDownloadedThisSession,
acknowledgeAsset
}
})

View File

@@ -1,9 +1,12 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
import { useAssetsStore } from '@/stores/assetsStore'
import { api } from '@/scripts/api'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { assetService } from '@/platform/assets/services/assetService'
// Mock the api module
vi.mock('@/scripts/api', () => ({
@@ -20,13 +23,17 @@ vi.mock('@/scripts/api', () => ({
// Mock the asset service
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetsByTag: vi.fn()
getAssetsByTag: vi.fn(),
getAssetsForNodeType: vi.fn()
}
}))
// Mock distribution type
// Mock distribution type - hoisted so it can be changed per test
const mockIsCloud = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
get isCloud() {
return mockIsCloud.value
}
}))
// Mock TaskItemImpl
@@ -115,7 +122,7 @@ describe('assetsStore - Refactored (Option A)', () => {
})
beforeEach(() => {
setActivePinia(createPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
store = useAssetsStore()
vi.clearAllMocks()
})
@@ -312,8 +319,8 @@ describe('assetsStore - Refactored (Option A)', () => {
// Verify sorting (newest first - lower index = newer)
for (let i = 1; i < store.historyAssets.length; i++) {
const prevDate = new Date(store.historyAssets[i - 1].created_at)
const currDate = new Date(store.historyAssets[i].created_at)
const prevDate = new Date(store.historyAssets[i - 1].created_at ?? 0)
const currDate = new Date(store.historyAssets[i].created_at ?? 0)
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
}
})
@@ -428,8 +435,8 @@ describe('assetsStore - Refactored (Option A)', () => {
// Should still maintain sorting
for (let i = 1; i < store.historyAssets.length; i++) {
const prevDate = new Date(store.historyAssets[i - 1].created_at)
const currDate = new Date(store.historyAssets[i].created_at)
const prevDate = new Date(store.historyAssets[i - 1].created_at ?? 0)
const currDate = new Date(store.historyAssets[i].created_at ?? 0)
expect(prevDate.getTime()).toBeGreaterThanOrEqual(currDate.getTime())
}
})
@@ -453,3 +460,158 @@ describe('assetsStore - Refactored (Option A)', () => {
})
})
})
describe('assetsStore - Model Assets Cache (Cloud)', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockIsCloud.value = true
vi.clearAllMocks()
})
afterEach(() => {
mockIsCloud.value = false
})
const createMockAsset = (id: string) => ({
id,
name: `asset-${id}`,
size: 100,
created_at: new Date().toISOString(),
tags: ['models'],
preview_url: `http://test.com/${id}`
})
describe('getAssets cache invalidation', () => {
it('should invalidate cache before mutating assets during batch loading', async () => {
const store = useAssetsStore()
const nodeType = 'CheckpointLoaderSimple'
const firstBatch = Array.from({ length: 500 }, (_, i) =>
createMockAsset(`asset-${i}`)
)
const secondBatch = Array.from({ length: 100 }, (_, i) =>
createMockAsset(`asset-${500 + i}`)
)
let callCount = 0
vi.mocked(assetService.getAssetsForNodeType).mockImplementation(
async () => {
callCount++
return callCount === 1 ? firstBatch : secondBatch
}
)
await store.updateModelsForNodeType(nodeType)
// Wait for background batch loading to complete
await vi.waitFor(() => {
expect(
vi.mocked(assetService.getAssetsForNodeType)
).toHaveBeenCalledTimes(2)
})
const assets = store.getAssets(nodeType)
expect(assets).toHaveLength(600)
})
it('should not return stale cached array after background batch completes', async () => {
const store = useAssetsStore()
const nodeType = 'LoraLoader'
// First batch must be exactly MODEL_BATCH_SIZE (500) to trigger hasMore
const firstBatch = Array.from({ length: 500 }, (_, i) =>
createMockAsset(`first-${i}`)
)
const secondBatch = [createMockAsset('new-asset')]
let callCount = 0
vi.mocked(assetService.getAssetsForNodeType).mockImplementation(
async () => {
callCount++
return callCount === 1 ? firstBatch : secondBatch
}
)
await store.updateModelsForNodeType(nodeType)
// Wait for background batch loading to complete
await vi.waitFor(() => {
expect(
vi.mocked(assetService.getAssetsForNodeType)
).toHaveBeenCalledTimes(2)
})
const assets = store.getAssets(nodeType)
expect(assets).toHaveLength(501)
expect(assets.map((a) => a.id)).toContain('new-asset')
})
it('should return cached array on subsequent getAssets calls', () => {
const store = useAssetsStore()
const nodeType = 'TestLoader'
const firstCall = store.getAssets(nodeType)
const secondCall = store.getAssets(nodeType)
expect(secondCall).toBe(firstCall)
})
})
describe('concurrent request handling', () => {
it('should discard stale request when newer request starts', async () => {
const store = useAssetsStore()
const nodeType = 'CheckpointLoaderSimple'
const firstBatch = Array.from({ length: 5 }, (_, i) =>
createMockAsset(`first-${i}`)
)
const secondBatch = Array.from({ length: 10 }, (_, i) =>
createMockAsset(`second-${i}`)
)
let resolveFirst: (value: ReturnType<typeof createMockAsset>[]) => void
const firstPromise = new Promise<ReturnType<typeof createMockAsset>[]>(
(resolve) => {
resolveFirst = resolve
}
)
let callCount = 0
vi.mocked(assetService.getAssetsForNodeType).mockImplementation(
async () => {
callCount++
return callCount === 1 ? firstPromise : secondBatch
}
)
const firstRequest = store.updateModelsForNodeType(nodeType)
const secondRequest = store.updateModelsForNodeType(nodeType)
resolveFirst!(firstBatch)
await Promise.all([firstRequest, secondRequest])
expect(store.getAssets(nodeType)).toHaveLength(10)
expect(
store.getAssets(nodeType).every((a) => a.id.startsWith('second-'))
).toBe(true)
})
})
describe('shallowReactive state reactivity', () => {
it('should trigger reactivity on isModelLoading change', async () => {
const store = useAssetsStore()
const nodeType = 'CheckpointLoaderSimple'
const loadingStates: boolean[] = []
watch(
() => store.isModelLoading(nodeType),
(val) => loadingStates.push(val),
{ immediate: true }
)
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue([])
await store.updateModelsForNodeType(nodeType)
await nextTick()
expect(loadingStates).toContain(true)
expect(loadingStates).toContain(false)
})
})
})

View File

@@ -1,13 +1,13 @@
import { useAsyncState, whenever } from '@vueuse/core'
import { isEqual } from 'es-toolkit'
import { defineStore } from 'pinia'
import { computed, shallowReactive, ref } from 'vue'
import { computed, reactive, ref, shallowReactive } from 'vue'
import {
mapInputFileToAssetItem,
mapTaskOutputToAssetItem
} from '@/platform/assets/composables/media/assetMappers'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { PaginationOptions } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { api } from '@/scripts/api'
@@ -78,7 +78,8 @@ function mapHistoryToAssets(historyItems: JobListItem[]): AssetItem[] {
return assetItems.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
new Date(b.created_at ?? 0).getTime() -
new Date(a.created_at ?? 0).getTime()
)
}
@@ -145,9 +146,9 @@ export const useAssetsStore = defineStore('assets', () => {
loadedIds.add(asset.id)
// Find insertion index to maintain sorted order (newest first)
const assetTime = new Date(asset.created_at).getTime()
const assetTime = new Date(asset.created_at ?? 0).getTime()
const insertIndex = allHistoryItems.value.findIndex(
(item) => new Date(item.created_at).getTime() < assetTime
(item) => new Date(item.created_at ?? 0).getTime() < assetTime
)
if (insertIndex === -1) {
@@ -251,6 +252,16 @@ export const useAssetsStore = defineStore('assets', () => {
return inputAssetsByFilename.value.get(filename)?.name ?? filename
}
const MODEL_BATCH_SIZE = 500
interface ModelPaginationState {
assets: Map<string, AssetItem>
offset: number
hasMore: boolean
isLoading: boolean
error?: Error
}
/**
* Model assets cached by node type (e.g., 'CheckpointLoaderSimple', 'LoraLoader')
* Used by multiple loader nodes to avoid duplicate fetches
@@ -258,111 +269,257 @@ export const useAssetsStore = defineStore('assets', () => {
*/
const getModelState = () => {
if (isCloud) {
const modelAssetsByNodeType = shallowReactive(
new Map<string, AssetItem[]>()
)
const modelLoadingByNodeType = shallowReactive(new Map<string, boolean>())
const modelErrorByNodeType = shallowReactive(
new Map<string, Error | null>()
)
const modelStateByKey = ref(new Map<string, ModelPaginationState>())
const stateByNodeType = shallowReactive(
new Map<string, ReturnType<typeof useAsyncState<AssetItem[]>>>()
)
const assetsArrayCache = new Map<
string,
{ source: Map<string, AssetItem>; array: AssetItem[] }
>()
const pendingRequestByKey = new Map<string, ModelPaginationState>()
function createState(): ModelPaginationState {
return reactive({
assets: new Map(),
offset: 0,
hasMore: true,
isLoading: false
})
}
function isStale(key: string, state: ModelPaginationState): boolean {
const committed = modelStateByKey.value.get(key)
const pending = pendingRequestByKey.get(key)
return committed !== state && pending !== state
}
const EMPTY_ASSETS: AssetItem[] = []
function getAssets(key: string): AssetItem[] {
const state = modelStateByKey.value.get(key)
const assetsMap = state?.assets
if (!assetsMap) return EMPTY_ASSETS
const cached = assetsArrayCache.get(key)
if (cached && cached.source === assetsMap) {
return cached.array
}
const array = Array.from(assetsMap.values())
assetsArrayCache.set(key, { source: assetsMap, array })
return array
}
function isLoading(key: string): boolean {
return modelStateByKey.value.get(key)?.isLoading ?? false
}
function getError(key: string): Error | undefined {
return modelStateByKey.value.get(key)?.error
}
function hasMore(key: string): boolean {
return modelStateByKey.value.get(key)?.hasMore ?? false
}
function hasAssetKey(key: string): boolean {
return modelStateByKey.value.has(key)
}
/**
* Internal helper to fetch and cache assets with a given key and fetcher
* Internal helper to fetch and cache assets with a given key and fetcher.
* Loads first batch immediately, then progressively loads remaining batches.
* Keeps existing data visible until new data is successfully fetched.
*/
async function updateModelsForKey(
key: string,
fetcher: () => Promise<AssetItem[]>
): Promise<AssetItem[]> {
if (!stateByNodeType.has(key)) {
stateByNodeType.set(
key,
useAsyncState(fetcher, [], {
immediate: false,
resetOnExecute: false,
onError: (err) => {
console.error(`Error fetching model assets for ${key}:`, err)
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
): Promise<void> {
const state = createState()
state.isLoading = true
const hasExistingData = modelStateByKey.value.has(key)
if (hasExistingData) {
pendingRequestByKey.set(key, state)
} else {
modelStateByKey.value.set(key, state)
}
async function loadBatches(): Promise<void> {
while (state.hasMore) {
try {
const newAssets = await fetcher({
limit: MODEL_BATCH_SIZE,
offset: state.offset
})
if (isStale(key, state)) return
const isFirstBatch = state.offset === 0
if (isFirstBatch) {
assetsArrayCache.delete(key)
if (hasExistingData) {
pendingRequestByKey.delete(key)
modelStateByKey.value.set(key, state)
}
state.assets = new Map(newAssets.map((a) => [a.id, a]))
} else {
const assetsToAdd = newAssets.filter(
(a) => !state.assets.has(a.id)
)
if (assetsToAdd.length > 0) {
assetsArrayCache.delete(key)
for (const asset of assetsToAdd) {
state.assets.set(asset.id, asset)
}
}
}
})
)
state.offset += newAssets.length
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
if (isFirstBatch) {
state.isLoading = false
}
if (state.hasMore) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
} catch (err) {
if (isStale(key, state)) return
state.error = err instanceof Error ? err : new Error(String(err))
state.hasMore = false
console.error(`Error loading batch for ${key}:`, err)
if (state.offset === 0) {
state.isLoading = false
pendingRequestByKey.delete(key)
// TODO: Add toast indicator for first-batch load failures
}
return
}
}
}
const state = stateByNodeType.get(key)!
modelLoadingByNodeType.set(key, true)
modelErrorByNodeType.set(key, null)
try {
await state.execute()
} finally {
modelLoadingByNodeType.set(key, state.isLoading.value)
}
const assets = state.state.value
const existingAssets = modelAssetsByNodeType.get(key)
if (!isEqual(existingAssets, assets)) {
modelAssetsByNodeType.set(key, assets)
}
modelErrorByNodeType.set(
key,
state.error.value instanceof Error ? state.error.value : null
)
return assets
await loadBatches()
}
/**
* Fetch and cache model assets for a specific node type
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
* @returns Promise resolving to the fetched assets
*/
async function updateModelsForNodeType(
nodeType: string
): Promise<AssetItem[]> {
return updateModelsForKey(nodeType, () =>
assetService.getAssetsForNodeType(nodeType)
async function updateModelsForNodeType(nodeType: string): Promise<void> {
await updateModelsForKey(nodeType, (opts) =>
assetService.getAssetsForNodeType(nodeType, opts)
)
}
/**
* Fetch and cache model assets for a specific tag
* @param tag The tag to fetch assets for (e.g., 'models')
* @returns Promise resolving to the fetched assets
*/
async function updateModelsForTag(tag: string): Promise<AssetItem[]> {
async function updateModelsForTag(tag: string): Promise<void> {
const key = `tag:${tag}`
return updateModelsForKey(key, () => assetService.getAssetsByTag(tag))
await updateModelsForKey(key, (opts) =>
assetService.getAssetsByTag(tag, true, opts)
)
}
/**
* Optimistically update an asset in the cache
* @param assetId The asset ID to update
* @param updates Partial asset data to merge
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
*/
function updateAssetInCache(
assetId: string,
updates: Partial<AssetItem>,
cacheKey?: string
) {
const keysToCheck = cacheKey
? [cacheKey]
: Array.from(modelStateByKey.value.keys())
for (const key of keysToCheck) {
const state = modelStateByKey.value.get(key)
if (!state?.assets) continue
const existingAsset = state.assets.get(assetId)
if (existingAsset) {
const updatedAsset = { ...existingAsset, ...updates }
state.assets.set(assetId, updatedAsset)
assetsArrayCache.delete(key)
if (cacheKey) return
}
}
}
/**
* Update asset metadata with optimistic cache update
* @param assetId The asset ID to update
* @param userMetadata The user_metadata to save
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetMetadata(
assetId: string,
userMetadata: Record<string, unknown>,
cacheKey?: string
) {
updateAssetInCache(assetId, { user_metadata: userMetadata }, cacheKey)
await assetService.updateAsset(assetId, { user_metadata: userMetadata })
}
/**
* Update asset tags with optimistic cache update
* @param assetId The asset ID to update
* @param tags The tags array to save
* @param cacheKey Optional cache key to target for optimistic update
*/
async function updateAssetTags(
assetId: string,
tags: string[],
cacheKey?: string
) {
updateAssetInCache(assetId, { tags }, cacheKey)
await assetService.updateAsset(assetId, { tags })
}
return {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
getAssets,
isLoading,
getError,
hasMore,
hasAssetKey,
updateModelsForNodeType,
updateModelsForTag
updateModelsForTag,
updateAssetMetadata,
updateAssetTags
}
}
const emptyAssets: AssetItem[] = []
return {
modelAssetsByNodeType: shallowReactive(new Map<string, AssetItem[]>()),
modelLoadingByNodeType: shallowReactive(new Map<string, boolean>()),
modelErrorByNodeType: shallowReactive(new Map<string, Error | null>()),
updateModelsForNodeType: async () => [],
updateModelsForTag: async () => []
getAssets: () => emptyAssets,
isLoading: () => false,
getError: () => undefined,
hasMore: () => false,
hasAssetKey: () => false,
updateModelsForNodeType: async () => {},
updateModelsForTag: async () => {},
updateAssetMetadata: async () => {},
updateAssetTags: async () => {}
}
}
const {
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
getAssets,
isLoading: isModelLoading,
getError,
hasMore,
hasAssetKey,
updateModelsForNodeType,
updateModelsForTag
updateModelsForTag,
updateAssetMetadata,
updateAssetTags
} = getModelState()
// Watch for completed downloads and refresh model caches
@@ -422,11 +579,17 @@ export const useAssetsStore = defineStore('assets', () => {
inputAssetsByFilename,
getInputName,
// Model assets
modelAssetsByNodeType,
modelLoadingByNodeType,
modelErrorByNodeType,
// Model assets - accessors
getAssets,
isModelLoading,
getError,
hasMore,
hasAssetKey,
// Model assets - actions
updateModelsForNodeType,
updateModelsForTag
updateModelsForTag,
updateAssetMetadata,
updateAssetTags
}
})

View File

@@ -25,12 +25,12 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import type { AuthHeader } from '@/types/authTypes'
import type { operations } from '@/types/comfyRegistryTypes'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
type CreditPurchaseResponse =
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
@@ -58,6 +58,8 @@ export class FirebaseAuthStoreError extends Error {
}
export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const { flags } = useFeatureFlags()
// State
const loading = ref(false)
const currentUser = ref<User | null>(null)
@@ -173,7 +175,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
* - null if no authentication method is available
*/
const getAuthHeader = async (): Promise<AuthHeader | null> => {
if (remoteConfig.value.team_workspaces_enabled) {
if (flags.teamWorkspacesEnabled) {
const workspaceToken = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.TOKEN
)
@@ -201,10 +203,19 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
return useApiKeyAuthStore().getAuthHeader()
}
/**
* Returns Firebase auth header for user-scoped endpoints (e.g., /customers/*).
* Use this for endpoints that need user identity, not workspace context.
*/
const getFirebaseAuthHeader = async (): Promise<AuthHeader | null> => {
const token = await getIdToken()
return token ? { Authorization: `Bearer ${token}` } : null
}
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
isFetchingBalance.value = true
try {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(
t('toastMessages.userNotAuthenticated')
@@ -242,7 +253,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
}
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
@@ -404,7 +415,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const addCredits = async (
requestBodyContent: CreditPurchasePayload
): Promise<CreditPurchaseResponse> => {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
@@ -444,21 +455,19 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
const accessBillingPortal = async (
targetTier?: BillingPortalTargetTier
): Promise<AccessBillingPortalResponse> => {
const authHeader = await getAuthHeader()
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const requestBody = targetTier ? { target_tier: targetTier } : undefined
const response = await fetch(buildApiUrl('/customers/billing'), {
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
...(requestBody && {
body: JSON.stringify(requestBody)
...(targetTier && {
body: JSON.stringify({ target_tier: targetTier })
})
})
@@ -503,6 +512,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
sendPasswordReset,
updatePassword: _updatePassword,
deleteAccount: _deleteAccount,
getAuthHeader
getAuthHeader,
getFirebaseAuthHeader
}
})

View File

@@ -14,7 +14,8 @@ import type {
import type {
ComfyInputsSpec as ComfyInputSpecV1,
ComfyNodeDef as ComfyNodeDefV1,
ComfyOutputTypesSpec as ComfyOutputSpecV1
ComfyOutputTypesSpec as ComfyOutputSpecV1,
PriceBadge
} from '@/schemas/nodeDefSchema'
import { NodeSearchService } from '@/services/nodeSearchService'
import { useSubgraphStore } from '@/stores/subgraphStore'
@@ -66,6 +67,12 @@ export class ComfyNodeDefImpl
* Order of inputs for each category (required, optional, hidden)
*/
readonly input_order?: Record<string, string[]>
/**
* Price badge definition for API nodes.
* Contains a JSONata expression to calculate pricing based on widget values
* and input connectivity.
*/
readonly price_badge?: PriceBadge
// V2 fields
readonly inputs: Record<string, InputSpecV2>
@@ -134,6 +141,7 @@ export class ComfyNodeDefImpl
this.output_name = obj.output_name
this.output_tooltips = obj.output_tooltips
this.input_order = obj.input_order
this.price_badge = obj.price_badge
// Initialize V2 fields
const defV2 = transformNodeDefV1ToV2(obj)

View File

@@ -493,6 +493,9 @@ export const useQueueStore = defineStore('queue', () => {
)
const hasPendingTasks = computed<boolean>(() => pendingTasks.value.length > 0)
const activeJobsCount = computed(
() => pendingTasks.value.length + runningTasks.value.length
)
const update = async () => {
isLoading.value = true
@@ -572,6 +575,7 @@ export const useQueueStore = defineStore('queue', () => {
flatTasks,
lastHistoryQueueIndex,
hasPendingTasks,
activeJobsCount,
update,
clear,

View File

@@ -2,6 +2,7 @@ export interface NavItemData {
id: string
label: string
icon: string
badge?: string | number
}
export interface NavGroupData {

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