Compare commits

...

2 Commits

Author SHA1 Message Date
Jin Yi
0447f2227d feat: replace ResultGallery with standalone MediaViewer for assets
- Add MediaViewer component using Reka UI Dialog with AssetItem directly
- Remove ResultItemImpl dependency from useMediaAssetGalleryStore
- Add getOutputAssets, getAssetsByJobIds to assetService
- Add prompt_id field to asset schema
2026-03-09 22:09:57 +09:00
jaeone94
76fd80aa98 fix: hide empty actionbar container and relocate error border to floating actionbar (#9657)
## Summary
When the actionbar is floating and has no docked buttons, the container
is now hidden (zero-width, transparent border) to avoid showing an empty
rounded box. Additionally, the error/destructive border is now applied
to the floating actionbar panel itself (via `ComfyActionbar`) instead of
the container, so it appears in the correct location when floating.

## Changes
- **TopMenuSection**: Added `hasDockedButtons` and
`isActionbarContainerEmpty` computed properties to detect when the
docked container has no visible buttons; `actionbarContainerClass`
computed hides the container by collapsing it when empty and floating,
while preserving the legacy drop zone via `:has(.border-dashed)` CSS
selector
- **TopMenuSection**: Error border
(`border-destructive-background-hover`) is now only applied to the
docked container when the actionbar is **not** floating
- **ComfyActionbar**: Accepts new `hasAnyError` prop and applies the
error border to the floating panel's `panelClass` when floating

## Review Focus
- The `has-[.border-dashed]` CSS selector restores the container visuals
when a legacy drag-target element is present inside it — verify this
works as expected
- Error border placement: docked mode shows border on container,
floating mode shows border on the fixed panel

## Screenshots


https://github.com/user-attachments/assets/75caabac-e391-4bfd-b4dc-62d564e55d37
2026-03-09 21:24:00 +09:00
10 changed files with 406 additions and 182 deletions

View File

@@ -34,17 +34,7 @@
</Button>
</div>
<div
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
@@ -55,6 +45,7 @@
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
:has-any-error="hasAnyError"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
@@ -123,7 +114,7 @@
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -145,6 +136,7 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -168,6 +160,7 @@ const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const executionErrorStore = useExecutionErrorStore()
const actionBarButtonStore = useActionBarButtonStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
@@ -182,6 +175,43 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
/**
* Whether the actionbar container has any visible docked buttons
* (excluding ComfyActionbar, which uses position:fixed when floating
* and does not contribute to the container's visual layout).
*/
const hasDockedButtons = computed(() => {
if (actionBarButtonStore.buttons.length > 0) return true
if (hasLegacyContent.value) return true
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
if (isDesktop && !isIntegratedTabBar.value) return true
if (isCloud && flags.workflowSharingEnabled) return true
if (!isRightSidePanelOpen.value) return true
return false
})
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
const borderClass =
!isActionbarFloating.value && hasAnyError.value
? 'border-destructive-background-hover'
: 'border-interface-stroke'
return cn(base, 'px-2', borderClass)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
@@ -233,6 +263,25 @@ const rightSidePanelTooltipConfig = computed(() =>
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)
function checkLegacyContent() {
const el = legacyCommandsContainerRef.value
if (!el) {
hasLegacyContent.value = false
return
}
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
hasLegacyContent.value =
el.querySelector(':scope > * > *:not(:empty)') !== null
}
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
childList: true,
subtree: true,
characterData: true
})
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'

View File

@@ -119,9 +119,14 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
const {
topMenuContainer,
queueOverlayExpanded = false,
hasAnyError = false
} = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
hasAnyError?: boolean
}>()
const emit = defineEmits<{
@@ -435,7 +440,12 @@ const panelClass = computed(() =>
isDragging.value && 'pointer-events-none select-none',
isDocked.value
? 'static border-none bg-transparent p-0'
: 'fixed shadow-interface'
: [
'fixed shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
]
)
)
</script>

View File

