Compare commits

...

23 Commits

Author SHA1 Message Date
GitHub Action
5a71450b85 [automated] Apply ESLint and Prettier fixes 2025-12-01 22:10:56 +00:00
coderabbitai[bot]
6031e6ccf2 📝 Add docstrings to drjkl/byom-2
Docstrings generation was requested by @guill.

* https://github.com/Comfy-Org/ComfyUI_frontend/pull/6969#issuecomment-3583609253

The following files were modified:

* `src/components/dialog/confirm/confirmDialog.ts`
* `src/composables/useFeatureFlags.ts`
* `src/platform/assets/services/assetService.ts`
* `src/services/dialogService.ts`
* `src/stores/dialogStore.ts`
2025-12-01 22:08:29 +00:00
DrJKL
4417b0d907 Update flag name 2025-12-01 13:40:51 -08:00
DrJKL
ff988cd6f1 cleanup: More consistent asset name 2025-12-01 13:39:14 -08:00
DrJKL
38cc93b8f6 Grammar is hard 2025-12-01 13:35:24 -08:00
DrJKL
23d8ccd394 UX: Gate the buttons behind a feature flag 2025-12-01 13:31:42 -08:00
DrJKL
23d01f0b34 UX: Handle failed update 2025-12-01 13:24:24 -08:00
DrJKL
64a7955eca UX: Add progress and confirmation to deletion 2025-12-01 13:09:08 -08:00
DrJKL
a5bd37ef56 Properly resolve MaybeRef 2025-12-01 12:00:00 -08:00
Alexander Brown
ca539a6fd0 Update src/platform/assets/components/AssetBadgeGroup.vue
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-01 11:50:44 -08:00
DrJKL
585e47f2df We should remove that reexport and standardize the import 2025-12-01 11:48:17 -08:00
DrJKL
aa13e4c385 i18n: internationalize deletion confirmation 2025-12-01 11:48:17 -08:00
DrJKL
57ed0afde6 nits: Typo and unused ref 2025-12-01 11:48:17 -08:00
DrJKL
3a18d27b9d WIP: Real API calls 2025-12-01 11:48:16 -08:00
DrJKL
f9e8dfdbe7 style: Hide details button for now 2025-12-01 11:48:16 -08:00
DrJKL
ed68b085cc style: Make sure extra properties hug the bottom of the card. 2025-12-01 11:48:15 -08:00
DrJKL
bb9475fed6 Cleanup: Remove dark-theme variants in buttonTypes. Really need to migrate this to CVA and setup the right types. 2025-12-01 11:48:14 -08:00
DrJKL
ba43494d32 WIP: Deleting assets, UI only, also not wired up 2025-12-01 11:48:14 -08:00
DrJKL
df82698f1e type: Add component prop typesafety to dialogs 2025-12-01 11:48:13 -08:00
DrJKL
d4b993b16d style: Fix card padding 2025-12-01 11:48:13 -08:00
DrJKL
04ab767649 WIP: Renaming Assets, UI, not hooked up to backend. 2025-12-01 11:48:12 -08:00
DrJKL
0b7d0f1d35 style: non-functional buttons and menu 2025-12-01 11:48:11 -08:00
DrJKL
0dfe36f1f8 style: Align card styles with Designs 2025-12-01 11:48:08 -08:00
21 changed files with 536 additions and 179 deletions

View File

