-
{{ label }}
+
diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts b/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts
index 89300617fff..981c1a01ef1 100644
--- a/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts
+++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.test.ts
@@ -1,12 +1,18 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import ModelInfoPanel from './ModelInfoPanel.vue'
+vi.mock('@/composables/useCopyToClipboard', () => ({
+ useCopyToClipboard: () => ({
+ copyToClipboard: vi.fn()
+ })
+}))
+
const i18n = createI18n({
legacy: false,
locale: 'en',
diff --git a/src/platform/assets/components/modelInfo/ModelInfoPanel.vue b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
index c69cb642995..834f33afaba 100644
--- a/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
+++ b/src/platform/assets/components/modelInfo/ModelInfoPanel.vue
@@ -10,17 +10,29 @@
-
+
+
+
+
- {{ asset.name }}
+ {{ asset.name }}
-
-
-
+
+
+
+
(
'descriptionTextarea'
@@ -219,6 +257,7 @@ const assetsStore = useAssetsStore()
const { modelTypes } = useModelTypes()
const pendingUpdates = ref({})
+const pendingModelType = ref(undefined)
const isEditingDisplayName = ref(false)
const isImmutable = computed(() => asset.is_immutable ?? true)
@@ -239,10 +278,17 @@ watch(
}
)
+watch(
+ () => asset.tags,
+ () => {
+ pendingModelType.value = undefined
+ }
+)
+
const debouncedFlushMetadata = useDebounceFn(() => {
if (isImmutable.value) return
assetsStore.updateAssetMetadata(
- asset.id,
+ asset,
{ ...(asset.user_metadata ?? {}), ...pendingUpdates.value },
cacheKey
)
@@ -267,7 +313,7 @@ const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
const newTags = asset.tags
.filter((tag) => tag !== currentModelType)
.concat(newModelType)
- assetsStore.updateAssetTags(asset.id, newTags, cacheKey)
+ assetsStore.updateAssetTags(asset, newTags, cacheKey)
}, 500)
const baseModels = computed({
@@ -288,9 +334,11 @@ const userDescription = computed({
})
const selectedModelType = computed({
- get: () => getAssetModelType(asset) ?? undefined,
+ get: () => pendingModelType.value ?? getAssetModelType(asset) ?? undefined,
set: (value: string | undefined) => {
- if (value) debouncedSaveModelType(value)
+ if (!value) return
+ pendingModelType.value = value
+ debouncedSaveModelType(value)
}
})
diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts
index f66d816d4f2..d6941e18cfa 100644
--- a/src/platform/assets/schemas/assetSchema.ts
+++ b/src/platform/assets/schemas/assetSchema.ts
@@ -106,6 +106,16 @@ const zAssetUserMetadata = z.object({
export type AssetUserMetadata = z.infer
+export const tagsOperationResultSchema = z.object({
+ total_tags: z.array(z.string()),
+ added: z.array(z.string()).optional(),
+ removed: z.array(z.string()).optional(),
+ already_present: z.array(z.string()).optional(),
+ not_present: z.array(z.string()).optional()
+})
+
+export type TagsOperationResult = z.infer
+
// Legacy interface for backward compatibility (now aligned with Zod schema)
export interface ModelFolderInfo {
name: string
diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts
index 61b6461ad45..c3b150911a6 100644
--- a/src/platform/assets/services/assetService.ts
+++ b/src/platform/assets/services/assetService.ts
@@ -5,7 +5,8 @@ import { st } from '@/i18n'
import {
assetItemSchema,
assetResponseSchema,
- asyncUploadResponseSchema
+ asyncUploadResponseSchema,
+ tagsOperationResultSchema
} from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
@@ -14,7 +15,8 @@ import type {
AssetUpdatePayload,
AsyncUploadResponse,
ModelFile,
- ModelFolder
+ ModelFolder,
+ TagsOperationResult
} from '@/platform/assets/schemas/assetSchema'
import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
@@ -471,6 +473,66 @@ function createAssetService() {
return await res.json()
}
+ /**
+ * Add tags to an asset
+ * @param id - The asset ID (UUID)
+ * @param tags - Tags to add
+ * @returns Promise
+ */
+ async function addAssetTags(
+ id: string,
+ tags: string[]
+ ): Promise {
+ const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}/tags`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tags })
+ })
+
+ if (!res.ok) {
+ throw new Error(
+ `Unable to add tags to asset ${id}: Server returned ${res.status}`
+ )
+ }
+
+ const result = await res.json()
+ const parseResult = tagsOperationResultSchema.safeParse(result)
+ if (!parseResult.success) {
+ throw fromZodError(parseResult.error)
+ }
+ return parseResult.data
+ }
+
+ /**
+ * Remove tags from an asset
+ * @param id - The asset ID (UUID)
+ * @param tags - Tags to remove
+ * @returns Promise
+ */
+ async function removeAssetTags(
+ id: string,
+ tags: string[]
+ ): Promise {
+ const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}/tags`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ tags })
+ })
+
+ if (!res.ok) {
+ throw new Error(
+ `Unable to remove tags from asset ${id}: Server returned ${res.status}`
+ )
+ }
+
+ const result = await res.json()
+ const parseResult = tagsOperationResultSchema.safeParse(result)
+ if (!parseResult.success) {
+ throw fromZodError(parseResult.error)
+ }
+ return parseResult.data
+ }
+
/**
* Uploads an asset asynchronously using the /api/assets/download endpoint
* Returns immediately with either the asset (if already exists) or a task to track
@@ -546,6 +608,8 @@ function createAssetService() {
getAssetsByTag,
deleteAsset,
updateAsset,
+ addAssetTags,
+ removeAssetTags,
getAssetMetadata,
uploadAssetFromUrl,
uploadAssetFromBase64,
diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts
index 21d19838f26..0293f9b3200 100644
--- a/src/stores/assetsStore.ts
+++ b/src/stores/assetsStore.ts
@@ -1,4 +1,5 @@
import { useAsyncState, whenever } from '@vueuse/core'
+import { difference } from 'es-toolkit'
import { defineStore } from 'pinia'
import { computed, reactive, ref, shallowReactive } from 'vue'
import {
@@ -455,32 +456,71 @@ export const useAssetsStore = defineStore('assets', () => {
/**
* Update asset metadata with optimistic cache update
- * @param assetId The asset ID to 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(
- assetId: string,
+ asset: AssetItem,
userMetadata: Record,
cacheKey?: string
) {
- updateAssetInCache(assetId, { user_metadata: userMetadata }, cacheKey)
- await assetService.updateAsset(assetId, { user_metadata: userMetadata })
+ 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 with optimistic cache update
- * @param assetId The asset ID to update
- * @param tags The tags array to save
+ * 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(
- assetId: string,
- tags: string[],
+ asset: AssetItem,
+ newTags: string[],
cacheKey?: string
) {
- updateAssetInCache(assetId, { tags }, cacheKey)
- await assetService.updateAsset(assetId, { tags })
+ 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)
+
+ try {
+ const removeResult =
+ tagsToRemove.length > 0
+ ? await assetService.removeAssetTags(asset.id, tagsToRemove)
+ : undefined
+
+ 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)
+ }
}
return {