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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -189,9 +189,7 @@ test.describe('Templates', () => {
const templateGrid = comfyPage.page.locator(
'[data-testid="template-workflows-content"]'
)
const nav = comfyPage.page
.locator('header')
.filter({ hasText: 'Templates' })
const nav = comfyPage.page.locator('header', { hasText: 'Templates' })
await comfyPage.templates.waitForMinimumCardCount(1)
await expect(templateGrid).toBeVisible()
@@ -201,7 +199,8 @@ test.describe('Templates', () => {
await comfyPage.page.setViewportSize(mobileSize)
await comfyPage.templates.waitForMinimumCardCount(1)
await expect(templateGrid).toBeVisible()
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
// Nav header is clipped by overflow-hidden parent at mobile size
await expect(nav).not.toBeInViewport()
const tabletSize = { width: 1024, height: 800 }
await comfyPage.page.setViewportSize(tabletSize)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -76,6 +76,7 @@ function getModuleName(id: string): string {
export function comfyAPIPlugin(isDev: boolean): Plugin {
return {
name: 'comfy-api-plugin',
apply: 'build',
transform(code: string, id: string) {
if (isDev) return null

View File

@@ -5,7 +5,7 @@
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,

View File

@@ -30,6 +30,10 @@ describe('MyStore', () => {
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
## i18n in Component Tests
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
## Mock Patterns
### Reset all mocks at once

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.38.8",
"version": "1.38.9",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -169,6 +169,7 @@
"firebase": "catalog:",
"fuse.js": "^7.0.0",
"glob": "^11.0.3",
"jsonata": "catalog:",
"jsondiffpatch": "^0.6.0",
"loglevel": "^1.9.2",
"marked": "^15.0.11",

View File

@@ -1,19 +0,0 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<clipPath id="hollow">
<path
d="M -50 50
A 100 100, 0, 0, 1, 150 50
A 100 100, 0, 0, 1, -50 50
M 30 50
A 20 20, 0, 0, 0, 70 50
A 20 20, 0, 0, 0, 30 50"/>
</clipPath>
</defs>
<g clip-path="var(--shape)" stroke-width="4">
<path d="M 50 0 A 50 50, 0, 0, 1, 50 100" fill="var(--type1, red)"/>
<path d="M 50 100 A 50 50, 0, 0, 1, 50 0" fill="var(--type2, blue)"/>
<path d="M50 0L50 100" 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>

Before

Width:  |  Height:  |  Size: 693 B

View File

@@ -1,20 +0,0 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<defs>
<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>
<g clip-path="var(--shape)" stroke-width="4">
<path d="M50 0A50 50 0 0 1 93 75L50 50" fill="var(--type1, red)"/>
<path d="M93 75A50 50 0 0 1 7 75L50 50" fill="var(--type2, blue)"/>
<path d="M7 75A50 50 0 0 1 50 0L50 50" fill="var(--type3, green)"/>
<path d="M50 50L50 0M50 50L93 75M50 50L7 75" 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>

Before

Width:  |  Height:  |  Size: 763 B

12
pnpm-lock.yaml generated
View File

@@ -186,6 +186,9 @@ catalogs:
jsdom:
specifier: ^27.4.0
version: 27.4.0
jsonata:
specifier: ^2.1.0
version: 2.1.0
knip:
specifier: ^5.75.1
version: 5.75.1
@@ -449,6 +452,9 @@ importers:
glob:
specifier: ^11.0.3
version: 11.0.3
jsonata:
specifier: 'catalog:'
version: 2.1.0
jsondiffpatch:
specifier: ^0.6.0
version: 0.6.0
@@ -6045,6 +6051,10 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonata@2.1.0:
resolution: {integrity: sha512-OCzaRMK8HobtX8fp37uIVmL8CY1IGc/a6gLsDqz3quExFR09/U78HUzWYr7T31UEB6+Eu0/8dkVD5fFDOl9a8w==}
engines: {node: '>= 8'}
jsonc-eslint-parser@2.4.0:
resolution: {integrity: sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -14403,6 +14413,8 @@ snapshots:
json5@2.2.3: {}
jsonata@2.1.0: {}
jsonc-eslint-parser@2.4.0:
dependencies:
acorn: 8.15.0

View File

@@ -62,6 +62,7 @@ catalog:
happy-dom: ^20.0.11
husky: ^9.1.7
jiti: 2.6.1
jsonata: ^2.1.0
jsdom: ^27.4.0
knip: ^5.75.1
lint-staged: ^16.2.7

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(

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