mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 00:04:06 +00:00
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:
@@ -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: ''
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user