Updates: Model Management (#8248)

## Summary

Model management improvements: refactored tag API, enhanced UX for
trigger phrases and editing.

## Changes

### API Refactor
- Add `addAssetTags` (POST) and `removeAssetTags` (DELETE) endpoints in
assetService
- `updateAssetTags` now computes diff and calls remove/add serially
- Add `TagsOperationResult` schema; cache syncs with server response

### UX Improvements
- **Trigger phrases**: click to copy individual phrases, copy-all button
in header
- **Display name**: show edit icon on hover instead of relying on
double-click
- **Model type**: show plain text when immutable instead of disabled
select
- **Tags input**: only show edit icon on hover
- **Field labels**: updated styling to use muted foreground color
- **Optimistic update** for model type selection

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-01-22 16:10:54 -08:00
committed by GitHub
parent 524c7e9b95
commit 4b1a30e330
9 changed files with 210 additions and 36 deletions

View File

@@ -26,7 +26,8 @@ export const buttonVariants = cva({
md: 'h-8 rounded-lg p-2 text-xs',
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
icon: 'size-8',
'icon-sm': 'size-5 p-0'
'icon-sm': 'size-5 p-0',
unset: ''
}
},

View File

@@ -71,7 +71,7 @@ onClickOutside(rootEl, () => {
<i
v-if="!disabled && !isEditing"
aria-hidden="true"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground"
class="icon-[lucide--square-pen] absolute bottom-2 right-2 size-4 text-muted-foreground transition-opacity opacity-0 group-hover:opacity-100"
/>
</TagsInputRoot>
</template>

View File

@@ -88,6 +88,7 @@
"reportIssueTooltip": "Submit the error report to Comfy Org",
"reportSent": "Report Submitted",
"copyToClipboard": "Copy to Clipboard",
"copyAll": "Copy All",
"openNewIssue": "Open New Issue",
"showReport": "Show Report",
"imageFailedToLoad": "Image failed to load",
@@ -2480,6 +2481,7 @@
"selectModelPrompt": "Select a model to see its information",
"basicInfo": "Basic Info",
"displayName": "Display Name",
"editDisplayName": "Edit display name",
"fileName": "File Name",
"source": "Source",
"viewOnSource": "View on {source}",

View File

@@ -1,6 +1,9 @@
<template>
<div class="flex flex-col gap-1 px-4 py-2 text-sm text-muted-foreground">
<span>{{ label }}</span>
<div class="flex flex-col gap-2 px-4 py-2 text-sm text-base-foreground">
<div class="flex items-center justify-between relative">
<span>{{ label }}</span>
<slot name="label-action" />
</div>
<slot />
</div>
</template>

View File

@@ -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',

View File

@@ -10,17 +10,29 @@
</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"
/>
<div class="group flex justify-between">
<EditableText
:model-value="displayName"
:is-editing="isEditingDisplayName"
:class="cn('break-all text-muted-foreground flex-auto')"
@dblclick="isEditingDisplayName = !isImmutable"
@edit="handleDisplayNameEdit"
@cancel="isEditingDisplayName = false"
/>
<Button
v-if="!isImmutable && !isEditingDisplayName"
size="icon-sm"
variant="muted-textonly"
class="transition-opacity opacity-0 group-hover:opacity-100"
:aria-label="t('assetBrowser.modelInfo.editDisplayName')"
@click="isEditingDisplayName = !isImmutable"
>
<i class="icon-[lucide--square-pen] self-center size-4" />
</Button>
</div>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.fileName')">
<span class="break-all">{{ asset.name }}</span>
<span class="break-all text-muted-foreground">{{ asset.name }}</span>
</ModelInfoField>
<ModelInfoField
v-if="sourceUrl"
@@ -51,7 +63,7 @@
</span>
</template>
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
<Select v-model="selectedModelType" :disabled="isImmutable">
<Select v-if="!isImmutable" v-model="selectedModelType">
<SelectTrigger class="w-full">
<SelectValue
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
@@ -67,6 +79,12 @@
</SelectItem>
</SelectContent>
</Select>
<div v-else class="p-2 text-sm text-muted-foreground">
{{
modelTypes.find((o) => o.value === selectedModelType)?.name ??
t('assetBrowser.unknown')
}}
</div>
</ModelInfoField>
<ModelInfoField :label="t('assetBrowser.modelInfo.compatibleBaseModels')">
<TagsInput
@@ -124,14 +142,31 @@
v-if="triggerPhrases.length > 0"
:label="t('assetBrowser.modelInfo.triggerPhrases')"
>
<div class="flex flex-wrap gap-1">
<span
<template #label-action>
<Button
variant="muted-textonly"
size="icon-sm"
:title="t('g.copyAll')"
:aria-label="t('g.copyAll')"
class="p-0"
@click="copyToClipboard(triggerPhrases.join(', '))"
>
<i class="icon-[lucide--copy] size-4 min-w-4 min-h-4 opacity-60" />
</Button>
</template>
<div class="flex flex-wrap gap-1 pt-1">
<Button
v-for="phrase in triggerPhrases"
:key="phrase"
class="rounded px-2 py-0.5 text-xs"
variant="muted-textonly"
size="unset"
:title="t('g.copyToClipboard')"
class="text-pretty whitespace-normal text-left text-xs"
@click="copyToClipboard(phrase)"
>
{{ phrase }}
</span>
<i class="icon-[lucide--copy] size-4 min-w-4 min-h-4 opacity-60" />
</Button>
</div>
</ModelInfoField>
<ModelInfoField
@@ -170,7 +205,9 @@ import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import Button from '@/components/ui/button/Button.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'
@@ -201,6 +238,7 @@ import { cn } from '@/utils/tailwindUtil'
import ModelInfoField from './ModelInfoField.vue'
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const descriptionTextarea = useTemplateRef<HTMLTextAreaElement>(
'descriptionTextarea'
@@ -219,6 +257,7 @@ const assetsStore = useAssetsStore()
const { modelTypes } = useModelTypes()
const pendingUpdates = ref<AssetUserMetadata>({})
const pendingModelType = ref<string | undefined>(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)
}
})
</script>

View File

@@ -106,6 +106,16 @@ const zAssetUserMetadata = z.object({
export type AssetUserMetadata = z.infer<typeof zAssetUserMetadata>
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<typeof tagsOperationResultSchema>
// Legacy interface for backward compatibility (now aligned with Zod schema)
export interface ModelFolderInfo {
name: string

View File

@@ -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<TagsOperationResult>
*/
async function addAssetTags(
id: string,
tags: string[]
): Promise<TagsOperationResult> {
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<TagsOperationResult>
*/
async function removeAssetTags(
id: string,
tags: string[]
): Promise<TagsOperationResult> {
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,

View File

@@ -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<string, unknown>,
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 {