@@ -1,17 +1,15 @@
<template>
<div :class="iconGroupClasses">
<div
:class="
cn(
'flex justify-center items-center shrink-0 outline-hidden border-none p-0 rounded-lg bg-secondary-background shadow-sm transition-all duration-200 cursor-pointer'
)
"
>
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const iconGroupClasses = cn(
'flex justify-center items-center shrink-0',
'outline-hidden border-none p-0 rounded-lg',
'bg-secondary-background shadow-sm',
'transition-all duration-200',
'cursor-pointer'
)
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="relative inline-flex items-center">
<IconButton :size="size" :type="type" @click="toggle">
<IconButton :size="size" :type="type" @click="popover?.toggle">
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
<i v-else class="icon-[lucide--more-vertical] text-sm" />
</IconButton>
@@ -25,8 +25,18 @@
)
}
}"
@show="$emit('menuOpened')"
@hide="$emit('menuClosed')"
@show="
() => {
isOpen = true
$emit('menuOpened')
}
"
@hide="
() => {
isOpen = false
$emit('menuClosed')
}
"
>
<div class="flex min-w-40 flex-col gap-2 p-2">
<slot :close="hide" />
@@ -48,8 +58,6 @@ interface MoreButtonProps extends BaseButtonProps {
isVertical?: boolean
}
const popover = ref<InstanceType<typeof Popover>>()
const {
size = 'md',
type = 'secondary',
@@ -61,15 +69,15 @@ defineEmits<{
menuClosed: []
}>()
const toggle = (event: Event) => {
popover.value?.toggle(event)
}
const isOpen = ref(false)
const popover = ref<InstanceType<typeof Popover>>()
const hide = () => {
function hide() {
popover.value?.hide()
}
defineExpose({
hide
hide,
isOpen
})
</script>

View File

@@ -18,8 +18,8 @@
...inputAttrs
}
}"
@keyup.enter="blurInputElement"
@keyup.escape="cancelEditing"
@keyup.enter.capture.stop="blurInputElement"
@keyup.escape.stop="cancelEditing"
@click.stop
@pointerdown.stop.capture
@pointermove.stop.capture
@@ -38,7 +38,7 @@ const {
} = defineProps<{
modelValue: string
isEditing?: boolean
inputAttrs?: Record<string, any>
inputAttrs?: Record<string, string>
}>()
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])

View File

@@ -14,6 +14,7 @@
<component
:is="item.headerComponent"
v-if="item.headerComponent"
v-bind="item.headerProps"
:id="item.key"
/>
<h3 v-else :id="item.key">

View File

@@ -0,0 +1,19 @@
<template>
<div
class="flex flex-col px-4 py-2 text-sm text-muted-foreground border-t border-border-default"
>
<p v-if="promptTextReal">
{{ promptTextReal }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
const { promptText } = defineProps<{
promptText?: MaybeRefOrGetter<string>
}>()
const promptTextReal = computed(() => toValue(promptText))
</script>

View File

@@ -0,0 +1,43 @@
<template>
<section class="w-full flex gap-2 justify-end px-2 pb-2">
<TextButton
:label="cancelTextX"
:disabled
type="transparent"
autofocus
@click="$emit('cancel')"
/>
<TextButton
:label="confirmTextX"
:disabled
type="transparent"
:class="confirmClass"
@click="$emit('confirm')"
/>
</section>
</template>
<script setup lang="ts">
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'
import TextButton from '@/components/button/TextButton.vue'
const { t } = useI18n()
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
cancelText?: string
confirmText?: string
confirmClass?: string
optionsDisabled?: MaybeRefOrGetter<boolean>
}>()
defineEmits<{
cancel: []
confirm: []
}>()
const confirmTextX = computed(() => confirmText || t('g.confirm'))
const cancelTextX = computed(() => cancelText || t('g.cancel'))
const disabled = computed(() => toValue(optionsDisabled))
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div
class="flex items-center gap-2 p-4 font-bold text-sm text-base-foreground font-inter"
>
<span v-if="title" class="flex-auto">{{ title }}</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
title?: string
}>()
</script>

View File

@@ -0,0 +1,39 @@
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComponentAttrs } from 'vue-component-type-helpers'
interface ConfirmDialogOptions {
headerProps?: ComponentAttrs<typeof ConfirmHeader>
props?: ComponentAttrs<typeof ConfirmBody>
footerProps?: ComponentAttrs<typeof ConfirmFooter>
}
/**
* Open a confirmation dialog composed of the standard confirm header, body, and footer.
*
* Forwards any provided `headerProps`, `props`, and `footerProps` to the corresponding components.
*
* @param options - Optional configuration with `headerProps`, `props`, and `footerProps` to customize the header, body, and footer components
* @returns A dialog handle representing the shown confirmation dialog
*/
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
const dialogStore = useDialogStore()
const { headerProps, props, footerProps } = options
return dialogStore.showDialog({
headerComponent: ConfirmHeader,
component: ConfirmBody,
footerComponent: ConfirmFooter,
headerProps,
props,
footerProps,
dialogComponentProps: {
pt: {
header: 'py-0! px-0!',
content: 'p-0!',
footer: 'p-0!'
}
}
})
}

