mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
refactor(assets): remove isCloud forks in assetsStore + delete legacy fetcher
FE-730 (L1.3). Both Cloud and OSS will serve /api/assets post-BE-786, so assetsStore collapses to a single asset-API code path. - Drop fetchInputFilesFromAPI (legacy /files/input caller); rename fetchInputFilesFromCloud to fetchInputFiles. - Remove the isCloud ternary on the input fetcher swap. - Unwrap getModelState()'s if(isCloud) body and delete its OSS no-op return object; the model pagination subsystem now runs unconditionally. - Drop now-dead isCloud + mapInputFileToAssetItem imports. - Delete mapInputFileToAssetItem + stripDirectoryAnnotation from assetMappers.ts (no remaining callers in src/); delete its test file. - Simplify assetsStore.test.ts: hardcode the distribution mock to isCloud: true, drop the mockIsCloud toggle helper and the dead mapper mock, rename the now-unconditional "(Cloud)" describe blocks. Blocked-merge by BE-786 (OSS --enable-assets removal) + FE-729 (isAssetAPIEnabled deletion). Opened as a Draft on the FE-729 stack. Auto-fixed unrelated tailwind class-order lint errors in five files via pnpm lint:fix to keep CI green.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="h-full scrollbar-thin scrollbar-thumb-(--dialog-surface) scrollbar-track-transparent scrollbar-gutter-stable overflow-y-auto [overflow-anchor:none]"
|
||||
class="scrollbar-thin scrollbar-thumb-(--dialog-surface) scrollbar-track-transparent scrollbar-gutter-stable h-full overflow-y-auto [overflow-anchor:none]"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
|
||||
@@ -370,7 +370,7 @@ function handleTitleCancel() {
|
||||
</section>
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="flex-1 scrollbar-thin overflow-y-auto">
|
||||
<div class="scrollbar-thin flex-1 overflow-y-auto">
|
||||
<TabErrors v-if="activeTab === 'errors'" />
|
||||
<template v-else-if="!hasSelection">
|
||||
<TabGlobalParameters v-if="activeTab === 'parameters'" />
|
||||
|
||||
@@ -23,7 +23,7 @@ defineExpose({
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-16 w-full scrollbar-gutter-stable rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border-default focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
'scrollbar-gutter-stable flex min-h-16 w-full rounded-lg border-none bg-secondary-background px-3 py-2 text-sm text-base-foreground placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border-default focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
rows="3"
|
||||
:class="
|
||||
cn(
|
||||
'w-full resize-y scrollbar-gutter-stable rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground transition-colors outline-none focus:bg-component-node-widget-background',
|
||||
'scrollbar-gutter-stable w-full resize-y rounded-lg border border-transparent bg-transparent px-3 py-2 text-sm text-component-node-foreground transition-colors outline-none focus:bg-component-node-widget-background',
|
||||
isImmutable && 'cursor-not-allowed'
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { mapInputFileToAssetItem } from './assetMappers'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (path: string) => `/api${path}`
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
|
||||
appendCloudResParam: vi.fn()
|
||||
}))
|
||||
|
||||
describe('mapInputFileToAssetItem', () => {
|
||||
it('preserves a clean filename', () => {
|
||||
const asset = mapInputFileToAssetItem('photo.png', 0, 'input')
|
||||
|
||||
expect(asset.name).toBe('photo.png')
|
||||
expect(asset.id).toBe('input-0-photo.png')
|
||||
expect(asset.preview_url).toBe('/api/view?filename=photo.png&type=input')
|
||||
})
|
||||
|
||||
it.for([
|
||||
['photo.png [input]', 'photo.png'],
|
||||
['photo.png [output]', 'photo.png'],
|
||||
['photo.png [temp]', 'photo.png'],
|
||||
['clip.mp4[input]', 'clip.mp4'],
|
||||
['MyFile.WEBP [Input]', 'MyFile.WEBP']
|
||||
])(
|
||||
'strips ComfyUI directory annotation: %s -> %s',
|
||||
([input, expectedName]) => {
|
||||
const asset = mapInputFileToAssetItem(input, 1, 'input')
|
||||
|
||||
expect(asset.name).toBe(expectedName)
|
||||
expect(asset.id).toBe(`input-1-${expectedName}`)
|
||||
expect(asset.preview_url).toBe(
|
||||
`/api/view?filename=${encodeURIComponent(expectedName)}&type=input`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves non-annotation brackets in the filename intact', () => {
|
||||
const asset = mapInputFileToAssetItem('my [draft] image.png', 0, 'input')
|
||||
|
||||
expect(asset.name).toBe('my [draft] image.png')
|
||||
})
|
||||
|
||||
it('uses the directory passed in for the type query param', () => {
|
||||
const asset = mapInputFileToAssetItem('clip.mp4 [output]', 0, 'output')
|
||||
|
||||
expect(asset.preview_url).toBe('/api/view?filename=clip.mp4&type=output')
|
||||
expect(asset.tags).toEqual(['output'])
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetContext } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
/**
|
||||
@@ -50,42 +48,3 @@ export function mapTaskOutputToAssetItem(
|
||||
user_metadata: metadata
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips ComfyUI's trailing directory-type annotation (e.g. ` [input]`,
|
||||
* ` [output]`, `[temp]`) from a filename returned by the OSS internal
|
||||
* `/internal/files/{type}` endpoint. The annotation is part of the wire
|
||||
* format LoadImage-style widgets expect, but for the assets sidebar we
|
||||
* want the canonical on-disk filename so type detection / titles work.
|
||||
*/
|
||||
function stripDirectoryAnnotation(filename: string): string {
|
||||
return filename.replace(/\s*\[(?:input|output|temp)\]\s*$/i, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps input directory file to AssetItem format
|
||||
* @param filename The filename
|
||||
* @param index File index for unique ID
|
||||
* @param directory The directory type
|
||||
* @returns AssetItem formatted object
|
||||
*/
|
||||
export function mapInputFileToAssetItem(
|
||||
filename: string,
|
||||
index: number,
|
||||
directory: 'input' | 'output' = 'input'
|
||||
): AssetItem {
|
||||
const cleanName = stripDirectoryAnnotation(filename)
|
||||
const params = new URLSearchParams({ filename: cleanName, type: directory })
|
||||
const preview_url = api.apiURL(`/view?${params}`)
|
||||
appendCloudResParam(params, cleanName)
|
||||
|
||||
return {
|
||||
id: `${directory}-${index}-${cleanName}`,
|
||||
name: cleanName,
|
||||
size: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
tags: [directory],
|
||||
thumbnail_url: api.apiURL(`/view?${params}`),
|
||||
preview_url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<div
|
||||
data-testid="widget-select-default-viewport"
|
||||
role="presentation"
|
||||
class="flex max-h-56 min-w-full scrollbar-thin scrollbar-thumb-alpha-smoke-500-50 scrollbar-track-transparent scrollbar-gutter-stable flex-col gap-1 overflow-y-auto p-1 text-xs"
|
||||
class="scrollbar-thin scrollbar-thumb-alpha-smoke-500-50 scrollbar-track-transparent scrollbar-gutter-stable flex max-h-56 min-w-full flex-col gap-1 overflow-y-auto p-1 text-xs"
|
||||
:style="viewportStyle"
|
||||
@pointerdown.capture.self="handleViewportPointerDown"
|
||||
>
|
||||
|
||||
@@ -33,12 +33,8 @@ vi.mock('@/platform/assets/services/assetService', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock distribution type - hoisted so it can be changed per test
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
}
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
// Mock modelToNodeStore with proper node providers and category lookups
|
||||
@@ -152,14 +148,6 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
|
||||
// Mock asset mappers - add unique timestamps
|
||||
vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
|
||||
mapInputFileToAssetItem: vi.fn((name, index, type) => ({
|
||||
id: `${type}-${index}`,
|
||||
name,
|
||||
size: 0,
|
||||
created_at: new Date(Date.now() - index * 1000).toISOString(),
|
||||
tags: [type],
|
||||
preview_url: `http://test.com/${name}`
|
||||
})),
|
||||
mapTaskOutputToAssetItem: vi.fn((task, output) => {
|
||||
const index = parseInt(task.jobId.split('_')[1]) || 0
|
||||
return {
|
||||
@@ -767,17 +755,12 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
describe('assetsStore - Model Assets Cache', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockIsCloud.value = true
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mockIsCloud.value = false
|
||||
})
|
||||
|
||||
const createMockAsset = (id: string, tags: string[] = ['models']) => ({
|
||||
id,
|
||||
name: `asset-${id}`,
|
||||
@@ -1451,25 +1434,20 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
|
||||
describe('getInputName', () => {
|
||||
it('resolves a hashed filename to the human-readable name when the input asset is in the cache', async () => {
|
||||
mockIsCloud.value = true
|
||||
try {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const store = useAssetsStore()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const store = useAssetsStore()
|
||||
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'input-1',
|
||||
name: 'cute-puppy.png',
|
||||
asset_hash: 'abc123def.png',
|
||||
tags: ['input']
|
||||
}
|
||||
])
|
||||
await store.updateInputs()
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'input-1',
|
||||
name: 'cute-puppy.png',
|
||||
asset_hash: 'abc123def.png',
|
||||
tags: ['input']
|
||||
}
|
||||
])
|
||||
await store.updateInputs()
|
||||
|
||||
expect(store.getInputName('abc123def.png')).toBe('cute-puppy.png')
|
||||
} finally {
|
||||
mockIsCloud.value = false
|
||||
}
|
||||
expect(store.getInputName('abc123def.png')).toBe('cute-puppy.png')
|
||||
})
|
||||
|
||||
it('falls back to the original filename when the input asset is not cached', () => {
|
||||
@@ -1478,27 +1456,22 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateInputs cloud routing', () => {
|
||||
it('reads from assetService.getAssetsByTag with limit 100 when isCloud is true', async () => {
|
||||
mockIsCloud.value = true
|
||||
try {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const store = useAssetsStore()
|
||||
describe('updateInputs', () => {
|
||||
it('reads from assetService.getAssetsByTag with limit 100', async () => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
const store = useAssetsStore()
|
||||
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([])
|
||||
await store.updateInputs()
|
||||
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([])
|
||||
await store.updateInputs()
|
||||
|
||||
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledWith(
|
||||
'input',
|
||||
false,
|
||||
{ limit: 100 }
|
||||
)
|
||||
expect(
|
||||
assetService.invalidateInputAssetsIncludingPublic
|
||||
).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
mockIsCloud.value = false
|
||||
}
|
||||
expect(vi.mocked(assetService.getAssetsByTag)).toHaveBeenCalledWith(
|
||||
'input',
|
||||
false,
|
||||
{ limit: 100 }
|
||||
)
|
||||
expect(
|
||||
assetService.invalidateInputAssetsIncludingPublic
|
||||
).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,17 +2,13 @@ import { useAsyncState, whenever } from '@vueuse/core'
|
||||
import { difference } from 'es-toolkit'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref, shallowReactive } from 'vue'
|
||||
import {
|
||||
mapInputFileToAssetItem,
|
||||
mapTaskOutputToAssetItem
|
||||
} from '@/platform/assets/composables/media/assetMappers'
|
||||
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
|
||||
import type {
|
||||
AssetItem,
|
||||
TagsOperationResult
|
||||
} from '@/platform/assets/schemas/assetSchema'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { PaginationOptions } from '@/platform/assets/services/assetService'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
@@ -22,30 +18,7 @@ import { useModelToNodeStore } from './modelToNodeStore'
|
||||
|
||||
const INPUT_LIMIT = 100
|
||||
|
||||
/**
|
||||
* Fetch input files from the internal API (OSS version)
|
||||
*/
|
||||
async function fetchInputFilesFromAPI(): Promise<AssetItem[]> {
|
||||
const response = await fetch(api.internalURL('/files/input'), {
|
||||
headers: {
|
||||
'Comfy-User': api.user
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch input files')
|
||||
}
|
||||
|
||||
const filenames: string[] = await response.json()
|
||||
return filenames.map((name, index) =>
|
||||
mapInputFileToAssetItem(name, index, 'input')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch input files from cloud service
|
||||
*/
|
||||
async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
|
||||
async function fetchInputFiles(): Promise<AssetItem[]> {
|
||||
return await assetService.getAssetsByTag('input', false, {
|
||||
limit: INPUT_LIMIT
|
||||
})
|
||||
@@ -118,10 +91,6 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
|
||||
const loadedIds = shallowReactive(new Set<string>())
|
||||
|
||||
const fetchInputFiles = isCloud
|
||||
? fetchInputFilesFromCloud
|
||||
: fetchInputFilesFromAPI
|
||||
|
||||
const {
|
||||
state: inputAssets,
|
||||
isLoading: inputLoading,
|
||||
@@ -321,418 +290,400 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
* Cloud-only feature - empty Maps in desktop builds
|
||||
*/
|
||||
const getModelState = () => {
|
||||
if (isCloud) {
|
||||
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
|
||||
const modelStateByCategory = ref(new Map<string, ModelPaginationState>())
|
||||
|
||||
const assetsArrayCache = new Map<
|
||||
string,
|
||||
{ source: Map<string, AssetItem>; array: AssetItem[] }
|
||||
>()
|
||||
const assetsArrayCache = new Map<
|
||||
string,
|
||||
{ source: Map<string, AssetItem>; array: AssetItem[] }
|
||||
>()
|
||||
|
||||
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
|
||||
const pendingPromiseByCategory = new Map<string, Promise<void>>()
|
||||
const pendingRequestByCategory = new Map<string, ModelPaginationState>()
|
||||
const pendingPromiseByCategory = new Map<string, Promise<void>>()
|
||||
|
||||
function createState(
|
||||
existingAssets?: Map<string, AssetItem>
|
||||
): ModelPaginationState {
|
||||
const assets = new Map(existingAssets)
|
||||
return reactive({
|
||||
assets,
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
isLoading: true
|
||||
})
|
||||
function createState(
|
||||
existingAssets?: Map<string, AssetItem>
|
||||
): ModelPaginationState {
|
||||
const assets = new Map(existingAssets)
|
||||
return reactive({
|
||||
assets,
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
isLoading: true
|
||||
})
|
||||
}
|
||||
|
||||
function isStale(category: string, state: ModelPaginationState): boolean {
|
||||
const committed = modelStateByCategory.value.get(category)
|
||||
const pending = pendingRequestByCategory.get(category)
|
||||
return committed !== state && pending !== state
|
||||
}
|
||||
|
||||
const EMPTY_ASSETS: AssetItem[] = []
|
||||
|
||||
/**
|
||||
* Resolve a key to a category. Handles both nodeType and tag:xxx formats.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
* @returns The category or undefined if not resolvable
|
||||
*/
|
||||
function resolveCategory(key: string): string | undefined {
|
||||
if (key.startsWith('tag:')) {
|
||||
return key
|
||||
}
|
||||
return modelToNodeStore.getCategoryForNodeType(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets by nodeType or tag key.
|
||||
* Translates nodeType to category internally for cache lookup.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
*/
|
||||
function getAssets(key: string): AssetItem[] {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return EMPTY_ASSETS
|
||||
|
||||
const state = modelStateByCategory.value.get(category)
|
||||
const assetsMap = state?.assets
|
||||
if (!assetsMap) return EMPTY_ASSETS
|
||||
|
||||
const cached = assetsArrayCache.get(category)
|
||||
if (cached && cached.source === assetsMap) {
|
||||
return cached.array
|
||||
}
|
||||
|
||||
function isStale(category: string, state: ModelPaginationState): boolean {
|
||||
const committed = modelStateByCategory.value.get(category)
|
||||
const pending = pendingRequestByCategory.get(category)
|
||||
return committed !== state && pending !== state
|
||||
const array = Array.from(assetsMap.values())
|
||||
assetsArrayCache.set(category, { source: assetsMap, array })
|
||||
return array
|
||||
}
|
||||
|
||||
function isLoading(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.isLoading ?? false
|
||||
}
|
||||
|
||||
function getError(key: string): Error | undefined {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return undefined
|
||||
return modelStateByCategory.value.get(category)?.error
|
||||
}
|
||||
|
||||
function hasMore(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.hasMore ?? false
|
||||
}
|
||||
|
||||
function hasAssetKey(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.has(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category exists in the cache.
|
||||
* Checks both direct category keys and tag-prefixed keys.
|
||||
* @param category The category to check (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function hasCategory(category: string): boolean {
|
||||
return (
|
||||
modelStateByCategory.value.has(category) ||
|
||||
modelStateByCategory.value.has(`tag:${category}`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and cache assets for a category.
|
||||
* Loads first batch immediately, then progressively loads remaining batches.
|
||||
* Keeps existing data visible until new data is successfully fetched.
|
||||
*
|
||||
* Concurrent calls for the same category are short-circuited: if a request
|
||||
* is already in progress (tracked via pendingRequestByCategory), subsequent
|
||||
* calls return immediately to avoid redundant work.
|
||||
*/
|
||||
async function updateModelsForCategory(
|
||||
category: string,
|
||||
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
|
||||
): Promise<void> {
|
||||
if (pendingPromiseByCategory.has(category)) {
|
||||
return pendingPromiseByCategory.get(category)!
|
||||
}
|
||||
|
||||
const EMPTY_ASSETS: AssetItem[] = []
|
||||
const existingState = modelStateByCategory.value.get(category)
|
||||
const state = createState(existingState?.assets)
|
||||
|
||||
/**
|
||||
* Resolve a key to a category. Handles both nodeType and tag:xxx formats.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
* @returns The category or undefined if not resolvable
|
||||
*/
|
||||
function resolveCategory(key: string): string | undefined {
|
||||
if (key.startsWith('tag:')) {
|
||||
return key
|
||||
}
|
||||
return modelToNodeStore.getCategoryForNodeType(key)
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
const hasExistingData = modelStateByCategory.value.has(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.set(category, state)
|
||||
} else {
|
||||
// Also track in pending map for initial loads to prevent concurrent calls
|
||||
pendingRequestByCategory.set(category, state)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets by nodeType or tag key.
|
||||
* Translates nodeType to category internally for cache lookup.
|
||||
* @param key Either a nodeType (e.g., 'CheckpointLoaderSimple') or tag key (e.g., 'tag:models')
|
||||
*/
|
||||
function getAssets(key: string): AssetItem[] {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return EMPTY_ASSETS
|
||||
async function loadBatches(): Promise<void> {
|
||||
while (state.hasMore) {
|
||||
try {
|
||||
const newAssets = await fetcher({
|
||||
limit: MODEL_BATCH_SIZE,
|
||||
offset: state.offset
|
||||
})
|
||||
|
||||
const state = modelStateByCategory.value.get(category)
|
||||
const assetsMap = state?.assets
|
||||
if (!assetsMap) return EMPTY_ASSETS
|
||||
if (isStale(category, state)) return
|
||||
|
||||
const cached = assetsArrayCache.get(category)
|
||||
if (cached && cached.source === assetsMap) {
|
||||
return cached.array
|
||||
}
|
||||
|
||||
const array = Array.from(assetsMap.values())
|
||||
assetsArrayCache.set(category, { source: assetsMap, array })
|
||||
return array
|
||||
}
|
||||
|
||||
function isLoading(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.isLoading ?? false
|
||||
}
|
||||
|
||||
function getError(key: string): Error | undefined {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return undefined
|
||||
return modelStateByCategory.value.get(category)?.error
|
||||
}
|
||||
|
||||
function hasMore(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.get(category)?.hasMore ?? false
|
||||
}
|
||||
|
||||
function hasAssetKey(key: string): boolean {
|
||||
const category = resolveCategory(key)
|
||||
if (!category) return false
|
||||
return modelStateByCategory.value.has(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category exists in the cache.
|
||||
* Checks both direct category keys and tag-prefixed keys.
|
||||
* @param category The category to check (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function hasCategory(category: string): boolean {
|
||||
return (
|
||||
modelStateByCategory.value.has(category) ||
|
||||
modelStateByCategory.value.has(`tag:${category}`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to fetch and cache assets for a category.
|
||||
* Loads first batch immediately, then progressively loads remaining batches.
|
||||
* Keeps existing data visible until new data is successfully fetched.
|
||||
*
|
||||
* Concurrent calls for the same category are short-circuited: if a request
|
||||
* is already in progress (tracked via pendingRequestByCategory), subsequent
|
||||
* calls return immediately to avoid redundant work.
|
||||
*/
|
||||
async function updateModelsForCategory(
|
||||
category: string,
|
||||
fetcher: (options: PaginationOptions) => Promise<AssetItem[]>
|
||||
): Promise<void> {
|
||||
if (pendingPromiseByCategory.has(category)) {
|
||||
return pendingPromiseByCategory.get(category)!
|
||||
}
|
||||
|
||||
const existingState = modelStateByCategory.value.get(category)
|
||||
const state = createState(existingState?.assets)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
|
||||
const hasExistingData = modelStateByCategory.value.has(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.set(category, state)
|
||||
} else {
|
||||
// Also track in pending map for initial loads to prevent concurrent calls
|
||||
pendingRequestByCategory.set(category, state)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
|
||||
async function loadBatches(): Promise<void> {
|
||||
while (state.hasMore) {
|
||||
try {
|
||||
const newAssets = await fetcher({
|
||||
limit: MODEL_BATCH_SIZE,
|
||||
offset: state.offset
|
||||
})
|
||||
|
||||
if (isStale(category, state)) return
|
||||
|
||||
const isFirstBatch = state.offset === 0
|
||||
if (isFirstBatch) {
|
||||
assetsArrayCache.delete(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.delete(category)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
const isFirstBatch = state.offset === 0
|
||||
if (isFirstBatch) {
|
||||
assetsArrayCache.delete(category)
|
||||
if (hasExistingData) {
|
||||
pendingRequestByCategory.delete(category)
|
||||
modelStateByCategory.value.set(category, state)
|
||||
}
|
||||
|
||||
// Merge new assets into existing map and track seen IDs
|
||||
for (const asset of newAssets) {
|
||||
seenIds.add(asset.id)
|
||||
state.assets.set(asset.id, asset)
|
||||
}
|
||||
state.assets = new Map(state.assets)
|
||||
|
||||
state.offset += newAssets.length
|
||||
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
|
||||
|
||||
if (isFirstBatch) {
|
||||
state.isLoading = false
|
||||
}
|
||||
|
||||
if (state.hasMore) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
} catch (err) {
|
||||
if (isStale(category, state)) return
|
||||
console.error(`Error loading batch for ${category}:`, err)
|
||||
|
||||
state.error = err instanceof Error ? err : new Error(String(err))
|
||||
state.hasMore = false
|
||||
state.isLoading = false
|
||||
pendingRequestByCategory.delete(category)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const staleIds = [...state.assets.keys()].filter(
|
||||
(id) => !seenIds.has(id)
|
||||
)
|
||||
for (const id of staleIds) {
|
||||
state.assets.delete(id)
|
||||
// Merge new assets into existing map and track seen IDs
|
||||
for (const asset of newAssets) {
|
||||
seenIds.add(asset.id)
|
||||
state.assets.set(asset.id, asset)
|
||||
}
|
||||
state.assets = new Map(state.assets)
|
||||
|
||||
state.offset += newAssets.length
|
||||
state.hasMore = newAssets.length === MODEL_BATCH_SIZE
|
||||
|
||||
if (isFirstBatch) {
|
||||
state.isLoading = false
|
||||
}
|
||||
|
||||
if (state.hasMore) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
} catch (err) {
|
||||
if (isStale(category, state)) return
|
||||
console.error(`Error loading batch for ${category}:`, err)
|
||||
|
||||
state.error = err instanceof Error ? err : new Error(String(err))
|
||||
state.hasMore = false
|
||||
state.isLoading = false
|
||||
pendingRequestByCategory.delete(category)
|
||||
|
||||
return
|
||||
}
|
||||
assetsArrayCache.delete(category)
|
||||
pendingRequestByCategory.delete(category)
|
||||
}
|
||||
|
||||
const promise = loadBatches().finally(() => {
|
||||
pendingPromiseByCategory.delete(category)
|
||||
})
|
||||
pendingPromiseByCategory.set(category, promise)
|
||||
await promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache model assets for a specific node type.
|
||||
* Translates nodeType to category internally - multiple node types
|
||||
* sharing the same category will share the same cache entry.
|
||||
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
|
||||
*/
|
||||
async function updateModelsForNodeType(nodeType: string): Promise<void> {
|
||||
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
|
||||
if (!category) return
|
||||
|
||||
// Use category as cache key but fetch using nodeType for API compatibility
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsForNodeType(nodeType, opts)
|
||||
const staleIds = [...state.assets.keys()].filter(
|
||||
(id) => !seenIds.has(id)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache model assets for a specific tag
|
||||
* @param tag The tag to fetch assets for (e.g., 'models')
|
||||
*/
|
||||
async function updateModelsForTag(tag: string): Promise<void> {
|
||||
const category = `tag:${tag}`
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsByTag(tag, true, opts)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cache for a specific category.
|
||||
* Forces a refetch on next access.
|
||||
* @param category The category to invalidate (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function invalidateCategory(category: string): void {
|
||||
modelStateByCategory.value.delete(category)
|
||||
for (const id of staleIds) {
|
||||
state.assets.delete(id)
|
||||
}
|
||||
assetsArrayCache.delete(category)
|
||||
pendingRequestByCategory.delete(category)
|
||||
}
|
||||
|
||||
const promise = loadBatches().finally(() => {
|
||||
pendingPromiseByCategory.delete(category)
|
||||
}
|
||||
})
|
||||
pendingPromiseByCategory.set(category, promise)
|
||||
await promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically update an asset in the cache
|
||||
* @param assetId The asset ID to update
|
||||
* @param updates Partial asset data to merge
|
||||
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
|
||||
*/
|
||||
function updateAssetInCache(
|
||||
assetId: string,
|
||||
updates: Partial<AssetItem>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const category = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (cacheKey && !category) return
|
||||
/**
|
||||
* Fetch and cache model assets for a specific node type.
|
||||
* Translates nodeType to category internally - multiple node types
|
||||
* sharing the same category will share the same cache entry.
|
||||
* @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple')
|
||||
*/
|
||||
async function updateModelsForNodeType(nodeType: string): Promise<void> {
|
||||
const category = modelToNodeStore.getCategoryForNodeType(nodeType)
|
||||
if (!category) return
|
||||
|
||||
const categoriesToCheck = category
|
||||
? [category]
|
||||
: Array.from(modelStateByCategory.value.keys())
|
||||
// Use category as cache key but fetch using nodeType for API compatibility
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsForNodeType(nodeType, opts)
|
||||
)
|
||||
}
|
||||
|
||||
for (const cat of categoriesToCheck) {
|
||||
const state = modelStateByCategory.value.get(cat)
|
||||
if (!state?.assets) continue
|
||||
/**
|
||||
* Fetch and cache model assets for a specific tag
|
||||
* @param tag The tag to fetch assets for (e.g., 'models')
|
||||
*/
|
||||
async function updateModelsForTag(tag: string): Promise<void> {
|
||||
const category = `tag:${tag}`
|
||||
await updateModelsForCategory(category, (opts) =>
|
||||
assetService.getAssetsByTag(tag, true, opts)
|
||||
)
|
||||
}
|
||||
|
||||
const existingAsset = state.assets.get(assetId)
|
||||
if (existingAsset) {
|
||||
const updatedAsset = { ...existingAsset, ...updates }
|
||||
state.assets.set(assetId, updatedAsset)
|
||||
assetsArrayCache.delete(cat)
|
||||
if (cacheKey) return
|
||||
}
|
||||
/**
|
||||
* Invalidate the cache for a specific category.
|
||||
* Forces a refetch on next access.
|
||||
* @param category The category to invalidate (e.g., 'checkpoints', 'loras')
|
||||
*/
|
||||
function invalidateCategory(category: string): void {
|
||||
modelStateByCategory.value.delete(category)
|
||||
assetsArrayCache.delete(category)
|
||||
pendingRequestByCategory.delete(category)
|
||||
pendingPromiseByCategory.delete(category)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically update an asset in the cache
|
||||
* @param assetId The asset ID to update
|
||||
* @param updates Partial asset data to merge
|
||||
* @param cacheKey Optional cache key to target (nodeType or 'tag:xxx')
|
||||
*/
|
||||
function updateAssetInCache(
|
||||
assetId: string,
|
||||
updates: Partial<AssetItem>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const category = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (cacheKey && !category) return
|
||||
|
||||
const categoriesToCheck = category
|
||||
? [category]
|
||||
: Array.from(modelStateByCategory.value.keys())
|
||||
|
||||
for (const cat of categoriesToCheck) {
|
||||
const state = modelStateByCategory.value.get(cat)
|
||||
if (!state?.assets) continue
|
||||
|
||||
const existingAsset = state.assets.get(assetId)
|
||||
if (existingAsset) {
|
||||
const updatedAsset = { ...existingAsset, ...updates }
|
||||
state.assets.set(assetId, updatedAsset)
|
||||
assetsArrayCache.delete(cat)
|
||||
if (cacheKey) return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset metadata with optimistic cache update
|
||||
* @param asset The asset to update
|
||||
* @param userMetadata The user_metadata to save
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetMetadata(
|
||||
asset: AssetItem,
|
||||
userMetadata: Record<string, unknown>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalMetadata = asset.user_metadata
|
||||
updateAssetInCache(asset.id, { user_metadata: userMetadata }, cacheKey)
|
||||
|
||||
try {
|
||||
const updatedAsset = await assetService.updateAsset(asset.id, {
|
||||
user_metadata: userMetadata
|
||||
})
|
||||
updateAssetInCache(asset.id, updatedAsset, cacheKey)
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset metadata:', error)
|
||||
updateAssetInCache(
|
||||
asset.id,
|
||||
{ user_metadata: originalMetadata },
|
||||
cacheKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset tags using add/remove endpoints
|
||||
* @param asset The asset to update (used to read current tags)
|
||||
* @param newTags The desired tags array
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetTags(
|
||||
asset: AssetItem,
|
||||
newTags: string[],
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalTags = asset.tags
|
||||
const tagsToAdd = difference(newTags, originalTags)
|
||||
const tagsToRemove = difference(originalTags, newTags)
|
||||
|
||||
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
|
||||
|
||||
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
|
||||
|
||||
let removedTagsOnServer: string[] = []
|
||||
try {
|
||||
let removeResult: TagsOperationResult | undefined
|
||||
if (tagsToRemove.length > 0) {
|
||||
removeResult = await assetService.removeAssetTags(
|
||||
asset.id,
|
||||
tagsToRemove
|
||||
)
|
||||
removedTagsOnServer = removeResult.removed ?? tagsToRemove
|
||||
}
|
||||
|
||||
const addResult =
|
||||
tagsToAdd.length > 0
|
||||
? await assetService.addAssetTags(asset.id, tagsToAdd)
|
||||
: undefined
|
||||
|
||||
const finalTags = (addResult ?? removeResult)?.total_tags
|
||||
if (finalTags) {
|
||||
updateAssetInCache(asset.id, { tags: finalTags }, cacheKey)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset tags:', error)
|
||||
updateAssetInCache(asset.id, { tags: originalTags }, cacheKey)
|
||||
|
||||
if (removedTagsOnServer.length > 0) {
|
||||
try {
|
||||
await assetService.addAssetTags(asset.id, removedTagsOnServer)
|
||||
} catch (compensationError) {
|
||||
console.error(
|
||||
'Failed to restore tags after partial failure; invalidating cache to force refetch:',
|
||||
compensationError
|
||||
)
|
||||
const categoriesToInvalidate = new Set<string>()
|
||||
const resolved = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (resolved) {
|
||||
categoriesToInvalidate.add(resolved)
|
||||
}
|
||||
for (const [
|
||||
category,
|
||||
state
|
||||
] of modelStateByCategory.value.entries()) {
|
||||
if (state.assets?.has(asset.id)) {
|
||||
categoriesToInvalidate.add(category)
|
||||
}
|
||||
}
|
||||
for (const category of categoriesToInvalidate) {
|
||||
invalidateCategory(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
|
||||
* Clears the category cache and tag-based caches so next access triggers refetch
|
||||
* @param category The model category to invalidate (e.g., 'checkpoints')
|
||||
*/
|
||||
function invalidateModelsForCategory(category: string): void {
|
||||
invalidateCategory(category)
|
||||
invalidateCategory(`tag:${category}`)
|
||||
invalidateCategory('tag:models')
|
||||
}
|
||||
|
||||
return {
|
||||
getAssets,
|
||||
isLoading,
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
}
|
||||
}
|
||||
|
||||
const emptyAssets: AssetItem[] = []
|
||||
/**
|
||||
* Update asset metadata with optimistic cache update
|
||||
* @param asset The asset to update
|
||||
* @param userMetadata The user_metadata to save
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetMetadata(
|
||||
asset: AssetItem,
|
||||
userMetadata: Record<string, unknown>,
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalMetadata = asset.user_metadata
|
||||
updateAssetInCache(asset.id, { user_metadata: userMetadata }, cacheKey)
|
||||
|
||||
try {
|
||||
const updatedAsset = await assetService.updateAsset(asset.id, {
|
||||
user_metadata: userMetadata
|
||||
})
|
||||
updateAssetInCache(asset.id, updatedAsset, cacheKey)
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset metadata:', error)
|
||||
updateAssetInCache(
|
||||
asset.id,
|
||||
{ user_metadata: originalMetadata },
|
||||
cacheKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update asset tags using add/remove endpoints
|
||||
* @param asset The asset to update (used to read current tags)
|
||||
* @param newTags The desired tags array
|
||||
* @param cacheKey Optional cache key to target for optimistic update
|
||||
*/
|
||||
async function updateAssetTags(
|
||||
asset: AssetItem,
|
||||
newTags: string[],
|
||||
cacheKey?: string
|
||||
) {
|
||||
const originalTags = asset.tags
|
||||
const tagsToAdd = difference(newTags, originalTags)
|
||||
const tagsToRemove = difference(originalTags, newTags)
|
||||
|
||||
if (tagsToAdd.length === 0 && tagsToRemove.length === 0) return
|
||||
|
||||
updateAssetInCache(asset.id, { tags: newTags }, cacheKey)
|
||||
|
||||
let removedTagsOnServer: string[] = []
|
||||
try {
|
||||
let removeResult: TagsOperationResult | undefined
|
||||
if (tagsToRemove.length > 0) {
|
||||
removeResult = await assetService.removeAssetTags(
|
||||
asset.id,
|
||||
tagsToRemove
|
||||
)
|
||||
removedTagsOnServer = removeResult.removed ?? tagsToRemove
|
||||
}
|
||||
|
||||
const addResult =
|
||||
tagsToAdd.length > 0
|
||||
? await assetService.addAssetTags(asset.id, tagsToAdd)
|
||||
: undefined
|
||||
|
||||
const finalTags = (addResult ?? removeResult)?.total_tags
|
||||
if (finalTags) {
|
||||
updateAssetInCache(asset.id, { tags: finalTags }, cacheKey)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update asset tags:', error)
|
||||
updateAssetInCache(asset.id, { tags: originalTags }, cacheKey)
|
||||
|
||||
if (removedTagsOnServer.length > 0) {
|
||||
try {
|
||||
await assetService.addAssetTags(asset.id, removedTagsOnServer)
|
||||
} catch (compensationError) {
|
||||
console.error(
|
||||
'Failed to restore tags after partial failure; invalidating cache to force refetch:',
|
||||
compensationError
|
||||
)
|
||||
const categoriesToInvalidate = new Set<string>()
|
||||
const resolved = cacheKey ? resolveCategory(cacheKey) : undefined
|
||||
if (resolved) {
|
||||
categoriesToInvalidate.add(resolved)
|
||||
}
|
||||
for (const [
|
||||
category,
|
||||
state
|
||||
] of modelStateByCategory.value.entries()) {
|
||||
if (state.assets?.has(asset.id)) {
|
||||
categoriesToInvalidate.add(category)
|
||||
}
|
||||
}
|
||||
for (const category of categoriesToInvalidate) {
|
||||
invalidateCategory(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate model caches for a given category (e.g., 'checkpoints', 'loras')
|
||||
* Clears the category cache and tag-based caches so next access triggers refetch
|
||||
* @param category The model category to invalidate (e.g., 'checkpoints')
|
||||
*/
|
||||
function invalidateModelsForCategory(category: string): void {
|
||||
invalidateCategory(category)
|
||||
invalidateCategory(`tag:${category}`)
|
||||
invalidateCategory('tag:models')
|
||||
}
|
||||
|
||||
return {
|
||||
getAssets: () => emptyAssets,
|
||||
isLoading: () => false,
|
||||
getError: () => undefined,
|
||||
hasMore: () => false,
|
||||
hasAssetKey: () => false,
|
||||
hasCategory: () => false,
|
||||
updateModelsForNodeType: async () => {},
|
||||
invalidateCategory: () => {},
|
||||
updateModelsForTag: async () => {},
|
||||
updateAssetMetadata: async () => {},
|
||||
updateAssetTags: async () => {},
|
||||
invalidateModelsForCategory: () => {}
|
||||
getAssets,
|
||||
isLoading,
|
||||
getError,
|
||||
hasMore,
|
||||
hasAssetKey,
|
||||
hasCategory,
|
||||
updateModelsForNodeType,
|
||||
updateModelsForTag,
|
||||
invalidateCategory,
|
||||
updateAssetMetadata,
|
||||
updateAssetTags,
|
||||
invalidateModelsForCategory
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user