@@ -170,9 +170,9 @@
</div>
</template>
</SidebarTabTemplate>
<ResultGallery
<MediaViewer
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
:items="previewableVisibleAssets"
/>
<MediaAssetContextMenu
v-if="contextMenuAsset"
@@ -221,12 +221,12 @@ import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridVi
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.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 MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import MediaViewer from '@/platform/assets/components/MediaViewer.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
@@ -240,7 +240,6 @@ import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import {
formatDuration,
getMediaTypeFromFilename,
@@ -454,28 +453,6 @@ watch(galleryActiveIndex, (index) => {
}
})
const galleryItems = computed(() => {
return previewableVisibleAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: mediaType === 'image' ? 'images' : mediaType
})
Object.defineProperty(resultItem, 'url', {
get() {
return asset.preview_url || ''
},
configurable: true
})
return resultItem
})
})
const refreshAssets = async () => {
await currentAssets.value.fetchMediaList()
if (error.value) {

View File

@@ -1,17 +1,16 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetItem } from '../schemas/assetSchema'
import MediaAssetCard from './MediaAssetCard.vue'
import MediaViewer from './MediaViewer.vue'
const meta: Meta<typeof MediaAssetCard> = {
title: 'Platform/Assets/MediaAssetCard',
component: MediaAssetCard,
decorators: [
() => ({
components: { ResultGallery },
components: { MediaViewer },
setup() {
const galleryStore = useMediaAssetGalleryStore()
return { galleryStore }
@@ -19,9 +18,9 @@ const meta: Meta<typeof MediaAssetCard> = {
template: `
<div>
<story />
<ResultGallery
<MediaViewer
v-model:active-index="galleryStore.activeIndex"
:all-gallery-items="galleryStore.items"
:items="galleryStore.items"
/>
</div>
`

View File

@@ -0,0 +1,135 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { AssetItem } from '../schemas/assetSchema'
import MediaViewer from './MediaViewer.vue'
const SAMPLE_MEDIA = {
image1: 'https://i.imgur.com/OB0y6MR.jpg',
image2: 'https://i.imgur.com/CzXTtJV.jpg',
image3: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
video:
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
audio: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3'
}
function makeAsset(
overrides: Partial<AssetItem> & { id: string; name: string }
): AssetItem {
return {
size: 0,
created_at: new Date().toISOString(),
tags: ['output'],
preview_url: '',
...overrides
}
}
const singleImage: AssetItem[] = [
makeAsset({
id: 'img-1',
name: 'landscape.jpg',
preview_url: SAMPLE_MEDIA.image1
})
]
const multipleImages: AssetItem[] = [
makeAsset({
id: 'img-1',
name: 'landscape.jpg',
preview_url: SAMPLE_MEDIA.image1
}),
makeAsset({
id: 'img-2',
name: 'portrait.jpg',
preview_url: SAMPLE_MEDIA.image2
}),
makeAsset({
id: 'img-3',
name: 'nature.jpg',
preview_url: SAMPLE_MEDIA.image3
})
]
const mixedMedia: AssetItem[] = [
makeAsset({
id: 'img-1',
name: 'photo.jpg',
preview_url: SAMPLE_MEDIA.image1
}),
makeAsset({
id: 'vid-1',
name: 'clip.mp4',
preview_url: SAMPLE_MEDIA.video
}),
makeAsset({
id: 'aud-1',
name: 'song.mp3',
preview_url: SAMPLE_MEDIA.audio
})
]
const meta: Meta<typeof MediaViewer> = {
title: 'Platform/Assets/MediaViewer',
component: MediaViewer,
parameters: { layout: 'fullscreen' },
argTypes: {
activeIndex: { control: { type: 'number', min: -1, max: 10 } }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const SingleImage: Story = {
args: {
items: singleImage,
activeIndex: 0
}
}
export const MultipleImages: Story = {
args: {
items: multipleImages,
activeIndex: 0
}
}
export const VideoPlayer: Story = {
args: {
items: [
makeAsset({
id: 'vid-1',
name: 'big-buck-bunny.mp4',
preview_url: SAMPLE_MEDIA.video
})
],
activeIndex: 0
}
}
export const AudioPlayer: Story = {
args: {
items: [
makeAsset({
id: 'aud-1',
name: 'soundtrack.mp3',
preview_url: SAMPLE_MEDIA.audio
})
],
activeIndex: 0
}
}
export const MixedMedia: Story = {
args: {
items: mixedMedia,
activeIndex: 0
}
}
export const Closed: Story = {
args: {
items: singleImage,
activeIndex: -1
}
}

View File

@@ -0,0 +1,122 @@
<template>
<DialogRoot :open="isOpen" @update:open="handleOpenChange">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-black/80" />
<DialogContent
class="fixed inset-0 z-50 flex items-center justify-center outline-none"
:aria-describedby="undefined"
>
<VisuallyHidden as-child>
<DialogTitle>{{ currentItem?.name ?? '' }}</DialogTitle>
</VisuallyHidden>
<!-- Close button -->
<DialogClose
class="absolute top-4 right-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
:aria-label="$t('g.close')"
>
<i class="icon-[lucide--x] size-5" />
</DialogClose>
<!-- Previous button -->
<button
v-if="hasMultiple"
class="absolute left-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
@click="navigate(-1)"
>
<i class="icon-[lucide--chevron-left] size-6" />
</button>
<!-- Media content -->
<ComfyImage
v-if="currentMediaType === 'image'"
:key="currentItem?.id"
:src="currentItem?.preview_url ?? ''"
:contain="false"
:alt="currentItem?.name ?? ''"
class="max-h-screen max-w-full object-contain"
/>
<video
v-else-if="currentMediaType === 'video'"
:key="currentItem?.id"
:src="currentItem?.preview_url ?? ''"
controls
class="max-h-screen max-w-full"
/>
<audio
v-else-if="currentMediaType === 'audio'"
:key="currentItem?.id"
:src="currentItem?.preview_url ?? ''"
controls
/>
<!-- Next button -->
<button
v-if="hasMultiple"
class="absolute right-4 z-10 cursor-pointer rounded-full bg-black/50 p-2 text-white hover:bg-black/70"
@click="navigate(1)"
>
<i class="icon-[lucide--chevron-right] size-6" />
</button>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
VisuallyHidden
} from 'reka-ui'
import { computed, onMounted, onUnmounted } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
const { items = [], activeIndex = -1 } = defineProps<{
items?: AssetItem[]
activeIndex?: number
}>()
const emit = defineEmits<{
'update:activeIndex': [value: number]
}>()
const isOpen = computed(() => activeIndex >= 0 && activeIndex < items.length)
const currentItem = computed(() =>
isOpen.value ? items[activeIndex] : undefined
)
const currentMediaType = computed(() =>
currentItem.value ? getMediaTypeFromFilename(currentItem.value.name) : ''
)
const hasMultiple = computed(() => items.length > 1)
function handleOpenChange(open: boolean) {
if (!open) {
emit('update:activeIndex', -1)
}
}
function navigate(direction: number) {
const newIndex = (activeIndex + direction + items.length) % items.length
emit('update:activeIndex', newIndex)
}
function handleKeyDown(event: KeyboardEvent) {
if (!isOpen.value) return
if (event.key === 'ArrowLeft') navigate(-1)
else if (event.key === 'ArrowRight') navigate(1)
}
onMounted(() => window.addEventListener('keydown', handleKeyDown))
onUnmounted(() => window.removeEventListener('keydown', handleKeyDown))
</script>

View File

@@ -1,161 +1,76 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { ResultItemImpl } from '@/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import type { AssetItem } from '../schemas/assetSchema'
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
vi.mock('@/stores/queueStore', () => ({
ResultItemImpl: vi
.fn<typeof ResultItemImpl>()
.mockImplementation(function (data) {
Object.assign(this, {
...data,
url: ''
})
})
}))
describe('useMediaAssetGalleryStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('openSingle', () => {
it('should convert AssetMeta to ResultItemImpl format', () => {
it('should set items and activeIndex', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
const asset: AssetItem = {
id: 'test-1',
name: 'test-image.png',
kind: 'image',
src: 'https://example.com/image.png',
size: 1024,
tags: [],
preview_url: 'https://example.com/image.png',
tags: ['output'],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
store.openSingle(asset)
expect(ResultItemImpl).toHaveBeenCalledWith({
filename: 'test-image.png',
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: 'images'
})
expect(store.items).toHaveLength(1)
expect(store.items[0]).toBe(asset)
expect(store.activeIndex).toBe(0)
})
it('should set correct mediaType for video assets', () => {
it('should preserve asset properties', () => {
const store = useMediaAssetGalleryStore()
const mockVideoAsset: AssetMeta = {
const asset: AssetItem = {
id: 'test-2',
name: 'test-video.mp4',
kind: 'video',
src: 'https://example.com/video.mp4',
size: 2048,
tags: [],
preview_url: 'https://example.com/video.mp4',
tags: ['output'],
created_at: '2025-01-01'
}
store.openSingle(mockVideoAsset)
store.openSingle(asset)
expect(ResultItemImpl).toHaveBeenCalledWith(
expect.objectContaining({
filename: 'test-video.mp4',
mediaType: 'video'
})
)
expect(store.items[0].name).toBe('test-video.mp4')
expect(store.items[0].preview_url).toBe('https://example.com/video.mp4')
})
it('should set correct mediaType for audio assets', () => {
it('should replace previous items when called multiple times', () => {
const store = useMediaAssetGalleryStore()
const mockAudioAsset: AssetMeta = {
id: 'test-3',
name: 'test-audio.mp3',
kind: 'audio',
src: 'https://example.com/audio.mp3',
size: 512,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAudioAsset)
expect(ResultItemImpl).toHaveBeenCalledWith(
expect.objectContaining({
filename: 'test-audio.mp3',
mediaType: 'audio'
})
)
})
it('should override url getter with asset.src', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
id: 'test-4',
name: 'test.png',
kind: 'image',
src: 'https://example.com/custom-url.png',
size: 1024,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
const resultItem = store.items[0]
expect(resultItem.url).toBe('https://example.com/custom-url.png')
})
it('should handle assets without src gracefully', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
id: 'test-5',
name: 'no-src.png',
kind: 'image',
src: '',
size: 1024,
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
const resultItem = store.items[0]
expect(resultItem.url).toBe('')
})
it('should update activeIndex and items when called multiple times', () => {
const store = useMediaAssetGalleryStore()
const asset1: AssetMeta = {
const asset1: AssetItem = {
id: '1',
name: 'first.png',
kind: 'image',
src: 'url1',
size: 100,
preview_url: 'url1',
tags: [],
created_at: '2025-01-01'
}
const asset2: AssetMeta = {
const asset2: AssetItem = {
id: '2',
name: 'second.png',
kind: 'image',
src: 'url2',
size: 200,
preview_url: 'url2',
tags: [],
created_at: '2025-01-01'
}
store.openSingle(asset1)
expect(store.items).toHaveLength(1)
expect(store.items[0].filename).toBe('first.png')
expect(store.items[0].name).toBe('first.png')
store.openSingle(asset2)
expect(store.items).toHaveLength(1)
expect(store.items[0].filename).toBe('second.png')
expect(store.items[0].name).toBe('second.png')
expect(store.activeIndex).toBe(0)
})
})
@@ -163,17 +78,16 @@ describe('useMediaAssetGalleryStore', () => {
describe('close', () => {
it('should reset activeIndex to -1', () => {
const store = useMediaAssetGalleryStore()
const mockAsset: AssetMeta = {
const asset: AssetItem = {
id: 'test',
name: 'test.png',
kind: 'image',
src: 'test-url',
size: 1024,
preview_url: 'test-url',
tags: [],
created_at: '2025-01-01'
}
store.openSingle(mockAsset)
store.openSingle(asset)
expect(store.activeIndex).toBe(0)
store.close()

View File

@@ -1,39 +1,20 @@
import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'
import { ResultItemImpl } from '@/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import type { AssetItem } from '../schemas/assetSchema'
export const useMediaAssetGalleryStore = defineStore(
'mediaAssetGallery',
() => {
const activeIndex = ref(-1)
const items = shallowRef<ResultItemImpl[]>([])
const items = shallowRef<AssetItem[]>([])
const close = () => {
activeIndex.value = -1
}
const openSingle = (asset: AssetMeta) => {
// Convert AssetMeta to ResultItemImpl format
const resultItem = new ResultItemImpl({
filename: asset.name,
subfolder: '',
type: 'output',
nodeId: '0',
mediaType: asset.kind === 'image' ? 'images' : asset.kind
})
// Override the url getter to use asset.src
Object.defineProperty(resultItem, 'url', {
get() {
return asset.src || ''
},
configurable: true
})
items.value = [resultItem]
const openSingle = (asset: AssetItem) => {
items.value = [asset]
activeIndex.value = 0
}

View File

@@ -16,7 +16,8 @@ const zAsset = z.object({
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
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
prompt_id: z.string().nullish()
})
const zAssetResponse = z.object({

View File

@@ -31,6 +31,9 @@ export interface PaginationOptions {
interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
includePublic?: boolean
jobIds?: string[]
sort?: string
order?: 'asc' | 'desc'
}
interface AssetExportOptions {
@@ -202,7 +205,10 @@ function createAssetService() {
includeTags,
limit = DEFAULT_LIMIT,
offset,
includePublic
includePublic,
jobIds,
sort,
order
} = options
const queryParams = new URLSearchParams({
include_tags: includeTags.join(','),
@@ -214,6 +220,15 @@ function createAssetService() {
if (includePublic !== undefined) {
queryParams.set('include_public', includePublic ? 'true' : 'false')
}
if (jobIds?.length) {
queryParams.set('job_ids', jobIds.join(','))
}
if (sort) {
queryParams.set('sort', sort)
}
if (order) {
queryParams.set('order', order)
}
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
const res = await api.fetchApi(url)
@@ -754,6 +769,25 @@ function createAssetService() {
return await res.json()
}
async function getOutputAssets(
options?: PaginationOptions & { sort?: string; order?: 'asc' | 'desc' }
): Promise<AssetResponse> {
return handleAssetRequest(
{ includeTags: ['output'], ...options },
'output assets'
)
}
async function getAssetsByJobIds(
jobIds: string[],
options?: PaginationOptions
): Promise<AssetResponse> {
return handleAssetRequest(
{ includeTags: ['output'], jobIds, ...options },
'job assets'
)
}
return {
getAssetModelFolders,
getAssetModels,
@@ -772,7 +806,9 @@ function createAssetService() {
uploadAssetFromBase64,
uploadAssetAsync,
createAssetExport,
getExportDownloadUrl
getExportDownloadUrl,
getOutputAssets,
getAssetsByJobIds
}
}