View File

@@ -9,11 +9,20 @@ export enum ServerFeatureFlag {
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
MAX_UPLOAD_SIZE = 'max_upload_size',
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled'
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled'
}
/**
* Composable for reactive access to server-side feature flags
* Provides reactive accessors for server-side feature flags.
*
* Exposes a readonly `flags` object containing convenience getters for known server feature keys
* and a `featureFlag` helper that returns a computed value for an arbitrary feature path,
* optionally using a supplied default when the feature is not present.
*
* @returns An object with:
* - `flags`: a readonly reactive object with predefined getters for common server feature flags
* - `featureFlag`: a generic function `(featurePath: string, defaultValue?) => ComputedRef<T>` that yields a computed feature value
*/
export function useFeatureFlags() {
const flags = reactive({
@@ -31,6 +40,12 @@ export function useFeatureFlags() {
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
false
)
},
get assetUpdateOptionsEnabled() {
return api.getServerFeature(
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
false
)
}
})

View File

@@ -163,6 +163,7 @@ export const i18n = createI18n({
legacy: false,
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
escapeParameter: true,
messages,
// Ignore warnings for locale options as each option is in its own language.
// e.g. "English", "中文", "Русский", "日本語", "한국어", "Français", "Español"

View File

@@ -1,6 +1,5 @@
{
"g": {
"beta": "Beta",
"user": "User",
"currentUser": "Current user",
"empty": "Empty",
@@ -125,6 +124,7 @@
"searchKeybindings": "Search Keybindings",
"searchExtensions": "Search Extensions",
"search": "Search",
"searchPlaceholder": "Search...",
"noResultsFound": "No Results Found",
"searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.",
"noTasksFound": "No Tasks Found",
@@ -2093,7 +2093,6 @@
"connectionError": "Please check your connection and try again",
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Type to search...",
"uploadModel": "Import model",
"uploadModelFromCivitai": "Import a model from Civitai",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
@@ -2152,6 +2151,13 @@
"media": {
"threeDModelPlaceholder": "3D Model",
"audioPlaceholder": "Audio"
},
"deletion": {
"header": "Delete this model?",
"body": "This model will be permanently removed from your library.",
"inProgress": "Deleting {assetName}...",
"complete": "{assetName} has been deleted.",
"failed": "{assetName} could not be deleted."
}
},
"mediaAsset": {

View File

@@ -1,5 +1,5 @@
<template>
<div class="absolute right-2 bottom-2 flex flex-wrap justify-end gap-1">
<div class="absolute left-2 bottom-2 flex flex-wrap justify-start gap-1">
<span
v-for="badge in badges"
:key="badge.label"

View File

@@ -26,7 +26,7 @@
v-model="searchQuery"
:autofocus="true"
size="lg"
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')"
:placeholder="$t('g.searchPlaceholder')"
class="max-w-96"
/>
<IconTextButton

View File

@@ -1,53 +1,106 @@
<template>
<component
:is="interactive ? 'button' : 'div'"
<div
v-if="!deletedLocal"
data-component-id="AssetCard"
:data-asset-id="asset.id"
v-bind="elementProps"
:class="cardClasses"
@click="interactive && $emit('select', asset)"
@keydown.enter="interactive && $emit('select', asset)"
:aria-labelledby="titleId"
:aria-describedby="descId"
:tabindex="interactive ? 0 : -1"
:class="
cn(
'rounded-2xl overflow-hidden transition-all duration-200 bg-modal-card-background p-2 gap-2 flex flex-col h-full',
interactive &&
'group appearance-none bg-transparent m-0 outline-none text-left hover:bg-secondary-background focus:bg-secondary-background border-none focus:outline-solid outline-base-foreground outline-4'
)
"
@keydown.enter.self="interactive && $emit('select', asset)"
>
<div class="relative aspect-square w-full overflow-hidden rounded-xl">
<img
v-if="shouldShowImage"
:src="asset.preview_url"
class="h-full w-full object-contain"
/>
<div
v-if="isLoading || error"
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<img
v-else
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-smoke-400 via-smoke-800 to-charcoal-400"
></div>
:src="asset.preview_url"
:alt="displayName"
class="size-full object-contain cursor-pointer"
role="button"
@click.self="interactive && $emit('select', asset)"
/>
<AssetBadgeGroup :badges="asset.badges" />
<IconGroup
v-if="flags.assetUpdateOptionsEnabled"
:class="
cn(
'absolute top-2 right-2 invisible group-hover:visible',
dropdownMenuButton?.isOpen && 'visible'
)
"
>
<IconButton v-if="false" size="sm">
<i class="icon-[lucide--file-text]" />
</IconButton>
<MoreButton ref="dropdown-menu-button" size="sm">
<template #default>
<IconTextButton
:label="$t('g.rename')"
type="secondary"
size="md"
@click="startAssetRename"
>
<template #icon>
<i class="icon-[lucide--pencil]" />
</template>
</IconTextButton>
<IconTextButton
:label="$t('g.delete')"
type="secondary"
size="md"
@click="confirmDeletion"
>
<template #icon>
<i class="icon-[lucide--trash-2]" />
</template>
</IconTextButton>
</template>
</MoreButton>
</IconGroup>
</div>
<div :class="cn('p-4 h-32 flex flex-col justify-between')">
<div>
<h3
:id="titleId"
v-tooltip.top="{ value: asset.name, showDelay: tooltipDelay }"
:class="
cn(
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
'text-base-foreground'
)
"
>
{{ asset.name }}
</h3>
<p
:id="descId"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
'text-muted-foreground'
)
"
>
{{ asset.description }}
</p>
</div>
<div :class="cn('flex gap-4 text-xs text-muted-foreground')">
<div class="max-h-32 flex flex-col gap-2 justify-between flex-auto">
<h3
:id="titleId"
v-tooltip.top="{ value: displayName, showDelay: tooltipDelay }"
:class="
cn(
'mb-2 m-0 text-base font-semibold line-clamp-2 wrap-anywhere',
'text-base-foreground'
)
"
>
<EditableText
:model-value="displayName"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'asset-name-input' }"
@edit="assetRename"
@cancel="assetRename()"
/>
</h3>
<p
:id="descId"
v-tooltip.top="{ value: asset.description, showDelay: tooltipDelay }"
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box] text-muted-foreground'
)
"
>
{{ asset.description }}
</p>
<div class="flex gap-4 text-xs text-muted-foreground mt-auto">
<span v-if="asset.stats.stars" class="flex items-center gap-1">
<i class="icon-[lucide--star] size-3" />
{{ asset.stats.stars }}
@@ -62,73 +115,133 @@
</span>
</div>
</div>
</component>
</div>
</template>
<script setup lang="ts">
import { useImage } from '@vueuse/core'
import { computed, useId } from 'vue'
import { computed, ref, toValue, useId, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import IconGroup from '@/components/button/IconGroup.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import EditableText from '@/components/common/EditableText.vue'
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
const { asset, interactive } = defineProps<{
asset: AssetDisplayItem
interactive?: boolean
}>()
defineEmits<{
select: [asset: AssetDisplayItem]
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const { closeDialog } = useDialogStore()
const { flags } = useFeatureFlags()
const dropdownMenuButton = useTemplateRef<InstanceType<typeof MoreButton>>(
'dropdown-menu-button'
)
const titleId = useId()
const descId = useId()
const isEditing = ref(false)
const newNameRef = ref<string>()
const deletedLocal = ref(false)
const displayName = computed(() => newNameRef.value ?? asset.name)
const tooltipDelay = computed<number>(() =>
settingStore.get('LiteGraph.Node.TooltipDelay')
)
const { error } = useImage({
src: props.asset.preview_url ?? '',
alt: props.asset.name
const { isLoading, error } = useImage({
src: asset.preview_url ?? '',
alt: asset.name
})
const shouldShowImage = computed(() => props.asset.preview_url && !error.value)
function confirmDeletion() {
dropdownMenuButton.value?.hide()
const assetName = toValue(displayName)
const promptText = ref<string>(t('assetBrowser.deletion.body'))
const optionsDisabled = ref(false)
const confirmDialog = showConfirmDialog({
headerProps: {
title: t('assetBrowser.deletion.header')
},
props: {
promptText
},
footerProps: {
confirmText: t('g.delete'),
// TODO: These need to be put into the new Button Variants once we have them.
confirmClass: cn(
'bg-danger-200 text-base-foreground hover:bg-danger-200/80 focus:bg-danger-200/80 focus:ring ring-base-foreground'
),
optionsDisabled,
onCancel: () => {
closeDialog(confirmDialog)
},
onConfirm: async () => {
try {
promptText.value = t('assetBrowser.deletion.inProgress', {
assetName
})
await assetService.deleteAsset(asset.id)
promptText.value = t('assetBrowser.deletion.complete', {
assetName
})
// Give a second for the completion message
await new Promise((resolve) => setTimeout(resolve, 1_000))
deletedLocal.value = true
} catch (err: unknown) {
console.error(err)
promptText.value = t('assetBrowser.deletion.failed', {
assetName
})
// Give a second for the completion message
await new Promise((resolve) => setTimeout(resolve, 3_000))
} finally {
closeDialog(confirmDialog)
}
}
}
})
}
const cardClasses = computed(() => {
const base = cn(
'rounded-xl overflow-hidden transition-all duration-200 bg-modal-card-background'
)
function startAssetRename() {
dropdownMenuButton.value?.hide()
isEditing.value = true
}
if (!props.interactive) {
return base
async function assetRename(newName?: string) {
isEditing.value = false
if (newName) {
// Optimistic update
newNameRef.value = newName
try {
const result = await assetService.updateAsset(asset.id, {
name: newName
})
// Update with the actual name once the server responds
newNameRef.value = result.name
} catch (err: unknown) {
console.error(err)
newNameRef.value = undefined
}
}
return cn(
base,
'group',
'appearance-none bg-transparent p-0 m-0',
'font-inherit text-inherit outline-none cursor-pointer text-left',
'hover:bg-secondary-background',
'border-none',
'focus:outline-solid outline-azure-600 outline-4'
)
})
const elementProps = computed(() =>
props.interactive
? {
type: 'button',
'aria-labelledby': titleId,
'aria-describedby': descId
}
: {
'aria-labelledby': titleId,
'aria-describedby': descId
}
)
defineEmits<{
select: [asset: AssetDisplayItem]
}>()
}
</script>

View File

@@ -1,7 +1,9 @@
<template>
<div
data-component-id="AssetGrid"
:style="gridStyle"
:class="
cn('grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-4 p-2')
"
role="grid"
:aria-label="$t('assetBrowser.assetCollection')"
:aria-rowcount="-1"
@@ -34,7 +36,6 @@
:key="asset.id"
:asset="asset"
:interactive="true"
role="gridcell"
@select="$emit('assetSelect', $event)"
/>
</template>
@@ -42,11 +43,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { createGridStyle } from '@/utils/gridUtil'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
assets: AssetDisplayItem[]
@@ -56,7 +55,4 @@ defineProps<{
defineEmits<{
assetSelect: [asset: AssetDisplayItem]
}>()
// Use same grid style as BaseModalLayout
const gridStyle = computed(() => createGridStyle())
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t-[1px] border-border-default"
class="upload-model-dialog flex flex-col justify-between gap-6 p-4 pt-6 border-t border-border-default"
>
<!-- Step 1: Enter URL -->
<UploadModelUrlInput

View File

@@ -1,7 +1,10 @@
import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
import { assetResponseSchema } from '@/platform/assets/schemas/assetSchema'
import {
assetItemSchema,
assetResponseSchema
} from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata,
@@ -262,12 +265,10 @@ function createAssetService() {
}
/**
* Deletes an asset by ID
* Only available in cloud environment
* Delete the asset identified by `id` (cloud environments only).
*
* @param id - The asset ID (UUID)
* @returns Promise<void>
* @throws Error if deletion fails
* @throws Error if the server responds with a non-ok status; message includes the HTTP status
*/
async function deleteAsset(id: string): Promise<void> {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
@@ -281,6 +282,44 @@ function createAssetService() {
}
}
/**
* Update an asset's metadata by its ID.
*
* Only available in cloud environment.
*
* @param id - The asset ID (UUID)
* @param newData - Partial metadata fields to apply to the asset
* @returns The updated AssetItem
* @throws Error if the server responds with a non-OK status or the response cannot be validated as an AssetItem
*/
async function updateAsset(
id: string,
newData: Partial<AssetMetadata>
): Promise<AssetItem> {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newData)
})
if (!res.ok) {
throw new Error(
`Unable to update asset ${id}: Server returned ${res.status}`
)
}
const newAsset = assetItemSchema.safeParse(await res.json())
if (newAsset.success) {
return newAsset.data
}
throw new Error(
`Unable to update asset ${id}: Invalid response - ${newAsset.error}`
)
}
/**
* Retrieves metadata from a download URL without downloading the file
*
@@ -360,6 +399,7 @@ function createAssetService() {
getAssetDetails,
getAssetsByTag,
deleteAsset,
updateAsset,
getAssetMetadata,
uploadAssetFromUrl
}

View File

@@ -34,7 +34,7 @@ import NodeConflictDialogContent from '@/workbench/extensions/manager/components
import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue'
import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import type { ComponentProps } from 'vue-component-type-helpers'
import type { ComponentAttrs } from 'vue-component-type-helpers'
export type ConfirmationDialogType =
| 'default'
@@ -47,8 +47,13 @@ export type ConfirmationDialogType =
export const useDialogService = () => {
const dialogStore = useDialogStore()
/**
* Open the global missing-nodes dialog and forward the provided props to its content component.
*
* @param props - Props passed through to the MissingNodesContent component
*/
function showLoadWorkflowWarning(
props: ComponentProps<typeof MissingNodesContent>
props: ComponentAttrs<typeof MissingNodesContent>
) {
dialogStore.showDialog({
key: 'global-missing-nodes',
@@ -73,8 +78,13 @@ export const useDialogService = () => {
})
}
/**
* Show the global missing-models warning dialog.
*
* @param props - Props forwarded to the MissingModelsWarning component
*/
function showMissingModelsWarning(
props: InstanceType<typeof MissingModelsWarning>['$props']
props: ComponentAttrs<typeof MissingModelsWarning>
) {
dialogStore.showDialog({
key: 'global-missing-models-warning',
@@ -103,6 +113,11 @@ export const useDialogService = () => {
})
}
/**
* Opens the global settings dialog with the About panel selected.
*
* Displays the settings dialog and sets its default inner panel to "about".
*/
function showAboutDialog() {
dialogStore.showDialog({
key: 'global-settings',
@@ -114,8 +129,15 @@ export const useDialogService = () => {
})
}
/**
* Shows the global execution error dialog populated from a websocket execution error message.
*
* Displays a dialog containing the error details from `executionError` and records a telemetry event when the dialog is closed.
*
* @param executionError - Websocket execution error message containing `exception_type`, `exception_message`, `node_id`, `node_type`, and `traceback`
*/
function showExecutionErrorDialog(executionError: ExecutionErrorWsMessage) {
const props: InstanceType<typeof ErrorDialogContent>['$props'] = {
const props: ComponentAttrs<typeof ErrorDialogContent> = {
error: {
exceptionType: executionError.exception_type,
exceptionMessage: executionError.exception_message,
@@ -140,8 +162,13 @@ export const useDialogService = () => {
})
}
/**
* Opens the global manager dialog using the default manager layout and styling and forwards props to the dialog content.
*
* @param props - Props to pass through to ManagerDialogContent (defaults to an empty object)
*/
function showManagerDialog(
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
props: ComponentAttrs<typeof ManagerDialogContent> = {}
) {
dialogStore.showDialog({
key: 'global-manager',
@@ -184,9 +211,12 @@ export const useDialogService = () => {
}
/**
* Show a error dialog to the user when an error occurs.
* @param error The error to show
* @param options The options for the dialog
* Displays a global error dialog for the given error and tracks the dialog close event for telemetry.
*
* @param error - An Error or any value to display; if an Error is provided it will be parsed for message, stack trace, and extension file.
* @param options - Optional configuration for the dialog
* @param options.title - Title used as the exception type shown in the dialog
* @param options.reportType - Optional report type forwarded to the dialog for reporting purposes
*/
function showErrorDialog(
error: unknown,
@@ -206,7 +236,7 @@ export const useDialogService = () => {
errorMessage: String(error)
}
const props: InstanceType<typeof ErrorDialogContent>['$props'] = {
const props: ComponentAttrs<typeof ErrorDialogContent> = {
error: {
exceptionType: options.title ?? 'Unknown Error',
exceptionMessage: errorProps.errorMessage,
@@ -412,15 +442,10 @@ export const useDialogService = () => {
}
/**
* Shows a dialog from a third party extension.
* @param options - The dialog options.
* @param options.key - The dialog key.
* @param options.title - The dialog title.
* @param options.headerComponent - The dialog header component.
* @param options.footerComponent - The dialog footer component.
* @param options.component - The dialog component.
* @param options.props - The dialog props.
* @returns The dialog instance and a function to close the dialog.
* Show a dialog provided by a third-party extension.
*
* @param options - Dialog configuration including `key`, optional `title`, header/footer components, dialog `component`, and `props` passed to the component.
* @returns An object with `dialog`, the dialog instance returned by the dialog store, and `closeDialog`, a function that closes the dialog using the provided `key`.
*/
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
return {
@@ -429,8 +454,15 @@ export const useDialogService = () => {
}
}
/**
* Toggles the global manager dialog's visibility.
*
* If the global manager dialog is open, it will be closed; otherwise it will be shown.
*
* @param props - Optional props to pass to the ManagerDialogContent when opening the dialog
*/
function toggleManagerDialog(
props?: InstanceType<typeof ManagerDialogContent>['$props']
props?: ComponentAttrs<typeof ManagerDialogContent>
) {
if (dialogStore.isDialogOpen('global-manager')) {
dialogStore.closeDialog({ key: 'global-manager' })
@@ -439,8 +471,13 @@ export const useDialogService = () => {
}
}
/**
* Toggles the global manager progress dialog: closes it if open, otherwise opens it.
*
* @param props - Optional props to pass to the ManagerProgressDialogContent when opening the dialog
*/
function toggleManagerProgressDialog(
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
props?: ComponentAttrs<typeof ManagerProgressDialogContent>
) {
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })

View File

@@ -7,6 +7,7 @@ import { markRaw, ref } from 'vue'
import type { Component } from 'vue'
import type GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import type { ComponentAttrs } from 'vue-component-type-helpers'
type DialogPosition =
| 'center'
@@ -33,30 +34,40 @@ interface CustomDialogComponentProps {
headless?: boolean
}
export type DialogComponentProps = InstanceType<typeof GlobalDialog>['$props'] &
export type DialogComponentProps = ComponentAttrs<typeof GlobalDialog> &
CustomDialogComponentProps
interface DialogInstance {
interface DialogInstance<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
key: string
visible: boolean
title?: string
headerComponent?: Component
component: Component
contentProps: Record<string, any>
footerComponent?: Component
footerProps?: Record<string, any>
headerComponent?: H
headerProps?: ComponentAttrs<H>
component: B
contentProps: ComponentAttrs<B>
footerComponent?: F
footerProps?: ComponentAttrs<F>
dialogComponentProps: DialogComponentProps
priority: number
}
export interface ShowDialogOptions {
export interface ShowDialogOptions<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
> {
key?: string
title?: string
headerComponent?: Component
footerComponent?: Component
component: Component
props?: Record<string, any>
footerProps?: Record<string, any>
headerComponent?: H
footerComponent?: F
component: B
props?: ComponentAttrs<B>
headerProps?: ComponentAttrs<H>
footerProps?: ComponentAttrs<F>
dialogComponentProps?: DialogComponentProps
/**
* Optional priority for dialog stacking.
@@ -105,6 +116,13 @@ export const useDialogStore = defineStore('dialog', () => {
}
}
/**
* Closes the dialog identified by the given key or the currently active dialog when no key is provided.
*
* Invokes the dialog's `onClose` callback if present, removes the dialog from the stack, updates the active dialog key, and adjusts close-on-Escape handling. If no matching dialog is found this function is a no-op.
*
* @param options - Optional object with a `key` specifying which dialog to close; when omitted the active dialog is closed.
*/
function closeDialog(options?: { key: string }) {
const targetDialog = options
? dialogStack.value.find((d) => d.key === options.key)
@@ -123,17 +141,19 @@ export const useDialogStore = defineStore('dialog', () => {
updateCloseOnEscapeStates()
}
function createDialog(options: {
key: string
title?: string
headerComponent?: Component
footerComponent?: Component
component: Component
props?: Record<string, any>
footerProps?: Record<string, any>
dialogComponentProps?: DialogComponentProps
priority?: number
}) {
/**
* Create and register a dialog instance from the given options and push it into the dialog stack.
*
* @param options - Configuration for the dialog. Must include a unique `key`. Other fields configure the component to render (`component`), optional `title`, optional `headerComponent`/`footerComponent` and their props, additional `props` for the content component, `dialogComponentProps` for dialog behavior, and an optional numeric `priority`.
* @returns The created dialog instance that was inserted into the store's stack.
*
* Side effects: enforces a maximum stack size of 10 by removing the oldest dialog when necessary, inserts the new dialog according to its priority, sets the dialog as the active one, and updates close-on-escape handling for the stack.
*/
function createDialog<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
>(options: ShowDialogOptions<H, B, F> & { key: string }) {
if (dialogStack.value.length >= 10) {
dialogStack.value.shift()
}
@@ -149,6 +169,7 @@ export const useDialogStore = defineStore('dialog', () => {
? markRaw(options.footerComponent)
: undefined,
component: markRaw(options.component),
headerProps: { ...options.headerProps },
contentProps: { ...options.props },
footerProps: { ...options.footerProps },
priority: options.priority ?? 1,
@@ -203,7 +224,17 @@ export const useDialogStore = defineStore('dialog', () => {
})
}
function showDialog(options: ShowDialogOptions) {
/**
* Opens the dialog described by `options` and ensures it is the active (top-most) dialog, creating a new dialog if one with the same key does not exist.
*
* @param options - Configuration for the dialog to show; may include a `key` to target an existing dialog or omit it to generate a new key
* @returns The dialog instance that was shown or created
*/
function showDialog<
H extends Component = Component,
B extends Component = Component,
F extends Component = Component
>(options: ShowDialogOptions<H, B, F>) {
const dialogKey = options.key || genDialogKey()
let dialog = dialogStack.value.find((d) => d.key === dialogKey)

View File

@@ -25,8 +25,7 @@ export const getButtonSizeClasses = (size: ButtonSize = 'md') => {
export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
const baseByType = {
primary:
'bg-neutral-900 border-none text-white dark-theme:bg-white dark-theme:text-neutral-900',
primary: 'bg-base-foreground border-none text-base-background',
secondary: cn(
'bg-secondary-background border-none text-base-foreground hover:bg-secondary-background-hover'
),
@@ -42,10 +41,8 @@ export const getButtonTypeClasses = (type: ButtonType = 'primary') => {
export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
const baseByType = {
primary:
'bg-neutral-900 text-white dark-theme:bg-white dark-theme:text-neutral-900',
secondary:
'bg-white text-neutral-950 dark-theme:bg-zinc-700 dark-theme:text-white',
primary: 'bg-base-background text-base-foreground',
secondary: 'bg-secondary-background text-base-foreground',
transparent: cn(
'bg-transparent text-base-foreground hover:bg-secondary-background-hover'
),
@@ -54,10 +51,9 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
} as const
const borderByType = {
primary: 'border border-solid border-white dark-theme:border-neutral-900',
secondary: 'border border-solid border-neutral-950 dark-theme:border-white',
transparent:
'border border-solid border-neutral-950 dark-theme:border-white',
primary: 'border border-solid border-base-background',
secondary: 'border border-solid border-base-foreground',
transparent: 'border border-solid border-base-foreground',
accent: 'border border-solid border-primary-background'
} as const

View File

@@ -14,6 +14,8 @@ interface GridOptions {
}
/**
* @deprecated Just use tailwind utilities directly.
* TODO: Create a common grid layout component if needed.
* Creates CSS grid styles for responsive grid layouts
* @param options Grid configuration options
* @returns CSS properties object for grid styling