Merge remote-tracking branch 'origin/main' into fix/remove-queue-overlay-mini
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 81 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
43
src/components/common/WorkspaceProfilePic.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
11
src/components/dialog/content/setting/WorkspacePanel.vue
Normal 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>
|
||||
163
src/components/dialog/content/setting/WorkspacePanelContent.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
109
src/components/sidebar/tabs/AssetsSidebarGridView.vue
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
111
src/components/sidebar/tabs/assets/ActiveJobCard.test.ts
Normal 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...')
|
||||
})
|
||||
})
|
||||
85
src/components/sidebar/tabs/assets/ActiveJobCard.vue
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
337
src/components/topbar/CurrentUserPopoverWorkspace.vue
Normal 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>
|
||||
166
src/components/topbar/WorkspaceSwitcherPopover.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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[]>(() =>
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
17
src/extensions/core/nightlyBadges.ts
Normal 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
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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": "مسح سجل قائمة الانتظار",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "پاکسازی تاریخچه صف کار",
|
||||
|
||||
@@ -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 l’historique de la file d’attente",
|
||||
|
||||
@@ -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": "ジョブキュー履歴をクリア",
|
||||
|
||||
@@ -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": "작업 대기열 기록 삭제",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Очистить историю очереди заданий",
|
||||
|
||||
@@ -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ı aşı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",
|
||||
|
||||
@@ -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": "清除作業佇列歷史",
|
||||
|
||||
@@ -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": "清除任务记录",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
12
src/platform/assets/components/modelInfo/ModelInfoField.vue
Normal 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>
|
||||
165
src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
296
src/platform/assets/components/modelInfo/ModelInfoPanel.vue
Normal 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>
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||